diff --git a/package.json b/package.json index 41051fe..b944d86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cordova-plugin-hostedwebapp", - "version": "0.1.5", + "version": "0.2.0", "description": "Hosted Web App Plugin", "cordova": { "id": "cordova-plugin-hostedwebapp", diff --git a/plugin.xml b/plugin.xml index 39b0db8..39663ce 100644 --- a/plugin.xml +++ b/plugin.xml @@ -1,7 +1,7 @@ + version="0.2.0"> HostedWebApp Hosted Web App Plugin MIT License @@ -20,13 +20,15 @@ + + - + - + diff --git a/readme.md b/readme.md index 832ca4b..8fc8999 100644 --- a/readme.md +++ b/readme.md @@ -37,6 +37,8 @@ When the application is launched, the plugin automatically handles navigation to > **Note:** Although the W3C specs for the Web App manifest consider absolute and relative URLs valid for the _start_url_ value (e.g. _http://www.racer2k.net/racer/start.html_ and _/start.html_ are both valid), the plugin requires this URL **to be an absolute URL**. Otherwise, the installed applications won't be able to navigate to the web site. +The plugin enables the injection of additional Cordova plugins and app-specific scripts that consume them allowing you to take advantage of native features in your hosted web apps. + Lastly, since network connectivity is essential to the operation of a hosted web application, the plugin implements a basic offline feature that will show an offline page whenever connectivity is lost and will prevent users from interacting with the application until the connection is restored. ## Installation @@ -82,6 +84,68 @@ The plugin enables using content hosted in a web site inside a Cordova applicati > **Note:** The plugin updates the Cordova configuration file (config.xml) with the information in the W3C manifest. If the information in the manifest changes, you can reapply the updated manifest settings at any time by executing prepare. For example: `cordova prepare` +### Using Cordova Plugins in Hosted Web Apps +The plugin supports the injection of Cordova and the plugin interface scripts into the pages of a hosted site. There are two different plugin modes: '_server_' and '_client_'. In '_client_' mode, the **cordova.js** file and the plugin interface script files are retrieved from the app package. In '_server_' mode, these files are downloaded from the server along with the rest of the app's content. The plugin also provides a mechanism for injecting scripts that can be used, among other things, to consume the plugins added to the app. Imported scripts can be retrieved from the app package or downloaded from a remote source. + +Very briefly, these are the steps that are needed to use plugins: + +- Add one or more Cordova plugins to the app. + +- Enable API access in any pages where Cordova and the plugins will be used. This injects the Cordova runtime environment and is configured via a custom extension in the W3C manifest. The **match** and **platform** attributes specifies the pages and platforms where you will use Cordova. + + ``` + { + ... + "mjs_api_access": [ + { "match": "http://yoursite.com/path1/*", "platform": "android, ios, windows", "access": "cordova" }, + ... + ] + } + ``` +- Optionally, choose a plugin mode. The default mode is _client_. + + **Client mode** + ``` + { + ... + "mjs_cordova": { + "plugin_mode": "client" + } + } + ``` + + **Server mode** + ``` + { + ... + "mjs_cordova": { + "plugin_mode": "server", + "base_url": "js/cordova" + } + } + ``` + + (In '_server_' mode, the Cordova files and plugin interface scripts must be deployed to the site to the path specified in **base_url**. Also, the **cordova.js** and **cordova_plugins.js** files for each platform need to be renamed to specify the platform in their names so that **cordova.js** and **cordova_plugins.js** become, in the case of Android for example, **cordova-android.js** and **cordova_plugins-android.js** respectively.) + +To inject scripts into the hosted web content: + +- Update the app's manifest to list the imported scripts in a custom **mjs_import_scripts** section. + ``` + { + ... + "mjs_import_scripts": [ + { "src": "js/alerts.js" }, + { "src": "http://yoursite.com/js/app/contacts.js" }, + { "src": "js/camera.js", "match": "http://yoursite.com/profile/*" }, + ... + ] + } + ``` + +- For app-hosted scripts, copy the script files to the Cordova project. The path in **mjs_import_scripts** must be specified relative to the '_www_' folder of the project. Server-hosted scripts must be deployed to the site. + +The following [wiki article](https://github.com/manifoldjs/ManifoldJS/wiki/Using-Cordova-Plugins-in-Hosted-Web-Apps) provides additional information about these features. + ### Offline Feature The plugin implements a basic offline feature that will show an offline page whenever network connectivity is lost. By default, the page shows a suitable message alerting the user about the loss of connectivity. To customize the offline experience, a page named **offline.html** can be placed in the **www** folder of the application and it will be used instead. @@ -130,20 +194,18 @@ For example, the following manifest references icons from the _/resources_ path } -### URL Access Rules -For a hosted web application, the W3C manifest defines a scope that restricts the URLs to which the application can navigate. Additionally, the manifest can include a proprietary setting named **mjs_access_whitelist** that defines an array of access rules each one consisting of a _url_ attribute that identifies the target of the rule and indicates whether URLs matching the rule should be navigated to by the application. Non-matching URLs will be launched externally. +### Navigation Scope +For a hosted web application, the W3C manifest defines a scope that restricts the URLs to which the application can navigate. Additionally, the manifest can include a proprietary setting named **mjs_extended_scope** that defines an array of scope rules each one indicating whether URLs matching the rule should be navigated to by the application. Non-matching URLs will be launched externally. -Typically, Cordova applications define access rules to implement a security policy that controls access to external domains. The access rules must not only allow access to the scope defined by the W3C manifest but also to external content used within the site, for example, to reference script files hosted by a CDN origin. - -To configure the security policy, the plugin hook maps the scope and URL access rules in the W3C manifest (**manifest.json**) to suitable access elements in the Cordova configuration file (**config.xml**). For example: +Typically, Cordova applications define scope rules to implement a security policy that controls access to external domains. To configure the security policy, the plugin hook maps the scope rules in the W3C manifest (**manifest.json**) to suitable `` elements in the Cordova configuration file (**config.xml**). For example: **Manifest.json**
 ...
    "start_url": "http://www.xyz.com/",
    "scope":  "/", 
-   "mjs_access_whitelist": [
-     { "url": "http//googleapis.com/*" },
+   "mjs_extended_scope": [
+     { "url": "http//otherdomain.com/*" },
      { "url": "http//login.anotherdomain.com/" }
    ]
 ...
@@ -152,9 +214,9 @@ To configure the security policy, the plugin hook maps the scope and URL access
 **Config.xml**
 
 ...
-<access origin="http://www.xyz.com/*" />
-<access origin="http://googleapis.com/*" /> 
-<access origin="http://login.anotherdomain.com/" />
+<allow-navigation href="http://www.xyz.com/*" />
+<allow-navigation href="http://otherdomain.com/*" /> 
+<allow-navigation href="http://login.anotherdomain.com/" />
 ...
 
@@ -198,13 +260,12 @@ Windows Phone 8.1 iOS Android -### Windows 8.1 and Windows Phone 8.1 Quirks +### Windows and Windows Phone Quirks -Cordova for Android and iOS platforms provide a security policy to control which network requests triggered by the page (css, js, images, XHRs, etc.) are allowed to be made; this means that they will be blocked if they are outside the scope and do not match any of the access rules defined in the manifest. +Cordova for Android and iOS platforms provide a security policy to control which network requests triggered by the page (css, js, images, XHRs, etc.) are allowed to be made; this means that they will be blocked if they don't match the `origin` attribute of any of the `` elements defined in the Cordova configuration file (**config.xml**). The Windows and Windows Phone platforms do not provide control for these kind of requests, and they will be allowed. - ## Changelog -Releases are documented in [GitHub](https://github.com/manifoldjs/ManifoldCordova/releases). \ No newline at end of file +Releases are documented in [GitHub](https://github.com/manifoldjs/ManifoldCordova/releases). diff --git a/scripts/package.json b/scripts/package.json index b693267..bba69a1 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -10,7 +10,7 @@ "builder": "node ./node_modules/mocha/bin/mocha --reporter mocha-teamcity-reporter" }, "devDependencies": { - "cordova-lib": "^5.0.0", + "cordova-lib": "^5.4.0", "mocha": "^2.2.1", "mocha-teamcity-reporter": "0.0.4", "q": "^1.2.0" diff --git a/scripts/rollbackWindowsWrapperFiles.js b/scripts/rollbackWindowsWrapperFiles.js index 7e47be4..5af2170 100644 --- a/scripts/rollbackWindowsWrapperFiles.js +++ b/scripts/rollbackWindowsWrapperFiles.js @@ -41,8 +41,15 @@ function deleteFile(path) { // Configure Cordova configuration parser function configureParser(context) { - var cordova_util = context.requireCordovaModule('cordova-lib/src/cordova/util'), - ConfigParser = context.requireCordovaModule('cordova-lib/src/configparser/ConfigParser'); + var cordova_util = context.requireCordovaModule('cordova-lib/src/cordova/util'); + var ConfigParser; + try { + ConfigParser = context.requireCordovaModule('cordova-lib/node_modules/cordova-common').ConfigParser; + } catch (err) { + // Fallback to old location of config parser (old versions of cordova-lib) + ConfigParser = context.requireCordovaModule('cordova-lib/src/configparser/ConfigParser'); + } + etree = context.requireCordovaModule('cordova-lib/node_modules/elementtree'); var xml = cordova_util.projectConfig(context.opts.projectRoot); diff --git a/scripts/test/assets/fullAccessRules/manifest.json b/scripts/test/assets/fullAccessRules/manifest.json index 0c6724e..a19734c 100644 --- a/scripts/test/assets/fullAccessRules/manifest.json +++ b/scripts/test/assets/fullAccessRules/manifest.json @@ -4,7 +4,7 @@ "orientation": "landscape", "display": "fullscreen", "scope": "http://wat-docs.azurewebsites.net/*", - "mjs_access_whitelist": [ - { "url": "http://wat.codeplex.com", "external": true } + "mjs_extended_scope": [ + "http://wat.codeplex.com" ] } diff --git a/scripts/test/assets/xmlEmptyWidget/manifest.json b/scripts/test/assets/xmlEmptyWidget/manifest.json index 71fe314..f482adb 100644 --- a/scripts/test/assets/xmlEmptyWidget/manifest.json +++ b/scripts/test/assets/xmlEmptyWidget/manifest.json @@ -4,8 +4,8 @@ "orientation": "landscape", "display": "fullscreen", "scope": "/scope-path/", - "mjs_access_whitelist": [ - { "url": "whitelist-rule-1" }, - { "url": "whitelist-rule-2" } - ] + "mjs_extended_scope": [ + "whitelist-rule-1", + "whitelist-rule-2" + ] } diff --git a/scripts/test/updateConfigurationBeforePrepare.js b/scripts/test/updateConfigurationBeforePrepare.js index 12566e3..f3d6ffb 100644 --- a/scripts/test/updateConfigurationBeforePrepare.js +++ b/scripts/test/updateConfigurationBeforePrepare.js @@ -43,6 +43,10 @@ function initializeContext(testDir) { return require('cordova-lib/src/cordova/util'); } + if (moduleName === 'cordova-lib/node_modules/cordova-common') { + return require('cordova-lib/node_modules/cordova-common'); + } + if (moduleName === 'cordova-lib/src/configparser/ConfigParser') { return require('cordova-lib/src/configparser/ConfigParser'); } @@ -228,27 +232,36 @@ describe('updateConfigurationBeforePrepare.js', function (){ }); }); - it('Should remove "root" full access rules from config.xml', function (done){ + it('Should keep generic network access rules from config.xml', function (done){ var testDir = path.join(workingDirectory, 'fullAccessRules'); var configXML = path.join(testDir, 'config.xml'); var ctx = initializeContext(testDir); updateConfiguration(ctx).then(function () { var content = fs.readFileSync(configXML).toString(); - assert(content.indexOf('') === -1); - assert(content.indexOf('') === -1); - assert(content.indexOf('') === -1); - assert(content.indexOf('') === -1); - assert(content.indexOf('') === -1); + assert(content.indexOf('') > 0); + assert(content.indexOf('') > 0); + + done(); + }); + }); + + it('Should remove generic allow-intent rules from config.xml', function (done){ + var testDir = path.join(workingDirectory, 'fullAccessRules'); + var configXML = path.join(testDir, 'config.xml'); + var ctx = initializeContext(testDir); + + updateConfiguration(ctx).then(function () { + var content = fs.readFileSync(configXML).toString(); + assert(content.indexOf('') === -1); + assert(content.indexOf('') === -1); assert(content.indexOf('') === -1); - assert(content.indexOf('') === -1); - assert(content.indexOf('') === -1); done(); }); }); - it('Should add access rules for web site domain in config.xml if scope is missing', function (done){ + it('Should add allow-navigation rule for web site domain in config.xml if scope is missing', function (done){ var testDir = path.join(workingDirectory, 'normalFlow'); var configXML = path.join(testDir, 'config.xml'); var ctx = initializeContext(testDir); @@ -256,18 +269,13 @@ describe('updateConfigurationBeforePrepare.js', function (){ updateConfiguration(ctx).then(function () { var content = fs.readFileSync(configXML).toString(); - // rules for android - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - - // rules for ios - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); + assert(content.indexOf('') > 0); done(); }); }); - it('Should add access rules for scope in config.xml if scope is a relative URL', function (done){ + it('Should add allow-navigation rule for scope in config.xml if scope is a relative URL', function (done){ var testDir = path.join(workingDirectory, 'xmlEmptyWidget'); var configXML = path.join(testDir, 'config.xml'); var ctx = initializeContext(testDir); @@ -275,18 +283,13 @@ describe('updateConfigurationBeforePrepare.js', function (){ updateConfiguration(ctx).then(function () { var content = fs.readFileSync(configXML).toString(); - // rules for android - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - - // rules for ios - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); + assert(content.indexOf('') > 0); done(); }); }); - it('Should add access rules for scope in config.xml if scope is a full URL', function (done){ + it('Should add allow-navigation rules for scope in config.xml if scope is a full URL', function (done){ var testDir = path.join(workingDirectory, 'fullUrlForScope'); var configXML = path.join(testDir, 'config.xml'); var ctx = initializeContext(testDir); @@ -294,18 +297,13 @@ describe('updateConfigurationBeforePrepare.js', function (){ updateConfiguration(ctx).then(function () { var content = fs.readFileSync(configXML).toString(); - // rules for android - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - - // rules for ios - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); + assert(content.indexOf('') > 0); done(); }); }); - it('Should add access rules for scope in config.xml if scope is a full URL with wildcard as subdomain', function (done){ + it('Should add allow-navigation rule for scope in config.xml if scope is a full URL with wildcard as subdomain', function (done){ var testDir = path.join(workingDirectory, 'wildcardSubdomainForScope'); var configXML = path.join(testDir, 'config.xml'); var ctx = initializeContext(testDir); @@ -313,34 +311,23 @@ describe('updateConfigurationBeforePrepare.js', function (){ updateConfiguration(ctx).then(function () { var content = fs.readFileSync(configXML).toString(); - // rules for android - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - - // rules for ios - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - + assert(content.indexOf('') > 0); + done(); }); }); - it('Should add access rules from mjs_access_whitelist list', function (done){ + it('Should add allow-navigation rules from mjs_access_whitelist list', function (done){ var testDir = path.join(workingDirectory, 'xmlEmptyWidget'); var configXML = path.join(testDir, 'config.xml'); var ctx = initializeContext(testDir); updateConfiguration(ctx).then(function () { var content = fs.readFileSync(configXML).toString(); - // rules for android - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - // rules for ios - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - assert(content.match(/[\s\S]*[\s\S]*<\/platform>/)); - + assert(content.indexOf('') > 0); + assert(content.indexOf('') > 0); + done(); }); }); diff --git a/scripts/updateConfigurationAfterPrepare.js b/scripts/updateConfigurationAfterPrepare.js index 02bc7a5..befa015 100644 --- a/scripts/updateConfigurationAfterPrepare.js +++ b/scripts/updateConfigurationAfterPrepare.js @@ -20,8 +20,15 @@ var logger = { // Configure Cordova configuration parser function configureParser(context) { - var cordova_util = context.requireCordovaModule('cordova-lib/src/cordova/util'), - ConfigParser = context.requireCordovaModule('cordova-lib/src/configparser/ConfigParser'); + var cordova_util = context.requireCordovaModule('cordova-lib/src/cordova/util'); + var ConfigParser; + try { + ConfigParser = context.requireCordovaModule('cordova-lib/node_modules/cordova-common').ConfigParser; + } catch (err) { + // Fallback to old location of config parser (old versions of cordova-lib) + ConfigParser = context.requireCordovaModule('cordova-lib/src/configparser/ConfigParser'); + } + etree = context.requireCordovaModule('cordova-lib/node_modules/elementtree'); var xml = cordova_util.projectConfig(projectRoot); diff --git a/scripts/updateConfigurationBeforePrepare.js b/scripts/updateConfigurationBeforePrepare.js index 752fcbe..3a0534e 100644 --- a/scripts/updateConfigurationBeforePrepare.js +++ b/scripts/updateConfigurationBeforePrepare.js @@ -111,8 +111,15 @@ function processImageList(images, baseUrl) { // Configure Cordova configuration parser function configureParser(context) { - var cordova_util = context.requireCordovaModule('cordova-lib/src/cordova/util'), + var cordova_util = context.requireCordovaModule('cordova-lib/src/cordova/util'); + var ConfigParser; + try { + ConfigParser = context.requireCordovaModule('cordova-lib/node_modules/cordova-common').ConfigParser; + } catch (err) { + // Fallback to old location of config parser (old versions of cordova-lib) ConfigParser = context.requireCordovaModule('cordova-lib/src/configparser/ConfigParser'); + } + etree = context.requireCordovaModule('cordova-lib/node_modules/elementtree'); var xml = cordova_util.projectConfig(projectRoot); @@ -122,36 +129,16 @@ function configureParser(context) { function processAccessRules(manifest) { if (manifest && manifest.start_url) { - // Remove previous rules + // Remove previous rules added by the hook config.removeElements('.//allow-intent[@hap-rule=\'yes\']'); config.removeElements('.//allow-navigation[@hap-rule=\'yes\']'); config.removeElements('.//access[@hap-rule=\'yes\']'); - // Remove "full access"" rules + // Remove "generic" rules to open external URLs outside the app config.removeElements('.//allow-intent[@href=\'http://*/*\']'); config.removeElements('.//allow-intent[@href=\'https://*/*\']'); config.removeElements('.//allow-intent[@href=\'*\']'); - config.removeElements('.//allow-navigation[@href=\'http://*/*\']'); - config.removeElements('.//allow-navigation[@href=\'https://*/*\']'); - config.removeElements('.//allow-navigation[@href=\'*\']'); - config.removeElements('.//access[@origin=\'http://*/*\']'); - config.removeElements('.//access[@origin=\'https://*/*\']'); - config.removeElements('.//access[@origin=\'*\']'); - - // get the android platform section and create it if it does not exist - var androidRoot = config.doc.find('platform[@name=\'android\']'); - if (!androidRoot) { - androidRoot = etree.SubElement(config.doc.getroot(), 'platform'); - androidRoot.set('name', 'android'); - } - - // get the ios platform section and create it if it does not exist - var iosRoot = config.doc.find('platform[@name=\'ios\']'); - if (!iosRoot) { - iosRoot = etree.SubElement(config.doc.getroot(), 'platform'); - iosRoot.set('name', 'ios'); - } - + // determine base rule based on the start_url and the scope var baseUrlPattern = manifest.start_url; if (manifest.scope && manifest.scope.length) { @@ -167,43 +154,37 @@ function processAccessRules(manifest) { if (baseUrlPattern.indexOf('*') === -1) { baseUrlPattern = url.resolve(baseUrlPattern, '*'); } - - // add base rule as an access rule for Android - var androidAccessBaseRule = new etree.SubElement(androidRoot, 'access'); - androidAccessBaseRule.set('hap-rule','yes'); - androidAccessBaseRule.set('origin', baseUrlPattern); - - // add base rule as a navigation rule for Android - var androidNavigationBaseRule = new etree.SubElement(androidRoot, 'allow-navigation'); - androidNavigationBaseRule.set('hap-rule','yes'); - androidNavigationBaseRule.set('href', baseUrlPattern); - - // add base rule as an svvrdd rule for iOS - var iosBaseAccessRule = new etree.SubElement(iosRoot, 'access'); - iosBaseAccessRule.set('hap-rule','yes'); - iosBaseAccessRule.set('origin', baseUrlPattern); + + // add base rule as a navigation rule + var navigationBaseRule = new etree.SubElement(config.doc.getroot(), 'allow-navigation'); + navigationBaseRule.set('hap-rule','yes'); + navigationBaseRule.set('href', baseUrlPattern); var baseUrl = baseUrlPattern.substring(0, baseUrlPattern.length - 1);; - // add additional access rules + // add additional navigation rules from mjs_access_whitelist + // TODO: mjs_access_whitelist is deprecated. Should be removed in future versions if (manifest.mjs_access_whitelist && manifest.mjs_access_whitelist instanceof Array) { manifest.mjs_access_whitelist.forEach(function (item) { // To avoid duplicates, add the rule only if it does not have the base URL as a prefix - if (item.url.indexOf(baseUrl) !== 0 ) { - // add as an access rule for Android - var androidAccessEl = new etree.SubElement(androidRoot, 'access'); - androidAccessEl.set('hap-rule','yes'); - androidAccessEl.set('origin', item.url); - - // add as a navigation rule for Android - var androidNavigationEl = new etree.SubElement(androidRoot, 'allow-navigation'); - androidNavigationEl.set('hap-rule','yes'); - androidNavigationEl.set('href', item.url); - - // add as an access rule for iOS - var iosAccessEl = new etree.SubElement(iosRoot, 'access'); - iosAccessEl.set('hap-rule','yes'); - iosAccessEl.set('origin', item.url); + if (item.url.indexOf(baseUrl) !== 0 ) { + // add as a navigation rule + var navigationEl = new etree.SubElement(config.doc.getroot(), 'allow-navigation'); + navigationEl.set('hap-rule','yes'); + navigationEl.set('href', item.url); + } + }); + } + + // add additional navigation rules from mjs_extended_scope + if (manifest.mjs_extended_scope && manifest.mjs_extended_scope instanceof Array) { + manifest.mjs_extended_scope.forEach(function (item) { + // To avoid duplicates, add the rule only if it does not have the base URL as a prefix + if (item.indexOf(baseUrl) !== 0 ) { + // add as a navigation rule + var navigationEl = new etree.SubElement(config.doc.getroot(), 'allow-navigation'); + navigationEl.set('hap-rule','yes'); + navigationEl.set('href', item); } }); } diff --git a/src/android/HostedWebApp.java b/src/android/HostedWebApp.java index e718b7a..480500d 100644 --- a/src/android/HostedWebApp.java +++ b/src/android/HostedWebApp.java @@ -3,10 +3,10 @@ import android.content.Intent; import android.net.Uri; import android.content.res.AssetManager; -import android.os.Build; import android.util.Log; import android.view.View; import android.view.ViewGroup; +import android.webkit.ValueCallback; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.LinearLayout; @@ -16,13 +16,19 @@ import org.apache.cordova.CordovaPlugin; import org.apache.cordova.PluginResult; +import org.apache.cordova.Whitelist; +import org.apache.cordova.engine.SystemWebView; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; /** * This class manipulates the Web App W3C manifest. @@ -55,7 +61,7 @@ public void pluginInitialize() { if (this.assetExists(HostedWebApp.DEFAULT_MANIFEST_FILE)) { try { this.manifestObject = this.loadLocalManifest(HostedWebApp.DEFAULT_MANIFEST_FILE); - this.webView.postMessage("hostedWebApp_manifestLoaded", this.manifestObject); + this.onManifestLoaded(); } catch (JSONException e) { e.printStackTrace(); } @@ -119,7 +125,7 @@ public void run() { if (me.assetExists(configFilename)) { try { me.manifestObject = me.loadLocalManifest(configFilename); - me.webView.postMessage("hostedWebApp_manifestLoaded", me.manifestObject); + me.onManifestLoaded(); callbackContext.success(me.manifestObject); } catch (JSONException e) { callbackContext.error(e.getMessage()); @@ -137,8 +143,8 @@ public void run() { callbackContext.sendPluginResult(pluginResult); } - return true; - } + return true; + } if (action.equals("enableOfflinePage")) { this.offlineOverlayEnabled = true; @@ -150,6 +156,25 @@ public void run() { return true; } + if (action.equals("injectPluginScript")) { + final List scripts = new ArrayList(); + scripts.add(args.getString(0)); + + cordova.getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + injectScripts(scripts, new ValueCallback() { + @Override + public void onReceiveValue(String s) { + callbackContext.success(1); + } + }); + } + }); + + return true; + } + return false; } @@ -180,7 +205,15 @@ else if (id.equals("onPageFinished")) { if (!this.isConnectionError) { this.hideOfflineOverlay(); } + + if (data != null) { + String url = data.toString(); + Log.v(LOG_TAG, String.format("Finished loading URL '%s'", url)); + + this.injectCordovaScripts(url); + } } + return null; } @@ -231,6 +264,121 @@ public JSONObject getManifest() { return this.manifestObject; } + private void injectCordovaScripts(String pageUrl) { + + // Inject cordova scripts + JSONArray apiAccessRules = this.manifestObject.optJSONArray("mjs_api_access"); + if (apiAccessRules != null) { + boolean allowApiAccess = false; + for (int i = 0; i < apiAccessRules.length(); i++) { + JSONObject apiRule = apiAccessRules.optJSONObject(i); + if (apiRule != null) { + // ensure rule applies to current platform and current page + if (this.isMatchingRuleForPage(pageUrl, apiRule, true)) { + String access = apiRule.optString("access", "cordova").trim(); + if (access.equalsIgnoreCase("cordova")) { + allowApiAccess = true; + } else if (access.equalsIgnoreCase("none")) { + allowApiAccess = false; + break; + } else { + Log.v(LOG_TAG, String.format("Unsupported API access type '%s' found in mjs_api_access rule.", access)); + } + } + } + } + + if (allowApiAccess) { + String pluginMode = "client"; + String cordovaBaseUrl = "/"; + + JSONObject cordovaSettings = this.manifestObject.optJSONObject("mjs_cordova"); + if (cordovaSettings != null) { + pluginMode = cordovaSettings.optString("plugin_mode", "client").trim(); + cordovaBaseUrl = cordovaSettings.optString("base_url", "").trim(); + if (!cordovaBaseUrl.endsWith("/")) { + cordovaBaseUrl += "/"; + } + } + + this.webView.getEngine().loadUrl("javascript: window.hostedWebApp = { 'platform': 'android', 'pluginMode': '" + pluginMode + "', 'cordovaBaseUrl': '" + cordovaBaseUrl + "'};", false); + + List scriptList = new ArrayList(); + if (pluginMode.equals("client")) { + scriptList.add("cordova.js"); + } + + scriptList.add("hostedapp-bridge.js"); + injectScripts(scriptList, null); + } + } + + // Inject custom scripts + JSONArray customScripts = this.manifestObject.optJSONArray("mjs_import_scripts"); + if (customScripts != null && customScripts.length() > 0) { + for (int i = 0; i < customScripts.length(); i++) { + JSONObject item = customScripts.optJSONObject(i); + if (item != null) { + String source = item.optString("src", "").trim(); + if (!source.isEmpty()) { + // ensure script applies to current page + if (this.isMatchingRuleForPage(pageUrl, item, false)) { + injectScripts(Arrays.asList(new String[]{source}), null); + } + } + } + } + } + } + + private boolean isMatchingRuleForPage(String pageUrl, JSONObject item, boolean checkPlatform) { + // ensure item applies to current platform + if (checkPlatform) { + boolean isPlatformMatch = true; + String platform = item.optString("platform", "").trim(); + if (!platform.isEmpty()) { + isPlatformMatch = false; + String[] platforms = platform.split(","); + for (String p : platforms) { + if (p.trim().equalsIgnoreCase("android")) { + isPlatformMatch = true; + break; + } + } + } + + if (!isPlatformMatch) { + return false; + } + } + + // ensure item applies to current page + boolean isURLMatch = true; + JSONArray match = item.optJSONArray("match"); + if (match == null) { + match = new JSONArray(); + String matchString = item.optString("match", "").trim(); + if (!matchString.isEmpty()) { + match.put(matchString); + } + } + + if (match.length() > 0) { + Whitelist whitelist = new Whitelist(); + for (int j = 0; j < match.length(); j++) { + whitelist.addWhiteListEntry(match.optString(j).trim(), false); + } + + isURLMatch = whitelist.isUrlWhiteListed(pageUrl); + } + + return isURLMatch; + } + + private void onManifestLoaded() { + this.webView.postMessage("hostedWebApp_manifestLoaded", this.manifestObject); + } + private CordovaPlugin getWhitelistPlugin() { if (this.whiteListPlugin == null) { this.whiteListPlugin = this.webView.getPluginManager().getPlugin("Whitelist"); @@ -304,10 +452,10 @@ private void showOfflineOverlay() { public void run() { if (me.rootLayout != null) { me.rootLayout.setVisibility(View.VISIBLE); + } } - } - }); - } + }); + } } private void hideOfflineOverlay() { @@ -337,4 +485,82 @@ private JSONObject loadLocalManifest(String manifestFile) throws JSONException { return null; } + + private void injectScripts(final List files, final ValueCallback resultCallback) { + final HostedWebApp me = this; + + this.cordova.getThreadPool().execute(new Runnable() { + @Override + public void run() { + String script = ""; + for (int i = 0; i < files.size(); i++) { + String fileName = files.get(i); + String content = ""; + Log.w(LOG_TAG, String.format("Injecting script: '%s'", fileName)); + + try { + Uri uri = Uri.parse(fileName); + if (uri.isRelative()) { + // Load script file from assets + try { + InputStream inputStream = me.activity.getResources().getAssets().open("www/" + fileName); + content = me.ReadStreamContent(inputStream); + + } catch (IOException e) { + Log.v(LOG_TAG, String.format("ERROR: failed to load script file: '%s'", fileName)); + e.printStackTrace(); + } + } else { + // load script file from URL + URL url = new URL(fileName); + try { + HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection(); + try { + InputStream inputStream = urlConnection.getInputStream(); + content = me.ReadStreamContent(inputStream); + } finally { + urlConnection.disconnect(); + } + } catch (IOException e) { + Log.v(LOG_TAG, String.format("ERROR: failed to load script file from URL: '%s'", fileName)); + e.printStackTrace(); + } + } + } catch (Exception e) { + Log.v(LOG_TAG, String.format("ERROR: Invalid path format of script file: '%s'", fileName)); + e.printStackTrace(); + } + + if (!content.isEmpty()) { + script += "\r\n//# sourceURL=" + fileName + "\r\n" + content; + } + } + + final String scriptToInject = script; + me.activity.runOnUiThread(new Runnable() { + @Override + public void run() { + SystemWebView webView = (SystemWebView) me.webView.getEngine().getView(); + if (webView != null) { + webView.evaluateJavascript(scriptToInject, resultCallback); + } else { + Log.v(LOG_TAG, String.format("WARNING: Unexpected Webview type. Expected: '%s'. Found: '%s'", SystemWebView.class.getName(), me.webView.getEngine().getView().getClass().getName())); + me.webView.getEngine().loadUrl("javascript:" + Uri.encode(scriptToInject), false); + resultCallback.onReceiveValue(null); + } + } + }); + } + }); + } + + private String ReadStreamContent(InputStream inputStream) throws IOException { + int size = inputStream.available(); + byte[] bytes = new byte[size]; + inputStream.read(bytes); + inputStream.close(); + String content = new String(bytes, "UTF-8"); + + return content; + } } diff --git a/src/ios/CDVHostedWebApp.h b/src/ios/CDVHostedWebApp.h index 861c341..d0e7274 100644 --- a/src/ios/CDVHostedWebApp.h +++ b/src/ios/CDVHostedWebApp.h @@ -27,4 +27,6 @@ -(void) disableOfflinePage:(CDVInvokedUrlCommand*)command; +-(void) injectPluginScript:(CDVInvokedUrlCommand *)command; + @end diff --git a/src/ios/CDVHostedWebApp.m b/src/ios/CDVHostedWebApp.m index b069dd3..db3018f 100644 --- a/src/ios/CDVHostedWebApp.m +++ b/src/ios/CDVHostedWebApp.m @@ -2,6 +2,10 @@ #import #import "CDVConnection.h" +static NSString* const IOS_PLATFORM = @"ios"; +static NSString* const DEFAULT_PLUGIN_MODE = @"client"; +static NSString* const DEFAULT_CORDOVA_BASE_URL = @""; + @interface CDVHostedWebApp () @property UIWebView *offlineView; @@ -137,6 +141,14 @@ -(void) disableOfflinePage:(CDVInvokedUrlCommand *)command { [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; } +-(void) injectPluginScript:(CDVInvokedUrlCommand *)command { + + NSArray* scriptList = @[[command.arguments objectAtIndex:0]]; + BOOL result = [self injectScripts: scriptList]; + CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsBool:result]; + [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId]; +} + // loads a manifest file and parses it -(NSDictionary *) loadManifestFile:(NSString *)manifestFileName { @@ -168,8 +180,7 @@ -(NSDictionary *) loadManifestFile:(NSString *)manifestFileName { } if([parsedManifest isKindOfClass:[NSDictionary class]]) { - [[NSNotificationCenter defaultCenter] postNotificationName:kManifestLoadedNotification object:parsedManifest]; - + [[NSNotificationCenter defaultCenter] postNotificationName:kManifestLoadedNotification object:parsedManifest]; return parsedManifest; } @@ -178,6 +189,124 @@ -(NSDictionary *) loadManifestFile:(NSString *)manifestFileName { return nil; } +-(BOOL) injectScripts:(NSArray *)scriptList { + + NSString* content = @""; + for (NSString* scriptName in scriptList) + { + NSURL* scriptUrl = [NSURL URLWithString:scriptName relativeToURL:[NSURL URLWithString:@"www/"]]; + NSString* scriptPath = scriptUrl.absoluteString; + NSError *error = nil; + NSString* fileContents = nil; + if (scriptUrl.scheme == nil) + { + fileContents = [NSString stringWithContentsOfFile: [[NSBundle mainBundle] pathForResource: scriptPath ofType:nil] encoding:NSUTF8StringEncoding error:&error]; + } + else + { + fileContents = [NSString stringWithContentsOfURL:scriptUrl encoding:NSUTF8StringEncoding error:&error]; + } + + if (error == nil) { + // prefix with @ sourceURL= comment to make the injected scripts visible in Safari's Web Inspector for debugging purposes + content = [content stringByAppendingFormat:@"\r\n//@ sourceURL=%@\r\n%@", scriptName, fileContents]; + } + else { + NSLog(@"ERROR failed to load script file: '%@'", scriptName); + } + } + + return[self.webView stringByEvaluatingJavaScriptFromString:content] != nil; +} + +- (BOOL) isCordovaEnabled +{ + BOOL enableCordova = NO; + NSObject* setting = [self.manifest objectForKey:@"mjs_api_access"]; + if (setting != nil && [setting isKindOfClass:[NSArray class]]) + { + NSArray* accessRules = (NSArray*) setting; + if (accessRules != nil) + { + for (NSDictionary* rule in accessRules) + { + if ([self isMatchingRuleForPage:rule withPlatformCheck:YES]) + { + setting = [rule objectForKey:@"access"]; + + NSString* access = setting != nil ? + [(NSString*)setting stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] : nil; + if (access == nil || [access isEqualToString:@"cordova"]) + { + enableCordova = YES; + } + else if ([access isEqualToString:@"none"]) + { + return NO; + } + else + { + NSLog(@"ERROR unsupported access type '%@' found in mjs_api_access rule.", access); + } + } + } + } + } + + return enableCordova; +} + +-(BOOL) isMatchingRuleForPage:(NSDictionary*) rule withPlatformCheck: (BOOL) checkPlatform +{ + // ensure rule applies to current platform + if (checkPlatform) + { + BOOL isPlatformMatch = NO; + NSObject* setting = [rule objectForKey:@"platform"]; + if (setting != nil && [setting isKindOfClass:[NSString class]]) + { + for (id item in [(NSString*)setting componentsSeparatedByString:@","]) + { + if ([[item stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]] caseInsensitiveCompare:IOS_PLATFORM] == NSOrderedSame) + { + isPlatformMatch = YES; + break; + } + } + + if (!isPlatformMatch) + { + return NO; + } + } + } + + // ensure rule applies to current page + BOOL isURLMatch = YES; + NSObject* setting = [rule objectForKey:@"match"]; + if (setting != nil) + { + NSArray* match = nil; + if ([setting isKindOfClass:[NSArray class]]) + { + match = (NSArray*) setting; + } + else if ([setting isKindOfClass:[NSString class]]) + { + match = [NSArray arrayWithObjects:setting, nil]; + } + + if (match != nil) + { + CDVWhitelist *whitelist = [[CDVWhitelist alloc] initWithArray:match]; + NSURL* url = self.webView.request.URL; + isURLMatch = [whitelist URLIsAllowed:url]; + } + } + + return isURLMatch; +} + // Creates an additional webview to load the offline page, places it above the content webview, and hides it. It will // be made visible whenever network connectivity is lost. - (void)createOfflineView @@ -255,6 +384,63 @@ - (void)webViewDidFinishLoad:(NSNotification*)notification if (!self.failedURL) { [self.offlineView setHidden:YES]; } + + // inject Cordova + if ([self isCordovaEnabled]) + { + NSObject* setting = [self.manifest objectForKey:@"mjs_cordova"]; + if (setting == nil && ![setting isKindOfClass:[NSDictionary class]]) + { + setting = [[NSDictionary alloc] init]; + } + + NSDictionary* cordova = (NSDictionary*) setting; + + setting = [cordova objectForKey:@"plugin_mode"]; + NSString* pluginMode = (setting != nil && [setting isKindOfClass:[NSString class]]) + ? [(NSString*)setting stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] + : DEFAULT_PLUGIN_MODE; + + setting = [cordova objectForKey:@"base_url"]; + NSString* cordovaBaseUrl = (setting != nil && [setting isKindOfClass:[NSString class]]) + ? [(NSString*)setting stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] + : DEFAULT_CORDOVA_BASE_URL; + + if (![cordovaBaseUrl hasSuffix:@"/"]) + { + cordovaBaseUrl = [cordovaBaseUrl stringByAppendingString:@"/"]; + } + + NSString* javascript = [NSString stringWithFormat:@"window.hostedWebApp = { 'platform': '%@', 'pluginMode': '%@', 'cordovaBaseUrl': '%@'};", IOS_PLATFORM, pluginMode, cordovaBaseUrl]; + [self.webView stringByEvaluatingJavaScriptFromString:javascript]; + + NSMutableArray* scripts = [[NSMutableArray alloc] init]; + if ([pluginMode isEqualToString:@"client"]) + { + [scripts addObject: @"cordova.js"]; + } + + [scripts addObject: @"hostedapp-bridge.js"]; + [self injectScripts: scripts]; + } + + // inject custom scripts + NSObject* setting = [self.manifest objectForKey:@"mjs_import_scripts"]; + if (setting != nil && [setting isKindOfClass:[NSArray class]]) + { + NSArray* customScripts = (NSArray*) setting; + if (customScripts != nil && customScripts.count > 0) + { + for (NSDictionary* item in customScripts) + { + if ([self isMatchingRuleForPage:item withPlatformCheck:NO]) + { + NSString* source = [item valueForKey:@"src"]; + [self injectScripts: @[source]]; + } + } + } + } } } @@ -285,22 +471,79 @@ - (void)didWebViewFailLoadWithError:(NSNotification*)notification - (BOOL) shouldOverrideLoadWithRequest:(NSURLRequest*)request navigationType:(UIWebViewNavigationType)navigationType { NSURL* url = [request URL]; - CDVViewController* cdvViewController = (CDVViewController*)self.viewController; + + if (![self shouldAllowNavigation:url]) + { + if ([[UIApplication sharedApplication] canOpenURL:url]) + { + [[UIApplication sharedApplication] openURL:url]; // opens the URL outside the webview + return YES; + } + } - if (cdvViewController != nil) { - if (cdvViewController.whitelist != nil) { - if ([cdvViewController.whitelist schemeIsAllowed:[url scheme]]) { - if (![cdvViewController.whitelist URLIsAllowed:url]) { - if ([[UIApplication sharedApplication] canOpenURL:url]) { - [[UIApplication sharedApplication] openURL:url]; // opens the URL outside the webview - return YES; - } + return NO; +} + +-(BOOL) shouldAllowNavigation:(NSURL*) url +{ + NSMutableArray* scopeList = [[NSMutableArray alloc] initWithCapacity:0]; + + // determine base rule based on the start_url and the scope + NSURL* baseURL = nil; + NSString* startURL = [self.manifest objectForKey:@"start_url"]; + if (startURL != nil) { + baseURL = [NSURL URLWithString:startURL]; + NSString* scope = [self.manifest objectForKey:@"scope"]; + if (scope != nil) { + baseURL = [NSURL URLWithString:scope relativeToURL:baseURL]; + } + } + + if (baseURL != nil) { + // If there are no wildcards in the pattern, add '*' at the end + if (![[baseURL absoluteString] containsString:@"*"]) { + baseURL = [NSURL URLWithString:@"*" relativeToURL:baseURL]; + } + + + // add base rule to the scope list + [scopeList addObject:[baseURL absoluteString]]; + } + + // add additional navigation rules from mjs_access_whitelist + // TODO: mjs_access_whitelist is deprecated. Should be removed in future versions + NSObject* setting = [self.manifest objectForKey:@"mjs_access_whitelist"]; + if (setting != nil && [setting isKindOfClass:[NSArray class]]) + { + NSArray* accessRules = (NSArray*) setting; + if (accessRules != nil) + { + for (NSDictionary* rule in accessRules) + { + NSString *accessUrl = [rule objectForKey:@"url"]; + if (accessUrl != nil) + { + [scopeList addObject:accessUrl]; } } } } - return NO; + // add additional navigation rules from mjs_extended_scope + setting = [self.manifest objectForKey:@"mjs_extended_scope"]; + if (setting != nil && [setting isKindOfClass:[NSArray class]]) + { + NSArray* scopeRules = (NSArray*) setting; + if (scopeRules != nil) + { + for (NSString* rule in scopeRules) + { + [scopeList addObject:rule]; + } + } + } + + return [[[CDVWhitelist alloc] initWithArray:scopeList] URLIsAllowed:url]; } @end diff --git a/src/windows/HostedWebAppPluginProxy.js b/src/windows/HostedWebAppPluginProxy.js index 27f21de..a83d2da 100644 --- a/src/windows/HostedWebAppPluginProxy.js +++ b/src/windows/HostedWebAppPluginProxy.js @@ -1,4 +1,4 @@ -var _manifest; +var _manifest; var _manifestError; var _offlineView; var _mainView; @@ -8,6 +8,14 @@ var _lastKnownLocation; var _lastKnownLocationFailed = false; var _whiteList = []; +function bridgeNativeEvent(e) { + _mainView.invokeScriptAsync('eval', "cordova && cordova.fireDocumentEvent('" + e.type + "', null, true);").start(); +} + +//document.addEventListener('backbutton', bridgeNativeEvent, false); +document.addEventListener('pause', bridgeNativeEvent, false); +document.addEventListener('resume', bridgeNativeEvent, false); + // creates a webview to host content function configureHost(url, zOrder, display) { var webView = document.createElement(cordova.platformId === 'windows8' ? 'iframe' : 'x-ms-webview'); @@ -36,6 +44,12 @@ function configureHost(url, zOrder, display) { // handles webview's navigation starting event function navigationStartingEvent(evt) { + if (handleCordovaExecCalls(evt)) { + evt.stopImmediatePropagation(); + evt.preventDefault(); + return; + } + if (evt.uri && evt.uri !== "") { var isInWhitelist = false; for (var i = 0; i < _whiteList.length; i++) { @@ -72,6 +86,96 @@ function navigationCompletedEvent(evt) { } } +function domContentLoadedEvent(evt) { + console.log('Finished loading URL: ' + _mainView.src); + + hideExtendedSplashScreen(); + + // inject Cordova + if (isCordovaEnabled()) { + var cordova = _manifest.mjs_cordova || {}; + + var pluginMode = cordova.plugin_mode || 'client'; + var cordovaBaseUrl = (cordova.base_url || '').trim(); + if (cordovaBaseUrl.indexOf('/', cordovaBaseUrl.length - 1) === -1) { + cordovaBaseUrl += '/'; + } + + _mainView.invokeScriptAsync('eval', 'window.hostedWebApp = { \'platform\': \'windows\', \'pluginMode\': \'' + pluginMode + '\', \'cordovaBaseUrl\': \'' + cordovaBaseUrl + '\'};').start(); + + var scriptsToInject = []; + if (pluginMode === 'client') { + scriptsToInject.push('cordova.js'); + } + + scriptsToInject.push('hostedapp-bridge.js'); + injectScripts(scriptsToInject); + } + + // inject import scripts + if (_manifest && _manifest.mjs_import_scripts && _manifest.mjs_import_scripts instanceof Array) { + var scriptFiles = _manifest.mjs_import_scripts + .filter(isMatchingRuleForPage) + .map(function (item) { + return item.src; + }); + + if (scriptFiles.length) { + injectScripts(scriptFiles); + } + } +} + +// checks if Cordova runtime environment is enabled for the current page +function isCordovaEnabled() { + var allow = true; + var enableCordova = false; + var accessRules = _manifest.mjs_api_access; + if (accessRules) { + accessRules.forEach(function (rule) { + if (isMatchingRuleForPage(rule, true)) { + var access = rule.access; + if (!access || access === 'cordova') { + enableCordova = true; + } + else if (access === 'none') { + allow = false; + } + else { + console.log('Unsupported API access type \'' + access + '\' found in mjs_api_access rule.'); + } + } + }); + } + + return enableCordova && allow; +} + +// check if an API access or custom script match rule applies to the current page +function isMatchingRuleForPage(rule, checkPlatform) { + + // ensure rule applies to current platform + if (checkPlatform) { + if (rule.platform && rule.platform.split(',') + .map(function (item) { return item.trim(); }) + .indexOf('windows') < 0) { + return false; + } + } + + // ensure rule applies to current page + var match = rule.match; + if (match) { + if (typeof match === 'string' && match.length) { + match = [match]; + } + + return match.some(function (item) { return convertPatternToRegex(item).test(_mainView.src); }); + } + + return true; +} + // handles network connectivity change events function connectivityEvent(evt) { console.log('Received a network connectivity change notification. The device is currently ' + evt.type + '.'); @@ -151,18 +255,25 @@ function configureWhiteList(manifest) { baseUrlPattern = baseUrlPattern.combineUri('*'); _whiteList.push(convertPatternToRegex(baseUrlPattern.absoluteUri)); - - // add additional access rules + // add additional access rules from mjs_access_whitelist + // TODO: mjs_access_whitelist is deprecated. Should be removed in future versions if (manifest.mjs_access_whitelist && manifest.mjs_access_whitelist instanceof Array) { manifest.mjs_access_whitelist.forEach(function (rule) { _whiteList.push(convertPatternToRegex(rule.url)); }); } + + // add additional access rules from mjs_extended_scope + if (manifest.mjs_extended_scope && manifest.mjs_extended_scope instanceof Array) { + manifest.mjs_extended_scope.forEach(function (rule) { + _whiteList.push(convertPatternToRegex(rule)); + }); + } } } // hides the extended splash screen -function hideExtendedSplashScreen(e) { +function hideExtendedSplashScreen() { var extendedSplashScreen = document.getElementById("extendedSplashScreen"); extendedSplashScreen.style.display = "none"; } @@ -182,6 +293,91 @@ function navigateBack(e) { return true; } +var exec = require('cordova/exec'); + +function injectScripts(files, successCallback, errorCallback) { + + var script = (arguments.length > 3 && typeof arguments[3] === 'string') ? arguments[3] : ''; + var fileList = (arguments.length > 4 && typeof arguments[4] === 'string') ? arguments[4] : ''; + + if (typeof files === 'string') { + files = files.length ? [files] : []; + } + + var fileName = files.shift(); + if (!fileName) { + var asyncOp = _mainView.invokeScriptAsync('eval', script); + asyncOp.oncomplete = function () { successCallback && successCallback(true); }; + asyncOp.onerror = function (err) { + console.log('Error injecting script file(s): ' + fileList + ' - ' + asyncOp.error); + errorCallback && errorCallback(err); + }; + + asyncOp.start() + return; + } + + console.log('Injecting script file: ' + fileName); + var uri = new Windows.Foundation.Uri('ms-appx:///www/', fileName); + + var onSuccess = function (content) { + script += '\r\n//# sourceURL=' + fileName + '\r\n' + content; + injectScripts(files, successCallback, errorCallback, script, (fileList ? ', ' : '') + fileName); + }; + + var onError = function (err) { + console.log('Error retrieving script file from app package: ' + fileName + ' - ' + err); + if (errorCallback) { + errorCallback(err); + } + }; + + if (uri.schemeName == 'ms-appx') { + Windows.Storage.StorageFile.getFileFromApplicationUriAsync(uri) + .done(function (file) { + Windows.Storage.FileIO.readTextAsync(file) + .done(onSuccess, onError); + }, onError); + } else { + var httpClient = new Windows.Web.Http.HttpClient(); + httpClient.getStringAsync(uri).done(onSuccess, onError); + httpClient.close(); + } +} + +function handleCordovaExecCalls(evt) { + if (evt.uri) { + var targetUri = new Windows.Foundation.Uri(evt.uri); + if (targetUri.host === '.cordova' && targetUri.path === '/exec') { + var service = targetUri.queryParsed.getFirstValueByName('service'); + var action = targetUri.queryParsed.getFirstValueByName('action'); + var args = JSON.parse(decodeURIComponent(targetUri.queryParsed.getFirstValueByName('args'))); + var callbackId = targetUri.queryParsed.getFirstValueByName('callbackId'); + + var success, fail; + if (callbackId !== '0') { + success = function (args) { + var params = args ? '"' + encodeURIComponent(JSON.stringify(args)) + '"' : ''; + var script = 'cordova.callbacks["' + callbackId + '"].success(' + params + ');'; + _mainView.invokeScriptAsync('eval', script).start(); + }; + + fail = function (err) { + var params = args ? '"' + encodeURIComponent(JSON.stringify(err)) + '"' : ''; + var script = 'cordova.callbacks["' + callbackId + '"].fail(' + params + ');'; + _mainView.invokeScriptAsync('eval', script).start(); + }; + } + + exec(success, fail, service, action, args); + + return true; + } + } + + return false; +} + module.exports = { // loads the W3C manifest file and parses it loadManifest: function (successCallback, errorCallback, args) { @@ -194,9 +390,7 @@ module.exports = { try { _manifest = JSON.parse(data); cordova.fireDocumentEvent("manifestLoaded", { manifest: _manifest }); - if (successCallback) { - successCallback(_manifest); - } + successCallback && successCallback(_manifest); } catch (err) { _manifestError = 'Error parsing manifest file: ' + manifestFileName + ' - ' + err.message; console.log(_manifestError); @@ -206,37 +400,37 @@ module.exports = { function (err) { _manifestError = 'Error reading manifest file: ' + manifestFileName + ' - ' + err; console.log(_manifestError); - if (errorCallback) { - errorCallback(err); - } + errorCallback && errorCallback(err); }); }, // returns the currently loaded manifest getManifest: function (successCallback, errorCallback) { if (_manifest) { - if (successCallback) { - successCallback(_manifest); - } + successCallback && successCallback(_manifest); } else { - if (errorCallback) { - errorCallback(new Error(_manifestError)); - } + errorCallback && errorCallback(new Error(_manifestError)); } }, // enables offline page support - enableOfflinePage: function () { + enableOfflinePage: function (successCallback, errorCallback) { _enableOfflineSupport = true; + successCallback && successCallback(); }, // disables offline page support - disableOfflinePage: function () { + disableOfflinePage: function (successCallback, errorCallback) { _enableOfflineSupport = false; + successCallback && successCallback(); }, getWebView: function () { return _mainView; + }, + + injectPluginScript: function (successCallback, errorCallback, file) { + injectScripts(file, successCallback, errorCallback); } }; // exports @@ -247,7 +441,7 @@ module.exports.loadManifest( configureOfflineSupport('offline.html'); configureWhiteList(manifest); _mainView = configureHost(manifest ? manifest.start_url : 'about:blank', _zIndex); - _mainView.addEventListener("MSWebViewDOMContentLoaded", hideExtendedSplashScreen, false); + _mainView.addEventListener("MSWebViewDOMContentLoaded", domContentLoadedEvent, false); cordova.fireDocumentEvent("webviewCreated", { webView: _mainView }); WinJS.Application.onbackclick = navigateBack; diff --git a/www/hostedWebApp.js b/www/hostedWebApp.js index ef17e8f..d70c907 100644 --- a/www/hostedWebApp.js +++ b/www/hostedWebApp.js @@ -14,26 +14,3 @@ var hostedwebapp = { } module.exports = hostedwebapp; - -//var _deviceReady = false; -// -//function onDeviceReady() { -// if (_deviceReady) return; -// _deviceReady = true; -// -// cordova.exec(function (data) { -// var manifestObject = JSON.parse(data); -// -// // hostedwebapp.navigateToStartUrl(); // TODO: plugin should expose this method -// if (cordova.platformId === "windows" || cordova.platformId === "windows8") { -// webView.src = manifestObject.start_url; -// } else { -// window.location.href = manifestObject.start_url; -// } -// }, function (err) { -// console.log("Error loading Hosted Web App plugin: " + err); -// }, -// "HostedWebApp", "getManifest", []); -//} -// -//document.addEventListener("deviceready", onDeviceReady, false); diff --git a/www/hostedapp-bridge.js b/www/hostedapp-bridge.js new file mode 100644 index 0000000..1efcc65 --- /dev/null +++ b/www/hostedapp-bridge.js @@ -0,0 +1,122 @@ +(function (platform, pluginMode, cordovaBaseUrl) { + function onCordovaLoaded() { + var channel = cordova.require('cordova/channel'); + channel.onNativeReady.subscribe(function () { + + // for Windows plaftform, redefine exec to bridge calls to exec on "native" side (i.e. webview container) + if (platform === 'windows') { + cordova.define.remove('cordova/exec'); + cordova.define('cordova/exec', function (require, exports, module) { + module.exports = function (completeCallback, failureCallback, service, action, args) { + var success, fail; + + var command = 'http://.cordova/exec?service=' + service + '&action=' + action + '&args=' + encodeURIComponent(JSON.stringify(args)); + + if (typeof completeCallback === 'function') { + success = function (args) { + var result = args ? JSON.parse(decodeURIComponent(args)) : undefined; + completeCallback(result); + }; + } + + if (typeof failureCallback === 'function') { + fail = function (args) { + var err = args ? JSON.parse(decodeURIComponent(args)) : undefined; + failureCallback(err); + }; + } + + var callbackId = 0; + if (success || fail) { + callbackId = service + cordova.callbackId++; + cordova.callbacks[callbackId] = { success: success, fail: fail }; + } + + command += '&callbackId=' + encodeURIComponent(callbackId); + + window.location.href = command; + }; + }); + } + + // change bridge mode in iOS to avoid Content Security Policy (CSP) issues with 'gap://' frame origin + if (platform === 'ios') { + var exec = cordova.require('cordova/exec'); + exec.setJsToNativeBridgeMode(exec.jsToNativeModes.XHR_OPTIONAL_PAYLOAD); + } + + // override plugin loader to handle script injection + var pluginloader = cordova.require('cordova/pluginloader'); + var defaultInjectScript = pluginloader.injectScript; + pluginloader.injectScript = function (url, onload, onerror) { + + var onloadHandler = onload, onerrorHandler = onerror; + + // check if script being injected is 'cordova_plugins.js' + var cordovaPluginsScript = 'cordova_plugins.js'; + if (url.indexOf(cordovaPluginsScript, url.length - cordovaPluginsScript.length) !== -1) { + + // In Windows platform, avoid loading scripts from the "native" side + if (platform === 'windows') { + + // redefine onload to exclude scripts in 'www' folder + onloadHandler = function () { + var moduleList = cordova.require('cordova/plugin_list'); + for (var i = moduleList.length - 1; i >= 0; i--) { + if (moduleList[i].file.indexOf('/www/') < 0) { + moduleList.splice(i, 1); + } + } + + onload(); + }; + } + + // In server mode, rewrite url to retrieve platform specific file + if (pluginMode === 'server') { + url = url.replace(cordovaPluginsScript, 'cordova_plugins-' + platform + '.js'); + } + } + + // In client mode, call native side to load and inject the script from the app package + if (pluginMode === 'client') { + return cordova.require('cordova/exec')(function (result) { + + // native side did not handle the script--using default mechanism + if (!result) { + return defaultInjectScript(url, onloadHandler, onerrorHandler); + } + + onloadHandler(); + }, + function (err) { + onerrorHandler(err); + }, + 'HostedWebApp', 'injectPluginScript', [url]); + } + + if (pluginMode === 'server') { + url = cordovaBaseUrl + url; + } + + defaultInjectScript(url, onloadHandler, onerrorHandler); + }; + }); + } + + // inject the platform specific cordova.js file + if (pluginMode === 'server') { + function injectScript(url, onload) { + var script = document.createElement('script'); + script.src = url; + script.onload = onload; + document.head.appendChild(script); + } + + var cordovaSrc = cordovaBaseUrl + 'cordova-' + platform + '.js'; + injectScript(cordovaSrc, onCordovaLoaded); + } else { + onCordovaLoaded(); + } + +})(window.hostedWebApp.platform, window.hostedWebApp.pluginMode, window.hostedWebApp.cordovaBaseUrl);