Ember Electron and Typescript

I've been working on modernizing a somewhat older application. It consists of a Electron application through the ember-electron addon for Ember.

My goal was to upgrade both the Ember as well as the Electron part to a typescripted project.

Prerequisites

$ ember -v                                                  
ember-cli: 4.12.1
node: 18.16.0
os: darwin arm64

$ yarn -v
1.22.18

Creating a typescript EmberJS application with Electron

We will start by creating an Ember application setup as typescript.

$ ember new --directory ember-app ember-electron-app --typescript --yarn --lang en

Add Electron to the application

We then add ember-electron to the application. This 'converts' our project into a projects that can run our EmberJS application as an Electron application.

$ cd ember-app
$ ember install ember-electron

yarn: Installed ember-electron
installing ember-electron-postinstall

Run 'ember g ember-electron' to complete ember-electron setup

As per instruction;

$ ember g ember-electron

installing ember-electron
  create testem-electron.js
Updating config/environment.js
Creating electron-forge project at './electron-app'
✔ Locating custom template: "ember-electron/forge/template"
✔ Initializing directory
✔ Preparing template
✔ Initializing template
✔ Installing template dependencies

✨  Running "lint:fix" script...

Let's test the app as is

$ ember electron

An error appears: Failed to install Devtron. But let's ignore that.

You should see "Congratulations, you made it!" in a native window.

Creating a typescript Electron application

On this page I found instructions on how to create an Electron application configured as typescript. We will do that and use some of it's setup.

$ cd ..
$ yarn create electron-app electron-app --template=webpack-typescript

Let's start that app.

$ cd electron-app
$ yarn electron-forge start

You should see a native window with "Hello World! Welcome to your Electron application."

Integrate the Electron app into our ember-electron

Inside our ember-app is a directory 'electron-app' that has the capability to run the EmberJS application. However that is in plain javascript and not typescript. To convert it we need to copy and change some things;

Adding some files from the electron template

cd ..
cp electron-app/*.ts ember-app/electron-app/
cp electron-app/src/* ember-app/electron-app/src/
cp electron-app/tsconfig.json ember-app/electron-app/
rm ember-app/electron-app/forge.config.js
rm electron-app/src/handle-file-urls.js
rm ember-app/electron-app/index.js

Entry point

We also must to change the 'main' entry point inside ember-app/electron-app/package.json.

change it from

"main": "src/index.js",

to

"main": ".webpack/main",

Dependencies of the electron-app

$ cd ember-app/electron-app
$ yarn add -D @electron-forge/plugin-webpack @typescript-eslint/eslint-plugin @typescript-eslint/parser @vercel/webpack-asset-relocator-loader css-loader eslint eslint-plugin-import fork-ts-checker-webpack-plugin node-loader style-loader ts-loader ts-node typescript

At this point you should be able to successfully run the electron app from within the electron-app directory

$ yarn electron-forge start

However running it through Ember will give you errors or a stall at "Starting Electron..."

$ cd ..
$ ember electron

Fixing "failed to load"

When you start the app with ember electron now you might see an error

Failed to load: /.../ember-app/electron-app/forge.config.ts
Cannot use import statement outside a module

Add "module": "commonjs", to the compilerOptions and "electron-app/**/*.ts" as additional include entry. My ember-app/tsconfig.json looks like;

{
  "extends": "@tsconfig/ember/tsconfig.json",
  "compilerOptions": {
    "module": "commonjs",
    "skipLibCheck": true,
    "noEmitOnError": false,
    "resolveJsonModule": true,

    // The combination of `baseUrl` with `paths` allows Ember's classic package
    // layout, which is not resolvable with the Node resolution algorithm, to
    // work with TypeScript.
    "baseUrl": ".",
    "paths": {
      "ember-electron-app/tests/*": ["tests/*"],
      "ember-electron-app/*": ["app/*"],
      "*": ["types/*"]
    }
  },
  "include": ["app/**/*", "tests/**/*", "types/**/*", "electron-app/**/*.ts"]
}

Dependencies of the ember-app

One reason why you still get errors or stalls is because of the way ember-electron starts the electron application in our workspace. It does this via the exposed api of @electron-forge/core package and not - as you might think - via a process such as electron-forge start. I believe that means it will also not see any installed dependencies that are defined inside package.json inside the electron-app directory.

I found installing the same dependencies again to our root directory solves these issues and the webpack stuff can be correctly loaded and executed. (Not sure if all these packages are required).

cd ember-app
yarn add -D @electron-forge/plugin-webpack @typescript-eslint/eslint-plugin @typescript-eslint/parser @vercel/webpack-asset-relocator-loader css-loader eslint eslint-plugin-import fork-ts-checker-webpack-plugin node-loader style-loader ts-loader ts-node typescript

Path resolutions

Running ember electron will now succesfully start the Electron application. However there is one more issue that pops up. You will see a (native) window with an error like so;

Compiled with problems:

ERROR Module not found: Error: Can't resolve './src/renderer.ts' in '/.../ember-app'"

This can be easely fixed by using absolute paths inside ember-app/electron-app/forge.config.ts

entryPoints: [
	{
		html: __dirname + '/src/index.html',
       js: __dirname + '/src/renderer.ts',
       name: 'main_window',
       preload: {
	       js: __dirname + '/src/preload.ts',
       },
	},
],

ember electron now works and you should greeted with "Hello World! Welcome to your Electron application."

Switch to loading the EmberJS application

To get our Ember application to show up we must change the entry point again to the following. Remember ember-electron creates our application inside the ember-app/electron-app/ember-dist directory.

entryPoints: [
  {
    html: __dirname + '/ember-dist/index.html',
    ...
  },
],

Recreate the ember-electron magic.

We also need to recreate the magic ember-electron does by adding these two files;

ember-app/electron-app/src/handle-file-urls.ts

import { fileURLToPath } from 'url';
import path from 'path';
import fs from 'fs';
import { promisify } from 'util';

const access = promisify(fs.access);

//
// Patch asset loading -- Ember apps use absolute paths to reference their
// assets, e.g. `<img src="/images/foo.jpg">`. 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.
//
export async function getAssetPath(emberAppDir: string, url: string) {
  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;
  }
}

export default function handleFileURLs(emberAppDir: string) {
  const { protocol } = require('electron');

  protocol.interceptFileProtocol(
    'file',
    async ({ url }: { url: string }, callback: Function) => {
      callback(await getAssetPath(emberAppDir, url));
    }
  );
}

And a modified ember-app/electron-app/src/index.ts to mimic the original index.js

/* eslint-disable no-console */

// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
// whether you're running in development or production).
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string;

const {
  default: installExtension,
  EMBER_INSPECTOR,
} = require('electron-devtools-installer');
const { pathToFileURL } = require('url');
const { app, BrowserWindow } = require('electron');
import path from 'path';
import isDev from 'electron-is-dev';
import handleFileUrls from './handle-file-urls';

const emberAppDir = path.resolve(__dirname, '../..', 'ember-dist');
const emberAppURL = pathToFileURL(
  path.join(emberAppDir, 'index.html')
).toString();

// 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({
//     productName: 'YourName',
//     companyName: 'YourCompany',
//     submitURL: 'https://your-domain.com/url-to-submit',
//     autoSubmit: true
// });

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('ready', async () => {
  if (isDev) {
    try {
      require('devtron').install();
    } catch (err) {
      console.log('Failed to install Devtron: ', err);
    }
    try {
      await installExtension(EMBER_INSPECTOR, {
        loadExtensionOptions: { allowFileAccess: true },
      });
    } catch (err) {
      console.log('Failed to install Ember Inspector: ', err);
    }
  }

  await handleFileUrls(emberAppDir);

  let mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
    },
  });

  // If you want to open up dev tools programmatically, call
  mainWindow.openDevTools();

  // Load the ember application
  mainWindow.loadURL(emberAppURL);

  // If a loading operation goes wrong, we'll send Electron back to
  // Ember App entry point
  mainWindow.webContents.on('did-fail-load', () => {
    mainWindow.loadURL(emberAppURL);
  });

  mainWindow.webContents.on(
    'render-process-gone',
    (_event: any, details: any) => {
      if (details.reason === 'killed' || details.reason === 'clean-exit') {
        return;
      }
      console.log(
        'Your main window process has exited unexpectedly -- see https://www.electronjs.org/docs/api/web-contents#event-render-process-gone'
      );
      console.log('Reason: ' + details.reason);
    }
  );

  mainWindow.on('unresponsive', () => {
    console.log(
      'Your Ember app (or other code) has made the window unresponsive.'
    );
  });

  mainWindow.on('responsive', () => {
    console.log('The main window has become responsive again.');
  });

  mainWindow.on('closed', () => {
    mainWindow = null;
  });
});

// Handle an unhandled error in the main thread
//
// Note that 'uncaughtException' is a crude mechanism for exception handling intended to
// be used only as a last resort. The event should not be used as an equivalent to
// "On Error Resume Next". Unhandled exceptions inherently mean that an application is in
// an undefined state. Attempting to resume application code without properly recovering
// from the exception can cause additional unforeseen and unpredictable issues.
//
// Attempting to resume normally after an uncaught exception can be similar to pulling out
// of the power cord when upgrading a computer -- nine out of ten times nothing happens -
// but the 10th time, the system becomes corrupted.
//
// The correct use of 'uncaughtException' is to perform synchronous cleanup of allocated
// resources (e.g. file descriptors, handles, etc) before shutting down the process. It is
// not safe to resume normal operation after 'uncaughtException'.
process.on('uncaughtException', (err) => {
  console.log('An exception in the main thread was not handled.');
  console.log(
    'This is a serious issue that needs to be handled and/or debugged.'
  );
  console.log(`Exception: ${err}`);
});

Now, when you run ember electron you should see the Ember welcome page inside a native window and everything is typescripted.

Enjoy!