diff --git a/blueprints/ember-electron/index.js b/blueprints/ember-electron/index.js index f31c9570..0d3a1c8e 100644 --- a/blueprints/ember-electron/index.js +++ b/blueprints/ember-electron/index.js @@ -7,9 +7,9 @@ const fs = require('fs'); const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); const YAWN = require('yawn-yaml/cjs'); -const SilentError = require('silent-error'); const { upgradingUrl, + routingAndAssetLoadingUrl, ciUrl } = require('../../lib/utils/documentation-urls'); @@ -24,17 +24,7 @@ module.exports = class EmberElectronBlueprint extends Blueprint { return entityName; } - beforeInstall() { - if (fs.existsSync(electronProjectPath)) { - return Promise.reject( - new SilentError([ - `Cannot create electron-forge project at './${electronProjectPath}'`, - `because a file or directory already exists there. Please remove/rename`, - `it and run the blueprint again: 'ember generate ember-electron'.` - ].join(' ')) - ); - } - + async afterInstall() { if (fs.existsSync('ember-electron')) { this.ui.writeLine(chalk.yellow([ `\n'ember-electron' directory detected -- this looks like an ember-electron`, @@ -42,13 +32,54 @@ module.exports = class EmberElectronBlueprint extends Blueprint { `should read the upgrading documentation at ${upgradingUrl}.\n` ].join(' '))); } - } - async afterInstall() { + await this.updateEnvironmentConfig(); await this.updateTravisYml(); await this.updateEslintIgnore(); await this.updateEslintRc(); - await this.createElectronProject(); + + if (!fs.existsSync(electronProjectPath)) { + await this.createElectronProject(); + } else { + this.ui.writeLine(chalk.yellow([ + `An electron-forge project already exists at './${electronProjectPath}'.`, + `If you're running the blueprint manually as part of an ember-electron`, + `upgrade, make sure to check for upgrade instructions relevant to your`, + `version upgrade at ${upgradingUrl}.\n` + ].join(' '))); + } + } + + async updateEnvironmentConfig() { + this.ui.writeLine(chalk.green('Updating config/environment.js')); + + let contents = (await readFile('config/environment.js')).toString(); + + const rootURLRegex = /(\srootURL\s*:)/; + if (rootURLRegex.test(contents)) { + contents = contents.replace(rootURLRegex, `$1 process.env.EMBER_CLI_ELECTRON ? '' :`); + } else { + this.ui.writeLine(chalk.yellow([ + `\nUnable to update rootURL setting to`, + `\`process.env.EMBER_CLI_ELECTRON ? '' : \`,`, + `which is needed for your Ember app to load assets under Electron.`, + `See ${routingAndAssetLoadingUrl} for more information.` + ].join(' '))); + } + + const locationTypeRegex = /(\slocationType\s*:)/; + if (locationTypeRegex.test(contents)) { + contents = contents.replace(locationTypeRegex, `$1 process.env.EMBER_CLI_ELECTRON ? 'hash' :`); + } else { + this.ui.writeLine(chalk.yellow([ + `\nUnable to update locationType setting to`, + `\`process.env.EMBER_CLI_ELECTRON ? 'hash' : \`,`, + `which is needed for your Ember app's routing to work under Electron.`, + `See ${routingAndAssetLoadingUrl} for more information.` + ].join(' '))); + } + + await writeFile('config/environment.js', contents); } async updateTravisYml() { diff --git a/forge/files/src/handle-file-urls.js b/forge/files/src/handle-file-urls.js new file mode 100644 index 00000000..58ee299b --- /dev/null +++ b/forge/files/src/handle-file-urls.js @@ -0,0 +1,29 @@ +const { protocol } = require('electron'); +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. +// +module.exports = function handleFileURLs(emberAppDir) { + protocol.interceptFileProtocol('file', async ({ url }, callback) => { + let urlPath = fileURLToPath(url); + let appPath = path.join(emberAppDir, urlPath); + try { + await access(appPath); + callback(appPath); + } catch (e) { + callback(urlPath); + } + }); +}; \ No newline at end of file diff --git a/forge/files/src/index.js b/forge/files/src/index.js index a3c22c33..0a55aca1 100644 --- a/forge/files/src/index.js +++ b/forge/files/src/index.js @@ -1,17 +1,16 @@ /* eslint-disable no-console */ const { default: installExtension, EMBER_INSPECTOR } = require('electron-devtools-installer'); +const { pathToFileURL } = require('url'); const { app, BrowserWindow } = require('electron'); const path = require('path'); const isDev = require('electron-is-dev'); -const setupServeProtocol = require('./setup-serve-protocol'); +const handleFileUrls = require('./handle-file-urls'); const emberAppDir = path.resolve(__dirname, '..', 'ember-dist'); -const emberAppURL = 'serve://dist'; +const emberAppURL = pathToFileURL(path.join(emberAppDir, 'index.html')).toString(); let mainWindow = null; -setupServeProtocol(emberAppDir); - // Uncomment the lines below to enable Electron's crash reporter // For more information, see http://electron.atom.io/docs/api/crash-reporter/ // electron.crashReporter.start({ @@ -32,15 +31,17 @@ app.on('ready', async () => { try { require('devtron').install(); } catch (err) { - console.log('Failed to install Devtrom: ', err); + console.log('Failed to install Devtron: ', err); } try { await installExtension(EMBER_INSPECTOR); } catch (err) { - console.log('Failed to install Ember Inspector: ', err) + console.log('Failed to install Ember Inspector: ', err); } } + await handleFileUrls(emberAppDir); + mainWindow = new BrowserWindow({ width: 800, height: 600, diff --git a/forge/files/src/setup-serve-protocol.js b/forge/files/src/setup-serve-protocol.js deleted file mode 100644 index c9d33af3..00000000 --- a/forge/files/src/setup-serve-protocol.js +++ /dev/null @@ -1,25 +0,0 @@ -const { app, protocol } = require('electron'); -const protocolServe = require('electron-protocol-serve'); -const path = require('path'); - -module.exports = function setupServeProtocol(emberAppDir) { - if (typeof protocol.registerSchemesAsPrivileged === 'function') { - protocol.registerSchemesAsPrivileged([{ - scheme: 'serve', - privileges: { - secure: true, - standard: true, - }, - }]); - } - else { - protocol.registerStandardSchemes(['serve'], { secure: true }); - } - protocolServe({ - cwd: emberAppDir, - app, - protocol, - }); - - process.env.ELECTRON_PROTOCOL_SERVE_INDEX = path.join(emberAppDir, 'index.html'); -} diff --git a/forge/files/tests/index.js b/forge/files/tests/index.js index e5e8d61e..e91dd823 100644 --- a/forge/files/tests/index.js +++ b/forge/files/tests/index.js @@ -1,27 +1,26 @@ const { default: installExtension, EMBER_INSPECTOR } = require('electron-devtools-installer'); const path = require('path'); const { app } = require('electron'); -const setupServeProtocol = require('../src/setup-serve-protocol'); +const handleFileUrls = require('../src/handle-file-urls'); const { setupTestem, openTestWindow } = require('ember-electron/lib/test-support'); const emberAppDir = path.resolve(__dirname, '..', 'ember-test'); -setupServeProtocol(emberAppDir); - app.on('ready', async function onReady() { try { require('devtron').install(); } catch (err) { - console.log('Failed to install Devtrom: ', err); + console.log('Failed to install Devtron: ', err); } try { await installExtension(EMBER_INSPECTOR); } catch (err) { - console.log('Failed to install Ember Inspector: ', err) + console.log('Failed to install Ember Inspector: ', err); } + await handleFileUrls(emberAppDir); setupTestem(); - openTestWindow(); + openTestWindow(emberAppDir); }); app.on('window-all-closed', function onWindowAllClosed() { diff --git a/forge/template.js b/forge/template.js index a34f289e..ad8b11b5 100644 --- a/forge/template.js +++ b/forge/template.js @@ -68,7 +68,6 @@ class EmberElectronTemplates extends BaseTemplate { return [ 'electron-devtools-installer', 'electron-is-dev', - 'electron-protocol-serve' ]; } diff --git a/index.js b/index.js index c8ccf526..e510fff9 100644 --- a/index.js +++ b/index.js @@ -44,9 +44,20 @@ module.exports = { node = replace(node, { files: [ 'tests/index.html' ], pattern: { - match: /src="[^"]*testem\.js"/, - replacement: 'src="http://testemserver/testem.js"', - }, + match: /(src|href)="([^"]+)"/g, + replacement(match, attr, value) { + if (value.endsWith('testem.js')) { + // Replace testem script source so our test main process code can + // recognize and redirect requests to the testem server + value = 'http://testemserver/testem.js'; + } else if (!value.includes(':/')) { + // Since we're loading from the filesystem, asset URLs in + // tests/index.html need to be prepended with '../' + value = `../${value}`; + } + return `${attr}="${value}"`; + } + } }); } return node; diff --git a/lib/resources/shim-head.js b/lib/resources/shim-head.js index 14c47fde..9f26911b 100644 --- a/lib/resources/shim-head.js +++ b/lib/resources/shim-head.js @@ -2,40 +2,6 @@ ((win) => { win.ELECTRON = true; - // On linux the renderer process doesn't inherit the main process' - // environment, so we need to fall back to using the remote module. - let serveIndex; - // If nodeIntegration is disabled, this will throw and serveIndex will remain - // undefined, which is fine because we only need it to fix module search paths - // that aren't relevant if node integration is disabled. - try { - serveIndex = process.env.ELECTRON_PROTOCOL_SERVE_INDEX || require('electron').remote.process.env.ELECTRON_PROTOCOL_SERVE_INDEX; - } catch (e) { - // When nodeIntegration is false we expect process to be undefined. Don't swallow the exception if something else is wrong - if (e.message !== "process is not defined") { - throw e; - } - } - - if (serveIndex && window.location.protocol !== 'file:') { - // Using electron-protocol-serve to load index.html via a 'serve:' URL - // prevents electron's renderer/init.js from setting the module search - // paths correctly. So this is basically a copy of that code, but using an - // environment variable set by electron-protocol-serve containing the - // filesystem path to index.html instead of window.location. - const path = require('path'); - const Module = require('module'); - - global.__filename = path.normalize(serveIndex); - global.__dirname = path.dirname(serveIndex); - - // Set module's filename so relative require can work as expected. - module.filename = global.__filename; - - // Also search for module under the html file. - module.paths = module.paths.concat(Module._nodeModulePaths(global.__dirname)); - } - // Store electrons node environment injections for later usage win.moduleNode = win.module; win.processNode = win.process; diff --git a/lib/test-support/index.js b/lib/test-support/index.js index c3653038..18cf1871 100644 --- a/lib/test-support/index.js +++ b/lib/test-support/index.js @@ -1,8 +1,8 @@ const { session, BrowserWindow } = require('electron'); -const { URL } = require('url'); +const { URL, pathToFileURL } = require('url'); // These are the command-line arguments passed to us by test-runner.js -const [ , , , testPageURL, testemUrl, testemId ] = process.argv; +const [ , , , testPageURL, testemURL, testemId ] = process.argv; // Set up communication with the testem server // @@ -12,7 +12,7 @@ const [ , , , testPageURL, testemUrl, testemId ] = process.argv; // actual testem server, so we can intercept any requests to http://testemserver // and redirect them to the actual testem server. function setupTestem() { - let { host: testemHost } = new URL(testemUrl); + let { host: testemHost } = new URL(testemURL); session.defaultSession.webRequest.onBeforeRequest((details, callback) => { let urlObj = new URL(details.url); @@ -27,8 +27,9 @@ function setupTestem() { }); } -// Open the test window -function openTestWindow() { +// Open the test window. `emberAppDir` is the path to the directory containing +// the built Ember app that we are testing +function openTestWindow(emberAppDir) { let window = new BrowserWindow({ width: 800, height: 600, @@ -40,8 +41,9 @@ function openTestWindow() { delete window.module; - // Combine the test page URL with our root serve://dist URL - let url = new URL(testPageURL, 'serve://dist'); + // Convert the emberAppDir to a file URL and append a '/' so when it's joined + // with the testPageURL the last path component isn't dropped + let url = new URL(testPageURL, `${pathToFileURL(emberAppDir)}/`); // We need to set this query param so the script in shim-test-head.js can // expose it to testem to use when communicating with the testem server @@ -60,4 +62,4 @@ function openTestWindow() { module.exports = { setupTestem, openTestWindow -} \ No newline at end of file +} diff --git a/lib/utils/documentation-urls.js b/lib/utils/documentation-urls.js index 16069b05..062db2ff 100644 --- a/lib/utils/documentation-urls.js +++ b/lib/utils/documentation-urls.js @@ -6,8 +6,12 @@ const upgradingUrl = `${guidesUrl}upgrading`; const ciUrl = `${guidesUrl}ci`; const structureUrl = `${guidesUrl}structure` +const faqUrl = `${baseUrl}faq/`; +const routingAndAssetLoadingUrl = `${faqUrl}routing-and-asset-loading` + module.exports = { upgradingUrl, ciUrl, - structureUrl + structureUrl, + routingAndAssetLoadingUrl }; \ No newline at end of file diff --git a/node-tests/fixtures/config-environment/environment.js b/node-tests/fixtures/config-environment/environment.js new file mode 100644 index 00000000..b1630eba --- /dev/null +++ b/node-tests/fixtures/config-environment/environment.js @@ -0,0 +1,51 @@ +'use strict'; + +module.exports = function(environment) { + let ENV = { + modulePrefix: 'test-app', + environment, + rootURL: '/', + locationType: 'auto', + EmberENV: { + FEATURES: { + // Here you can enable experimental features on an ember canary build + // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true + }, + EXTEND_PROTOTYPES: { + // Prevent Ember Data from overriding Date.parse. + Date: false + } + }, + + APP: { + // Here you can pass flags/options to your application instance + // when it is created + } + }; + + if (environment === 'development') { + // ENV.APP.LOG_RESOLVER = true; + // ENV.APP.LOG_ACTIVE_GENERATION = true; + // ENV.APP.LOG_TRANSITIONS = true; + // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; + // ENV.APP.LOG_VIEW_LOOKUPS = true; + } + + if (environment === 'test') { + // Testem prefers this... + ENV.locationType = 'none'; + + // keep test console output quieter + ENV.APP.LOG_ACTIVE_GENERATION = false; + ENV.APP.LOG_VIEW_LOOKUPS = false; + + ENV.APP.rootElement = '#ember-testing'; + ENV.APP.autoboot = false; + } + + if (environment === 'production') { + // here you can enable a production-specific feature + } + + return ENV; +}; diff --git a/node-tests/unit/blueprint-test.js b/node-tests/unit/blueprint-test.js index aad33129..15f97168 100644 --- a/node-tests/unit/blueprint-test.js +++ b/node-tests/unit/blueprint-test.js @@ -3,7 +3,8 @@ const { expect } = require('chai'); const tmp = require('tmp'); const { readFileSync, - copyFileSync + copyFileSync, + mkdirSync } = require('fs'); const path = require('path'); @@ -25,16 +26,54 @@ describe('blueprint', function() { process.chdir(rootDir); }); - function normalizeYaml(content) { - let lines = content.split('\n'); - // filter out empty lines because we don't care about empty-line differences - lines = lines.filter((line) => line.trim()); - // remove \r's in case we're on windows and they were added - lines = lines.map((line) => line.replace('\r', '')); - return lines.join('\n'); - } + describe('update config/environment.js', function() { + let oldEnv; + beforeEach(function() { + oldEnv = process.env; + process.env = {}; + }); + + afterEach(function() { + process.env = oldEnv; + }) + + async function getEnv() { + let environmentJsFixture = path.join(__dirname, '..', 'fixtures', 'config-environment', 'environment.js'); + let environmentJs = path.join(process.cwd(), 'config', 'environment.js'); + + mkdirSync('config'); + copyFileSync(environmentJsFixture, environmentJs); + + await blueprint.updateEnvironmentConfig(); + + let factory = require(environmentJs); + return factory(); + } + + it('non-electron build', async function() { + let ENV = await getEnv(); + expect(ENV.rootURL).to.equal('/'); + expect(ENV.locationType).to.equal('auto'); + }); + + it('electron build', async function() { + process.env.EMBER_CLI_ELECTRON = '1'; + let ENV = await getEnv(); + expect(ENV.rootURL).to.equal(''); + expect(ENV.locationType).to.equal('hash'); + }) + }); + + describe('update travis.yml', function() { + function normalizeYaml(content) { + let lines = content.split('\n'); + // filter out empty lines because we don't care about empty-line differences + lines = lines.filter((line) => line.trim()); + // remove \r's in case we're on windows and they were added + lines = lines.map((line) => line.replace('\r', '')); + return lines.join('\n'); + } - describe('update travis.yml', async function() { async function runTest(fixtureName) { let fixtureDir = path.join(__dirname, '..', 'fixtures', 'travis-yml', fixtureName); diff --git a/package.json b/package.json index a7c10131..552e10f3 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "broccoli-string-replace": "^0.1.2", "chalk": "^4.0.0", "debug": "^4.1.1", - "electron-protocol-serve": "^1.4.0", "ember-cli-babel": "^7.19.0", "ncp": "^2.0.0", "portfinder": "^1.0.25", diff --git a/tests/dummy/app/router.js b/tests/dummy/app/router.js index 78a7f5d8..60c7ebfa 100644 --- a/tests/dummy/app/router.js +++ b/tests/dummy/app/router.js @@ -10,6 +10,7 @@ Router.map(function() { docsRoute(this, function() { this.route('faq', function() { this.route('common-issues'); + this.route('routing-and-asset-loading'); this.route('security'); }); diff --git a/tests/dummy/app/templates/docs.hbs b/tests/dummy/app/templates/docs.hbs index 2cc4bcd9..1424203b 100644 --- a/tests/dummy/app/templates/docs.hbs +++ b/tests/dummy/app/templates/docs.hbs @@ -12,6 +12,7 @@ {{nav.section "FAQ"}} {{nav.item "Common Issues" "docs.faq.common-issues"}} + {{nav.item "Routing and Asset Loading" "docs.faq.routing-and-asset-loading"}} {{nav.item "Security" "docs.faq.security"}} diff --git a/tests/dummy/app/templates/docs/faq/common-issues.md b/tests/dummy/app/templates/docs/faq/common-issues.md index 229ea822..a119fade 100644 --- a/tests/dummy/app/templates/docs/faq/common-issues.md +++ b/tests/dummy/app/templates/docs/faq/common-issues.md @@ -1,37 +1,5 @@ # Common Issues -## What is electron-protocol-serve and why do I need this? - -`electron-protocol-serve` is a module created for Ember-Electron, mimicking the behavior Ember expects from a server. -This allows Ember-Electron to load an Ember application without modifying. - -This also mitigates problems existing with XHR and the `file://` protocol, allows the application to have absolute paths, use `location: auto` and resolve just as if served from a static webserver. - -However this also means that if you want to access files on your local file system, they must be addressed using the the `file://` protocol. To access local files, the `BrowserWindow` has to be configured to load local files like this: - -```js -mainWindow = new BrowserWindow({ - webPreferences: { - webSecurity: false, - allowRunningInsecureContent: false, - } -}); -``` - -This disables the same-origin policy, so this should done only after careful evaluation. - -If you need to read a local file, using the [`FileReader`](https://developer.mozilla.org/en-US/docs/Web/API/FileReader) API might be another good option that works without changing the `webPreferences`. - - -## When I make AJAX requests they fail due to CORS problems (`Access-Control-Allow-Origin` header) - -This is also a side-effect of using `electron-protocol-serve`. One solution is to add an `Access-Control-Allow-Origin` header to the endpoint you are accessing, but that requires that you have control over that endpoint and that this fits with the security model of the server. Otherwise, this can be solved by setting `webPreferences` when opening your `BrowserWindow` as described above. However, this is only safe if you know you will never be running untrusted code in the `BrowserWindow` or allowing the `BrowserWindow` to navigate to a non-local URL. - -The reason this is necessary has to do with Electron's configuration of WebKit. Electron sets the [allow_universal_access_from_file_urls](https://webkitgtk.org/reference/webkit2gtk/stable/WebKitSettings.html#WebKitSettings--allow-universal-access-from-file-urls), which disables the same-origin policy for Javascript loaded from `file:` URLs. However, since `electron-protocol-serve` causes us to load files from the custom `serve:` protocol, WebKit doesn't know that they are local files and enforces the same-origin policy by default, so we have to disable it using `webPreferences`. - -You should carefully consider if doing this is safe, as it potentially opens up new security holes that vanilla Electron using `file:` URLs does not. The difference is that the `allow_universal_access_from_file_urls` option evaluates the situation on each request, and if the current origin isn't a `file:` URL, it will enforce the same-origin policy. When using `webPreferences` you are disabling the same-origin policy for the entire window, so if the window somehow navigates to an `http:` URL, the same-origin policy will still be disabled. So be sure to consider your circumstances carefully from a security standpoint when doing this, and ensure that if your window needs to display remote or untrusted content, it does so in a sandboxed `WebView`. - - ## I can't open the Ember Inspector in the packaged app This is known and desired behavior of Electron. The Developer Tools are still accessible programmatically or via a shortcut (see below). However, the packages needed for [DevTron](https://github.com/electron/devtron) and the [Ember Inspector](https://github.com/emberjs/ember-inspector) addons are not currently bundled with production builds. diff --git a/tests/dummy/app/templates/docs/faq/routing-and-asset-loading.md b/tests/dummy/app/templates/docs/faq/routing-and-asset-loading.md new file mode 100644 index 00000000..181ba407 --- /dev/null +++ b/tests/dummy/app/templates/docs/faq/routing-and-asset-loading.md @@ -0,0 +1,101 @@ +# Routing and Asset Loading + +This is a bit of a deep dive into how we make Ember's routing and asset loading work properly when running in an Electron application, along with a discussion of some possible alternatives. This all stems from the fact that in an Electron application we load the Ember app using a `file:` URL rather than an `http:` URL. + +## Routing + +Let's say our app uses the default `history` (or `auto`) location type, and has the following routes: + +```javascript +Router.map(function() { + this.route('my-route'); +}); +``` + +and we deploy it to `http://my-app.com`. This means that the URL `http://my-app.com/` corresponds to the `index` route, and `http://my-app.com/my-route` corresponds to the `my-route` route. The way this is achieved is that whatever web server is serving the Ember app is typically configured to return the Ember app's `index.html` for any requests to `http://my-app.com/`, and `index.html` loads all the Javascript for the application, including Ember itself. Then Ember's routing code looks at the URL path and activates the corresponding route. So the web server combined with Ember's `history` location/routing code creates an abstraction layer that interprets logical locations, in the form of the URL path, resolves them all to the same `index.html` file, but in a way that exposes that logical location to Ember so it can activate the correct route. + +When running an Electron app, we serve the files out of the filesystem using a `file:` URL, which doesn't give us the flexibility of a web server -- the URL path cannot be a logical location, but must be a filesystem path that points directly to `index.html`. For example, when running a development build, the path might be `file:///Users/me/projects/my-app/electron-app/ember-dist/index.html`. We can't use the URL path to specify the Ember route because telling the browser to load `file:///Users/me/projects/my-app/electron-app/ember-dist/index.html/my-route` will not work -- it will look for a file named `my-route` inside a directory named `index.html`, and won't find it. + +So we have to go back to an older method of specifying logical locations, which is to use the URL hash. Fortunately, Ember supports this via the [HashLocation](https://api.emberjs.com/ember/release/classes/HashLocation) since that was the only option in older browsers. Since URL hashes don't affect how `file:` URLs are resolved to paths, we can tell the browser to load `file:///Users/me/projects/my-app/electron-app/ember-dist/index.html#/my-route`, and it will load the `index.html` and then Ember can resolve the route using the path contained in the URL hash. + +This is why `ember-electron`'s blueprint configures the application's `config/environment.js` with the following entry: + +```javascript +locationType: process.env.EMBER_CLI_ELECTRON ? 'hash' : 'auto', +``` + +This way when building for Electron, the app will be configured to use the hash location, and if the app is also built for a browser, it will use the default auto location (which detects and chooses a location type based on browser capabilities). + +## Asset loading + +Ember apps typically load their assets using relative URLs with absolute paths, e.g. `` or ``. The browser combines these with the current location to generate the URL from which to load the asset. So if our current URL is `http://my-app.com`, our example assets URLs would resolve to `http://my-app.com/assets/vendor.js` and `http://my-app.com/img/logo.jpg`. Even if our current URL is `http://my-app.com/my-route`, they will resolve to those same URLs, because of the fact that the asset URL paths are absolute, i.e. start with `/`, so when combined with the current URL, the asset path replaces the URL path, rather than being appended to it. + +With `file:` URLs, though, this won't work because, following those same rules, combining `file:///Users/me/projects/my-app/electron-app/ember-dist/index.html` with `/assets/vendor.js` will result in `file:///assets/vendor.js`, not `file:///Users/me/projects/my-app/electron-app/ember-dist/assets/vendor.js`, which is where `vendor.js` actually lives. HTML loaded from the filesystem typically uses relative URLs to get around this, i.e. if we had `` or `` (note that there are no leading `/`s), then the paths would be interpreted as relative to the current directory, and would resolve to the correct `file:` URLs. + +### Javascript and CSS + +For `vendor.js` and other scripts and CSS loaded out of `index.html`, we have a nice solution for making the URL paths relative. The Ember app's `index.html` specifies them using a `rootURL` template parameter, e.g. ``, which is resolved during the Ember build. The default `rootURL` for Ember apps is `/`, so it would end up being ``. But we can set the `rootURL` to `''`, causing it to end up being `` -- a relative path, which is exactly what we want. This is why `ember-electron`'s blueprint configures the application's `config/environment.js` with the following: + +```javascript +rootURL: process.env.EMBER_CLI_ELECTRON ? '' : '/', +``` + +There is an additional wrinkle when running tests. Since Ember puts the `index.html` used for tests at `tests/index.html`, it actually needs assets paths that look like `../assets/vendor.js`. However, `ember-cli` ([clean-base-url](https://github.com/ember-cli/clean-base-url) actually) forces a non-empty `rootURL` to start with `/`, so `ember-electron` uses the broccoli pipeline to modify these URLs in `tests/index.html` to start with `../`. + +### Other assets (images, fonts, etc.) + +Unfortunately, this approach will not work for images and other such assets that don't start with `rootURL`, like our `` example. To solve this case, we do a little trickery in the main process. Electron allows us to intercept requests from the browser, so we use [protocol.interceptFileProtocol](https://www.electronjs.org/docs/api/protocol#protocolinterceptfileprotocolscheme-handler-completion) to intercept requests for `file:` URLs, look at their paths, and determine if they look like asset paths. To accomplish this, `ember-electron`'s blueprint creates a `electron-app/src/handle-file-urls.js` that sets this up, and calls into it from the main process code -- `electron-app/src/index.js` and `electron-app/tests/index.js`. + +This request intercepting code extracts the requested URL path, appends it to the path to the root of the Ember app, and then looks to see if the result is the path to a file inside the Ember app. If so, it resolves the request to that file. If not, it resolves it to the full requested (absolute) path. + +This is not perfect -- there is a concievable case where it could do the wrong thing, which is if the URL path pointed to a file when interpreted either as absolute or relative to the Ember app. For example, if you put `images/foo.jpg` in your Ember app's `public` directory, then a request for `/images/foo.jpg` would resolve to it. If for some reason the application were trying to load `/images/foo.jpg` from the root of the filesystem (i.e. you have a top-level directory called `images` and the app expects it to contain `foo.jpg`), it would resolve to the one inside the Ember app, and the application would have no way to load the one at the root of the filesystem. This seems like a very unlikely use case, though -- applications that load files from the filesystem generally do it through node APIs, not in-browser requests. + +### Source maps + +One might observe that the solution for images, fonts, etc., could also apply to the Javascript and CSS, and wonder why we need both solutions. The answer is source maps. Unfortunately when the Chromium developer tools load source maps, it is not intercept-able by the Electron `protocol` module, and the developer tools are not aware of any intercepting that happens in the main process. So if we left the leading `/` on the script tags, e.g. ``, then when the developer tools sees the `sourceMappingURL=vendor.map` at the end of `vendor.js` it will treat `vendor.map` as a path that is relative to where it thinks the script was loaded from, i.e. `/assets/vendor.js`, so it will try to load it from `/assets/vendor.map`. Since this request isn't intercept-able, the source maps will fail to load. So to make source maps work, we really need to load the Javascript and CSS files from relative URLs, and since we cannot easily rewrite the other assets' URLs to be relative, we need two different solutions for the two different flavors of assets. + +## Alternatives + +We've investigated a number of alternatives, but none of them seemed better than the existing solution. They are listed here for posterity, and in case things change in the future and/or somebody wants to try them out or figure out how to adapt them into a better solution, or simply finds that one of them better fits their application's specific use cases -- each of these alternatives is viable, just with certain drawbacks. + +### electron-protocol-serve + +[electron-protocol-serve](https://github.com/bendemboski/electron-protocol-serve) was an ingenious method of implementing an abstraction layer similar to a web server, that solved the routing problem, and both flavors of the asset loading problem. Unfortunately, it ended up being too far off the beaten path of Electron patterns. Electron and Chromium both apply less security/sandboxing when the HTML is loaded from a `file:` URL, so various common Electron application patterns would not work without manually disabling certain browser security measures when loading from non-`file:` URLs. But the bigger problem was that starting with Electron 7, and due to some changes in Chromium, it could no longer load source maps when using `electron-protocol-serve` (see [this issue](https://github.com/electron/electron/issues/21699)). + +Note that this is fundamentally the same reason why we need different solutions for Javascript/CSS and other assets -- see above. + +### use a web server + +It would certainly be possible to bundle `express` or some other web server with an Electron app, configure it to serve the Ember app, and then load the app over HTTP rather than using `file:` URLs. This would also solve the routing problem and both flavors of the asset loading problem, but would also have one of the same drawbacks as `electron-protocol-serve` did, which is that the app would run at the higher level of security/sandboxing applied by Chromium and Electron to apps loaded via non-`file:` URLs. But also, in addition to straying from the Electron beaten path, running a web server seems like a pretty high-runtime-overhead approach to be our out-of-box solution, so we've opted for something lighter-weight. + +### leverage broccoli-asset-rev + +`broccoli-asset-rev` is a `brocolli` plugin that rewrites asset paths to add cache-busting fingerprints and also prepend CDN URLs, so while it wouldn't help with the routing problem, it is fundamentally attacking the same problem that we're trying to attack of rewriting the URLs of all of an Ember application's assets. Unfortunately, we can't really use it as intended -- it's built to accept static CDN URLs that are the same for every user of the application, and are known at Ember build time. We cannot prepend absolute `file:` URLs to our asset paths because this prepending happens at Ember build time, and we don't know the absolute filesystem path to the application until it's installed on the user's system. + +One option would be to specify `./` as the prepend URL, which would convert the asset paths into relative paths. This almost works, but unfortunately is again a bit too far off the beaten path -- `broccoli-asset-rev` was not written with relative URLs in mind, so things don't quite work. An initial stab is adding this to `ember-cli-build.js`: + +```javascript +fingerprint: { + enabled: process.env.EMBER_CLI_ELECTRON || process.env.EMBER_ENV === 'production', + prepend: './' +} +``` + +This fixes all the asset paths to load into the browser, but leaves sourcemaps broken. The reason is that `broccoli-asset-rev` also rewrites source map URLs expecting them to be absolute. So if `prepend` is `http://cdn.com/`, then it would expect `vendor.map` to be at `http://cdn.com/assets/vendor-.map`, and would rewrite the `sourceMappingURL=vendor.map` at the end of `vendor.js` to be `sourceMappingURL=http://cdn.com/assets/vendor-.map`. This is fine for prepending absolute URLs, but if we prepend `./`, then it ends up rewriting the source map URL as `sourceMappingURL=./assets/vendor-.map`. But since both `vendor-.js` and `vendor-.map` end up in the same directory, and the `sourceMappingURL` is interpreted relative to where `vendor.js` is, it resolves to `.../ember-dist/assets/assets/vendor-.map`. `broccoli-asset-rev` simply doesn't expect or handle relative `prepend` URLs. + +This could potentially be fixed by broccoli'ing the map files into `assets/assets`, or using the broccoli pipeline to rewrite the `assets/assets/` to just be `assets/`, but then we're kinda layering kludges, and it really doesn't seem like the way to go. It can also be partially fixed by changing the fingerprint config to + +```javascript +fingerprint: { + enabled: process.env.EMBER_CLI_ELECTRON || process.env.EMBER_ENV === 'production', + extensions: [ 'js', 'css', 'png', 'jpg', 'gif' ], + prepend: './' +} +``` + +where the `extensions` array includes all default asset types except `map`, i.e. disables rewriting the map files so the `sourceMappingURL`s in the Javascript files are not modified...but it appears that `broccoli-asset-rewrite` has a bug where if we have CSS sourcemaps, the fact that we're rewriting `.css` files causes it to incorrectly recognize the `my-app.css` substring of `sourceMappingURL=my-app.css.map` as a reference to the CSS file and rewrite it...so further evidence that we're wandering off the beaten path. + + +### change assets paths to be relative + +One other fix for asset loading is to simply make all the asset paths relative, e.g. change `` to ``. However, this will not work if the Ember app is also built to run in the browser without some similar acrobatics as described above to re-introduce the `/` or prepend a CDN URL, and it also assumes that the application author has control over all of the markup, which may not be the case if they use third-party addons for UI. \ No newline at end of file diff --git a/tests/dummy/app/templates/docs/guides/structure.md b/tests/dummy/app/templates/docs/guides/structure.md index 70d13ae6..c1ea978a 100644 --- a/tests/dummy/app/templates/docs/guides/structure.md +++ b/tests/dummy/app/templates/docs/guides/structure.md @@ -28,6 +28,7 @@ When you first install `ember-electron`, the blueprint creates an `electron-forg ├── package.json ├── node_modules ├── src + │ │── handle-file-urls.js │ └── index.js ├── tests │ └── index.js diff --git a/tests/dummy/app/templates/docs/guides/upgrading.md b/tests/dummy/app/templates/docs/guides/upgrading.md index c591bdf7..7d0c7179 100644 --- a/tests/dummy/app/templates/docs/guides/upgrading.md +++ b/tests/dummy/app/templates/docs/guides/upgrading.md @@ -75,25 +75,7 @@ Either way, you will need to update the configuration since the format has chang ### main.js -The equivalent of `ember-electron/main.js` is `electron-app/src/index.js`, so you'll want to put the contents of your `main.js` there (note that `src/index.js` is specified by the `main` entry in `electron-app/package.json`, so you can name it whatever/put it wherever you want as long as you update the `main` entry accordingy). Because the Electron project structure differs a little from `ember-electron` 2.x's, you'll need to replace anywhere you reference the path to the Ember application directory (`../ember`) with the new path (`../ember-dist`). For example, `ember-electron` 2.x's default `main.js` contains: - -```javascript -protocolServe({ - cwd: join(__dirname || resolve(dirname('')), '..', 'ember'), - app, - protocol, -}); -``` - -while `ember-electron` 3.x's contains: - -``` -protocolServe({ - cwd: join(__dirname || resolve(dirname('')), '..', 'ember-dist'), - app, - protocol, -}); -``` +The equivalent of `ember-electron/main.js` is `electron-app/src/index.js`, so you'll want to put the contents of your `main.js` there (note that `src/index.js` is specified by the `main` entry in `electron-app/package.json`, so you can name it whatever/put it wherever you want as long as you update the `main` entry accordingly). Because the Electron project structure differs a little from `ember-electron` 2.x's, you'll need to replace anywhere you reference the path to the Ember application directory (`../ember`) with the new path (`../ember-dist`). ### test-main.js @@ -125,3 +107,98 @@ If you need to exclude files for other platforms from your packaged build, you c ### code/files/etc Anything else that was in the `ember-electron` in 2.x should be "just files" -- `.js` files `require`d from the main process or `requireNode`d from the Ember app, or other files accessed via the filesystem APIs. So these can be migrated into `electron-app` however seems best, updating the references to their paths in other source files as needed. + +### electron-protocol-serve + +Since 2.x, we have removed `electron-protocol-serve` from the default blueprint in favor of loading the Ember app using `file:` URLs. The instructions above should get you set up to run properly, but if you app has any references/assumptions around the URL used to load the Ember app, you'll need to update them. If you added `webSecurity: false` to work around issues caused by `electron-protocol-serve` (as described [here](/versions/v2.10.2/docs/faq/common-issues#what-is-electron-protocol-serve-and-why-do-i-need-this-)) you should be able to remove it now. + +Another effect of switching from `serve:` URLs to `file:` URLs is that you may need to migrate data stored in browser storage such as `localStorage` or `IndexedDB` or your users could experience data loss. If a user has been running a version of your application that uses `serve:` URLs, then the browser will have any such data associated with the `serve://dist` domain, and the browser's security measures to prevent one site from accessing another site's will prevent your app, which accessed via a `file:` URL, from accessing the previously-created data. + +Unfortunately, Electron doesn't currently provide any mechanisms to address this, so if your application has stored any such data on users' systems, and it's critical that it remain intact when the user updates to a version of the application that uses `file:` URLs, you'll have to migrate it. Here's an example of how you might do it for `localStorage`: + +```javascript +// src/index.js +const { protocol, BrowserWindow } = require('electron'); +const { promises: { writeFile } } = require('fs'); +const { fileSync } = require('tmp'); + +function needsMigration() { + // You'll want some way of determining if the migration has already happened, + // the when the app starts it doesn't always re-copy the data from the + // `serve://dist`-scoped, overwriting any changes the user has since made to + // the `file:`-scoped data. For example, you might use `electron-store` to + // keep track of whether you've run the migration or not. +} + +if (needsMigration()) { + // Register the `serve:` scheme as privileged, like `electron-protocol-serve` + // does. This enables access to browser storage from pages loaded via the + // `serve:` protocol. This needs to be done before the app's `ready` event. + protocol.registerSchemesAsPrivileged([ + { + scheme: 'serve', + privileges: { + secure: true, + standard: true + } + } + ]); + + app.on('ready', async () => { + // Set up a protocol handler to return empty HTML from any request to + // `serve:` URLs, so we can load `serve://dist` in a browser window and use + // it to access localStorage + protocol.registerStringProtocol('serve', (request, callback) => { + callback({ mimeType: 'text/html', data: '' }); + }); + + // Navigate to our empty page in a hidden browser window + let window = new BrowserWindow({ show: false }); + try { + await window.loadURL('serve://dist'); + + // Get a JSON-stringified version of this origin's entire localStorage + let localStorageJson = await window.webContents.executeJavaScript('JSON.stringify(window.localStorage)'); + + // Create an empty HTML file in a temporary location that we can load via a + // `file:` URL so we can write our values to the `file:`-scoped localStorage. + // We don't do this with a protocol handler because we don't want to mess + // with how `file:` URLs are handled, as it could cause problems when we + // actually load Ember app over a `file:` URL. + let tempFile = fileSync(); + await writeFile(tempFile.name, ''); + await window.loadFile(tempFile.name); + + // Iterate over the values and set them in file:'s localStorage + for (let [ key, value ] of Object.entries(JSON.parse(localStorageJson))) { + await window.webContents.executeJavaScript(`window.localStorage.setItem('${key}', '${value}')`); + } + } finally { + window.destroy(); + } + }); +} +``` + +This would be somewhat more complicated for storages with more structure/data formats like `IndexedDB`, but this should serve as a template for how the data could be migrated. + +# Upgrading from 3.0.0-beta.2 + +Between `3.0.0-beta.2` and `3.0.0-beta.3` we removed `electron-protocol-serve` from the default blueprint as explained [here](#electron-protocol-serve). The best way to upgrade from a `3.0.0` beta version before `3.0.0-beta.3` is to: + +1. Start with a clean working tree (no uncommitted changes) +2. Update `ember-electron` to the latest version +3. Rerun the blueprint (`ember g ember-electron`) overwriting all files when prompted +4. Look at the git diff and re-introduce any changes/customizations you previously made to the affected files +5. `cd electron-app && yarn remove electron-protocol-serve` since `electron-protocol-serve` is no longer used + +The changes you should end up with are: + +* Modifications to the `rootURL` and `locationType` settings in `config/environment.js` +* A new `electron-app/src/handle-file-urls.js` file +* Changes to `electron-app/src/index.js` and `electron-app/tests/index.js` to switch from `electron-protocol-serve` to `file:` URLs +* Removal of `electron-protocol-serve` from `electron-app/package.json` + +If your application uses any browser storage like `localStorage` or `IndexedDB`, you may need to migrate the data so it's accessible from `file:` URLs -- make sure to read the [this](#electron-protocol-serve) section for more info. + +You can read more about the removal of `electron-protocol-serve` and loading from `file:` URLs [here](../faq/routing-and-asset-loading). diff --git a/yarn.lock b/yarn.lock index 5d50e215..ef8581a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6744,11 +6744,6 @@ electron-packager@^14.0.6: semver "^6.0.0" yargs-parser "^16.0.0" -electron-protocol-serve@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/electron-protocol-serve/-/electron-protocol-serve-1.4.0.tgz#c2dc986094b8138ae8f292995ce592090295745e" - integrity sha512-RZqtCQDuEES2sHjHux/Lauo8GQ2wBKqPgN4vBXQDq7NOlDQ+2cGXN57AH7riHSPhEsQ/XG73WWJAbedQgKqbEQ== - electron-rebuild@^1.8.6: version "1.8.6" resolved "https://registry.yarnpkg.com/electron-rebuild/-/electron-rebuild-1.8.6.tgz#4454ef5517c0588aef9bca0d923ff5633000b949"