Repository navigation
feat(desktop): restart to update through Localify's installer - #93
Merged
Merged
Conversation
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
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.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
run()'s first step istake_app_lock(new,src/infrastructure/updates/app_lock.rs). It takes an exclusiveflockonapp.lockand holds it for the app's lifetime.app.lockis held whileupdate.lockis held too, an install is running. The app waits for it, and after a second it shows "Updating Localify…" (anosascriptdialog, closed by its own child handle once the lock frees).app.lockstays held withoutupdate.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 triesapp.lockagain 5 times, 20 ms apart: an installer that just finished lets go ofupdate.lockan instant beforeapp.lock, and the daemon's check takesapp.lockalone for an instant.setup, so the copy that runs always holds it.execs that executable if so. Only a newer one, so a version mismatch can't loop.Update::install,app.restart(),check_app_update,install_app_update, theAppUpdateProvider, and the app's ownupdater.log(inLocalify Desktop/) are gone.Cargo.lockentries. Nothing used it for checking any more.plugins.updaterandcreateUpdaterArtifactsstay intauri.conf.json. The bundler needs them to sign the.app.tar.gzthatlatest.jsonpoints at and that the installer verifies.~/Library/Application Support/Localify/updater.log.Check nowin Settings and theCheck for Updates…menu run the core's coalescedstate::checkin-process. It shares the machine-wide cooldown and writesupdates.json, which the daemon's status reads. The app never downloads: the installer downloads after the app quits.state == available && canInstall.web in Shop (feat-cart)) and open shell tabs (Shell in Shop).restart_to_updatespawnslocalify update install --relaunchfrom the bundle's own CLI (Contents/MacOS/localify). The child gets its own session (setsidinpre_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 releasesapp.lock.canInstallalso needs the open app's own bundle to pass part A's destination rules (InstallTarget::ofon 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_updatechecks again right before it spawns and quits.openthe installed app.Install updates automatically(bound toupdate_settings) and the status fromupdate_status: the available version, the last check ("Last checked 2 hours ago."), andreasonwhen 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).automaticon.Changes to part A (please look)
Part A (#92) merged as 0cafd93;
mainis 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_statusgainslastCheckAt(Unix seconds) andcanInstall.canInstallis false when the destination rules or Linux block an install, and also whenupdates.jsoncan't be trusted (describe_unreadable);reasonthen 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 byapp_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'stake_locks. It opens lock files the same way the installer does.automatic = true. The welcome flow can save the config, for example by toggling "connect agents", beforelocalify installruns, so the install found an existing config and leftautomaticoff./etc/localifyfolder, 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.ConfigStore::start_new_installas it starts, before the window can save anything: on a new install it writes the config withautomatic = true.localify install'ssave_defaultswritesautomaticonly on a new install too, and now writes the config before creating directories, so its own directories don't count.automatic = false(ConfigStore::loadis back to the defaults), so the daemon fails closed./etcconfig into a user config that already exists keeps the legacyautomatic, off when the key is absent (copy_config_to_user_folder).InstallTarget::of(cli)iscurrent()'s check for a givenlocalify;current()now calls it.updates::blocker_ofexposes 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 toprevious/(Previous::Stranded) shows its path in Settings, since the status'sreasonnow 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, exceptapp_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 frommain.src-tauri:cargo fmtandcargo clippy --locked -- -D warningspass, 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 typecheckandpnpm desktop:test(61 pass). This round changes nothing indesktop-ui/: the UI already hidesRestart to updateand showsreasonwhencanInstallis false.oxlintran on 1a07fac.update install --relaunch, as its own process-group leader (fromsetsid), with empty stdin; a missing CLI fails with no side effects.canInstallis true without a blocker, false on Linux and with an unreadableupdates.json; the JSON carries both new fields; an app whose own bundle can't be replaced getscanInstall = falsewith its reason, though the daemon says it can install; an open-app blocker keeps the reason of a status with nothing waiting.automaticoff; 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 takesautomaticfrom the legacy config.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:execafter a swap;setup;start_new_installon a fresh Mac, and the status of a copy opened from a DMG;--relaunchreopening it;The first end-to-end run needs a signed build older than a published release.