Packaging Electron apps

Distributing Electron apps with Conveyor has a bunch of advantages and doesn’t take long. Packaging GitHub Desktop lets us see what the configuration looks like for a production-grade app. We’ll also use other GitHub services like Releases, Pages and Actions. The benefits can be seen in the nearly 2,000 lines of code that can be deleted vs the original Squirrel based solution.

Introduction

View repository

GitHub Desktop is a GUI for Git that supports Windows and macOS. It uses Electron, TypeScript, React, and users can log in using OAuth. It also integrates with the Windows notification center. The current packaging solution is based on Electron Packager and requires a fairly large amount of scripting, visible in the build.ts and package.ts files. It also requires platform specific code to register URL handlers on Windows.

Distributing apps with Conveyor is simple, enables cross-building the download/update site for every supported platform from a single machine, and gives us access to some useful features like aggressive updates (checking on each startup).

We’ll port GitHub Desktop to use Conveyor in these commits:

Importing useful settings

The first commit adds a conveyor.conf file. We start by declaring that this is an Electron app and that we’d like to import data from the package.json files in the source tree. The config file syntax is HOCON, a superset of JSON designed for human convenience.

include required("/stdlib/electron/electron.conf")

// Import package.json files so we can avoid duplicating config. There are two in this project.
package-json {
  include required("app/package.json")
}

outer-package-json {
  include required("package.json")
}

Package metadata

We need to specify names, versions and icons. Some can be taken from the package.json files using HOCON’s ${} substitution syntax:

app {
  display-name = ${package-json.productName}
  // The name used for files and directories on the filesystem.
  fsname = github-desktop   
  license = MIT
  // Must set this explicitly because you can't use words like "beta" in version numbers.
  version = 3.1.3     
  icons = "app/static/logos/{256x256,512x512,1024x1024}.png"
  contact-email = "contact@hydraulic.dev"

  // Avoid conflicting with the official app.
  // rdns-name is a reverse DNS name that uniquely identifies this package.
  rdns-name = dev.hydraulic.samples.GitHubClient
  vendor = Hydraulic

  // GH Desktop doesn't support Linux.
  machines = [ windows.amd64, mac ]

  // vcs-url = open source = free Conveyor license. 
  vcs-url = "https://github.com/hydraulic-software/github-desktop"

  // This will control what version of Electron is downloaded and bundled.
  electron.version = ${outer-package-json.devDependencies.electron}
}

Because we set vcs-url, installed apps will update to the latest GitHub Release version.

If you want to know what the JSON equivalent of your config is you can run conveyor json. Included files will be resolved, substitutions applied, objects merged together and the final result is rendered with JSON syntax. You’ll also see all the default values for keys you didn’t set.

Retrieving files from GitHub Actions

Conveyor can build your Windows MSIX packages, notarized Mac zips and Linux .deb files from any OS you prefer because it doesn’t use any native tooling to do so. Unfortunately Desktop has a build process that customizes the minified JavaScript and bundled native binaries depending on what OS runs the build. Although we could have patched it to support genuine cross-compilation, for this demo we just accept the situation and use GitHub Actions to assemble the files that will be shipped in Electron’s app files directory. GitHub Actions has a couple of limitations we’ll have to work around:

  1. No direct download links for artifacts exported from jobs.
  2. Can only export files using zips, and those zips don’t preserve UNIX file permissions.

We can solve them like this:

  1. A direct download link to the output of a CI build job is created using the nightly.link service. You give this website the URL of your Actions job YAML, and it gives you back download links that can be used as Conveyor inputs.
  2. UNIX files are wrapped in a tarball which is then in turn exported inside a zip, which preserves UNIX permissions (in particular the execute bit). Windows files are exported directly inside a zip. Conveyor is then told to extract the archives and inner archives to get at the files.

Let’s import the latest build from CI as files to package:

ci-artifacts-url = nightly.link/hydraulic-software/github-desktop/workflows/ci/conveyorize

app {
  windows.amd64.inputs = ${ci-artifacts-url}/build-out-Windows-x64.zip
  mac.amd64.inputs = [{
    from = ${ci-artifacts-url}/build-out-macOS-x64.zip
    extract = 2
  }]
  mac.aarch64.inputs = [{
    from = ${ci-artifacts-url}/build-out-macOS-arm64.zip
    extract = 2
  }]
}

You can extract archives within archives by setting the extract key on an input object. When we don’t need this we can simply point to the URL or path of the file to import. By default, archives are extracted one level, so we can just use the nightly.link URL directly.

URL handling

GitHub Desktop handles custom URL schemes to support OAuth logins:

app {
  // URL handlers for logging in.
  url-schemes = [
    x-github-desktop-auth
    x-github-desktop-dev-auth
    x-github-client
    github-mac
    github-windows
  ]
}

There are several for backwards compatibility reasons. You don’t need anything more than this.

Update modes and channels

The default update user experience differs by platform:

  • On Windows apps will update silently in the background whether the app is running or not. This uses the same background transfer technology as Windows Update to avoid contention when the internet connection is in use.
  • On macOS apps will update in the background whilst the app runs.
  • On Debian derived Linux distributions, apps will update at the same time as other packages (via apt).
  • On other Linux distros the user will have to re-download the app.

Sometimes you want apps to update as fast as possible. This will be the case for nightly or beta builds, where it’s more important that the user is running the very latest code than having the fastest startup. Aggressive updates is a good choice here: it enables a fully synchronous update check every time the app is started. If an update is available it’s immediately downloaded, applied and the app will start up with the new version, without the user or you having to do anything. This mode can also be useful for apps that need to stay in sync with a rapidly changing server protocol:

app.updates = aggressive

Here we’re using HOCON path syntax. it’s equivalent to "app": { "updates": "aggressive" } in JSON. You can mix and match styles as you wish.

macOS specific integrations

Apps on macOS can request integrations using XML files inside the app bundle. Conveyor generates these based on your config, but features it doesn’t abstract can be set using config:

app {
  mac {
    entitlements-plist."com.apple.security.automation.apple-events" = true

    info-plist {
      NSAppleEventsUsageDescription = GitHub Desktop uses Apple Events to implement integration features with external editors.

      CFBundleDocumentTypes = [
        {
          CFBundleTypeName = Folders
          CFBundleTypeRole = Viewer
          LSItemContentTypes = [ public.folder ]
          LSHandlerRank = Alternate
        }
      ]

      LSApplicationCategoryType = public.app-category.developer-tools
      NSHumanReadableCopyright = "Copyright © 2017- GitHub, Inc."
    }
  }
}

We declare a permission request, set a bit of app metadata and allow GitHub Desktop to be opened from apps that support “open with” on folders. You can read about what keys are available in Apple’s bundle documentation.

Windows specific integrations

Apps packaged with Conveyor use a different mechanism for extending Windows to the registry editing approach you may be used to. Integrations are declared via the AppX Manifest file, an XML file that tells Windows how the app should be installed and uninstalled. The manifest is generated for you, but you can add arbitrary bits of XML in the extensions section. We need some here to register use of the notification center:

app {
  windows {
    // Register an object that will be invoked by Windows when the user 
    // interacts with a "toast" notification for things like pull requests 
    // being opened. 
    //
    // See https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/send-local-toast-desktop-cpp-wrl#packaged
    manifests.msix.extensions-xml = """
        <!-- Register COM CLSID LocalServer32 registry key -->
        <com:Extension Category="windows.comServer">
          <com:ComServer>
            <com:ExeServer Executable="GitHubDesktop.exe" DisplayName="Toast activator">
              <com:Class Id="27D44D0C-A542-5B90-BCDB-AC3126048BA2" DisplayName="Toast activator"/>
            </com:ExeServer>
          </com:ComServer>
        </com:Extension>

        <!-- Specify which CLSID to activate when toast clicked -->
        <desktop:Extension Category="windows.toastNotificationActivation">
          <desktop:ToastNotificationActivation ToastActivatorCLSID="27D44D0C-A542-5B90-BCDB-AC3126048BA2" />
        </desktop:Extension>
    """
  }
}

This is needed by the desktop-notifications module. The Windows API doesn’t let you just register a normal function as a callback, because Windows stores notifications and lets the user activate them much later, perhaps when the program that created them has already shut down. COM is Microsoft’s technology for activating objects and performing RPCs on them, so it makes sense that they use it here. With this bit of XML we can register a COM object and then point the “toast” notifications at it. The GUID here can be created using a tool like uuidgen. The XML will be checked by Conveyor for compliance with Microsoft’s schemas, and you can read about the available elements in the Windows documentation.

We also need a small code change to make notifications work. Electron needs to be told what the app ID is for notification clicks to work properly:

if (__WIN32__) {
  app.setAppUserModelId('GithubDesktop_49jahnq5qzr1m!GithubDesktop')
}

The string here can be found by running conveyor make app-user-model-id. The ASCII code after the underscore is a hash of the signing identity and is part of how Windows avoids namespace conflicts. Signing with different certificates will change the ID. Instead of hard-coding it we could also use the Windows GetCurrentPackageId API at runtime.

One last small code change is needed. The old Squirrel-based code ran imperative code to deal with URL handlers manually. This isn’t necessary with Conveyor, so we remove it.

Deleting unnecessary code

Now for the fun part - deleting code we no longer need.

Desktop contains code to respond to installation and uninstallation, which it uses to add/update start menu entries and create a batch file. Start menu registration is automatic with Conveyor, and the batch file for the CLI can be created at first launch instead.

The app contains a significant amount of UI code that isn’t needed anymore when using Conveyor, like for displaying update-and-restart banners and showing update progress. On Windows the app may be updated in the background even when not running, so we can just assume it’ll stay up to date without bothering the user. On macOS the Sparkle framework will provide update UI for us using native widgets, whilst being careful not to steal focus or otherwise get in the user’s way. This lets us avoid custom UI.

The about box contains code for forcing update checks when you’re not on the main channel. The best way to create a beta channel with Conveyor is to create a separate beta.conveyor.conf file that includes the main config, overrides the site URL, fsname and display name and finally sets app.updates = aggressive. This lets users install both the beta and stable tracks simultaneously, with an update check happening on every launch for the beta channel:

include required("conveyor.conf")
app {
    fsname = github-desktop-beta
    display-name = GitHub Desktop Beta
    site.base-url = "https://example.com/beta"
    updates = aggressive
}

Finally, we can delete the large amount of code that drives Electron Packager. And with that we’re done: 1,857 lines of code gone!

Doing the release

We can run conveyor make site --rerun=all to download a build from Actions and create all the packages and update metadata. The --rerun=all flag is needed because Conveyor caches downloaded files, so when the results of the CI job have changed we need to force a refresh. Alternatively, we could require a specific build ID to be passed in on the command line like this: conveyor -KrunID=1234 make site and then write:

ci-artifacts-url = nightly.link/hydraulic-software/github-desktop/actions/runs/${runID}

Now we could upload the files to GitHub Pages/Releases manually, but there’s no need. With a few more bits of config Conveyor can do that for us too.

app {
  site {    
    github {
      oauth-token = ${env.GITHUB_TOKEN} 
      pages-branch = gh-pages
    }
  }
}

Running conveyor make copied-site will now generate all the files as before, then create a new GitHub Release so clients start updating, then upload a fresh download page to GitHub Pages. If you want to, the entire process can also be run on a cheap Linux based GitHub Actions runner so auto-updating clients is just a git push.

Code signing

We’ve got app that works fine, can update itself and which is easy to release, but it’s self-signed. Windows users will need administrator privileges to install, virus scanners might mess with us and Edge will make it hard to open the installer. MacOS users will need to override GateKeeper. Conveyor generates a download page that tells users what to do along with curl | bash instructions (it looks like this) but that’s lipstick on a pig. The user experience of self-signed apps is poor so let’s fix it.

Code signing is a complex and fragile task that frequently frustrates people new to desktop development, so it’s been a big focus for Conveyor.

Linux. GitHub Desktop doesn’t support this platform but we’ll cover it anyway. Code is signed here but identities aren’t verified. Conveyor will generate a private key and save it to a defaults.conf file in your home directory. It looks like this:

app.signing-key = "clutch praise puppy illness attitude twenty path build erupt attend cart remember spare possible taste hospital else chicken lion elite casual cross train blush/2023-07-21T11:16:35Z"

You can back this up by saving the file, or printing it out, or writing down the words with a pen and paper. All other needed keys are derived from this. If you use macOS the key will be stored in the OS keychain instead, which protects it from access by other apps.

macOS. You need to join Apple’s Developer Program but if you distribute as an individual they just use the name on your credit card as an identity, so it’s nearly instant. Conveyor generates an apple.csr file from your root key that you can upload to obtain a certificate and other credentials (instructions here). Now you won’t be blocked by GateKeeper.

Windows. The most complex platform is Windows, but Conveyor still makes it much easier than other tools. The simplest approach is to distribute via the Microsoft Store. It costs $19 to join as an individual (one off payment), Microsoft’s policies are relaxed and Conveyor can automate the release process after you get approved. The next simplest approach is use a cloud signing service like SSL.com eSigner or DigiCert KeyLocker. Sign up to one of these services, go through ID verification and you’ll get API access tokens. Add them to your config and you’re done. Alternatively, buy a hardware signing device and configure Conveyor to use it. The results won’t trigger browser warnings and users won’t need admin privileges to install it.

You can also sign with pre-existing keys if you have them. Conveyor can read most formats.

Future work

There are a few enhancements that could come later:

  1. Conveyor doesn’t currently generate Windows ARM binaries. Windows can run x86-64 binaries on ARM, and the primary users of Windows ARM seem to be Mac users running it in Parallels, so this isn’t critical right now as long as you have a Mac version.
  2. GitHub Desktop could be packaged for Linux by removing the app.machines key and it will at least partly run, but we exclude it because the upstream project doesn’t officially support Linux.

Conclusion

Distributing Electron apps with Conveyor is straightforward and yields a simple configuration file. You can easily use GitHub’s own services to build and host your app. The resulting apps can update unobtrusively in the background, and on Windows will update even when not running. Or they can be configured to update on every launch, with progress tracking and other UI provided by Conveyor instead of needing per-app implementations. You can use both techniques for different update channels. Some OS integrations like URL handlers can be specified with a single config key, but you’re not limited to what Conveyor supports. You can easily declare custom integrations directly in your config file using platform specific metadata like Apple PLists, Windows manifest XML or (not shown here) Linux .desktop keys. Code signing is deeply supported and made as easy as humanely possible. Finally, all this is free for open source apps and a commercial license gets you full support for your launch.

Deploy an Electron app today by following the tutorial.

.