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:

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.

Retrieving files from GitHub Actions

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 resource 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 restarted without the user 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 integrate themselves by adding data to the Info.plist file. Conveyor lets you enhance this file via 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.

      // Enable opening Desktop via the Finder.
      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 (the Finder unfortunately does not support this).

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 a COM object that will be invoked by Windows when the user 
    // interacts with a "toast" notification for things like pull requests 
    // being opened, etc. 
    //
    // 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 ID string here can be found by simply creating the package (conveyor make windows-msix), installing it and then running Get-StartApps from PowerShell. 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.

One last small code change is needed. The old Squirrel code registered a URL handler manually and added a custom flag along the way. This isn’t necessary with Conveyor, so we remove the code that looks for 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 code that drives Electron Packager. And with that, we’re done.

Doing the release

With that done, we can just run conveyor make site --rerun=all to produce the necessary files. The download.html file goes into the docs directory so it will be hosted by GitHub Pages, and the rest of the files go into a new GitHub Release. 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}

Future work

There are a few enhancements that could come later:

  1. Conveyor doesn’t currently generate Windows ARM binaries. Windows can run x86 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.
  3. The batch file that starts the CLI app isn’t being created in this version. A few more lines of code need to go back to add that, or we could add a Conveyor feature to export batch files onto the path automatically. This is already possible for native CLI apps!

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.

You can start packaging your own Electron apps by following the tutorial.

.