Skip to main content

Client Updates

Full source: development/UPDATES.md.

The release pipeline publishes a GitHub Release with named files attached. This is how the three clients notice one and take it.

There is no store, no update server and no latest.yml. Every client reads the same release list; what differs is what it can do with what it finds.

ClientWhat an update isWho installs it
Desktop (Windows)BetweenUs-<version>-Setup.exethe NSIS installer, silently
Desktop (Linux)BetweenUs-<version>.AppImagethe app, writing it over its own file
AndroidBetweenUs-<version>-<abi>.apkAndroid's package installer
Weba reloadnobody — the deployment was already updated

Desktop​

Windows ships one build: an installer. A portable exe shipped beside it until the pair of them made every update a question of which build was asking, and handing a portable copy the installer installs a second BetweenUs rather than updating the first. One build, one asset, no question.

A release that built the other platforms only offers this one nothing, rather than something it cannot apply: Windows is only ever handed the setup exe and Linux only ever the AppImage.

The installer​

Assisted rather than one-click, and per user:

Where it goesthe user chooses, and an update keeps that choice
Elevationnone — a per-user install never asks for an administrator
Shortcutsdesktop and start menu
Uninstallleaves AppData, so reinstalling does not lose anyone's keys

Applying it​

The download waits in <userData>/updates. Restart and install starts it as --updated /S --force-run — silent, into the directory already chosen, and BetweenUs starts again when it is done — and this process quits so NSIS has nothing left to close. Started with no arguments it opens its wizard behind the running app, which is exactly what made the button look dead.

If it cannot be started at all, the download is on the disk and runnable, so the file manager opens on it and the reason is shown.

Starting BetweenUs when the installer is done​

electron-builder starts the app from two places — the finish page's "Run BetweenUs" checkbox, and the silent install an update performs — and both went through StdUtils::ExecShellAsUser, whose job is handing a launch down from an elevated installer to the signed-in user. It does that by asking the desktop shell to run the file on its behalf, and the error it fails with is discarded. A shell that will not take the call ends both paths the same way: the installer finishes and nothing opens, after a first install and after an update alike.

This installer is never elevated, so there is nothing to hand a launch down from. apps/desktop/nsis/installer.nsh replaces both with a plain ExecShell on the installed executable — the finish page through customFinishPage, the silent update through customInstall. A machine where the old call worked now starts the app twice; the second copy sees the single-instance lock, hands the window to the first and quits.

Linux: one AppImage, replaced in place​

Linux ships one build too, an AppImage, for the same reason Windows ships an installer: it is the one format that can update itself. There is no installer to run. The update is writing the new AppImage over the file the app was started from, which the AppImage runtime names in APPIMAGE. Replacing a running AppImage is safe, because the FUSE mount holds the old inode open. The file is copied beside the target and renamed over it, so a failure leaves the old build runnable, and the app relaunches from the same path.

That only works when the file is somewhere the user can write, which is what the installer script is for:

curl -fsSL https://raw.githubusercontent.com/aiyu-ayaan/BetweenUs/master/scripts/install-linux.sh | sh
Where it goes~/.local/share/betweenus/BetweenUs.AppImage, so the updater can write over it
Elevationnone: per user, and it refuses to run as root
Shortcutsa menu entry and icon, plus a betweenus command in ~/.local/bin
Integritythe download is checked against the sha256 GitHub records for the asset
Channels--channel beta / --channel alpha, the same cumulative rules as the app
Uninstall--uninstall, which leaves ~/.config/@betweenus/desktop, so keys survive a reinstall

Re-running it is always safe and is the manual update path. --version pins a release and --from-file installs a local AppImage.

An AppImage run from anywhere else still works. It updates itself as long as its own directory is writable. A linux-unpacked tree has no single file to replace, so it reports itself as unpacked and is never offered an update.

Why it starts in about a second. compression: maximum is right for the NSIS installer, which is unpacked once. The legacy AppImage toolset turned it into xz with 1 MB blocks, and an AppImage is read page by page through FUSE for as long as it runs: a cold start took 90 seconds. The builder now uses the static AppImage runtime (toolsets.appimage in electron-builder.yml). It compresses with zstd, starts in about 1.5 s on the same machine, and carries its own FUSE client, so it runs without libfuse2, which Ubuntu 22.04 and later do not install. Every release boots the AppImage in CI before publishing it (.github/scripts/smoke-appimage.sh).

Sandbox. An AppImage cannot ship a setuid chrome-sandbox, so Chromium relies on unprivileged user namespaces. Where the kernel refuses them (Ubuntu 24.04's AppArmor policy), the AppImage's own launcher detects that and passes --no-sandbox. That holds for a relaunch after an update and a start from the session too, because both go through the AppImage file.

Start with the system​

On by default on both platforms, and switched in Settings → Notifications → This computer.

WindowsLinux
Mechanismapp.setLoginItemSettings (the Run key)an XDG autostart entry, ~/.config/autostart/betweenus.desktop
Started as--hidden, straight to the traythe same
Dev channelits own entry, per app namebetweenus-dev.desktop

setLoginItemSettings does nothing on Linux: no error and no effect. So electron/autostart.ts writes the entry that GNOME, KDE, Cinnamon, XFCE and MATE all honour. It is rewritten on every launch while the switch is on, because an AppImage can be moved and the entry has to follow it. Exec is quoted the way the Desktop Entry spec asks, so an install path with a space still starts. A development window never registers itself on either platform.

When it downloads​

A check finds an offer and fetches it there and then: the download is the slow half and it is the half that can happen quietly. Installing is always asked for. A download that is still waiting when the app is closed is picked back up on the next launch — the file name carries the version — and anything that is no longer an upgrade on this build is deleted.

The notes are drawn as markdown​

A release body is ### Features with a list under it, **bold**, a fenced block of shell. Every client used to show that text exactly as it arrived, hashes and asterisks included. All three now render it.

It is the message parser doing it, with one rule switched on:

parse(text) chat no headings - a heading in a chat line is shouting
parseNotes(text) release notes headings, and a table's `| --- |` rule swallowed

The drawing is per client — components/ReleaseNotes.tsx on desktop and web, feature/update/ReleaseNotes.kt on Android — because the message list lays custom emoji and link previews over its blocks and none of that belongs in a changelog. Headings come out at two sizes, lists get a marker gutter, fenced code gets its own ground.

Tables are not parsed: the rule row is swallowed, the rows stay as pipes.

Channels​

Cumulative, and the same three as Android:

ChannelOffered
stablefinished releases only
betabetas, and every stable release
alphaeverything

The default is the channel this build belongs to, so an alpha install isn't stranded on stable until the version it's running is released. It's stored in betweenus-settings.json — a property of this copy of the app, not of the account. Changing it in Settings → Updates takes effect on the spot and re-checks; the offer in hand is dropped, because it was picked on the old channel.

"Newer" is by version and never by publish date: a stable release cut after an alpha is not an upgrade for somebody running that alpha.

It checks on launch and then every six hours, plus a Check for updates button in Settings → Updates. The GitHub API is unauthenticated — sixty requests an hour per address — which is why a refused check is reported rather than retried.

Why not electron-updater​

It would want a latest.yml published alongside the assets, a publish block in the builder config, and a second release path to keep working. The rules here are two hundred lines with a self-check beside them (electron/updates.check.ts).

Web: notice, then reload​

A tab can't install anything. The deployment is updated by whoever runs it and a tab picks the new build up when it reloads, so the whole feature is noticing that a reload is now worth doing.

What it watches is the asset fingerprint, not a version number. Vite names every built file index-<hash>.js and the hash moves exactly when the contents do; index.html is the one unhashed file and lists which hashes are current.

running = the /assets/… names in this document
served = the /assets/… names in a no-store fetch of /index.html
different, and neither empty → offer a reload

Nothing has to be remembered at release time. That's why it isn't the web client's package.json version — the release workflow bumps the root and desktop manifests only, so a check against that number would never fire.

Either side coming back empty is "cannot tell", never "changed": a development server, a 502 page, a proxy's holding page and an offline tab all land there, and prompting a reload on a failed fetch is a reload loop.

A visible tab asks every five minutes, and any tab asks the moment it's brought back to the front — the cheapest moment, and the one where a week-old tab is most likely to be stale.

Android​

A check on launch, a daily WorkManager job on unmetered network for a phone nobody has opened, the per-ABI APK rather than the universal one, and PackageInstaller for the install so a refusal comes back with a reason. See the Android client.

The asset names are the contract​

BetweenUs-<version>-Setup.exe desktop (Windows), installed
BetweenUs-<version>.AppImage desktop (Linux, x86_64)
BetweenUs-<version>-<abi>.apk Android, per ABI
BetweenUs-<version>-universal.apk Android, fallback

Renaming any of these silently stops that client being offered updates: the check still runs, finds the release, and finds nothing in it it can apply. They are set in apps/desktop/electron-builder.yml and in the android job of .github/workflows/release.yml. The Linux installer script matches the same AppImage name, so it depends on this contract too.

Carried-forward artifacts keep the version they were built for in their file name, which is correct — the version a client compares against comes from the release tag, not from the name.