From 93ad16b87aa9c7d81f8636d08a98a64b70c83d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9o=20Goulois?= Date: Mon, 9 Nov 2026 18:18:49 +0100 Subject: [PATCH 1/9] docs: machines update themselves, through the daemon 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. --- docs/adr/0034-machines-update-themselves.md | 16 ++++++++++++++++ docs/desktop.md | 14 ++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 docs/adr/0034-machines-update-themselves.md diff --git a/docs/adr/0034-machines-update-themselves.md b/docs/adr/0034-machines-update-themselves.md new file mode 100644 index 0000000..8923ad2 --- /dev/null +++ b/docs/adr/0034-machines-update-themselves.md @@ -0,0 +1,16 @@ +# Machines Update Themselves + +Each Mac with Localify installs new versions by itself, and a machine that sees a paired machine on a newer version checks for it at once. The founder asked for it after updating his laptop to 1.6 and finding his runner still on 1.5, so the new remote controls could not reach it. + +**The daemon installs, not the app.** The app's updater only runs while the app is open, and a runner often has it closed. The daemon is always running. It reads the same signed manifest the app reads, downloads the same archive, checks its signature against the public key built into Localify, and replaces the app bundle. A file that fails the signature check is never installed. The daemon already restarts on the new binary when its file changes. Only official releases are installed, never a file a peer or the relay sent. + +**Paired machines only trigger a check.** `remote_machines` already knows each machine's version. When a paired machine reports a version newer than this one, this machine checks the manifest now instead of waiting for its next scheduled check. A peer cannot supply a build, pick a version, or force an install: the most it can do is make this machine check the official feed early. That needs no new permission and no consent switch. + +**Running services come first.** +- **Background runs** survive the daemon restarting, as they do today. +- **Services in the app's tabs** belong to the app, and they stop when it relaunches. So after installing, the daemon tells the open app that an update is ready. The app shows it and relaunches by itself only once no service runs in its tabs. While a service does, it shows `Restart to update` and waits for the person. +- **The CLI** (`localify` on `PATH`) is the app bundle's binary, so it updates with it. + +**On by default, one switch.** Settings → General gets `Install updates automatically`, on for new installs and for existing ones. A person who wants to choose when to update turns it off, and the app's `Check for updates` works as today. With it off, a newer paired machine only makes this one show "Update available". + +**macOS only for now.** On Linux, the CLI is a user binary installed by `install.sh`, checked by SHA-256 only. Installing it unattended would need signed Linux artifacts first. Until then, a Linux machine that sees a newer version says "Update available: run the install command again". diff --git a/docs/desktop.md b/docs/desktop.md index e98e465..476ae14 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -118,6 +118,20 @@ changed and stayed the same on two checks, the daemon exits with code 75, and la starts the new build. `ThrottleInterval` is 1 second, so launchd adds at most that. A daemon started another way, such as a dev build, is left alone. +### Automatic updates + +Each Mac installs new versions by itself (ADR 0034). The daemon does it, so it works with the app closed. + +- **Setting:** `Install updates automatically` in Settings → General, stored as `[updates] automatic` in the config, on by default. The management socket's `update_settings {automatic}` changes it. With it off, nothing below installs; the app's `Check for updates` works as before. +- **When it checks:** 2 minutes after the daemon starts, then every 6 hours plus or minus 30 minutes, and at once when `remote_machines` reports a paired machine on a newer version than this one, at most once every 10 minutes for that reason. A check reads the manifest the app's updater reads (`latest.json`, from the build's updater endpoint). +- **What it installs:** only a version strictly newer than the running one, never a downgrade. It downloads `localify-desktop.app.tar.gz`, verifies its signature against the updater public key built into Localify, and extracts it to a staging folder next to the installed app, on the same volume. The extracted bundle's `CFBundleShortVersionString` must equal the manifest's version, and its code signature must pass `codesign --verify --deep --strict` with Localify's team identifier. Anything that fails is deleted, logged in `updater.log` and retried at the next scheduled check. +- **Where:** the bundle that contains the daemon's own executable. A daemon not running from a `.app` bundle, such as a dev build or a Homebrew binary, never installs. The new bundle gets the owner and mode of the old one. +- **Swap:** one `renamex_np(RENAME_SWAP)` exchanges the staged bundle and the installed one, so the path always holds a whole app. Before the swap, the daemon writes `/update-pending.json` with both versions and a start count of 0. The new daemon's first step, before anything else, raises that count; once it answers on the management socket, it deletes the old bundle and the file. A daemon that finds the count at 3 swaps the old bundle back, turns the setting off with the reason shown in Settings, and exits, so launchd starts the old build. +- **One installer at a time:** the daemon and the app both take `/update.lock` before they install. The app's `Install update` button asks the daemon to install (`update_install`), and falls back to the app's own updater only when the daemon predates this. +- **The app, after an install:** the daemon tells the open app through the app bridge (`{"cmd": "update_ready", "version"}`). The app shows "Localify is installed" and relaunches by itself as soon as no service runs in its tabs. While one does, it shows `Restart to update` and waits. Background runs and the CLI are not affected: background runs survive the daemon's restart, and `localify` on `PATH` is the bundle's own binary. +- **Linux:** no unattended install, since the CLI there is checked by SHA-256 only. A Linux daemon that sees a newer version in the manifest or among paired machines says "Update available: run the install command again" in `localify status` and in the app. + + ## Launch at Login Successful desktop activation registers Localify with the official Tauri From 55b38b46c277da175a413f7db5f29f7496681947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9o=20Goulois?= Date: Mon, 9 Nov 2026 18:25:21 +0100 Subject: [PATCH 2/9] docs: automatic updates, revised after review 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. --- docs/adr/0034-machines-update-themselves.md | 18 +++++++--------- docs/desktop.md | 24 +++++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/adr/0034-machines-update-themselves.md b/docs/adr/0034-machines-update-themselves.md index 8923ad2..d939b96 100644 --- a/docs/adr/0034-machines-update-themselves.md +++ b/docs/adr/0034-machines-update-themselves.md @@ -1,16 +1,14 @@ # Machines Update Themselves -Each Mac with Localify installs new versions by itself, and a machine that sees a paired machine on a newer version checks for it at once. The founder asked for it after updating his laptop to 1.6 and finding his runner still on 1.5, so the new remote controls could not reach it. +A Mac with Localify can install new versions by itself, and a machine that sees a paired machine on a newer version checks for one at once. The founder asked for it after updating his laptop to 1.6 and finding his runner still on 1.5, so the new remote controls could not reach it. -**The daemon installs, not the app.** The app's updater only runs while the app is open, and a runner often has it closed. The daemon is always running. It reads the same signed manifest the app reads, downloads the same archive, checks its signature against the public key built into Localify, and replaces the app bundle. A file that fails the signature check is never installed. The daemon already restarts on the new binary when its file changes. Only official releases are installed, never a file a peer or the relay sent. +**Who installs.** +- **With the app closed, the daemon installs.** That covers a runner nobody sits at. The daemon already runs as the installing user, not as root, and installs only where that user can replace the app. This feature adds no privileged process. +- **With the app open, the app installs, and only when the person says so.** Its updater downloads in the background and offers `Restart to update`. It never relaunches by itself: a relaunch closes service tabs and shell tabs, and an idle check cannot prove that no one is using them. +- **One installer at a time.** The daemon never replaces a bundle an app is running from. A person dragging a DMG in at the same moment cannot be prevented, and the daemon notices the change and stops. -**Paired machines only trigger a check.** `remote_machines` already knows each machine's version. When a paired machine reports a version newer than this one, this machine checks the manifest now instead of waiting for its next scheduled check. A peer cannot supply a build, pick a version, or force an install: the most it can do is make this machine check the official feed early. That needs no new permission and no consent switch. +**Paired machines only trigger a check.** A paired machine's version comes from the relay's presence, which is not authenticated, so it is a hint and nothing more. When it is newer than this machine's, this machine checks the official manifest early, within a machine-wide cooldown. A hint never chooses a build or proves that one exists. Only a release signed with Localify's updater key, whose bundle is signed by Localify's Developer ID, is ever installed. -**Running services come first.** -- **Background runs** survive the daemon restarting, as they do today. -- **Services in the app's tabs** belong to the app, and they stop when it relaunches. So after installing, the daemon tells the open app that an update is ready. The app shows it and relaunches by itself only once no service runs in its tabs. While a service does, it shows `Restart to update` and waits for the person. -- **The CLI** (`localify` on `PATH`) is the app bundle's binary, so it updates with it. +**Opt-in for existing installs, on for new ones.** Unattended updates restart the daemon, which drops proxied and remote connections for a moment. Someone who installed Localify before this version keeps updating by hand until they turn it on: in Settings, or with `localify update --auto on`, which works over SSH on a runner. New installs start with it on, and the welcome screen says so. -**On by default, one switch.** Settings → General gets `Install updates automatically`, on for new installs and for existing ones. A person who wants to choose when to update turns it off, and the app's `Check for updates` works as today. With it off, a newer paired machine only makes this one show "Update available". - -**macOS only for now.** On Linux, the CLI is a user binary installed by `install.sh`, checked by SHA-256 only. Installing it unattended would need signed Linux artifacts first. Until then, a Linux machine that sees a newer version says "Update available: run the install command again". +**No automatic rollback in this version.** A recovery that works when the new build cannot even start needs a supervisor outside that build, and this version does not ship one. The installer keeps the previous bundle and documents how to put it back. Linux stays notification-only, since its CLI is checked by SHA-256 and not signed. diff --git a/docs/desktop.md b/docs/desktop.md index 476ae14..f7ac082 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -120,17 +120,19 @@ daemon started another way, such as a dev build, is left alone. ### Automatic updates -Each Mac installs new versions by itself (ADR 0034). The daemon does it, so it works with the app closed. - -- **Setting:** `Install updates automatically` in Settings → General, stored as `[updates] automatic` in the config, on by default. The management socket's `update_settings {automatic}` changes it. With it off, nothing below installs; the app's `Check for updates` works as before. -- **When it checks:** 2 minutes after the daemon starts, then every 6 hours plus or minus 30 minutes, and at once when `remote_machines` reports a paired machine on a newer version than this one, at most once every 10 minutes for that reason. A check reads the manifest the app's updater reads (`latest.json`, from the build's updater endpoint). -- **What it installs:** only a version strictly newer than the running one, never a downgrade. It downloads `localify-desktop.app.tar.gz`, verifies its signature against the updater public key built into Localify, and extracts it to a staging folder next to the installed app, on the same volume. The extracted bundle's `CFBundleShortVersionString` must equal the manifest's version, and its code signature must pass `codesign --verify --deep --strict` with Localify's team identifier. Anything that fails is deleted, logged in `updater.log` and retried at the next scheduled check. -- **Where:** the bundle that contains the daemon's own executable. A daemon not running from a `.app` bundle, such as a dev build or a Homebrew binary, never installs. The new bundle gets the owner and mode of the old one. -- **Swap:** one `renamex_np(RENAME_SWAP)` exchanges the staged bundle and the installed one, so the path always holds a whole app. Before the swap, the daemon writes `/update-pending.json` with both versions and a start count of 0. The new daemon's first step, before anything else, raises that count; once it answers on the management socket, it deletes the old bundle and the file. A daemon that finds the count at 3 swaps the old bundle back, turns the setting off with the reason shown in Settings, and exits, so launchd starts the old build. -- **One installer at a time:** the daemon and the app both take `/update.lock` before they install. The app's `Install update` button asks the daemon to install (`update_install`), and falls back to the app's own updater only when the daemon predates this. -- **The app, after an install:** the daemon tells the open app through the app bridge (`{"cmd": "update_ready", "version"}`). The app shows "Localify is installed" and relaunches by itself as soon as no service runs in its tabs. While one does, it shows `Restart to update` and waits. Background runs and the CLI are not affected: background runs survive the daemon's restart, and `localify` on `PATH` is the bundle's own binary. -- **Linux:** no unattended install, since the CLI there is checked by SHA-256 only. A Linux daemon that sees a newer version in the manifest or among paired machines says "Update available: run the install command again" in `localify status` and in the app. - +A Mac can install new versions by itself (ADR 0034). + +- **Setting:** `[updates] automatic` in the config. It starts on for new installs and off for installs that predate it. Settings → General has `Install updates automatically`, and the CLI has `localify update --auto on|off`. The management socket's `update_settings {automatic}` changes it, and every install checks it again just before it replaces anything. +- **Checks:** 2 minutes after the daemon starts, then every 6 hours plus or minus 30 minutes. Also when `remote_machines` shows a paired machine on a newer version: such a version comes from the relay's presence and is only a hint. Every check, scheduled, triggered or manual, goes through one coalesced operation with a machine-wide cooldown of 10 minutes, doubled after each failure up to 6 hours. The last check's time is persisted, so a restart doesn't reset it. A check reads the manifest the app's updater reads (`latest.json`, from the build's updater endpoint) over HTTPS. That manifest is discovery, not authority. +- **Trust:** the archive's minisign signature is checked against the updater public key built into Localify before anything is extracted. Only a version strictly newer than both the installed bundle's `CFBundleShortVersionString` and the highest version this machine ever installed is accepted. A build that failed here before, recorded by its archive SHA-256, is skipped until the next release. After extraction, the bundle's version must equal the manifest's. `codesign --verify --deep --strict` must pass with a requirement anchored to Apple and pinned to Localify's team ID and bundle identifier, covering the daemon's executable too. Freshness is not guaranteed: a compromised feed could withhold an update or offer an older valid one to an older client, and this version accepts that. +- **Extraction:** into a new private directory (0700) on the destination's volume, made by the installer. The archive may hold exactly one `Localify.app`, with no absolute or `..` paths, no hard links, no device or FIFO entries, no setuid or setgid bits, and only relative symlinks that stay inside the bundle. Symlinks are never followed during extraction. The expanded size is capped at 1 GiB and 20,000 entries. Quarantine is not added and never cleared; the archive is notarized and stapled. +- **Where:** only the production bundle whose executable is the daemon's, at `/Applications/Localify.app` or `~/Applications/Localify.app`, when the user owns it and can replace it. A dev build, a translocated or read-only bundle, a mounted DMG, or a location that would need authorization stays manual, and Settings says why. +- **When the daemon installs:** only while no Localify app process runs from that bundle. While the app is open, the daemon leaves the install to the app (below), and only reports the update as available. +- **Swap:** under `~/Library/Application Support/Localify/update.lock`, a path that doesn't depend on the configurable data dir. The installer checks again that the destination is the same inode and version it verified, and fails without touching it when the volume lacks `RENAME_SWAP`. It then exchanges the staged bundle and the installed one with `renamex_np`. The old bundle moves to `~/Library/Application Support/Localify/previous/.app`, which keeps one previous version. The daemon restarts on the new binary, as it already does after any replacement. Anything that replaces the bundle meanwhile, a DMG or the app's own updater, makes the installer stop and leave things as they are. +- **With the app open:** the app's updater downloads in the background when `automatic` is on and shows `Restart to update`. Restarting lists what it will close (service tabs and shell tabs) and installs on the person's click, under the same lock. The app never relaunches by itself. +- **Status:** `update_status` on the management socket answers `{installed, available, state, reason}`, and so does `localify update --status`. That is what the app and the CLI read, so nothing depends on a notification arriving. +- **Recovery:** no automatic rollback. If a new version misbehaves, `~/Library/Application Support/Localify/previous/` holds the last one: quit Localify, put that bundle back in place of the installed one, and turn `automatic` off until a fixed release. +- **Linux:** no unattended install, because the CLI there is checked by SHA-256 only. A Linux daemon that sees a newer version says "Update available: run the install command again" in `localify status` and in the app. ## Launch at Login From 25ac22d129aea33bd8e3218685118e8225c224b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9o=20Goulois?= Date: Mon, 9 Nov 2026 18:27:14 +0100 Subject: [PATCH 3/9] docs: one installer, an app lifetime lock, and manual installs with automatic off --- docs/adr/0034-machines-update-themselves.md | 4 ++-- docs/desktop.md | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/adr/0034-machines-update-themselves.md b/docs/adr/0034-machines-update-themselves.md index d939b96..cb24a01 100644 --- a/docs/adr/0034-machines-update-themselves.md +++ b/docs/adr/0034-machines-update-themselves.md @@ -4,8 +4,8 @@ A Mac with Localify can install new versions by itself, and a machine that sees **Who installs.** - **With the app closed, the daemon installs.** That covers a runner nobody sits at. The daemon already runs as the installing user, not as root, and installs only where that user can replace the app. This feature adds no privileged process. -- **With the app open, the app installs, and only when the person says so.** Its updater downloads in the background and offers `Restart to update`. It never relaunches by itself: a relaunch closes service tabs and shell tabs, and an idle check cannot prove that no one is using them. -- **One installer at a time.** The daemon never replaces a bundle an app is running from. A person dragging a DMG in at the same moment cannot be prevented, and the daemon notices the change and stops. +- **With the app open, the update waits for the person.** The app downloads in the background and offers `Restart to update`. On that click, it hands off to Localify's own installer and quits, and the installer opens the new version. It never relaunches by itself: a relaunch closes service tabs and shell tabs, and an idle check cannot prove that no one is using them. Tauri's own replacement is not used, so there is one installer with one set of rules. +- **One installer at a time.** The app holds a lock for its lifetime, and the installer also looks for any process running from the bundle, so it never replaces a bundle in use. That process check covers older apps that take no lock. A person dragging a DMG in at the very same moment is not supported. **Paired machines only trigger a check.** A paired machine's version comes from the relay's presence, which is not authenticated, so it is a hint and nothing more. When it is newer than this machine's, this machine checks the official manifest early, within a machine-wide cooldown. A hint never chooses a build or proves that one exists. Only a release signed with Localify's updater key, whose bundle is signed by Localify's Developer ID, is ever installed. diff --git a/docs/desktop.md b/docs/desktop.md index f7ac082..4c54f89 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -122,14 +122,16 @@ daemon started another way, such as a dev build, is left alone. A Mac can install new versions by itself (ADR 0034). -- **Setting:** `[updates] automatic` in the config. It starts on for new installs and off for installs that predate it. Settings → General has `Install updates automatically`, and the CLI has `localify update --auto on|off`. The management socket's `update_settings {automatic}` changes it, and every install checks it again just before it replaces anything. +- **Setting:** `[updates] automatic` in the config. It starts on for new installs and off for installs that predate it. Settings → General has `Install updates automatically`, and the CLI has `localify update --auto on|off`. The management socket's `update_settings {automatic}` changes it. An unattended install requires `automatic` to be on just before it replaces anything; an install a person confirms works whether it is on or off. - **Checks:** 2 minutes after the daemon starts, then every 6 hours plus or minus 30 minutes. Also when `remote_machines` shows a paired machine on a newer version: such a version comes from the relay's presence and is only a hint. Every check, scheduled, triggered or manual, goes through one coalesced operation with a machine-wide cooldown of 10 minutes, doubled after each failure up to 6 hours. The last check's time is persisted, so a restart doesn't reset it. A check reads the manifest the app's updater reads (`latest.json`, from the build's updater endpoint) over HTTPS. That manifest is discovery, not authority. - **Trust:** the archive's minisign signature is checked against the updater public key built into Localify before anything is extracted. Only a version strictly newer than both the installed bundle's `CFBundleShortVersionString` and the highest version this machine ever installed is accepted. A build that failed here before, recorded by its archive SHA-256, is skipped until the next release. After extraction, the bundle's version must equal the manifest's. `codesign --verify --deep --strict` must pass with a requirement anchored to Apple and pinned to Localify's team ID and bundle identifier, covering the daemon's executable too. Freshness is not guaranteed: a compromised feed could withhold an update or offer an older valid one to an older client, and this version accepts that. - **Extraction:** into a new private directory (0700) on the destination's volume, made by the installer. The archive may hold exactly one `Localify.app`, with no absolute or `..` paths, no hard links, no device or FIFO entries, no setuid or setgid bits, and only relative symlinks that stay inside the bundle. Symlinks are never followed during extraction. The expanded size is capped at 1 GiB and 20,000 entries. Quarantine is not added and never cleared; the archive is notarized and stapled. - **Where:** only the production bundle whose executable is the daemon's, at `/Applications/Localify.app` or `~/Applications/Localify.app`, when the user owns it and can replace it. A dev build, a translocated or read-only bundle, a mounted DMG, or a location that would need authorization stays manual, and Settings says why. -- **When the daemon installs:** only while no Localify app process runs from that bundle. While the app is open, the daemon leaves the install to the app (below), and only reports the update as available. -- **Swap:** under `~/Library/Application Support/Localify/update.lock`, a path that doesn't depend on the configurable data dir. The installer checks again that the destination is the same inode and version it verified, and fails without touching it when the volume lacks `RENAME_SWAP`. It then exchanges the staged bundle and the installed one with `renamex_np`. The old bundle moves to `~/Library/Application Support/Localify/previous/.app`, which keeps one previous version. The daemon restarts on the new binary, as it already does after any replacement. Anything that replaces the bundle meanwhile, a DMG or the app's own updater, makes the installer stop and leave things as they are. -- **With the app open:** the app's updater downloads in the background when `automatic` is on and shows `Restart to update`. Restarting lists what it will close (service tabs and shell tabs) and installs on the person's click, under the same lock. The app never relaunches by itself. +- **One installer:** the `localify` binary's own installer, in the core, enforces every rule here. The daemon runs it unattended, and `localify update install` runs it when a person asks. Tauri's replacement (`Update::install`) is never used. The app's updater only checks and downloads; installing goes through this installer. +- **Is the app running:** the app takes an exclusive `flock` on `~/Library/Application Support/Localify/app.lock` as its first step and holds it for its lifetime. While the installer holds the update lock, an app that starts waits for it, then starts on whatever is installed. The installer runs only when it can take `app.lock`, and also finds no process whose executable lies inside the bundle (`proc_listallpids` and `proc_pidpath`). That second check also covers apps from 1.6 and earlier, which take no lock, and other accounts using the same bundle. If it can't read a process's path, it doesn't install. +- **When the daemon installs:** only when both checks pass. Otherwise it reports the update as available and tries again at the next check. +- **Swap:** under an exclusive `flock` on `~/Library/Application Support/Localify/update.lock`, a file that is never deleted, in the bundle owner's account, at a path that doesn't depend on the configurable data dir. Only the owner's account installs. The installer checks again that the destination is the same inode and version it verified, and fails without touching it when the volume lacks `RENAME_SWAP`. It then exchanges the staged bundle and the installed one with `renamex_np`. The old bundle moves to `~/Library/Application Support/Localify/previous/.app`, which keeps one previous version. The daemon restarts on the new binary, as it already does after any replacement. A destination that changed before that check makes the installer stop and leave it as it is. A person replacing the bundle at the very same moment, from a DMG for example, is not supported. +- **With the app open:** the app checks and downloads in the background when `automatic` is on, and shows `Restart to update`. Restarting lists what it will close (service tabs and shell tabs). On the person's click, the app starts `localify update install --relaunch` detached in its own session, then quits. That process waits for `app.lock`, installs, and opens the new app in the same login session. The app never relaunches by itself. - **Status:** `update_status` on the management socket answers `{installed, available, state, reason}`, and so does `localify update --status`. That is what the app and the CLI read, so nothing depends on a notification arriving. - **Recovery:** no automatic rollback. If a new version misbehaves, `~/Library/Application Support/Localify/previous/` holds the last one: quit Localify, put that bundle back in place of the installed one, and turn `automatic` off until a fixed release. - **Linux:** no unattended install, because the CLI there is checked by SHA-256 only. A Linux daemon that sees a newer version says "Update available: run the install command again" in `localify status` and in the app. From 64283163f1378a8f9d89f7bc1863c39fc31dec37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9o=20Goulois?= Date: Mon, 9 Nov 2026 18:57:57 +0100 Subject: [PATCH 4/9] feat: machines update themselves, through the daemon's installer 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. --- .github/workflows/release.yml | 6 + Cargo.lock | 190 ++++++++- Cargo.toml | 12 + changes/automatic-updates.md | 12 + docs/desktop.md | 7 +- src/cli/mod.rs | 1 + src/cli/status.rs | 18 + src/cli/update.rs | 187 +++++++++ src/config.rs | 10 + src/daemon/lifecycle.rs | 2 +- src/daemon/mgmt_socket.rs | 65 ++- src/daemon/mod.rs | 1 + src/daemon/remote/mod.rs | 14 + src/daemon/remote/tests/viewing.rs | 1 + src/daemon/server.rs | 16 +- src/daemon/updates.rs | 168 ++++++++ src/infrastructure/config/mod.rs | 42 +- src/infrastructure/mgmt_client.rs | 18 + src/infrastructure/mod.rs | 1 + src/infrastructure/updates/extract.rs | 524 +++++++++++++++++++++++ src/infrastructure/updates/install.rs | 583 ++++++++++++++++++++++++++ src/infrastructure/updates/macos.rs | 456 ++++++++++++++++++++ src/infrastructure/updates/mod.rs | 485 +++++++++++++++++++++ src/infrastructure/updates/release.rs | 349 +++++++++++++++ src/infrastructure/updates/rules.rs | 285 +++++++++++++ src/infrastructure/updates/state.rs | 313 ++++++++++++++ src/main.rs | 46 ++ 27 files changed, 3801 insertions(+), 11 deletions(-) create mode 100644 changes/automatic-updates.md create mode 100644 src/cli/update.rs create mode 100644 src/daemon/updates.rs create mode 100644 src/infrastructure/updates/extract.rs create mode 100644 src/infrastructure/updates/install.rs create mode 100644 src/infrastructure/updates/macos.rs create mode 100644 src/infrastructure/updates/mod.rs create mode 100644 src/infrastructure/updates/release.rs create mode 100644 src/infrastructure/updates/rules.rs create mode 100644 src/infrastructure/updates/state.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01969c6..b617b59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,9 @@ jobs: echo "tag=$TAG" >> $GITHUB_OUTPUT - name: Build release binary + # The manifest the daemon checks to say an update is available. + env: + LOCALIFY_UPDATER_BASE_URL: https://downloads.localify.dev run: cargo build --release --bin localify - name: Package binary @@ -126,6 +129,9 @@ jobs: echo "tag=$TAG" >> $GITHUB_OUTPUT - name: Build release binary + # The manifest the daemon checks to say an update is available. + env: + LOCALIFY_UPDATER_BASE_URL: https://downloads.localify.dev run: cargo build --release --bin localify - name: Package binary diff --git a/Cargo.lock b/Cargo.lock index cf2d11d..15829a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -217,6 +223,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.10.0" @@ -419,6 +431,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -428,6 +446,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -447,9 +474,11 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", + "digest", "fiat-crypto", "rustc_version", "subtle", + "zeroize", ] [[package]] @@ -469,6 +498,16 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "der-parser" version = "10.0.0" @@ -530,6 +569,30 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -564,12 +627,33 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1015,6 +1099,18 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1040,6 +1136,8 @@ dependencies = [ "clap", "clap_complete", "dialoguer", + "ed25519-dalek", + "flate2", "futures-util", "http-body-util", "humantime", @@ -1047,14 +1145,17 @@ dependencies = [ "hyper-rustls", "hyper-util", "libc", + "minisign-verify", "rcgen", "rustls", + "semver", "serde", "serde_json", "sha2", "simple-dns", "snow", "socket2", + "tar", "tempfile", "thiserror", "time", @@ -1121,6 +1222,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1240,6 +1357,22 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "poly1305" version = "0.8.0" @@ -1306,7 +1439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1316,7 +1449,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1342,6 +1484,15 @@ dependencies = [ "yasna", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1597,6 +1748,21 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simple-dns" version = "0.11.2" @@ -1644,6 +1810,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1690,6 +1866,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.24.0" diff --git a/Cargo.toml b/Cargo.toml index 6a2fef5..ab0719b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,16 @@ tokio-tungstenite = { version = "0.29", default-features = false, features = ["h webpki-roots = "1" zeroize = "1" +# Automatic updates (docs/desktop.md, "Automatic updates", ADR 0034): the +# release archive is a signed .tar.gz. The signature uses Tauri's minisign +# format, checked with the crate the app's updater already uses. Without +# default features `tar` writes no extended attributes. +flate2 = "1" +tar = { version = "0.4", default-features = false } +minisign-verify = "0.2" +# Release versions are semver, compared as such. Already in the tree. +semver = "1" + [profile.release] strip = true lto = true @@ -114,5 +124,7 @@ panic = "abort" [dev-dependencies] tempfile = "3" +# Signs test archives in the minisign format the updater checks. +ed25519-dalek = "2" # A paused clock for the relay link's timing tests. tokio = { version = "1", features = ["test-util"] } diff --git a/changes/automatic-updates.md b/changes/automatic-updates.md new file mode 100644 index 0000000..81b6f20 --- /dev/null +++ b/changes/automatic-updates.md @@ -0,0 +1,12 @@ +--- +type: new +link: docs/desktop.md +--- + +## Your Macs install new Localify versions by themselves + +While the app is closed, a Mac installs a new version on its own, even a +runner nobody sits at, and checks at once when a paired machine is on a newer +version. New installs start with it on; an existing install turns it on with +`localify update --auto on`, which works over SSH. `localify update` shows +what is installed and available, and `localify update install` installs now. diff --git a/docs/desktop.md b/docs/desktop.md index 4c54f89..59a6e6c 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -128,13 +128,14 @@ A Mac can install new versions by itself (ADR 0034). - **Extraction:** into a new private directory (0700) on the destination's volume, made by the installer. The archive may hold exactly one `Localify.app`, with no absolute or `..` paths, no hard links, no device or FIFO entries, no setuid or setgid bits, and only relative symlinks that stay inside the bundle. Symlinks are never followed during extraction. The expanded size is capped at 1 GiB and 20,000 entries. Quarantine is not added and never cleared; the archive is notarized and stapled. - **Where:** only the production bundle whose executable is the daemon's, at `/Applications/Localify.app` or `~/Applications/Localify.app`, when the user owns it and can replace it. A dev build, a translocated or read-only bundle, a mounted DMG, or a location that would need authorization stays manual, and Settings says why. - **One installer:** the `localify` binary's own installer, in the core, enforces every rule here. The daemon runs it unattended, and `localify update install` runs it when a person asks. Tauri's replacement (`Update::install`) is never used. The app's updater only checks and downloads; installing goes through this installer. -- **Is the app running:** the app takes an exclusive `flock` on `~/Library/Application Support/Localify/app.lock` as its first step and holds it for its lifetime. While the installer holds the update lock, an app that starts waits for it, then starts on whatever is installed. The installer runs only when it can take `app.lock`, and also finds no process whose executable lies inside the bundle (`proc_listallpids` and `proc_pidpath`). That second check also covers apps from 1.6 and earlier, which take no lock, and other accounts using the same bundle. If it can't read a process's path, it doesn't install. +- **Is the app running:** the app takes an exclusive `flock` on `~/Library/Application Support/Localify/app.lock` as its first step and holds it for its lifetime. While the installer holds the update lock, an app that starts waits for it, then starts on whatever is installed. The installer runs only when it can take `app.lock`, and also finds no process whose executable lies inside the bundle (`proc_listallpids` and `proc_pidpath`). That second check also covers apps from 1.6 and earlier, which take no lock, and other accounts using the same bundle. If it can't read a process's path, it doesn't install. Two cases are not "in use": the bundle's own `localify`, which the daemon and the CLI run from and which outlive a replacement, and a process whose executable was deleted, which can't be running the bundle's files on disk. - **When the daemon installs:** only when both checks pass. Otherwise it reports the update as available and tries again at the next check. - **Swap:** under an exclusive `flock` on `~/Library/Application Support/Localify/update.lock`, a file that is never deleted, in the bundle owner's account, at a path that doesn't depend on the configurable data dir. Only the owner's account installs. The installer checks again that the destination is the same inode and version it verified, and fails without touching it when the volume lacks `RENAME_SWAP`. It then exchanges the staged bundle and the installed one with `renamex_np`. The old bundle moves to `~/Library/Application Support/Localify/previous/.app`, which keeps one previous version. The daemon restarts on the new binary, as it already does after any replacement. A destination that changed before that check makes the installer stop and leave it as it is. A person replacing the bundle at the very same moment, from a DMG for example, is not supported. - **With the app open:** the app checks and downloads in the background when `automatic` is on, and shows `Restart to update`. Restarting lists what it will close (service tabs and shell tabs). On the person's click, the app starts `localify update install --relaunch` detached in its own session, then quits. That process waits for `app.lock`, installs, and opens the new app in the same login session. The app never relaunches by itself. -- **Status:** `update_status` on the management socket answers `{installed, available, state, reason}`, and so does `localify update --status`. That is what the app and the CLI read, so nothing depends on a notification arriving. +- **Status:** `update_status` on the management socket answers `{installed, available, state, reason, automatic}`, and so does `localify update --status` (JSON when stdout is not a terminal; read locally when the daemon isn't running). `state` is `unknown` (never checked), `upToDate`, `available`, `checking`, `installing` or `failed` (the last check failed). `reason` says why an available update waits, or why the check failed. That is what the app and the CLI read, so nothing depends on a notification arriving. +- **Files:** `~/Library/Application Support/Localify/` holds `app.lock`, `update.lock`, `check.lock` (held during a check, so checks coalesce), `updates.json` (the highest version installed, the digests that failed, the last check, its failures and what it read), `staging/`, `previous/` and `updater.log`. `localify::infrastructure::updates::app_lock_path()` gives the app its lock's path. - **Recovery:** no automatic rollback. If a new version misbehaves, `~/Library/Application Support/Localify/previous/` holds the last one: quit Localify, put that bundle back in place of the installed one, and turn `automatic` off until a fixed release. -- **Linux:** no unattended install, because the CLI there is checked by SHA-256 only. A Linux daemon that sees a newer version says "Update available: run the install command again" in `localify status` and in the app. +- **Linux:** no unattended install, because the CLI there is checked by SHA-256 only. A Linux daemon that sees a newer version says "Update available: run the install command again" in `localify status` and in the app. It reads only the manifest's `version`; release builds get the manifest's address from `LOCALIFY_UPDATER_BASE_URL`, as the app does. ## Launch at Login diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9cda659..cfcc705 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -32,3 +32,4 @@ pub mod stop; pub mod trust; pub mod uninstall; pub mod unregister; +pub mod update; diff --git a/src/cli/status.rs b/src/cli/status.rs index acd485b..f0fca60 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -10,6 +10,7 @@ use crate::domain::RegistrationSource; use crate::infrastructure::dns::{SystemResolutionCheck, check_system_resolution}; use crate::infrastructure::macos_system::LegacyInstall; use crate::infrastructure::network::get_network_info; +use crate::infrastructure::updates::{UpdatePhase, UpdateStatus}; pub fn execute(ctx: &AppContext, daemon_config: &DaemonConfig) -> Result<()> { let network_info = get_network_info(); @@ -43,6 +44,9 @@ pub fn execute(ctx: &AppContext, daemon_config: &DaemonConfig) -> Result<()> { println!(" HTTP: http://localhost:{}", daemon_config.http_port); println!(" HTTPS: https://localhost:{}", daemon_config.https_port); print_dns_resolution_status(check_system_resolution(Duration::from_millis(1500))); + if let Some(line) = ctx.mgmt_client.update_status().ok().and_then(update_line) { + println!(" {line}"); + } } None => { println!("Localify daemon: stopped"); @@ -107,6 +111,20 @@ fn print_dns_resolution_status(check: SystemResolutionCheck) { ); } +/// A line only when an update is available. Linux never installs by +/// itself, so its reason is the whole line. +fn update_line(status: UpdateStatus) -> Option { + if status.state != UpdatePhase::Available { + return None; + } + let available = status.available.unwrap_or_default(); + Some(match status.reason { + Some(reason) if cfg!(target_os = "linux") => reason, + Some(reason) => format!("Update available: {available}. {reason}"), + None => format!("Update available: {available}. It installs once the app is closed."), + }) +} + fn trust_command() -> String { if cfg!(target_os = "macos") { BRANDING.admin_command("trust") diff --git a/src/cli/update.rs b/src/cli/update.rs new file mode 100644 index 0000000..78846c3 --- /dev/null +++ b/src/cli/update.rs @@ -0,0 +1,187 @@ +//! `localify update`: what is installed and available, the automatic +//! setting, and an install a person asks for (docs/desktop.md, "Automatic +//! updates"). It needs no app and no window, so it works over SSH on a +//! runner nobody sits at. + +use std::path::Path; + +use anyhow::{Result, anyhow}; + +use super::output::{print_json, wants_json}; +use crate::branding::BRANDING; +use crate::infrastructure::mgmt_client::MgmtSocketClient; +use crate::infrastructure::updates::{self, UpdatePaths, UpdatePhase, UpdateStatus, Updater}; + +fn update_paths() -> Result { + UpdatePaths::for_user().ok_or_else(|| anyhow!("HOME is not set, so updates can't be managed.")) +} + +/// The daemon's status, which knows whether it is checking or installing +/// now, or the same status read here when the daemon isn't running. +fn read_status(client: &MgmtSocketClient, config_path: &Path) -> Result { + if let Ok(status) = client.update_status() { + return Ok(status); + } + let updater = Updater::new(update_paths()?, config_path.to_path_buf(), false); + Ok(updater.status()) +} + +/// `localify update` and `localify update --status`. +pub fn status(json: bool, client: &MgmtSocketClient, config_path: &Path) -> Result<()> { + print_status(&read_status(client, config_path)?, json) +} + +/// `localify update --auto on|off`. The daemon reads the setting again +/// right before any unattended install, so it needs no restart. +pub fn set_automatic( + on: bool, + json: bool, + client: &MgmtSocketClient, + config_path: &Path, +) -> Result<()> { + updates::set_automatic(config_path, on)?; + if let Ok(paths) = update_paths() { + updates::log(&paths, &format!("automatic={on} by localify update")); + } + print_status(&read_status(client, config_path)?, json) +} + +fn print_status(status: &UpdateStatus, json: bool) -> Result<()> { + if wants_json(json) { + return print_json(status); + } + println!("Installed: {} {}", BRANDING.app_name, status.installed); + let available = status.available.as_deref().unwrap_or_default(); + match status.state { + UpdatePhase::Available => println!("Available: {} {available}", BRANDING.app_name), + UpdatePhase::UpToDate => println!("Up to date."), + UpdatePhase::Unknown => println!("Not checked yet."), + UpdatePhase::Checking => println!("Checking for updates..."), + UpdatePhase::Installing => println!("Installing {} {available}...", BRANDING.app_name), + UpdatePhase::Failed => println!("The last check failed."), + } + let automatic = if status.automatic { "on" } else { "off" }; + println!("Automatic updates: {automatic}"); + if let Some(reason) = &status.reason { + println!("{reason}"); + } + Ok(()) +} + +/// `localify update install [--relaunch]`. +#[cfg(target_os = "macos")] +pub fn install( + relaunch: bool, + json: bool, + client: &MgmtSocketClient, + config_path: &Path, +) -> Result<()> { + let paths = update_paths()?; + let result = macos_install::run(&paths, relaunch); + updates::record_install(&paths, &result); + if relaunch { + macos_install::open_app(); + } + let installed = result?; + if wants_json(json) { + return print_json(&read_status(client, config_path)?); + } + match installed { + Some(version) => println!( + "Installed {} {version}. The daemon restarts on it in a few seconds.", + BRANDING.app_name + ), + None => println!( + "{} {} is up to date.", + BRANDING.app_name, + updates::installed_version() + ), + } + Ok(()) +} + +/// Linux has no install path: its CLI is checked by SHA-256 only. +#[cfg(not(target_os = "macos"))] +pub fn install( + _relaunch: bool, + _json: bool, + _client: &MgmtSocketClient, + _config_path: &Path, +) -> Result<()> { + Err(anyhow!( + "{} on Linux never installs updates by itself. {}.", + BRANDING.app_name, + updates::LINUX_REASON + )) +} + +#[cfg(target_os = "macos")] +mod macos_install { + use std::process::Command; + use std::time::Duration; + + use anyhow::{Result, anyhow}; + use semver::Version; + + use crate::infrastructure::updates::install::{Installer, Mode}; + use crate::infrastructure::updates::macos::{self, BUNDLE_ID, InstallTarget}; + use crate::infrastructure::updates::{UpdatePaths, release, state, unix_now}; + + /// How long `--relaunch` waits for the app to quit. + const APP_QUIT_WAIT: Duration = Duration::from_secs(120); + + /// Checks (within the machine-wide cooldown), downloads, verifies and + /// swaps in. `None` when there is nothing newer. + pub(super) fn run(paths: &UpdatePaths, relaunch: bool) -> Result> { + let target = InstallTarget::current()?; + let endpoint = release::updater_endpoint() + .ok_or_else(|| anyhow!("This build has no update feed: update it by hand."))?; + let checked = state::check(paths, unix_now(), || release::fetch_manifest(&endpoint))?; + let Some(release) = checked.release() else { + return Ok(None); + }; + let installer = Installer { + paths, + target, + public_key: release::updater_public_key(), + signature: &macos::Codesign, + processes: &macos::process_paths, + // A person asked: the setting doesn't apply. + automatic: &|| true, + }; + let Some(version) = installer.wants(release)? else { + return Ok(None); + }; + let url = release + .archive + .as_ref() + .map(|archive| archive.url.as_str()) + .ok_or_else(|| anyhow!("The update manifest has no archive for this Mac"))?; + eprintln!("Downloading Localify {version}..."); + let (archive, _digest) = release::download_archive(url)?; + let staged = installer.stage(release, &archive)?; + let wait_for_app = if relaunch { + eprintln!("Waiting for Localify to quit..."); + APP_QUIT_WAIT + } else { + Duration::ZERO + }; + installer + .replace(staged, Mode::Confirmed { wait_for_app }) + .map(Some) + } + + /// Opens the app in this login session, the new version when the + /// install worked and the one still there when it didn't, so a person + /// who clicked Restart to update is never left without it. + pub(super) fn open_app() { + let mut open = Command::new("/usr/bin/open"); + match InstallTarget::current() { + Ok(target) => open.arg(target.bundle), + Err(_) => open.args(["-b", BUNDLE_ID]), + }; + if let Err(error) = open.status() { + eprintln!("Could not open Localify: {error}"); + } + } +} diff --git a/src/config.rs b/src/config.rs index 84981cb..1178447 100644 --- a/src/config.rs +++ b/src/config.rs @@ -162,6 +162,16 @@ impl Default for AgentConfig { } } +/// Automatic updates (docs/desktop.md, "Automatic updates"). +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct UpdatesConfig { + /// Install new versions without asking. A config without the key + /// predates the feature and keeps updating by hand: `localify install` + /// writes it on for a new install. + #[serde(default)] + pub automatic: bool, +} + /// All resolved paths needed by Localify components. /// Loaded once from config, then passed to components via DI. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] diff --git a/src/daemon/lifecycle.rs b/src/daemon/lifecycle.rs index 77a729b..ec6259a 100644 --- a/src/daemon/lifecycle.rs +++ b/src/daemon/lifecycle.rs @@ -255,7 +255,7 @@ async fn serve( cancel_signal.cancel(); }); - let result = server.run(shutdown).await; + let result = server.run(shutdown, managed).await; let _ = pid_file.remove(); result diff --git a/src/daemon/mgmt_socket.rs b/src/daemon/mgmt_socket.rs index eb5b421..89f3607 100644 --- a/src/daemon/mgmt_socket.rs +++ b/src/daemon/mgmt_socket.rs @@ -23,6 +23,7 @@ use crate::infrastructure::inventory::LocalInventory; use crate::infrastructure::launch_sessions::LaunchSessionUpdate; use crate::infrastructure::remote::store::SettingsChange; use crate::infrastructure::remote::wire::AccessWants; +use crate::infrastructure::updates::Updater; /// Management commands sent over the Unix socket. #[derive(Debug, Deserialize)] @@ -151,6 +152,13 @@ enum MgmtCommand { #[serde(default)] tail: Option, }, + /// What is installed, what is available and why it waits + /// (docs/desktop.md, "Automatic updates"). + #[serde(rename = "update_status")] + UpdateStatus, + /// Turns `[updates] automatic` on or off; answers the new status. + #[serde(rename = "update_settings")] + UpdateSettings { automatic: bool }, } /// Without an offset, a follow starts this far before the end: a few @@ -214,6 +222,7 @@ fn parse_command(line: &str) -> Option { "remote_machines" => Some(MgmtCommand::RemoteMachines { probe: false }), "remote_events" => Some(MgmtCommand::RemoteEvents { access: false }), "remote_access_suppressed" => Some(MgmtCommand::RemoteAccessSuppressed), + "update_status" => Some(MgmtCommand::UpdateStatus), _ => None, } } @@ -227,12 +236,14 @@ pub struct DaemonPorts { pub dns: u16, } -/// What the machine-wide commands answer from: the tunnel, the inventory -/// and the relay link. +/// What the machine-wide commands answer from: the tunnel, the inventory, +/// the relay link and the updater. pub struct MachineServices { pub tunnel: PublicTunnel, pub inventory: Arc, pub remote: RemoteLink, + /// `None` when the daemon has no home folder to keep its files in. + pub updates: Option>, } /// Serve management commands on a Unix domain socket. @@ -252,6 +263,7 @@ pub async fn serve( tunnel, inventory, remote, + updates, } = services; let cancel = shutdown.token(); @@ -281,6 +293,7 @@ pub async fn serve( let tunnel = tunnel.clone(); let inventory = Arc::clone(&inventory); let remote = remote.clone(); + let updates = updates.clone(); tokio::spawn(async move { let (reader, mut writer) = stream.into_split(); @@ -416,6 +429,10 @@ pub async fn serve( Some(MgmtCommand::Inventory { have }) => { inventory_answer(&inventory, &tunnel, remote.switches(), have).await } + Some(MgmtCommand::UpdateStatus) => update_answer(updates, None).await, + Some(MgmtCommand::UpdateSettings { automatic }) => { + update_answer(updates, Some(automatic)).await + } Some(command) => remote_answer(&remote, command).await, None => MgmtResponse::error(format!("Unknown command: {}", line.trim())), }; @@ -441,6 +458,27 @@ pub async fn serve( Ok(()) } +/// `update_status`, or `update_settings` with `automatic`. Reading the +/// installed bundle runs `plutil`, so it runs on a blocking thread. +async fn update_answer(updates: Option>, automatic: Option) -> MgmtResponse { + let Some(updates) = updates else { + return MgmtResponse::error("Updates are unavailable: the daemon has no home folder."); + }; + let answer = tokio::task::spawn_blocking(move || match automatic { + Some(on) => updates.set_automatic(on), + None => Ok(updates.status()), + }) + .await; + match answer { + Ok(Ok(status)) => match serde_json::to_value(status) { + Ok(status) => MgmtResponse::success(status), + Err(error) => MgmtResponse::error(error.to_string()), + }, + Ok(Err(error)) => MgmtResponse::error(format!("{error:#}")), + Err(error) => MgmtResponse::error(error.to_string()), + } +} + /// The one-shot remote commands. async fn remote_answer(remote: &RemoteLink, command: MgmtCommand) -> MgmtResponse { let answer = match command { @@ -775,6 +813,11 @@ mod tests { let sock_clone = sock.clone(); let config = dir.path().join("config.toml"); std::fs::write(&config, "").unwrap(); + let updates = Arc::new(Updater::new( + crate::infrastructure::updates::UpdatePaths::new(dir.path().join("updates")), + config.clone(), + false, + )); let inventory = Arc::new( LocalInventory::new(config, dir.path().join("data")) .unwrap() @@ -803,6 +846,7 @@ mod tests { tunnel, inventory, remote: RemoteLink::new(&data_dir, Arc::new(source)), + updates: Some(updates), }, ) .await; @@ -952,6 +996,23 @@ mod tests { // --- serve() integration tests --- + #[tokio::test] + async fn update_settings_turns_automatic_updates_on_and_status_reads_it() { + let (sock, _shutdown, _rx, _dir) = start_server(vec![]).await; + + let before = send_command(&sock, r#"{"cmd":"update_status"}"#).await; + assert_eq!(before["ok"], true); + assert_eq!(before["data"]["automatic"], false); + assert_eq!(before["data"]["state"], "unknown"); + + let set = send_command(&sock, r#"{"cmd":"update_settings","automatic":true}"#).await; + assert_eq!(set["ok"], true); + assert_eq!(set["data"]["automatic"], true); + let after = send_command(&sock, "update_status").await; + assert_eq!(after["data"]["automatic"], true); + assert!(after["data"]["installed"].is_string()); + } + #[tokio::test] async fn serve_status_returns_pid_and_count() { let (sock, cancel, _rx, _dir) = diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index aba747a..380a558 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -14,5 +14,6 @@ pub mod server; pub mod static_files; pub mod theme; pub mod tls; +pub mod updates; pub use server::Server; diff --git a/src/daemon/remote/mod.rs b/src/daemon/remote/mod.rs index ccfe0ef..11cf8a0 100644 --- a/src/daemon/remote/mod.rs +++ b/src/daemon/remote/mod.rs @@ -700,6 +700,20 @@ impl RemoteLink { Ok(views) } + /// The versions paired machines announce in the relay's presence. It is + /// not authenticated: the updater only takes it as a hint to check early. + pub fn paired_versions(&self) -> Vec { + self.list() + .map(|views| { + views + .into_iter() + .filter(|view| view.paired && !view.is_self) + .filter_map(|view| view.version) + .collect() + }) + .unwrap_or_default() + } + fn list(&self) -> Result> { let pins = self.shared.store.pins()?; let view = self.shared.view(); diff --git a/src/daemon/remote/tests/viewing.rs b/src/daemon/remote/tests/viewing.rs index b888d24..da48043 100644 --- a/src/daemon/remote/tests/viewing.rs +++ b/src/daemon/remote/tests/viewing.rs @@ -501,6 +501,7 @@ pub(super) async fn serve_management(daemon: &Daemon) -> PathBuf { tunnel: PublicTunnel::new(1), inventory: std::sync::Arc::new(inventory), remote: daemon.link.clone(), + updates: None, }; let ports = DaemonPorts { http: 80, diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 0ff35a1..ff7eacc 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -35,6 +35,7 @@ use crate::infrastructure::config::ConfigStore; use crate::infrastructure::inventory::LocalInventory; use crate::infrastructure::network::get_lan_ip; use crate::infrastructure::paths::LocalifyPaths; +use crate::infrastructure::updates::{UpdatePaths, Updater}; use localify::desktop::ProjectRootStore; /// Middleware that copies the client IP from `ConnectInfo` into a `ClientAddr` extension. @@ -124,7 +125,9 @@ impl Server { }) } - pub async fn run(self, shutdown: ShutdownHandle) -> Result<()> { + /// `managed` says launchd or systemd runs this daemon: only then does + /// it install updates, since only then does it restart on the new build. + pub async fn run(self, shutdown: ShutdownHandle, managed: bool) -> Result<()> { let cancel = shutdown.token(); info!( http = self.http_port, @@ -142,6 +145,13 @@ impl Server { self.config_path.clone(), self.data_dir.clone(), )?); + let updates = UpdatePaths::for_user().map(|paths| { + Arc::new(Updater::new( + paths, + self.config_path.clone(), + managed && cfg!(target_os = "macos"), + )) + }); spawn_reload_listener( self.state.clone(), self.reload_rx, @@ -164,6 +174,9 @@ impl Server { let source = DaemonSource::new(Arc::clone(&inventory), tunnel.clone(), app); let remote = RemoteLink::new(&self.data_dir, Arc::new(source)); remote::spawn(remote.clone(), cancel.clone()); + if let Some(updater) = &updates { + super::updates::spawn(Arc::clone(updater), remote.clone(), cancel.clone()); + } public_tunnel::spawn( tunnel.clone(), access, @@ -188,6 +201,7 @@ impl Server { tunnel, inventory, remote, + updates, }, ); diff --git a/src/daemon/updates.rs b/src/daemon/updates.rs new file mode 100644 index 0000000..2532f30 --- /dev/null +++ b/src/daemon/updates.rs @@ -0,0 +1,168 @@ +//! When the daemon checks for updates (docs/desktop.md, "Automatic +//! updates"): 2 minutes after it starts, then every 6 hours plus or minus +//! 30 minutes, and early when a paired machine announces a newer version. +//! Each check is the updater's one coalesced operation, so its cooldown +//! holds however often this asks. + +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use semver::Version; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; +use tracing::{info, warn}; + +use super::remote::RemoteLink; +use crate::infrastructure::updates::rules::{self, FIRST_CHECK_DELAY}; +use crate::infrastructure::updates::{self, Updater}; + +/// How often the paired machines' versions are read. Reading them is local. +const HINT_POLL: Duration = Duration::from_secs(60); + +/// Starts the checks, unless this build has no updater endpoint. +pub fn spawn(updater: Arc, remote: RemoteLink, cancel: CancellationToken) { + if !updater.checks() { + return; + } + tokio::spawn(async move { + // A replaced bundle restarts the daemon, so this stays true. + let installed = match tokio::task::spawn_blocking(updates::installed_version).await { + Ok(version) => version, + Err(error) => { + warn!("Could not read the installed version: {error}"); + return; + } + }; + let check = move || { + let updater = Arc::clone(&updater); + async move { + if let Err(error) = tokio::task::spawn_blocking(move || updater.cycle()).await { + warn!("The update check stopped: {error}"); + } + } + }; + let peers = move || remote.paired_versions(); + run(check, peers, installed, rules::next_check_delay, cancel).await; + }); +} + +/// The schedule, apart from what a check does, so a paused clock can test +/// it. `next_delay` picks the wait after each scheduled check from a seed. +async fn run( + check: C, + peers: impl Fn() -> Vec, + installed: Version, + next_delay: fn(u64) -> Duration, + cancel: CancellationToken, +) where + C: Fn() -> F, + F: Future, +{ + let mut next = Instant::now() + FIRST_CHECK_DELAY; + let mut hinted: Option = None; + let mut hints = tokio::time::interval(HINT_POLL); + loop { + tokio::select! { + _ = cancel.cancelled() => return, + _ = tokio::time::sleep_until(next) => { + check().await; + next = Instant::now() + next_delay(updates::random_u64()); + } + _ = hints.tick() => { + let versions = peers(); + let hint = rules::peer_hint(versions.iter().map(String::as_str), &installed); + // Once per announced version: the cooldown covers the rest. + if let Some(version) = hint + && hinted.as_ref() != Some(&version) + { + info!("A paired machine runs Localify {version}: checking for updates"); + hinted = Some(version); + check().await; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + struct Probe { + checks: Arc, + peers: Arc>>, + cancel: CancellationToken, + } + + async fn start() -> Probe { + let checks = Arc::new(AtomicUsize::new(0)); + let peers = Arc::new(Mutex::new(Vec::new())); + let cancel = CancellationToken::new(); + let counted = Arc::clone(&checks); + let seen = Arc::clone(&peers); + tokio::spawn(run( + move || { + counted.fetch_add(1, Ordering::SeqCst); + async {} + }, + move || seen.lock().unwrap().clone(), + Version::new(1, 6, 0), + |_| Duration::from_secs(6 * 60 * 60), + cancel.clone(), + )); + // The schedule starts counting once the task first runs. + advance(Duration::ZERO).await; + Probe { + checks, + peers, + cancel, + } + } + + async fn advance(by: Duration) { + tokio::time::advance(by).await; + // Lets the woken task run. + for _ in 0..10 { + tokio::task::yield_now().await; + } + } + + #[tokio::test(start_paused = true)] + async fn the_first_check_is_two_minutes_after_start_then_every_six_hours() { + let probe = start().await; + advance(Duration::from_secs(119)).await; + assert_eq!(probe.checks.load(Ordering::SeqCst), 0); + advance(Duration::from_secs(1)).await; + assert_eq!(probe.checks.load(Ordering::SeqCst), 1); + advance(Duration::from_secs(6 * 60 * 60 - 1)).await; + assert_eq!(probe.checks.load(Ordering::SeqCst), 1); + advance(Duration::from_secs(1)).await; + assert_eq!(probe.checks.load(Ordering::SeqCst), 2); + probe.cancel.cancel(); + } + + #[tokio::test(start_paused = true)] + async fn a_newer_paired_machine_triggers_one_check_per_version() { + let probe = start().await; + *probe.peers.lock().unwrap() = vec!["1.5.0".into(), "1.6.0".into()]; + // Past the first scheduled check, with no newer peer. + advance(Duration::from_secs(125)).await; + assert_eq!(probe.checks.load(Ordering::SeqCst), 1); + + *probe.peers.lock().unwrap() = vec!["1.7.0".into()]; + advance(Duration::from_secs(60)).await; + assert_eq!(probe.checks.load(Ordering::SeqCst), 2); + // The same hint again asks nothing more. + advance(Duration::from_secs(60 * 5)).await; + assert_eq!(probe.checks.load(Ordering::SeqCst), 2); + + *probe.peers.lock().unwrap() = vec!["1.7.1".into()]; + advance(Duration::from_secs(60)).await; + assert_eq!(probe.checks.load(Ordering::SeqCst), 3); + probe.cancel.cancel(); + } +} diff --git a/src/infrastructure/config/mod.rs b/src/infrastructure/config/mod.rs index c62b923..e7ed71e 100644 --- a/src/infrastructure/config/mod.rs +++ b/src/infrastructure/config/mod.rs @@ -2,7 +2,7 @@ mod dto; pub mod watcher; // Re-export shared config types so existing CLI/daemon code keeps compiling. -pub use crate::config::{AgentConfig, DaemonConfig, DockerConfig, LocalifyPaths}; +pub use crate::config::{AgentConfig, DaemonConfig, DockerConfig, LocalifyPaths, UpdatesConfig}; use crate::application::ports::{ConfigLoadError, ConfigLoader, DomainRepository, RepositoryError}; use crate::branding::BRANDING; @@ -170,6 +170,9 @@ pub struct Config { #[serde(default)] pub agent: AgentConfig, + #[serde(default)] + pub updates: UpdatesConfig, + #[serde(default)] domains: HashMap, @@ -1736,8 +1739,11 @@ impl ConfigLoader for ConfigStore { Ok((config.daemon, config.paths)) } + /// A new install's config: automatic updates start on, unlike a config + /// that predates them. fn save_defaults(&self) -> Result<(), ConfigLoadError> { - let config = Config::default(); + let mut config = Config::default(); + config.updates.automatic = true; self.save(&config).map_err(map_config_error) } @@ -1806,6 +1812,38 @@ mod tests { DomainRegistration::new(pattern, vec![route]) } + // --- [updates] automatic --- + + #[test] + fn a_new_install_writes_automatic_updates_on() { + let (_dir, store) = temp_store(); + ConfigLoader::save_defaults(&store).unwrap(); + assert!(store.load().unwrap().updates.automatic); + let text = fs::read_to_string(store.path.clone()).unwrap(); + assert!(text.contains("[updates]\nautomatic = true"), "{text}"); + } + + #[test] + fn a_config_from_before_automatic_updates_keeps_them_off() { + let (_dir, store) = temp_store(); + fs::write(&store.path, "[daemon]\nlog_level = \"info\"\n").unwrap(); + assert!(!store.load().unwrap().updates.automatic); + // Saving it again writes the setting it had: off. + let config = store.load().unwrap(); + store.save(&config).unwrap(); + assert!(!store.load().unwrap().updates.automatic); + } + + #[test] + fn the_automatic_setting_survives_other_changes() { + let (_dir, store) = temp_store(); + fs::write(&store.path, "[updates]\nautomatic = true\n").unwrap(); + store + .add_domain(proxy_registration("app.localify", 3000)) + .unwrap(); + assert!(store.load().unwrap().updates.automatic); + } + // --- DaemonConfig::validate --- #[test] diff --git a/src/infrastructure/mgmt_client.rs b/src/infrastructure/mgmt_client.rs index 158db80..559b8da 100644 --- a/src/infrastructure/mgmt_client.rs +++ b/src/infrastructure/mgmt_client.rs @@ -12,6 +12,7 @@ use crate::domain::{ }; use crate::infrastructure::cloud::types::{PublicStatus, PublicUrl}; use crate::infrastructure::launch_sessions::{LaunchSessionDto, LaunchSessionUpdate}; +use crate::infrastructure::updates::UpdateStatus; /// What the daemon should do after it stops serving. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -120,6 +121,23 @@ impl MgmtSocketClient { Ok(response["data"].take()) } + /// What is installed, what is available and why it waits + /// (docs/desktop.md, "Automatic updates"). + pub fn update_status(&self) -> Result { + let mut response = self.send_command(r#"{"cmd":"update_status"}"#)?; + serde_json::from_value(response["data"].take()) + .map_err(|error| DaemonConnectionError::ProtocolError(error.to_string())) + } + + /// Turns automatic updates on or off, and returns the new status. + #[allow(dead_code)] // Used by the desktop through the library crate. + pub fn update_settings(&self, automatic: bool) -> Result { + let command = serde_json::json!({ "cmd": "update_settings", "automatic": automatic }); + let mut response = self.send_command(&command.to_string())?; + serde_json::from_value(response["data"].take()) + .map_err(|error| DaemonConnectionError::ProtocolError(error.to_string())) + } + /// Deletes a pin; `true` when there was one. pub fn remote_unpair(&self, machine_id: &str) -> Result { let command = serde_json::json!({ "cmd": "remote_unpair", "machineId": machine_id }); diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index a873501..fe9e3f9 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -44,3 +44,4 @@ pub mod system_service; pub mod target_probe; pub mod test_pause; pub mod tracing; +pub mod updates; diff --git a/src/infrastructure/updates/extract.rs b/src/infrastructure/updates/extract.rs new file mode 100644 index 0000000..ed46a97 --- /dev/null +++ b/src/infrastructure/updates/extract.rs @@ -0,0 +1,524 @@ +//! Expands a release archive into the installer's private staging +//! directory, refusing anything a Localify release never holds +//! (docs/desktop.md, "Automatic updates", "Extraction"). +//! +//! Nothing here follows a symlink while writing: an entry under a symlink is +//! refused, files are created with `O_NOFOLLOW` and `create_new`, and every +//! symlink is checked once the whole archive is out. + +use std::collections::HashSet; +use std::ffi::OsStr; +use std::fs::{self, DirBuilder, OpenOptions, Permissions}; +use std::io::{self, Read}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; + +use flate2::read::GzDecoder; +use tar::EntryType; + +/// The one top-level item a release archive holds. +pub const BUNDLE_NAME: &str = "Localify.app"; + +/// How much an archive may expand to. +#[derive(Debug, Clone, Copy)] +pub struct Limits { + pub max_bytes: u64, + pub max_entries: usize, +} + +/// 1 GiB and 20,000 entries: a release is about 40 MB and 20 entries. +pub const LIMITS: Limits = Limits { + max_bytes: 1024 * 1024 * 1024, + max_entries: 20_000, +}; + +#[derive(Debug, thiserror::Error)] +pub enum ExtractError { + #[error("the archive has an absolute path: {0}")] + AbsolutePath(String), + #[error("the archive has a path with '..': {0}")] + ParentComponent(String), + #[error("the archive holds something besides one {BUNDLE_NAME}: {0}")] + OutsideBundle(String), + #[error("the archive has no {BUNDLE_NAME}")] + NoBundle, + #[error("the archive has a hard link: {0}")] + HardLink(String), + #[error("the archive has a device, FIFO or other special file: {0}")] + SpecialFile(String), + #[error("the archive has a setuid or setgid file: {0}")] + SetId(String), + #[error("the archive has a symlink that leaves the bundle: {0}")] + SymlinkEscapes(String), + #[error("the archive puts an entry under a symlink: {0}")] + UnderSymlink(String), + #[error("the archive lists an entry twice: {0}")] + Duplicate(String), + #[error("the archive expands past {} MiB", LIMITS.max_bytes / (1024 * 1024))] + TooBig, + #[error("the archive has more than {} entries", LIMITS.max_entries)] + TooManyEntries, + #[error("the archive could not be read: {0}")] + Archive(io::Error), + #[error("could not write the staged bundle: {0}")] + Write(io::Error), +} + +/// Expands `archive` (a `.tar.gz`) into `staging`, which must be a new empty +/// directory only this process writes, and returns the bundle's path. +pub fn extract_bundle( + archive: &[u8], + staging: &Path, + limits: Limits, +) -> Result { + let mut tar = tar::Archive::new(GzDecoder::new(archive)); + let mut state = Extraction::new(staging, limits); + for entry in tar.entries().map_err(ExtractError::Archive)? { + let entry = entry.map_err(ExtractError::Archive)?; + state.add(entry)?; + } + state.finish() +} + +struct Extraction<'a> { + staging: &'a Path, + limits: Limits, + entries: usize, + bytes: u64, + /// Relative paths of the symlinks written so far. + symlinks: HashSet, + /// Relative paths of every entry written so far. + written: HashSet, +} + +impl<'a> Extraction<'a> { + fn new(staging: &'a Path, limits: Limits) -> Self { + Self { + staging, + limits, + entries: 0, + bytes: 0, + symlinks: HashSet::new(), + written: HashSet::new(), + } + } + + fn add(&mut self, mut entry: tar::Entry<'_, R>) -> Result<(), ExtractError> { + self.entries += 1; + if self.entries > self.limits.max_entries { + return Err(ExtractError::TooManyEntries); + } + let raw = entry.path_bytes().into_owned(); + let shown = String::from_utf8_lossy(&raw).into_owned(); + let relative = bundle_relative_path(&raw)?; + if relative + .ancestors() + .skip(1) + .any(|a| self.symlinks.contains(a)) + { + return Err(ExtractError::UnderSymlink(shown)); + } + let mode = entry.header().mode().map_err(ExtractError::Archive)?; + if mode & 0o6000 != 0 { + return Err(ExtractError::SetId(shown)); + } + let kind = entry.header().entry_type(); + let is_dir = kind == EntryType::Directory; + if !self.written.insert(relative.clone()) && !is_dir { + return Err(ExtractError::Duplicate(shown)); + } + let target = self.staging.join(&relative); + match kind { + EntryType::Directory => make_dir(&target, mode), + EntryType::Regular => self.write_file(&mut entry, &target, mode), + EntryType::Symlink => { + let link = entry + .link_name_bytes() + .map(|name| name.into_owned()) + .ok_or_else(|| ExtractError::SymlinkEscapes(shown.clone()))?; + check_symlink_target(&relative, &link) + .ok_or_else(|| ExtractError::SymlinkEscapes(shown.clone()))?; + make_parent(&target)?; + std::os::unix::fs::symlink(OsStr::from_bytes(&link), &target) + .map_err(ExtractError::Write)?; + self.symlinks.insert(relative); + Ok(()) + } + EntryType::Link => Err(ExtractError::HardLink(shown)), + _ => Err(ExtractError::SpecialFile(shown)), + } + } + + fn write_file( + &mut self, + entry: &mut tar::Entry<'_, R>, + target: &Path, + mode: u32, + ) -> Result<(), ExtractError> { + let size = entry.size(); + self.bytes = self.bytes.saturating_add(size); + if self.bytes > self.limits.max_bytes { + return Err(ExtractError::TooBig); + } + make_parent(target)?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .custom_flags(libc::O_NOFOLLOW) + .mode(0o600) + .open(target) + .map_err(ExtractError::Write)?; + let copied = io::copy(&mut entry.take(size), &mut file).map_err(ExtractError::Archive)?; + if copied != size { + return Err(ExtractError::Archive(io::Error::new( + io::ErrorKind::UnexpectedEof, + "an entry is shorter than its header says", + ))); + } + file.set_permissions(Permissions::from_mode(mode & 0o755)) + .map_err(ExtractError::Write) + } + + /// Checks what only the whole archive shows: the bundle is there, and + /// every symlink, followed through the others, stays inside it. + fn finish(self) -> Result { + let bundle = self.staging.join(BUNDLE_NAME); + let is_dir = fs::symlink_metadata(&bundle).is_ok_and(|meta| meta.file_type().is_dir()); + if !is_dir { + return Err(ExtractError::NoBundle); + } + let root = fs::canonicalize(&bundle).map_err(ExtractError::Write)?; + for link in &self.symlinks { + let resolved = fs::canonicalize(self.staging.join(link)); + if !resolved.is_ok_and(|path| path.starts_with(&root)) { + return Err(ExtractError::SymlinkEscapes(link.display().to_string())); + } + } + Ok(bundle) + } +} + +/// The entry's path with `./` dropped, refused when it is absolute, has +/// `..`, or lies outside `Localify.app`. +fn bundle_relative_path(raw: &[u8]) -> Result { + let shown = || String::from_utf8_lossy(raw).into_owned(); + let path = Path::new(OsStr::from_bytes(raw)); + let mut relative = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(part) => relative.push(part), + Component::CurDir => {} + Component::ParentDir => return Err(ExtractError::ParentComponent(shown())), + Component::RootDir | Component::Prefix(_) => { + return Err(ExtractError::AbsolutePath(shown())); + } + } + } + match relative.components().next() { + Some(Component::Normal(first)) if first == BUNDLE_NAME => Ok(relative), + _ => Err(ExtractError::OutsideBundle(shown())), + } +} + +/// `Some` when a symlink at `link_path` (relative to the staging directory) +/// pointing at `target` stays inside the bundle, read without the filesystem. +fn check_symlink_target(link_path: &Path, target: &[u8]) -> Option<()> { + let target = Path::new(OsStr::from_bytes(target)); + if target.as_os_str().is_empty() { + return None; + } + // The bundle's own name is the floor: the stack never goes below it. + let mut stack: Vec<&OsStr> = link_path + .parent()? + .components() + .map(Component::as_os_str) + .collect(); + for component in target.components() { + match component { + Component::Normal(part) => stack.push(part), + Component::CurDir => {} + Component::ParentDir => { + stack.pop(); + if stack.is_empty() { + return None; + } + } + Component::RootDir | Component::Prefix(_) => return None, + } + } + Some(()) +} + +fn make_parent(target: &Path) -> Result<(), ExtractError> { + match target.parent() { + Some(parent) => DirBuilder::new() + .recursive(true) + .mode(0o755) + .create(parent) + .map_err(ExtractError::Write), + None => Ok(()), + } +} + +fn make_dir(target: &Path, mode: u32) -> Result<(), ExtractError> { + match fs::symlink_metadata(target) { + Ok(meta) if meta.file_type().is_dir() => {} + Ok(_) => { + return Err(ExtractError::Duplicate(target.display().to_string())); + } + Err(_) => { + make_parent(target)?; + DirBuilder::new() + .mode(0o755) + .create(target) + .map_err(ExtractError::Write)?; + } + } + // The owner keeps write access, so the staging dir can be cleaned up. + fs::set_permissions(target, Permissions::from_mode((mode & 0o755) | 0o700)) + .map_err(ExtractError::Write) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use flate2::Compression; + use flate2::write::GzEncoder; + + /// One entry of a crafted archive. + pub(crate) enum Item<'a> { + Dir(&'a str), + File(&'a str, &'a [u8], u32), + Symlink(&'a str, &'a str), + HardLink(&'a str, &'a str), + Fifo(&'a str), + } + + /// Builds a `.tar.gz` with raw headers, so it can hold what `tar`'s + /// builder refuses to write, such as `..` and absolute paths. + pub(crate) fn archive(items: &[Item<'_>]) -> Vec { + let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast())); + for item in items { + let (path, kind, mode, data, link): (&str, EntryType, u32, &[u8], Option<&str>) = + match *item { + Item::Dir(path) => (path, EntryType::Directory, 0o755, b"", None), + Item::File(path, data, mode) => (path, EntryType::Regular, mode, data, None), + Item::Symlink(path, to) => (path, EntryType::Symlink, 0o755, b"", Some(to)), + Item::HardLink(path, to) => (path, EntryType::Link, 0o644, b"", Some(to)), + Item::Fifo(path) => (path, EntryType::Fifo, 0o644, b"", None), + }; + let mut header = tar::Header::new_gnu(); + raw_name(&mut header.as_old_mut().name, path); + if let Some(link) = link { + raw_name(&mut header.as_old_mut().linkname, link); + } + header.set_entry_type(kind); + header.set_mode(mode); + header.set_size(data.len() as u64); + header.set_cksum(); + builder.append(&header, data).unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() + } + + fn raw_name(field: &mut [u8; 100], value: &str) { + field.fill(0); + field[..value.len()].copy_from_slice(value.as_bytes()); + } + + pub(crate) fn good_bundle() -> Vec { + archive(&[ + Item::Dir("Localify.app/"), + Item::Dir("Localify.app/Contents/"), + Item::File("Localify.app/Contents/Info.plist", b"", 0o644), + Item::File("Localify.app/Contents/MacOS/localify", b"bin", 0o755), + Item::Symlink("Localify.app/Contents/Current", "MacOS"), + ]) + } + + fn extract(bytes: &[u8]) -> (tempfile::TempDir, Result) { + extract_with(bytes, LIMITS) + } + + fn extract_with( + bytes: &[u8], + limits: Limits, + ) -> (tempfile::TempDir, Result) { + let dir = tempfile::tempdir().unwrap(); + let result = extract_bundle(bytes, dir.path(), limits); + (dir, result) + } + + #[test] + fn a_plain_bundle_expands_with_its_modes() { + let (dir, result) = extract(&good_bundle()); + let bundle = result.unwrap(); + assert_eq!(bundle, dir.path().join("Localify.app")); + let binary = bundle.join("Contents/MacOS/localify"); + assert_eq!(fs::read(&binary).unwrap(), b"bin"); + let mode = fs::metadata(&binary).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o755); + let link = fs::read_link(bundle.join("Contents/Current")).unwrap(); + assert_eq!(link, Path::new("MacOS")); + } + + #[test] + fn a_leading_dot_slash_is_accepted() { + let bytes = archive(&[Item::File( + "./Localify.app/Contents/Info.plist", + b"x", + 0o644, + )]); + assert!(extract(&bytes).1.is_ok()); + } + + #[test] + fn traversal_is_refused_before_anything_is_written() { + let bytes = archive(&[Item::File("Localify.app/../../evil", b"x", 0o644)]); + let (dir, result) = extract(&bytes); + assert!(matches!(result, Err(ExtractError::ParentComponent(_)))); + assert!(!dir.path().join("../evil").exists()); + } + + #[test] + fn an_absolute_path_is_refused() { + let bytes = archive(&[Item::File("/tmp/Localify.app/evil", b"x", 0o644)]); + assert!(matches!( + extract(&bytes).1, + Err(ExtractError::AbsolutePath(_)) + )); + } + + #[test] + fn a_hard_link_is_refused() { + let bytes = archive(&[ + Item::File("Localify.app/a", b"x", 0o644), + Item::HardLink("Localify.app/b", "Localify.app/a"), + ]); + assert!(matches!(extract(&bytes).1, Err(ExtractError::HardLink(_)))); + } + + #[test] + fn a_fifo_is_refused() { + let bytes = archive(&[Item::Fifo("Localify.app/pipe")]); + assert!(matches!( + extract(&bytes).1, + Err(ExtractError::SpecialFile(_)) + )); + } + + #[test] + fn setuid_and_setgid_files_are_refused() { + for mode in [0o4755, 0o2755] { + let bytes = archive(&[Item::File("Localify.app/Contents/MacOS/x", b"x", mode)]); + assert!(matches!(extract(&bytes).1, Err(ExtractError::SetId(_)))); + } + } + + #[test] + fn symlinks_that_leave_the_bundle_are_refused() { + for target in ["../../outside", "/etc/passwd", "..", "../Other.app"] { + let bytes = archive(&[Item::Symlink("Localify.app/link", target)]); + assert!( + matches!(extract(&bytes).1, Err(ExtractError::SymlinkEscapes(_))), + "{target} was accepted" + ); + } + } + + #[test] + fn a_symlink_chain_that_leaves_the_bundle_is_refused() { + // Each link stays inside on its own; through `here`, `out` reaches + // the staging directory. + let bytes = archive(&[ + Item::Dir("Localify.app/"), + Item::Symlink("Localify.app/here", "."), + Item::Symlink("Localify.app/out", "here/.."), + ]); + assert!(matches!( + extract(&bytes).1, + Err(ExtractError::SymlinkEscapes(_)) + )); + } + + #[test] + fn an_entry_under_a_symlink_is_refused() { + let bytes = archive(&[ + Item::Dir("Localify.app/Contents/"), + Item::Symlink("Localify.app/Contents/Link", "."), + Item::File("Localify.app/Contents/Link/file", b"x", 0o644), + ]); + assert!(matches!( + extract(&bytes).1, + Err(ExtractError::UnderSymlink(_)) + )); + } + + #[test] + fn two_bundles_or_a_stray_file_are_refused() { + let two = archive(&[ + Item::File("Localify.app/a", b"x", 0o644), + Item::File("Other.app/a", b"x", 0o644), + ]); + assert!(matches!( + extract(&two).1, + Err(ExtractError::OutsideBundle(_)) + )); + let stray = archive(&[Item::File("README", b"x", 0o644)]); + assert!(matches!( + extract(&stray).1, + Err(ExtractError::OutsideBundle(_)) + )); + } + + #[test] + fn a_file_listed_twice_is_refused() { + let bytes = archive(&[ + Item::File("Localify.app/a", b"x", 0o644), + Item::File("Localify.app/a", b"y", 0o644), + ]); + assert!(matches!(extract(&bytes).1, Err(ExtractError::Duplicate(_)))); + } + + #[test] + fn an_archive_past_the_size_cap_is_refused() { + let bytes = archive(&[Item::File("Localify.app/big", &[0u8; 4096], 0o644)]); + let limits = Limits { + max_bytes: 4095, + max_entries: 10, + }; + assert!(matches!( + extract_with(&bytes, limits).1, + Err(ExtractError::TooBig) + )); + } + + #[test] + fn an_archive_past_the_entry_cap_is_refused() { + let limits = Limits { + max_bytes: 1024, + max_entries: 3, + }; + assert!(matches!( + extract_with(&good_bundle(), limits).1, + Err(ExtractError::TooManyEntries) + )); + } + + #[test] + fn an_empty_archive_has_no_bundle() { + assert!(matches!( + extract(&archive(&[])).1, + Err(ExtractError::NoBundle) + )); + } + + #[test] + fn garbage_is_an_archive_error() { + assert!(matches!( + extract(b"not a gzip").1, + Err(ExtractError::Archive(_)) + )); + } +} diff --git a/src/infrastructure/updates/install.rs b/src/infrastructure/updates/install.rs new file mode 100644 index 0000000..24824da --- /dev/null +++ b/src/infrastructure/updates/install.rs @@ -0,0 +1,583 @@ +//! Localify's one installer (docs/desktop.md, "Automatic updates"): the +//! daemon runs it unattended, and `localify update install` when a person +//! asks. Every rule of the contract is enforced here, in this order: +//! signature, extraction, version, code signature, then, under the update +//! lock, the app checks, the destination check, and the swap. + +use std::fs::{self, DirBuilder, File, OpenOptions}; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use semver::Version; + +use super::UpdatePaths; +use super::extract::{self, LIMITS}; +use super::macos::{self, InstallTarget, SignatureCheck}; +use super::release::{self, Release}; +use super::rules::{self, ProcessPath, parse_version}; +use super::state::StateStore; + +/// Who asked for the install. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + /// The daemon: only with `automatic` on just before replacing, and + /// only while the app is closed. + Unattended, + /// A person ran `localify update install`: waits up to this long for + /// the app to quit. + Confirmed { wait_for_app: Duration }, +} + +/// What the installer depends on, so tests can supply their own. +pub struct Installer<'a> { + pub paths: &'a UpdatePaths, + pub target: InstallTarget, + pub public_key: &'a str, + pub signature: &'a dyn SignatureCheck, + pub processes: &'a dyn Fn() -> Result>, + /// Reads `[updates] automatic` from the config, at the last moment. + pub automatic: &'a dyn Fn() -> bool, +} + +/// A bundle ready to swap in, in the installer's private directory, which +/// is removed when this is dropped. +pub struct Staged { + dir: StagingDir, + bundle: PathBuf, + version: Version, +} + +impl Installer<'_> { + /// Whether `release` may replace the installed bundle at all: strictly + /// newer than it and than every version installed here. + pub fn wants(&self, release: &Release) -> Result> { + let offered = parse_version(&release.version) + .ok_or_else(|| anyhow!("The update manifest has an invalid version"))?; + let highest = StateStore::new(self.paths).load().highest(); + let newer = rules::offer_is_newer(&offered, &self.target.version, highest.as_ref()); + Ok(newer.then_some(offered)) + } + + /// Checks the archive and expands it into a new private directory: the + /// signature before anything is extracted, then the bundle's version + /// and its code signature. An archive that fails any of these is + /// recorded by its SHA-256 and skipped until another release. + pub fn stage(&self, release: &Release, archive: &[u8]) -> Result { + let digest = release::sha256_hex(archive); + let store = StateStore::new(self.paths); + if store.load().has_failed(&digest) { + bail!( + "Localify {} failed to install here before. Waiting for the next release.", + release.version + ); + } + let staged = self.verify_and_expand(release, archive); + if let Err(error) = &staged { + self.log(&format!("rejected sha256={digest}: {error:#}")); + store.update(self.paths, |state| state.record_failed(&digest))?; + } + staged + } + + fn verify_and_expand(&self, release: &Release, archive: &[u8]) -> Result { + let manifest_archive = release + .archive + .as_ref() + .ok_or_else(|| anyhow!("The update manifest has no archive for this Mac"))?; + release::verify_signature(archive, &manifest_archive.signature, self.public_key)?; + let dir = StagingDir::create(self.paths, self.target.device())?; + let bundle = extract::extract_bundle(archive, dir.path(), LIMITS) + .context("The update archive was refused")?; + let version = macos::bundle_version(&bundle)?; + if version.to_string() != release.version { + bail!( + "The update archive holds version {version}, not the {} the manifest names", + release.version + ); + } + self.signature.verify(&bundle)?; + Ok(Staged { + dir, + bundle, + version, + }) + } + + /// Swaps the staged bundle in, under the update lock, once the app is + /// closed and the destination is still the one checked. The old bundle + /// is kept in `previous/`. + pub fn replace(&self, staged: Staged, mode: Mode) -> Result { + let _held = self.take_locks(mode)?; + let bundle_use = rules::bundle_use(&self.target.bundle, &(self.processes)()?); + rules::app_is_closed(true, bundle_use)?; + if mode == Mode::Unattended && !(self.automatic)() { + bail!("Automatic updates were turned off, so nothing was replaced."); + } + self.target.still_unchanged()?; + macos::swap(&staged.bundle, &self.target.bundle)?; + self.log(&format!( + "installed version={} previous={} at={}", + staged.version, + self.target.version, + self.target.bundle.display() + )); + self.keep_previous(&staged.bundle); + StateStore::new(self.paths).update(self.paths, |state| { + state.saw_installed(&staged.version); + state.install_error = None; + })?; + drop(staged.dir); + Ok(staged.version) + } + + /// The update lock, then `app.lock`. A person's install waits for the + /// app to quit without holding the update lock meanwhile, so an app + /// starting then is never stuck behind it. + fn take_locks(&self, mode: Mode) -> Result<(File, File)> { + let wait = match mode { + Mode::Unattended => Duration::ZERO, + Mode::Confirmed { wait_for_app } => wait_for_app, + }; + let started = Instant::now(); + loop { + let update = open_lock(&self.paths.update_lock())?; + match mode { + Mode::Unattended => update + .try_lock() + .map_err(|_| anyhow!("Another install is running."))?, + Mode::Confirmed { .. } => update.lock()?, + } + let app = open_lock(&self.paths.app_lock())?; + if app.try_lock().is_ok() { + return Ok((update, app)); + } + drop(update); + if started.elapsed() >= wait { + return Err(rules::AppRunning::LockHeld.into()); + } + std::thread::sleep(Duration::from_millis(500)); + } + } + + /// Moves the bundle swapped out to `previous/.app`, replacing + /// the one kept before. A failure only costs the copy. + fn keep_previous(&self, old_bundle: &Path) { + let previous = self.paths.previous_dir(); + let kept = previous.join(format!("{}.app", self.target.version)); + let moved = remove_dir_contents(&previous) + .and_then(|()| fs::create_dir_all(&previous)) + .and_then(|()| fs::rename(old_bundle, &kept)); + match moved { + Ok(()) => self.log(&format!("kept previous={}", kept.display())), + Err(error) => self.log(&format!("could not keep the previous bundle: {error}")), + } + } + + pub fn log(&self, message: &str) { + super::log(self.paths, message); + } +} + +/// The app checks without the update lock, before a download the daemon +/// would waste while the app is open. They run again under the lock. +pub fn app_is_closed_now(installer: &Installer<'_>) -> Result<()> { + let app = open_lock(&installer.paths.app_lock())?; + let free = app.try_lock().is_ok(); + drop(app); + let bundle_use = rules::bundle_use(&installer.target.bundle, &(installer.processes)()?); + rules::app_is_closed(free, bundle_use)?; + Ok(()) +} + +fn open_lock(path: &Path) -> Result { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).with_context(|| format!("Could not create {}", dir.display()))?; + } + OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(path) + .with_context(|| format!("Could not open {}", path.display())) +} + +fn remove_dir_contents(dir: &Path) -> std::io::Result<()> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + for entry in entries { + let path = entry?.path(); + if fs::symlink_metadata(&path)?.is_dir() { + fs::remove_dir_all(&path)?; + } else { + fs::remove_file(&path)?; + } + } + Ok(()) +} + +/// A new 0700 directory under the updates folder, on the destination's +/// volume, removed with what it holds when dropped. +struct StagingDir(PathBuf); + +impl StagingDir { + fn create(paths: &UpdatePaths, device: u64) -> Result { + let root = paths.staging_root(); + DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&root) + .with_context(|| format!("Could not create {}", root.display()))?; + let name = format!("{}-{}", std::process::id(), super::random_u64()); + let dir = root.join(name); + DirBuilder::new() + .mode(0o700) + .create(&dir) + .with_context(|| format!("Could not create {}", dir.display()))?; + let staging = Self(dir); + if fs::metadata(&staging.0)?.dev() != device { + bail!( + "{} is on another volume than the installed app, so the update can't be swapped in.", + staging.0.display() + ); + } + Ok(staging) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for StagingDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + use crate::infrastructure::updates::extract::tests::{Item, archive}; + use crate::infrastructure::updates::macos::tests::fake_bundle; + use crate::infrastructure::updates::macos::{CLI_IN_BUNDLE, TargetError}; + use crate::infrastructure::updates::release::ReleaseArchive; + use crate::infrastructure::updates::release::tests::TestKey; + + struct Signed(bool); + + impl SignatureCheck for Signed { + fn verify(&self, _bundle: &Path) -> Result<()> { + if self.0 { + Ok(()) + } else { + bail!("not signed by Localify") + } + } + } + + struct World { + _dir: tempfile::TempDir, + root: PathBuf, + paths: UpdatePaths, + key: TestKey, + public: String, + } + + impl World { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(dir.path()).unwrap(); + fake_bundle(&root.join("Localify.app"), "1.6.0"); + let key = TestKey::new(3); + Self { + paths: UpdatePaths::new(root.join("Support")), + _dir: dir, + root, + public: key.public(), + key, + } + } + + fn bundle(&self) -> PathBuf { + self.root.join("Localify.app") + } + + fn target(&self) -> Result { + // SAFETY: no preconditions. + let uid = unsafe { libc::geteuid() }; + InstallTarget::at(&self.bundle().join(CLI_IN_BUNDLE), &[self.bundle()], uid) + } + + fn installer<'a>( + &'a self, + signature: &'a dyn SignatureCheck, + processes: &'a dyn Fn() -> Result>, + automatic: &'a dyn Fn() -> bool, + ) -> Installer<'a> { + Installer { + paths: &self.paths, + target: self.target().unwrap(), + public_key: &self.public, + signature, + processes, + automatic, + } + } + + /// A release whose archive holds a bundle of `bundle_version`. + fn release(&self, version: &str, bundle_version: &str) -> (Release, Vec) { + let plist = format!( + "\ + CFBundleIdentifiercom.localify.desktop\ + CFBundleShortVersionString{bundle_version}\ + " + ); + let bytes = archive(&[ + Item::Dir("Localify.app/"), + Item::File("Localify.app/Contents/Info.plist", plist.as_bytes(), 0o644), + Item::File("Localify.app/Contents/MacOS/localify", b"new", 0o755), + ]); + let release = Release { + version: version.into(), + archive: Some(ReleaseArchive { + url: "https://d.test/a.tar.gz".into(), + signature: self.key.sign(&bytes), + }), + }; + (release, bytes) + } + } + + fn none() -> Result> { + Ok(vec![(1, ProcessPath::Path("/sbin/launchd".into()))]) + } + + #[test] + fn a_signed_newer_release_is_swapped_in_and_the_old_one_kept() { + let world = World::new(); + let installer = world.installer(&Signed(true), &none, &|| true); + let (release, bytes) = world.release("1.7.0", "1.7.0"); + assert_eq!( + installer.wants(&release).unwrap(), + Some(Version::new(1, 7, 0)) + ); + + let staged = installer.stage(&release, &bytes).unwrap(); + let installed = installer.replace(staged, Mode::Unattended).unwrap(); + + assert_eq!(installed, Version::new(1, 7, 0)); + assert_eq!( + fs::read(world.bundle().join(CLI_IN_BUNDLE)).unwrap(), + b"new" + ); + let kept = world.paths.previous_dir().join("1.6.0.app"); + assert_eq!(macos::bundle_version(&kept).unwrap(), Version::new(1, 6, 0)); + assert_eq!(fs::read_dir(world.paths.staging_root()).unwrap().count(), 0); + let state = StateStore::new(&world.paths).load(); + assert_eq!(state.highest(), Some(Version::new(1, 7, 0))); + // The same release is no longer an update. + let after = world.installer(&Signed(true), &none, &|| true); + assert_eq!(after.wants(&release).unwrap(), None); + } + + #[test] + fn a_bad_signature_is_refused_before_extraction_and_remembered() { + let world = World::new(); + let installer = world.installer(&Signed(true), &none, &|| true); + let (mut release, bytes) = world.release("1.7.0", "1.7.0"); + release.archive.as_mut().unwrap().signature = TestKey::new(9).sign(&bytes); + + let error = installer.stage(&release, &bytes).err().unwrap(); + assert!(error.to_string().contains("signature"), "{error:#}"); + assert!(!world.paths.staging_root().exists()); + let digest = release::sha256_hex(&bytes); + assert!(StateStore::new(&world.paths).load().has_failed(&digest)); + + // Even correctly signed later, the same archive is skipped. + let (good, _) = world.release("1.7.0", "1.7.0"); + let again = installer.stage(&good, &bytes).err().unwrap(); + assert!(again.to_string().contains("failed to install here before")); + } + + #[test] + fn a_bundle_whose_version_differs_from_the_manifest_is_refused() { + let world = World::new(); + let installer = world.installer(&Signed(true), &none, &|| true); + let (release, bytes) = world.release("1.7.0", "1.6.5"); + let error = installer.stage(&release, &bytes).err().unwrap(); + assert!(error.to_string().contains("1.6.5"), "{error:#}"); + } + + #[test] + fn a_bundle_not_signed_by_localify_is_refused_and_remembered() { + let world = World::new(); + let installer = world.installer(&Signed(false), &none, &|| true); + let (release, bytes) = world.release("1.7.0", "1.7.0"); + assert!(installer.stage(&release, &bytes).is_err()); + let digest = release::sha256_hex(&bytes); + assert!(StateStore::new(&world.paths).load().has_failed(&digest)); + assert_eq!( + macos::bundle_version(&world.bundle()).unwrap(), + Version::new(1, 6, 0) + ); + } + + #[test] + fn an_older_or_equal_release_or_one_below_the_highest_is_not_wanted() { + let world = World::new(); + let installer = world.installer(&Signed(true), &none, &|| true); + assert_eq!( + installer.wants(&world.release("1.6.0", "1.6.0").0).unwrap(), + None + ); + assert_eq!( + installer.wants(&world.release("1.5.0", "1.5.0").0).unwrap(), + None + ); + StateStore::new(&world.paths) + .update(&world.paths, |state| { + state.saw_installed(&Version::new(1, 8, 0)) + }) + .unwrap(); + assert_eq!( + installer.wants(&world.release("1.7.0", "1.7.0").0).unwrap(), + None + ); + } + + #[test] + fn the_app_holding_its_lock_stops_the_install() { + let world = World::new(); + let installer = world.installer(&Signed(true), &none, &|| true); + let (release, bytes) = world.release("1.7.0", "1.7.0"); + let app = open_lock(&world.paths.app_lock()).unwrap(); + app.lock().unwrap(); + + let staged = installer.stage(&release, &bytes).unwrap(); + let error = installer.replace(staged, Mode::Unattended).unwrap_err(); + assert!( + error.downcast_ref::().is_some(), + "{error:#}" + ); + assert_eq!( + macos::bundle_version(&world.bundle()).unwrap(), + Version::new(1, 6, 0) + ); + // A person's install waits, then gives up the same way. + let staged = installer.stage(&release, &bytes).unwrap(); + let wait = Mode::Confirmed { + wait_for_app: Duration::from_millis(200), + }; + assert!(installer.replace(staged, wait).is_err()); + } + + #[test] + fn a_persons_install_proceeds_once_the_app_quits() { + let world = World::new(); + let installer = world.installer(&Signed(true), &none, &|| false); + let (release, bytes) = world.release("1.7.0", "1.7.0"); + let app = open_lock(&world.paths.app_lock()).unwrap(); + app.lock().unwrap(); + let quitting = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + drop(app); + }); + + let staged = installer.stage(&release, &bytes).unwrap(); + let wait = Mode::Confirmed { + wait_for_app: Duration::from_secs(10), + }; + // Confirmed installs don't need `automatic`. + assert_eq!( + installer.replace(staged, wait).unwrap(), + Version::new(1, 7, 0) + ); + quitting.join().unwrap(); + } + + #[test] + fn a_process_running_from_the_bundle_or_an_unknown_one_stops_the_install() { + let world = World::new(); + let app_path = world.bundle().join("Contents/MacOS/localify-desktop"); + let running = move || Ok(vec![(42, ProcessPath::Path(app_path.clone()))]); + let unknown = || Ok(vec![(43, ProcessPath::Unreadable)]); + let (release, bytes) = world.release("1.7.0", "1.7.0"); + for processes in [ + &running as &dyn Fn() -> Result>, + &unknown, + ] { + let installer = world.installer(&Signed(true), processes, &|| true); + let staged = installer.stage(&release, &bytes).unwrap(); + assert!(installer.replace(staged, Mode::Unattended).is_err()); + } + assert_eq!( + macos::bundle_version(&world.bundle()).unwrap(), + Version::new(1, 6, 0) + ); + } + + #[test] + fn automatic_turned_off_at_the_last_moment_stops_an_unattended_install() { + let world = World::new(); + let reads = Cell::new(0); + let automatic = || { + reads.set(reads.get() + 1); + false + }; + let installer = world.installer(&Signed(true), &none, &automatic); + let (release, bytes) = world.release("1.7.0", "1.7.0"); + let staged = installer.stage(&release, &bytes).unwrap(); + assert!(installer.replace(staged, Mode::Unattended).is_err()); + assert_eq!(reads.get(), 1); + assert_eq!( + macos::bundle_version(&world.bundle()).unwrap(), + Version::new(1, 6, 0) + ); + } + + /// Stages the live release with every real check: the built-in key, + /// extraction, the version and `codesign`. Swaps nothing. Run with + /// `LOCALIFY_UPDATE_MANIFEST=https://downloads.localify.dev/latest.json + /// cargo test the_live_release -- --ignored`. + #[test] + #[ignore = "downloads the live release"] + fn the_live_release_stages_with_every_real_check() { + let endpoint = std::env::var("LOCALIFY_UPDATE_MANIFEST").unwrap(); + let release = release::fetch_manifest(&endpoint).unwrap(); + let world = World::new(); + let installer = Installer { + public_key: release::updater_public_key(), + ..world.installer(&macos::Codesign, &none, &|| true) + }; + let url = &release.archive.as_ref().unwrap().url; + let (bytes, digest) = release::download_archive(url).unwrap(); + let staged = installer.stage(&release, &bytes).unwrap(); + assert_eq!(staged.version.to_string(), release.version); + println!("staged {} sha256={digest}", release.version); + } + + #[test] + fn a_destination_replaced_during_the_install_is_left_alone() { + let world = World::new(); + let installer = world.installer(&Signed(true), &none, &|| true); + let (release, bytes) = world.release("1.7.0", "1.7.0"); + let staged = installer.stage(&release, &bytes).unwrap(); + // Someone drags 1.6.1 in from a DMG meanwhile. + fs::rename(world.bundle(), world.root.join("Trash.app")).unwrap(); + fake_bundle(&world.bundle(), "1.6.1"); + + assert!(installer.replace(staged, Mode::Unattended).is_err()); + assert_eq!( + macos::bundle_version(&world.bundle()).unwrap(), + Version::new(1, 6, 1) + ); + } +} diff --git a/src/infrastructure/updates/macos.rs b/src/infrastructure/updates/macos.rs new file mode 100644 index 0000000..4dd5b35 --- /dev/null +++ b/src/infrastructure/updates/macos.rs @@ -0,0 +1,456 @@ +//! What the installer asks of macOS: where the bundle is and whether this +//! account may replace it, its signature, the processes running from it, +//! and the atomic swap. + +use std::ffi::{CString, OsStr}; +use std::io; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, anyhow, bail}; +use semver::Version; + +use super::rules::{ProcessPath, parse_version}; +use crate::branding::{BRANDING, user_home_dir}; + +/// Localify's Apple team, read from the shipped app's signature +/// (`codesign -dvv /Applications/Localify.app`: `TeamIdentifier=P5G8WAJ2G2`, +/// `Authority=Developer ID Application: Téo Goulois (P5G8WAJ2G2)`). +pub const TEAM_ID: &str = "P5G8WAJ2G2"; +/// `identifier` in `src-tauri/tauri.conf.json`. +pub const BUNDLE_ID: &str = "com.localify.desktop"; +/// The signing identifier of the bundle's `localify`, the daemon's +/// executable. +pub const CLI_ID: &str = "localify"; + +/// The bundle's `localify` binary, relative to the bundle. +pub const CLI_IN_BUNDLE: &str = "Contents/MacOS/localify"; + +/// Why the installer can't replace this copy of Localify. Each message is +/// what Settings and `localify update --status` show. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum TargetError { + #[error("This build of Localify is a development build: update it by hand.")] + DevBuild, + #[error("This copy of Localify is not in an app bundle: update it by hand.")] + NotInBundle, + #[error( + "Localify runs from {0}, not /Applications or ~/Applications: move it there to update automatically." + )] + Location(String), + #[error("{0} is not Localify's app bundle: update it by hand.")] + NotLocalify(String), + #[error("{0} belongs to another account: only its owner can update it.")] + Owner(String), + #[error("This account can't replace {0}: update it by hand, or give it write access.")] + NotWritable(String), + #[error("Could not read {0}: {1}")] + Unreadable(String, String), +} + +/// The installed bundle the installer may replace, as it was when checked. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallTarget { + pub bundle: PathBuf, + pub version: Version, + dev: u64, + ino: u64, +} + +impl InstallTarget { + /// The bundle holding this very binary, when it is the production app + /// at a supported place, owned and replaceable by this account. + pub fn current() -> Result { + if BRANDING.is_dev_profile() || cfg!(debug_assertions) { + return Err(TargetError::DevBuild); + } + let exe = std::env::current_exe() + .and_then(std::fs::canonicalize) + .map_err(|error| TargetError::Unreadable("this binary".into(), error.to_string()))?; + let home = user_home_dir().unwrap_or_default(); + let allowed = [ + PathBuf::from("/Applications/Localify.app"), + home.join("Applications/Localify.app"), + ]; + // SAFETY: geteuid has no preconditions and cannot fail. + let uid = unsafe { libc::geteuid() }; + Self::at(&exe, &allowed, uid) + } + + /// The rules, apart from where they are read: `exe` is this binary's + /// canonical path, `allowed` the bundle paths that may be replaced. + pub fn at(exe: &Path, allowed: &[PathBuf], uid: u32) -> Result { + let bundle = bundle_of(exe).ok_or(TargetError::NotInBundle)?; + let shown = bundle.display().to_string(); + if !allowed.iter().any(|path| path == &bundle) { + return Err(TargetError::Location(shown)); + } + let unreadable = |error: String| TargetError::Unreadable(shown.clone(), error); + if info_value(&bundle, "CFBundleIdentifier").map_err(|e| unreadable(e.to_string()))? + != BUNDLE_ID + { + return Err(TargetError::NotLocalify(shown)); + } + let meta = std::fs::symlink_metadata(&bundle).map_err(|e| unreadable(e.to_string()))?; + if meta.uid() != uid { + return Err(TargetError::Owner(shown)); + } + let parent = bundle.parent().unwrap_or(Path::new("/")); + if !writable(&bundle) || !writable(parent) { + return Err(TargetError::NotWritable(shown)); + } + let version = bundle_version(&bundle).map_err(|e| unreadable(e.to_string()))?; + Ok(Self { + bundle, + version, + dev: meta.dev(), + ino: meta.ino(), + }) + } + + /// Fails when the bundle is no longer the one checked: another inode or + /// another version. The installer then leaves it as it is. + pub fn still_unchanged(&self) -> Result<()> { + let meta = std::fs::symlink_metadata(&self.bundle) + .with_context(|| format!("{} is gone", self.bundle.display()))?; + if (meta.dev(), meta.ino()) != (self.dev, self.ino) { + bail!( + "{} was replaced while the update was prepared, so it was left as it is.", + self.bundle.display() + ); + } + let version = bundle_version(&self.bundle)?; + if version != self.version { + bail!( + "{} changed to {version} while the update was prepared, so it was left as it is.", + self.bundle.display() + ); + } + Ok(()) + } + + /// The device this bundle is on: staging must be on the same one. + pub fn device(&self) -> u64 { + self.dev + } +} + +/// The version of the app bundle this binary runs from, if it runs from one. +pub fn running_bundle_version() -> Option { + let exe = std::env::current_exe() + .and_then(std::fs::canonicalize) + .ok()?; + bundle_version(&bundle_of(&exe)?).ok() +} + +/// `/x/Localify.app` for `/x/Localify.app/Contents/MacOS/localify`. +fn bundle_of(exe: &Path) -> Option { + let macos = exe.parent()?; + let contents = macos.parent()?; + let bundle = contents.parent()?; + let shaped = exe.file_name() == Some(OsStr::new("localify")) + && macos.file_name() == Some(OsStr::new("MacOS")) + && contents.file_name() == Some(OsStr::new("Contents")) + && bundle.extension() == Some(OsStr::new("app")); + shaped.then(|| bundle.to_path_buf()) +} + +fn writable(path: &Path) -> bool { + let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + // SAFETY: `path` is a NUL-terminated string that outlives the call, + // which only reads it. A read-only volume answers EROFS. + unsafe { libc::access(path.as_ptr(), libc::W_OK) == 0 } +} + +/// A string from the bundle's `Info.plist`, XML or binary. +pub fn info_value(bundle: &Path, key: &str) -> Result { + let plist = bundle.join("Contents/Info.plist"); + let output = Command::new("/usr/bin/plutil") + .args(["-extract", key, "raw", "-o", "-"]) + .arg(&plist) + .output() + .with_context(|| format!("Could not run plutil on {}", plist.display()))?; + if !output.status.success() { + bail!("{} has no {key}", plist.display()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// `CFBundleShortVersionString`, the version the app shows. +pub fn bundle_version(bundle: &Path) -> Result { + let value = info_value(bundle, "CFBundleShortVersionString")?; + parse_version(&value) + .ok_or_else(|| anyhow!("{} has an invalid version {value}", bundle.display())) +} + +/// Checks a staged bundle's code signature. Behind a trait because a test +/// can't make a bundle Apple signed. +pub trait SignatureCheck { + fn verify(&self, bundle: &Path) -> Result<()>; +} + +/// `codesign --verify --deep --strict`, with a requirement anchored to Apple +/// and pinned to Localify's team and identifiers, on the bundle and on the +/// daemon's executable inside it. +pub struct Codesign; + +impl SignatureCheck for Codesign { + fn verify(&self, bundle: &Path) -> Result<()> { + codesign(bundle, &["--deep"], BUNDLE_ID)?; + codesign(&bundle.join(CLI_IN_BUNDLE), &[], CLI_ID) + } +} + +/// A Developer ID requirement for `identifier` from Localify's team. +pub fn requirement(identifier: &str) -> String { + format!( + "anchor apple generic and identifier \"{identifier}\" \ + and certificate leaf[subject.OU] = \"{TEAM_ID}\" \ + and certificate 1[field.1.2.840.113635.100.6.2.6] exists \ + and certificate leaf[field.1.2.840.113635.100.6.1.13] exists" + ) +} + +fn codesign(path: &Path, extra: &[&str], identifier: &str) -> Result<()> { + let output = Command::new("/usr/bin/codesign") + .arg("--verify") + .args(extra) + .arg("--strict") + .arg("--test-requirement") + .arg(format!("={}", requirement(identifier))) + .arg(path) + .output() + .context("Could not run codesign")?; + if output.status.success() { + return Ok(()); + } + bail!( + "{} is not signed by Localify: {}", + path.display(), + String::from_utf8_lossy(&output.stderr).trim() + ) +} + +/// Every process's executable path, as `proc_pidpath` reads it. +pub fn process_paths() -> Result> { + list_pids()? + .into_iter() + .map(|pid| Ok((pid, process_path(pid)))) + .collect() +} + +fn list_pids() -> Result> { + // SAFETY: a null buffer of size 0 only asks for the current count. + let count = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) }; + if count <= 0 { + bail!("Could not list processes: {}", io::Error::last_os_error()); + } + let mut pids: Vec = vec![0; count as usize * 2 + 64]; + let size = libc::c_int::try_from(pids.len() * size_of::())?; + // SAFETY: `pids` holds `size` bytes of c_ints, and the call writes at + // most `size` bytes. + let filled = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast(), size) }; + if filled <= 0 { + bail!("Could not list processes: {}", io::Error::last_os_error()); + } + // A full buffer may have cut the list short: then nothing is certain. + if filled as usize >= pids.len() { + bail!("Could not list every process: the list kept growing"); + } + pids.truncate(filled as usize); + Ok(pids) +} + +fn process_path(pid: i32) -> ProcessPath { + let mut buffer = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + // SAFETY: `buffer` holds PROC_PIDPATHINFO_MAXSIZE bytes, the size passed, + // and the call writes at most that many. + let length = + unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) }; + if length > 0 { + buffer.truncate(length as usize); + return ProcessPath::Path(PathBuf::from(OsStr::from_bytes(&buffer))); + } + match io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => ProcessPath::Gone, + Some(libc::ENOENT) => ProcessPath::Deleted, + _ => ProcessPath::Unreadable, + } +} + +/// Exchanges `staged` and `installed` in one step with `renamex_np`, so +/// `installed` is never missing. Fails, touching nothing, on a volume +/// without `RENAME_SWAP`. +pub fn swap(staged: &Path, installed: &Path) -> Result<()> { + let from = CString::new(staged.as_os_str().as_bytes())?; + let to = CString::new(installed.as_os_str().as_bytes())?; + // SAFETY: both are NUL-terminated strings that outlive the call, which + // only reads them. + let result = unsafe { libc::renamex_np(from.as_ptr(), to.as_ptr(), libc::RENAME_SWAP) }; + if result == 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + match error.raw_os_error() { + Some(libc::ENOTSUP) | Some(libc::EINVAL) => bail!( + "The volume of {} can't swap two folders in one step (RENAME_SWAP), so it was left as it is.", + installed.display() + ), + _ => Err(anyhow!(error).context(format!( + "Could not swap the new bundle in at {}", + installed.display() + ))), + } +} + +#[cfg(test)] +pub(crate) mod tests { + use std::fs; + + use super::*; + + /// A bundle shaped like Localify's, unsigned. + pub(crate) fn fake_bundle(at: &Path, version: &str) { + fs::create_dir_all(at.join("Contents/MacOS")).unwrap(); + fs::write(at.join(CLI_IN_BUNDLE), version).unwrap(); + fs::write( + at.join("Contents/Info.plist"), + format!( + r#" + + +CFBundleIdentifier{BUNDLE_ID} +CFBundleShortVersionString{version} +"# + ), + ) + .unwrap(); + } + + fn uid() -> u32 { + // SAFETY: no preconditions. + unsafe { libc::geteuid() } + } + + #[test] + fn the_production_bundle_at_an_allowed_place_is_a_target() { + let dir = tempfile::tempdir().unwrap(); + let dir = fs::canonicalize(dir.path()).unwrap(); + let bundle = dir.join("Localify.app"); + fake_bundle(&bundle, "1.6.0"); + let target = InstallTarget::at( + &bundle.join(CLI_IN_BUNDLE), + std::slice::from_ref(&bundle), + uid(), + ) + .unwrap(); + assert_eq!(target.bundle, bundle); + assert_eq!(target.version, Version::new(1, 6, 0)); + target.still_unchanged().unwrap(); + } + + #[test] + fn other_places_owners_and_bundles_are_refused() { + let dir = tempfile::tempdir().unwrap(); + let bundle = dir.path().join("Localify.app"); + fake_bundle(&bundle, "1.6.0"); + let exe = bundle.join(CLI_IN_BUNDLE); + let elsewhere = PathBuf::from("/Applications/Localify.app"); + assert!(matches!( + InstallTarget::at(&exe, std::slice::from_ref(&elsewhere), uid()), + Err(TargetError::Location(_)) + )); + assert!(matches!( + InstallTarget::at(&exe, std::slice::from_ref(&bundle), uid() + 1), + Err(TargetError::Owner(_)) + )); + assert!(matches!( + InstallTarget::at( + Path::new("/usr/local/bin/localify"), + std::slice::from_ref(&bundle), + uid() + ), + Err(TargetError::NotInBundle) + )); + let plist = bundle.join("Contents/Info.plist"); + let text = fs::read_to_string(&plist) + .unwrap() + .replace(BUNDLE_ID, "com.localify.dev"); + fs::write(&plist, text).unwrap(); + assert!(matches!( + InstallTarget::at(&exe, &[bundle], uid()), + Err(TargetError::NotLocalify(_)) + )); + } + + #[test] + fn a_bundle_that_changed_since_the_check_is_left_alone() { + let dir = tempfile::tempdir().unwrap(); + let bundle = dir.path().join("Localify.app"); + fake_bundle(&bundle, "1.6.0"); + let target = InstallTarget::at( + &bundle.join(CLI_IN_BUNDLE), + std::slice::from_ref(&bundle), + uid(), + ) + .unwrap(); + fake_bundle(&bundle, "1.6.1"); + assert!(target.still_unchanged().is_err()); + + fs::rename(&bundle, dir.path().join("Old.app")).unwrap(); + fake_bundle(&bundle, "1.6.0"); + assert!(target.still_unchanged().is_err()); + } + + #[test] + fn swapping_exchanges_the_two_folders() { + // Temp dirs are on the boot volume, APFS on every supported macOS. + let dir = tempfile::tempdir().unwrap(); + let installed = dir.path().join("Localify.app"); + let staged = dir.path().join("staged/Localify.app"); + fake_bundle(&installed, "1.6.0"); + fake_bundle(&staged, "1.7.0"); + swap(&staged, &installed).unwrap(); + assert_eq!(bundle_version(&installed).unwrap(), Version::new(1, 7, 0)); + assert_eq!(bundle_version(&staged).unwrap(), Version::new(1, 6, 0)); + } + + #[test] + fn swapping_with_a_missing_side_touches_nothing() { + let dir = tempfile::tempdir().unwrap(); + let installed = dir.path().join("Localify.app"); + fake_bundle(&installed, "1.6.0"); + assert!(swap(&dir.path().join("missing.app"), &installed).is_err()); + assert_eq!(bundle_version(&installed).unwrap(), Version::new(1, 6, 0)); + } + + #[test] + fn this_test_process_is_in_the_process_list() { + let me = std::env::current_exe().unwrap(); + let me = fs::canonicalize(me).unwrap(); + let processes = process_paths().unwrap(); + let pid = std::process::id() as i32; + let found = processes.iter().find(|(p, _)| *p == pid).unwrap(); + assert_eq!(found.1, ProcessPath::Path(me)); + } + + #[test] + fn the_requirement_pins_the_team_and_identifier() { + let text = requirement(BUNDLE_ID); + assert!(text.starts_with("anchor apple generic")); + assert!(text.contains(r#"identifier "com.localify.desktop""#)); + assert!(text.contains(r#"leaf[subject.OU] = "P5G8WAJ2G2""#)); + } + + #[test] + fn an_unsigned_bundle_fails_codesign() { + let dir = tempfile::tempdir().unwrap(); + let bundle = dir.path().join("Localify.app"); + fake_bundle(&bundle, "1.7.0"); + assert!(Codesign.verify(&bundle).is_err()); + } +} diff --git a/src/infrastructure/updates/mod.rs b/src/infrastructure/updates/mod.rs new file mode 100644 index 0000000..cea72e7 --- /dev/null +++ b/src/infrastructure/updates/mod.rs @@ -0,0 +1,485 @@ +//! Automatic updates (ADR 0034, docs/desktop.md, "Automatic updates"): the +//! checks, the status every client reads, and, on macOS, Localify's one +//! installer. Linux only learns that a version exists and says so. + +// The installer's parts (extraction, signatures, staging) are called only by +// the macOS installer, but build everywhere so their tests run on Linux too. +#![cfg_attr(not(target_os = "macos"), allow(dead_code))] + +pub mod extract; +#[cfg(target_os = "macos")] +pub mod install; +#[cfg(target_os = "macos")] +pub mod macos; +pub mod release; +pub mod rules; +pub mod state; + +use std::collections::hash_map::RandomState; +use std::fs::OpenOptions; +use std::hash::{BuildHasher, Hasher}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::Result; +use semver::Version; +use serde::{Deserialize, Serialize}; + +use self::rules::parse_version; +use self::state::{StateStore, UpdateState}; +use crate::branding::{BRANDING, user_home_dir}; +use crate::infrastructure::config::ConfigStore; + +/// What Linux shows: its CLI is checked by SHA-256 only, so it never +/// installs by itself. +#[cfg_attr(target_os = "macos", allow(dead_code))] +pub const LINUX_REASON: &str = "Update available: run the install command again"; + +/// The updater's files. On macOS they live in the account that owns the +/// bundle, at a path that doesn't depend on the configurable data dir. +#[derive(Debug, Clone)] +pub struct UpdatePaths { + dir: PathBuf, +} + +impl UpdatePaths { + pub fn new(dir: PathBuf) -> Self { + Self { dir } + } + + /// `~/Library/Application Support/Localify` on macOS. `None` when + /// `HOME` is unset. + pub fn for_user() -> Option { + if cfg!(target_os = "macos") { + return user_home_dir().map(|home| { + Self::new( + home.join("Library/Application Support") + .join(BRANDING.app_name), + ) + }); + } + Some(Self::new(BRANDING.data_dir().join("updates"))) + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + /// The app holds an exclusive `flock` on it for its lifetime. + pub fn app_lock(&self) -> PathBuf { + self.dir.join("app.lock") + } + + /// Held while a bundle is swapped. Never deleted. + pub fn update_lock(&self) -> PathBuf { + self.dir.join("update.lock") + } + + /// Held while a check runs, so checks coalesce. + pub fn check_lock(&self) -> PathBuf { + self.dir.join("check.lock") + } + + pub fn state_file(&self) -> PathBuf { + self.dir.join("updates.json") + } + + /// Holds the bundle the last install replaced. + pub fn previous_dir(&self) -> PathBuf { + self.dir.join("previous") + } + + pub fn staging_root(&self) -> PathBuf { + self.dir.join("staging") + } + + pub fn log_file(&self) -> PathBuf { + self.dir.join("updater.log") + } +} + +/// The lock the app takes as its first step and holds for its lifetime, +/// so the installer never replaces it while it runs. +#[allow(dead_code)] // Used by the desktop through the library crate. +pub fn app_lock_path() -> Option { + UpdatePaths::for_user().map(|paths| paths.app_lock()) +} + +/// Appends a line to `updater.log`. Logging never fails an update. +pub fn log(paths: &UpdatePaths, message: &str) { + let _ = std::fs::create_dir_all(paths.dir()); + let Ok(mut file) = OpenOptions::new() + .create(true) + .append(true) + .open(paths.log_file()) + else { + return; + }; + let _ = writeln!(file, "{} {message}", unix_now()); +} + +pub fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()) +} + +/// Not cryptographic: spreads schedules and names staging directories. +pub(crate) fn random_u64() -> u64 { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| u64::from(elapsed.subsec_nanos())); + let mut hasher = RandomState::new().build_hasher(); + hasher.write_u64(nanos); + hasher.finish() +} + +/// `[updates] automatic`. A config that can't be read counts as off. +pub fn automatic(config_path: &Path) -> bool { + ConfigStore::new(config_path.to_path_buf()) + .load() + .is_ok_and(|config| config.updates.automatic) +} + +pub fn set_automatic(config_path: &Path, on: bool) -> Result<()> { + let store = ConfigStore::new(config_path.to_path_buf()); + let mut config = store.load()?; + config.updates.automatic = on; + store.save(&config)?; + Ok(()) +} + +/// The version installed here: the app bundle's when this binary runs from +/// one, otherwise this binary's own. +pub fn installed_version() -> Version { + #[cfg(target_os = "macos")] + if let Some(version) = macos::running_bundle_version() { + return version; + } + parse_version(env!("CARGO_PKG_VERSION")).unwrap_or_else(|| Version::new(0, 0, 0)) +} + +/// What `update_status` and `localify update --status` answer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UpdateStatus { + pub installed: String, + pub available: Option, + pub state: UpdatePhase, + /// Why an available update isn't installing by itself, or why the last + /// check failed. + pub reason: Option, + pub automatic: bool, +} + +/// `state` in [`UpdateStatus`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum UpdatePhase { + /// No check has run yet. + Unknown, + UpToDate, + Available, + Checking, + Installing, + /// The last check failed. + Failed, +} + +/// What the daemon is doing now. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Activity { + Idle, + Checking, + Installing, +} + +/// The status from what is known: `blocker` is why this machine can't +/// install by itself (a destination rule, or Linux). +pub fn describe( + installed: &Version, + state: &UpdateState, + automatic: bool, + blocker: Option, + activity: Activity, +) -> UpdateStatus { + let available = state + .latest + .as_ref() + .and_then(|release| parse_version(&release.version)) + .filter(|offered| rules::offer_is_newer(offered, installed, state.highest().as_ref())); + let (phase, reason) = match activity { + Activity::Checking => (UpdatePhase::Checking, None), + Activity::Installing => (UpdatePhase::Installing, None), + Activity::Idle if available.is_some() => ( + UpdatePhase::Available, + blocker + .or_else(|| state.install_error.clone()) + .or_else(|| (!automatic).then(auto_off_reason)), + ), + Activity::Idle if state.failures > 0 => (UpdatePhase::Failed, state.last_error.clone()), + Activity::Idle if state.last_check_at.is_none() => (UpdatePhase::Unknown, None), + Activity::Idle => (UpdatePhase::UpToDate, None), + }; + UpdateStatus { + installed: installed.to_string(), + available: available.map(|version| version.to_string()), + state: phase, + reason, + automatic, + } +} + +fn auto_off_reason() -> String { + format!( + "Automatic updates are off. Install it with: {0} update install, or turn them on with: {0} update --auto on", + BRANDING.cli_command() + ) +} + +/// The daemon's side: its checks, its unattended installs, and the status +/// it answers on the management socket. +pub struct Updater { + paths: UpdatePaths, + config_path: PathBuf, + endpoint: Option, + /// Only a daemon launchd restarts on the new build installs. + may_install: bool, + activity: Mutex, +} + +impl Updater { + pub fn new(paths: UpdatePaths, config_path: PathBuf, may_install: bool) -> Self { + Self { + paths, + config_path, + endpoint: release::updater_endpoint(), + may_install, + activity: Mutex::new(Activity::Idle), + } + } + + /// A build without an updater endpoint, such as a dev build, never + /// checks. + pub fn checks(&self) -> bool { + self.endpoint.is_some() + } + + pub fn status(&self) -> UpdateStatus { + let activity = self + .activity + .lock() + .map_or(Activity::Idle, |activity| *activity); + describe( + &installed_version(), + &StateStore::new(&self.paths).load(), + automatic(&self.config_path), + blocker(), + activity, + ) + } + + pub fn set_automatic(&self, on: bool) -> Result { + set_automatic(&self.config_path, on)?; + log(&self.paths, &format!("automatic={on}")); + Ok(self.status()) + } + + /// One scheduled or hinted check, then, when this Mac may and the app + /// is closed, an unattended install. Blocking: the daemon runs it on a + /// blocking thread. + pub fn cycle(&self) { + let Some(endpoint) = &self.endpoint else { + return; + }; + let checked = { + let _activity = self.set_activity(Activity::Checking); + state::check(&self.paths, unix_now(), || { + release::fetch_manifest(endpoint) + }) + }; + let release = match checked { + Ok(checked) => checked.release().cloned(), + Err(error) => { + log(&self.paths, &format!("check failed: {error:#}")); + return; + } + }; + #[cfg(target_os = "macos")] + if let Some(release) = release + && self.may_install + { + self.install_unattended(&release); + } + #[cfg(not(target_os = "macos"))] + let _ = (release, self.may_install); + } + + #[cfg(target_os = "macos")] + fn install_unattended(&self, release: &release::Release) { + let automatic = || automatic(&self.config_path); + let attempt = || -> Result> { + // Why it can't is in the status already. + let Ok(target) = macos::InstallTarget::current() else { + return Ok(None); + }; + let installer = install::Installer { + paths: &self.paths, + target, + public_key: release::updater_public_key(), + signature: &macos::Codesign, + processes: &macos::process_paths, + automatic: &automatic, + }; + if !automatic() || installer.wants(release)?.is_none() { + return Ok(None); + } + // Before downloading: the same checks run again under the lock. + install::app_is_closed_now(&installer)?; + let _activity = self.set_activity(Activity::Installing); + let url = installer_url(release)?; + let (archive, _digest) = release::download_archive(url)?; + let staged = installer.stage(release, &archive)?; + installer + .replace(staged, install::Mode::Unattended) + .map(Some) + }; + let result = attempt(); + record_install(&self.paths, &result); + } + + fn set_activity(&self, activity: Activity) -> ActivityGuard<'_> { + if let Ok(mut current) = self.activity.lock() { + *current = activity; + } + ActivityGuard(&self.activity) + } +} + +/// Sets the activity back to idle when the step ends, however it ends. +struct ActivityGuard<'a>(&'a Mutex); + +impl Drop for ActivityGuard<'_> { + fn drop(&mut self) { + if let Ok(mut current) = self.0.lock() { + *current = Activity::Idle; + } + } +} + +#[cfg(target_os = "macos")] +fn installer_url(release: &release::Release) -> Result<&str> { + release + .archive + .as_ref() + .map(|archive| archive.url.as_str()) + .ok_or_else(|| anyhow::anyhow!("The update manifest has no archive for this Mac")) +} + +/// Logs an install's outcome and keeps its error for the status. +pub fn record_install(paths: &UpdatePaths, result: &Result>) { + let error = match result { + Ok(None) => return, + Ok(Some(_)) => None, + Err(error) => { + log(paths, &format!("install failed: {error:#}")); + Some(format!("{error:#}")) + } + }; + let _ = StateStore::new(paths).update(paths, |state| state.install_error = error); +} + +/// Why this machine can't install updates by itself, if it can't. +fn blocker() -> Option { + #[cfg(target_os = "macos")] + return macos::InstallTarget::current() + .err() + .map(|error| error.to_string()); + #[cfg(not(target_os = "macos"))] + Some(LINUX_REASON.to_string()) +} + +#[cfg(test)] +mod tests { + use super::release::Release; + use super::*; + + fn checked(latest: &str) -> UpdateState { + UpdateState { + last_check_at: Some(1), + latest: Some(Release { + version: latest.into(), + archive: None, + }), + ..UpdateState::default() + } + } + + fn v(value: &str) -> Version { + parse_version(value).unwrap() + } + + #[test] + fn a_newer_release_is_available_and_says_why_it_waits() { + let status = describe(&v("1.6.0"), &checked("1.7.0"), false, None, Activity::Idle); + assert_eq!(status.state, UpdatePhase::Available); + assert_eq!(status.available.as_deref(), Some("1.7.0")); + assert!(status.reason.unwrap().contains("--auto on")); + + let on = describe(&v("1.6.0"), &checked("1.7.0"), true, None, Activity::Idle); + assert_eq!(on.reason, None); + + let linux = describe( + &v("1.6.0"), + &checked("1.7.0"), + true, + Some(LINUX_REASON.into()), + Activity::Idle, + ); + assert_eq!(linux.reason.as_deref(), Some(LINUX_REASON)); + } + + #[test] + fn the_same_or_an_older_release_is_up_to_date() { + let status = describe(&v("1.7.0"), &checked("1.7.0"), true, None, Activity::Idle); + assert_eq!(status.state, UpdatePhase::UpToDate); + assert_eq!(status.available, None); + let never = describe( + &v("1.7.0"), + &UpdateState::default(), + true, + None, + Activity::Idle, + ); + assert_eq!(never.state, UpdatePhase::Unknown); + } + + #[test] + fn a_failed_check_and_the_current_activity_show() { + let mut state = checked("1.6.0"); + state.failures = 2; + state.last_error = Some("offline".into()); + let failed = describe(&v("1.6.0"), &state, true, None, Activity::Idle); + assert_eq!(failed.state, UpdatePhase::Failed); + assert_eq!(failed.reason.as_deref(), Some("offline")); + let busy = describe(&v("1.6.0"), &state, true, None, Activity::Installing); + assert_eq!(busy.state, UpdatePhase::Installing); + } + + #[test] + fn the_status_serializes_as_the_socket_answers_it() { + let status = describe(&v("1.6.0"), &checked("1.7.0"), true, None, Activity::Idle); + let json = serde_json::to_value(&status).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "installed": "1.6.0", + "available": "1.7.0", + "state": "available", + "reason": null, + "automatic": true, + }) + ); + } +} diff --git a/src/infrastructure/updates/release.rs b/src/infrastructure/updates/release.rs new file mode 100644 index 0000000..27f01f5 --- /dev/null +++ b/src/infrastructure/updates/release.rs @@ -0,0 +1,349 @@ +//! What a check reads: the manifest the app's updater reads (`latest.json`), +//! and the signed archive it names. The manifest is discovery, not +//! authority: only the archive's minisign signature, checked against the +//! updater key built into Localify, makes it trusted. + +use std::collections::HashMap; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; +use base64::Engine; +use http_body_util::{BodyExt, Empty, Limited}; +use hyper::body::Bytes; +use hyper::header::USER_AGENT; +use hyper::{Request, Uri}; +use hyper_util::client::legacy::Client; +use hyper_util::rt::TokioExecutor; +use minisign_verify::{PublicKey, Signature}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::rules::parse_version; +use crate::infrastructure::cloud::http::block_on; + +/// The updater's public key, the one the app's updater checks with. +const UPDATER_PUBLIC_KEY: &str = include_str!("../../../src-tauri/updater.pub"); +/// Set at build time, as for the app (`src-tauri/build.rs`). +const UPDATER_ENDPOINT: Option<&str> = option_env!("LOCALIFY_UPDATER_ENDPOINT"); +const UPDATER_BASE_URL: Option<&str> = option_env!("LOCALIFY_UPDATER_BASE_URL"); + +const MANIFEST_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_MANIFEST_BYTES: usize = 64 * 1024; +const ARCHIVE_TIMEOUT: Duration = Duration::from_secs(10 * 60); +/// A release archive is about 15 MB. +const MAX_ARCHIVE_BYTES: usize = 256 * 1024 * 1024; + +/// The updater key built into this binary, base64 as in `updater.pub`. +pub fn updater_public_key() -> &'static str { + UPDATER_PUBLIC_KEY.trim() +} + +/// The manifest this build checks, or `None` for a debug build or a build +/// made without `LOCALIFY_UPDATER_ENDPOINT` or `LOCALIFY_UPDATER_BASE_URL`, +/// which then never checks. +pub fn updater_endpoint() -> Option { + if cfg!(debug_assertions) { + return None; + } + endpoint_from(UPDATER_ENDPOINT, UPDATER_BASE_URL) +} + +fn endpoint_from(endpoint: Option<&str>, base_url: Option<&str>) -> Option { + let endpoint = endpoint.map(str::trim).filter(|value| !value.is_empty()); + if let Some(endpoint) = endpoint { + return Some(endpoint.to_string()); + } + base_url + .map(|base| base.trim().trim_end_matches('/')) + .filter(|base| !base.is_empty()) + .map(|base| format!("{base}/latest.json")) +} + +/// The manifest's key for this Mac's archive, as the app's updater names it. +pub fn platform_key() -> Option<&'static str> { + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + Some("darwin-aarch64") + } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { + Some("darwin-x86_64") + } else { + None + } +} + +/// The release a manifest offers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Release { + pub version: String, + /// The archive for this platform. Linux has none: it only learns that + /// a version exists. + #[serde(default)] + pub archive: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReleaseArchive { + pub url: String, + /// Tauri's format: the minisign signature file, base64. + pub signature: String, +} + +#[derive(Deserialize)] +struct Manifest { + version: String, + #[serde(default)] + platforms: HashMap, +} + +#[derive(Deserialize)] +struct ManifestPlatform { + signature: String, + url: String, +} + +/// Reads `latest.json`. With a `platform`, the manifest must name an HTTPS +/// archive for it. +pub fn parse_manifest(bytes: &[u8], platform: Option<&str>) -> Result { + let manifest: Manifest = + serde_json::from_slice(bytes).context("The update manifest is not valid JSON")?; + let version = parse_version(&manifest.version) + .ok_or_else(|| anyhow!("The update manifest has an invalid version"))?; + let archive = match platform { + None => None, + Some(key) => { + let entry = manifest + .platforms + .get(key) + .ok_or_else(|| anyhow!("The update manifest has no archive for {key}"))?; + if !entry.url.starts_with("https://") { + bail!("The update manifest names an archive that is not HTTPS"); + } + Some(ReleaseArchive { + url: entry.url.clone(), + signature: entry.signature.trim().to_string(), + }) + } + }; + Ok(Release { + version: version.to_string(), + archive, + }) +} + +/// Reads the manifest at `endpoint` for this platform. +pub fn fetch_manifest(endpoint: &str) -> Result { + let bytes = get(endpoint, MAX_MANIFEST_BYTES, MANIFEST_TIMEOUT) + .with_context(|| format!("Could not read the update manifest at {endpoint}"))?; + parse_manifest(&bytes, platform_key()) +} + +/// Downloads an archive whole, capped, and returns it with its SHA-256. +pub fn download_archive(url: &str) -> Result<(Vec, String)> { + let bytes = get(url, MAX_ARCHIVE_BYTES, ARCHIVE_TIMEOUT) + .with_context(|| format!("Could not download the update from {url}"))?; + let digest = sha256_hex(&bytes); + Ok((bytes, digest)) +} + +pub fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Checks `data` against a signature in Tauri's format: the minisign +/// signature file and public key, each base64. Only prehashed signatures, +/// which every Localify release has, are accepted. +pub fn verify_signature(data: &[u8], signature_b64: &str, public_key_b64: &str) -> Result<()> { + let public_key = decode_text(public_key_b64).context("The updater key is not readable")?; + let public_key = PublicKey::decode(&public_key) + .map_err(|error| anyhow!("The updater key is not readable: {error}"))?; + let signature = decode_text(signature_b64).context("The update's signature is not readable")?; + let signature = Signature::decode(&signature) + .map_err(|error| anyhow!("The update's signature is not readable: {error}"))?; + public_key + .verify(data, &signature, false) + .map_err(|error| anyhow!("The update's signature does not match Localify's key: {error}")) +} + +fn decode_text(value: &str) -> Result { + let bytes = base64::engine::general_purpose::STANDARD.decode(value.trim())?; + Ok(String::from_utf8(bytes)?) +} + +/// One HTTPS GET, read whole up to `max_bytes`. Redirects are not followed. +fn get(url: &str, max_bytes: usize, timeout: Duration) -> Result> { + let uri: Uri = url.parse().context("invalid URL")?; + if uri.scheme_str() != Some("https") { + bail!("only HTTPS is allowed"); + } + block_on(async move { + tokio::time::timeout(timeout, get_async(uri, max_bytes)) + .await + .map_err(|_| anyhow!("no complete answer after {} s", timeout.as_secs()))? + })? +} + +async fn get_async(uri: Uri, max_bytes: usize) -> Result> { + // An explicit provider, because the app never installs a default one. + let connector = hyper_rustls::HttpsConnectorBuilder::new() + .with_provider_and_webpki_roots(rustls::crypto::aws_lc_rs::default_provider())? + .https_only() + .enable_http1() + .build(); + let client: Client<_, Empty> = Client::builder(TokioExecutor::new()).build(connector); + let request = Request::get(uri) + .header(USER_AGENT, concat!("localify/", env!("CARGO_PKG_VERSION"))) + .body(Empty::new())?; + let response = client.request(request).await?; + let status = response.status(); + if !status.is_success() { + bail!("the server answered {status}"); + } + let body = Limited::new(response.into_body(), max_bytes) + .collect() + .await + .map_err(|error| anyhow!("{error}"))?; + Ok(body.to_bytes().to_vec()) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use blake2::Blake2b512; + use ed25519_dalek::{Signer, SigningKey}; + + const KEY_ID: [u8; 8] = *b"testkey1"; + + /// A minisign key pair in Tauri's base64 format, for tests. + pub(crate) struct TestKey(SigningKey); + + impl TestKey { + pub(crate) fn new(seed: u8) -> Self { + Self(SigningKey::from_bytes(&[seed; 32])) + } + + pub(crate) fn public(&self) -> String { + let mut bin = b"Ed".to_vec(); + bin.extend_from_slice(&KEY_ID); + bin.extend_from_slice(self.0.verifying_key().as_bytes()); + let file = format!("untrusted comment: minisign public key\n{}\n", b64(&bin)); + b64(file.as_bytes()) + } + + /// A prehashed signature, as `tauri signer sign` makes. + pub(crate) fn sign(&self, data: &[u8]) -> String { + let hash = Blake2b512::digest(data); + let signature = self.0.sign(&hash).to_bytes(); + let mut bin = b"ED".to_vec(); + bin.extend_from_slice(&KEY_ID); + bin.extend_from_slice(&signature); + let trusted = "timestamp:1\tfile:Localify.app.tar.gz"; + let mut global = signature.to_vec(); + global.extend_from_slice(trusted.as_bytes()); + let global = self.0.sign(&global).to_bytes(); + let file = format!( + "untrusted comment: signature from tauri secret key\n{}\ntrusted comment: {trusted}\n{}\n", + b64(&bin), + b64(&global) + ); + b64(file.as_bytes()) + } + } + + fn b64(bytes: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + + #[test] + fn a_signature_from_the_key_verifies() { + let key = TestKey::new(7); + let data = b"the archive"; + verify_signature(data, &key.sign(data), &key.public()).unwrap(); + } + + #[test] + fn a_changed_archive_or_another_key_fails() { + let key = TestKey::new(7); + let signature = key.sign(b"the archive"); + assert!(verify_signature(b"the archivf", &signature, &key.public()).is_err()); + let other = TestKey::new(8); + assert!(verify_signature(b"the archive", &signature, &other.public()).is_err()); + assert!(verify_signature(b"the archive", "not base64!", &key.public()).is_err()); + } + + #[test] + fn the_built_in_key_is_readable() { + let text = decode_text(updater_public_key()).unwrap(); + PublicKey::decode(&text).unwrap(); + } + + #[test] + fn the_endpoint_comes_from_the_endpoint_or_the_base_url() { + assert_eq!( + endpoint_from(Some(" https://a.test/m.json "), Some("https://b.test")), + Some("https://a.test/m.json".into()) + ); + assert_eq!( + endpoint_from(None, Some("https://b.test/")), + Some("https://b.test/latest.json".into()) + ); + assert_eq!(endpoint_from(Some(""), Some(" ")), None); + assert_eq!(endpoint_from(None, None), None); + } + + const MANIFEST: &str = r#"{ + "version": "1.7.0", + "notes": "Localify 1.7.0", + "platforms": { + "darwin-aarch64": {"signature": "c2ln", "url": "https://d.test/1.7.0/a.tar.gz"} + } + }"#; + + #[test] + fn the_manifest_names_the_platforms_archive() { + let release = parse_manifest(MANIFEST.as_bytes(), Some("darwin-aarch64")).unwrap(); + assert_eq!(release.version, "1.7.0"); + let archive = release.archive.unwrap(); + assert_eq!(archive.url, "https://d.test/1.7.0/a.tar.gz"); + assert_eq!(archive.signature, "c2ln"); + } + + #[test] + fn without_a_platform_only_the_version_is_read() { + let release = parse_manifest(MANIFEST.as_bytes(), None).unwrap(); + assert_eq!(release.version, "1.7.0"); + assert_eq!(release.archive, None); + } + + #[test] + fn a_manifest_missing_the_platform_or_over_http_is_refused() { + assert!(parse_manifest(MANIFEST.as_bytes(), Some("darwin-x86_64")).is_err()); + let http = MANIFEST.replace("https://", "http://"); + assert!(parse_manifest(http.as_bytes(), Some("darwin-aarch64")).is_err()); + let bad = MANIFEST.replace(r#""version": "1.7.0""#, r#""version": "seven""#); + assert!(parse_manifest(bad.as_bytes(), None).is_err()); + } + + #[tokio::test] + async fn a_fetch_runs_on_the_daemons_blocking_threads() { + // Its own runtime starts there without a nested-runtime panic; the + // fetch itself fails, since nothing listens. + let result = tokio::task::spawn_blocking(|| { + get( + "https://127.0.0.1:9/latest.json", + 10, + Duration::from_secs(5), + ) + }) + .await; + assert!(result.unwrap().is_err()); + } + + #[test] + fn only_https_urls_are_fetched() { + let error = get("http://example.test/latest.json", 10, MANIFEST_TIMEOUT).unwrap_err(); + assert!(error.to_string().contains("HTTPS")); + } +} diff --git a/src/infrastructure/updates/rules.rs b/src/infrastructure/updates/rules.rs new file mode 100644 index 0000000..020338b --- /dev/null +++ b/src/infrastructure/updates/rules.rs @@ -0,0 +1,285 @@ +//! The decisions of automatic updates that need no I/O +//! (docs/desktop.md, "Automatic updates"), kept apart so tests pin them. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use semver::Version; + +/// The first check after the daemon starts. +pub const FIRST_CHECK_DELAY: Duration = Duration::from_secs(2 * 60); +/// Between scheduled checks, before the jitter. +pub const CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); +/// Each scheduled check moves by up to this much either way, so machines +/// started together don't all ask at once. +pub const CHECK_JITTER: Duration = Duration::from_secs(30 * 60); +/// The machine-wide wait after a check, doubled after each failure. +pub const BASE_COOLDOWN: Duration = Duration::from_secs(10 * 60); +pub const MAX_COOLDOWN: Duration = Duration::from_secs(6 * 60 * 60); + +/// Reads a release version, with or without a leading `v`. +pub fn parse_version(value: &str) -> Option { + let value = value.trim(); + Version::parse(value.strip_prefix('v').unwrap_or(value)).ok() +} + +/// How long after a check the next may reach the network: 10 minutes after +/// a success, doubled after each failure in a row, up to 6 hours. +pub fn cooldown(failures: u32) -> Duration { + let factor = 1u64 << failures.min(16); + let secs = BASE_COOLDOWN.as_secs().saturating_mul(factor); + Duration::from_secs(secs.min(MAX_COOLDOWN.as_secs())) +} + +/// Whether a check may reach the network now, from the persisted time of the +/// last one (Unix seconds). A clock moved back counts as due. +pub fn check_is_due(now: u64, last_check_at: Option, failures: u32) -> bool { + match last_check_at { + None => true, + Some(last) if last > now => true, + Some(last) => now - last >= cooldown(failures).as_secs(), + } +} + +/// The wait before the next scheduled check: 6 hours plus or minus 30 +/// minutes, picked from `seed`. +pub fn next_check_delay(seed: u64) -> Duration { + let jitter = CHECK_JITTER.as_secs(); + let offset = seed % (2 * jitter + 1); + Duration::from_secs(CHECK_INTERVAL.as_secs() - jitter + offset) +} + +/// Whether an offered version may be installed: strictly newer than the +/// installed bundle and than every version this machine ever installed, so +/// an older valid release is never put back. +pub fn offer_is_newer(offered: &Version, installed: &Version, highest: Option<&Version>) -> bool { + offered > installed && highest.is_none_or(|highest| offered > highest) +} + +/// The newest version paired machines report, when it is newer than ours. +/// It comes from the relay's unauthenticated presence: it only makes this +/// machine check early, never chooses what to install. +pub fn peer_hint<'a>( + peer_versions: impl IntoIterator, + installed: &Version, +) -> Option { + peer_versions + .into_iter() + .filter_map(parse_version) + .filter(|version| version > installed) + .max() +} + +/// What `proc_pidpath` said about one process. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProcessPath { + Path(PathBuf), + /// It exited between the listing and the read. + Gone, + /// Its executable was deleted, so it can't be a file of the bundle on + /// disk now. Apps that updated themselves while running look like this. + Deleted, + /// Any other failure: nothing can be said about it. + Unreadable, +} + +/// Whether some process is running from the bundle. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BundleUse { + Free, + InUse { pid: i32, path: PathBuf }, + Unknown { pid: i32 }, +} + +/// Looks for a process whose executable lies inside `bundle`, other than +/// the bundle's own `localify` binary: the daemon and the CLI run from it +/// and are built to outlive a replacement (the daemon restarts on the new +/// build). A process whose path can't be read makes the answer unknown. +pub fn bundle_use(bundle: &Path, processes: &[(i32, ProcessPath)]) -> BundleUse { + let cli = bundle.join("Contents/MacOS/localify"); + let mut unknown = None; + for (pid, path) in processes { + match path { + ProcessPath::Path(path) if path.starts_with(bundle) && path != &cli => { + return BundleUse::InUse { + pid: *pid, + path: path.clone(), + }; + } + ProcessPath::Unreadable => unknown = unknown.or(Some(*pid)), + _ => {} + } + } + match unknown { + Some(pid) => BundleUse::Unknown { pid }, + None => BundleUse::Free, + } +} + +/// Why the installer won't replace the bundle now. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum AppRunning { + #[error("Localify is open. Quit it, or use Restart to update in the app.")] + LockHeld, + #[error("Localify is running from the bundle (process {pid}, {}).", path.display())] + Process { pid: i32, path: PathBuf }, + #[error("Could not tell whether process {pid} runs from the bundle, so nothing was replaced.")] + Unknown { pid: i32 }, +} + +/// The installer runs only when it holds `app.lock` and no process runs +/// from the bundle; when unsure, it doesn't. +pub fn app_is_closed(app_lock_taken: bool, bundle_use: BundleUse) -> Result<(), AppRunning> { + if !app_lock_taken { + return Err(AppRunning::LockHeld); + } + match bundle_use { + BundleUse::Free => Ok(()), + BundleUse::InUse { pid, path } => Err(AppRunning::Process { pid, path }), + BundleUse::Unknown { pid } => Err(AppRunning::Unknown { pid }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(value: &str) -> Version { + parse_version(value).unwrap() + } + + #[test] + fn versions_parse_with_or_without_a_v() { + assert_eq!(parse_version("v1.7.0"), Some(v("1.7.0"))); + assert_eq!(parse_version(" 1.7.0 "), Some(v("1.7.0"))); + assert_eq!(parse_version("1.7"), None); + } + + #[test] + fn only_a_strictly_newer_version_is_offered() { + let installed = v("1.6.0"); + assert!(offer_is_newer(&v("1.6.1"), &installed, None)); + assert!(!offer_is_newer(&v("1.6.0"), &installed, None)); + assert!(!offer_is_newer(&v("1.5.9"), &installed, None)); + assert!(offer_is_newer(&v("1.7.0-beta.1"), &installed, None)); + } + + #[test] + fn a_version_below_the_highest_ever_installed_is_a_downgrade() { + // Someone put 1.6 back by hand after 1.8: 1.7 is still refused. + let installed = v("1.6.0"); + let highest = v("1.8.0"); + assert!(!offer_is_newer(&v("1.7.0"), &installed, Some(&highest))); + assert!(!offer_is_newer(&v("1.8.0"), &installed, Some(&highest))); + assert!(offer_is_newer(&v("1.8.1"), &installed, Some(&highest))); + } + + #[test] + fn the_cooldown_doubles_after_each_failure_up_to_six_hours() { + assert_eq!(cooldown(0), Duration::from_secs(600)); + assert_eq!(cooldown(1), Duration::from_secs(1200)); + assert_eq!(cooldown(3), Duration::from_secs(4800)); + assert_eq!(cooldown(6), MAX_COOLDOWN); + assert_eq!(cooldown(u32::MAX), MAX_COOLDOWN); + } + + #[test] + fn a_check_is_due_once_the_cooldown_passed() { + assert!(check_is_due(1_000, None, 0)); + assert!(!check_is_due(1_000 + 599, Some(1_000), 0)); + assert!(check_is_due(1_000 + 600, Some(1_000), 0)); + assert!(!check_is_due(1_000 + 1_199, Some(1_000), 1)); + assert!(check_is_due(1_000 + 1_200, Some(1_000), 1)); + // A clock moved back doesn't block checks for good. + assert!(check_is_due(500, Some(1_000), 0)); + } + + #[test] + fn scheduled_checks_stay_within_half_an_hour_of_six_hours() { + let low = CHECK_INTERVAL - CHECK_JITTER; + let high = CHECK_INTERVAL + CHECK_JITTER; + assert_eq!(next_check_delay(0), low); + assert_eq!(next_check_delay(2 * CHECK_JITTER.as_secs()), high); + for seed in [1, 77, 12_345, u64::MAX] { + let delay = next_check_delay(seed); + assert!(delay >= low && delay <= high); + } + } + + #[test] + fn a_peer_on_a_newer_version_is_a_hint() { + let installed = v("1.6.0"); + assert_eq!( + peer_hint(["1.5.0", "1.7.0", "garbage", "1.6.2"], &installed), + Some(v("1.7.0")) + ); + assert_eq!(peer_hint(["1.6.0", "1.5.0"], &installed), None); + } + + fn bundle() -> PathBuf { + PathBuf::from("/Applications/Localify.app") + } + + #[test] + fn the_bundles_own_cli_and_other_apps_leave_it_free() { + let processes = [ + ( + 10, + ProcessPath::Path(bundle().join("Contents/MacOS/localify")), + ), + ( + 11, + ProcessPath::Path("/Applications/Localify.app.old/x".into()), + ), + (12, ProcessPath::Path("/usr/bin/true".into())), + (13, ProcessPath::Gone), + (14, ProcessPath::Deleted), + ]; + assert_eq!(bundle_use(&bundle(), &processes), BundleUse::Free); + } + + #[test] + fn the_app_running_from_the_bundle_is_found() { + let app = bundle().join("Contents/MacOS/localify-desktop"); + let processes = [ + (20, ProcessPath::Unreadable), + (21, ProcessPath::Path(app.clone())), + ]; + assert_eq!( + bundle_use(&bundle(), &processes), + BundleUse::InUse { pid: 21, path: app } + ); + } + + #[test] + fn an_unreadable_process_makes_the_answer_unknown() { + let processes = [(30, ProcessPath::Unreadable)]; + assert_eq!( + bundle_use(&bundle(), &processes), + BundleUse::Unknown { pid: 30 } + ); + } + + #[test] + fn the_installer_needs_the_lock_and_a_free_bundle() { + assert_eq!(app_is_closed(true, BundleUse::Free), Ok(())); + assert_eq!( + app_is_closed(false, BundleUse::Free), + Err(AppRunning::LockHeld) + ); + assert!(matches!( + app_is_closed(true, BundleUse::Unknown { pid: 3 }), + Err(AppRunning::Unknown { pid: 3 }) + )); + assert!(matches!( + app_is_closed( + true, + BundleUse::InUse { + pid: 4, + path: "/x".into() + } + ), + Err(AppRunning::Process { pid: 4, .. }) + )); + } +} diff --git a/src/infrastructure/updates/state.rs b/src/infrastructure/updates/state.rs new file mode 100644 index 0000000..a0a7f68 --- /dev/null +++ b/src/infrastructure/updates/state.rs @@ -0,0 +1,313 @@ +//! What the updater remembers between runs, in `updates.json`: the highest +//! version this machine installed, the archives that failed here, and when +//! the last check ran, so a restart doesn't reset the cooldown. + +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::path::PathBuf; + +use anyhow::{Context, Result, anyhow}; +use semver::Version; +use serde::{Deserialize, Serialize}; + +use super::UpdatePaths; +use super::release::Release; +use super::rules::{self, parse_version}; + +/// Enough to cover every release a machine could fail on in a row. +const MAX_FAILED_DIGESTS: usize = 20; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateState { + #[serde(default)] + pub highest_installed: Option, + /// SHA-256 of archives that failed verification here, newest last. + #[serde(default)] + pub failed_digests: Vec, + /// Unix seconds. + #[serde(default)] + pub last_check_at: Option, + /// Failed checks in a row. + #[serde(default)] + pub failures: u32, + #[serde(default)] + pub last_error: Option, + /// What the last successful check read. + #[serde(default)] + pub latest: Option, + /// Why the last install attempt didn't install, until one succeeds. + #[serde(default)] + pub install_error: Option, +} + +impl UpdateState { + pub fn highest(&self) -> Option { + self.highest_installed.as_deref().and_then(parse_version) + } + + /// Raises the highest installed version to `version` if it is higher. + pub fn saw_installed(&mut self, version: &Version) { + if self.highest().is_none_or(|highest| version > &highest) { + self.highest_installed = Some(version.to_string()); + } + } + + pub fn has_failed(&self, digest: &str) -> bool { + self.failed_digests.iter().any(|failed| failed == digest) + } + + pub fn record_failed(&mut self, digest: &str) { + if !self.has_failed(digest) { + self.failed_digests.push(digest.to_string()); + } + let excess = self.failed_digests.len().saturating_sub(MAX_FAILED_DIGESTS); + self.failed_digests.drain(..excess); + } +} + +/// `updates.json`, read and written whole. +pub struct StateStore { + path: PathBuf, +} + +impl StateStore { + pub fn new(paths: &UpdatePaths) -> Self { + Self { + path: paths.state_file(), + } + } + + /// A missing or unreadable file reads as a fresh state: the worst that + /// costs is one early check. + pub fn load(&self) -> UpdateState { + fs::read(&self.path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() + } + + /// Writes a temporary file and renames it over, so a reader never sees + /// half a file. + pub fn save(&self, state: &UpdateState) -> Result<()> { + let dir = self + .path + .parent() + .ok_or_else(|| anyhow!("The update state has no directory"))?; + fs::create_dir_all(dir).with_context(|| format!("Could not create {}", dir.display()))?; + let temporary = self + .path + .with_extension(format!("json.{}", std::process::id())); + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&temporary) + .with_context(|| format!("Could not write {}", temporary.display()))?; + file.write_all(&serde_json::to_vec_pretty(state)?)?; + file.sync_all()?; + fs::rename(&temporary, &self.path) + .with_context(|| format!("Could not write {}", self.path.display())) + } + + /// Reads, changes and writes the state under the check lock, so two + /// processes don't lose each other's change. + pub fn update( + &self, + paths: &UpdatePaths, + change: impl FnOnce(&mut UpdateState) -> T, + ) -> Result { + let _lock = lock(paths)?; + let mut state = self.load(); + let answer = change(&mut state); + self.save(&state)?; + Ok(answer) + } +} + +/// Whether a check reached the network or answered from the last one. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Checked { + Fetched(Release), + /// Within the cooldown of a successful check: its release. + Recent(Option), +} + +impl Checked { + pub fn release(&self) -> Option<&Release> { + match self { + Self::Fetched(release) => Some(release), + Self::Recent(release) => release.as_ref(), + } + } +} + +/// The one check every caller goes through: scheduled, hinted or asked by a +/// person, in the daemon or the CLI. It holds a machine-wide lock, so +/// concurrent callers wait for one check and share its answer, and it +/// reaches the network only once the cooldown has passed. Within the +/// cooldown of a failed check it answers that failure. +pub fn check( + paths: &UpdatePaths, + now: u64, + fetch: impl FnOnce() -> Result, +) -> Result { + let store = StateStore::new(paths); + let _lock = lock(paths)?; + let mut state = store.load(); + if !rules::check_is_due(now, state.last_check_at, state.failures) { + return match (&state.last_error, state.failures) { + (Some(error), failures) if failures > 0 => Err(anyhow!( + "{error}\nThe next check is allowed {} minutes after the last one.", + rules::cooldown(failures).as_secs() / 60 + )), + _ => Ok(Checked::Recent(state.latest)), + }; + } + state.last_check_at = Some(now); + let result = fetch(); + match &result { + Ok(release) => { + state.failures = 0; + state.last_error = None; + state.latest = Some(release.clone()); + } + Err(error) => { + state.failures = state.failures.saturating_add(1); + state.last_error = Some(format!("{error:#}")); + } + } + store.save(&state)?; + result.map(Checked::Fetched) +} + +/// The check lock, a file never deleted, so every process locks the same +/// inode. +fn lock(paths: &UpdatePaths) -> Result { + let path = paths.check_lock(); + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).with_context(|| format!("Could not create {}", dir.display()))?; + } + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(&path) + .with_context(|| format!("Could not open {}", path.display()))?; + file.lock() + .with_context(|| format!("Could not lock {}", path.display()))?; + Ok(file) +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + + fn release(version: &str) -> Release { + Release { + version: version.into(), + archive: None, + } + } + + fn paths() -> (tempfile::TempDir, UpdatePaths) { + let dir = tempfile::tempdir().unwrap(); + let paths = UpdatePaths::new(dir.path().to_path_buf()); + (dir, paths) + } + + #[test] + fn checks_within_the_cooldown_share_the_last_answer() { + let (_dir, paths) = paths(); + let fetches = Cell::new(0); + let fetch = || { + fetches.set(fetches.get() + 1); + Ok(release("1.7.0")) + }; + assert_eq!( + check(&paths, 1_000, fetch).unwrap(), + Checked::Fetched(release("1.7.0")) + ); + let again = check(&paths, 1_000 + 599, || { + fetches.set(fetches.get() + 1); + Ok(release("9.9.9")) + }) + .unwrap(); + assert_eq!(again, Checked::Recent(Some(release("1.7.0")))); + assert_eq!(fetches.get(), 1); + + let later = check(&paths, 1_000 + 600, || Ok(release("1.7.1"))).unwrap(); + assert_eq!(later, Checked::Fetched(release("1.7.1"))); + } + + #[test] + fn failures_double_the_cooldown_and_survive_a_restart() { + let (_dir, paths) = paths(); + assert!(check(&paths, 1_000, || Err(anyhow!("offline"))).is_err()); + // A new process reads the persisted failure: 20 minutes now. + let early = check(&paths, 1_000 + 1_199, || Ok(release("1.7.0"))); + let error = early.unwrap_err().to_string(); + assert!(error.contains("offline"), "{error}"); + assert!(check(&paths, 1_000 + 1_200, || Err(anyhow!("offline"))).is_err()); + assert_eq!(StateStore::new(&paths).load().failures, 2); + // 40 minutes after the second failure. + assert!(check(&paths, 2_200 + 2_399, || Ok(release("1.7.0"))).is_err()); + assert!(check(&paths, 2_200 + 2_400, || Ok(release("1.7.0"))).is_ok()); + let state = StateStore::new(&paths).load(); + assert_eq!((state.failures, state.last_error), (0, None)); + } + + #[test] + fn concurrent_checks_coalesce_into_one_fetch() { + let (_dir, paths) = paths(); + let fetches = std::sync::atomic::AtomicUsize::new(0); + std::thread::scope(|scope| { + for _ in 0..4 { + scope.spawn(|| { + check(&paths, 5_000, || { + fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(release("1.7.0")) + }) + .unwrap() + }); + } + }); + assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1); + } + + #[test] + fn failed_digests_are_remembered_and_bounded() { + let mut state = UpdateState::default(); + state.record_failed("aa"); + state.record_failed("aa"); + assert_eq!(state.failed_digests, vec!["aa".to_string()]); + for index in 0..30 { + state.record_failed(&format!("{index:02}")); + } + assert_eq!(state.failed_digests.len(), MAX_FAILED_DIGESTS); + assert!(!state.has_failed("aa")); + assert!(state.has_failed("29")); + } + + #[test] + fn the_highest_installed_version_only_rises() { + let mut state = UpdateState::default(); + state.saw_installed(&Version::new(1, 7, 0)); + state.saw_installed(&Version::new(1, 6, 0)); + assert_eq!(state.highest(), Some(Version::new(1, 7, 0))); + } + + #[test] + fn a_corrupt_state_file_reads_as_fresh() { + let (_dir, paths) = paths(); + fs::create_dir_all(paths.dir()).unwrap(); + fs::write(paths.state_file(), b"{not json").unwrap(); + assert_eq!(StateStore::new(&paths).load(), UpdateState::default()); + } +} diff --git a/src/main.rs b/src/main.rs index 9ff111c..cc371ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -460,6 +460,25 @@ enum Commands { command: Option, }, + /// Show what is installed and available, turn automatic updates on or off, or install now + #[command(args_conflicts_with_subcommands = true)] + Update { + /// Show what is installed and available, and why an update waits (the default) + #[arg(long)] + status: bool, + + /// Install new versions by themselves (on), or only when asked (off) + #[arg(long, value_enum)] + auto: Option, + + /// Print JSON (the default when stdout is not a terminal) + #[arg(long, global = true)] + json: bool, + + #[command(subcommand)] + command: Option, + }, + /// Share a project domain at https://.localify.fyi, behind a password unless opened PublicUrl { /// Print JSON (the default when stdout is not a terminal) @@ -484,6 +503,16 @@ enum Commands { }, } +#[derive(Subcommand)] +enum UpdateCommands { + /// Install the latest version now, once the app is closed (macOS) + Install { + /// Wait for the app to quit, then open the new version + #[arg(long)] + relaunch: bool, + }, +} + #[derive(Subcommand)] enum MachinesCommands { /// Pair with another machine: both show a code, and a person confirms it on each @@ -1520,6 +1549,23 @@ fn main() -> Result<()> { Commands::Billing { json } => { cli::cloud::billing(json, &cloud_adapters(config_path, &paths)) } + Commands::Update { + status: _, + auto, + json, + command, + } => match (command, auto) { + (Some(UpdateCommands::Install { relaunch }), _) => { + cli::update::install(relaunch, json, &ctx.mgmt_client, config_path) + } + (None, Some(switch)) => cli::update::set_automatic( + switch == cli::machines::Switch::On, + json, + &ctx.mgmt_client, + config_path, + ), + (None, None) => cli::update::status(json, &ctx.mgmt_client, config_path), + }, Commands::Machines { json, command } => match command { None => cli::machines::list(json, &ctx), Some(MachinesCommands::Pair { accept: true, .. }) => cli::machines::accept(&ctx), From 12442b9adf86a928db79267ad06ecbf3fa3a708f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9o=20Goulois?= Date: Mon, 9 Nov 2026 19:17:46 +0100 Subject: [PATCH 5/9] fix(config): a config that doesn't exist yet keeps automatic updates on 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. --- desktop-ui/src/hooks/use-app-update.ts | 13 - .../src/providers/app-update-provider.tsx | 258 ------------------ src/infrastructure/config/mod.rs | 28 +- 3 files changed, 22 insertions(+), 277 deletions(-) delete mode 100644 desktop-ui/src/hooks/use-app-update.ts delete mode 100644 desktop-ui/src/providers/app-update-provider.tsx diff --git a/desktop-ui/src/hooks/use-app-update.ts b/desktop-ui/src/hooks/use-app-update.ts deleted file mode 100644 index 6235e66..0000000 --- a/desktop-ui/src/hooks/use-app-update.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useContext } from "react"; - -import { AppUpdateContext } from "@/providers/app-update-provider"; - -export function useAppUpdate() { - const context = useContext(AppUpdateContext); - - if (!context) { - throw new Error("useAppUpdate must be used within AppUpdateProvider"); - } - - return context; -} diff --git a/desktop-ui/src/providers/app-update-provider.tsx b/desktop-ui/src/providers/app-update-provider.tsx deleted file mode 100644 index 0b4d55e..0000000 --- a/desktop-ui/src/providers/app-update-provider.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { confirm, message } from "@tauri-apps/plugin-dialog"; -import { - createContext, - type PropsWithChildren, - useEffect, - useEffectEvent, - useMemo, - useRef, - useState, -} from "react"; - -import { tauriClient } from "@/lib/tauri-client"; -import type { AppUpdateInfoDto } from "@/types/localify"; - -export type AppUpdateStatus = - | "idle" - | "checking" - | "available" - | "installing" - | "upToDate" - | "unconfigured" - | "error"; - -interface AppUpdateState { - status: AppUpdateStatus; - availableUpdate: AppUpdateInfoDto | null; - errorMessage: string | null; -} - -interface AppUpdateContextValue extends AppUpdateState { - isChecking: boolean; - isInstalling: boolean; - checkForUpdates: () => Promise; - installUpdate: () => Promise; -} - -interface AppUpdateProviderProps extends PropsWithChildren { - desktopName: string; - enabled: boolean; -} - -const defaultState: AppUpdateState = { - status: "idle", - availableUpdate: null, - errorMessage: null, -}; - -const UPDATE_CHECK_TIMEOUT_MS = 30_000; -const UPDATE_INSTALL_TIMEOUT_MS = 5 * 60_000; - -export const AppUpdateContext = createContext(null); - -async function withTimeout( - promise: Promise, - timeoutMs: number, - message: string, -): Promise { - let timeoutId: ReturnType | undefined; - const timeout = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); - }); - - try { - return await Promise.race([promise, timeout]); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } -} - -function resolveErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim()) { - return error.message; - } - - return "Localify could not download the latest desktop update."; -} - -async function confirmUpdateInstall( - desktopName: string, - update: AppUpdateInfoDto, -): Promise { - return confirm( - `Version ${update.version} is available.\n\nDownload and install it now? ${desktopName} will restart automatically when the install finishes.`, - { - title: "Update Available", - kind: "info", - okLabel: "Download", - cancelLabel: "Not now", - }, - ); -} - -export const AppUpdateProvider = ({ - children, - desktopName, - enabled, -}: AppUpdateProviderProps) => { - const [state, setState] = useState(defaultState); - const backgroundCheckStartedRef = useRef(false); - const checkInFlightRef = useRef(false); - const installInFlightRef = useRef(false); - - const installUpdateEvent = useEffectEvent( - async (update: AppUpdateInfoDto | null, source: "background" | "manual") => { - if (!update || installInFlightRef.current) { - return; - } - - installInFlightRef.current = true; - setState({ - status: "installing", - availableUpdate: update, - errorMessage: null, - }); - - try { - await withTimeout( - tauriClient.installAppUpdate(), - UPDATE_INSTALL_TIMEOUT_MS, - "Update download timed out. Check your connection and try again.", - ); - setState({ - status: "upToDate", - availableUpdate: null, - errorMessage: null, - }); - } catch (error) { - const errorMessage = resolveErrorMessage(error); - - setState({ - status: "error", - availableUpdate: update, - errorMessage, - }); - - await message(errorMessage, { - title: source === "manual" ? "Update Failed" : "Update Install Failed", - kind: "error", - }); - } finally { - installInFlightRef.current = false; - } - }, - ); - - const checkForUpdatesEvent = useEffectEvent(async (source: "background" | "manual") => { - if (!enabled || checkInFlightRef.current || installInFlightRef.current) { - return; - } - - checkInFlightRef.current = true; - setState((current) => ({ - ...current, - status: "checking", - errorMessage: null, - })); - - try { - const updateCheck = await withTimeout( - tauriClient.checkAppUpdate(), - UPDATE_CHECK_TIMEOUT_MS, - "Update check timed out. Try again in a moment.", - ); - - if (!updateCheck.configured) { - setState({ - status: "unconfigured", - availableUpdate: null, - errorMessage: null, - }); - - if (source === "manual") { - await message("Desktop updates are not configured for this build.", { - title: "Updates Unavailable", - kind: "info", - }); - } - - return; - } - - if (!updateCheck.update) { - setState({ - status: "upToDate", - availableUpdate: null, - errorMessage: null, - }); - - if (source === "manual") { - await message(`${desktopName} is already up to date.`, { - title: "You're Up to Date", - kind: "info", - }); - } - - return; - } - - setState({ - status: "available", - availableUpdate: updateCheck.update, - errorMessage: null, - }); - - const confirmed = await confirmUpdateInstall(desktopName, updateCheck.update); - if (!confirmed) { - return; - } - - await installUpdateEvent(updateCheck.update, source); - } catch (error) { - const errorMessage = resolveErrorMessage(error); - - setState((current) => ({ - status: "error", - availableUpdate: current.availableUpdate, - errorMessage, - })); - - if (source === "manual") { - await message(errorMessage, { - title: "Update Check Failed", - kind: "error", - }); - } - } finally { - checkInFlightRef.current = false; - } - }); - - useEffect(() => { - if (!enabled || backgroundCheckStartedRef.current) { - return; - } - - backgroundCheckStartedRef.current = true; - void checkForUpdatesEvent("background"); - }, [enabled]); - - const value = useMemo( - () => ({ - ...state, - isChecking: state.status === "checking", - isInstalling: state.status === "installing", - checkForUpdates: async () => { - await checkForUpdatesEvent("manual"); - }, - installUpdate: async () => { - await installUpdateEvent(state.availableUpdate, "manual"); - }, - }), - [state], - ); - - return {children}; -}; diff --git a/src/infrastructure/config/mod.rs b/src/infrastructure/config/mod.rs index e7ed71e..7a13999 100644 --- a/src/infrastructure/config/mod.rs +++ b/src/infrastructure/config/mod.rs @@ -1046,9 +1046,12 @@ impl ConfigStore { Ok(()) } + /// A config that doesn't exist yet is a new install's, so anything + /// saved before `localify install`, such as a setting changed during + /// the app's welcome, keeps automatic updates on. pub fn load(&self) -> Result { if !self.path.exists() { - return Ok(Config::default()); + return Ok(new_install_config()); } let content = fs::read_to_string(&self.path).map_err(ConfigError::ReadError)?; @@ -1733,18 +1736,22 @@ fn map_config_error(e: ConfigError) -> ConfigLoadError { } } +/// A new install's config: automatic updates start on, unlike a config +/// that predates them. +fn new_install_config() -> Config { + let mut config = Config::default(); + config.updates.automatic = true; + config +} + impl ConfigLoader for ConfigStore { fn load(&self) -> Result<(DaemonConfig, LocalifyPaths), ConfigLoadError> { let config = ConfigStore::load(self).map_err(map_config_error)?; Ok((config.daemon, config.paths)) } - /// A new install's config: automatic updates start on, unlike a config - /// that predates them. fn save_defaults(&self) -> Result<(), ConfigLoadError> { - let mut config = Config::default(); - config.updates.automatic = true; - self.save(&config).map_err(map_config_error) + self.save(&new_install_config()).map_err(map_config_error) } fn exists(&self) -> bool { @@ -1823,6 +1830,15 @@ mod tests { assert!(text.contains("[updates]\nautomatic = true"), "{text}"); } + #[test] + fn a_setting_saved_before_the_install_keeps_automatic_updates_on() { + let (_dir, store) = temp_store(); + let mut config = store.load().unwrap(); + config.agent.connect_agents = false; + store.save(&config).unwrap(); + assert!(store.load().unwrap().updates.automatic); + } + #[test] fn a_config_from_before_automatic_updates_keeps_them_off() { let (_dir, store) = temp_store(); From ee8ebe01d728dd3999df2210dc7f3a30b1108163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9o=20Goulois?= Date: Mon, 9 Nov 2026 19:17:46 +0100 Subject: [PATCH 6/9] feat(desktop): restart to update through Localify's installer 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. --- desktop-ui/src/components/app-layout.tsx | 2 + desktop-ui/src/components/app-shell.tsx | 24 +- .../src/components/app/restart-to-update.tsx | 106 ++++++++ .../app/settings/settings-screen.tsx | 2 +- .../app/settings/settings-update-card.tsx | 146 +++++----- .../app/settings/settings-update-preview.tsx | 134 +++++---- .../app/settings/settings-updates.tsx | 49 +++- .../src/components/app/update-notice.tsx | 40 +++ desktop-ui/src/components/onboarding.tsx | 20 +- desktop-ui/src/lib/queries/system.queries.ts | 29 ++ desktop-ui/src/lib/tauri-client.ts | 24 +- desktop-ui/src/lib/util.app-update.test.ts | 155 +++++++++++ desktop-ui/src/lib/util.app-update.ts | 94 +++++++ .../src/lib/validators/validator.autostart.ts | 4 + desktop-ui/src/types/localify.ts | 29 +- docs/adr/0034-machines-update-themselves.md | 2 +- docs/desktop.md | 12 +- src-tauri/Cargo.lock | 199 +------------- src-tauri/Cargo.toml | 1 - src-tauri/src/commands.rs | 256 ++++-------------- src-tauri/src/lib.rs | 206 ++++++++------ src/desktop/api.rs | 4 + src/desktop/mod.rs | 1 + src/desktop/updates.rs | 215 +++++++++++++++ src/infrastructure/updates/app_lock.rs | 169 ++++++++++++ src/infrastructure/updates/mod.rs | 23 +- 26 files changed, 1257 insertions(+), 689 deletions(-) create mode 100644 desktop-ui/src/components/app/restart-to-update.tsx create mode 100644 desktop-ui/src/components/app/update-notice.tsx create mode 100644 desktop-ui/src/lib/util.app-update.test.ts create mode 100644 desktop-ui/src/lib/util.app-update.ts create mode 100644 src/desktop/updates.rs create mode 100644 src/infrastructure/updates/app_lock.rs diff --git a/desktop-ui/src/components/app-layout.tsx b/desktop-ui/src/components/app-layout.tsx index 114f956..27544fa 100644 --- a/desktop-ui/src/components/app-layout.tsx +++ b/desktop-ui/src/components/app-layout.tsx @@ -9,6 +9,7 @@ import { HttpsTrustNotice } from "./app/https-trust-notice"; import { useProjectCreateHotkey } from "./app/projects/use-project-create-hotkey"; import { useProjectSearchHotkey } from "./app/projects/use-project-search-hotkey"; import { TerminalPanel } from "./app/terminal/terminal-panel"; +import { UpdateNotice } from "./app/update-notice"; import { DesktopAnalytics } from "./shared/desktop-analytics"; import { PageTransition } from "./shared/page-transition"; import { SharedNavigation } from "./shared/shared-navigation"; @@ -43,6 +44,7 @@ function AppLayoutContent() { {!isOpen && !outputOpen ? : null} +
diff --git a/desktop-ui/src/components/app-shell.tsx b/desktop-ui/src/components/app-shell.tsx index 2a9ecaf..da6185d 100644 --- a/desktop-ui/src/components/app-shell.tsx +++ b/desktop-ui/src/components/app-shell.tsx @@ -9,7 +9,6 @@ import { loadStoredOnboardingCompleted, markStoredOnboardingCompleted, } from "@/lib/util.onboarding-state"; -import { AppUpdateProvider } from "@/providers/app-update-provider"; import { SpinnerIcon } from "./icons"; import { Onboarding } from "./onboarding"; @@ -83,20 +82,13 @@ export const AppShell = ({ children }: AppShellProps) => { markStoredOnboardingCompleted(); }; - return ( - - {needsSetup ? ( - - ) : ( - <>{children} - )} - + return needsSetup ? ( + + ) : ( + <>{children} ); }; diff --git a/desktop-ui/src/components/app/restart-to-update.tsx b/desktop-ui/src/components/app/restart-to-update.tsx new file mode 100644 index 0000000..08914fd --- /dev/null +++ b/desktop-ui/src/components/app/restart-to-update.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { systemMutations } from "@/lib/queries"; +import { restartCloses } from "@/lib/util.app-update"; +import { useTerminal } from "@/providers/terminal-provider"; + +interface RestartToUpdateProps { + appName: string; + version: string; + size?: "xs" | "sm"; +} + +/** + * `Restart to update`: names the tabs a restart closes, then hands the install to Localify's + * installer and quits. The app never restarts by itself. + */ +export const RestartToUpdate = ({ appName, version, size = "sm" }: RestartToUpdateProps) => { + const { sessions } = useTerminal(); + const restart = useMutation(systemMutations.restartToUpdate()); + const [open, setOpen] = useState(false); + const closes = restartCloses(sessions); + const closesNothing = closes.services.length === 0 && closes.shells.length === 0; + + return ( + <> + + + + + Restart to update? + + {appName} quits, installs {version}, then opens again. + + + + {closesNothing ? ( +

No service or shell tab is open.

+ ) : ( + <> + + + + )} + {restart.error ? ( +

{restart.error.message}

+ ) : null} +
+ + } disabled={restart.isPending}> + Cancel + + + +
+
+ + ); +}; + +const ClosedTabs = ({ label, names }: { label: string; names: string[] }) => { + if (names.length === 0) return null; + + return ( +
+

{label}

+
    + {names.map((name, index) => ( + // Two shells in one checkout share a name. +
  • + {name} +
  • + ))} +
+
+ ); +}; diff --git a/desktop-ui/src/components/app/settings/settings-screen.tsx b/desktop-ui/src/components/app/settings/settings-screen.tsx index 9e316b1..1ab2327 100644 --- a/desktop-ui/src/components/app/settings/settings-screen.tsx +++ b/desktop-ui/src/components/app/settings/settings-screen.tsx @@ -61,6 +61,7 @@ export const SettingsScreen = () => {
+ @@ -70,7 +71,6 @@ export const SettingsScreen = () => { - {import.meta.env.DEV ? : null}
diff --git a/desktop-ui/src/components/app/settings/settings-update-card.tsx b/desktop-ui/src/components/app/settings/settings-update-card.tsx index a426276..1f54b24 100644 --- a/desktop-ui/src/components/app/settings/settings-update-card.tsx +++ b/desktop-ui/src/components/app/settings/settings-update-card.tsx @@ -1,95 +1,87 @@ "use client"; -import { motion } from "motion/react"; +import type { ReactNode } from "react"; + +import type { UpdateStatusDto } from "@/types/localify"; import { DownloadIcon } from "@/components/icons"; import { Button } from "@/components/ui/button"; -import type { AppUpdateStatus } from "@/providers/app-update-provider"; -import type { AppUpdateInfoDto } from "@/types/localify"; +import { Switch } from "@/components/ui/switch"; +import { describeUpdate } from "@/lib/util.app-update"; + +import { SettingsCard } from "./settings-card"; interface SettingsUpdateCardProps { - status: AppUpdateStatus; - availableUpdate: AppUpdateInfoDto | null; - errorMessage: string | null; - onCheckForUpdates: () => void; - onInstallUpdate: () => void; + appName: string; + /** Null while it is read. */ + status: UpdateStatusDto | null; + /** Unix seconds, for the last check's age. */ + now: number; + error: string | null; + automatic: boolean; + automaticDisabled: boolean; + onAutomaticChange: (automatic: boolean) => void; + checking: boolean; + onCheck: () => void; + /** Shown in place of `Check now` when an update can be installed. */ + restart: ReactNode; } export const SettingsUpdateCard = ({ + appName, status, - availableUpdate, - errorMessage, - onCheckForUpdates, - onInstallUpdate, + now, + error, + automatic, + automaticDisabled, + onAutomaticChange, + checking, + onCheck, + restart, }: SettingsUpdateCardProps) => { - const isChecking = status === "checking"; - const isInstalling = status === "installing"; - const hasUpdate = Boolean(availableUpdate); - const title = hasUpdate - ? `Update ready - v${availableUpdate?.version}` - : status === "upToDate" - ? "You're up to date" - : status === "unconfigured" - ? "Updates unavailable" - : "Desktop updates"; - const description = hasUpdate - ? "A new Localify build is ready to download and install." - : status === "unconfigured" - ? "Auto-updates are not configured for this build." - : "Checks for new Localify builds in the background and on demand."; - - const action = hasUpdate ? ( - - ) : ( - - ); + const summary = status ? describeUpdate(status, appName, now) : null; + const busy = status?.state === "checking" || status?.state === "installing"; return ( -
- -
- - - -
-

- {title} -

-

- {description} + } + title="Install updates automatically" + description={`New versions install while ${appName} is closed. While it's open, they wait for you to restart.`} + trailing={ + + } + > +

+
+

+ {summary?.title ?? "Reading the update status…"} +

+ {summary?.detail ? ( +

+ {summary.detail}

-
- {action} + ) : null}
- - - {errorMessage ? ( -

{errorMessage}

- ) : null} -
+ {summary?.canRestart ? ( + restart + ) : ( + + )} + + {error ?

{error}

: null} + ); }; diff --git a/desktop-ui/src/components/app/settings/settings-update-preview.tsx b/desktop-ui/src/components/app/settings/settings-update-preview.tsx index 92e8202..a4ab352 100644 --- a/desktop-ui/src/components/app/settings/settings-update-preview.tsx +++ b/desktop-ui/src/components/app/settings/settings-update-preview.tsx @@ -2,76 +2,64 @@ import { useState } from "react"; +import type { UpdateStatusDto } from "@/types/localify"; + import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import type { AppUpdateStatus } from "@/providers/app-update-provider"; -import type { AppUpdateInfoDto } from "@/types/localify"; import { SettingsUpdateCard } from "./settings-update-card"; -type UpdatePreviewState = { - status: AppUpdateStatus; - label: string; - update: AppUpdateInfoDto | null; - errorMessage: string | null; -}; +const NOW = 1_780_000_000; -const previewUpdate = { - version: "1.4.0", - notes: "Mock release notes for the update card preview.", - pubDate: "2026-04-27T10:00:00Z", -} satisfies AppUpdateInfoDto; +const base: UpdateStatusDto = { + installed: "1.6.0", + available: null, + state: "upToDate", + reason: null, + automatic: true, + lastCheckAt: NOW - 3 * 3600, + canInstall: true, +}; -const previewStates = [ - { - status: "idle", - label: "Idle", - update: null, - errorMessage: null, - }, - { - status: "checking", - label: "Checking", - update: null, - errorMessage: null, - }, - { - status: "available", - label: "Available", - update: previewUpdate, - errorMessage: null, - }, - { - status: "installing", - label: "Installing", - update: previewUpdate, - errorMessage: null, - }, +const previewStates: { label: string; status: UpdateStatusDto }[] = [ + { label: "Never checked", status: { ...base, state: "unknown", lastCheckAt: null } }, + { label: "Up to date", status: base }, + { label: "Checking", status: { ...base, state: "checking" } }, + { label: "Available", status: { ...base, state: "available", available: "1.7.0" } }, { - status: "upToDate", - label: "Up to date", - update: null, - errorMessage: null, + label: "Automatic off", + status: { + ...base, + state: "available", + available: "1.7.0", + automatic: false, + reason: + "Automatic updates are off. Install it with: localify update install, or turn them on with: localify update --auto on", + }, }, { - status: "unconfigured", - label: "Unconfigured", - update: null, - errorMessage: null, + label: "Can't install", + status: { + ...base, + state: "available", + available: "1.7.0", + canInstall: false, + reason: + "/Applications/Localify.app belongs to another account: only its owner can update it.", + }, }, + { label: "Installing", status: { ...base, state: "installing", available: "1.7.0" } }, { - status: "error", - label: "Error", - update: previewUpdate, - errorMessage: "Localify could not download the latest desktop update.", + label: "Failed", + status: { ...base, state: "failed", reason: "The update feed could not be reached." }, }, -] satisfies UpdatePreviewState[]; +]; +/** Mock states for designing the update card without a daemon. */ export const SettingsUpdatePreview = () => { - const [selectedStatus, setSelectedStatus] = useState("checking"); - const selectedState = - previewStates.find((previewState) => previewState.status === selectedStatus) ?? - previewStates[0]; + const [selected, setSelected] = useState("Available"); + const preview = + previewStates.find((previewState) => previewState.label === selected) ?? previewStates[0]; return (
@@ -85,16 +73,11 @@ export const SettingsUpdatePreview = () => {
{previewStates.map((previewState) => ( @@ -102,15 +85,20 @@ export const SettingsUpdatePreview = () => {
{ - setSelectedStatus("checking"); - }} - onInstallUpdate={() => { - setSelectedStatus("installing"); - }} + appName="Localify" + status={preview.status} + now={NOW} + error={null} + automatic={preview.status.automatic} + automaticDisabled={false} + onAutomaticChange={() => {}} + checking={false} + onCheck={() => setSelected("Checking")} + restart={ + + } />
); diff --git a/desktop-ui/src/components/app/settings/settings-updates.tsx b/desktop-ui/src/components/app/settings/settings-updates.tsx index d4c4882..84e8f19 100644 --- a/desktop-ui/src/components/app/settings/settings-updates.tsx +++ b/desktop-ui/src/components/app/settings/settings-updates.tsx @@ -1,23 +1,50 @@ "use client"; -import { useAppUpdate } from "@/hooks/use-app-update"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { RestartToUpdate } from "@/components/app/restart-to-update"; +import { bootstrapQueries, systemMutations, systemQueries } from "@/lib/queries"; import { SettingsUpdateCard } from "./settings-update-card"; export const SettingsUpdates = () => { - const appUpdate = useAppUpdate(); + const queryClient = useQueryClient(); + const { data: bootstrap } = useQuery(bootstrapQueries.read()); + const status = useQuery(systemQueries.updateStatus()); + const setAutomatic = useMutation(systemMutations.setAutomaticUpdates(queryClient)); + const check = useMutation(systemMutations.checkForUpdates(queryClient)); + + const appName = bootstrap?.environment.appName ?? "Localify"; + const pendingAutomatic = setAutomatic.isPending ? setAutomatic.variables?.automatic : undefined; + const error = check.error?.message ?? status.error?.message ?? null; + + async function handleAutomaticChange(automatic: boolean) { + try { + await setAutomatic.mutateAsync({ automatic }); + } catch (mutationError) { + const message = + mutationError instanceof Error ? mutationError.message : String(mutationError); + toast.error("The update setting didn't change", { description: message }); + } + } return ( { - void appUpdate.checkForUpdates(); - }} - onInstallUpdate={() => { - void appUpdate.installUpdate(); - }} + appName={appName} + status={status.data ?? null} + now={Math.floor(Date.now() / 1000)} + error={error} + automatic={pendingAutomatic ?? status.data?.automatic ?? false} + automaticDisabled={!status.data || setAutomatic.isPending} + onAutomaticChange={(automatic) => void handleAutomaticChange(automatic)} + checking={check.isPending} + onCheck={() => check.mutate()} + restart={ + status.data?.available ? ( + + ) : null + } /> ); }; diff --git a/desktop-ui/src/components/app/update-notice.tsx b/desktop-ui/src/components/app/update-notice.tsx new file mode 100644 index 0000000..e231231 --- /dev/null +++ b/desktop-ui/src/components/app/update-notice.tsx @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { XIcon } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { bootstrapQueries, systemQueries } from "@/lib/queries"; + +import { RestartToUpdate } from "./restart-to-update"; + +/** A quiet line while an update waits for the person to restart. Hidden for this run once closed. */ +export const UpdateNotice = () => { + const { data: bootstrap } = useQuery(bootstrapQueries.read()); + const { data: status } = useQuery(systemQueries.updateStatus()); + const [dismissed, setDismissed] = useState(null); + + const version = status?.available; + if (!bootstrap || !version || status.state !== "available" || !status.canInstall) return null; + if (dismissed === version) return null; + + const appName = bootstrap.environment.appName; + + return ( +
+

+ {appName} {version} is ready to install. +

+
+ + +
+
+ ); +}; diff --git a/desktop-ui/src/components/onboarding.tsx b/desktop-ui/src/components/onboarding.tsx index 8658e22..28a6cc4 100644 --- a/desktop-ui/src/components/onboarding.tsx +++ b/desktop-ui/src/components/onboarding.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion, @@ -30,7 +30,13 @@ import { import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { captureDesktopEvent } from "@/lib/analytics"; -import { bootstrapQueries, configMutations, daemonMutations, systemMutations } from "@/lib/queries"; +import { + bootstrapQueries, + configMutations, + daemonMutations, + systemMutations, + systemQueries, +} from "@/lib/queries"; import { tauriClient } from "@/lib/tauri-client"; import { assertInstallCompleted } from "@/lib/util.install-result"; import { cn } from "@/lib/utils"; @@ -623,6 +629,8 @@ function ActivateStep({ const queryClient = useQueryClient(); const install = useMutation(systemMutations.install(queryClient)); const startDaemon = useMutation(daemonMutations.start(queryClient)); + // On for a new install; an older config that predates the setting keeps it off. + const { data: updateStatus } = useQuery(systemQueries.updateStatus()); const appName = bootstrap.environment.appName; const [state, setState] = useState("idle"); @@ -683,6 +691,14 @@ function ActivateStep({ label: `DNS for ${suffix} domains, without /etc/hosts`, }, { icon: , label: `${appName} runs as you, not as root` }, + ...(updateStatus?.automatic + ? [ + { + icon: , + label: "Updates install themselves, unless you turn them off in Settings", + }, + ] + : []), ...(connectAgents ? [ { diff --git a/desktop-ui/src/lib/queries/system.queries.ts b/desktop-ui/src/lib/queries/system.queries.ts index b8e64c9..ffaa737 100644 --- a/desktop-ui/src/lib/queries/system.queries.ts +++ b/desktop-ui/src/lib/queries/system.queries.ts @@ -16,6 +16,8 @@ import type { SettingsImportResultDto, StepDto, UpdateAutostartRequest, + UpdateSettingsRequest, + UpdateStatusDto, UpdateLauncherPreferencesRequest, UninstallPreviewDto, } from "../../types/localify"; @@ -32,8 +34,12 @@ const systemQueryKeys = { agentMcpStatus: () => [...systemQueryKeys.all(), "agent-mcp-status"] as const, autostartStatus: () => [...systemQueryKeys.all(), "autostart-status"] as const, uninstallPreview: () => [...systemQueryKeys.all(), "uninstall-preview"] as const, + updateStatus: () => [...systemQueryKeys.all(), "update-status"] as const, } as const; +/** The daemon checks every few hours: a slow poll still shows its answer and an install. */ +const UPDATE_STATUS_POLL_MS = 5 * 60_000; + const systemMutationInvalidations = [ daemonQueries.all(), domainQueries.all(), @@ -59,6 +65,12 @@ export const systemQueries = { queryKey: systemQueryKeys.uninstallPreview(), queryFn: () => tauriClient.getUninstallPreview(), }), + updateStatus: () => + queryOptions({ + queryKey: systemQueryKeys.updateStatus(), + queryFn: () => tauriClient.getUpdateStatus(), + refetchInterval: UPDATE_STATUS_POLL_MS, + }), } as const; export const systemMutations = { @@ -114,6 +126,23 @@ export const systemMutations = { mutationFn: (request) => tauriClient.setAutostartEnabled(request), onSuccess: async () => invalidateBootstrapAnd(queryClient, systemMutationInvalidations), }), + setAutomaticUpdates: (queryClient: QueryClient) => + mutationOptions({ + mutationFn: (request) => tauriClient.setAutomaticUpdates(request), + onSuccess: (status) => queryClient.setQueryData(systemQueryKeys.updateStatus(), status), + }), + checkForUpdates: (queryClient: QueryClient) => + mutationOptions({ + mutationFn: () => tauriClient.checkForUpdates(), + onSuccess: (status) => queryClient.setQueryData(systemQueryKeys.updateStatus(), status), + // A failed check is in the status too. + onError: async () => + queryClient.invalidateQueries({ queryKey: systemQueryKeys.updateStatus() }), + }), + restartToUpdate: () => + mutationOptions({ + mutationFn: () => tauriClient.restartToUpdate(), + }), uninstall: (queryClient: QueryClient) => mutationOptions({ mutationFn: () => tauriClient.uninstallLocalify(), diff --git a/desktop-ui/src/lib/tauri-client.ts b/desktop-ui/src/lib/tauri-client.ts index b2f2917..7f1106e 100644 --- a/desktop-ui/src/lib/tauri-client.ts +++ b/desktop-ui/src/lib/tauri-client.ts @@ -11,7 +11,6 @@ import type { AppConfigDto, AutostartStatusDto, DeepLinkPayloadDto, - AppUpdateCheckDto, BootstrapDto, CheckPublicUrlSlugRequest, CliAccessDto, @@ -109,6 +108,8 @@ import type { ShellEnvironmentDto, TerminalWriteRequest, UpdateAutostartRequest, + UpdateSettingsRequest, + UpdateStatusDto, UnregisterDomainResultDto, UpdateDomainRequest, UpdateLauncherPreferencesRequest, @@ -116,7 +117,10 @@ import type { } from "../types/localify"; import { addProjectServiceRequestSchema } from "./validators/validator.add-project-service"; -import { updateAutostartRequestSchema } from "./validators/validator.autostart"; +import { + updateAutostartRequestSchema, + updateSettingsRequestSchema, +} from "./validators/validator.autostart"; import { checkPublicUrlSlugRequestSchema, createPublicUrlRequestSchema, @@ -220,8 +224,11 @@ export interface DesktopClient { getBootstrap(): Promise; getAutostartStatus(): Promise; setAutostartEnabled(request: UpdateAutostartRequest): Promise; - checkAppUpdate(): Promise; - installAppUpdate(): Promise; + getUpdateStatus(): Promise; + setAutomaticUpdates(request: UpdateSettingsRequest): Promise; + checkForUpdates(): Promise; + /** Hands the install off and quits; rejects, and stays open, when it can't. */ + restartToUpdate(): Promise; readInstallAttributionClipboard(): Promise; consumePendingDeepLink(): Promise; getConfig(): Promise; @@ -358,8 +365,13 @@ export function createDesktopClient(): DesktopClient { tauriInvoke("set_autostart_enabled", { request: updateAutostartRequestSchema.parse(request), }), - checkAppUpdate: () => tauriInvoke("check_app_update"), - installAppUpdate: () => tauriInvoke("install_app_update"), + getUpdateStatus: () => tauriInvoke("get_update_status"), + setAutomaticUpdates: (request) => + tauriInvoke("set_automatic_updates", { + request: updateSettingsRequestSchema.parse(request), + }), + checkForUpdates: () => tauriInvoke("check_for_updates"), + restartToUpdate: () => tauriInvoke("restart_to_update"), readInstallAttributionClipboard: () => tauriInvoke("read_install_attribution_clipboard"), consumePendingDeepLink: () => tauriInvoke("consume_pending_deep_link"), getConfig: () => tauriInvoke("get_config"), diff --git a/desktop-ui/src/lib/util.app-update.test.ts b/desktop-ui/src/lib/util.app-update.test.ts new file mode 100644 index 0000000..74b11fa --- /dev/null +++ b/desktop-ui/src/lib/util.app-update.test.ts @@ -0,0 +1,155 @@ +/// +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { TerminalSessionDto, UpdateStatusDto } from "../types/localify.ts"; + +import { describeUpdate, lastCheckLine, restartCloses } from "./util.app-update.ts"; + +function tab( + id: string, + options: { + service?: string; + environment?: string; + status?: TerminalSessionDto["status"]; + ended?: boolean; + } = {}, +): TerminalSessionDto { + const environment = options.environment ?? "primary"; + return { + id, + title: options.service ?? "zsh", + workingDirectory: "/repo", + target: { + kind: options.service ? "service" : "project", + projectId: "shop", + projectName: "Shop", + environmentSlug: environment, + environmentName: environment === "primary" ? "main" : environment, + serviceId: options.service ?? null, + serviceName: options.service ?? null, + }, + status: options.status ?? "running", + run: options.service + ? { + runId: `run-${id}`, + profile: null, + ended: options.ended + ? { code: 0, registered: true, stopped: true, at: 1, error: null } + : null, + } + : null, + }; +} + +test("a restart lists the running services and open shells by name", () => { + const closes = restartCloses([ + tab("1", { service: "web" }), + tab("2", { service: "api", environment: "feat-cart" }), + tab("3"), + tab("4", { environment: "feat-cart" }), + ]); + + assert.deepEqual(closes, { + services: ["web in Shop", "api in Shop (feat-cart)"], + shells: ["Shell in Shop", "Shell in Shop (feat-cart)"], + }); +}); + +test("tabs that already ended are not listed", () => { + const closes = restartCloses([ + tab("1", { service: "web", ended: true }), + tab("2", { service: "api", status: "exited" }), + tab("3", { status: "exited" }), + ]); + + assert.deepEqual(closes, { services: [], shells: [] }); +}); + +function status(overrides: Partial): UpdateStatusDto { + return { + installed: "1.6.0", + available: null, + state: "upToDate", + reason: null, + automatic: true, + lastCheckAt: 1_000, + canInstall: true, + ...overrides, + }; +} + +test("an available update offers a restart only where it can install", () => { + const ready = describeUpdate( + status({ state: "available", available: "1.7.0" }), + "Localify", + 1_000, + ); + assert.deepEqual(ready, { + title: "Localify 1.7.0 is available", + detail: "Restart to update when you're ready.", + canRestart: true, + }); + + const devBuild = describeUpdate( + status({ + state: "available", + available: "1.7.0", + canInstall: false, + reason: "This build of Localify is a development build: update it by hand.", + }), + "Localify", + 1_000, + ); + assert.equal(devBuild.canRestart, false); + assert.equal( + devBuild.detail, + "This build of Localify is a development build: update it by hand.", + ); +}); + +test("an install that waits says why and still offers the restart", () => { + const summary = describeUpdate( + status({ + state: "available", + available: "1.7.0", + automatic: false, + reason: "Automatic updates are off.", + }), + "Localify", + 1_000, + ); + + assert.equal(summary.detail, "Automatic updates are off."); + assert.equal(summary.canRestart, true); +}); + +test("the other states read as sentences", () => { + assert.deepEqual(describeUpdate(status({}), "Localify", 1_000 + 7_200), { + title: "Localify 1.6.0 is up to date", + detail: "Last checked 2 hours ago.", + canRestart: false, + }); + assert.equal( + describeUpdate(status({ state: "failed", reason: "offline" }), "Localify", 1_000).detail, + "offline", + ); + assert.equal( + describeUpdate(status({ state: "installing", available: "1.7.0" }), "Localify", 1_000).title, + "Installing Localify 1.7.0…", + ); + assert.deepEqual( + describeUpdate(status({ state: "unknown", lastCheckAt: null }), "Localify", 1_000), + { title: "Localify 1.6.0", detail: "Not checked yet.", canRestart: false }, + ); +}); + +test("the last check reads in the largest whole unit", () => { + assert.equal(lastCheckLine(null, 100), "Not checked yet."); + assert.equal(lastCheckLine(100, 130), "Last checked just now."); + assert.equal(lastCheckLine(100, 160), "Last checked 1 minute ago."); + assert.equal(lastCheckLine(0, 59 * 60), "Last checked 59 minutes ago."); + assert.equal(lastCheckLine(0, 3 * 86_400), "Last checked 3 days ago."); + // A clock that went back reads as now. + assert.equal(lastCheckLine(500, 100), "Last checked just now."); +}); diff --git a/desktop-ui/src/lib/util.app-update.ts b/desktop-ui/src/lib/util.app-update.ts new file mode 100644 index 0000000..6d4c275 --- /dev/null +++ b/desktop-ui/src/lib/util.app-update.ts @@ -0,0 +1,94 @@ +import type { TerminalSessionDto, UpdateStatusDto } from "../types/localify.ts"; + +/** + * What `Restart to update` closes, by name: the service runs and shells the app's tabs hold. + * Services started in the background belong to the daemon and keep running. + */ +export interface RestartCloses { + services: string[]; + shells: string[]; +} + +export function restartCloses(sessions: TerminalSessionDto[]): RestartCloses { + const services: string[] = []; + const shells: string[] = []; + + for (const session of sessions) { + if (session.status !== "running") continue; + const place = placeOf(session); + if (!session.run) { + shells.push(`Shell in ${place}`); + } else if (!session.run.ended) { + services.push(`${session.target.serviceName ?? "Service"} in ${place}`); + } + } + + return { services, shells }; +} + +// A worktree's tab names its checkout; the primary one needs no label. +function placeOf(session: TerminalSessionDto) { + const { environmentName, environmentSlug, projectName } = session.target; + return environmentSlug === "primary" ? projectName : `${projectName} (${environmentName})`; +} + +export interface UpdateSummary { + title: string; + /** Why it waits, why the check failed, or when the last check ran. */ + detail: string | null; + /** Offer `Restart to update`. */ + canRestart: boolean; +} + +/** The status in words, for Settings. `now` is in Unix seconds. */ +export function describeUpdate( + status: UpdateStatusDto, + appName: string, + now: number, +): UpdateSummary { + const lastCheck = lastCheckLine(status.lastCheckAt, now); + const next = status.available ? `${appName} ${status.available}` : null; + + switch (status.state) { + case "available": { + const canRestart = status.canInstall && status.available !== null; + return { + title: next ? `${next} is available` : "An update is available", + detail: status.reason ?? (canRestart ? "Restart to update when you're ready." : lastCheck), + canRestart, + }; + } + case "checking": + return { title: "Checking for updates…", detail: lastCheck, canRestart: false }; + case "installing": + return { + title: `Installing ${next ?? "an update"}…`, + detail: null, + canRestart: false, + }; + case "failed": + return { title: "The last check failed", detail: status.reason, canRestart: false }; + case "upToDate": + return { + title: `${appName} ${status.installed} is up to date`, + detail: lastCheck, + canRestart: false, + }; + case "unknown": + return { title: `${appName} ${status.installed}`, detail: lastCheck, canRestart: false }; + } +} + +export function lastCheckLine(lastCheckAt: number | null, now: number): string { + if (lastCheckAt === null) return "Not checked yet."; + const minutes = Math.floor(Math.max(0, now - lastCheckAt) / 60); + if (minutes < 1) return "Last checked just now."; + if (minutes < 60) return `Last checked ${plural(minutes, "minute")} ago.`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `Last checked ${plural(hours, "hour")} ago.`; + return `Last checked ${plural(Math.floor(hours / 24), "day")} ago.`; +} + +function plural(count: number, unit: string) { + return `${count} ${unit}${count === 1 ? "" : "s"}`; +} diff --git a/desktop-ui/src/lib/validators/validator.autostart.ts b/desktop-ui/src/lib/validators/validator.autostart.ts index c55a4dd..5f0c4d4 100644 --- a/desktop-ui/src/lib/validators/validator.autostart.ts +++ b/desktop-ui/src/lib/validators/validator.autostart.ts @@ -5,3 +5,7 @@ export const updateAutostartRequestSchema = z.object({ }); export type UpdateAutostartInput = z.infer; + +export const updateSettingsRequestSchema = z.object({ + automatic: z.boolean(), +}); diff --git a/desktop-ui/src/types/localify.ts b/desktop-ui/src/types/localify.ts index 0e52320..943988a 100644 --- a/desktop-ui/src/types/localify.ts +++ b/desktop-ui/src/types/localify.ts @@ -539,15 +539,30 @@ export interface SettingsImportResultDto { daemonReloadError: string | null; } -export interface AppUpdateInfoDto { - version: string; - notes: string | null; - pubDate: string | null; +/** `state` of the daemon's `update_status` (docs/desktop.md, "Automatic updates"). */ +export type UpdatePhase = + | "unknown" + | "upToDate" + | "available" + | "checking" + | "installing" + | "failed"; + +export interface UpdateStatusDto { + installed: string; + available: string | null; + state: UpdatePhase; + /** Why an available update waits, or why the last check failed. */ + reason: string | null; + automatic: boolean; + /** Unix seconds. */ + lastCheckAt: number | null; + /** False when this copy can only be updated by hand: `reason` says why. */ + canInstall: boolean; } -export interface AppUpdateCheckDto { - configured: boolean; - update: AppUpdateInfoDto | null; +export interface UpdateSettingsRequest { + automatic: boolean; } export type DeepLinkRouteDto = "projects" | "logs" | "launchService"; diff --git a/docs/adr/0034-machines-update-themselves.md b/docs/adr/0034-machines-update-themselves.md index cb24a01..1c2ecbd 100644 --- a/docs/adr/0034-machines-update-themselves.md +++ b/docs/adr/0034-machines-update-themselves.md @@ -4,7 +4,7 @@ A Mac with Localify can install new versions by itself, and a machine that sees **Who installs.** - **With the app closed, the daemon installs.** That covers a runner nobody sits at. The daemon already runs as the installing user, not as root, and installs only where that user can replace the app. This feature adds no privileged process. -- **With the app open, the update waits for the person.** The app downloads in the background and offers `Restart to update`. On that click, it hands off to Localify's own installer and quits, and the installer opens the new version. It never relaunches by itself: a relaunch closes service tabs and shell tabs, and an idle check cannot prove that no one is using them. Tauri's own replacement is not used, so there is one installer with one set of rules. +- **With the app open, the update waits for the person.** The app shows the update the daemon found and offers `Restart to update`. On that click, it hands off to Localify's own installer and quits, and the installer opens the new version. It never relaunches by itself: a relaunch closes service tabs and shell tabs, and an idle check cannot prove that no one is using them. Tauri's own replacement is not used, so there is one installer with one set of rules. - **One installer at a time.** The app holds a lock for its lifetime, and the installer also looks for any process running from the bundle, so it never replaces a bundle in use. That process check covers older apps that take no lock. A person dragging a DMG in at the very same moment is not supported. **Paired machines only trigger a check.** A paired machine's version comes from the relay's presence, which is not authenticated, so it is a hint and nothing more. When it is newer than this machine's, this machine checks the official manifest early, within a machine-wide cooldown. A hint never chooses a build or proves that one exists. Only a release signed with Localify's updater key, whose bundle is signed by Localify's Developer ID, is ever installed. diff --git a/docs/desktop.md b/docs/desktop.md index 59a6e6c..d792324 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -122,18 +122,18 @@ daemon started another way, such as a dev build, is left alone. A Mac can install new versions by itself (ADR 0034). -- **Setting:** `[updates] automatic` in the config. It starts on for new installs and off for installs that predate it. Settings → General has `Install updates automatically`, and the CLI has `localify update --auto on|off`. The management socket's `update_settings {automatic}` changes it. An unattended install requires `automatic` to be on just before it replaces anything; an install a person confirms works whether it is on or off. +- **Setting:** `[updates] automatic` in the config. It starts on for new installs and off for installs that predate it. A config that doesn't exist yet counts as a new install's, so a setting saved during the app's welcome, before `localify install`, keeps it on. Settings → General has `Install updates automatically`, and the CLI has `localify update --auto on|off`. The management socket's `update_settings {automatic}` changes it. An unattended install requires `automatic` to be on just before it replaces anything; an install a person confirms works whether it is on or off. - **Checks:** 2 minutes after the daemon starts, then every 6 hours plus or minus 30 minutes. Also when `remote_machines` shows a paired machine on a newer version: such a version comes from the relay's presence and is only a hint. Every check, scheduled, triggered or manual, goes through one coalesced operation with a machine-wide cooldown of 10 minutes, doubled after each failure up to 6 hours. The last check's time is persisted, so a restart doesn't reset it. A check reads the manifest the app's updater reads (`latest.json`, from the build's updater endpoint) over HTTPS. That manifest is discovery, not authority. - **Trust:** the archive's minisign signature is checked against the updater public key built into Localify before anything is extracted. Only a version strictly newer than both the installed bundle's `CFBundleShortVersionString` and the highest version this machine ever installed is accepted. A build that failed here before, recorded by its archive SHA-256, is skipped until the next release. After extraction, the bundle's version must equal the manifest's. `codesign --verify --deep --strict` must pass with a requirement anchored to Apple and pinned to Localify's team ID and bundle identifier, covering the daemon's executable too. Freshness is not guaranteed: a compromised feed could withhold an update or offer an older valid one to an older client, and this version accepts that. - **Extraction:** into a new private directory (0700) on the destination's volume, made by the installer. The archive may hold exactly one `Localify.app`, with no absolute or `..` paths, no hard links, no device or FIFO entries, no setuid or setgid bits, and only relative symlinks that stay inside the bundle. Symlinks are never followed during extraction. The expanded size is capped at 1 GiB and 20,000 entries. Quarantine is not added and never cleared; the archive is notarized and stapled. - **Where:** only the production bundle whose executable is the daemon's, at `/Applications/Localify.app` or `~/Applications/Localify.app`, when the user owns it and can replace it. A dev build, a translocated or read-only bundle, a mounted DMG, or a location that would need authorization stays manual, and Settings says why. -- **One installer:** the `localify` binary's own installer, in the core, enforces every rule here. The daemon runs it unattended, and `localify update install` runs it when a person asks. Tauri's replacement (`Update::install`) is never used. The app's updater only checks and downloads; installing goes through this installer. -- **Is the app running:** the app takes an exclusive `flock` on `~/Library/Application Support/Localify/app.lock` as its first step and holds it for its lifetime. While the installer holds the update lock, an app that starts waits for it, then starts on whatever is installed. The installer runs only when it can take `app.lock`, and also finds no process whose executable lies inside the bundle (`proc_listallpids` and `proc_pidpath`). That second check also covers apps from 1.6 and earlier, which take no lock, and other accounts using the same bundle. If it can't read a process's path, it doesn't install. Two cases are not "in use": the bundle's own `localify`, which the daemon and the CLI run from and which outlive a replacement, and a process whose executable was deleted, which can't be running the bundle's files on disk. +- **One installer:** the `localify` binary's own installer, in the core, enforces every rule here. The daemon runs it unattended, and `localify update install` runs it when a person asks. The app has no updater of its own: it reads `update_status`, runs the same coalesced check for `Check now` and the `Check for Updates…` menu, and installs through this installer. Tauri's updater plugin is not used; the bundler still signs the updater archive with the key in `tauri.conf.json`. +- **Is the app running:** the app takes an exclusive `flock` on `~/Library/Application Support/Localify/app.lock` as its first step and holds it for its lifetime. An app that finds `app.lock` held while `update.lock` is held too waits for the install, saying "Updating Localify…" after a second, then starts on whatever is installed: when the install replaced its bundle, it runs the new executable instead. When `app.lock` is held without `update.lock`, another copy of the app holds it, and this one hands its launch over without waiting. The installer runs only when it can take `app.lock`, and also finds no process whose executable lies inside the bundle (`proc_listallpids` and `proc_pidpath`). That second check also covers apps from 1.6 and earlier, which take no lock, and other accounts using the same bundle. If it can't read a process's path, it doesn't install. Two cases are not "in use": the bundle's own `localify`, which the daemon and the CLI run from and which outlive a replacement, and a process whose executable was deleted, which can't be running the bundle's files on disk. - **When the daemon installs:** only when both checks pass. Otherwise it reports the update as available and tries again at the next check. - **Swap:** under an exclusive `flock` on `~/Library/Application Support/Localify/update.lock`, a file that is never deleted, in the bundle owner's account, at a path that doesn't depend on the configurable data dir. Only the owner's account installs. The installer checks again that the destination is the same inode and version it verified, and fails without touching it when the volume lacks `RENAME_SWAP`. It then exchanges the staged bundle and the installed one with `renamex_np`. The old bundle moves to `~/Library/Application Support/Localify/previous/.app`, which keeps one previous version. The daemon restarts on the new binary, as it already does after any replacement. A destination that changed before that check makes the installer stop and leave it as it is. A person replacing the bundle at the very same moment, from a DMG for example, is not supported. -- **With the app open:** the app checks and downloads in the background when `automatic` is on, and shows `Restart to update`. Restarting lists what it will close (service tabs and shell tabs). On the person's click, the app starts `localify update install --relaunch` detached in its own session, then quits. That process waits for `app.lock`, installs, and opens the new app in the same login session. The app never relaunches by itself. -- **Status:** `update_status` on the management socket answers `{installed, available, state, reason, automatic}`, and so does `localify update --status` (JSON when stdout is not a terminal; read locally when the daemon isn't running). `state` is `unknown` (never checked), `upToDate`, `available`, `checking`, `installing` or `failed` (the last check failed). `reason` says why an available update waits, or why the check failed. That is what the app and the CLI read, so nothing depends on a notification arriving. -- **Files:** `~/Library/Application Support/Localify/` holds `app.lock`, `update.lock`, `check.lock` (held during a check, so checks coalesce), `updates.json` (the highest version installed, the digests that failed, the last check, its failures and what it read), `staging/`, `previous/` and `updater.log`. `localify::infrastructure::updates::app_lock_path()` gives the app its lock's path. +- **With the app open:** the app shows an available update in Settings → `Install updates automatically` and as a banner, with `Restart to update` when this copy may be replaced. Restarting lists what it will close by name: the running service tabs and the open shell tabs. On the person's click, the app starts `localify update install --relaunch` from its bundle's own CLI, detached in its own session (`setsid`) with stdin on `/dev/null`, then quits, which releases `app.lock`. If that process can't start, the app says why and stays open. The installer downloads after the app quits. That process waits for `app.lock`, installs, and opens the new app in the same login session. The app never relaunches by itself. +- **Status:** `update_status` on the management socket answers `{installed, available, state, reason, automatic, lastCheckAt, canInstall}`, and so does `localify update --status` (JSON when stdout is not a terminal; read locally when the daemon isn't running). `state` is `unknown` (never checked), `upToDate`, `available`, `checking`, `installing` or `failed` (the last check failed). `reason` says why an available update waits, or why the check failed. `lastCheckAt` is the last check's time in Unix seconds. `canInstall` is false when this copy can only be updated by hand (a dev build, a bundle in another place or owned by another account, Linux); `reason` then says why, and the app offers no `Restart to update`. With the daemon down, the app asks the bundle's CLI for the same status. That is what the app and the CLI read, so nothing depends on a notification arriving. +- **Files:** `~/Library/Application Support/Localify/` holds `app.lock`, `update.lock`, `check.lock` (held during a check, so checks coalesce), `updates.json` (the highest version installed, the digests that failed, the last check, its failures and what it read), `staging/`, `previous/` and `updater.log`, the one updater log. `localify::infrastructure::updates::app_lock::take_app_lock` is how the app takes its lock. - **Recovery:** no automatic rollback. If a new version misbehaves, `~/Library/Application Support/Localify/previous/` holds the last one: quit Localify, put that bundle back in place of the installed one, and turn `automatic` off until a fixed release. - **Linux:** no unattended install, because the CLI there is checked by SHA-256 only. A Linux daemon that sees a newer version says "Update available: run the install command again" in `localify status` and in the app. It reads only the manifest's `version`; release builds get the manifest's address from `LOCALIFY_UPDATER_BASE_URL`, as the app does. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 77b6d51..1b109c9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -107,15 +107,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arc-swap" version = "1.9.1" @@ -1112,17 +1103,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "derive_more" version = "0.99.20" @@ -2692,6 +2672,7 @@ dependencies = [ "clap", "clap_complete", "dialoguer", + "flate2", "futures-util", "http-body-util", "humantime", @@ -2699,14 +2680,17 @@ dependencies = [ "hyper-rustls", "hyper-util", "libc", + "minisign-verify", "rcgen", "rustls", + "semver", "serde", "serde_json", "sha2", "simple-dns", "snow", "socket2", + "tar", "thiserror 2.0.18", "time", "tokio", @@ -2739,7 +2723,6 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-single-instance", - "tauri-plugin-updater", ] [[package]] @@ -3146,18 +3129,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-osa-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" -dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-app-kit", - "objc2-foundation", -] - [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3235,12 +3206,6 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "option-ext" version = "0.2.0" @@ -3267,20 +3232,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "osakit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" -dependencies = [ - "objc2", - "objc2-foundation", - "objc2-osa-kit", - "serde", - "serde_json", - "thiserror 2.0.18", -] - [[package]] name = "pango" version = "0.18.3" @@ -4014,20 +3965,15 @@ dependencies = [ "http-body", "http-body-util", "hyper", - "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", - "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -4133,25 +4079,12 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -4161,33 +4094,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - [[package]] name = "rustls-webpki" version = "0.103.11" @@ -4221,15 +4127,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "schemars" version = "0.8.22" @@ -4287,29 +4184,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "selectors" version = "0.24.0" @@ -4903,7 +4777,6 @@ checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", - "xattr", ] [[package]] @@ -5193,39 +5066,6 @@ dependencies = [ "zbus", ] -[[package]] -name = "tauri-plugin-updater" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" -dependencies = [ - "base64 0.22.1", - "dirs 6.0.0", - "flate2", - "futures-util", - "http", - "infer", - "log", - "minisign-verify", - "osakit", - "percent-encoding", - "reqwest", - "rustls", - "semver", - "serde", - "serde_json", - "tar", - "tauri", - "tauri-plugin", - "tempfile", - "thiserror 2.0.18", - "time", - "tokio", - "url", - "windows-sys 0.60.2", - "zip", -] - [[package]] name = "tauri-runtime" version = "2.10.1" @@ -6220,15 +6060,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "1.0.7" @@ -7010,16 +6841,6 @@ dependencies = [ "time", ] -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "xkeysym" version = "0.2.1" @@ -7199,18 +7020,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zip" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" -dependencies = [ - "arbitrary", - "crc32fast", - "indexmap 2.14.0", - "memchr", -] - [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fdeb8e9..83f6ef7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,7 +23,6 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-global-shortcut = "2.3.1" -tauri-plugin-updater = "2.10.1" [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies] tauri-plugin-autostart = "2.5.1" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5b43d15..81e06ec 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -29,16 +29,11 @@ use localify::desktop::dto::{ StartCloudAddonCheckoutRequest, StartCloudCheckoutRequest, SuggestPublicUrlSlugRequest, }; use serde::{Deserialize, Serialize}; -use std::fs::OpenOptions; -use std::io::Write; -use std::path::PathBuf; #[cfg(target_os = "macos")] use std::process::Command; use std::sync::{Mutex, MutexGuard}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tauri::async_runtime; use tauri::{AppHandle, Runtime}; -use tauri_plugin_updater::UpdaterExt; use localify::desktop::dto::{ServiceRunRequest, ServiceStopDto}; use localify::desktop::shell_environment::ShellEnvironmentDto; @@ -47,6 +42,8 @@ use localify::desktop::terminal::{ TerminalManager, TerminalResizeRequest, TerminalRestartServiceRequest, TerminalSessionDto, TerminalSessionRequest, TerminalWriteRequest, }; +use localify::desktop::updates::hand_off_update; +use localify::infrastructure::updates::UpdateStatus; pub struct AppState { pub api: DesktopApi, @@ -106,21 +103,6 @@ pub struct DeepLinkLaunchDto { pub profile: Option, } -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AppUpdateInfoDto { - pub version: String, - pub notes: Option, - pub pub_date: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AppUpdateCheckDto { - pub configured: bool, - pub update: Option, -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AutostartStatusDto { @@ -133,21 +115,18 @@ pub struct UpdateAutostartRequest { pub enabled: bool, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSettingsRequest { + pub automatic: bool, +} + const INSTALL_ATTRIBUTION_PREFIX: &str = "localify:install-attribution:v1:"; -const UPDATE_DOWNLOAD_ATTEMPTS: usize = 3; -const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(20); -const UPDATE_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300); fn map_error(error: E) -> String { error.to_string() } -fn map_updater_error(stage: &str, error: E) -> String { - let message = format!("{stage}: {error}"); - append_updater_log(&format!("error {message}")); - message -} - /// Trust that is still pending does not count against the install: the machine /// has no screen to confirm on, and everything else works. fn install_completed(result: &InstallResultDto) -> bool { @@ -196,37 +175,6 @@ fn autostart_uninstall_step(app: &AppHandle) -> StepDto { } } -fn desktop_state_dir() -> Result { - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .filter(|path| !path.as_os_str().is_empty()) - .ok_or_else(|| "Failed to resolve the user home directory.".to_string())?; - - Ok(home - .join("Library/Application Support") - .join(localify::branding::BRANDING.desktop_state_dir_name())) -} - -fn append_updater_log(message: &str) { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs().to_string()) - .unwrap_or_else(|_| "unknown".into()); - let Ok(directory) = desktop_state_dir() else { - return; - }; - - if std::fs::create_dir_all(&directory).is_err() { - return; - } - - let path = directory.join("updater.log"); - let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) else { - return; - }; - let _ = writeln!(file, "{timestamp} {message}"); -} - #[cfg(target_os = "macos")] fn read_clipboard_text() -> Result, String> { let output = Command::new("pbpaste") @@ -250,148 +198,6 @@ fn read_clipboard_text() -> Result, String> { Ok(None) } -fn build_updater( - app: &AppHandle, -) -> Result, String> { - let Some(endpoint) = super::updater_endpoint() else { - append_updater_log("build skipped updater_endpoint=none"); - return Ok(None); - }; - - append_updater_log(&format!("build endpoint={endpoint}")); - let builder = app - .updater_builder() - .pubkey(super::updater_public_key()) - .timeout(UPDATE_CHECK_TIMEOUT) - .endpoints(vec![tauri::Url::parse(endpoint).map_err(map_error)?]) - .map_err(|error| map_updater_error("configure updater endpoint", error))?; - let updater = super::configure_updater_builder(builder) - .build() - .map_err(|error| map_updater_error("build updater", error))?; - - Ok(Some(updater)) -} - -pub(crate) async fn check_app_update_with_handle( - app: AppHandle, -) -> Result { - let current_version = app.package_info().version.to_string(); - append_updater_log(&format!("check start current_version={current_version}")); - let Some(updater) = build_updater(&app)? else { - return Ok(AppUpdateCheckDto { - configured: false, - update: None, - }); - }; - - let update = updater - .check() - .await - .map_err(|error| map_updater_error("check update manifest", error))? - .map(|update| AppUpdateInfoDto { - version: update.version.to_string(), - notes: update.body, - pub_date: update.date.map(|value| value.to_string()), - }); - match &update { - Some(update) => append_updater_log(&format!("check available version={}", update.version)), - None => append_updater_log("check no_update"), - } - - Ok(AppUpdateCheckDto { - configured: true, - update, - }) -} - -pub(crate) async fn install_app_update_with_handle( - app: AppHandle, -) -> Result<(), String> { - append_updater_log("install start"); - let Some(updater) = build_updater(&app)? else { - return Err("Desktop updater is not configured for this build.".into()); - }; - - let Some(mut update) = updater - .check() - .await - .map_err(|error| map_updater_error("install check update manifest", error))? - else { - append_updater_log("install no_update"); - return Ok(()); - }; - - append_updater_log(&format!( - "install candidate version={} url={}", - update.version, update.download_url - )); - update.timeout = Some(UPDATE_DOWNLOAD_TIMEOUT); - - let mut downloaded_update = None; - let mut last_download_error = None; - for attempt in 1..=UPDATE_DOWNLOAD_ATTEMPTS { - append_updater_log(&format!( - "download start attempt={attempt}/{UPDATE_DOWNLOAD_ATTEMPTS}" - )); - let mut downloaded_bytes = 0usize; - let mut expected_bytes = None; - let mut next_progress_log = 5 * 1024 * 1024; - let download_result = update - .download( - |chunk_size, content_length| { - downloaded_bytes += chunk_size; - expected_bytes = content_length; - if downloaded_bytes >= next_progress_log { - append_updater_log(&format!( - "download progress attempt={attempt} bytes={downloaded_bytes} expected={expected_bytes:?}" - )); - next_progress_log += 5 * 1024 * 1024; - } - }, - || { - append_updater_log(&format!("download finished attempt={attempt}")); - }, - ) - .await; - - match download_result { - Ok(bytes) => { - append_updater_log(&format!( - "download verified attempt={attempt} bytes={}", - bytes.len() - )); - downloaded_update = Some(bytes); - last_download_error = None; - break; - } - Err(error) => { - let message = format!( - "{error}; attempt={attempt}; downloaded_bytes={downloaded_bytes}; expected_bytes={expected_bytes:?}" - ); - append_updater_log(&format!("download error {message}")); - last_download_error = Some(message); - } - } - } - - let Some(downloaded_update) = downloaded_update else { - let message = last_download_error.unwrap_or_else(|| "unknown download error".into()); - return Err(map_updater_error("download update", message)); - }; - - update - .install(&downloaded_update) - .map_err(|error| map_updater_error("install downloaded update", error))?; - - append_updater_log("install finished restarting"); - app.restart() -} - -#[tauri::command] -pub async fn check_app_update(app: tauri::AppHandle) -> Result { - check_app_update_with_handle(app).await -} - #[tauri::command] pub fn get_autostart_status(app: tauri::AppHandle) -> Result { Ok(AutostartStatusDto { @@ -414,8 +220,50 @@ pub fn set_autostart_enabled( } #[tauri::command] -pub async fn install_app_update(app: tauri::AppHandle) -> Result<(), String> { - install_app_update_with_handle(app).await +pub async fn get_update_status(state: tauri::State<'_, AppState>) -> Result { + let api = state.api.clone(); + async_runtime::spawn_blocking(move || api.update_status().map_err(map_error)) + .await + .map_err(map_error)? +} + +#[tauri::command] +pub async fn set_automatic_updates( + state: tauri::State<'_, AppState>, + request: UpdateSettingsRequest, +) -> Result { + let api = state.api.clone(); + async_runtime::spawn_blocking(move || { + api.set_automatic_updates(request.automatic) + .map_err(map_error) + }) + .await + .map_err(map_error)? +} + +#[tauri::command] +pub async fn check_for_updates(state: tauri::State<'_, AppState>) -> Result { + let api = state.api.clone(); + async_runtime::spawn_blocking(move || api.check_for_updates().map_err(map_error)) + .await + .map_err(map_error)? +} + +/// `Restart to update`. When the install can't be handed off, the app +/// says why and stays open. +#[tauri::command] +pub fn restart_to_update(app: tauri::AppHandle) -> Result<(), String> { + // The CLI next to a dev build would open the installed app instead. + if cfg!(debug_assertions) { + return Err("A development build doesn't install updates: update it by hand.".into()); + } + let cli = super::sibling_binary_path().ok_or_else(|| { + "This copy of Localify has no CLI in its bundle, so it can't install updates. Update it by hand." + .to_string() + })?; + hand_off_update(&cli).map_err(|error| format!("{error:#}"))?; + super::quit_app(&app); + Ok(()) } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a9c9d3f..f5ef9f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,9 +3,11 @@ mod remote; mod terminal; use std::collections::HashMap; +use std::fs::File; #[cfg(target_os = "macos")] use std::io::Write; use std::path::PathBuf; +use std::process::Child; #[cfg(target_os = "macos")] use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -22,6 +24,8 @@ use localify::desktop::run_pty::AppInstallation; use localify::desktop::terminal::TerminalManager; use localify::infrastructure::running_services::RunningServiceStore; use localify::infrastructure::shell_env; +use localify::infrastructure::updates::UpdatePaths; +use localify::infrastructure::updates::app_lock::{AppLock, take_app_lock}; use tauri::image::Image; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::tray::TrayIconBuilder; @@ -48,8 +52,6 @@ const TRAY_PROJECT_LIMIT: usize = 10; #[cfg(target_os = "macos")] const MENU_CHECK_UPDATES: &str = "menu-check-updates"; const TRAY_TEMPLATE_ICON: &[u8] = include_bytes!("../icons/tray-template.png"); -const UPDATER_PUBLIC_KEY: &str = include_str!("../updater.pub"); -const UPDATER_ENDPOINT: Option<&str> = option_env!("LOCALIFY_UPDATER_ENDPOINT"); const DEEP_LINK_EVENT: &str = "localify://deep-link"; const LOCALIFY_SCHEME: &str = "localify"; @@ -153,27 +155,6 @@ fn sibling_binary_path() -> Option { candidate.exists().then_some(candidate) } -pub(crate) fn updater_endpoint() -> Option<&'static str> { - if cfg!(debug_assertions) { - return None; - } - - UPDATER_ENDPOINT - .map(str::trim) - .filter(|value| !value.is_empty()) -} - -pub(crate) fn updater_public_key() -> &'static str { - UPDATER_PUBLIC_KEY.trim() -} - -pub(crate) fn configure_updater_builder( - builder: tauri_plugin_updater::UpdaterBuilder, -) -> tauri_plugin_updater::UpdaterBuilder { - // Updater archives must be read byte-for-byte; let the signed .tar.gz stay opaque. - builder.configure_client(|client| client.no_gzip().no_brotli().no_zstd().no_deflate()) -} - fn runtime_desktop_name() -> String { BRANDING.runtime_desktop_name() } @@ -735,15 +716,21 @@ fn run_tray_action(app: &AppHandle, id: &str) -> Result<(), Strin sync_tray_menu(app).map_err(|error| error.to_string()) } TRAY_QUIT => { - let tray_state = app.state::(); - tray_state.quitting.store(true, Ordering::Relaxed); - app.exit(0); + quit_app(app); Ok(()) } _ => Ok(()), } } +/// Quits for good: closing the window only hides it. +fn quit_app(app: &AppHandle) { + app.state::() + .quitting + .store(true, Ordering::Relaxed); + app.exit(0); +} + fn handle_tray_action_error(app: &AppHandle, id: &str, error: String) { eprintln!("tray action '{id}' failed: {error}"); @@ -952,27 +939,6 @@ fn show_message_dialog( .blocking_show(); } -#[cfg(target_os = "macos")] -fn confirm_update_install( - app: &AppHandle, - version: &str, - current_version: &str, -) -> bool { - let message = format!( - "Version {version} is available.\n\nCurrent version: {current_version}\n\nInstall the update now? Localify will restart automatically when it finishes." - ); - - app.dialog() - .message(message) - .title("Update Available") - .kind(MessageDialogKind::Info) - .buttons(MessageDialogButtons::OkCancelCustom( - "Install".into(), - "Not now".into(), - )) - .blocking_show() -} - #[cfg(target_os = "macos")] fn finish_update_check(app: &AppHandle) { app.state::() @@ -990,39 +956,42 @@ async fn run_manual_update_check(app: AppHandle) { } } +/// The menu's check, the same one as `Check now` in Settings. Installing +/// goes through `Restart to update` in the window, which lists what it +/// closes. #[cfg(target_os = "macos")] async fn run_manual_update_check_inner(app: AppHandle) -> Result<(), String> { - let current_version = app.package_info().version.to_string(); - let update_check = commands::check_app_update_with_handle(app.clone()).await?; - - if !update_check.configured { - show_message_dialog( - &app, - "Updates Unavailable", - "Desktop updates are not configured for this build.".into(), - MessageDialogKind::Info, - ); - finish_update_check(&app); - return Ok(()); - } - - let Some(update) = update_check.update else { - show_message_dialog( - &app, + let api = app.state::().api.clone(); + let status = async_runtime::spawn_blocking(move || api.check_for_updates()) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string())?; + let (title, message) = match (&status.available, &status.reason) { + (None, _) => ( "You're Up to Date", - format!("Localify {current_version} is already the latest version."), - MessageDialogKind::Info, - ); - finish_update_check(&app); - return Ok(()); + format!( + "{} {} is the latest version.", + BRANDING.app_name, status.installed + ), + ), + (Some(version), Some(reason)) if !status.can_install => ( + "Update Available", + format!("{} {version} is available. {reason}", BRANDING.app_name), + ), + (Some(version), _) => ( + "Update Available", + format!( + "{} {version} is available. Use Restart to update in the window to install it.", + BRANDING.app_name + ), + ), }; - - if !confirm_update_install(&app, &update.version, ¤t_version) { - finish_update_check(&app); - return Ok(()); + if status.available.is_some() { + let _ = open_main_window(&app); } - - commands::install_app_update_with_handle(app).await + show_message_dialog(&app, title, message, MessageDialogKind::Info); + finish_update_check(&app); + Ok(()) } #[cfg(target_os = "macos")] @@ -1094,8 +1063,84 @@ fn setup_app_menu(app: &AppHandle) -> tauri::Result<()> { sync_app_menu(app) } +/// The app's first step (docs/desktop.md, "Automatic updates"): it holds +/// `app.lock` for its lifetime, so the installer never replaces the bundle +/// under it. While an install holds the lock, the app waits for it. Without +/// the lock the app still starts: the installer also looks for processes +/// running from the bundle. +fn hold_app_lock() -> Option { + let paths = UpdatePaths::for_user()?; + let mut message = None; + let lock = take_app_lock(&paths, || message = show_updating_message()); + if let Some(mut message) = message { + let _ = message.kill(); + let _ = message.wait(); + } + match lock { + Ok(AppLock::Held { file, waited }) => { + if waited { + restart_if_replaced(); + } + Some(file) + } + // That copy gets this launch through the single-instance plugin. + Ok(AppLock::HeldByAnotherApp) => None, + Err(error) => { + eprintln!("the app lock is off: {error:#}"); + None + } + } +} + +#[cfg(target_os = "macos")] +fn show_updating_message() -> Option { + let script = format!( + "display dialog \"Updating {0}…\" with title \"{0}\" buttons {{\"OK\"}} default button 1 giving up after 600", + BRANDING.app_name + ); + Command::new("/usr/bin/osascript") + .args(["-e", &script]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok() +} + +#[cfg(not(target_os = "macos"))] +fn show_updating_message() -> Option { + None +} + +/// An install that held the lock may have replaced the bundle this process +/// started from: the version installed now starts instead. +#[cfg(target_os = "macos")] +fn restart_if_replaced() { + use std::os::unix::process::CommandExt; + + let Ok(exe) = std::env::current_exe() else { + return; + }; + let Some(bundle) = exe.ancestors().nth(3) else { + return; + }; + let Ok(installed) = localify::infrastructure::updates::macos::bundle_version(bundle) else { + return; + }; + if installed.to_string() == env!("CARGO_PKG_VERSION") { + return; + } + let error = Command::new(&exe).args(std::env::args_os().skip(1)).exec(); + eprintln!("could not start Localify {installed}: {error}"); +} + +#[cfg(not(target_os = "macos"))] +fn restart_if_replaced() {} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Held until the process exits. + let _app_lock = hold_app_lock(); let api = DesktopApi::new(default_config_path(), default_binary_path()); let mut builder = tauri::Builder::default(); @@ -1151,11 +1196,6 @@ pub fn run() { terminal.warm_shell_environment(); start_app_bridge(app.handle(), bridge_api.clone(), terminal.clone()); app.manage(terminal); - app.handle().plugin( - tauri_plugin_updater::Builder::new() - .pubkey(updater_public_key()) - .build(), - )?; let app_handle = app.handle().clone(); app_handle.clone().deep_link().on_open_url(move |event| { let urls = event.urls(); @@ -1193,8 +1233,10 @@ pub fn run() { commands::get_bootstrap, commands::get_autostart_status, commands::set_autostart_enabled, - commands::check_app_update, - commands::install_app_update, + commands::get_update_status, + commands::set_automatic_updates, + commands::check_for_updates, + commands::restart_to_update, commands::read_install_attribution_clipboard, commands::get_config, commands::update_config, diff --git a/src/desktop/api.rs b/src/desktop/api.rs index ff7f2fe..a53bede 100644 --- a/src/desktop/api.rs +++ b/src/desktop/api.rs @@ -2999,6 +2999,10 @@ impl DesktopApi { &self.binary_path } + pub(super) fn config_path(&self) -> &Path { + &self.config_path + } + /// The last run of every service, with what the app shows about it: a /// service still running from a shell tab of a previous version is a /// legacy run, kept as it is. diff --git a/src/desktop/mod.rs b/src/desktop/mod.rs index b85e874..1824c48 100644 --- a/src/desktop/mod.rs +++ b/src/desktop/mod.rs @@ -14,6 +14,7 @@ mod remote; pub mod run_pty; pub mod shell_environment; pub mod terminal; +pub mod updates; pub use crate::infrastructure::editor::EditorPreference; pub use crate::infrastructure::local_dev::TerminalPreference; diff --git a/src/desktop/updates.rs b/src/desktop/updates.rs new file mode 100644 index 0000000..326b508 --- /dev/null +++ b/src/desktop/updates.rs @@ -0,0 +1,215 @@ +//! Updates in the app (docs/desktop.md, "Automatic updates"). The app never +//! installs: it shows the status the daemon answers, changes +//! `[updates] automatic`, runs the one coalesced check when asked, and on +//! `Restart to update` hands off to `localify update install --relaunch`. + +use std::io; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, anyhow, bail}; + +use super::DesktopApi; +use crate::infrastructure::mgmt_client::MgmtSocketClient; +use crate::infrastructure::updates::{self, UpdatePaths, UpdateStatus, release, state}; + +impl DesktopApi { + /// The daemon's status, which knows whether it is checking or + /// installing now. With the daemon down, the CLI reads the same status + /// from the files: only the bundle's `localify` can tell whether its + /// bundle may be replaced. + pub fn update_status(&self) -> Result { + if let Ok(status) = self.update_client()?.update_status() { + return Ok(status); + } + self.update_cli(&["update", "--status", "--json"]) + } + + /// `Install updates automatically`. The daemon reads the setting again + /// before any unattended install, so it needs no restart. + pub fn set_automatic_updates(&self, automatic: bool) -> Result { + if let Ok(status) = self.update_client()?.update_settings(automatic) { + return Ok(status); + } + let on = if automatic { "on" } else { "off" }; + self.update_cli(&["update", "--auto", on, "--json"]) + } + + /// `Check now`: the check every caller goes through, so it shares the + /// machine-wide cooldown and leaves its answer where the daemon reads + /// it. + pub fn check_for_updates(&self) -> Result { + let endpoint = release::updater_endpoint() + .ok_or_else(|| anyhow!("This build has no update feed: update it by hand."))?; + let paths = UpdatePaths::for_user() + .ok_or_else(|| anyhow!("HOME is not set, so updates can't be checked."))?; + state::check(&paths, updates::unix_now(), || { + release::fetch_manifest(&endpoint) + })?; + self.update_status() + } + + fn update_client(&self) -> Result { + let (_, paths) = self.load_config_and_paths()?; + Ok(MgmtSocketClient::new(&paths.socket_path)) + } + + fn update_cli(&self, args: &[&str]) -> Result { + let output = Command::new(self.binary_path()) + .arg("--config") + .arg(self.config_path()) + .args(args) + .stdin(Stdio::null()) + .output() + .with_context(|| format!("Could not run {}", self.binary_path().display()))?; + if !output.status.success() { + bail!("{}", String::from_utf8_lossy(&output.stderr).trim()); + } + serde_json::from_slice(&output.stdout) + .context("The update status from the CLI could not be read") + } +} + +/// `Restart to update`: starts `localify update install --relaunch` from the +/// bundle's own CLI, in its own session with no terminal, so it outlives the +/// app. It waits for `app.lock`, installs, then opens the app again. The +/// caller quits right after, which releases the lock. +pub fn hand_off_update(cli: &Path) -> Result<()> { + let mut command = Command::new(cli); + command + .args(["update", "install", "--relaunch"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + // SAFETY: `setsid` is async-signal-safe and reads no memory of the + // parent, which is all `pre_exec` asks of what runs between fork and + // exec. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command + .spawn() + .with_context(|| format!("Could not start {}", cli.display()))?; + // Reaped if the app is still there when it ends. + std::thread::spawn(move || child.wait()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + use std::time::{Duration, Instant}; + + use super::*; + use crate::infrastructure::config::{Config, ConfigStore}; + use crate::infrastructure::paths::LocalifyPaths; + + fn script(dir: &Path, body: &str) -> PathBuf { + let path = dir.join("localify"); + fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + /// An API whose daemon isn't running and whose CLI is `body`. + fn api_without_daemon(dir: &Path, body: &str) -> DesktopApi { + let config_path = dir.join("config.toml"); + let mut config = Config::default(); + config.paths = LocalifyPaths { + data_dir: dir.join("data"), + pid_file: dir.join("localify.pid"), + log_file: dir.join("localify.log"), + certs_dir: dir.join("certs"), + socket_path: dir.join("localify.sock"), + }; + ConfigStore::new(config_path.clone()).save(&config).unwrap(); + DesktopApi::new(config_path, script(dir, body)) + } + + const STATUS: &str = r#"{"installed":"1.6.0","available":"1.7.0","state":"available","reason":null,"automatic":true,"lastCheckAt":5,"canInstall":true}"#; + + #[test] + fn without_the_daemon_the_status_comes_from_the_cli() { + let dir = tempfile::tempdir().unwrap(); + let args = dir.path().join("args"); + let body = format!("echo \"$@\" > '{}'\necho '{STATUS}'", args.display()); + let api = api_without_daemon(dir.path(), &body); + + let status = api.update_status().unwrap(); + + assert_eq!(status.available.as_deref(), Some("1.7.0")); + assert_eq!(status.last_check_at, Some(5)); + assert!(status.can_install); + let config = dir.path().join("config.toml"); + assert_eq!( + fs::read_to_string(args).unwrap().trim(), + format!("--config {} update --status --json", config.display()) + ); + } + + #[test] + fn without_the_daemon_the_cli_changes_the_setting() { + let dir = tempfile::tempdir().unwrap(); + let args = dir.path().join("args"); + let body = format!("echo \"$@\" > '{}'\necho '{STATUS}'", args.display()); + let api = api_without_daemon(dir.path(), &body); + + api.set_automatic_updates(false).unwrap(); + + let args = fs::read_to_string(args).unwrap(); + assert!(args.ends_with("update --auto off --json\n"), "{args}"); + } + + #[test] + fn a_cli_that_fails_says_why() { + let dir = tempfile::tempdir().unwrap(); + let api = api_without_daemon(dir.path(), "echo 'HOME is not set' >&2\nexit 1"); + + let error = api.update_status().unwrap_err(); + + assert_eq!(error.to_string(), "HOME is not set"); + } + + #[test] + fn the_handoff_runs_the_install_in_its_own_session_without_input() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("out"); + let body = format!( + "if read -r line; then input=some; else input=none; fi\n\ + echo \"$@|$$|$(ps -o pgid= -p $$ | tr -d ' ')|$input\" > '{0}.tmp'\n\ + mv '{0}.tmp' '{0}'", + out.display() + ); + let cli = script(dir.path(), &body); + + hand_off_update(&cli).unwrap(); + + let started = Instant::now(); + while !out.exists() && started.elapsed() < Duration::from_secs(10) { + std::thread::sleep(Duration::from_millis(50)); + } + let line = fs::read_to_string(&out).unwrap(); + let parts: Vec<&str> = line.trim().split('|').collect(); + assert_eq!(parts[0], "update install --relaunch"); + // `setsid` makes it the leader of a new session and process group. + assert_eq!(parts[1], parts[2]); + assert_eq!(parts[3], "none"); + } + + #[test] + fn a_handoff_that_cannot_start_fails_without_quitting_anything() { + let dir = tempfile::tempdir().unwrap(); + + let error = hand_off_update(&dir.path().join("missing")).unwrap_err(); + + assert!(error.to_string().starts_with("Could not start"), "{error}"); + } +} diff --git a/src/infrastructure/updates/app_lock.rs b/src/infrastructure/updates/app_lock.rs new file mode 100644 index 0000000..35f1b3d --- /dev/null +++ b/src/infrastructure/updates/app_lock.rs @@ -0,0 +1,169 @@ +//! The app's lifetime lock (docs/desktop.md, "Automatic updates"). The app +//! takes `app.lock` as its first step and holds it until it exits, so the +//! installer never replaces the bundle under it. + +use std::fs::{self, File, OpenOptions, TryLockError}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; + +use super::UpdatePaths; + +/// How often a waiting app tries again. +const POLL: Duration = Duration::from_millis(200); +/// A swap takes a second or two: the app says it is updating only past this. +pub const MESSAGE_AFTER: Duration = Duration::from_secs(1); + +/// What the app's start found. +#[derive(Debug)] +pub enum AppLock { + /// The app holds the lock for as long as it keeps `file` open. + /// `waited` is true when an install held it first, so the bundle may + /// have changed under this process. + Held { file: File, waited: bool }, + /// Another copy of the app holds it. This copy hands its launch over to + /// that one and exits, so it never waits for it. + HeldByAnotherApp, +} + +/// Takes `app.lock`. An installer holds it only while it also holds +/// `update.lock`, so a lock held without `update.lock` is another copy of +/// the app. While an install holds it, this waits, and calls `on_wait` +/// once the wait passes [`MESSAGE_AFTER`]. +/// +/// Looking at `update.lock` takes it for an instant when it is free. An +/// unattended install that tries it at that instant gives up until its next +/// check, which it would do anyway, since the app holds `app.lock`. +pub fn take_app_lock(paths: &UpdatePaths, on_wait: impl FnOnce()) -> Result { + let app_lock = paths.app_lock(); + let file = open_lock(&app_lock)?; + let started = Instant::now(); + let mut on_wait = Some(on_wait); + let mut waited = false; + loop { + match file.try_lock() { + Ok(()) => return Ok(AppLock::Held { file, waited }), + Err(TryLockError::WouldBlock) => {} + Err(TryLockError::Error(error)) => { + return Err(error) + .with_context(|| format!("Could not lock {}", app_lock.display())); + } + } + if !is_held(&paths.update_lock())? { + return Ok(AppLock::HeldByAnotherApp); + } + waited = true; + if started.elapsed() >= MESSAGE_AFTER + && let Some(on_wait) = on_wait.take() + { + on_wait(); + } + std::thread::sleep(POLL); + } +} + +fn is_held(path: &Path) -> Result { + match open_lock(path)?.try_lock() { + Ok(()) => Ok(false), + Err(TryLockError::WouldBlock) => Ok(true), + Err(TryLockError::Error(error)) => { + Err(error).with_context(|| format!("Could not lock {}", path.display())) + } + } +} + +fn open_lock(path: &Path) -> Result { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).with_context(|| format!("Could not create {}", dir.display()))?; + } + OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(path) + .with_context(|| format!("Could not open {}", path.display())) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::mpsc; + + use super::*; + + fn paths() -> (tempfile::TempDir, UpdatePaths) { + let dir = tempfile::tempdir().unwrap(); + let paths = UpdatePaths::new(dir.path().join("Localify")); + (dir, paths) + } + + fn held(path: &Path) -> File { + let file = open_lock(path).unwrap(); + file.lock().unwrap(); + file + } + + #[test] + fn a_free_lock_is_taken_and_kept_until_the_file_closes() { + let (_dir, paths) = paths(); + let AppLock::Held { file, waited } = take_app_lock(&paths, || {}).unwrap() else { + panic!("the lock was free"); + }; + assert!(!waited); + assert!(is_held(&paths.app_lock()).unwrap()); + drop(file); + assert!(!is_held(&paths.app_lock()).unwrap()); + } + + #[test] + fn another_app_holding_it_is_not_waited_for() { + let (_dir, paths) = paths(); + let _other_app = held(&paths.app_lock()); + let called = AtomicBool::new(false); + let lock = take_app_lock(&paths, || called.store(true, Ordering::SeqCst)).unwrap(); + assert!(matches!(lock, AppLock::HeldByAnotherApp)); + assert!(!called.load(Ordering::SeqCst)); + } + + #[test] + fn an_install_holding_both_locks_is_waited_for_with_a_message() { + let (_dir, paths) = paths(); + let update = held(&paths.update_lock()); + let app = held(&paths.app_lock()); + let (said, heard) = mpsc::channel(); + let installer = std::thread::spawn(move || { + // The message comes once the wait is long enough, then the + // install ends. + heard.recv_timeout(Duration::from_secs(10)).unwrap(); + drop(app); + drop(update); + }); + let lock = take_app_lock(&paths, move || said.send(()).unwrap()).unwrap(); + installer.join().unwrap(); + let AppLock::Held { waited, .. } = lock else { + panic!("the install ended"); + }; + assert!(waited); + assert!(is_held(&paths.app_lock()).unwrap()); + } + + #[test] + fn a_short_install_ends_before_any_message() { + let (_dir, paths) = paths(); + let update = held(&paths.update_lock()); + let app = held(&paths.app_lock()); + let installer = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + drop(app); + drop(update); + }); + let called = AtomicBool::new(false); + let lock = take_app_lock(&paths, || called.store(true, Ordering::SeqCst)).unwrap(); + installer.join().unwrap(); + assert!(matches!(lock, AppLock::Held { waited: true, .. })); + assert!(!called.load(Ordering::SeqCst)); + } +} diff --git a/src/infrastructure/updates/mod.rs b/src/infrastructure/updates/mod.rs index cea72e7..c30f94f 100644 --- a/src/infrastructure/updates/mod.rs +++ b/src/infrastructure/updates/mod.rs @@ -6,6 +6,8 @@ // the macOS installer, but build everywhere so their tests run on Linux too. #![cfg_attr(not(target_os = "macos"), allow(dead_code))] +#[allow(dead_code)] // Used by the desktop through the library crate. +pub mod app_lock; pub mod extract; #[cfg(target_os = "macos")] pub mod install; @@ -100,13 +102,6 @@ impl UpdatePaths { } } -/// The lock the app takes as its first step and holds for its lifetime, -/// so the installer never replaces it while it runs. -#[allow(dead_code)] // Used by the desktop through the library crate. -pub fn app_lock_path() -> Option { - UpdatePaths::for_user().map(|paths| paths.app_lock()) -} - /// Appends a line to `updater.log`. Logging never fails an update. pub fn log(paths: &UpdatePaths, message: &str) { let _ = std::fs::create_dir_all(paths.dir()); @@ -171,6 +166,13 @@ pub struct UpdateStatus { /// check failed. pub reason: Option, pub automatic: bool, + /// When the last check ran, in Unix seconds. + #[serde(rename = "lastCheckAt", default)] + pub last_check_at: Option, + /// Whether this copy may be replaced at all. When it may not, `reason` + /// says why, and only a manual install updates it. + #[serde(rename = "canInstall", default)] + pub can_install: bool, } /// `state` in [`UpdateStatus`]. @@ -209,6 +211,7 @@ pub fn describe( .as_ref() .and_then(|release| parse_version(&release.version)) .filter(|offered| rules::offer_is_newer(offered, installed, state.highest().as_ref())); + let can_install = blocker.is_none(); let (phase, reason) = match activity { Activity::Checking => (UpdatePhase::Checking, None), Activity::Installing => (UpdatePhase::Installing, None), @@ -228,6 +231,8 @@ pub fn describe( state: phase, reason, automatic, + last_check_at: state.last_check_at, + can_install, } } @@ -438,6 +443,8 @@ mod tests { Activity::Idle, ); assert_eq!(linux.reason.as_deref(), Some(LINUX_REASON)); + assert!(on.can_install); + assert!(!linux.can_install); } #[test] @@ -479,6 +486,8 @@ mod tests { "state": "available", "reason": null, "automatic": true, + "lastCheckAt": 1, + "canInstall": true, }) ); } From 1a07fac4b1c9c7a228ba72f3c7cd18970317196c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9o=20Goulois?= Date: Mon, 9 Nov 2026 20:18:11 +0100 Subject: [PATCH 7/9] fix(desktop): show where a previous version was left, in Settings 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. --- desktop-ui/src/lib/util.app-update.test.ts | 9 +++++++++ desktop-ui/src/lib/util.app-update.ts | 13 ++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/desktop-ui/src/lib/util.app-update.test.ts b/desktop-ui/src/lib/util.app-update.test.ts index 74b11fa..1926034 100644 --- a/desktop-ui/src/lib/util.app-update.test.ts +++ b/desktop-ui/src/lib/util.app-update.test.ts @@ -144,6 +144,15 @@ test("the other states read as sentences", () => { ); }); +test("a previous version the last install couldn't move says where it is", () => { + const stranded = "The version the last update replaced could not be moved to previous/."; + assert.equal(describeUpdate(status({ reason: stranded }), "Localify", 1_000).detail, stranded); + assert.equal( + describeUpdate(status({ state: "unknown", reason: stranded }), "Localify", 1_000).detail, + stranded, + ); +}); + test("the last check reads in the largest whole unit", () => { assert.equal(lastCheckLine(null, 100), "Not checked yet."); assert.equal(lastCheckLine(100, 130), "Last checked just now."); diff --git a/desktop-ui/src/lib/util.app-update.ts b/desktop-ui/src/lib/util.app-update.ts index 6d4c275..ce312ae 100644 --- a/desktop-ui/src/lib/util.app-update.ts +++ b/desktop-ui/src/lib/util.app-update.ts @@ -34,7 +34,10 @@ function placeOf(session: TerminalSessionDto) { export interface UpdateSummary { title: string; - /** Why it waits, why the check failed, or when the last check ran. */ + /** + * Why it waits, why the check failed, where the bundle the last install replaced was left when + * it couldn't go to `previous/`, or when the last check ran. + */ detail: string | null; /** Offer `Restart to update`. */ canRestart: boolean; @@ -71,11 +74,15 @@ export function describeUpdate( case "upToDate": return { title: `${appName} ${status.installed} is up to date`, - detail: lastCheck, + detail: status.reason ?? lastCheck, canRestart: false, }; case "unknown": - return { title: `${appName} ${status.installed}`, detail: lastCheck, canRestart: false }; + return { + title: `${appName} ${status.installed}`, + detail: status.reason ?? lastCheck, + canRestart: false, + }; } } From 4f8d2d293efeedfcfb99ec171161c38ed3d8301a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9o=20Goulois?= Date: Mon, 9 Nov 2026 20:40:44 +0100 Subject: [PATCH 8/9] fix(updates): fail closed on automatic updates, a racing install, and 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. --- docs/desktop.md | 8 +- src-tauri/src/commands.rs | 10 +- src-tauri/src/lib.rs | 81 +++++++++++----- src/application/install.rs | 4 +- src/config.rs | 4 +- src/desktop/updates.rs | 103 +++++++++++++++++--- src/infrastructure/config/mod.rs | 129 ++++++++++++++++++++++--- src/infrastructure/macos_system.rs | 41 +++++++- src/infrastructure/updates/app_lock.rs | 83 ++++++++++------ src/infrastructure/updates/macos.rs | 15 ++- src/infrastructure/updates/mod.rs | 27 +++++- 11 files changed, 411 insertions(+), 94 deletions(-) diff --git a/docs/desktop.md b/docs/desktop.md index cc6d161..99bce5e 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -122,17 +122,17 @@ daemon started another way, such as a dev build, is left alone. A Mac can install new versions by itself (ADR 0034). -- **Setting:** `[updates] automatic` in the config. It starts on for new installs and off for installs that predate it. A config that doesn't exist yet counts as a new install's, so a setting saved during the app's welcome, before `localify install`, keeps it on. Settings has `Install updates automatically`, right after Startup, and the CLI has `localify update --auto on|off`. The management socket's `update_settings {automatic}` changes it. An unattended install requires `automatic` to be on just before it replaces anything; an install a person confirms works whether it is on or off. +- **Setting:** `[updates] automatic` in the config. It starts on only for a new install: no config, no old `/etc/localify` folder, and nothing an earlier install leaves behind (its root CA and certificates, the daemon's pid file or log, the launchd service, the DNS resolver). A place that can't be checked counts as there. On a new install the app writes the config, with it on, as it starts, before its welcome can save a setting; `localify install` writes it on too when it finds no config and nothing else. Anything else starts off. A config that is missing or can't be read counts as off. Moving an old `/etc/localify` config into the user's folder keeps that config's setting, off when it has none, even when the welcome already wrote a config there. Settings has `Install updates automatically`, right after Startup, and the CLI has `localify update --auto on|off`. The management socket's `update_settings {automatic}` changes it. An unattended install requires `automatic` to be on just before it replaces anything; an install a person confirms works whether it is on or off. - **Checks:** 2 minutes after the daemon starts, then every 6 hours plus or minus 30 minutes. Also when `remote_machines` shows a paired machine on a newer version: such a version comes from the relay's presence and is only a hint. Every check, scheduled, triggered or manual, goes through one coalesced operation with a machine-wide cooldown of 10 minutes, doubled after each failure up to 6 hours. The last check's time is persisted, so a restart doesn't reset it. The daemon also downloads at most once every 6 hours until an install succeeds: the time of its last download is persisted before the download starts, so neither a restart nor a hint repeats it. A person's `localify update install` doesn't wait for that cooldown. A check reads the manifest the app's updater reads (`latest.json`, from the build's updater endpoint) over HTTPS. That manifest is discovery, not authority. - **Trust:** the archive's minisign signature is checked against the updater public key built into Localify before anything is extracted. Only a version strictly newer than both the installed bundle's `CFBundleShortVersionString` and the highest version this machine ever installed is accepted, compared by semver precedence, so build metadata (`+...`) never makes a version newer. A build that failed here before is skipped until the next release: it is recorded by its archive's SHA-256 and by its release identity (the manifest's version and signature), which is checked before anything is downloaded. Only failures of the archive are recorded: a bad signature, one it can't be read from, one that breaks the extraction rules, or a bundle whose version isn't the manifest's. Nothing else is, since it can be this machine's fault: a full disk, a write error, or a `codesign` or `plutil` that refuses or couldn't run, as they also do for local reasons such as too many open files. Those are retried at the next check. The tools are given the updates folder's resolved path, the one whose every folder was checked, never a spelling through a symlink. After extraction, the bundle's version must equal the manifest's. `codesign --verify --deep --strict` must pass with a requirement anchored to Apple and pinned to Localify's team ID and bundle identifier, covering the daemon's executable too. Freshness is not guaranteed: a compromised feed could withhold an update or offer an older valid one to an older client, and this version accepts that. - **Extraction:** into a new private directory on the destination's volume, made by the installer. No other account may be able to change any folder above it: from `/` down to the updates folder, each must be owned by this account or root, have no group or other write in its mode, and have no ACL entry that lets another account add, delete or rename what it holds, or change its ACL or owner (`add_file`, `add_subdirectory`, `delete_child`, `delete`, `writesecurity`, `chown`). Denials, entries for this account, and entries only passed on to new folders don't count. Otherwise the installer stops before extracting, and the status's `reason` names the folder: whoever can change it could rename a folder below and put their own tree where the staged bundle is. Each folder is opened from the one above it, never through a symlink, and the new directory stays open until the swap. The updates folder, `staging/` and the new directory must be owned by this account; the installer removes write access for anyone else from their mode, clears their ACLs (as `chmod -N` does, so an inherited ACL can't let another account in), and checks the new directory is empty. The archive may hold exactly one `Localify.app`, with no absolute or `..` paths, no hard links, no device or FIFO entries, no setuid or setgid bits, no data in entries that aren't files, and only relative symlinks that stay inside the bundle. Every path is created one component at a time from a directory descriptor, never following a symlink, and symlinks are made only after every file and folder, so a name that differs only in case on a case-insensitive volume can't lead outside. The archive is read in tar's raw mode: every header counts toward 20,000 entries, including GNU long-name and PAX headers, whose contents are capped at 1 MiB together. Of PAX records, only `path` and `linkpath` are used; `size`, sparse records and global headers are refused. The files are capped at 1 GiB, and the decompressed stream at that plus the headers. Quarantine is not added and never cleared; the archive is notarized and stapled. -- **Where:** only the production bundle whose executable is the daemon's, at `/Applications/Localify.app` or `~/Applications/Localify.app`, when the user owns it and can replace it. A dev build, a translocated or read-only bundle, a mounted DMG, or a location that would need authorization stays manual, and Settings says why. +- **Where:** only the production bundle whose executable is the daemon's, at `/Applications/Localify.app` or `~/Applications/Localify.app`, when the user owns it and can replace it. A dev build, a translocated or read-only bundle, a mounted DMG, or a location that would need authorization stays manual, and Settings says why. The app applies the same rules to its own bundle, since `Restart to update` hands off to that bundle's CLI: a copy opened from a DMG or Downloads offers no `Restart to update` and says why, even when the daemon's bundle could be replaced. It checks again right before it hands off and quits. - **One installer:** the `localify` binary's own installer, in the core, enforces every rule here. The daemon runs it unattended, and `localify update install` runs it when a person asks. The app has no updater of its own: it reads `update_status`, runs the same coalesced check for `Check now` and the `Check for Updates…` menu, and installs through this installer. Tauri's updater plugin is not used; the bundler still signs the updater archive with the key in `tauri.conf.json`. -- **Is the app running:** the app takes an exclusive `flock` on `~/Library/Application Support/Localify/app.lock` as its first step and holds it for its lifetime. An app that finds `app.lock` held while `update.lock` is held too waits for the install, saying "Updating Localify…" after a second, then starts on whatever is installed: when the install replaced its bundle, it runs the new executable instead. When `app.lock` is held without `update.lock`, another copy of the app holds it, and this one hands its launch over without waiting. The installer runs only when it can take `app.lock`, and also finds no process whose executable lies inside the bundle (`proc_listallpids` and `proc_pidpath`). That second check also covers apps from 1.6 and earlier, which take no lock, and other accounts using the same bundle. If it can't read a process's path, it doesn't install. Two cases are not "in use": the bundle's own `localify`, which the daemon and the CLI run from and which outlive a replacement, and a process whose executable was deleted, which can't be running the bundle's files on disk. +- **Is the app running:** the app takes an exclusive `flock` on `~/Library/Application Support/Localify/app.lock` as its first step and holds it for its lifetime. An app that finds `app.lock` held while `update.lock` is held too waits for the install, saying "Updating Localify…" after a second. When `app.lock` stays held without `update.lock`, another copy of the app holds it, and this one hands its launch over without waiting. It tries `app.lock` again for a tenth of a second first, since an installer 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, this one is the copy that runs, and it takes the lock then. Whatever it found, before its window opens the app checks whether its bundle now holds a newer version than its own, and runs that executable instead. The installer runs only when it can take `app.lock`, and also finds no process whose executable lies inside the bundle (`proc_listallpids` and `proc_pidpath`). That second check also covers apps from 1.6 and earlier, which take no lock, and other accounts using the same bundle. If it can't read a process's path, it doesn't install. Two cases are not "in use": the bundle's own `localify`, which the daemon and the CLI run from and which outlive a replacement, and a process whose executable was deleted, which can't be running the bundle's files on disk. - **When the daemon installs:** only when both checks pass. Otherwise it reports the update as available and tries again at the next check. - **Swap:** under an exclusive `flock` on `~/Library/Application Support/Localify/update.lock`, a file that is never deleted, in the bundle owner's account, at a path that doesn't depend on the configurable data dir. Only the owner's account installs. The installer checks again that the destination is the same inode and version it verified, and that the staged bundle still has the version and passes the same `codesign` check, and fails without touching it when the volume lacks `RENAME_SWAP`. `codesign` and `plutil` need a path, so right before and right after each of them the installer checks that the path still leads to the bundle in the staging directory it holds open. It then exchanges the staged bundle and the installed one with `renameatx_np`, naming the staged one from that open directory and the installed one from its open folder, so nothing that renames a folder above them changes what is swapped. The old bundle moves to `~/Library/Application Support/Localify/previous/.app`, which keeps one previous version; the copy kept before is removed only once the new one is in `previous/`. If the move fails, the old bundle stays in its staging directory, and the install's output and the status's `reason` say where, until it is deleted. The daemon restarts on the new binary, as it already does after any replacement. A destination that changed before that check makes the installer stop and leave it as it is. A person replacing the bundle at the very same moment, from a DMG for example, is not supported. - **With the app open:** the app shows an available update in Settings → `Install updates automatically` and as a banner, with `Restart to update` when this copy may be replaced. Restarting lists what it will close by name: the running service tabs and the open shell tabs. On the person's click, the app starts `localify update install --relaunch` from its bundle's own CLI, detached in its own session (`setsid`) with stdin on `/dev/null`, then quits, which releases `app.lock`. If that process can't start, the app says why and stays open. The installer downloads after the app quits. That process waits for `app.lock`, installs, and opens the new app in the same login session. The app never relaunches by itself. -- **Status:** `update_status` on the management socket answers `{installed, available, state, reason, automatic, lastCheckAt, canInstall}`, and so does `localify update --status` (JSON when stdout is not a terminal; read locally when the daemon isn't running). `state` is `unknown` (never checked), `upToDate`, `available`, `checking`, `installing` or `failed` (the last check failed, or `updates.json` can't be trusted). `reason` says why an available update waits, why the check failed, or where a previous bundle was left. `lastCheckAt` is the last check's time in Unix seconds. `canInstall` is false when this copy can only be updated by hand (a dev build, a bundle in another place or owned by another account, Linux) or `updates.json` can't be trusted; `reason` then says why, and the app offers no `Restart to update`. With the daemon down, the app asks the bundle's CLI for the same status. That is what the app and the CLI read, so nothing depends on a notification arriving. +- **Status:** `update_status` on the management socket answers `{installed, available, state, reason, automatic, lastCheckAt, canInstall}`, and so does `localify update --status` (JSON when stdout is not a terminal; read locally when the daemon isn't running). `state` is `unknown` (never checked), `upToDate`, `available`, `checking`, `installing` or `failed` (the last check failed, or `updates.json` can't be trusted). `reason` says why an available update waits, why the check failed, or where a previous bundle was left. `lastCheckAt` is the last check's time in Unix seconds. `canInstall` is false when this copy can only be updated by hand (a dev build, a bundle in another place or owned by another account, Linux) or `updates.json` can't be trusted; `reason` then says why, and the app offers no `Restart to update`. The app also sets it false when its own bundle fails those rules, with that reason when an update waits. With the daemon down, the app asks the bundle's CLI for the same status. That is what the app and the CLI read, so nothing depends on a notification arriving. - **Files:** `~/Library/Application Support/Localify/` holds `app.lock`, `update.lock`, `check.lock` (held during a check, so checks coalesce), `updates.json` (the highest version installed, the digests and releases that failed, the last check, its failures and what it read, the last unattended download), `staging/`, `previous/` and `updater.log`, the one updater log. A missing `updates.json` is a first use. One that can't be read or holds something invalid, such as a highest version that isn't a version, stops every check and install, with a `failed` status that names the file: reading it as new would forget the oldest version allowed and the releases that failed. To start again, delete it; the installed version is then the oldest one allowed. `localify::infrastructure::updates::app_lock::take_app_lock` is how the app takes its lock. - **Recovery:** no automatic rollback. If a new version misbehaves, `~/Library/Application Support/Localify/previous/` holds the last one (or the staging directory the status names, when it couldn't be moved there): quit Localify, put that bundle back in place of the installed one, and turn `automatic` off until a fixed release. - **Linux:** no unattended install, because the CLI there is checked by SHA-256 only. A Linux daemon that sees a newer version says "Update available: run the install command again" in `localify status` and in the app. It reads only the manifest's `version`; release builds get the manifest's address from `LOCALIFY_UPDATER_BASE_URL`, as the app does. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 81e06ec..dbef647 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -42,7 +42,7 @@ use localify::desktop::terminal::{ TerminalManager, TerminalResizeRequest, TerminalRestartServiceRequest, TerminalSessionDto, TerminalSessionRequest, TerminalWriteRequest, }; -use localify::desktop::updates::hand_off_update; +use localify::desktop::updates::{hand_off_update, open_app_blocker}; use localify::infrastructure::updates::UpdateStatus; pub struct AppState { @@ -253,9 +253,11 @@ pub async fn check_for_updates(state: tauri::State<'_, AppState>) -> Result Result<(), String> { - // The CLI next to a dev build would open the installed app instead. - if cfg!(debug_assertions) { - return Err("A development build doesn't install updates: update it by hand.".into()); + // Checked again now: the installer would refuse this bundle after the + // app quit, closing its tabs for nothing. A dev build is refused here + // too, as the CLI next to it would open the installed app instead. + if let Some(reason) = open_app_blocker() { + return Err(reason); } let cli = super::sibling_binary_path().ok_or_else(|| { "This copy of Localify has no CLI in its bundle, so it can't install updates. Update it by hand." diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f5ef9f9..1308517 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1063,12 +1063,14 @@ fn setup_app_menu(app: &AppHandle) -> tauri::Result<()> { sync_app_menu(app) } -/// The app's first step (docs/desktop.md, "Automatic updates"): it holds -/// `app.lock` for its lifetime, so the installer never replaces the bundle -/// under it. While an install holds the lock, the app waits for it. Without -/// the lock the app still starts: the installer also looks for processes -/// running from the bundle. -fn hold_app_lock() -> Option { +/// The app's first step (docs/desktop.md, "Automatic updates"): it takes +/// `app.lock`, which it holds for its lifetime, so the installer never +/// replaces the bundle under it. While an install holds the lock, the app +/// waits for it. Then, whatever it found, it checks whether an install +/// replaced its bundle. `None` when the lock can't be taken: the app still +/// starts, as the installer also looks for processes running from the +/// bundle. +fn hold_app_lock() -> Option { let paths = UpdatePaths::for_user()?; let mut message = None; let lock = take_app_lock(&paths, || message = show_updating_message()); @@ -1076,19 +1078,32 @@ fn hold_app_lock() -> Option { let _ = message.kill(); let _ = message.wait(); } - match lock { - Ok(AppLock::Held { file, waited }) => { - if waited { - restart_if_replaced(); + restart_if_replaced(); + lock.inspect_err(|error| eprintln!("the app lock is off: {error:#}")) + .ok() +} + +/// `app.lock`, held for the app's lifetime. +struct HeldAppLock { + _file: Option, +} + +/// The lock the copy the single-instance plugin kept holds. A copy that +/// found `app.lock` held by another app was handed over and quit, unless +/// no other copy runs: then whoever held it has let go (an installer that +/// just ended, or a copy that quit since), and this one takes it now. +fn primary_app_lock(at_start: Option) -> Option { + match at_start { + Some(AppLock::Held(file)) => Some(file), + Some(AppLock::HeldByAnotherApp) => match hold_app_lock() { + Some(AppLock::Held(file)) => Some(file), + Some(AppLock::HeldByAnotherApp) => { + eprintln!("the app lock is off: another copy of the app holds it"); + None } - Some(file) - } - // That copy gets this launch through the single-instance plugin. - Ok(AppLock::HeldByAnotherApp) => None, - Err(error) => { - eprintln!("the app lock is off: {error:#}"); - None - } + None => None, + }, + None => None, } } @@ -1112,22 +1127,32 @@ fn show_updating_message() -> Option { None } -/// An install that held the lock may have replaced the bundle this process -/// started from: the version installed now starts instead. +/// An install may have replaced the bundle this process started from: the +/// newer version installed now starts instead. Only a newer one, so a +/// bundle whose version isn't this binary's never starts itself again. #[cfg(target_os = "macos")] fn restart_if_replaced() { use std::os::unix::process::CommandExt; + use localify::infrastructure::updates::{macos::bundle_version, rules::parse_version}; + let Ok(exe) = std::env::current_exe() else { return; }; - let Some(bundle) = exe.ancestors().nth(3) else { + let Some(bundle) = exe + .ancestors() + .nth(3) + .filter(|bundle| bundle.extension().is_some_and(|ext| ext == "app")) + else { return; }; - let Ok(installed) = localify::infrastructure::updates::macos::bundle_version(bundle) else { + let (Ok(installed), Some(running)) = ( + bundle_version(bundle), + parse_version(env!("CARGO_PKG_VERSION")), + ) else { return; }; - if installed.to_string() == env!("CARGO_PKG_VERSION") { + if installed <= running { return; } let error = Command::new(&exe).args(std::env::args_os().skip(1)).exec(); @@ -1139,9 +1164,11 @@ fn restart_if_replaced() {} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // Held until the process exits. - let _app_lock = hold_app_lock(); + let app_lock = hold_app_lock(); let api = DesktopApi::new(default_config_path(), default_binary_path()); + if let Err(error) = api.start_new_install() { + eprintln!("could not write a new install's config: {error:#}"); + } let mut builder = tauri::Builder::default(); @@ -1185,6 +1212,10 @@ pub fn run() { builder .setup(move |app| { + // Only the copy the single-instance plugin keeps gets here. + app.manage(HeldAppLock { + _file: primary_app_lock(app_lock), + }); // In setup: the panel's events need the app's handle. let terminal = TerminalManager::new( executable.clone(), diff --git a/src/application/install.rs b/src/application/install.rs index e7e561c..b314582 100644 --- a/src/application/install.rs +++ b/src/application/install.rs @@ -48,8 +48,10 @@ impl<'a> Install<'a> { let lan_ip = self.network.lan_ip().unwrap_or(Ipv4Addr::LOCALHOST); let dns_port = self.dns_port; - self.create_directories(&mut steps)?; + // Before anything is created: whether this is a new install, which + // decides automatic updates, is read from what is already there. self.ensure_config_file(&mut steps)?; + self.create_directories(&mut steps)?; self.init_root_ca(&mut steps); steps.extend(self.system.apply_system_changes()?); self.report_ca_trust(&mut steps); diff --git a/src/config.rs b/src/config.rs index 1178447..b6e4432 100644 --- a/src/config.rs +++ b/src/config.rs @@ -166,8 +166,8 @@ impl Default for AgentConfig { #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct UpdatesConfig { /// Install new versions without asking. A config without the key - /// predates the feature and keeps updating by hand: `localify install` - /// writes it on for a new install. + /// predates the feature and keeps updating by hand: the app's first + /// start, or `localify install`, writes it on for a new install. #[serde(default)] pub automatic: bool, } diff --git a/src/desktop/updates.rs b/src/desktop/updates.rs index 326b508..9b78382 100644 --- a/src/desktop/updates.rs +++ b/src/desktop/updates.rs @@ -11,29 +11,48 @@ use std::process::{Command, Stdio}; use anyhow::{Context, Result, anyhow, bail}; use super::DesktopApi; +use crate::branding::BRANDING; +use crate::infrastructure::config::ConfigStore; use crate::infrastructure::mgmt_client::MgmtSocketClient; -use crate::infrastructure::updates::{self, UpdatePaths, UpdateStatus, release, state}; +use crate::infrastructure::updates::{ + self, UpdatePaths, UpdatePhase, UpdateStatus, release, state, +}; impl DesktopApi { + /// On a new install, writes the config with automatic updates on, before + /// the welcome can save a setting into a config without them. An + /// install that predates them, or left anything on this machine, is + /// left as it is (docs/desktop.md, "Automatic updates"). + pub fn start_new_install(&self) -> Result { + Ok(ConfigStore::new(self.config_path().to_path_buf()).start_new_install()?) + } + /// The daemon's status, which knows whether it is checking or /// installing now. With the daemon down, the CLI reads the same status /// from the files: only the bundle's `localify` can tell whether its /// bundle may be replaced. + /// + /// Either describes the daemon's bundle, so it is narrowed to what the + /// open app can do ([`open_app_blocker`]). pub fn update_status(&self) -> Result { - if let Ok(status) = self.update_client()?.update_status() { - return Ok(status); - } - self.update_cli(&["update", "--status", "--json"]) + let status = match self.update_client()?.update_status() { + Ok(status) => status, + Err(_) => self.update_cli(&["update", "--status", "--json"])?, + }; + Ok(for_open_app(status, open_app_blocker())) } /// `Install updates automatically`. The daemon reads the setting again /// before any unattended install, so it needs no restart. pub fn set_automatic_updates(&self, automatic: bool) -> Result { - if let Ok(status) = self.update_client()?.update_settings(automatic) { - return Ok(status); - } - let on = if automatic { "on" } else { "off" }; - self.update_cli(&["update", "--auto", on, "--json"]) + let status = match self.update_client()?.update_settings(automatic) { + Ok(status) => status, + Err(_) => { + let on = if automatic { "on" } else { "off" }; + self.update_cli(&["update", "--auto", on, "--json"])? + } + }; + Ok(for_open_app(status, open_app_blocker())) } /// `Check now`: the check every caller goes through, so it shares the @@ -71,6 +90,32 @@ impl DesktopApi { } } +/// Why the open app can't install an update itself, if it can't. +/// `Restart to update` hands off to this bundle's own CLI, whose installer +/// replaces this bundle and no other. The daemon's status speaks for the +/// daemon's bundle, which may be another copy: one in /Applications while +/// this one runs from a disk image or Downloads. +pub fn open_app_blocker() -> Option { + let exe = std::env::current_exe().unwrap_or_default(); + updates::blocker_of(&exe.with_file_name(BRANDING.cli_command())) +} + +/// The status, for an app whose own bundle `blocker` keeps from being +/// replaced: no `Restart to update`, and an update that waits says why, as +/// the daemon's own blocker would. +fn for_open_app(mut status: UpdateStatus, blocker: Option) -> UpdateStatus { + let Some(blocker) = blocker else { + return status; + }; + if status.can_install { + status.can_install = false; + if status.state == UpdatePhase::Available { + status.reason = Some(blocker); + } + } + status +} + /// `Restart to update`: starts `localify update install --relaunch` from the /// bundle's own CLI, in its own session with no terminal, so it outlives the /// app. It waits for `app.lock`, installs, then opens the app again. The @@ -147,7 +192,6 @@ mod tests { assert_eq!(status.available.as_deref(), Some("1.7.0")); assert_eq!(status.last_check_at, Some(5)); - assert!(status.can_install); let config = dir.path().join("config.toml"); assert_eq!( fs::read_to_string(args).unwrap().trim(), @@ -155,6 +199,43 @@ mod tests { ); } + #[test] + fn an_app_whose_own_bundle_cant_be_replaced_offers_no_restart() { + let dir = tempfile::tempdir().unwrap(); + // The daemon's bundle may be replaced; this test binary, like a copy + // run from a disk image, is not one that may. + let api = api_without_daemon(dir.path(), &format!("echo '{STATUS}'")); + + let status = api.update_status().unwrap(); + + assert!(!status.can_install); + let reason = status.reason.unwrap(); + assert_eq!(Some(reason), open_app_blocker()); + } + + #[test] + fn the_open_apps_blocker_keeps_the_reason_of_a_status_that_waits_for_nothing() { + let status: UpdateStatus = serde_json::from_str( + r#"{"installed":"1.6.0","available":null,"state":"upToDate","reason":"kept","automatic":true,"lastCheckAt":5,"canInstall":true}"#, + ) + .unwrap(); + + let status = for_open_app(status, Some("moved".into())); + + assert!(!status.can_install); + assert_eq!(status.reason.as_deref(), Some("kept")); + } + + #[test] + fn an_installable_app_keeps_the_daemons_status() { + let status: UpdateStatus = serde_json::from_str(STATUS).unwrap(); + + let status = for_open_app(status, None); + + assert!(status.can_install); + assert_eq!(status.reason, None); + } + #[test] fn without_the_daemon_the_cli_changes_the_setting() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/infrastructure/config/mod.rs b/src/infrastructure/config/mod.rs index 7a13999..43c2961 100644 --- a/src/infrastructure/config/mod.rs +++ b/src/infrastructure/config/mod.rs @@ -1029,12 +1029,55 @@ fn normalized_label(value: &str) -> Option { pub struct ConfigStore { path: PathBuf, + /// Where an earlier Localify leaves something behind. `None` is this + /// machine's places ([`previous_install_traces`]). + install_traces: Option>, } impl ConfigStore { /// Create a new ConfigStore pointing at the given config file path pub fn new(path: PathBuf) -> Self { - Self { path } + Self { + path, + install_traces: None, + } + } + + /// A store that looks for an earlier install at `traces` instead of + /// this machine's places. + #[cfg(test)] + pub fn with_install_traces(path: PathBuf, traces: Vec) -> Self { + Self { + path, + install_traces: Some(traces), + } + } + + /// Whether this is a new install: no config here, and nothing an + /// earlier Localify leaves behind. A place that can't be checked counts + /// as taken, so an install that predates automatic updates never gets + /// them without its owner's choice. + pub fn is_new_install(&self) -> bool { + let traces = self + .install_traces + .clone() + .unwrap_or_else(previous_install_traces); + std::iter::once(self.path.clone()) + .chain(traces) + .all(|path| matches!(path.try_exists(), Ok(false))) + } + + /// Writes a new install's config, with automatic updates on, when this + /// is a new install. The app does it before its welcome can save any + /// setting, which would otherwise write a config with them off. Returns + /// whether it wrote one. + #[allow(dead_code)] // Used by the desktop through the library crate. + pub fn start_new_install(&self) -> Result { + if !self.is_new_install() { + return Ok(false); + } + self.save(&new_install_config(true))?; + Ok(true) } fn ensure_config_dir(&self) -> Result<(), ConfigError> { @@ -1046,12 +1089,12 @@ impl ConfigStore { Ok(()) } - /// A config that doesn't exist yet is a new install's, so anything - /// saved before `localify install`, such as a setting changed during - /// the app's welcome, keeps automatic updates on. + /// A config that doesn't exist yet loads as the defaults, with + /// automatic updates off: only [`Self::start_new_install`] and + /// `localify install` turn them on, for a new install. pub fn load(&self) -> Result { if !self.path.exists() { - return Ok(new_install_config()); + return Ok(Config::default()); } let content = fs::read_to_string(&self.path).map_err(ConfigError::ReadError)?; @@ -1736,14 +1779,38 @@ fn map_config_error(e: ConfigError) -> ConfigLoadError { } } -/// A new install's config: automatic updates start on, unlike a config -/// that predates them. -fn new_install_config() -> Config { +/// The config `localify install` or the app writes when there is none: +/// automatic updates are on only for a new install. +fn new_install_config(new_install: bool) -> Config { let mut config = Config::default(); - config.updates.automatic = true; + config.updates.automatic = new_install; config } +/// What an earlier Localify leaves on this machine: its root CA and +/// certificates, the daemon's pid and log, the launchd service, the DNS +/// resolver (or systemd-resolved's drop-in), and the old `/etc` and +/// `/var/log` folders. Not the data dir or the app's folder: the app makes +/// them on its first start. +pub fn previous_install_traces() -> Vec { + let paths = LocalifyPaths::default(); + let slug = BRANDING.slug(); + let mut traces = vec![ + paths.data_dir.join("ca.crt"), + paths.certs_dir, + paths.pid_file, + paths.log_file, + BRANDING.resolver_file(), + BRANDING.resolved_drop_in_file(), + PathBuf::from("/etc").join(slug), + PathBuf::from("/var/log").join(slug), + ]; + if cfg!(target_os = "macos") { + traces.push(crate::infrastructure::macos_system::service_plist_path()); + } + traces +} + impl ConfigLoader for ConfigStore { fn load(&self) -> Result<(DaemonConfig, LocalifyPaths), ConfigLoadError> { let config = ConfigStore::load(self).map_err(map_config_error)?; @@ -1751,7 +1818,8 @@ impl ConfigLoader for ConfigStore { } fn save_defaults(&self) -> Result<(), ConfigLoadError> { - self.save(&new_install_config()).map_err(map_config_error) + self.save(&new_install_config(self.is_new_install())) + .map_err(map_config_error) } fn exists(&self) -> bool { @@ -1821,9 +1889,19 @@ mod tests { // --- [updates] automatic --- + /// A store on a machine where nothing of an earlier install is left. + fn new_machine() -> (tempfile::TempDir, ConfigStore) { + let dir = tempfile::tempdir().unwrap(); + let store = ConfigStore::with_install_traces( + dir.path().join("config.toml"), + vec![dir.path().join("etc-localify"), dir.path().join("ca.crt")], + ); + (dir, store) + } + #[test] fn a_new_install_writes_automatic_updates_on() { - let (_dir, store) = temp_store(); + let (_dir, store) = new_machine(); ConfigLoader::save_defaults(&store).unwrap(); assert!(store.load().unwrap().updates.automatic); let text = fs::read_to_string(store.path.clone()).unwrap(); @@ -1831,12 +1909,37 @@ mod tests { } #[test] - fn a_setting_saved_before_the_install_keeps_automatic_updates_on() { - let (_dir, store) = temp_store(); + fn an_install_over_an_earlier_ones_traces_keeps_automatic_updates_off() { + let (dir, store) = new_machine(); + fs::create_dir(dir.path().join("etc-localify")).unwrap(); + ConfigLoader::save_defaults(&store).unwrap(); + assert!(!store.load().unwrap().updates.automatic); + } + + #[test] + fn a_setting_saved_after_a_new_install_starts_keeps_automatic_updates_on() { + let (_dir, store) = new_machine(); + assert!(store.start_new_install().unwrap()); let mut config = store.load().unwrap(); config.agent.connect_agents = false; store.save(&config).unwrap(); assert!(store.load().unwrap().updates.automatic); + // Once there is a config, it is no longer a new install. + assert!(!store.start_new_install().unwrap()); + } + + #[test] + fn a_machine_with_an_earlier_install_does_not_start_as_a_new_one() { + let (dir, store) = new_machine(); + fs::write(dir.path().join("ca.crt"), "").unwrap(); + assert!(!store.start_new_install().unwrap()); + assert!(!store.path.exists()); + } + + #[test] + fn a_missing_config_reads_automatic_updates_off() { + let (_dir, store) = temp_store(); + assert!(!store.load().unwrap().updates.automatic); } #[test] diff --git a/src/infrastructure/macos_system.rs b/src/infrastructure/macos_system.rs index 5cef717..0acf2de 100644 --- a/src/infrastructure/macos_system.rs +++ b/src/infrastructure/macos_system.rs @@ -377,19 +377,24 @@ fn copy_config_to_user_folder(legacy_path: &Path, config_path: &Path) -> Result< ) })?; // The script removes the old folder, so its domains must not be lost when a newer - // config already exists. That config keeps its own settings. + // config already exists. That config keeps its own settings, except automatic + // updates: the app's welcome can write that config before this runs, and an + // install that predates them keeps them off until its owner turns them on. if config_path.exists() { let store = ConfigStore::new(config_path.to_path_buf()); let mut current = store .load() .context("Failed to read the config in your user folder")?; + let automatic = migrated.updates.automatic; + let changed = current.updates.automatic != automatic; + current.updates.automatic = automatic; let added = current.adopt_missing_from(migrated); - if added > 0 { + if added > 0 || changed { store .save(¤t) .context("Failed to save the config in your user folder")?; } - return Ok(added > 0); + return Ok(added > 0 || changed); } migrated.paths = LocalifyPaths::default(); @@ -1892,6 +1897,36 @@ SHA-1 hash: AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:00:11:22:33 assert_eq!(config.daemon.http_port, 9000); } + #[test] + fn migrating_after_the_welcome_saved_a_setting_keeps_automatic_updates_off() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join("legacy.toml"); + fs::write(&legacy, "[daemon]\nhttp_port = 8081\n").unwrap(); + let target = dir.path().join("config.toml"); + // The welcome's "Connect agents" saves a config before activation. + let store = ConfigStore::new(target.clone()); + let mut config = store.load().unwrap(); + config.agent.connect_agents = false; + store.save(&config).unwrap(); + + copy_config_to_user_folder(&legacy, &target).unwrap(); + + assert!(!store.load().unwrap().updates.automatic); + } + + #[test] + fn migrating_takes_automatic_updates_from_the_old_config() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join("legacy.toml"); + fs::write(&legacy, "[daemon]\nhttp_port = 8081\n").unwrap(); + let target = dir.path().join("config.toml"); + fs::write(&target, "[updates]\nautomatic = true\n").unwrap(); + + assert!(copy_config_to_user_folder(&legacy, &target).unwrap()); + + assert!(!ConfigStore::new(target).load().unwrap().updates.automatic); + } + #[test] fn migrating_never_overwrites_a_config_in_the_users_folder() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/infrastructure/updates/app_lock.rs b/src/infrastructure/updates/app_lock.rs index 35f1b3d..e6d94ea 100644 --- a/src/infrastructure/updates/app_lock.rs +++ b/src/infrastructure/updates/app_lock.rs @@ -15,46 +15,64 @@ use super::UpdatePaths; const POLL: Duration = Duration::from_millis(200); /// A swap takes a second or two: the app says it is updating only past this. pub const MESSAGE_AFTER: Duration = Duration::from_secs(1); +/// An installer lets go of `update.lock` just before `app.lock`, and the +/// daemon's check takes `app.lock` alone for an instant: `app.lock` held +/// with `update.lock` free is tried again this many times before it counts +/// as another app. +const RELEASE_TRIES: u32 = 5; +const RELEASE_POLL: Duration = Duration::from_millis(20); /// What the app's start found. #[derive(Debug)] pub enum AppLock { /// The app holds the lock for as long as it keeps `file` open. - /// `waited` is true when an install held it first, so the bundle may - /// have changed under this process. - Held { file: File, waited: bool }, + Held(File), /// Another copy of the app holds it. This copy hands its launch over to - /// that one and exits, so it never waits for it. + /// that one, so it never waits for it. HeldByAnotherApp, } /// Takes `app.lock`. An installer holds it only while it also holds /// `update.lock`, so a lock held without `update.lock` is another copy of -/// the app. While an install holds it, this waits, and calls `on_wait` -/// once the wait passes [`MESSAGE_AFTER`]. +/// the app, once it stays held a little. While an install holds it, this +/// waits, and calls `on_wait` once the wait passes [`MESSAGE_AFTER`]. /// /// Looking at `update.lock` takes it for an instant when it is free. An /// unattended install that tries it at that instant gives up until its next /// check, which it would do anyway, since the app holds `app.lock`. pub fn take_app_lock(paths: &UpdatePaths, on_wait: impl FnOnce()) -> Result { + take(paths, on_wait, || {}) +} + +/// [`take_app_lock`], with `between_probes` called after `app.lock` was +/// found held and before `update.lock` is looked at: tests end an install +/// there. +fn take( + paths: &UpdatePaths, + on_wait: impl FnOnce(), + mut between_probes: impl FnMut(), +) -> Result { let app_lock = paths.app_lock(); let file = open_lock(&app_lock)?; let started = Instant::now(); let mut on_wait = Some(on_wait); - let mut waited = false; loop { - match file.try_lock() { - Ok(()) => return Ok(AppLock::Held { file, waited }), - Err(TryLockError::WouldBlock) => {} - Err(TryLockError::Error(error)) => { - return Err(error) - .with_context(|| format!("Could not lock {}", app_lock.display())); - } + if try_lock(&file, &app_lock)? { + return Ok(AppLock::Held(file)); } + between_probes(); if !is_held(&paths.update_lock())? { - return Ok(AppLock::HeldByAnotherApp); + // No install now: one may have just ended, letting go of both. + for _ in 0..RELEASE_TRIES { + if try_lock(&file, &app_lock)? { + return Ok(AppLock::Held(file)); + } + std::thread::sleep(RELEASE_POLL); + } + if !is_held(&paths.update_lock())? { + return Ok(AppLock::HeldByAnotherApp); + } } - waited = true; if started.elapsed() >= MESSAGE_AFTER && let Some(on_wait) = on_wait.take() { @@ -64,16 +82,20 @@ pub fn take_app_lock(paths: &UpdatePaths, on_wait: impl FnOnce()) -> Result Result { - match open_lock(path)?.try_lock() { - Ok(()) => Ok(false), - Err(TryLockError::WouldBlock) => Ok(true), +fn try_lock(file: &File, path: &Path) -> Result { + match file.try_lock() { + Ok(()) => Ok(true), + Err(TryLockError::WouldBlock) => Ok(false), Err(TryLockError::Error(error)) => { Err(error).with_context(|| format!("Could not lock {}", path.display())) } } } +fn is_held(path: &Path) -> Result { + Ok(!try_lock(&open_lock(path)?, path)?) +} + fn open_lock(path: &Path) -> Result { if let Some(dir) = path.parent() { fs::create_dir_all(dir).with_context(|| format!("Could not create {}", dir.display()))?; @@ -109,10 +131,9 @@ mod tests { #[test] fn a_free_lock_is_taken_and_kept_until_the_file_closes() { let (_dir, paths) = paths(); - let AppLock::Held { file, waited } = take_app_lock(&paths, || {}).unwrap() else { + let AppLock::Held(file) = take_app_lock(&paths, || {}).unwrap() else { panic!("the lock was free"); }; - assert!(!waited); assert!(is_held(&paths.app_lock()).unwrap()); drop(file); assert!(!is_held(&paths.app_lock()).unwrap()); @@ -143,10 +164,7 @@ mod tests { }); let lock = take_app_lock(&paths, move || said.send(()).unwrap()).unwrap(); installer.join().unwrap(); - let AppLock::Held { waited, .. } = lock else { - panic!("the install ended"); - }; - assert!(waited); + assert!(matches!(lock, AppLock::Held(_)), "the install ended"); assert!(is_held(&paths.app_lock()).unwrap()); } @@ -163,7 +181,18 @@ mod tests { let called = AtomicBool::new(false); let lock = take_app_lock(&paths, || called.store(true, Ordering::SeqCst)).unwrap(); installer.join().unwrap(); - assert!(matches!(lock, AppLock::Held { waited: true, .. })); + assert!(matches!(lock, AppLock::Held(_))); assert!(!called.load(Ordering::SeqCst)); } + + #[test] + fn an_install_that_ends_between_the_two_probes_is_not_another_app() { + let (_dir, paths) = paths(); + let mut installer = Some((held(&paths.update_lock()), held(&paths.app_lock()))); + // The installer lets go of both locks right after `app.lock` was + // found held, and before `update.lock` is looked at. + let lock = take(&paths, || {}, || drop(installer.take())).unwrap(); + assert!(matches!(lock, AppLock::Held(_))); + assert!(is_held(&paths.app_lock()).unwrap()); + } } diff --git a/src/infrastructure/updates/macos.rs b/src/infrastructure/updates/macos.rs index bd0db0b..0bfe196 100644 --- a/src/infrastructure/updates/macos.rs +++ b/src/infrastructure/updates/macos.rs @@ -65,12 +65,21 @@ impl InstallTarget { /// The bundle holding this very binary, when it is the production app /// at a supported place, owned and replaceable by this account. pub fn current() -> Result { + let exe = std::env::current_exe() + .map_err(|error| TargetError::Unreadable("this binary".into(), error.to_string()))?; + Self::of(&exe) + } + + /// The bundle holding the `localify` at `cli`, on the same terms as + /// [`Self::current`]: the open app asks it of its own bundle's CLI, + /// which is what installs when it hands off. + pub fn of(cli: &Path) -> Result { if BRANDING.is_dev_profile() || cfg!(debug_assertions) { return Err(TargetError::DevBuild); } - let exe = std::env::current_exe() - .and_then(std::fs::canonicalize) - .map_err(|error| TargetError::Unreadable("this binary".into(), error.to_string()))?; + let exe = std::fs::canonicalize(cli).map_err(|error| { + TargetError::Unreadable(cli.display().to_string(), error.to_string()) + })?; let home = user_home_dir().unwrap_or_default(); let allowed = [ PathBuf::from("/Applications/Localify.app"), diff --git a/src/infrastructure/updates/mod.rs b/src/infrastructure/updates/mod.rs index 3f207ab..08ef0aa 100644 --- a/src/infrastructure/updates/mod.rs +++ b/src/infrastructure/updates/mod.rs @@ -132,7 +132,8 @@ pub(crate) fn random_u64() -> u64 { hasher.finish() } -/// `[updates] automatic`. A config that can't be read counts as off. +/// `[updates] automatic`. A config that is missing or can't be read counts +/// as off. pub fn automatic(config_path: &Path) -> bool { ConfigStore::new(config_path.to_path_buf()) .load() @@ -441,6 +442,21 @@ fn blocker() -> Option { Some(LINUX_REASON.to_string()) } +/// Why the bundle whose `localify` is `cli` can't be replaced, if it +/// can't: [`blocker`]'s rules, for another copy than this binary's. +#[allow(dead_code)] // Used by the desktop through the library crate. +pub fn blocker_of(cli: &Path) -> Option { + #[cfg(target_os = "macos")] + return macos::InstallTarget::of(cli) + .err() + .map(|error| error.to_string()); + #[cfg(not(target_os = "macos"))] + { + let _ = cli; + Some(LINUX_REASON.to_string()) + } +} + #[cfg(test)] mod tests { use super::release::Release; @@ -461,6 +477,15 @@ mod tests { parse_version(value).unwrap() } + #[test] + fn a_missing_or_unreadable_config_reads_automatic_updates_off() { + let dir = tempfile::tempdir().unwrap(); + assert!(!automatic(&dir.path().join("config.toml"))); + let broken = dir.path().join("broken.toml"); + std::fs::write(&broken, "[updates\nautomatic = true\n").unwrap(); + assert!(!automatic(&broken)); + } + #[test] fn a_newer_release_is_available_and_says_why_it_waits() { let status = describe(&v("1.6.0"), &checked("1.7.0"), false, None, Activity::Idle); From 6f6167e4948bee9a3402905c7223c6073a8ddb40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9o=20Goulois?= Date: Mon, 9 Nov 2026 20:49:11 +0100 Subject: [PATCH 9/9] fix(updates): count an old root helper as an earlier install, and restart 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. --- src-tauri/src/lib.rs | 23 ++++++++++++++++---- src/infrastructure/app_bridge.rs | 4 +++- src/infrastructure/config/mod.rs | 13 ++++++++---- src/infrastructure/macos_system.rs | 34 ++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1308517..e1533f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1127,15 +1127,27 @@ fn show_updating_message() -> Option { None } +/// Set on the process `restart_if_replaced` starts, so it starts at most once: +/// a bundle whose `Info.plist` names a newer version than its binary would +/// otherwise start itself forever. +#[cfg(target_os = "macos")] +const RESTARTED_ENV: &str = "LOCALIFY_RESTARTED_FOR_UPDATE"; + /// An install may have replaced the bundle this process started from: the -/// newer version installed now starts instead. Only a newer one, so a -/// bundle whose version isn't this binary's never starts itself again. +/// newer version installed now starts instead. Only a newer one, by semver +/// precedence, and only once. #[cfg(target_os = "macos")] fn restart_if_replaced() { use std::os::unix::process::CommandExt; use localify::infrastructure::updates::{macos::bundle_version, rules::parse_version}; + if std::env::var_os(RESTARTED_ENV).is_some() { + // SAFETY: this runs first thing in `run`, before the app starts any + // thread that reads the environment. + unsafe { std::env::remove_var(RESTARTED_ENV) }; + return; + } let Ok(exe) = std::env::current_exe() else { return; }; @@ -1152,10 +1164,13 @@ fn restart_if_replaced() { ) else { return; }; - if installed <= running { + if installed.cmp_precedence(&running) != std::cmp::Ordering::Greater { return; } - let error = Command::new(&exe).args(std::env::args_os().skip(1)).exec(); + let error = Command::new(&exe) + .args(std::env::args_os().skip(1)) + .env(RESTARTED_ENV, "1") + .exec(); eprintln!("could not start Localify {installed}: {error}"); } diff --git a/src/infrastructure/app_bridge.rs b/src/infrastructure/app_bridge.rs index e2cbf04..33da593 100644 --- a/src/infrastructure/app_bridge.rs +++ b/src/infrastructure/app_bridge.rs @@ -439,7 +439,9 @@ mod tests { let waited = started.elapsed(); assert!(matches!(handoff, Handoff::Unanswered(_)), "{handoff:?}"); assert!(waited >= ACK_TIMEOUT, "gave up after {waited:?}"); - assert!(waited < ACK_TIMEOUT + Duration::from_secs(2), "{waited:?}"); + // Bounded, not exact: a loaded test run delays the thread that gives + // up, so only a wait far past the timeout means it never gave up. + assert!(waited < ACK_TIMEOUT + Duration::from_secs(10), "{waited:?}"); assert_eq!( probe(&socket), AppPresence::Absent, diff --git a/src/infrastructure/config/mod.rs b/src/infrastructure/config/mod.rs index 43c2961..ed63b09 100644 --- a/src/infrastructure/config/mod.rs +++ b/src/infrastructure/config/mod.rs @@ -1789,9 +1789,10 @@ fn new_install_config(new_install: bool) -> Config { /// What an earlier Localify leaves on this machine: its root CA and /// certificates, the daemon's pid and log, the launchd service, the DNS -/// resolver (or systemd-resolved's drop-in), and the old `/etc` and -/// `/var/log` folders. Not the data dir or the app's folder: the app makes -/// them on its first start. +/// resolver (or systemd-resolved's drop-in), the old `/etc` and +/// `/var/log` folders, and on macOS the old root helper's plists, copies +/// and run files. Not the data dir or the app's folder: the app makes them +/// on its first start. pub fn previous_install_traces() -> Vec { let paths = LocalifyPaths::default(); let slug = BRANDING.slug(); @@ -1806,7 +1807,11 @@ pub fn previous_install_traces() -> Vec { PathBuf::from("/var/log").join(slug), ]; if cfg!(target_os = "macos") { - traces.push(crate::infrastructure::macos_system::service_plist_path()); + use crate::infrastructure::macos_system::{ + LegacyRoots, legacy_install_traces, service_plist_path, + }; + traces.push(service_plist_path()); + traces.extend(legacy_install_traces(&LegacyRoots::system())); } traces } diff --git a/src/infrastructure/macos_system.rs b/src/infrastructure/macos_system.rs index 0acf2de..8bf7dbf 100644 --- a/src/infrastructure/macos_system.rs +++ b/src/infrastructure/macos_system.rs @@ -326,6 +326,26 @@ impl LegacyInstall { } } +/// What an install from before the user daemon leaves, for telling a new +/// install from an old one: its helpers, their copies, and its run files. +/// A folder that can't be read for any reason but being absent counts as +/// one, so a doubt never reads as a new install. +pub fn legacy_install_traces(roots: &LegacyRoots) -> Vec { + let legacy = LegacyInstall::detect(roots); + let mut traces = Vec::new(); + traces.extend(legacy.helper_plists); + traces.extend(legacy.helper_copies); + traces.extend(legacy.run_files); + for dir in [&roots.launch_daemons, &roots.helper_tools, &roots.var_run] { + if let Err(error) = fs::read_dir(dir) + && error.kind() != std::io::ErrorKind::NotFound + { + traces.push(dir.clone()); + } + } + traces +} + fn entries_starting_with(dir: &Path, prefix: &str) -> Vec { let Ok(entries) = fs::read_dir(dir) else { return Vec::new(); @@ -1318,6 +1338,20 @@ mod tests { } } + #[test] + fn an_old_helper_left_alone_is_a_trace_of_an_earlier_install() { + let dir = tempfile::tempdir().unwrap(); + let roots = empty_roots(dir.path()); + assert!(legacy_install_traces(&roots).is_empty()); + + let helper = roots + .launch_daemons + .join(format!("{}.dns.plist", BRANDING.legacy_helper_prefix())); + fs::write(&helper, "").unwrap(); + + assert_eq!(legacy_install_traces(&roots), vec![helper]); + } + fn empty_roots(dir: &Path) -> LegacyRoots { let root = |name: &str| { let path = dir.join(name);