diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 8db9981..8c0d40b 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -320,6 +320,78 @@ It is also possible to supply additional parameters using the [optional] `option > For more information refer to [Electron documentation](https://electronjs.org/docs/api/browser-window#winloadurlurl-options). +## Writing a Plugin +### Initializing +Add a directory for your plugin's electron platform under src/electron and in it, initialize an NPM repository (`npm init`) +### plugin.xml +In your plugin.xml, add the following elements: +```xml + + + +``` + +### package.json +In the repository's `package.json`, add the following: +```json + "cordova": { + "serviceName": "YourService" + } +``` +Where `YourService` is the service name that is being registered with cordova. + +### Writing the actual plugin +In the `src/electron` directory, add a file `index.js`\ +This file could look something like this: +```js +module.exports = function(action, args, callbackContext) { + if(action === 'yourAction') { + console.log(args[0]); // will echo 'foo' + console.log(args[1]); // will echo 123 + callbackContext.success('yourAction completed successfully'); + return true; + } + return false; +} +``` +Returning `true` from the function indicates that the invocation has returned without problem, while returning `false` means the action was not found on the service.\ +The service can then be called via +```js +const success = success => console.log(success) +const error = error => console.log(error) +cordova.exec(success, error, 'YourService', 'yourAction', ['foo', 123]); +``` +### Error handling +To indicate an error in the execution and trigger the plugin caller's `error` callback, use `callbackContext.error` + +### Multiple plugin results +If you want to return more than one plugin result, you need to keep the callback, this can be done by using `callbackContext.sendPluginResult` and `pluginResult.setKeepCallback`.\ +Here is an example of what this might look like: +```js +module.exports = function(action, args, callbackContext) { + if(action === 'yourAction') { + let i = 0; + const PluginResult = callbackContext.PluginResult; + const interval = setInterval(() => { + if (i++ < 3) { + const result = new PluginResult(PluginResult.STATUS_OK, i); + result.setKeepCallback(true); + callbackContext.sendPluginResult(result); + return; + } + clearInterval(interval) + callbackContext.success('Last result') + callbackContext.success('Already closed. This will not arrive') + }, 1000); + return true; + } + return false; +} +``` +In the example above, the `success` function will be called 4 times, with `0`, `1`, `2` and `'Last result'`.\ +After this, the listener for the callbackContext will be removed and subsequent calls will not be heard by the caller. + + ## Customizing the Electron's Main Process If it is necessary to customize the Electron's main process beyond the scope of the Electron's configuration settings, changes can be added directly to the `cdv-electron-main.js` file located in `{PROJECT_ROOT_DIR}/platform/electron/platform_www/`. This is the application's main process. diff --git a/bin/templates/platform_www/CallbackContext.js b/bin/templates/platform_www/CallbackContext.js new file mode 100644 index 0000000..a354b85 --- /dev/null +++ b/bin/templates/platform_www/CallbackContext.js @@ -0,0 +1,41 @@ +class PluginResult { + constructor (status, data) { + this.status = status; + this.data = data !== undefined ? data : null; + this.keepCallback = false; + } + + setKeepCallback (value) { + this.keepCallback = value; + } +} +PluginResult.STATUS_OK = 1; +PluginResult.STATUS_ERROR = 2; +PluginResult.ERROR_UNKNOWN_SERVICE = 4; +PluginResult.ERROR_UNKNOWN_ACTION = 8; +PluginResult.ERROR_UNEXPECTED_RESULT = 16; +PluginResult.ERROR_INVOCATION_EXCEPTION_NODE = 32; +PluginResult.ERROR_INVOCATION_EXCEPTION_CHROME = 64; + +class CallbackContext { + constructor (contextId, window) { + this.contextId = contextId; + this.window = window; + // add PluginResult as instance variable to be able to access it in plugins + this.PluginResult = PluginResult; + } + + sendPluginResult (result) { + this.window.webContents.send(this.contextId, result); + } + + success (data) { + this.sendPluginResult(new PluginResult(PluginResult.STATUS_OK, data)); + } + + error (data) { + this.sendPluginResult(new PluginResult(PluginResult.STATUS_ERROR, data)); + } +} + +module.exports = { CallbackContext, PluginResult }; diff --git a/bin/templates/platform_www/cdv-electron-main.js b/bin/templates/platform_www/cdv-electron-main.js index 19b852c..beab443 100644 --- a/bin/templates/platform_www/cdv-electron-main.js +++ b/bin/templates/platform_www/cdv-electron-main.js @@ -91,9 +91,9 @@ function createWindow () { // Emitted when the window is closed. mainWindow.on('closed', () => { - // Dereference the window object, usually you would store windows - // in an array if your app supports multi windows, this is the time - // when you should delete the corresponding element. + // Dereference the window object, usually you would store windows + // in an array if your app supports multi windows, this is the time + // when you should delete the corresponding element. mainWindow = null; }); } @@ -146,17 +146,56 @@ app.on('activate', () => { } }); -ipcMain.handle('cdv-plugin-exec', async (_, serviceName, action, ...args) => { - if (cordova && cordova.services && cordova.services[serviceName]) { - const plugin = require(cordova.services[serviceName]); +ipcMain.handle('cdv-plugin-exec', async (_, serviceName, action, args, callbackId) => { + // This function should never return a rejected promise or throw an exception, as otherwise ipcRenderer callback will convert the parameter to a string incapsulated in an Error. See https://github.com/electron/electron/issues/24427 - return plugin[action] - ? plugin[action](args) - : Promise.reject(new Error(`The action "${action}" for the requested plugin service "${serviceName}" does not exist.`)); - } else { - return Promise.reject(new Error(`The requested plugin service "${serviceName}" does not exist have native support.`)); + const { CallbackContext, PluginResult } = require('./CallbackContext.js'); + const callbackContext = new CallbackContext(callbackId, mainWindow); + + // this condition should never be met, exec.js already tests for it. + if (!(cordova && cordova.services && cordova.services[serviceName])) { + const message = `NODE: Invalid Service. Service '${serviceName}' does not have an electron implementation.`; + callbackContext.error(new PluginResult(PluginResult.ERROR | PluginResult.ERROR_UNKNOWN_SERVICE, message)); + return; } -}); + const plugin = require(cordova.services[serviceName]); + + // API3 backwards compatible plugin call handling + const packageConfig = require(cordova.services[serviceName] + '/package.json'); + if (packageConfig.cordova.API3 !== false) { + console.error('WARNING! Package ' + cordova.services[serviceName] + ' is using a deprecated API. Migrate to cordova-electron API 4.x ASAP. This API will be break in the next major version.'); + try { + await plugin[action](args); + } catch (exception) { + const message = "NODE: Exception while invoking service action '" + serviceName + '.' + action + "'\r\n" + exception; + // print error to terminal + console.error(message, exception); + // trigger node side error callback + callbackContext.error(new PluginResult(PluginResult.ERROR | PluginResult.ERROR_INVOCATION_EXCEPTION_NODE, { message, exception })); + } + return; + } + + // API 4.x handling + try { + const result = await plugin(action, args, callbackContext); + if (result === true) { + // successful invocation + } else if (result === false) { + const message = `NODE: Invalid action. Service '${serviceName}' does not have an electron implementation for action '${action}'.`; + callbackContext.error(new PluginResult(PluginResult.ERROR | PluginResult.ERROR_UNKNOWN_ACTION, message)); + } else { + const message = 'NODE: Unexpected plugin exec result' + result; + callbackContext.error(new PluginResult(PluginResult.ERROR | PluginResult.ERROR_UNEXPECTED_RESULT, message)); + } + } catch (exception) { + const message = "NODE: Exception while invoking service action '" + serviceName + '.' + action + "'\r\n" + exception; + // print error to terminal + console.error(message, exception); + // trigger node side error callback + callbackContext.error(new PluginResult(PluginResult.ERROR | PluginResult.ERROR_INVOCATION_EXCEPTION_NODE, { message, exception })); + } +}); // In this file you can include the rest of your app's specific main process // code. You can also put them in separate files and require them here. diff --git a/bin/templates/platform_www/cdv-electron-preload.js b/bin/templates/platform_www/cdv-electron-preload.js index ac00ab2..15427dd 100644 --- a/bin/templates/platform_www/cdv-electron-preload.js +++ b/bin/templates/platform_www/cdv-electron-preload.js @@ -20,16 +20,33 @@ const { contextBridge, ipcRenderer } = require('electron'); const { cordova } = require('./package.json'); +const { PluginResult } = require('./CallbackContext.js'); + contextBridge.exposeInMainWorld('_cdvElectronIpc', { - exec: (success, error, serviceName, action, args) => { - return ipcRenderer.invoke('cdv-plugin-exec', serviceName, action, args) - .then( - success, - error - ); + exec: async (success, error, serviceName, action, args, callbackId) => { + ipcRenderer.on(callbackId, (event, result) => { + if (result.status === PluginResult.STATUS_OK) { + success(result.data); + } else if (result.status & PluginResult.STATUS_ERROR) { + error(result.data); + } else { + error(new Error('Unexpected plugin result status code')); + } + + if (!result.keepCallback) { + ipcRenderer.removeAllListeners(callbackId); + } + }); + try { + await ipcRenderer.invoke('cdv-plugin-exec', serviceName, action, args, callbackId); + } catch (exception) { + const message = "CHROME: Exception while invoking service action '" + serviceName + '.' + action + "'"; + console.error(message, exception); + error({ message, exception }); + } }, hasService: (serviceName) => cordova && - cordova.services && - cordova.services[serviceName] + cordova.services && + cordova.services[serviceName] }); diff --git a/cordova-js-src/exec.js b/cordova-js-src/exec.js index 835a8c4..d60eda3 100644 --- a/cordova-js-src/exec.js +++ b/cordova-js-src/exec.js @@ -39,9 +39,10 @@ const execProxy = require('cordova/exec/proxy'); * @param {String[]} [args] Zero or more arguments to pass to the method */ module.exports = function (success, fail, service, action, args) { + const callbackId = service + cordova.callbackId++; if (window._cdvElectronIpc.hasService(service)) { // Electron based plugin support - window._cdvElectronIpc.exec(success, fail, service, action, args); + window._cdvElectronIpc.exec(success, fail, service, action, args, callbackId); } else { // Fall back for browser based plugin support... const proxy = execProxy.get(service, action); @@ -49,8 +50,6 @@ module.exports = function (success, fail, service, action, args) { args = args || []; if (proxy) { - const callbackId = service + cordova.callbackId++; - if (typeof success === 'function' || typeof fail === 'function') { cordova.callbacks[callbackId] = { success: success, fail: fail }; } @@ -96,13 +95,14 @@ module.exports = function (success, fail, service, action, args) { }; proxy(onSuccess, onError, args); } catch (e) { - console.log('Exception calling native with command :: ' + service + ' :: ' + action + ' ::exception=' + e); + console.error(`Exception when calling fallback browser action '${action}' on service '${service}'`, e); } } else { - console.log('Error: exec proxy not found for :: ' + service + ' :: ' + action); + const message = `Could not call electron action '${action}' on service '${service}'; Service is not registered`; + console.error(message); if (typeof fail === 'function') { - fail('Missing Command Error'); + fail(message); } } } diff --git a/package.json b/package.json index 48b1285..e01dafc 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@cordova/eslint-config": "^4.0.0", "cordova-js": "^6.1.0", + "eslint": "^7.32.0", "jasmine": "^4.1.0", "nyc": "^15.1.0", "rewire": "^6.0.0" diff --git a/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/plugin.xml b/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/plugin.xml index 835911f..9d8cd3f 100644 --- a/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/plugin.xml +++ b/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/plugin.xml @@ -18,23 +18,28 @@ under the License. --> - - Sanple - Cordova Sample Plugin - Apache 2.0 - cordova,sample - - - - - - - - + + Sanple + Cordova Sample Plugin + Apache 2.0 + cordova,sample + + + + + + + + - - - + + + + + + + + + + diff --git a/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/src/browser/sample.js b/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/src/browser/sample.js new file mode 100644 index 0000000..fb7d441 --- /dev/null +++ b/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/src/browser/sample.js @@ -0,0 +1,8 @@ +module.exports = { + testLocation() { + return window.location; + }, + testEchoBrowser(args) { + return "BROWSER: echo1 is: " + args[1]; + } +} \ No newline at end of file diff --git a/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/src/electron/index.js b/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/src/electron/index.js index 774baa5..9fe6107 100644 --- a/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/src/electron/index.js +++ b/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/src/electron/index.js @@ -17,22 +17,59 @@ under the License. */ -const { system, osInfo } = require('systeminformation'); -const electron = require - -module.exports = { - getSampleInfo: async () => { - try { - const { model } = await system(); - const { platform } = await osInfo(); - - return { - model, - platform: platform === 'darwin' ? codename : distro, - electronVersion: process.versions.electron - }; - } catch (e) { - console.log(e); +class Sample { + testProcess(args, callbackContext) { + const process = require('process') + callbackContext.success(process.cwd()); + } + testMany(args, callbackContext) { + let i = 0; + const PluginResult = callbackContext.PluginResult; + const interval = setInterval(() => { + if (i++ < 3) { + const result = new PluginResult(PluginResult.STATUS_OK, i); + result.setKeepCallback(true); + callbackContext.sendPluginResult(result); + return; + } + callbackContext.success('ELECTRON: last many. This will arrive') + callbackContext.success('ELECTRON: > last many. This will not') + clearInterval(interval) + }, 1000); + } + testError(args, callbackContext) { + const PluginResult = callbackContext.PluginResult; + let result; + for (let i = 0; i < 3; i++) { + result = new PluginResult(PluginResult.STATUS_ERROR, "ELECTRON: multiple errors: " + i); + result.setKeepCallback(true) + callbackContext.sendPluginResult(result) } + callbackContext.error('ELECTRON: last error. This will arrive') + callbackContext.error('ELECTRON: > last error. This will not') + } + testEchoElectron(args, callbackContext) { + const message = 'ELECTRON: echo1 is ' + args[1] + console.log(message) + callbackContext.success(message); } -}; + testException(args, callbackContext) { + throw new Error('Exception in sample plugin') + } +} + +module.exports = function (action, args, callbackContext) { + const sample = new Sample(); + if (!sample[action]) { + return false; + } + console.log('ELECTRON/PLUGIN Sample: dispatching service action ' + action + '(' + args + ')') + sample[action](args, callbackContext) + return true; +} + +// module.exports = { +// testEchoElectron: function() { +// console.log('ECHO ELECTRON', arguments) +// } +// } \ No newline at end of file diff --git a/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/src/electron/package.json b/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/src/electron/package.json index 1fdb535..968b12c 100644 --- a/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/src/electron/package.json +++ b/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/src/electron/package.json @@ -15,6 +15,7 @@ "systeminformation": "^4.27.9" }, "cordova": { - "serviceName": "Sample" + "serviceName": "Sample", + "API3": false } } diff --git a/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/www/init.js b/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/www/init.js new file mode 100644 index 0000000..d229351 --- /dev/null +++ b/tests/spec/fixtures/test-app-with-electron-plugin/plugins/cordova-plugin-sample/www/init.js @@ -0,0 +1,56 @@ +/* + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + */ +document.addEventListener("deviceready", () => { + const tests = { + testProcess: null, + testLocation: null, + testMany: null, + testError: null, + testException: null, + testEchoElectron: ['echo', 'echoo', 'echooo'], + testEchoBrowser: ['echo', 'echoo', 'echooo'], + testUnknownAction: ['echo', 'echoo', 'echooo'] + } + for(let test in tests) { + cordova.exec( + data => { + console.log(`${test} success callback`, data) + }, + error => { + console.error(`${test} error callback`, error) + }, + 'Sample', + test, + tests[test] + ); + } + + cordova.exec( + data => { + console.log(`UnkonwService success callback`, data) + }, + error => { + console.error(`UnkonwService error callback`, error) + }, + 'UnknownService', + 'irrelevant' + ); +});