Get started

Engineering NotesInstallation, operations, and quality

One shell fingerprint decides whether the desktop app gets a new UI or a new installer

When a desktop app's UI lives inside its installer, changing one button label means shipping a new installer. Windows users download it and see a restart prompt on every release. Customers who manage installer replacement through an internal process go through that process every release. Serving the UI from the server removes that cost. It also creates a new combination: new UI running on an old installer and failing silently in specific features.

Preventing that combination takes two decisions. The app decides whether it may accept new UI from the server, and the release pipeline decides whether this release has to ship an installer. The OwlAI desktop app uses one value for both decisions: the shell fingerprint, a content hash of the files that actually go into the installer. This post covers what goes into that value and why the two decisions share it.

One shell fingerprint decides both UI acceptance and installer shipping
Figure 1. One shell fingerprint decides both UI acceptance and installer shipping

Serving UI from the server puts new UI on old shells

The desktop app is built with Electron and has two parts. The shell is what goes into the installer: the Electron runtime (Chromium and Node), the code that works with folders and files on your PC, the IPC (inter-process calls) between the UI and the shell, the update logic, and the list of signing public keys the app trusts. The UI is the same React bundle the web app uses, with every feature screen, including chat, meetings, and playbooks.

The app fetches its UI from the server it connects to. The server's frontend image serves a UI zip and a signed manifest, and the app installs only bundles whose signature checks out against a public key shipped in the installer. We call this an over-the-air (OTA) UI bundle update. The app checks the server 30 seconds after launch and every 4 hours after that, and downloads a new bundle when the server's UI version differs from the current one.

Splitting shell from UI removes the guarantee that the two are always the same version. The server may already be on a new version while the user's shell is still old. Suppose the new UI passes one extra argument to the shell's file-write call. The old shell doesn't know that argument, ignores it, and writes the file the old way. The UI loads normally, no error appears, and only the result is wrong.

A hand-bumped shell version misses changes that keep the same names

The common fix is to give the shell a version number and have each UI bundle declare the minimum shell version it needs. The OwlAI shell has such a number. A snapshot records the names of the APIs the shell exposes to the UI, and a test fails if the names change without a version bump. That catches UI that calls a newly added API.

It doesn't catch the example above. A change that keeps the names and alters only the arguments or the shell's internal behavior leaves the name list untouched. A rule that people bump the number by hand doesn't close the gap either: the first time someone forgets, the silent failure comes back.

The installer decision has a common fix too: check git history for changes in the shell directory. It's simple, but two things get in the way. The frontend image build that produces the UI bundle runs inside Docker, where there is no .git. And if the app's decision and the release's decision are computed from different inputs, some release will see them disagree. If the UI is rejected because the shell changed but no installer ships, that release's UI reaches no app at all.

So the shell version number stays as the name-list check, and on top of it we compute a fingerprint from file contents. Both the app and the release pipeline use that fingerprint.

The shell fingerprint hashes only what ships in the installer

The shell fingerprint is a content hash of what actually goes into the installer. Its inputs fall into four groups:

Input Why it's included
The entire shell code directory (excluding tests and build outputs), the fingerprint script itself, the packaging configuration, and the installer icons A one-line change to shell code changes the fingerprint, and so does a change to how it's computed
The shell part of package.json: name, type, dependencies, and the Electron and electron-builder versions The runtime and dependencies that ship in the installer
Every package that dependencies actually installs according to the lock file (transitive dependencies included), plus the Electron version The range in package.json (^6.8.3) can stay the same while the installed version changes, and then the code inside the installer changes
The release keys in the UI bundle signing key list, and the signing public key for the shell update feed A release that changes keys needs a shell that trusts the new keys to get out

The last row means key rotation needs no separate bookkeeping. A release that changes the key list changes the fingerprint, so an installer ships. Switch the signing key only after most installed apps have moved to the shell that trusts the new key, and old shells never reject UI signed with it.

UI-only dependencies go in devDependencies. The bundler packs them into the UI, so they don't need to ship in the installer and don't enter the fingerprint. That placement is why a release that only upgrades the UI's chart library goes out as a UI update alone. If one moves into dependencies by mistake, the app archive inside the installer (app.asar) grows. With only shell dependencies it's about 23MB, and the build fails above 60MB.

The same commit has to produce the same fingerprint in the installer build and in the image build, so values that change during a build are excluded or normalized:

  • A tag build inserts the version into package.json and reorders its keys. The fingerprint reads key-sorted JSON with the version field removed.
  • A tag build tidies the signing key list file. The fingerprint doesn't hash that file as is; it counts only the release keys.
  • Checkouts on the Windows build host use different line endings. Text files are hashed after line endings are normalized.

If any input file is missing, the computation fails. Skipping a missing file and producing some other value would let the two builds' fingerprints drift apart without anyone noticing.

The app accepts only UI with a matching fingerprint

A UI bundle records in its manifest the shell fingerprint of the source it was built from, and the shell carries the fingerprint computed when it was built. The app accepts the UI only when the two match. UI from a release that changed the shell never reaches an old shell, so the combination above never happens.

When the fingerprints differ, what the app does depends on the server version:

  • If the server is on a newer release than this installer, the app keeps its current UI and shows "Update the app to get the latest update." It also triggers a shell update check right away.
  • If the server has rolled back to a release older than this installer, the app keeps its current UI without any notice. The app doesn't accept shell downgrades, so "update the app" would be false.

When the fingerprints match and only the UI version differs, the app follows the server. If the server rolls back, the UI goes back with it.

A release ships an installer only when the fingerprint changes

The release pipeline reads the "installed shell fingerprint" that the previous publication left in the downloads directory and compares it with this source's fingerprint. If they match, the Windows, macOS, and Linux installer builds finish as successful without building anything, and publication is skipped. The server version and the UI bundle still go out. If they differ, the pipeline builds and publishes installers for all three platforms and records that fingerprint for the next release to read.

When the decision is ambiguous, an installer ships. These are the ambiguous cases:

  • The previous publication's feed or installed shell fingerprint can't be read (including installers published before fingerprints were recorded)
  • The installer versions in the per-platform feeds disagree
  • The same tag is run again
  • The decision script fails

We ship an installer when in doubt because the two mistakes cost different amounts. An unneeded installer costs users one more download and a restart. A missing installer makes every app reject that release's UI, and since the new shell exists nowhere, apps stay on the old UI until a later release ships an installer.

Deciding from the shell fingerprint whether to ship an installer or only the UI
Figure 2. Deciding from the shell fingerprint whether to ship an installer or only the UI

Right before publishing, three values are compared, and if any of them differs, nothing is published:

  1. Does the fingerprint embedded in the built installer match the source fingerprint computed before the build?
  2. Do the three platforms agree on the decision and the fingerprint?
  3. Does that fingerprint match the fingerprint of the server UI bundle built from the same tag?

Check 1 makes sure the version inserted by the tag build and the key list cleanup didn't leak into the fingerprint. An installer with a leaked value would reject every UI from its own server. If check 3 failed and the release went out anyway, every app would show "Update the app."

An on-premises server starts serving a version's installer to apps only after the update completes. It doesn't serve the installer of a version that was only imported or that failed and was rolled back during the update. When the server moves to a version without an installer (a UI-only release), it keeps serving the installer it served before. On the app side, the shell looks only at the feed of the server it's connected to, and accepts an update only when that feed's signature verifies against the release key shipped in the installer.

Design choices

We tied the installer decision and the UI acceptance decision to one value instead of keeping them separate. Separate decisions could each be tuned for their own purpose. But if they disagree even once, a release's UI reaches no app, and that state lasts until the next installer goes out. With one value, every release that changes the fingerprint ships an installer.

We compute the fingerprint from file contents instead of git history. Git would need less normalization. But the image build has no .git and still has to produce the same value, so we paid the cost of removing each value that changes during a build. Missing one normalization would make the fingerprints drift, and checks 1 and 3 stop publication when that happens.

The shell reads no environment variables at build time. Changing shell behavior through build environment variables would be more flexible, but those values aren't fingerprint inputs, so changing them wouldn't ship a new installer. Shell behavior therefore lives only in files inside the shell directory and in the packaging configuration, and a test blocks environment variable reads in shell code that runs at build time.

When an installer has to ship on purpose, we add a commit that bumps the shell version file instead of flipping a pipeline switch. The file sits inside the shell directory, so the fingerprint changes. A "force installer" switch would take less effort, but a release built with it would ship an installer while the fingerprint stayed the same, and the two decisions would rest on different grounds. The cost is that someone has to add that commit by hand whenever a value changes only outside the source.

If your organization manages installer replacement through an internal process, you can key that process to this rule: the only releases that ship an installer are the ones that change the shell fingerprint. When the UI and installer versions differ, the app's About screen shows both, as in "Version {UI} (installer {shell})." If the installer version hasn't changed, the release changed only the UI.