Skip to content

feat(desktop): restart to update through Localify's installer - #93

Merged
teo-goulois merged 10 commits into
mainfrom
feat/auto-updates-app
Nov 9, 2026
Merged

teo-goulois merged 10 commits into
mainfrom
feat/auto-updates-app

Conversation

@teo-goulois

@teo-goulois teo-goulois commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Part B of automatic updates (ADR 0034): the desktop app's side. The app no longer installs anything. It reads the daemon's status and hands installs to the core's installer.

What it does

  • The app lock. run()'s first step is take_app_lock (new, src/infrastructure/updates/app_lock.rs). It takes an exclusive flock on app.lock and holds it for the app's lifetime.
    • If app.lock is held while update.lock is held too, an install is running. The app waits for it, and after a second it shows "Updating Localify…" (an osascript dialog, closed by its own child handle once the lock frees).
    • If app.lock stays held without update.lock, another copy of the app holds it. This copy doesn't wait, and the single-instance plugin hands its launch over to that copy. Before deciding, it tries app.lock again 5 times, 20 ms apart: an installer that just finished lets go of update.lock an instant before app.lock, and the daemon's check takes app.lock alone for an instant.
    • If no other copy takes the launch (the single-instance plugin kept this one), this copy takes the lock in setup, so the copy that runs always holds it.
    • Whatever it found, before the window opens, the app checks whether its bundle now holds a newer version than its own, and execs that executable if so. Only a newer one, so a version mismatch can't loop.
    • If the lock can't be taken, the app still starts. The installer's process check still covers it.
  • No Tauri replacement. Update::install, app.restart(), check_app_update, install_app_update, the AppUpdateProvider, and the app's own updater.log (in Localify Desktop/) are gone.
    • The Tauri updater plugin is removed entirely: the crate, its runtime registration and its Cargo.lock entries. Nothing used it for checking any more.
    • plugins.updater and createUpdaterArtifacts stay in tauri.conf.json. The bundler needs them to sign the .app.tar.gz that latest.json points at and that the installer verifies.
    • The only updater log now is the installer's, ~/Library/Application Support/Localify/updater.log.
  • Checking. Check now in Settings and the Check for Updates… menu run the core's coalesced state::check in-process. It shares the machine-wide cooldown and writes updates.json, which the daemon's status reads. The app never downloads: the installer downloads after the app quits.
  • Restart to update. It shows in Settings, and in a quiet banner under the nav that can be hidden for the session, when state == available && canInstall.
    • Its dialog names what closes: running service tabs (web in Shop (feat-cart)) and open shell tabs (Shell in Shop).
    • On confirm, restart_to_update spawns localify update install --relaunch from the bundle's own CLI (Contents/MacOS/localify). The child gets its own session (setsid in pre_exec), and stdin, stdout and stderr all go to /dev/null. Then the app quits through the same path as the tray's Quit, which releases app.lock.
    • canInstall also needs the open app's own bundle to pass part A's destination rules (InstallTarget::of on the bundle's CLI): the daemon's status describes the daemon's bundle, and the hand-off installs over this one. A copy opened from a DMG or Downloads shows no button and says why, even when the daemon could install. restart_to_update checks again right before it spawns and quits.
    • If the spawn fails, the error shows in the dialog and the app stays open. A dev build is refused by the same check, because the CLI next to it would open the installed app.
  • Settings. The card now sits right after Startup, since Settings has no "General" tab. It has Install updates automatically (bound to update_settings) and the status from update_status: the available version, the last check ("Last checked 2 hours ago."), and reason when an install waits or can't happen. With the daemon down, the app asks the bundle's CLI (localify update --status --json / --auto on|off).
  • Onboarding. The Activate step gets one line, "Updates install themselves, unless you turn them off in Settings" (run through unslop). It shows only when the status reads automatic on.

Changes to part A (please look)

Part A (#92) merged as 0cafd93; main is merged into this branch, and main's reviewed installer, extraction, staging, state store and error classification are kept as they are. This PR changes part A in these places:

  • update_status gains lastCheckAt (Unix seconds) and canInstall. canInstall is false when the destination rules or Linux block an install, and also when updates.json can't be trusted (describe_unreadable); reason then says why. Settings needed the last check, and the button needs to tell a blocker apart from "automatic is off" or a previous install error.
  • app_lock_path() is replaced by app_lock::take_app_lock(&UpdatePaths, on_wait) (new, src/infrastructure/updates/app_lock.rs), which keeps the lock protocol in the core next to the installer's take_locks. It opens lock files the same way the installer does.
  • Only a confirmed new install starts with automatic = true. The welcome flow can save the config, for example by toggling "connect agents", before localify install runs, so the install found an existing config and left automatic off.
    • A new install has no config, no old /etc/localify folder, and none of what an earlier install leaves: the root CA, certs/, the daemon's pid file and log, the launchd plist, the DNS resolver file, /var/log/localify (previous_install_traces). A place that can't be checked counts as there.
    • The app calls ConfigStore::start_new_install as it starts, before the window can save anything: on a new install it writes the config with automatic = true. localify install's save_defaults writes automatic only on a new install too, and now writes the config before creating directories, so its own directories don't count.
    • A missing or unreadable config reads automatic = false (ConfigStore::load is back to the defaults), so the daemon fails closed.
    • Moving a legacy /etc config into a user config that already exists keeps the legacy automatic, off when the key is absent (copy_config_to_user_folder).
  • InstallTarget::of(cli) is current()'s check for a given localify; current() now calls it. updates::blocker_of exposes it to the app.
  • docs/desktop.md, "Automatic updates", on main's text: the setting sentence (a missing config, and "Settings" instead of "Settings → General", a tab that doesn't exist), "One installer", "Is the app running", "With the app open", "Status" (the two new fields, the app's fallback to the bundle's CLI, and the open app's own check), "Where" (the app's own bundle) and "Files" (take_app_lock). ADR 0034's "With the app open" line no longer says the app downloads.

Nothing else in part A changed. The install's results reach the app through update_status: a previous bundle main's installer couldn't move to previous/ (Previous::Stranded) shows its path in Settings, since the status's reason now shows in every state, not only when an update waits.

Tested

On 4f8d2d2 (the fixes for the review of 1a07fac):

  • cargo fmt, cargo clippy --locked -- -D warnings, cargo test --locked: all pass, except app_bridge::tests::a_launch_with_no_answer_in_five_seconds_is_unanswered, which failed in both full runs (its upper time bound, under load) and passes alone. That file doesn't differ from main.
  • src-tauri: cargo fmt and cargo clippy --locked -- -D warnings pass, with the sidecar copied in and then removed. src-tauri's own tests were not run, because of disk space; this PR adds none there.
  • pnpm run typecheck and pnpm desktop:test (61 pass). This round changes nothing in desktop-ui/: the UI already hides Restart to update and shows reason when canInstall is false. oxlint ran on 1a07fac.
  • Each new test below was run with its fix reverted and failed, then passed with it.
  • Rust tests:
    • Lock: a free lock is held until the file closes; another app holding it isn't waited for; an install holding both locks is waited for, with the message; a short install ends before any message; an installer that lets go of both locks between the two probes is not another app (a test hook runs between the probes).
    • Handoff: a fake CLI runs with update install --relaunch, as its own process-group leader (from setsid), with empty stdin; a missing CLI fails with no side effects.
    • Commands: the status and the setting fall back to the CLI when the daemon is down, and a failing CLI's stderr becomes the error.
    • Status: canInstall is true without a blocker, false on Linux and with an unreadable updates.json; the JSON carries both new fields; an app whose own bundle can't be replaced gets canInstall = false with its reason, though the daemon says it can install; an open-app blocker keeps the reason of a status with nothing waiting.
    • Config: a missing or unreadable config reads automatic off; a new install writes it on, and a setting saved after the app's start keeps it on; an install over an earlier one's traces writes it off; a machine with an earlier install's CA doesn't start as a new one; a legacy migration after the welcome saved a setting leaves it off, and migrating takes automatic from the legacy config.
  • Node tests: what a restart closes, including tabs already ended; the status sentences, including last-check ages and a stranded previous bundle's path.

Not seen in a running app

None of this was run in a live app, on purpose: nothing ran against /Applications/Localify.app. Not seen:

  • the "Updating Localify…" dialog and the re-exec after a swap;
  • the single-instance plugin keeping a copy that found the lock held, and that copy taking it in setup;
  • start_new_install on a fresh Mac, and the status of a copy opened from a DMG;
  • the banner and the restart dialog's layout;
  • the handoff quitting the app and --relaunch reopening it;
  • the menu check's dialogs;
  • the onboarding line on a fresh machine.

The first end-to-end run needs a signed build older than a published release.

ADR 0034 and the contract in docs/desktop.md: the daemon installs signed
releases with the app closed, a paired machine on a newer version only
triggers an early check of the official feed, the app relaunches only
when no service runs in its tabs, and Linux keeps its manual install.
The daemon runs as the user, installs only while the app is closed and
only into a bundle the user owns; the app updates on the person's click
and never relaunches by itself; existing installs opt in; presence
versions are hints behind one coalesced, backed-off check; extraction
and signing requirements are spelled out; no automatic rollback in this
version.
Part A of ADR 0034. A Mac with the app closed now installs new releases
by itself, so a runner nobody sits at stops falling behind its paired
machines. There is one installer, in the core, used by the daemon
unattended and by `localify update install` when a person asks.

- The installer verifies the archive's minisign signature with the
  built-in updater key before extracting, extracts under the spec's rules
  (one Localify.app, no absolute or `..` paths, no hard links or special
  files, no setuid or setgid, internal relative symlinks never followed,
  1 GiB and 20,000 entries), checks the bundle's version, the
  strictly-newer and highest-installed rules, failed digests, and
  `codesign` pinned to Localify's team and bundle id. It swaps with
  renamex_np(RENAME_SWAP) under update.lock, only with app.lock free and
  no process running from the bundle, and keeps the previous bundle.
- The daemon checks 2 minutes after start, then every 6 h +/- 30 min, and
  early when a paired machine announces a newer version. Every check goes
  through one machine-wide coalesced operation with a 10-minute cooldown,
  doubled after failures up to 6 h, persisted in updates.json.
- `update_status` and `update_settings` on the management socket, and
  `localify update [--status] [--auto on|off]` and
  `localify update install [--relaunch]` in the CLI.
- `[updates] automatic`: `localify install` writes it on for a new
  install; a config without it keeps updating by hand.
- Linux only reports "Update available: run the install command again";
  its release build now gets the manifest's address.

New dependencies: flate2 and tar (no default features, so no xattr) to
read the release .tar.gz; minisign-verify, the crate the app's updater
already uses for this signature format; semver, already in the tree, to
compare versions; ed25519-dalek as a dev-dependency to sign test archives.
The app's welcome can save a setting, such as connecting agents, before
localify install runs. The install then found a config and left
[updates] automatic off, so a new install through the app never updated
by itself. A missing config now reads as a new install's.
The app no longer installs updates itself. It takes app.lock as its
first step and holds it for its lifetime, waiting with an Updating
message while an install holds it. It reads update_status, shows the
available version in Settings and as a banner, and Restart to update
lists the service and shell tabs it closes, then hands off to the
bundle's localify update install --relaunch, detached in its own
session, and quits. Settings gets Install updates automatically, bound
to update_settings, with the last check and why an install can't
happen, so update_status now carries lastCheckAt and canInstall.

Tauri's updater plugin, Update::install, app.restart() and the app's
own updater.log are gone. Check now and the Check for Updates menu run
the core's coalesced check.
Part A merged as 0cafd93. Its reviewed installer, extraction, staging, state store and error classification win; part B's own changes are applied on top: take_app_lock in place of app_lock_path(), lastCheckAt and canInstall in update_status, and a missing config read as a new install's.
The status's reason names the staging directory that holds the replaced bundle when it couldn't move to previous/, also when up to date or never checked. Settings showed only the last check in those states.
@teo-goulois
teo-goulois marked this pull request as ready for review November 9, 2026 19:18
… a copy that can't be replaced

- Only a confirmed new install starts with automatic updates on: no config,
  no old /etc/localify folder, and nothing an earlier install left. The app
  writes that config as it starts, before its welcome can save a setting,
  and `localify install` checks the same before creating anything. A
  missing or unreadable config reads off again, and migrating a legacy
  config keeps its `automatic`, off when it has none.
- An installer that lets go of both locks between the app's two probes no
  longer counts as another app: `app.lock` is tried again before that. A
  copy the single-instance plugin keeps takes the lock then, and the app
  always checks for a newer bundle before its window opens.
- `canInstall` and `Restart to update` also need the open app's own bundle
  to pass the installer's destination rules, checked again right before the
  hand-off, so a copy opened from a DMG or Downloads no longer quits for an
  install that would be refused.
…tart for an update once

An install from before the user daemon can leave only its root helper,
its copies or its run files after a partial cleanup. Those now count, as
does a folder that can't be read, so such a Mac never starts with
automatic updates on. The app's restart onto a newer installed bundle now
happens at most once and compares by semver precedence, so a bundle whose
Info.plist names a newer version than its binary can't loop. The bridge
timing test allows a loaded run more time before calling it a hang.
@teo-goulois
teo-goulois merged commit 35ebefa into main Nov 9, 2026
4 checks passed
@teo-goulois
teo-goulois deleted the feat/auto-updates-app branch November 9, 2026 19:59
@teo-goulois teo-goulois mentioned this pull request Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant