From cc975d221d0d0095bddad36575e7889e7970990e Mon Sep 17 00:00:00 2001 From: Randall Morey Date: Mon, 9 Nov 2020 15:48:54 -0500 Subject: [PATCH] =?UTF-8?q?chore:=20=F0=9F=A4=96=20wire=20up=20postMessage?= =?UTF-8?q?=20ipc=20proxy=20and=20Ember=20ipc=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/desktop/app/routes/index.js | 22 ++++ ui/desktop/app/services/ipc.js | 107 ++++++++++++++++++ ui/desktop/app/templates/index.hbs | 1 + .../electron-app/src/handle-file-urls.js | 42 ------- ui/desktop/electron-app/src/handlers.js | 17 +++ ui/desktop/electron-app/src/index.js | 3 +- ui/desktop/electron-app/src/preload.js | 25 +++- ui/desktop/tests/unit/routes/index-test.js | 11 ++ ui/desktop/tests/unit/services/ipc-test.js | 12 ++ 9 files changed, 196 insertions(+), 44 deletions(-) create mode 100644 ui/desktop/app/routes/index.js create mode 100644 ui/desktop/app/services/ipc.js create mode 100644 ui/desktop/app/templates/index.hbs delete mode 100644 ui/desktop/electron-app/src/handle-file-urls.js create mode 100644 ui/desktop/electron-app/src/handlers.js create mode 100644 ui/desktop/tests/unit/routes/index-test.js create mode 100644 ui/desktop/tests/unit/services/ipc-test.js diff --git a/ui/desktop/app/routes/index.js b/ui/desktop/app/routes/index.js new file mode 100644 index 000000000..f51d7f5d2 --- /dev/null +++ b/ui/desktop/app/routes/index.js @@ -0,0 +1,22 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class IndexRoute extends Route { + + // =services + + @service ipc; + + // =methods + + /** + * Invokes an IPC method and returns the result. + * @return {Promise} + */ + model() { + return this.ipc.invoke('getFoobars', { + id: '1' + }); + } + +} diff --git a/ui/desktop/app/services/ipc.js b/ui/desktop/app/services/ipc.js new file mode 100644 index 000000000..d672ce818 --- /dev/null +++ b/ui/desktop/app/services/ipc.js @@ -0,0 +1,107 @@ +import Service from '@ember/service'; +import { Promise } from 'rsvp'; + +/** + * An IPC request is a promise-like object that invokes a method on the main + * process and resolves to its response. + * + * Since the renderer cannot call into main or access Node modules directly, + * it uses the window's `postMessage` interface. Messages posted in this manner + * are proxied by `preload.js` to the main process. This ensures that main + * and renderer remain isolated and frontend code does not rely on the presence + * of a main process. + */ +class IPCRequest { + + // =attributes + + /** + * @type {?MessageChannel} + * @private + */ + #channel = null; + + /** + * @type {?Promise} + * @private + */ + #promise = null; + + // =methods + + /** + * Posts a message to the window object, along with a MessagePort + * with which to receive a response. An internal promise is generated which + * resolves to the response. + * @param {string} method + * @param {object} payload + * @param {?string} origin + */ + constructor(method, payload, origin='serve://boundary') { + this.method = method; + this.payload = payload; + this.origin = origin; + this.#channel = new MessageChannel(); + this.#promise = new Promise((resolve/*, reject*/) => { + this.#channel.port1.onmessage = (event) => { + this.close(); + resolve(event.data); + } + }); + window.postMessage({ + method: this.method, + payload: this.payload + }, origin, [ this.#channel.port2 ]); + } + + /** + * Closes the message ports and drops the channel reference. + * This is called upon receiving a response for cleanup purposes. + */ + close() { + this.#channel.port1.close(); + this.#channel.port2.close(); + this.#channel = null; + } + + /** + * Proxies to the underlying promise. + */ + then() { + return this.#promise.then(...arguments); + } + + /** + * Proxies to the underlying promise. + */ + catch() { + return this.#promise.catch(...arguments); + } + + /** + * Proxies to the underlying promise. + */ + finally() { + return this.#promise.finally(...arguments); + } + +} + +/** + * A simple factory for generating IPCRequests in Ember. + */ +export default class IpcService extends Service { + + // =methods + + /** + * Creates a new IPC request and returns it. + * @param {string} method + * @param {object} payload + * @return {IPCRequest} + */ + invoke(method, payload) { + return new IPCRequest(method, payload); + } + +} diff --git a/ui/desktop/app/templates/index.hbs b/ui/desktop/app/templates/index.hbs new file mode 100644 index 000000000..6bc86217b --- /dev/null +++ b/ui/desktop/app/templates/index.hbs @@ -0,0 +1 @@ +{{@model.message}} diff --git a/ui/desktop/electron-app/src/handle-file-urls.js b/ui/desktop/electron-app/src/handle-file-urls.js deleted file mode 100644 index f6eeb1707..000000000 --- a/ui/desktop/electron-app/src/handle-file-urls.js +++ /dev/null @@ -1,42 +0,0 @@ -const { fileURLToPath } = require('url'); -const path = require('path'); -const fs = require('fs'); -const { promisify } = require('util'); - -const access = promisify(fs.access); - -// -// Patch asset loading -- Ember apps use absolute paths to reference their -// assets, e.g. ``. When the current URL is a `file:` -// URL, that ends up resolving to the absolute filesystem path `/images/foo.jpg` -// rather than being relative to the root of the Ember app. So, we intercept -// `file:` URL request and look to see if they point to an asset when -// interpreted as being relative to the root of the Ember app. If so, we return -// that path, and if not we leave them as-is, as their absolute path. -// -async function getAssetPath(emberAppDir, url) { - let urlPath = fileURLToPath(url); - // Get the root of the path -- should be '/' on MacOS or something like - // 'C:\' on Windows - let { root } = path.parse(urlPath); - // Get the relative path from the root to the full path - let relPath = path.relative(root, urlPath); - // Join the relative path with the Ember app directory - let appPath = path.join(emberAppDir, relPath); - try { - await access(appPath); - return appPath; - } catch (e) { - return urlPath; - } -} - -module.exports = function handleFileURLs(emberAppDir) { - const { protocol } = require('electron'); - - protocol.interceptFileProtocol('file', async ({ url }, callback) => { - callback(await getAssetPath(emberAppDir, url)); - }); -}; - -module.exports.getAssetPath = getAssetPath; diff --git a/ui/desktop/electron-app/src/handlers.js b/ui/desktop/electron-app/src/handlers.js new file mode 100644 index 000000000..f5bcf175b --- /dev/null +++ b/ui/desktop/electron-app/src/handlers.js @@ -0,0 +1,17 @@ +const { ipcMain } = require('electron'); +const isDev = require('electron-is-dev'); + +const log = (method, request) => { + if (isDev) console.log(`[ipc-main](${method}): `, request); +} + + +/** + * A demonstration method `getFoobars` which returns an object with a message. + */ +ipcMain.handle('getFoobars', async (event, request) => { + log('getFoobars', request); + return { + message: 'this is a response from an ipcMain handler' + }; +}); diff --git a/ui/desktop/electron-app/src/index.js b/ui/desktop/electron-app/src/index.js index c4c77dafc..c4a1a50e0 100644 --- a/ui/desktop/electron-app/src/index.js +++ b/ui/desktop/electron-app/src/index.js @@ -1,7 +1,8 @@ /* eslint-disable no-console */ const path = require('path'); const { default: installExtension, EMBER_INSPECTOR } = require('electron-devtools-installer'); -const { session, app, protocol, BrowserWindow } = require('electron'); +const { session, app, protocol, BrowserWindow, ipcMain } = require('electron'); +require('./handlers.js'); const isDev = require('electron-is-dev'); diff --git a/ui/desktop/electron-app/src/preload.js b/ui/desktop/electron-app/src/preload.js index a63bbf48c..8c9757b0d 100644 --- a/ui/desktop/electron-app/src/preload.js +++ b/ui/desktop/electron-app/src/preload.js @@ -1 +1,24 @@ -// nothing here yet +const { ipcRenderer } = require('electron'); +const emberAppOrigin = 'serve://boundary'; + +process.once('loaded', () => { + + /** + * Ember-land has no access to the renderer or node modules, and thus + * cannot call into the main process or make invocations directly. + * In order to communicate with the main process, the Ember app uses + * the `postMessage` interface to send messages to its browser window. + * These messages include message ports with which to receive responses from + * the main process. + * + * This preload script simply wires up the forwarding of + * messages received on the window and responses received from main. + */ + window.addEventListener('message', async function (event) { + if (event.origin !== emberAppOrigin) return; + const response = + await ipcRenderer.invoke(event.data.method, event.data.payload); + event.ports[0].postMessage(response); + }); + +}); diff --git a/ui/desktop/tests/unit/routes/index-test.js b/ui/desktop/tests/unit/routes/index-test.js new file mode 100644 index 000000000..eaaac17bc --- /dev/null +++ b/ui/desktop/tests/unit/routes/index-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | index', function(hooks) { + setupTest(hooks); + + test('it exists', function(assert) { + let route = this.owner.lookup('route:index'); + assert.ok(route); + }); +}); diff --git a/ui/desktop/tests/unit/services/ipc-test.js b/ui/desktop/tests/unit/services/ipc-test.js new file mode 100644 index 000000000..d0edd75aa --- /dev/null +++ b/ui/desktop/tests/unit/services/ipc-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Service | ipc', function(hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function(assert) { + let service = this.owner.lookup('service:ipc'); + assert.ok(service); + }); +});