From fe67813a267628194a82173c63428e15f5159ba9 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:32:28 -0700 Subject: [PATCH 1/3] feat(updater): add native release updates with RC channels --- .github/workflows/apps.yml | 4 + .github/workflows/desktop.yml | 4 + .github/workflows/release-desktop.yml | 3 +- AGENTS.md | 1 + Cargo.lock | 25 + Cargo.toml | 4 + README.md | 2 + README.zh-CN.md | 2 + crates/shell/Cargo.toml | 5 +- crates/shell/src/apps.rs | 2 +- crates/shell/src/lib.rs | 6 + crates/shell/src/native_apps.rs | 29 + crates/updater-ui/Cargo.toml | 12 + crates/updater-ui/examples/updates.rs | 39 + crates/updater-ui/src/install.rs | 190 ++++ crates/updater-ui/src/lib.rs | 666 ++++++++++++ crates/updater/Cargo.toml | 17 + crates/updater/README.md | 25 + crates/updater/README.zh-CN.md | 25 + crates/updater/build.rs | 3 + crates/updater/examples/check.rs | 74 ++ crates/updater/src/lib.rs | 944 ++++++++++++++++++ crates/updater/src/tests.rs | 587 +++++++++++ desktop/Cargo.toml | 3 +- desktop/scripts/package.py | 77 +- desktop/scripts/test_package.py | 70 +- docs/updater.md | 136 +++ docs/updater.zh-CN.md | 115 +++ native-apps.json | 38 + phone/Cargo.toml | 3 +- .../android/AndroidManifest.xml.template | 9 + .../octosense/HomeUpdateInstallActivity.java | 145 +++ .../makepad/octosense/HomeUpdatePolicy.java | 104 ++ .../makepad/octosense/HomeUpdaterClient.java | 216 ++++ .../octosense/MakepadAppExtension.java | 4 + .../settings/controller/updates.octoscript | 2 + phone/resources/settings/settings.splash | 1 + phone/src/settings_app.rs | 13 +- phone/src/settings_host.rs | 10 +- phone/src/settings_script_bridge.rs | 2 +- phone/src/settings_script_domain_tests.rs | 13 + rom/scripts/build-home.py | 23 +- rom/tests/java/HomeUpdateInstallTest.java | 102 ++ rom/tests/java/HomeUpdatePolicyTest.java | 105 ++ .../android/app/Activity.java | 10 + .../android/app/ActivityOptions.java | 7 + .../android/app/PendingIntent.java | 6 + .../android/content/Context.java | 10 + .../android/content/Intent.java | 11 + .../android/content/IntentSender.java | 1 + .../android/content/SharedPreferences.java | 14 + .../android/content/pm/PackageInfo.java | 1 + .../android/content/pm/PackageInstaller.java | 10 + .../android/content/pm/PackageManager.java | 3 + .../home-updater-stubs/android/net/Uri.java | 1 + .../home-updater-stubs/android/os/Build.java | 1 + .../home-updater-stubs/android/os/Bundle.java | 5 + .../android/view/Window.java | 1 + rom/tests/test_home_app_updater.py | 111 ++ rom/tests/test_home_build.py | 20 + tools/test_native_apps.py | 2 +- 61 files changed, 4049 insertions(+), 25 deletions(-) create mode 100644 crates/updater-ui/Cargo.toml create mode 100644 crates/updater-ui/examples/updates.rs create mode 100644 crates/updater-ui/src/install.rs create mode 100644 crates/updater-ui/src/lib.rs create mode 100644 crates/updater/Cargo.toml create mode 100644 crates/updater/README.md create mode 100644 crates/updater/README.zh-CN.md create mode 100644 crates/updater/build.rs create mode 100644 crates/updater/examples/check.rs create mode 100644 crates/updater/src/lib.rs create mode 100644 crates/updater/src/tests.rs create mode 100644 docs/updater.md create mode 100644 docs/updater.zh-CN.md create mode 100644 phone/resources/android/java/dev/makepad/octosense/HomeUpdateInstallActivity.java create mode 100644 phone/resources/android/java/dev/makepad/octosense/HomeUpdatePolicy.java create mode 100644 phone/resources/android/java/dev/makepad/octosense/HomeUpdaterClient.java create mode 100644 rom/tests/java/HomeUpdateInstallTest.java create mode 100644 rom/tests/java/HomeUpdatePolicyTest.java create mode 100644 rom/tests/java/home-updater-stubs/android/app/Activity.java create mode 100644 rom/tests/java/home-updater-stubs/android/app/ActivityOptions.java create mode 100644 rom/tests/java/home-updater-stubs/android/app/PendingIntent.java create mode 100644 rom/tests/java/home-updater-stubs/android/content/Context.java create mode 100644 rom/tests/java/home-updater-stubs/android/content/Intent.java create mode 100644 rom/tests/java/home-updater-stubs/android/content/IntentSender.java create mode 100644 rom/tests/java/home-updater-stubs/android/content/SharedPreferences.java create mode 100644 rom/tests/java/home-updater-stubs/android/content/pm/PackageInfo.java create mode 100644 rom/tests/java/home-updater-stubs/android/content/pm/PackageInstaller.java create mode 100644 rom/tests/java/home-updater-stubs/android/content/pm/PackageManager.java create mode 100644 rom/tests/java/home-updater-stubs/android/net/Uri.java create mode 100644 rom/tests/java/home-updater-stubs/android/os/Build.java create mode 100644 rom/tests/java/home-updater-stubs/android/os/Bundle.java create mode 100644 rom/tests/java/home-updater-stubs/android/view/Window.java create mode 100644 rom/tests/test_home_app_updater.py diff --git a/.github/workflows/apps.yml b/.github/workflows/apps.yml index f9f34f7a2..af14587e9 100644 --- a/.github/workflows/apps.yml +++ b/.github/workflows/apps.yml @@ -72,6 +72,10 @@ jobs: run: | cargo test --locked -p octosense-kernel cargo clippy --locked -p octosense-kernel --all-targets --no-deps -- -D warnings + - name: OctoSense release updater + run: | + cargo test --locked -p octosense-updater + cargo clippy --locked -p octosense-updater --all-targets --no-deps -- -D warnings - name: App peers run: | cargo test --locked -p octosense-app-peers diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index e52e8f03f..f2fe014e3 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -86,6 +86,10 @@ jobs: python3 desktop/packaging/make_icons.py --check # The release packaging script (no build; release-desktop.yml builds). python3 -m unittest discover -s desktop/scripts -p 'test_package.py' + - name: In-app updater and installer handoff + run: | + cargo test --locked -p octosense-updater-ui --lib + cargo check --locked -p octosense-updater-ui --example updates - name: One Makepad, App Hub, octos and Rinx in the locked graph run: python3 tools/setup.py --check --cargo - name: The native apps' generated places match native-apps.json diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 009b865d3..6e038c785 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -238,7 +238,8 @@ jobs: # installer), plus what went into them: the binary, the kernel and # the staged resources. exe=octosense; [ "$RUNNER_OS" = Windows ] && exe=octosense.exe - python "$SRC/tools/release-scan.py" upload/packages/* resources kernel "$SRC/target/release/$exe" + target=$(python -c 'import json; print(json.load(open("receipt.json"))["target"])') + python "$SRC/tools/release-scan.py" upload/packages/* resources kernel "$SRC/target/$target/release/$exe" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: octosense-desktop-unsigned-${{ matrix.platform }} diff --git a/AGENTS.md b/AGENTS.md index 0c6c2a131..d467633bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ | AppCard (opt-in) | `apps/appcard/` | [apps/appcard/AGENTS.md](apps/appcard/AGENTS.md), `apps.yml` | | A native app (App Hub, Rinx, Terminal, Sheets, Reference, AppCard, and the Makepad apps made native: Calculator, Clock, Notes, Reminders, Weather, and Task, which runs only as its own process; and OctosCode, whose `kernel` port the coding scope holds, `crates/shell/src/coding_scope.rs`): its crate, pin, features, hosting per target, sandbox, storage, its tools, which of them the system agent may call (`agent.system_tools`) and which its own agent may (`agent.own_tools`) | `native-apps.json` only; `python3 tools/native_apps.py` writes the marked blocks in the `Cargo.toml`s, `crates/shell/src/native_apps.rs` and `Cargo.lock` | `python3 tools/native_apps.py --check`, `python3 -m unittest discover -s tools -p 'test_*.py'` | | An external pin (Makepad, OctoScript, App Hub, octos, Rinx) | root `Cargo.toml` `[workspace.dependencies]` (a native app's in `native-apps.json`), `native-runtime.lock.json`, `runtime-patches.lock.json` | `python3 tools/setup.py --update`, then `--check --cargo` and `python3 tools/native_apps.py --check` | +| OctoSense application updates (release channels, verified downloads, native installer handoff) | `crates/updater/`, `crates/updater-ui/`, Home's `HomeUpdaterClient.java`; [updater guide](docs/updater.md) | `cargo test --locked -p octosense-updater -p octosense-updater-ui`; `python3 -m unittest discover -s rom/tests -p 'test_home_app_updater.py'`; then both packagings' checks | | A decision | `docs/adr/` (next free number) | — | Start every session with `python3 tools/setup.py` (it prepares `.sources/`, and changes nothing that is already right). diff --git a/Cargo.lock b/Cargo.lock index bcdbfba57..63461625f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11894,6 +11894,7 @@ dependencies = [ "octosense-reference", "octosense-sheets-service", "octosense-sound-service", + "octosense-updater-ui", "octosense-vector-service", "octosense-wasm-host", "octosense-word-service", @@ -11949,6 +11950,30 @@ dependencies = [ "url", ] +[[package]] +name = "octosense-updater" +version = "0.1.0" +dependencies = [ + "reqwest 0.12.28", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "tokio", + "uuid", +] + +[[package]] +name = "octosense-updater-ui" +version = "0.1.0" +dependencies = [ + "makepad-app-module", + "makepad-widgets", + "octosense-updater", + "serde_json", +] + [[package]] name = "octosense-vector-service" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 303c7775f..7bbc28991 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ members = [ "crates/wasm-host", "crates/skill-gen", "crates/engine-area", + "crates/updater", + "crates/updater-ui", "apps/reference", "apps/news/host-service", "apps/sheets/host-service", @@ -86,6 +88,7 @@ authors = ["octos-app contributors"] # Reference and AppCard. A git source shares its repository's revision with # the pins below; tools/native_apps.py writes this block and checks that. # BEGIN native-apps: pins (generated by tools/native_apps.py from native-apps.json; do not edit) +octosense-updater-ui = { path = "crates/updater-ui" } rinx = { git = "https://github.com/hagency-org/Rinx.git", tag = "v1.1.0", default-features = false, features = ["octosense-module"] } octosense-reference = { path = "apps/reference" } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "32d6415fb7476345ad36ee4f98d6f844d1f07fd2", default-features = false } @@ -190,6 +193,7 @@ octosense-l0-chat = { path = "crates/l0-chat" } octosense-oauth-service = { path = "crates/oauth-service" } octosense-markdown-editor = { path = "crates/markdown-editor" } octosense-wasm-host = { path = "crates/wasm-host" } +octosense-updater = { path = "crates/updater" } # The craft engines' skill references (ADR 0013): the engine services' # tests generate and check `commands.md` and `safety.json` with it. octosense-skill-gen = { path = "crates/skill-gen" } diff --git a/README.md b/README.md index af04409e0..3f18be58b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ English | [简体中文](README.zh-CN.md) [Host OS API status](docs/host-os-api-status.md) tracks file, location and external-link support, existing services, remaining gaps and validation limits. +[OctoSense Updates](docs/updater.md) checks and downloads verified host releases, then opens the OS installation flow. It ships in builds containing this change; older releases need one manual upgrade first. + **Desktop download:** [OctoSense 0.1.0-rc.2](https://github.com/OctoSense-org/OctoSense/releases/tag/desktop-v0.1.0-rc.2) supports the current App Hub catalog, GitHub-attested app releases and the public host APIs declared by SDK 1.10.0. Choose the asset for your operating system and architecture; the release notes record prerequisites, signing status and platform validation. This desktop release does not update Android Home. Run the [live Mail → card → Calendar demo](docs/demos/mail-calendar/README.md) with your own mailbox: pinned source, selective policy, fictional test inputs, human draft edits and Calendar navigation. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8eb9075d1..7dffc09d5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,6 +6,8 @@ [宿主 OS API 状态](docs/host-os-api-status.zh-CN.md)记录文件、定位和外部链接支持,以及已有服务、剩余缺口和验证范围。 +[OctoSense Updates](docs/updater.zh-CN.md) 检查并下载经过校验的宿主发行版,再打开操作系统安装流程。此功能随本次变更引入;旧版本需要先手动升级一次。 + **桌面版下载**:[OctoSense 0.1.0-rc.2](https://github.com/OctoSense-org/OctoSense/releases/tag/desktop-v0.1.0-rc.2) 支持当前的 App Hub 签名目录、带 GitHub 证明的应用 Release 及 SDK 1.10.0 声明的公开宿主 API。请选择与操作系统和架构对应的文件;运行前置条件、签名状态和各平台验收范围见发行说明。此桌面版本不会更新 Android Home。 用自己的邮箱[复现真实邮件 → 卡片 → Calendar 演示](docs/demos/mail-calendar/README.zh-CN.md):固定源码版本、重要邮件策略、虚构测试输入、人工修改草稿和日历跳转。 diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 19838f13a..1326b286b 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -110,6 +110,7 @@ makepad-aichat = { workspace = true, optional = true } # service: no kernel, no credentials, no AppCard. App Hub's build packs the # system apps OCTOSENSE_SYSTEM_APPS names. # BEGIN native-apps: deps (generated by tools/native_apps.py from native-apps.json; do not edit) +octosense-updater-ui = { workspace = true, optional = true } rinx = { workspace = true, features = ["octosense-module"], optional = true } octosense-reference = { workspace = true, optional = true } makepad-sheets = { workspace = true, optional = true } @@ -132,6 +133,7 @@ keyring = { workspace = true, features = ["apple-native"] } # builds link these without flags. [target.'cfg(any(target_os = "android", target_os = "ios", target_env = "ohos"))'.dependencies] # BEGIN native-apps: mobile deps (generated by tools/native_apps.py from native-apps.json; do not edit) +octosense-updater-ui = { workspace = true } octosense-reference = { workspace = true } makepad-sheets = { workspace = true } octosense-app-hub-app = { workspace = true } @@ -168,7 +170,8 @@ octos-core = ["octosense-ai-host/octos-core"] # assistant is the shell's (they imply `octos-core`); App Hub brings the host # services its system apps call. AppCard is opt-in only, on every target. # BEGIN native-apps: features (generated by tools/native_apps.py from native-apps.json; do not edit) -mobile-apps = ["app-rinx", "app-reference", "app-sheets", "app-hub", "app-calculator", "app-clock", "app-notes", "app-reminders", "app-weather", "app-octoscode", "octos-core", "wasm-functions"] +mobile-apps = ["app-updater", "app-rinx", "app-reference", "app-sheets", "app-hub", "app-calculator", "app-clock", "app-notes", "app-reminders", "app-weather", "app-octoscode", "octos-core", "wasm-functions"] +app-updater = ["dep:octosense-updater-ui"] app-rinx = ["dep:rinx", "octos-core"] app-reference = ["dep:octosense-reference"] app-sheets = ["dep:makepad-sheets"] diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 6b41d5ea6..0853c7e8b 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -799,7 +799,7 @@ impl AppRegistry { return if self.module(id).is_some() { Hosting::Module } else { Hosting::Process }; } // Neither the store nor Settings has a process form. - if matches!(id, "apphub" | "settings") && self.module(id).is_some() { + if matches!(id, "apphub" | "settings" | "updater") && self.module(id).is_some() { return Hosting::Module; } if self.modules.iter().any(|m| m.id() == id) && !self.overrides.contains_key(id) { diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index e468154d4..536ae0a70 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -6009,6 +6009,8 @@ impl MatchEvent for App { // the one path a style switch uses. self.apply_material_to_chrome(cx, material, None); self.next_id = 1; + #[cfg(any(feature = "app-updater", native_mobile))] + octosense_updater_ui::configure(crate::octosense::paths::home().join("updates")); // The hosting registry: the linked modules, the person's overrides // in ~/.makepad/wm/apps.splash, a dev run's `--module ` flags. let args: Vec = std::env::args().collect(); @@ -6423,6 +6425,10 @@ impl App { } pub fn shell_handle_event(&mut self, cx: &mut Cx, event: &Event) { + #[cfg(any(feature = "app-updater", native_mobile))] + if let Event::AndroidIntegration { channel, payload } = event { + if channel == "home_updater.result" { octosense_updater_ui::android_result(payload); } + } #[cfg(any(feature = "app-hub", native_mobile))] self.audio_session_event(cx, event); #[cfg(any(feature = "app-hub", native_mobile))] diff --git a/crates/shell/src/native_apps.rs b/crates/shell/src/native_apps.rs index 2412ad77b..453351cd0 100644 --- a/crates/shell/src/native_apps.rs +++ b/crates/shell/src/native_apps.rs @@ -114,6 +114,33 @@ pub struct NativeApp { } pub const APPS: &[NativeApp] = &[ + NativeApp { + id: "updater", + feature: "app-updater", + bin: None, + macos: Hosting::Module, + windows: Hosting::Module, + linux: Hosting::Module, + android: Hosting::Module, + ios: Hosting::Module, + ohos: Hosting::Module, + wasm: Hosting::Module, + octos: &[], + tools: &[], + network: Network::Any, + processes: false, + accounts: false, + external: &[], + storage: r#"{"accounts": false, "agent_workspace": "none", "external": []}"#, + tools_json: r##"[]"##, + generic_tools: &[], + grants: &[], + system_tools: &[], + own_tools: &[], + calls_per_turn: None, + calls_per_day: None, + kernel: KernelPort::None, + }, NativeApp { id: "rinx", feature: "app-rinx", @@ -523,6 +550,8 @@ pub fn package_of(id: &str) -> Option<&'static str> { /// Push the native modules this build links, in manifest order. #[allow(unused_variables)] pub fn link(out: &mut Vec<&'static dyn AppModule>) { + #[cfg(any(feature = "app-updater", native_mobile))] + out.push(&octosense_updater_ui::UPDATER_MODULE); #[cfg(feature = "app-rinx")] out.push(&rinx::module::RINX_MODULE); #[cfg(any(feature = "app-reference", native_mobile))] diff --git a/crates/updater-ui/Cargo.toml b/crates/updater-ui/Cargo.toml new file mode 100644 index 000000000..283885643 --- /dev/null +++ b/crates/updater-ui/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "octosense-updater-ui" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +publish = false + +[dependencies] +makepad-widgets = { workspace = true } +makepad-app-module = { workspace = true } +octosense-updater = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/updater-ui/examples/updates.rs b/crates/updater-ui/examples/updates.rs new file mode 100644 index 000000000..f8b8d8ec9 --- /dev/null +++ b/crates/updater-ui/examples/updates.rs @@ -0,0 +1,39 @@ +//! Isolated, app-owned instrument surface. This example never configures a personal profile. +use makepad_widgets::*; +use octosense_updater_ui as updater; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + startup() do #(UpdateTestApp::script_component(vm)) { + ui: Root { + main_window := Window { + window.inner_size: vec2(520, 800) + body +: { updater := UpdaterView {} } + } + } + } +} + +#[derive(Script, ScriptHook)] +struct UpdateTestApp { + #[live] + ui: WidgetRef, +} +impl MatchEvent for UpdateTestApp {} +impl AppMain for UpdateTestApp { + fn script_mod(vm: &mut ScriptVm) -> ScriptValue { + let cache = std::env::var_os("OCTOSENSE_UPDATER_TEST_CACHE") + .map(std::path::PathBuf::from) + .expect("Set OCTOSENSE_UPDATER_TEST_CACHE to an isolated absolute directory"); + updater::configure(cache); + makepad_widgets::script_mod(vm); + updater::script_mod(vm); + self::script_mod(vm) + } + fn handle_event(&mut self, cx: &mut Cx, event: &Event) { + self.match_event(cx, event); + self.ui.handle_event(cx, event, &mut Scope::empty()); + } +} +app_main!(UpdateTestApp); diff --git a/crates/updater-ui/src/install.rs b/crates/updater-ui/src/install.rs new file mode 100644 index 000000000..3e754f0a5 --- /dev/null +++ b/crates/updater-ui/src/install.rs @@ -0,0 +1,190 @@ +//! Handoff to the operating system after download verification. This module +//! never runs a shell, replaces the running app, or reports that an opened +//! installer has completed installation. + +use octosense_updater::{Cancellation, Platform, VerifiedArtifact}; +use std::{ + ffi::OsString, + path::Path, + process::{Child, Command}, + time::{Duration, Instant}, +}; + +#[derive(Debug, PartialEq, Eq)] +struct InstallPlan { + program: OsString, + args: Vec, + // A Windows installer stays alive while the user reviews its prompts. + wait: bool, + message: &'static str, +} + +fn plan(platform: Platform, path: &Path) -> Result { + if !path.is_absolute() { + return Err("The verified update must have an absolute file path.".into()); + } + let has_extension = |expected: &str| { + path.extension().and_then(|extension| extension.to_str()) == Some(expected) + }; + match platform { + Platform::MacArm64 if has_extension("dmg") => Ok(InstallPlan { + program: "/usr/bin/open".into(), + args: vec![path.as_os_str().into()], + wait: true, + message: "The verified disk image was handed to macOS. Quit OctoSense, then drag OctoSense to Applications and confirm replacement. Your app data stays in its existing profile.", + }), + Platform::WindowsX64 if has_extension("exe") => Ok(InstallPlan { + program: path.as_os_str().into(), + args: vec![], + wait: false, + message: "The verified installer is open. Follow its prompts and close OctoSense when requested. Installation is not complete until the installer finishes.", + }), + Platform::LinuxX64Deb if has_extension("deb") => Ok(InstallPlan { + program: "xdg-open".into(), + args: vec![path.as_os_str().into()], + wait: true, + message: "The verified package was handed to your desktop's package installer. Review and install it there, then restart OctoSense. If no installer opens, use your distribution's package manager on the downloaded file.", + }), + Platform::LinuxX64AppImage if has_extension("AppImage") => Ok(InstallPlan { + program: "xdg-open".into(), + args: vec![path.parent().ok_or("The update folder is unavailable.")?.as_os_str().into()], + wait: true, + message: "The folder containing the verified AppImage is open. Quit OctoSense, replace your old AppImage with this file, allow execution in its file properties, and reopen it. The running app has not been replaced.", + }), + Platform::AndroidArm64 => Err("Android updates use the host's package installer.".into()), + _ => Err("This file is not an installer for the selected platform.".into()), + } +} + +fn host_supports(platform: Platform) -> bool { + match platform { + Platform::MacArm64 => cfg!(all(target_os = "macos", target_arch = "aarch64")), + Platform::WindowsX64 => cfg!(all(target_os = "windows", target_arch = "x86_64")), + Platform::LinuxX64Deb | Platform::LinuxX64AppImage => { + cfg!(all(target_os = "linux", target_arch = "x86_64")) + } + Platform::AndroidArm64 => false, + } +} + +/// Called only after a person requests installation, on the updater worker. +/// Recheck the full downloaded file immediately before opening the installer. +pub fn install(artifact: &VerifiedArtifact, cancellation: &Cancellation) -> Result { + if !host_supports(artifact.platform()) { + return Err("This update cannot be installed on the running platform.".into()); + } + let path = artifact.revalidate().map_err(|error| error.to_string())?; + let plan = plan(artifact.platform(), &path)?; + // Hashing a large installer takes time. A close/cancel during verification + // must not leave a deferred OS installer prompt behind. + if cancellation.is_cancelled() { + return Err("Update installation was cancelled before opening the installer.".into()); + } + let child = Command::new(&plan.program) + .args(&plan.args) + .spawn() + .map_err(|error| format!("Could not open the verified installer: {error}"))?; + observe_handoff( + child, + if plan.wait { + Duration::from_secs(2) + } else { + Duration::ZERO + }, + )?; + Ok(plan.message.into()) +} + +// Some desktop handlers keep their launcher alive until the installer closes. +// Once spawned, the OS owns the interaction: preserve its source and release +// the updater worker promptly, rather than holding global BUSY indefinitely. +fn observe_handoff(mut child: Child, maximum: Duration) -> Result<(), String> { + let deadline = Instant::now() + maximum; + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) => return Err(format!("The operating system could not open the verified update ({status}). The file remains downloaded.")), + Ok(None) => {} + Err(_) => break, + } + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn long_lived_handler_does_not_hold_the_updater_worker() { + let child = Command::new("/bin/sleep").arg("1").spawn().unwrap(); + let started = Instant::now(); + observe_handoff(child, Duration::from_millis(20)).unwrap(); + assert!(started.elapsed() < Duration::from_millis(750)); + } + + #[cfg(unix)] + #[test] + fn immediate_handler_failure_is_reported() { + let child = Command::new("/usr/bin/false").spawn().unwrap(); + assert!(observe_handoff(child, Duration::from_secs(1)).is_err()); + } + + #[test] + fn mac_disk_image_is_one_literal_argument() { + let path = std::env::temp_dir().join("OctoSense $(touch bad); quoted ' name.dmg"); + let command = plan(Platform::MacArm64, &path).unwrap(); + assert_eq!(command.program, "/usr/bin/open"); + assert_eq!(command.args, vec![path.as_os_str()]); + assert!(command.wait); + } + + #[test] + fn windows_setup_is_executed_directly_without_a_shell() { + // An absolute path for the platform running this test. Command gets + // the verified file as its program, never `cmd /c` or `start`. + let path = std::env::temp_dir().join("setup with & special characters.exe"); + let command = plan(Platform::WindowsX64, &path).unwrap(); + assert_eq!(command.program, path.as_os_str()); + assert!(command.args.is_empty()); + assert!(!command.wait); + } + + #[test] + fn linux_handoffs_do_not_execute_the_download_or_replace_the_running_file() { + let directory = std::env::temp_dir(); + let deb_path = directory.join("new.deb"); + let deb = plan(Platform::LinuxX64Deb, &deb_path).unwrap(); + assert_eq!(deb.program, "xdg-open"); + assert_eq!(deb.args, vec![deb_path.as_os_str()]); + let image_path = directory.join("new.AppImage"); + let image = plan(Platform::LinuxX64AppImage, &image_path).unwrap(); + assert_eq!(image.program, "xdg-open"); + assert_eq!(image.args, vec![image_path.parent().unwrap().as_os_str()]); + } + + #[test] + fn wrong_extensions_relative_paths_and_android_are_refused() { + assert!(plan(Platform::MacArm64, Path::new("relative.dmg")).is_err()); + for (platform, filename) in [ + (Platform::MacArm64, "setup.exe"), + (Platform::WindowsX64, "setup.cmd"), + (Platform::LinuxX64Deb, "new.AppImage"), + (Platform::LinuxX64AppImage, "new.deb"), + (Platform::AndroidArm64, "home.apk"), + ] { + assert!( + plan(platform, &std::env::temp_dir().join(filename)).is_err(), + "{filename}" + ); + } + } +} diff --git a/crates/updater-ui/src/lib.rs b/crates/updater-ui/src/lib.rs new file mode 100644 index 000000000..7b33efe96 --- /dev/null +++ b/crates/updater-ui/src/lib.rs @@ -0,0 +1,666 @@ +//! Host-owned release UI. No agent tools or contained-app service can install an update. +use makepad_app_module::{ + makepad_ai_services::wire::{ServiceCall, ServiceManifest, ToolResult}, + AppModule, ExecOutcome, InstanceHandles, InstanceParts, OpenSchema, ServiceExecutor, + ValidatedOpen, +}; +pub use makepad_widgets; +use makepad_widgets::*; +use octosense_updater::{ + BuildIdentity, Cancellation, Channel, CheckResult, CheckStatus, Client, Offer, Platform, + Product, VerifiedArtifact, +}; +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, Mutex, OnceLock, + }, +}; + +mod install; + +static CACHE: OnceLock = OnceLock::new(); +static BUSY: AtomicBool = AtomicBool::new(false); +static REQUEST: AtomicU64 = AtomicU64::new(1); +static ANDROID: Mutex> = Mutex::new(Vec::new()); + +/// The shell supplies a host-private directory, outside every app's storage jail. +pub fn configure(cache: PathBuf) { + let _ = CACHE.set(cache); +} + +/// Android integration packets are consumed by the shell before module dispatch. +pub fn android_result(payload: &str) { + if payload.len() > 8192 { + return; + } + if let Ok(value) = serde_json::from_str::(payload) { + let mut pending = ANDROID.lock().unwrap_or_else(|e| e.into_inner()); + if pending.len() == 32 { + pending.remove(0); + } + pending.push(value); + makepad_widgets::makepad_platform::thread::SignalToUI::set_ui_signal(); + } +} + +script_mod! { + use mod.prelude.widgets.* + mod.widgets.UpdaterView = set_type_default() do #(UpdaterView::register_widget(vm)) { + ..mod.widgets.RectView + width: Fill height: Fill + draw_bg.color: theme.color_bg_app + flow: Down padding: 24 spacing: 18 + title := Label { text: "OctoSense Updates" draw_text.text_style.font_size: 25 } + current := Label { width: Fill text: "Reading this installation…" draw_text.wrap: Words } + ScrollYView { + width: Fill height: Fill + body := View { + width: Fill height: Fit flow: Down spacing: 16 + channel := Button { height: 44 text: "Channel: Preview" } + source := Label { width: Fill text: "Official releases from OctoSense-org / OctoSense on GitHub." draw_text.wrap: Words } + status := Label { width: Fill text: "Check for an update when you are ready." draw_text.wrap: Words } + offer := Label { width: Fill text: "" draw_text.wrap: Words } + check := Button { width: Fill height: 44 text: "Check for updates" } + download := Button { width: Fill height: 44 text: "Download update" visible: false } + cancel := Button { width: Fill height: 44 text: "Cancel download" visible: false } + install := Button { width: Fill height: 44 text: "Open installer" visible: false } + permission := Button { width: Fill height: 44 text: "Allow updates in Android Settings" visible: false } + retry_install := Button { width: Fill height: 44 text: "Refresh installation status" visible: false } + notes := Button { width: Fill height: 44 text: "Release notes" visible: false } + detail := Label { width: Fill text: "Updates are downloaded and verified before you choose to install. Save your work before replacing or restarting OctoSense." draw_text.wrap: Words } + } + } + } +} + +enum Completed { + Checked(CheckResult), + Downloaded(VerifiedArtifact), + Opened(String), + AndroidReady(PathBuf, String), +} +enum WorkerEvent { + Progress(u64, u64), + Finished(Box>), +} +struct Worker { + receive: mpsc::Receiver, + cancel: Cancellation, +} +impl Drop for Worker { + fn drop(&mut self) { + self.cancel.cancel(); + } +} + +fn worker( + run: impl FnOnce(&Cancellation, &mpsc::SyncSender) -> Result + + Send + + 'static, +) -> Result { + if BUSY + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err("Another update operation is still finishing. Try again shortly.".into()); + } + let (send, receive) = mpsc::sync_channel(16); + let cancel = Cancellation::default(); + let token = cancel.clone(); + let spawned = std::thread::Builder::new() + .name("octosense-updater".into()) + .spawn(move || { + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&token, &send))) + .unwrap_or_else(|_| { + Err("Update operation stopped unexpectedly. You can retry.".into()) + }); + BUSY.store(false, Ordering::Release); + if let Err(mpsc::SendError(WorkerEvent::Finished(result))) = + send.send(WorkerEvent::Finished(Box::new(result))) + { + // The window closed just as a download finished. No view owns + // this unused artifact, so do not leave it in private storage. + if let Ok(Completed::Downloaded(artifact)) = *result { + let _ = artifact.discard(); + } + } + makepad_widgets::makepad_platform::thread::SignalToUI::set_ui_signal(); + }); + if let Err(error) = spawned { + BUSY.store(false, Ordering::Release); + return Err(format!("Could not start update worker: {error}")); + } + Ok(Worker { receive, cancel }) +} + +#[derive(Script, ScriptHook, Widget)] +pub struct UpdaterView { + #[deref] + view: View, + #[rust] + initialized: bool, + #[rust] + channel: usize, + #[rust] + identity: Option, + #[rust] + cache: Option, + #[rust] + offer: Option, + #[rust] + artifact: Option, + #[rust] + task: Option, + #[rust] + message: String, + #[rust] + android_request: u64, + #[rust] + android_standalone: bool, + #[rust] + android_can_install: bool, + #[rust] + android_install_pending: bool, + #[rust] + install_opened: bool, +} + +impl UpdaterView { + fn product() -> Product { + if cfg!(target_os = "android") { + Product::Home + } else { + Product::Desktop + } + } + + fn android_command(&mut self, cx: &mut Cx, operation: &str, mut value: serde_json::Value) { + let id = REQUEST.fetch_add(1, Ordering::Relaxed); + self.android_request = id; + value["id"] = id.into(); + value["operation"] = operation.into(); + cx.android_integration("home_updater.command", &value.to_string()); + } + + fn initialize(&mut self, cx: &mut Cx) { + if self.initialized { + return; + } + self.initialized = true; + let identity = BuildIdentity::from_env(Self::product()); + self.channel = match identity.default_channel() { + Channel::Stable => 0, + Channel::ReleaseCandidate => 1, + Channel::Preview => 2, + }; + self.view.label(cx, ids!(current)).set_text( + cx, + &format!( + "Installed: {}", + identity + .version() + .map(ToString::to_string) + .unwrap_or_else(|| "Development build (no release version)".into()) + ), + ); + self.identity = Some(identity); + self.cache = CACHE.get().cloned(); + self.message = "Check for an update when you are ready.".into(); + if cfg!(target_os = "android") { + self.message = "Checking this Android installation…".into(); + self.android_command(cx, "update_info", serde_json::json!({})); + } else if Platform::current(Self::product()).is_none() { + self.message = "In-app updates are not available for this platform yet.".into(); + } + self.refresh(cx); + } + + fn refresh(&mut self, cx: &mut Cx) { + let busy = self.task.is_some(); + let supported = Platform::current(Self::product()).is_some() + && self.cache.is_some() + && (!cfg!(target_os = "android") || self.android_standalone); + self.view + .label(cx, ids!(status)) + .set_text(cx, &self.message); + self.view.button(cx, ids!(channel)).set_text( + cx, + match self.channel { + 0 => "Channel: Stable", + 1 => "Channel: Release candidate", + _ => "Channel: Preview (all test releases)", + }, + ); + self.view + .button(cx, ids!(channel)) + .set_enabled(cx, !busy && !self.android_install_pending); + self.view + .button(cx, ids!(check)) + .set_enabled(cx, supported && !busy && !self.android_install_pending); + self.view + .button(cx, ids!(download)) + .set_visible(cx, self.offer.is_some() && self.artifact.is_none() && !busy); + self.view.button(cx, ids!(cancel)).set_visible(cx, busy); + self.view + .button(cx, ids!(cancel)) + .set_enabled(cx, busy && !self.android_install_pending); + self.view.button(cx, ids!(install)).set_visible( + cx, + self.artifact.is_some() && !busy && !self.android_install_pending, + ); + self.view.button(cx, ids!(install)).set_text( + cx, + if cfg!(target_os = "android") { + "Review and install update" + } else { + "Open verified installer" + }, + ); + self.view.button(cx, ids!(permission)).set_visible( + cx, + cfg!(target_os = "android") + && self.artifact.is_some() + && !self.android_can_install + && !busy + && !self.android_install_pending, + ); + self.view.button(cx, ids!(retry_install)).set_visible( + cx, + cfg!(target_os = "android") + && (self.android_install_pending || self.artifact.is_some()), + ); + self.view + .button(cx, ids!(notes)) + .set_visible(cx, self.offer.is_some()); + let description = self + .offer + .as_ref() + .map(|offer| { + format!( + "OctoSense {}\n{} · {:.1} MB", + offer.version(), + offer.asset_name(), + offer.size() as f64 / 1_000_000.0 + ) + }) + .unwrap_or_default(); + self.view.label(cx, ids!(offer)).set_text(cx, &description); + self.view.redraw(cx); + } + + fn start(&mut self, task: Result) { + match task { + Ok(task) => self.task = Some(task), + Err(error) => self.message = error, + } + } + + fn retire_offer(&mut self) { + if let Some(artifact) = self.artifact.take() { + // A mounted DMG or installer may still need its source file. + if !self.install_opened { + let _ = artifact.discard(); + } + } + self.offer = None; + self.install_opened = false; + } + + fn check(&mut self) { + if self.task.is_some() || self.android_install_pending { + return; + } + let Some(cache) = self.cache.clone() else { + return; + }; + let Some(platform) = Platform::current(Self::product()) else { + return; + }; + if cfg!(target_os = "android") && !self.android_standalone { + return; + } + let Some(identity) = self.identity.clone() else { + return; + }; + self.retire_offer(); + let channel = match self.channel { + 0 => Channel::Stable, + 1 => Channel::ReleaseCandidate, + _ => Channel::Preview, + }; + self.message = "Checking official releases…".into(); + self.start(worker(move |cancel, _| { + let client = Client::new(cache).map_err(|e| e.to_string())?; + client + .check(&identity, channel, platform, cancel) + .map(Completed::Checked) + .map_err(|e| e.to_string()) + })); + } + + fn download(&mut self) { + if self.task.is_some() { + return; + } + let (Some(cache), Some(offer)) = (self.cache.clone(), self.offer.clone()) else { + return; + }; + self.message = "Downloading update…".into(); + self.start(worker(move |cancel, send| { + let client = Client::new(cache).map_err(|e| e.to_string())?; + client + .download(&offer, cancel, |progress| { + let _ = send.try_send(WorkerEvent::Progress(progress.received, progress.total)); + makepad_widgets::makepad_platform::thread::SignalToUI::set_ui_signal(); + }) + .map(Completed::Downloaded) + .map_err(|e| e.to_string()) + })); + } + + fn install(&mut self) { + if self.task.is_some() || self.android_install_pending { + return; + } + let Some(artifact) = self.artifact.clone() else { + return; + }; + // Android's PackageInstaller requires its own human confirmation as well. + if cfg!(target_os = "android") && !makepad_widgets::makepad_platform::trusted_user_input() { + self.message = "Tap Install directly on the phone to review this update.".into(); + return; + } + self.message = "Rechecking the downloaded file…".into(); + self.start(worker(move |cancel, _| { + if cfg!(target_os = "android") { + let path = artifact.revalidate().map_err(|e| e.to_string())?; + if cancel.is_cancelled() { + return Err("Update cancelled.".into()); + } + Ok(Completed::AndroidReady(path, artifact.sha256().to_owned())) + } else { + install::install(&artifact, cancel).map(Completed::Opened) + } + })); + // Reserve the source while handoff is running: closing the view may + // race with `open` successfully mounting it before its result arrives. + // Retaining a verified cache file is safer than deleting a source the + // operating system is already using. + if self.task.is_some() { + self.install_opened = true; + } + } + + fn poll(&mut self, cx: &mut Cx) { + let events: Vec<_> = self + .task + .as_ref() + .map(|task| task.receive.try_iter().collect()) + .unwrap_or_default(); + let changed = !events.is_empty(); + for event in events { + match event { + WorkerEvent::Progress(received, total) => { + self.message = format!( + "Downloading: {:.1} / {:.1} MB", + received as f64 / 1_000_000.0, + total as f64 / 1_000_000.0 + ) + } + WorkerEvent::Finished(result) => { + let result = *result; + let cancelled = self + .task + .as_ref() + .is_some_and(|task| task.cancel.is_cancelled()); + self.task = None; + if cancelled && !matches!(&result, Ok(Completed::Opened(_))) { + if let Ok(Completed::Downloaded(artifact)) = result { + let _ = artifact.discard(); + } + self.message = "Update cancelled; no installer was opened.".into(); + self.install_opened = false; + continue; + } + match result { + Ok(Completed::Checked(result)) => { + self.message = match result.status { + CheckStatus::Available => "An update is available. Download it to continue.", + CheckStatus::Current => "You are up to date on this channel.", + CheckStatus::Development => "This is a development build. Install a release build to enable versioned updates.", + CheckStatus::Unavailable => "No compatible release is published on this channel yet.", + }.into(); + self.offer = if result.status == CheckStatus::Available { + result.offer + } else { + None + }; + } + Ok(Completed::Downloaded(artifact)) => { + self.artifact = Some(artifact); + self.message = if cfg!(target_os = "android") { + "Download verified. Save your work, then choose Review and install update. Android will ask you to confirm." + } else { + "Download verified. Save your work, then choose Open verified installer. Installation may restart OctoSense." + }.into(); + } + Ok(Completed::Opened(message)) => { + self.message = message; + self.install_opened = true; + } + Ok(Completed::AndroidReady(path, digest)) => { + self.android_install_pending = true; + self.install_opened = true; + self.message = + "Waiting for Android to verify and confirm installation…".into(); + self.android_command( + cx, + "update_install", + serde_json::json!({"path":path,"sha256":digest}), + ); + } + Err(error) => { + self.install_opened = false; + self.message = error; + } + } + } + } + } + if changed { + self.refresh(cx); + } + let packets = { + let mut pending = ANDROID.lock().unwrap_or_else(|e| e.into_inner()); + let mut found = Vec::new(); + pending.retain(|value| { + if value["id"].as_u64() == Some(self.android_request) { + found.push(value.clone()); + false + } else { + true + } + }); + found + }; + for value in packets { + if let Some(allowed) = value["can_install"].as_bool() { + self.android_can_install = allowed; + } + let status = value["status"].as_str().unwrap_or("error"); + if value["operation"] == "update_info" && status == "ok" { + self.cache = value["cache_dir"].as_str().map(PathBuf::from); + self.android_standalone = value["standalone"].as_bool().unwrap_or(false); + self.android_can_install = value["can_install"].as_bool().unwrap_or(false); + self.view.label(cx, ids!(current)).set_text( + cx, + &format!( + "Installed: OctoSense Home {}", + value["version_name"].as_str().unwrap_or("unknown") + ), + ); + self.message = if let Some(reason) = + value["message"].as_str().filter(|text| !text.is_empty()) + { + reason.chars().take(1024).collect() + } else if self.android_standalone { + "Check for an update when you are ready.".into() + } else { + "This installation uses a different signing identity. Use Settings → Updates for ROM updates, or obtain a compatible build from your distributor. Your installed Home and data will be kept.".into() + }; + if self.android_standalone { + // An OS installation can outlive this window or replace the + // process. Recover its durable status before offering work. + self.android_install_pending = true; + self.android_command(cx, "update_status", serde_json::json!({})); + } + } else { + self.message = value["message"] + .as_str() + .filter(|s| !s.is_empty()) + .unwrap_or(if status == "idle" { + "Check for an update when you are ready." + } else { + status + }) + .chars() + .take(1024) + .collect(); + self.android_install_pending = + matches!(status, "preparing" | "awaiting_confirmation"); + if status == "permission_required" { + self.android_can_install = false; + } + } + self.refresh(cx); + } + } +} + +impl Widget for UpdaterView { + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.initialize(cx); + self.poll(cx); + self.view.draw_walk(cx, scope, walk) + } + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.initialize(cx); + self.poll(cx); + self.view.handle_event(cx, event, scope); + if let Event::Actions(actions) = event { + let changed = [ + ids!(channel), + ids!(check), + ids!(download), + ids!(cancel), + ids!(install), + ids!(notes), + ids!(permission), + ids!(retry_install), + ] + .iter() + .any(|id| self.view.button(cx, *id).clicked(actions)); + if !changed { + return; + } + if self.view.button(cx, ids!(channel)).clicked(actions) + && self.task.is_none() + && !self.android_install_pending + { + self.channel = (self.channel + 1) % 3; + self.retire_offer(); + self.message = "Channel changed. Check for updates to continue.".into(); + } + if self.view.button(cx, ids!(check)).clicked(actions) { + self.check(); + } + if self.view.button(cx, ids!(download)).clicked(actions) { + self.download(); + } + if self.view.button(cx, ids!(cancel)).clicked(actions) { + if let Some(task) = &self.task { + task.cancel.cancel(); + self.message = "Cancelling…".into(); + } + } + if self.view.button(cx, ids!(install)).clicked(actions) { + self.install(); + } + if self.view.button(cx, ids!(notes)).clicked(actions) { + if let Some(offer) = &self.offer { + cx.open_url(&offer.release_url(), OpenUrlInPlace::No); + } + } + if self.view.button(cx, ids!(permission)).clicked(actions) + && makepad_widgets::makepad_platform::trusted_user_input() + { + self.android_command(cx, "update_allow_install", serde_json::json!({})); + } + if self.view.button(cx, ids!(retry_install)).clicked(actions) { + self.android_command(cx, "update_status", serde_json::json!({})); + } + self.refresh(cx); + } + } +} + +pub struct UpdaterModule; +pub static UPDATER_MODULE: UpdaterModule = UpdaterModule; +impl AppModule for UpdaterModule { + fn id(&self) -> &'static str { + "updater" + } + fn label(&self) -> &'static str { + "OctoSense Updates" + } + fn register(&self, vm: &mut ScriptVm) { + script_mod(vm); + } + fn open_schema(&self) -> OpenSchema { + OpenSchema::new(1) + } + fn capabilities(&self) -> &'static [&'static str] { + &[] + } + fn create(&self, vm: &mut ScriptVm, _: ValidatedOpen, _: InstanceHandles) -> InstanceParts { + let value = script_eval!(vm, { use mod.widgets.* UpdaterView {} }); + let root = WidgetRef::script_from_value(vm, value); + let closing = root.clone(); + InstanceParts { + root, + executor: Box::new(NoTools), + shutdown: Box::new(move |vm| { + if let Some(mut view) = closing.borrow_mut::() { + if let Some(task) = view.task.take() { + task.cancel.cancel(); + } + view.retire_offer(); + if cfg!(target_os = "android") { + // Cancel queued Settings openings as well as APK prep. + // A session already handed to Android is preserved. + view.android_command(vm.cx_mut(), "update_cancel", serde_json::json!({})); + } + } + }), + } + } +} +struct NoTools; +impl ServiceExecutor for NoTools { + fn manifest(&self) -> ServiceManifest { + ServiceManifest::new( + "updater", + "OctoSense Updates", + "Install reviewed OctoSense releases. No agent tools.", + ) + } + fn execute(&mut self, _: &mut Cx, call: &ServiceCall) -> ExecOutcome { + ExecOutcome::Done(ToolResult::unavailable( + &call.call_id, + "Updates can only be requested in the host-owned Updates window", + )) + } +} diff --git a/crates/updater/Cargo.toml b/crates/updater/Cargo.toml new file mode 100644 index 000000000..8c016bf5f --- /dev/null +++ b/crates/updater/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "octosense-updater" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +sha2 = "0.10" +semver = "1" +uuid.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/updater/README.md b/crates/updater/README.md new file mode 100644 index 000000000..088659492 --- /dev/null +++ b/crates/updater/README.md @@ -0,0 +1,25 @@ +# OctoSense updater core + +English | [简体中文](README.zh-CN.md) + +`octosense-updater` checks OctoSense's public GitHub releases and downloads a verified installer into host-owned private storage. It does not install, run a shell command, replace a running executable, or expose a service to app agents. The shell must ask the person to open the native installer after download. + +`Client::check` and `Client::download` are synchronous worker-thread entry points. Network I/O uses a private Tokio runtime so cancellation can interrupt a stalled request; UI events must never call these methods directly. Cloned clients share a busy guard. Failures and cancellation release it, remove that attempt's partial file, and permit Retry. Cancellation does not interrupt a subsequent native installer that the person has opened. + +Release identity comes from `OCTOSENSE_RELEASE_TAG` baked by the packaging job, such as `desktop-v0.1.0-rc.2` or `home-v0.1.0-beta.2`. An unset, empty or invalid baked tag is a development build, not a fabricated installed version. `BuildIdentity::default_channel` uses Stable for stable releases, ReleaseCandidate for `rc` releases, Preview for other prereleases and development builds. A check on a development build can show the latest release, but the UI must explain that its installed version cannot be compared. The package's Cargo version is never used for update comparison. + +| Channel | Eligible releases | +| --- | --- | +| Stable | Non-draft, non-prerelease GitHub releases with stable semantic versions | +| ReleaseCandidate | Stable releases and `rc` / `rc.` prereleases | +| Preview | Stable releases and all semantic-version prereleases | + +Selection uses semantic-version precedence, not publication time; `rc.10` is newer than `rc.2`, and the final version is newer than both. Home and desktop are separate tracks. Exact asset names bind the selected version and platform: macOS ARM64 DMG, Windows x64 setup EXE, Linux x64 AppImage or DEB, Android ARM64 Home APK. Linux selects DEB only for `/usr/bin/octosense`, or AppImage when its launcher environment and running executable agree. Unknown/manual layouts are explicitly unsupported rather than switching formats. Home does not download or replace the privileged Bridge APK; a ROM update is a separate delivery path. + +An offer is created only after the exact asset URL, GitHub SHA-256 digest, and release's `SHA256SUMS` agree. The checksum file itself must match its GitHub digest. Initial URLs are fixed to the official repository and selected tag/name. HTTPS redirects are limited to GitHub's release CDN hosts and the official repository's download path. No tokens, cookies or developer-configurable download endpoint are used. These are integrity checks under trust in the project's GitHub release; they are not an independent publisher signature. Native platform signing checks remain necessary, especially Android's installed-package signer check. + +Metadata is bounded to 2 MiB per page, ten 100-release pages and two minutes per check. Checksum files are bounded to 256 KiB. Installers are bounded to 1 GiB on desktop or 512 MiB on Android, 20 seconds without network progress and 15 minutes total. Download size and SHA-256 are checked while streaming, then the private staging directory is atomically renamed. Each attempt has a unique directory. A failed attempt cannot replace a previously verified artifact. Windows storage inherits the host's private app-data ACL; Unix cache directories require mode 0700 and files are created as 0600. The host creates the cache's parent; the updater creates the final cache directory. The OS may reclaim cache contents, in which case the person downloads again. + +`VerifiedArtifact` exposes read-only identity and path accessors. Call `revalidate()` on a worker immediately before native handoff; it checks file type, confinement, exact size and SHA-256 again. It does not persist a reusable trust receipt across process restarts. Call `discard()` when an unused download is abandoned before native handoff; it deletes only that installer and its empty private directory. Never discard after opening a native installer that may still read the file. Other completed files remain in cache; abrupt process death may leave an unverified pending directory, which is never considered installable. + +The `check` example supports `--product`, `--platform`, `--installed`, `--stable`, `--rc`, `--download`, and `--cache`. It prints a JSON result, never launches an installer, and downloads only when `--download` is explicit. Unit tests cover version/channel/platform selection, malicious release metadata, redirect restrictions, checksum disagreements, cancellation, incomplete transfers, retry, cache permissions and changed or symlinked artifacts. See the PR validation record for commands actually run and live-release acceptance results. diff --git a/crates/updater/README.zh-CN.md b/crates/updater/README.zh-CN.md new file mode 100644 index 000000000..f7f9a4b50 --- /dev/null +++ b/crates/updater/README.zh-CN.md @@ -0,0 +1,25 @@ +# OctoSense 更新核心 + +[English](README.md) | 简体中文 + +`octosense-updater` 检查 OctoSense 的 GitHub 公开发行版,将验证通过的安装包下载到宿主私有存储。它不会安装、执行 shell 命令、替换正在运行的程序,也不会向应用智能体暴露更新服务。下载完成后,外壳应由用户明确点击,再打开系统安装程序。 + +`Client::check` 和 `Client::download` 是工作线程上的同步入口。网络操作使用私有 Tokio 运行时,取消请求能够中断停滞的网络操作;不要在 UI 事件线程直接调用。克隆的客户端共享忙碌状态。失败和取消会释放状态、移除本次未完成文件,允许重试。用户已打开的系统安装程序不受下载取消影响。 + +打包任务通过 `OCTOSENSE_RELEASE_TAG` 固化发行身份,例如 `desktop-v0.1.0-rc.2` 或 `home-v0.1.0-beta.2`。没有标签、空标签或无效标签均视为开发构建,不会伪造已安装版本。`BuildIdentity::default_channel` 为稳定版选择 Stable,为 RC 版选择 ReleaseCandidate,为其他预览版和开发构建选择 Preview。开发构建可查询最新发行版,但 UI 必须说明无法比较当前安装版本。更新比较不使用 Cargo 包版本。 + +| 通道 | 可选发行版 | +| --- | --- | +| Stable | 非草稿、GitHub 未标记预发布、语义版本为正式版 | +| ReleaseCandidate | 正式版,以及 `rc` 或 `rc.<数字>` 候选版 | +| Preview | 正式版和所有语义版本预发布版 | + +选择依据语义版本先后,而不是发布时间;`rc.10` 比 `rc.2` 新,正式版又比两者新。Home 与桌面是不同产品线。安装包名称严格绑定版本和平台:macOS ARM64 DMG、Windows x64 安装 EXE、Linux x64 AppImage 或 DEB、Android ARM64 Home APK。Linux 只有在 `/usr/bin/octosense` 运行时选择 DEB;AppImage 还要求启动环境与运行文件路径一致。无法识别的手动安装布局明确显示不支持,不会悄悄更换格式。Home 不下载或替换需要特权的 Bridge APK;ROM 更新属于独立发布流程。 + +只有安装包 URL、GitHub SHA-256 摘要与发行版 `SHA256SUMS` 一致才会创建更新候选。校验清单自身也必须匹配 GitHub 摘要。初始 URL 固定在官方仓库的具体标签与文件名;HTTPS 重定向仅允许 GitHub 发行版 CDN 和官方仓库下载路径。不使用令牌、Cookie 或开发者可修改的下载端点。这是在信任项目 GitHub 发布渠道基础上的完整性校验,并不是独立的发布者签名。仍需要平台原生签名检查,尤其是 Android 与已安装应用的签名一致性检查。 + +每页元数据限制为 2 MiB,最多十页、每页一百条,检查总时限为两分钟。校验清单限制 256 KiB;桌面安装包限制 1 GiB,Android 限制 512 MiB,网络二十秒无进展或下载总计十五分钟即超时。流式下载同时检查长度和 SHA-256,通过后原子重命名私有暂存目录。每次尝试使用独立目录,失败不会覆盖已验证文件。Windows 使用宿主私有应用数据目录继承的 ACL;Unix 缓存目录必须为 0700,文件以 0600 创建。宿主负责创建父目录,更新器创建最终缓存目录。系统可清理缓存,此时用户重新下载即可。 + +`VerifiedArtifact` 提供只读身份与路径访问器。交给系统安装程序前,必须在工作线程调用 `revalidate()`,再次检查文件类型、目录约束、长度和 SHA-256。进程重启后不会复用持久化的信任凭据。尚未交给系统且不再需要的下载可调用 `discard()`,仅删除该安装包及其空的私有目录。系统安装程序仍可能读取文件,因此打开后不要删除。其他已完成文件保留在缓存中;进程突然退出可能留下未验证的暂存目录,它永远不会被视为可安装文件。 + +`check` 示例支持 `--product`、`--platform`、`--installed`、`--stable`、`--rc`、`--download`、`--cache`,输出 JSON,永远不启动安装程序;只有显式 `--download` 才下载。单元测试涵盖版本、通道与平台筛选、恶意元数据、重定向限制、摘要不一致、取消、未完成下载、重试、缓存权限,以及被修改或替换成符号链接的文件。实际执行命令和真实发行版验证结果以 PR 的验证记录为准。 diff --git a/crates/updater/build.rs b/crates/updater/build.rs new file mode 100644 index 000000000..8503777e6 --- /dev/null +++ b/crates/updater/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-env-changed=OCTOSENSE_RELEASE_TAG"); +} diff --git a/crates/updater/examples/check.rs b/crates/updater/examples/check.rs new file mode 100644 index 000000000..25bbc4cb9 --- /dev/null +++ b/crates/updater/examples/check.rs @@ -0,0 +1,74 @@ +//! Public release acceptance without installing anything. +use octosense_updater::{BuildIdentity, Cancellation, Channel, Client, Platform, Product}; +fn main() -> Result<(), Box> { + let mut product = Product::Desktop; + let mut platform = None; + let mut tag = None; + let mut channel = Channel::Preview; + let mut download = false; + let mut args = std::env::args().skip(1); + let mut cache = std::env::temp_dir().join("octosense-updater-acceptance"); + while let Some(arg) = args.next() { + match arg.as_str() { + "--product" => { + product = match args.next().as_deref() { + Some("home") => Product::Home, + Some("desktop") => Product::Desktop, + _ => return Err("expected home or desktop".into()), + } + } + "--platform" => { + platform = Some(match args.next().as_deref() { + Some("mac-arm64") => Platform::MacArm64, + Some("windows-x64") => Platform::WindowsX64, + Some("linux-appimage") => Platform::LinuxX64AppImage, + Some("linux-deb") => Platform::LinuxX64Deb, + Some("android-arm64") => Platform::AndroidArm64, + _ => return Err("unsupported platform".into()), + }) + } + "--installed" => tag = Some(args.next().ok_or("missing tag")?), + "--stable" => channel = Channel::Stable, + "--rc" => channel = Channel::ReleaseCandidate, + "--download" => download = true, + "--cache" => cache = args.next().ok_or("missing cache path")?.into(), + "--help" => { + println!("check [--product desktop|home] [--platform mac-arm64|windows-x64|linux-appimage|linux-deb|android-arm64] [--installed TAG] [--stable|--rc] [--download] [--cache DIR]\nDownloads only with --download; never launches an installer."); + return Ok(()); + } + _ => return Err("unknown argument; use --help".into()), + } + } + let platform = platform + .or_else(|| Platform::current(product)) + .ok_or("specify --platform for this product")?; + let client = Client::new(cache)?; + let cancel = Cancellation::new(); + let result = client.check( + &BuildIdentity::from_tag(product, tag.as_deref())?, + channel, + platform, + &cancel, + )?; + let mut receipt = serde_json::json!({"status":format!("{:?}", result.status),"product":format!("{product:?}"),"platform":format!("{platform:?}"),"installer_opened":false}); + if let Some(offer) = result.offer { + receipt["tag"] = offer.tag().into(); + receipt["asset"] = offer.asset_name().into(); + receipt["size"] = offer.size().into(); + receipt["sha256"] = offer.sha256().into(); + if download { + let mut last = 0; + let artifact = client.download(&offer, &cancel, |p| { + let pct = p.received * 100 / p.total; + if pct / 10 > last / 10 { + eprintln!("Download {pct}%"); + last = pct; + } + })?; + artifact.revalidate()?; + receipt["download_verified"] = true.into(); + } + } + println!("{}", serde_json::to_string_pretty(&receipt)?); + Ok(()) +} diff --git a/crates/updater/src/lib.rs b/crates/updater/src/lib.rs new file mode 100644 index 000000000..ed32f4918 --- /dev/null +++ b/crates/updater/src/lib.rs @@ -0,0 +1,944 @@ +//! Host-owned, explicit OctoSense updates from the project's public GitHub releases. +//! +//! This crate checks and downloads; it never launches installers or replaces the +//! running application. Run its blocking public methods on a worker thread. No +//! account, GitHub token, shell command or application-agent capability is used. + +use semver::Version; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + fmt, + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; + +const RELEASES_API: &str = "https://api.github.com/repos/OctoSense-org/OctoSense/releases"; +const RELEASES_WEB: &str = "https://github.com/OctoSense-org/OctoSense/releases"; +const MAX_METADATA: u64 = 2 * 1024 * 1024; +const MAX_SUMS: u64 = 256 * 1024; +const MAX_INSTALLER: u64 = 1024 * 1024 * 1024; +const MAX_ANDROID_INSTALLER: u64 = 512 * 1024 * 1024; +const NETWORK_IDLE: Duration = Duration::from_secs(20); +const DOWNLOAD_DEADLINE: Duration = Duration::from_secs(15 * 60); + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + Busy, + Cancelled, + Network, + RateLimited, + Timeout, + InvalidMetadata(&'static str), + Integrity(&'static str), + Storage(&'static str), + Unsupported, +} +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Busy => f.write_str("An update operation is already running."), + Self::Cancelled => f.write_str("Update cancelled; no installer was opened."), + Self::Network => f.write_str( + "Could not reach the public release server. Check your connection and retry.", + ), + Self::RateLimited => { + f.write_str("GitHub temporarily limited update checks. Try again later.") + } + Self::Timeout => f.write_str( + "The release download timed out. Retry when the connection is available.", + ), + Self::InvalidMetadata(reason) => write!(f, "Release metadata was rejected: {reason}"), + Self::Integrity(reason) => write!(f, "Update verification failed: {reason}"), + Self::Storage(reason) => write!(f, "Update storage is unavailable: {reason}"), + Self::Unsupported => f.write_str("No updater package is supported for this platform."), + } + } +} +impl std::error::Error for Error {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Product { + Desktop, + Home, +} +impl Product { + fn prefix(self) -> &'static str { + match self { + Self::Desktop => "desktop-v", + Self::Home => "home-v", + } + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Channel { + Stable, + ReleaseCandidate, + Preview, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + MacArm64, + WindowsX64, + LinuxX64AppImage, + LinuxX64Deb, + AndroidArm64, +} +impl Platform { + pub fn current(product: Product) -> Option { + match (product, std::env::consts::OS, std::env::consts::ARCH) { + (Product::Desktop, "macos", "aarch64") => Some(Self::MacArm64), + (Product::Desktop, "windows", "x86_64") => Some(Self::WindowsX64), + (Product::Desktop, "linux", "x86_64") => { + let executable = std::env::current_exe().ok()?.canonicalize().ok()?; + let app_dir = std::env::var_os("APPDIR").and_then(|p| { + let path = PathBuf::from(p); + path.is_absolute() + .then(|| path.canonicalize().ok()) + .flatten() + }); + let app_image_is_file = std::env::var_os("APPIMAGE").is_some_and(|p| { + let path = PathBuf::from(p); + path.is_absolute() && path.is_file() + }); + linux_platform(&executable, app_dir.as_deref(), app_image_is_file) + } + (Product::Home, "android", "aarch64") => Some(Self::AndroidArm64), + _ => None, + } + } + pub fn product(self) -> Product { + if self == Self::AndroidArm64 { + Product::Home + } else { + Product::Desktop + } + } + fn maximum_size(self) -> u64 { + if self == Self::AndroidArm64 { + MAX_ANDROID_INSTALLER + } else { + MAX_INSTALLER + } + } + fn asset_name(self, version: &Version) -> String { + match self { + Self::MacArm64 => format!("OctoSense_{version}_aarch64.dmg"), + Self::WindowsX64 => format!("octosense_{version}_x64-setup.exe"), + Self::LinuxX64AppImage => format!("octosense_{version}_x86_64.AppImage"), + Self::LinuxX64Deb => format!("octosense_{version}_amd64.deb"), + Self::AndroidArm64 => format!("OctoSenseHome_{version}_arm64.apk"), + } + } +} + +// The DEB and AppImage ship the same binary. Never infer the install format +// from a distro name or silently switch an existing DEB install to AppImage. +fn linux_platform( + executable: &Path, + app_dir: Option<&Path>, + app_image_is_file: bool, +) -> Option { + if app_image_is_file && app_dir.is_some_and(|dir| executable == dir.join("usr/bin/octosense")) { + return Some(Platform::LinuxX64AppImage); + } + if executable == Path::new("/usr/bin/octosense") { + return Some(Platform::LinuxX64Deb); + } + None +} + +/// A release tag must be baked by the packaging job. Cargo's package version is +/// not the installed release: untagged local builds remain development builds. +#[derive(Debug, Clone)] +pub struct BuildIdentity { + product: Product, + tag: Option, + version: Option, +} +impl BuildIdentity { + pub fn from_env(product: Product) -> Self { + Self::from_tag(product, option_env!("OCTOSENSE_RELEASE_TAG")).unwrap_or(Self { + product, + tag: None, + version: None, + }) + } + pub fn from_tag(product: Product, tag: Option<&str>) -> Result { + match tag.filter(|t| !t.is_empty()) { + Some(tag) => Ok(Self { + product, + tag: Some(tag.into()), + version: Some(parse_tag(product, tag)?), + }), + None => Ok(Self { + product, + tag: None, + version: None, + }), + } + } + pub fn default_channel(&self) -> Channel { + match self.version.as_ref() { + Some(v) if is_release_candidate(v) => Channel::ReleaseCandidate, + Some(v) if !v.pre.is_empty() => Channel::Preview, + None => Channel::Preview, + _ => Channel::Stable, + } + } + pub fn product(&self) -> Product { + self.product + } + pub fn tag(&self) -> Option<&str> { + self.tag.as_deref() + } + pub fn version(&self) -> Option<&Version> { + self.version.as_ref() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckStatus { + Available, + Current, + Development, + Unavailable, +} +#[derive(Debug, Clone)] +pub struct CheckResult { + pub status: CheckStatus, + pub offer: Option, +} + +/// Only a successful check can construct an offer. Network locations and +/// digests are private so application code cannot turn this into an arbitrary +/// URL downloader or pass unchecked metadata to the installer path. +#[derive(Debug, Clone)] +pub struct Offer { + tag: String, + version: Version, + platform: Platform, + asset: ReleaseAsset, + sha256: String, +} +impl Offer { + pub fn tag(&self) -> &str { + &self.tag + } + pub fn version(&self) -> &Version { + &self.version + } + pub fn platform(&self) -> Platform { + self.platform + } + pub fn asset_name(&self) -> &str { + &self.asset.name + } + pub fn size(&self) -> u64 { + self.asset.size + } + pub fn sha256(&self) -> &str { + &self.sha256 + } + pub fn release_url(&self) -> String { + format!("{RELEASES_WEB}/tag/{}", self.tag) + } +} + +#[derive(Debug, Clone, Default)] +pub struct Cancellation(Arc); +impl Cancellation { + pub fn new() -> Self { + Self::default() + } + pub fn cancel(&self) { + self.0.store(true, Ordering::Relaxed); + } + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Relaxed) + } + fn check(&self) -> Result<()> { + if self.is_cancelled() { + Err(Error::Cancelled) + } else { + Ok(()) + } + } +} +#[derive(Debug, Clone, Copy)] +pub struct DownloadProgress { + pub received: u64, + pub total: u64, +} + +/// Holds an immutable verified identity; the file must still be revalidated +/// immediately before native handoff. The native Android installer additionally +/// checks APK package, version and signer against the installed application. +#[derive(Debug, Clone)] +pub struct VerifiedArtifact { + offer: Offer, + root: PathBuf, + directory: PathBuf, + path: PathBuf, +} +impl VerifiedArtifact { + pub fn offer(&self) -> &Offer { + &self.offer + } + pub fn path(&self) -> &Path { + &self.path + } + pub fn sha256(&self) -> &str { + self.offer.sha256() + } + pub fn platform(&self) -> Platform { + self.offer.platform() + } + pub fn tag(&self) -> &str { + self.offer.tag() + } + pub fn asset_name(&self) -> &str { + self.offer.asset_name() + } + /// Delete an unused download when changing channel or starting over. Never + /// call after native handoff: a mounted DMG/installer can still need it. + pub fn discard(self) -> Result<()> { + self.validate_location()?; + match fs::remove_file(&self.path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(Error::Storage("cannot remove unused installer")), + } + // No recursive deletion, even if another file appeared in this directory. + fs::remove_dir(&self.directory) + .map_err(|_| Error::Storage("cannot remove artifact directory")) + } + fn validate_location(&self) -> Result<()> { + validate_directory(&self.root)?; + validate_directory(&self.directory)?; + let canonical_root = self + .root + .canonicalize() + .map_err(|_| Error::Storage("missing update directory"))?; + let canonical_directory = self + .directory + .canonicalize() + .map_err(|_| Error::Storage("missing artifact directory"))?; + if canonical_root != self.root || canonical_directory.parent() != Some(self.root.as_path()) + { + return Err(Error::Storage("artifact directory changed")); + } + if self.path.parent() != Some(self.directory.as_path()) + || self.path.file_name().and_then(|s| s.to_str()) != Some(self.offer.asset_name()) + { + return Err(Error::Storage("installer path changed")); + } + Ok(()) + } + pub fn revalidate(&self) -> Result { + self.validate_location()?; + let meta = + fs::symlink_metadata(&self.path).map_err(|_| Error::Storage("installer is missing"))?; + if !meta.file_type().is_file() || meta.len() != self.offer.size() { + return Err(Error::Integrity("installer size or file type changed")); + } + let mut file = + File::open(&self.path).map_err(|_| Error::Storage("cannot read installer"))?; + let mut hasher = Sha256::new(); + let mut bytes = 0; + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(|_| Error::Storage("cannot read installer"))?; + if read == 0 { + break; + } + bytes += read as u64; + if bytes > self.offer.size() { + return Err(Error::Integrity("installer grew after download")); + } + hasher.update(&buffer[..read]); + } + if bytes != self.offer.size() || format!("{:x}", hasher.finalize()) != self.offer.sha256 { + return Err(Error::Integrity("installer changed after download")); + } + Ok(self.path.clone()) + } +} + +/// Clone shares an operation guard. A failed/cancelled operation releases it so +/// Retry works; partial files never become verified artifacts. +#[derive(Clone)] +pub struct Client { + cache_dir: PathBuf, + http: reqwest::Client, + busy: Arc, +} +impl Client { + pub fn new(cache_dir: impl Into) -> Result { + let http = reqwest::Client::builder() + .user_agent(concat!("OctoSense-Updater/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(Duration::from_secs(10)) + // A worker runtime lives for one operation; do not cache connections + // whose I/O tasks would belong to that completed runtime. + .pool_max_idle_per_host(0) + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() >= 5 { + return attempt.error("too many release redirects"); + } + if valid_redirect(attempt.url()) { + attempt.follow() + } else { + attempt.error("untrusted release redirect") + } + })) + .build() + .map_err(|_| Error::Network)?; + Ok(Self { + cache_dir: cache_dir.into(), + http, + busy: Arc::new(AtomicBool::new(false)), + }) + } + pub fn check( + &self, + current: &BuildIdentity, + channel: Channel, + platform: Platform, + cancel: &Cancellation, + ) -> Result { + let _guard = Operation::enter(&self.busy)?; + cancel.check()?; + if platform.product() != current.product { + return Err(Error::Unsupported); + } + run(self.check_async(current, channel, platform, cancel)) + } + pub fn download( + &self, + offer: &Offer, + cancel: &Cancellation, + mut progress: impl FnMut(DownloadProgress), + ) -> Result { + let _guard = Operation::enter(&self.busy)?; + cancel.check()?; + run(self.download_async(offer, cancel, &mut progress)) + } + async fn check_async( + &self, + current: &BuildIdentity, + channel: Channel, + platform: Platform, + cancel: &Cancellation, + ) -> Result { + let deadline = Instant::now() + Duration::from_secs(120); + let mut releases = Vec::new(); + for page in 1..=10 { + let url = format!("{RELEASES_API}?per_page=100&page={page}"); + let response = self.request(&url, cancel, deadline).await?; + let bytes = read_response(response, MAX_METADATA, cancel, deadline).await?; + let page_releases: Vec = serde_json::from_slice(&bytes) + .map_err(|_| Error::InvalidMetadata("invalid release list"))?; + if page_releases.len() > 100 { + return Err(Error::InvalidMetadata("oversized release list")); + } + let count = page_releases.len(); + releases.extend(page_releases); + if count < 100 { + break; + } + if page == 10 { + return Err(Error::InvalidMetadata( + "release history exceeds safety limit", + )); + } + } + let Some((release, version, asset)) = + select_release(releases, current.product, channel, platform)? + else { + return Ok(CheckResult { + status: CheckStatus::Unavailable, + offer: None, + }); + }; + let expected_digest = validate_asset(&release.tag_name, &asset, platform.maximum_size())?; + let sums: Vec<_> = release + .assets + .iter() + .filter(|a| a.name == "SHA256SUMS") + .collect(); + if sums.len() != 1 { + return Err(Error::InvalidMetadata( + "release must include one SHA256SUMS asset", + )); + } + let sums = sums[0]; + let sums_digest = validate_asset(&release.tag_name, sums, MAX_SUMS)?; + let response = self + .request(&sums.browser_download_url, cancel, deadline) + .await?; + let bytes = read_response(response, MAX_SUMS, cancel, deadline).await?; + verify_bytes(&bytes, sums.size, &sums_digest)?; + verify_checksum_manifest(&bytes, &asset.name, &expected_digest)?; + let status = update_status(current, &version); + Ok(CheckResult { + status, + offer: Some(Offer { + tag: release.tag_name, + version, + platform, + asset, + sha256: expected_digest, + }), + }) + } + async fn download_async( + &self, + offer: &Offer, + cancel: &Cancellation, + progress: &mut impl FnMut(DownloadProgress), + ) -> Result { + let deadline = Instant::now() + DOWNLOAD_DEADLINE; + let mut pending = PendingDownload::new(&self.cache_dir, offer)?; + progress(DownloadProgress { + received: 0, + total: offer.size(), + }); + let mut response = self + .request(&offer.asset.browser_download_url, cancel, deadline) + .await?; + if let Some(length) = response.content_length() { + if length != offer.size() { + return Err(Error::Integrity("server returned an unexpected size")); + } + } + let mut last_progress = Instant::now(); + while let Some(chunk) = wait_network(response.chunk(), cancel, deadline).await? { + pending.append(&chunk)?; + // A fast CDN can produce thousands of chunks per second. Keep the + // UI's event queue bounded in practice without hiding completion. + if last_progress.elapsed() >= Duration::from_millis(100) + || pending.received == offer.size() + { + progress(DownloadProgress { + received: pending.received, + total: offer.size(), + }); + last_progress = Instant::now(); + } + } + cancel.check()?; + pending.finish() + } + async fn request( + &self, + url: &str, + cancel: &Cancellation, + deadline: Instant, + ) -> Result { + let response = wait_network( + self.http + .get(url) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send(), + cancel, + deadline, + ) + .await?; + match response.status().as_u16() { + 200 => Ok(response), + 403 | 429 => Err(Error::RateLimited), + _ => Err(Error::Network), + } + } +} + +fn run(future: impl std::future::Future>) -> Result { + // The API deliberately owns a small runtime on its worker. Refuse calls + // made from an async runtime instead of panicking in block_on. + if tokio::runtime::Handle::try_current().is_ok() { + return Err(Error::Storage("updater must run on its own worker thread")); + } + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| Error::Storage("cannot start updater worker"))?; + let result = rt.block_on(future); + // DNS can use an OS blocking resolver. Returning from a cancelled request + // must not wait indefinitely for that resolver while dropping the runtime. + rt.shutdown_timeout(Duration::from_millis(100)); + result +} +async fn wait_network( + future: impl std::future::Future>, + cancel: &Cancellation, + deadline: Instant, +) -> Result { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or(Error::Timeout)?; + let timer = tokio::time::sleep(remaining.min(NETWORK_IDLE)); + tokio::pin!(future, timer); + loop { + cancel.check()?; + tokio::select! { + result = &mut future => return result.map_err(|_| Error::Network), + _ = &mut timer => return Err(Error::Timeout), + _ = tokio::time::sleep(Duration::from_millis(100)) => {}, + } + } +} +async fn read_response( + mut response: reqwest::Response, + maximum: u64, + cancel: &Cancellation, + deadline: Instant, +) -> Result> { + if response.content_length().is_some_and(|n| n > maximum) { + return Err(Error::InvalidMetadata("response exceeds safety limit")); + } + let mut bytes = Vec::new(); + while let Some(chunk) = wait_network(response.chunk(), cancel, deadline).await? { + if bytes.len() as u64 + chunk.len() as u64 > maximum { + return Err(Error::InvalidMetadata("response exceeds safety limit")); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} +fn valid_redirect(url: &reqwest::Url) -> bool { + url.scheme() == "https" + && url.port_or_known_default() == Some(443) + && url.username().is_empty() + && url.password().is_none() + && match url.host_str() { + Some("release-assets.githubusercontent.com" | "objects.githubusercontent.com") => true, + Some("github.com") => url + .path() + .starts_with("/OctoSense-org/OctoSense/releases/download/"), + _ => false, + } +} + +#[derive(Debug, Clone, Deserialize)] +struct ReleaseAsset { + name: String, + size: u64, + browser_download_url: String, + digest: Option, +} +#[derive(Debug, Clone, Deserialize)] +struct Release { + tag_name: String, + draft: bool, + prerelease: bool, + assets: Vec, +} +fn parse_tag(product: Product, tag: &str) -> Result { + if tag.len() > 100 + || !tag + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b".-+".contains(&c)) + { + return Err(Error::InvalidMetadata("invalid release tag")); + } + let version = tag + .strip_prefix(product.prefix()) + .ok_or(Error::InvalidMetadata("release belongs to another product"))?; + Version::parse(version).map_err(|_| Error::InvalidMetadata("invalid release version")) +} +fn is_release_candidate(version: &Version) -> bool { + let pre = version.pre.as_str(); + pre == "rc" + || pre.strip_prefix("rc.").is_some_and(|suffix| { + !suffix.is_empty() + && suffix + .split('.') + .all(|part| part.bytes().all(|b| b.is_ascii_digit())) + }) +} +fn select_release( + releases: Vec, + product: Product, + channel: Channel, + platform: Platform, +) -> Result> { + let mut candidates = Vec::new(); + for release in releases { + if release.draft || !release.tag_name.starts_with(product.prefix()) { + continue; + } + let Ok(version) = parse_tag(product, &release.tag_name) else { + continue; + }; + match channel { + Channel::Stable if release.prerelease || !version.pre.is_empty() => continue, + Channel::ReleaseCandidate + if !is_release_candidate(&version) + && (release.prerelease || !version.pre.is_empty()) => + { + continue + } + _ => {} + } + let wanted = platform.asset_name(&version); + let matching: Vec<_> = release + .assets + .iter() + .filter(|a| a.name == wanted) + .cloned() + .collect(); + if matching.len() > 1 { + return Err(Error::InvalidMetadata("ambiguous installer asset")); + } + if let Some(asset) = matching.into_iter().next() { + candidates.push((release, version, asset)); + } + } + // API publication order is not version order; old releases can be republished. + candidates.sort_by(|a, b| b.1.cmp_precedence(&a.1)); + if candidates.len() > 1 && candidates[0].1.cmp_precedence(&candidates[1].1).is_eq() { + return Err(Error::InvalidMetadata("ambiguous release version")); + } + Ok(candidates.into_iter().next()) +} +fn update_status(current: &BuildIdentity, newest: &Version) -> CheckStatus { + match ¤t.version { + None => CheckStatus::Development, + Some(v) if !v.cmp_precedence(newest).is_lt() => CheckStatus::Current, + Some(_) => CheckStatus::Available, + } +} +fn digest_string(value: &str) -> Result { + if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(Error::InvalidMetadata("invalid SHA-256 digest")); + } + Ok(value.to_ascii_lowercase()) +} +fn validate_asset(tag: &str, asset: &ReleaseAsset, maximum: u64) -> Result { + if asset.size == 0 || asset.size > maximum { + return Err(Error::InvalidMetadata("invalid asset size")); + } + if asset.name.is_empty() + || asset.name.len() > 180 + || !asset + .name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._-+".contains(&c)) + || asset.name.starts_with('.') + { + return Err(Error::InvalidMetadata("invalid asset name")); + } + let expected = format!("{RELEASES_WEB}/download/{tag}/{}", asset.name); + if asset.browser_download_url != expected { + return Err(Error::InvalidMetadata( + "asset URL is outside the selected official release", + )); + } + let digest = asset + .digest + .as_deref() + .and_then(|d| d.strip_prefix("sha256:")) + .ok_or(Error::InvalidMetadata("GitHub SHA-256 digest is missing"))?; + digest_string(digest) +} +fn verify_bytes(bytes: &[u8], size: u64, digest: &str) -> Result<()> { + if bytes.len() as u64 != size || format!("{:x}", Sha256::digest(bytes)) != digest { + return Err(Error::Integrity( + "release checksum file does not match GitHub metadata", + )); + } + Ok(()) +} +fn verify_checksum_manifest(bytes: &[u8], asset: &str, expected: &str) -> Result<()> { + let manifest = std::str::from_utf8(bytes) + .map_err(|_| Error::InvalidMetadata("checksums are not UTF-8"))?; + let mut seen = HashMap::new(); + for line in manifest.lines().filter(|line| !line.is_empty()) { + if line.len() < 67 || !line.is_char_boundary(64) || !line.is_char_boundary(66) { + return Err(Error::InvalidMetadata("malformed checksum line")); + } + let digest = digest_string(&line[..64])?; + if &line[64..66] != " " && &line[64..66] != " *" { + return Err(Error::InvalidMetadata("malformed checksum separator")); + } + let name = &line[66..]; + if name.is_empty() + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b)) + || name.starts_with('.') + { + return Err(Error::InvalidMetadata("unsafe checksum filename")); + } + if seen.insert(name, digest).is_some() { + return Err(Error::InvalidMetadata("duplicate checksum filename")); + } + } + match seen.get(asset) { + Some(digest) if digest == expected => Ok(()), + Some(_) => Err(Error::Integrity("GitHub digest and SHA256SUMS disagree")), + None => Err(Error::Integrity("installer is missing from SHA256SUMS")), + } +} + +struct Operation(Arc); +impl Operation { + fn enter(busy: &Arc) -> Result { + busy.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .map_err(|_| Error::Busy)?; + Ok(Self(busy.clone())) + } +} +impl Drop for Operation { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} +fn validate_directory(path: &Path) -> Result<()> { + let meta = fs::symlink_metadata(path) + .map_err(|_| Error::Storage("cannot inspect update directory"))?; + if !meta.file_type().is_dir() { + return Err(Error::Storage("update directory is not a real directory")); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if meta.permissions().mode() & 0o077 != 0 { + return Err(Error::Storage("update directory must be private")); + } + } + Ok(()) +} +fn create_private_directory(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new() + .mode(0o700) + .create(path) + .map_err(|_| Error::Storage("cannot create private update directory"))?; + } + #[cfg(not(unix))] + fs::create_dir(path).map_err(|_| Error::Storage("cannot create update directory"))?; + validate_directory(path) +} +fn prepare_root(path: &Path) -> Result { + if fs::symlink_metadata(path).is_err() { + // Parent is host-owned app storage; caller must create it. Never recurse + // into a path selected by release metadata. + create_private_directory(path)?; + } + validate_directory(path)?; + path.canonicalize() + .map_err(|_| Error::Storage("cannot locate update directory")) +} +struct PendingDownload { + offer: Offer, + root: PathBuf, + directory: PathBuf, + path: PathBuf, + file: Option, + hasher: Sha256, + received: u64, + committed: bool, +} +impl PendingDownload { + fn new(root: &Path, offer: &Offer) -> Result { + validate_asset(&offer.tag, &offer.asset, offer.platform.maximum_size())?; + let root = prepare_root(root)?; + let directory = root.join(format!("pending-{}", uuid::Uuid::new_v4())); + create_private_directory(&directory)?; + let path = directory.join(offer.asset_name()); + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = match options.open(&path) { + Ok(file) => file, + Err(_) => { + let _ = fs::remove_dir(&directory); + return Err(Error::Storage("cannot create download file")); + } + }; + Ok(Self { + offer: offer.clone(), + root, + directory, + path, + file: Some(file), + hasher: Sha256::new(), + received: 0, + committed: false, + }) + } + fn append(&mut self, bytes: &[u8]) -> Result<()> { + let next = self + .received + .checked_add(bytes.len() as u64) + .ok_or(Error::Integrity("download size overflow"))?; + if next > self.offer.size() { + return Err(Error::Integrity("download exceeds advertised size")); + } + self.file + .as_mut() + .ok_or(Error::Storage("download is already closed"))? + .write_all(bytes) + .map_err(|_| Error::Storage("cannot write download; check free space"))?; + self.hasher.update(bytes); + self.received = next; + Ok(()) + } + fn finish(mut self) -> Result { + if self.received != self.offer.size() { + return Err(Error::Integrity("download was incomplete")); + } + if format!("{:x}", self.hasher.clone().finalize()) != self.offer.sha256 { + return Err(Error::Integrity("download checksum does not match release")); + } + self.file + .take() + .ok_or(Error::Storage("download is already closed"))? + .sync_all() + .map_err(|_| Error::Storage("cannot finish download"))?; + let directory = self.root.join(format!("verified-{}", uuid::Uuid::new_v4())); + fs::rename(&self.directory, &directory) + .map_err(|_| Error::Storage("cannot commit verified download"))?; + self.committed = true; + Ok(VerifiedArtifact { + offer: self.offer.clone(), + root: self.root.clone(), + path: directory.join(self.offer.asset_name()), + directory, + }) + } +} +impl Drop for PendingDownload { + fn drop(&mut self) { + self.file.take(); + if !self.committed { + // Remove only files we created, never a recursive tree supplied by + // metadata and never another operation's verified artifact. + let _ = fs::remove_file(&self.path); + let _ = fs::remove_dir(&self.directory); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/updater/src/tests.rs b/crates/updater/src/tests.rs new file mode 100644 index 000000000..728ef0dc3 --- /dev/null +++ b/crates/updater/src/tests.rs @@ -0,0 +1,587 @@ +use super::*; + +fn asset(tag: &str, name: &str, bytes: &[u8]) -> ReleaseAsset { + ReleaseAsset { + name: name.into(), + size: bytes.len() as u64, + browser_download_url: format!("{RELEASES_WEB}/download/{tag}/{name}"), + digest: Some(format!("sha256:{:x}", Sha256::digest(bytes))), + } +} +fn release(tag: &str, platform: Platform) -> Release { + let version = parse_tag(platform.product(), tag).unwrap(); + Release { + tag_name: tag.into(), + draft: false, + prerelease: !version.pre.is_empty(), + assets: vec![asset(tag, &platform.asset_name(&version), b"installer")], + } +} +fn offer(bytes: &[u8]) -> Offer { + let tag = "desktop-v0.1.0-rc.2"; + let version = parse_tag(Product::Desktop, tag).unwrap(); + let asset = asset(tag, &Platform::MacArm64.asset_name(&version), bytes); + let sha256 = validate_asset(tag, &asset, MAX_INSTALLER).unwrap(); + Offer { + tag: tag.into(), + version, + platform: Platform::MacArm64, + asset, + sha256, + } +} + +#[test] +fn untagged_build_is_not_a_release() { + for tag in [None, Some("")] { + let build = BuildIdentity::from_tag(Product::Desktop, tag).unwrap(); + assert!(build.tag().is_none()); + assert!(build.version().is_none()); + } + assert!(BuildIdentity::from_tag(Product::Desktop, Some("home-v0.1.0")).is_err()); + assert!(BuildIdentity::from_tag(Product::Desktop, Some("0.1.0")).is_err()); +} +#[test] +fn rejects_unsafe_and_non_semver_tags() { + for tag in [ + "desktop-v../../bad", + "desktop-v0.1.0/evil", + "desktop-v0.1.0%2f", + "desktop-v0.01.0", + "desktop-v0.1.0\n", + "desktop-v0.1.0?token=x", + ] { + assert!(parse_tag(Product::Desktop, tag).is_err(), "{tag}"); + } + assert_eq!( + parse_tag(Product::Home, "home-v1.2.3-beta.4").unwrap(), + Version::parse("1.2.3-beta.4").unwrap() + ); +} +#[test] +fn selects_semver_not_publication_order() { + let releases = vec![ + release("desktop-v0.1.0-rc.2", Platform::MacArm64), + release("desktop-v0.1.0-rc.10", Platform::MacArm64), + release("desktop-v0.1.0-beta.99", Platform::MacArm64), + ]; + let selected = select_release( + releases, + Product::Desktop, + Channel::Preview, + Platform::MacArm64, + ) + .unwrap() + .unwrap(); + assert_eq!(selected.0.tag_name, "desktop-v0.1.0-rc.10"); +} +#[test] +fn stable_excludes_all_preview_sources() { + let mut misleading = release("desktop-v0.3.0-rc.1", Platform::MacArm64); + misleading.prerelease = false; + let mut flag_only = release("desktop-v0.4.0", Platform::MacArm64); + flag_only.prerelease = true; + let all = vec![ + misleading, + flag_only, + release("desktop-v0.2.0", Platform::MacArm64), + ]; + let chosen = select_release(all, Product::Desktop, Channel::Stable, Platform::MacArm64) + .unwrap() + .unwrap(); + assert_eq!(chosen.0.tag_name, "desktop-v0.2.0"); +} +#[test] +fn stable_reports_none_if_only_previews_exist() { + assert!(select_release( + vec![release("desktop-v0.1.0-rc.2", Platform::MacArm64)], + Product::Desktop, + Channel::Stable, + Platform::MacArm64 + ) + .unwrap() + .is_none()); +} +#[test] +fn product_architecture_and_draft_are_fenced() { + let mut draft = release("desktop-v99.0.0", Platform::MacArm64); + draft.draft = true; + let selected = select_release( + vec![ + draft, + release("home-v88.0.0", Platform::AndroidArm64), + release("desktop-v77.0.0", Platform::WindowsX64), + release("desktop-v0.1.0", Platform::MacArm64), + ], + Product::Desktop, + Channel::Preview, + Platform::MacArm64, + ) + .unwrap() + .unwrap(); + assert_eq!(selected.0.tag_name, "desktop-v0.1.0"); +} +#[test] +fn exact_platform_asset_names_match_published_convention() { + let v = Version::parse("0.1.0-rc.2").unwrap(); + for (platform, expected) in [ + (Platform::MacArm64, "OctoSense_0.1.0-rc.2_aarch64.dmg"), + (Platform::WindowsX64, "octosense_0.1.0-rc.2_x64-setup.exe"), + ( + Platform::LinuxX64AppImage, + "octosense_0.1.0-rc.2_x86_64.AppImage", + ), + (Platform::LinuxX64Deb, "octosense_0.1.0-rc.2_amd64.deb"), + (Platform::AndroidArm64, "OctoSenseHome_0.1.0-rc.2_arm64.apk"), + ] { + assert_eq!(platform.asset_name(&v), expected); + } +} +#[test] +fn duplicate_assets_and_equivalent_versions_are_refused() { + let mut dup = release("desktop-v0.1.0", Platform::MacArm64); + dup.assets.push(dup.assets[0].clone()); + assert!(select_release( + vec![dup], + Product::Desktop, + Channel::Stable, + Platform::MacArm64 + ) + .is_err()); + assert!(select_release( + vec![ + release("desktop-v0.1.0+first", Platform::MacArm64), + release("desktop-v0.1.0+second", Platform::MacArm64) + ], + Product::Desktop, + Channel::Stable, + Platform::MacArm64 + ) + .is_err()); +} +#[test] +fn asset_urls_must_match_the_exact_official_tag_and_name() { + let offer = offer(b"installer"); + for url in [ + "http://github.com/OctoSense-org/OctoSense/releases/download/desktop-v0.1.0-rc.2/OctoSense_0.1.0-rc.2_aarch64.dmg", + "https://github.com.evil.test/OctoSense-org/OctoSense/releases/download/desktop-v0.1.0-rc.2/OctoSense_0.1.0-rc.2_aarch64.dmg", + "https://github.com/another/repo/releases/download/desktop-v0.1.0-rc.2/OctoSense_0.1.0-rc.2_aarch64.dmg", + "https://github.com/OctoSense-org/OctoSense/releases/download/desktop-v0.1.0-rc.1/OctoSense_0.1.0-rc.2_aarch64.dmg", + "https://github.com/OctoSense-org/OctoSense/releases/download/desktop-v0.1.0-rc.2/OctoSense_0.1.0-rc.2_aarch64.dmg?download=1", + "file:///tmp/installer.dmg", + ] { + let mut bad = offer.asset.clone(); bad.browser_download_url = url.into(); + assert!(validate_asset(offer.tag(), &bad, MAX_INSTALLER).is_err(), "{url}"); + } +} +#[test] +fn redirects_allow_only_github_release_https_hosts() { + for url in ["https://release-assets.githubusercontent.com/github-production-release-asset/a?token=signed", "https://objects.githubusercontent.com/a", "https://github.com/OctoSense-org/OctoSense/releases/download/desktop-v1.0.0/a"] { + assert!(valid_redirect(&reqwest::Url::parse(url).unwrap())); + } + for url in [ + "http://release-assets.githubusercontent.com/a", + "https://release-assets.githubusercontent.com:444/a", + "https://user@release-assets.githubusercontent.com/a", + "https://api.github.com/a", + "https://github.com/someone/other/releases/download/a", + "https://127.0.0.1/a", + "https://release-assets.githubusercontent.com.evil.test/a", + ] { + assert!(!valid_redirect(&reqwest::Url::parse(url).unwrap()), "{url}"); + } +} +#[test] +fn mandatory_github_digest_and_asset_bounds() { + let o = offer(b"installer"); + for digest in [None, Some("sha512:abc".into()), Some("sha256:xyz".into())] { + let mut bad = o.asset.clone(); + bad.digest = digest; + assert!(validate_asset(o.tag(), &bad, MAX_INSTALLER).is_err()); + } + for size in [0, MAX_INSTALLER + 1] { + let mut bad = o.asset.clone(); + bad.size = size; + assert!(validate_asset(o.tag(), &bad, MAX_INSTALLER).is_err()); + } +} +#[test] +fn manifest_must_bind_exact_filename_and_github_digest() { + let hash = "ab".repeat(32); + assert!(verify_checksum_manifest( + format!("{hash} correct.dmg\n").as_bytes(), + "correct.dmg", + &hash + ) + .is_ok()); + assert!(verify_checksum_manifest( + format!("{hash} *correct.dmg\r\n").as_bytes(), + "correct.dmg", + &hash + ) + .is_ok()); + for text in [ + format!("{hash} other.dmg\n"), + format!("{} correct.dmg\n", "cd".repeat(32)), + format!("{hash} correct.dmg\n{hash} correct.dmg\n"), + format!("{hash} ../correct.dmg\n"), + format!("{hash} /correct.dmg\n"), + format!("{hash} correct.dmg extra\n"), + format!("{hash}\t correct.dmg\n"), + "é".repeat(35), + ] { + assert!( + verify_checksum_manifest(text.as_bytes(), "correct.dmg", &hash).is_err(), + "{text}" + ); + } +} +#[test] +fn checksum_manifest_itself_is_verified() { + let bytes = b"a manifest"; + let hash = format!("{:x}", Sha256::digest(bytes)); + assert!(verify_bytes(bytes, bytes.len() as u64, &hash).is_ok()); + assert!(verify_bytes(b"b manifest", bytes.len() as u64, &hash).is_err()); + assert!(verify_bytes(bytes, 999, &hash).is_err()); +} +#[test] +fn completed_download_is_revalidated_before_handoff() { + let tmp = tempfile::tempdir().unwrap(); + let bytes = b"verified installer"; + let mut pending = PendingDownload::new(&tmp.path().join("updates"), &offer(bytes)).unwrap(); + pending.append(&bytes[..4]).unwrap(); + pending.append(&bytes[4..]).unwrap(); + let artifact = pending.finish().unwrap(); + assert_eq!(fs::read(artifact.revalidate().unwrap()).unwrap(), bytes); + assert!(artifact + .path() + .starts_with(tmp.path().canonicalize().unwrap())); + assert_eq!(artifact.platform(), Platform::MacArm64); + assert!(artifact + .path() + .parent() + .unwrap() + .file_name() + .unwrap() + .to_str() + .unwrap() + .starts_with("verified-")); +} +#[test] +fn corruption_after_download_blocks_handoff() { + let tmp = tempfile::tempdir().unwrap(); + let mut pending = + PendingDownload::new(&tmp.path().join("updates"), &offer(b"installer")).unwrap(); + pending.append(b"installer").unwrap(); + let artifact = pending.finish().unwrap(); + fs::write(artifact.path(), b"corrupted").unwrap(); + assert!(matches!(artifact.revalidate(), Err(Error::Integrity(_)))); +} +#[test] +fn incomplete_corrupt_and_oversize_downloads_leave_no_artifact() { + for (bytes, append_fails) in [ + (b"short".as_slice(), false), + (b"corrupted".as_slice(), false), + (b"much longer than installer".as_slice(), true), + ] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("updates"); + let mut pending = PendingDownload::new(&root, &offer(b"installer")).unwrap(); + let result = pending.append(bytes); + if append_fails { + assert!(result.is_err()); + drop(pending); + } else { + result.unwrap(); + assert!(pending.finish().is_err()); + } + assert_eq!(fs::read_dir(root).unwrap().count(), 0); + } +} +#[test] +fn interruption_removes_only_its_partial_file_and_retry_works() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("updates"); + { + let mut first = PendingDownload::new(&root, &offer(b"installer")).unwrap(); + first.append(b"inst").unwrap(); + fs::write(root.join("unrelated.txt"), b"keep").unwrap(); + } + let mut second = PendingDownload::new(&root, &offer(b"installer")).unwrap(); + second.append(b"installer").unwrap(); + let artifact = second.finish().unwrap(); + artifact.revalidate().unwrap(); + assert_eq!(fs::read(root.join("unrelated.txt")).unwrap(), b"keep"); + assert_eq!(fs::read_dir(root).unwrap().count(), 2); +} +#[test] +fn busy_guard_is_shared_and_released_after_failure() { + let busy = Arc::new(AtomicBool::new(false)); + let first = Operation::enter(&busy).unwrap(); + assert!(matches!(Operation::enter(&busy), Err(Error::Busy))); + drop(first); + assert!(Operation::enter(&busy).is_ok()); +} +#[test] +fn cancellation_is_shared_and_prevents_network_or_disk_work() { + let tmp = tempfile::tempdir().unwrap(); + let client = Client::new(tmp.path().join("updates")).unwrap(); + let cancel = Cancellation::new(); + cancel.clone().cancel(); + assert!(matches!( + client.check( + &BuildIdentity::from_tag(Product::Desktop, None).unwrap(), + Channel::Preview, + Platform::MacArm64, + &cancel + ), + Err(Error::Cancelled) + )); + assert!(matches!( + client.download(&offer(b"installer"), &cancel, |_| panic!( + "should not begin" + )), + Err(Error::Cancelled) + )); + assert!(!tmp.path().join("updates").exists()); +} +#[test] +fn cancellation_interrupts_stalled_network_future() { + let cancel = Cancellation::new(); + let other = cancel.clone(); + let join = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(40)); + other.cancel(); + }); + let start = Instant::now(); + let result: Result<()> = run(wait_network( + std::future::pending::>(), + &cancel, + Instant::now() + Duration::from_secs(60), + )); + join.join().unwrap(); + assert_eq!(result.unwrap_err(), Error::Cancelled); + assert!(start.elapsed() < Duration::from_secs(2)); +} +#[test] +fn total_deadline_bounds_stalled_network() { + let result: Result<()> = run(wait_network( + std::future::pending::>(), + &Cancellation::new(), + Instant::now() + Duration::from_millis(5), + )); + assert_eq!(result.unwrap_err(), Error::Timeout); +} +#[test] +fn a_worker_runtime_is_required_instead_of_nested_runtime_panic() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + assert!(matches!(run(async { Ok(()) }), Err(Error::Storage(_)))); + }); +} +#[cfg(unix)] +#[test] +fn cache_and_artifact_symlinks_are_refused() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + let root = tmp.path().join("updates"); + symlink(other.path(), &root).unwrap(); + assert!(PendingDownload::new(&root, &offer(b"installer")).is_err()); + fs::remove_file(&root).unwrap(); + let mut pending = PendingDownload::new(&root, &offer(b"installer")).unwrap(); + pending.append(b"installer").unwrap(); + let artifact = pending.finish().unwrap(); + let outside = other.path().join("installer"); + fs::write(&outside, b"installer").unwrap(); + fs::remove_file(artifact.path()).unwrap(); + symlink(outside, artifact.path()).unwrap(); + assert!(artifact.revalidate().is_err()); +} +#[cfg(unix)] +#[test] +fn directories_are_private_and_insecure_existing_cache_is_refused() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("updates"); + let pending = PendingDownload::new(&root, &offer(b"installer")).unwrap(); + assert_eq!( + fs::metadata(&root).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(&pending.path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + drop(pending); + fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(PendingDownload::new(&root, &offer(b"installer")).is_err()); +} +#[test] +fn error_messages_never_echo_network_url_or_file_content() { + assert!(!Error::Network.to_string().contains("http")); + assert!(Error::Integrity("download checksum does not match release") + .to_string() + .contains("verification failed")); +} + +#[test] +fn release_candidate_channel_excludes_beta_alpha_and_mislabelled_prerelease() { + let mut flagged_stable = release("desktop-v4.0.0", Platform::MacArm64); + flagged_stable.prerelease = true; + let selected = select_release( + vec![ + release("desktop-v8.0.0-nightly.1", Platform::MacArm64), + release("desktop-v9.0.0-beta.2", Platform::MacArm64), + release("desktop-v9.0.0-alpha.1", Platform::MacArm64), + release("desktop-v9.0.0-rc.beta", Platform::MacArm64), + release("desktop-v1.0.0-rc.2", Platform::MacArm64), + release("desktop-v1.0.0-rc.10", Platform::MacArm64), + flagged_stable, + ], + Product::Desktop, + Channel::ReleaseCandidate, + Platform::MacArm64, + ) + .unwrap() + .unwrap(); + assert_eq!(selected.0.tag_name, "desktop-v1.0.0-rc.10"); + let promoted = select_release( + vec![selected.0, release("desktop-v1.0.0", Platform::MacArm64)], + Product::Desktop, + Channel::ReleaseCandidate, + Platform::MacArm64, + ) + .unwrap() + .unwrap(); + assert_eq!(promoted.0.tag_name, "desktop-v1.0.0"); +} +#[test] +fn installed_release_selects_its_default_channel_without_upgrading_stable_users_to_rc() { + for (tag, channel) in [ + (None, Channel::Preview), + (Some("desktop-v1.0.0"), Channel::Stable), + (Some("desktop-v1.0.0-rc.2"), Channel::ReleaseCandidate), + (Some("desktop-v1.0.0-beta.2"), Channel::Preview), + ] { + assert_eq!( + BuildIdentity::from_tag(Product::Desktop, tag) + .unwrap() + .default_channel(), + channel + ); + } +} + +#[test] +fn selecting_an_older_channel_never_offers_an_automatic_downgrade() { + let installed = BuildIdentity::from_tag(Product::Desktop, Some("desktop-v1.0.0")).unwrap(); + assert_eq!( + update_status(&installed, &Version::parse("1.0.0-rc.10").unwrap()), + CheckStatus::Current + ); + assert_eq!( + update_status(&installed, &Version::parse("1.0.0+build2").unwrap()), + CheckStatus::Current + ); + assert_eq!( + update_status(&installed, &Version::parse("1.0.1-rc.1").unwrap()), + CheckStatus::Available + ); +} +#[test] +fn linux_installed_format_is_preserved_and_unknown_layout_is_unsupported() { + assert_eq!( + linux_platform(Path::new("/usr/bin/octosense"), None, false), + Some(Platform::LinuxX64Deb) + ); + assert_eq!( + linux_platform( + Path::new("/mount/image/usr/bin/octosense"), + Some(Path::new("/mount/image")), + true + ), + Some(Platform::LinuxX64AppImage) + ); + assert_eq!( + linux_platform( + Path::new("/mount/image/usr/bin/octosense"), + Some(Path::new("/mount/image")), + false + ), + None + ); + assert_eq!( + linux_platform( + Path::new("/mount/other/usr/bin/octosense"), + Some(Path::new("/mount/image")), + true + ), + None + ); + assert_eq!( + linux_platform(Path::new("/home/test/bin/octosense"), None, false), + None + ); +} + +#[test] +fn android_limit_matches_native_installer_before_starting_download() { + let o = offer(b"installer"); + let mut too_large = o.asset.clone(); + too_large.size = MAX_ANDROID_INSTALLER + 1; + assert!(validate_asset(o.tag(), &too_large, Platform::AndroidArm64.maximum_size()).is_err()); + assert!(validate_asset(o.tag(), &too_large, Platform::MacArm64.maximum_size()).is_ok()); +} +#[test] +fn discarding_unused_artifact_preserves_other_downloads() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("updates"); + let mut first = PendingDownload::new(&root, &offer(b"installer")).unwrap(); + first.append(b"installer").unwrap(); + let first = first.finish().unwrap(); + let first_path = first.path().to_owned(); + let mut second = PendingDownload::new(&root, &offer(b"installer")).unwrap(); + second.append(b"installer").unwrap(); + let second = second.finish().unwrap(); + first.discard().unwrap(); + assert!(!first_path.exists()); + second.revalidate().unwrap(); + assert_eq!(fs::read_dir(root).unwrap().count(), 1); +} +#[test] +fn discarding_never_recursively_deletes_unexpected_files() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("updates"); + let mut pending = PendingDownload::new(&root, &offer(b"installer")).unwrap(); + pending.append(b"installer").unwrap(); + let artifact = pending.finish().unwrap(); + let unexpected = artifact.path().parent().unwrap().join("other.txt"); + fs::write(&unexpected, b"keep").unwrap(); + assert!(artifact.discard().is_err()); + assert_eq!(fs::read(unexpected).unwrap(), b"keep"); +} + +#[test] +fn finished_worker_does_not_wait_indefinitely_for_os_resolver_tasks() { + let (unblock, wait) = std::sync::mpsc::channel(); + let (finished, result) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let outcome = run(async { + tokio::task::spawn_blocking(move || { + let _ = wait.recv(); + }); + // Ensure the blocking task has been submitted before ending work. + tokio::task::yield_now().await; + Ok(()) + }); + finished.send(outcome).unwrap(); + }); + let bounded = result.recv_timeout(Duration::from_secs(2)); + let _ = unblock.send(()); + worker.join().unwrap(); + assert!(bounded.expect("runtime shutdown must be bounded").is_ok()); +} diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 29fac1ac3..8040dd15d 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -49,8 +49,9 @@ octosense-shell = { workspace = true } # every standard build. `mobile-apps` is the set phone builds link, also # usable on the desktop; AppCard is not among them (`--features app-appcard`). # BEGIN native-apps: features (generated by tools/native_apps.py from native-apps.json; do not edit) -default = ["octos-core", "wasm-functions", "craft-engines", "app-rinx", "app-terminal", "app-hub", "app-calculator", "app-clock", "app-notes", "app-reminders", "app-weather", "app-octoscode"] +default = ["octos-core", "wasm-functions", "craft-engines", "app-updater", "app-rinx", "app-terminal", "app-hub", "app-calculator", "app-clock", "app-notes", "app-reminders", "app-weather", "app-octoscode"] mobile-apps = ["octosense-shell/mobile-apps"] +app-updater = ["octosense-shell/app-updater"] app-rinx = ["octosense-shell/app-rinx"] app-reference = ["octosense-shell/app-reference"] app-sheets = ["octosense-shell/app-sheets"] diff --git a/desktop/scripts/package.py b/desktop/scripts/package.py index 8c8fb1d95..7b2bc63a9 100644 --- a/desktop/scripts/package.py +++ b/desktop/scripts/package.py @@ -13,6 +13,10 @@ `crate_resource(...)` is read from the package, never from `.sources/` or the checkout. Paths in the binary are remapped (`--remap-path-prefix` for the home directory, `CARGO_HOME` and the checkout) and debug info stripped (symbol names stay, so backtraces and `[ui-hang]` reports remain readable). + The build explicitly selects this machine's Rust target and the repository's + `target/` directory; packaging and identity checks use that exact + `target//release/` executable, regardless of inherited Cargo + target or output-directory settings. 2. **Stage resources**: the `resources/` directory of every git or path crate the app links (Makepad's widgets and fonts, the shell's icons, App Hub, Rinx, ...) into `target/octosense-package/resources//`, the @@ -50,6 +54,7 @@ publishing anything. """ import argparse +import hashlib import importlib.util import json import os @@ -86,12 +91,55 @@ def version_from_tag(tag): """`desktop-v1.2.3` (or a ref ending in it) -> `1.2.3`. Installers need a numeric version, so anything else is refused.""" name = tag.rsplit("/", 1)[-1] - match = re.fullmatch(r"desktop-v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)", name) + number = r"(?:0|[1-9][0-9]*)" + match = re.fullmatch(rf"desktop-v({number}\.{number}\.{number}(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)", name) if not match: raise ValueError(f"{tag!r} is not a desktop release tag (desktop-v..[-
])")
+    if "-" in match.group(1):
+        prerelease = match.group(1).split("-", 1)[1]
+        if any(part.isdigit() and len(part) > 1 and part.startswith("0") for part in prerelease.split(".")):
+            raise ValueError("Numeric prerelease identifiers cannot have leading zeroes")
     return match.group(1)
 
 
+def release_identity(version=None, tag=None):
+    """Only explicit release packaging gets an updater identity. A generic
+    Cargo build must not accidentally advertise its 0.1.0 as a release."""
+    if tag:
+        tagged_version = version_from_tag(tag)
+        if version and version != tagged_version:
+            raise ValueError("--version and --version-from-tag must name the same release")
+        return f"desktop-v{tagged_version}"
+    if version:
+        version_from_tag(f"desktop-v{version}")
+        return f"desktop-v{version}"
+    return ""
+
+
+def binary_sha256(path):
+    digest = hashlib.sha256()
+    with path.open("rb") as source:
+        for chunk in iter(lambda: source.read(1024 * 1024), b""):
+            digest.update(chunk)
+    return digest.hexdigest()
+
+
+def record_release_identity(binary, tag):
+    """Keep tagged --skip-build packaging bound to the binary actually built
+    with that tag, instead of silently relabeling an earlier executable."""
+    receipt = {"release_tag": tag, "sha256": binary_sha256(binary)}
+    binary.with_name(binary.name + ".release-identity.json").write_text(json.dumps(receipt) + "\n")
+
+
+def verify_release_identity(binary, tag):
+    try:
+        receipt = json.loads(binary.with_name(binary.name + ".release-identity.json").read_text())
+    except (OSError, ValueError) as error:
+        raise RuntimeError("Tagged --skip-build requires a release-identity receipt from an earlier package.py build; rebuild without --skip-build") from error
+    if receipt != {"release_tag": tag, "sha256": binary_sha256(binary)}:
+        raise RuntimeError("The reused binary does not match the requested release tag; rebuild without --skip-build")
+
+
 def remap_flags(root, home=None, cargo_home=None):
     """`--remap-path-prefix` pairs for rustc, general first: when several
     match, rustc applies the last, so the checkout (`.`) beats the home dir
@@ -159,6 +207,17 @@ def host_triple():
     return re.search(r"^host: (\S+)$", out, re.M).group(1)
 
 
+def native_build_layout(root, os_name, triple):
+    """Build and package the same native artifact even when Cargo config or
+    environment selects another target/target-dir. An explicit --target puts
+    release output below that triple, not directly in target/release."""
+    target_dir = root / "target"
+    binaries_dir = target_dir / triple / "release"
+    binary = binaries_dir / (PACKAGE + (".exe" if os_name == "windows" else ""))
+    options = ["--target", triple, "--target-dir", str(target_dir)]
+    return binaries_dir, binary, options
+
+
 def resource_crates(metadata, linked):
     """(crate_name, resources dir) of every linked git or path package that
     has a `resources/` directory. Registry crates are left out: Makepad
@@ -295,7 +354,10 @@ def main(argv=None):
     os_name = host_os()
     formats = args.formats.split(",") if args.formats else DEFAULT_FORMATS[os_name]
     version = version_from_tag(args.version_from_tag) if args.version_from_tag else (args.version or cargo_version())
+    release_tag = release_identity(args.version, args.version_from_tag)
     added = package_env(os_name, ROOT, os.environ)
+    # Override a stray inherited value even for a non-release build.
+    added["OCTOSENSE_RELEASE_TAG"] = release_tag
     if args.print_env:
         print(json.dumps(added, indent=2))
         return
@@ -304,11 +366,16 @@ def main(argv=None):
     out.mkdir(parents=True, exist_ok=True)
     offline = ["--offline"] if args.offline else []
     features = feature_args(args.features)
+    triple = host_triple()
+    binaries_dir, binary, build_options = native_build_layout(ROOT, os_name, triple)
 
     if not args.skip_build:
         print(f"==> cargo build --release -p {PACKAGE} (MAKEPAD_PACKAGE_DIR={added['MAKEPAD_PACKAGE_DIR']})", flush=True)
-        subprocess.run(["cargo", "build", "--locked", "--release", "-p", PACKAGE, *features, *offline],
+        subprocess.run(["cargo", "build", "--locked", "--release", "-p", PACKAGE, *build_options, *features, *offline],
                        cwd=ROOT, env=env, check=True)
+        record_release_identity(binary, release_tag)
+    elif release_tag:
+        verify_release_identity(binary, release_tag)
 
     print("==> staging resources", flush=True)
     metadata = json.loads(cargo_json(["metadata", "--locked", "--format-version", "1"], env))
@@ -318,7 +385,6 @@ def main(argv=None):
         size = sum(f.stat().st_size for f in (resources / name).rglob("*") if f.is_file())
         print(f"    {name:<28} {size / 1e6:7.1f} MB  <- {src.relative_to(ROOT) if ROOT in src.parents else name}")
 
-    triple = host_triple()
     kernel_receipt = None
     sidecar = None
     if not args.no_kernel:
@@ -333,7 +399,6 @@ def main(argv=None):
         print("==> no kernel: this package runs without an assistant", flush=True)
 
     base = json.loads((PACKAGING / "release.json").read_text())
-    binaries_dir = ROOT / "target" / "release"
     config = packager_config(base, version=version, binaries_dir=binaries_dir, out_dir=out / "dist",
                              resources=resources, kernel=sidecar)
     if os_name == "linux":
@@ -350,13 +415,13 @@ def main(argv=None):
     receipt_path = out / "receipt.json"
     receipt_path.unlink(missing_ok=True)  # A failed restage must not leave an old success receipt.
     receipt = {
-        "version": version, "target": triple, "formats": formats,
+        "version": version, "release_tag": release_tag or None, "target": triple, "formats": formats,
         "resources": [name for name, _ in crates], "kernel": kernel_receipt,
         "makepad_package_dir": added["MAKEPAD_PACKAGE_DIR"],
     }
     print(f"==> cargo packager --formats {','.join(formats)}", flush=True)
     try:
-        subprocess.run(["cargo", "packager", "--release", "--config", str(generated), "--formats", ",".join(formats)],
+        subprocess.run(["cargo", "packager", "--release", "--target", triple, "--config", str(generated), "--formats", ",".join(formats)],
                        cwd=PACKAGING, env=env, check=True)
     finally:
         generated.unlink(missing_ok=True)
diff --git a/desktop/scripts/test_package.py b/desktop/scripts/test_package.py
index 45055bb7c..9e506f7a2 100644
--- a/desktop/scripts/test_package.py
+++ b/desktop/scripts/test_package.py
@@ -1,5 +1,6 @@
 """Tests for desktop/scripts/package.py (no build, no network)."""
 import hashlib
+import contextlib
 import importlib.util
 import io
 import json
@@ -232,13 +233,80 @@ class VersionTests(unittest.TestCase):
     def test_the_version_comes_from_a_desktop_tag(self):
         self.assertEqual(package.version_from_tag("desktop-v0.2.0"), "0.2.0")
         self.assertEqual(package.version_from_tag("refs/tags/desktop-v1.0.0-rc.1"), "1.0.0-rc.1")
-        for tag in ("home-v0.2.0", "rom-v20260919-j", "desktop-0.2.0", "desktop-v0.2", "desktop-vX"):
+        for tag in ("home-v0.2.0", "rom-v20260919-j", "desktop-0.2.0", "desktop-v0.2", "desktop-vX",
+                    "desktop-v01.2.3", "desktop-v1.2.3-rc..1", "desktop-v1.2.3-rc.01"):
             with self.assertRaises(ValueError, msg=tag):
                 package.version_from_tag(tag)
 
     def test_the_default_version_is_the_packages(self):
         self.assertRegex(package.cargo_version(), r"^\d+\.\d+\.\d+")
 
+    def test_release_identity_requires_explicit_consistent_release_input(self):
+        self.assertEqual(package.release_identity(), "")
+        self.assertEqual(package.release_identity("1.2.3-rc.1"), "desktop-v1.2.3-rc.1")
+        self.assertEqual(package.release_identity(tag="refs/tags/desktop-v1.2.3"), "desktop-v1.2.3")
+        for version, tag in (("1.0.0", "desktop-v2.0.0"), ("dev", None), (None, "home-v1.0.0")):
+            with self.assertRaises(ValueError):
+                package.release_identity(version, tag)
+
+    def test_reused_binary_cannot_be_relabelled_or_changed_since_build(self):
+        with tempfile.TemporaryDirectory() as directory:
+            binary = Path(directory) / "octosense"
+            binary.write_bytes(b"synthetic executable")
+            with self.assertRaisesRegex(RuntimeError, "receipt"):
+                package.verify_release_identity(binary, "desktop-v1.0.0")
+            package.record_release_identity(binary, "desktop-v1.0.0")
+            package.verify_release_identity(binary, "desktop-v1.0.0")
+            with self.assertRaisesRegex(RuntimeError, "does not match"):
+                package.verify_release_identity(binary, "desktop-v2.0.0")
+            binary.write_bytes(b"different executable")
+            with self.assertRaisesRegex(RuntimeError, "does not match"):
+                package.verify_release_identity(binary, "desktop-v1.0.0")
+
+    def test_print_env_overrides_inherited_release_tag_for_development_build(self):
+        for args, expected in (([], ""), (["--version-from-tag", "desktop-v1.0.0-rc.1"], "desktop-v1.0.0-rc.1")):
+            output = io.StringIO()
+            with mock.patch.dict(os.environ, {"OCTOSENSE_RELEASE_TAG": "desktop-v99.0.0"}), contextlib.redirect_stdout(output):
+                package.main(["--print-env", *args])
+            self.assertEqual(json.loads(output.getvalue())["OCTOSENSE_RELEASE_TAG"], expected)
+
+    def test_native_layout_binds_windows_receipt_and_packaging_to_the_target(self):
+        root = Path("/build/root with spaces")
+        directory, binary, options = package.native_build_layout(root, "windows", "x86_64-pc-windows-msvc")
+        self.assertEqual(binary, directory / "octosense.exe")
+        self.assertEqual(directory, root / "target/x86_64-pc-windows-msvc/release")
+        self.assertEqual(options, ["--target", "x86_64-pc-windows-msvc", "--target-dir", str(root / "target")])
+
+    @unittest.skipUnless(shutil.which("cargo") and shutil.which("rustc"), "requires installed Rust tools")
+    def test_real_cargo_target_overrides_cannot_bless_a_stale_release_binary(self):
+        # A dependency-free Cargo fixture exercises real config/env precedence;
+        # it does not build OctoSense, download dependencies, or run an installer.
+        with tempfile.TemporaryDirectory() as temporary:
+            root = Path(temporary)
+            (root / "src").mkdir()
+            (root / "Cargo.toml").write_text('[package]\nname="octosense"\nversion="0.1.0"\nedition="2021"\n[workspace]\n')
+            (root / "src/main.rs").write_text('fn main() { println!("{}", env!("OCTOSENSE_RELEASE_TAG")); }\n')
+            env = dict(os.environ, OCTOSENSE_RELEASE_TAG="desktop-v1.2.3-rc.4")
+            # An existing normal-path executable must never get the new tag.
+            stale = root / "target/release" / ("octosense.exe" if os.name == "nt" else "octosense")
+            stale.parent.mkdir(parents=True)
+            stale.write_bytes(b"old build -- must not be packaged")
+            subprocess_args = {"cwd": root, "env": env, "check": True, "capture_output": True, "text": True}
+            package.subprocess.run(["cargo", "generate-lockfile", "--offline"], **subprocess_args)
+            (root / ".cargo").mkdir()
+            (root / ".cargo/config.toml").write_text('[build]\ntarget="not-a-real-config-target"\ntarget-dir="other-config-target"\n')
+            env.update(CARGO_TARGET_DIR=str(root / "other-env-target"), CARGO_BUILD_TARGET="not-a-real-env-target")
+            directory, binary, options = package.native_build_layout(root, package.host_os(), package.host_triple())
+            package.subprocess.run(["cargo", "build", "--locked", "--offline", "--release", "-p", "octosense", *options], **subprocess_args)
+            self.assertEqual(package.subprocess.check_output([str(binary)], text=True).strip(), "desktop-v1.2.3-rc.4")
+            package.record_release_identity(binary, env["OCTOSENSE_RELEASE_TAG"])
+            package.verify_release_identity(binary, env["OCTOSENSE_RELEASE_TAG"])
+            self.assertFalse(stale.with_name(stale.name + ".release-identity.json").exists())
+            self.assertEqual(stale.read_bytes(), b"old build -- must not be packaged")
+            self.assertEqual(binary.parent, directory)
+            self.assertFalse((root / "other-env-target").exists())
+            self.assertFalse((root / "other-config-target").exists())
+
 
 class EnvironmentTests(unittest.TestCase):
     def test_each_os_reads_resources_where_its_package_puts_them(self):
diff --git a/docs/updater.md b/docs/updater.md
new file mode 100644
index 000000000..2b49da911
--- /dev/null
+++ b/docs/updater.md
@@ -0,0 +1,136 @@
+# Updating OctoSense
+
+English | [简体中文](updater.zh-CN.md)
+
+Open **OctoSense Updates** in the desktop or Home launcher. Android Home also
+offers **Settings → Updates → Open OctoSense app updater**. Choose a channel,
+then **Check for updates**. Review the offered version and download size, choose
+**Download update**, and open the installer separately after verification.
+
+| Channel | Eligible releases |
+| --- | --- |
+| Stable | Stable releases only. |
+| Release candidate | Stable releases and `rc` prereleases; excludes beta, alpha and nightly releases. |
+| Preview | Stable releases and all semantic prereleases. |
+
+An installed RC defaults to the release-candidate channel. Versions compare
+numerically: `rc.10` follows `rc.9`, and the corresponding stable version follows
+its RCs. Switching channels does not permit a downgrade.
+
+This updater is introduced by this source change. Published desktop
+`0.1.0-rc.2` and Home `0.1.0-beta.2` do not contain it; install a newer release
+containing the updater once to get this interface. Unversioned development
+builds do not know their installed release and cannot offer an update.
+
+## Installation
+
+| Platform | Handoff | What the user finishes |
+| --- | --- | --- |
+| macOS, Apple silicon | Opens the verified `.dmg` | Quit OctoSense, drag the new app into Applications, confirm replacement, then reopen it. |
+| Windows, x64 | Starts the verified NSIS `.exe` directly | Follow its prompts; close OctoSense when requested. |
+| Linux, x86_64, Debian package | Opens the verified `.deb` with the desktop's package handler | Approve the package installation, then restart. Without a desktop package handler, use the distribution's package manager. |
+| Linux, x86_64, AppImage | Opens the download folder | Quit OctoSense, replace the old AppImage, allow execution in its file properties, then reopen it. The updater does not overwrite the running image. |
+| Android, ARM64 | Checks the verified APK against installed Home and stages it with Android's package installer | Review and confirm the operating system's installation prompt. |
+
+Opening an installer does **not** report a completed installation. This first
+version does not check periodically, silently quit, elevate itself, overwrite
+the running application, or remove the user's profile. OS installation and
+signature checks still apply.
+
+Android 13 (API 33) or newer is required. Android updates **standalone Home** only. System Bridge and ROM updates remain
+separate. The installed package and candidate must have the public standalone
+Home signing identity; system-installed Home, ROM signatures, isolated test
+packages and other signers are refused. The candidate must have a higher Android
+version code and a compatible minimum Android version. If Android has not
+allowed OctoSense to install packages, the user can open its permission settings,
+return, and explicitly try installation again. No automatic installation resumes
+after changing that permission. An APK is never installed by first uninstalling
+Home to bypass a signer mismatch.
+
+## Verification and privacy
+
+The host reads public releases from `OctoSense-org/OctoSense` on GitHub. Desktop
+selects `desktop-v…` tags; Home selects `home-v…` tags. Semantic version and the
+exact platform asset name decide the offer; release publication order does not.
+
+Both GitHub's SHA-256 asset digests and `SHA256SUMS` are required. The updater
+checks the checksum file's own digest, requires its installer hash to match
+GitHub's metadata, then verifies the complete downloaded installer and declared
+size. Downloads use private staging and are rechecked before OS handoff.
+Malformed metadata, unexpected URLs, symlinks, incomplete or modified files are
+refused. This trusts the project's GitHub release account over HTTPS; a checksum
+is not an independent publisher signature.
+
+Desktop downloads live in the shell's `updates/` directory (`OCTOSENSE_HOME` if
+set, otherwise `~/.octosense/updates`). Android uses app-private cache under
+`octosense-updates/`. Checking releases needs no OctoSense, GitHub or Google
+login. No mail, app contents, model credentials or agent conversations are sent.
+The updater is a native host interface, not an App Hub app or an agent tool.
+
+## Code and release identity
+
+- [`crates/updater`](../crates/updater/src/lib.rs) owns channels, release
+  selection, bounded downloads, cancellation and verified artifacts.
+- [`crates/updater-ui`](../crates/updater-ui/src/lib.rs) owns the native module
+  and explicit user actions. Network work and hashing run off the drawing thread.
+- [`install.rs`](../crates/updater-ui/src/install.rs) uses literal arguments
+  for desktop installer handoff; release data never becomes a shell command.
+- Android's native extension independently checks package, version, signer and
+  bytes before staging a session that requires OS user confirmation.
+- [`native-apps.json`](../native-apps.json) registers the module in desktop and
+  Home, without an agent update tool.
+
+Cargo's common `0.1.0` package version is not the installed release identity.
+Packaging embeds `OCTOSENSE_RELEASE_TAG` instead. The
+[desktop packager](../desktop/scripts/package.py) derives it from an explicit
+`--version-from-tag` or `--version`. Tagged `--skip-build` packaging requires a
+matching tag and binary SHA-256 receipt from an earlier build, preventing a stale
+binary from being relabeled. Generic builds clear inherited tags. The
+desktop build and packager explicitly select the native Rust target; the binary
+and identity receipt come from `target//release/`. Inherited
+`CARGO_TARGET_DIR`, `CARGO_BUILD_TARGET` or Cargo configuration cannot redirect
+the build while leaving packaging to read a stale `target/release` executable.
+The [Home builder](../rom/scripts/build-home.py) sets the tag only for a versioned,
+signed standalone build; development and ROM builds leave it empty.
+
+## What we learned from OctosCode
+
+The native OctosCode app at `d47fade94498` documents a manual RC download; it
+does not contain the CLI updater. The separate
+[`octoscode update` CLI](https://github.com/octos-org/octoscode/blob/8722b551a7ee653c2457f1152a636160351b9b1f/src/cmd/update.rs)
+uses `axoupdater` for receipt-owned cargo-dist installations and defers to
+Homebrew, npm or Cargo for installations they own. Its prerelease option resolves
+an exact channel tag and tests numeric RC ordering. OctoSense follows those
+ownership and explicit-channel principles while handing complete app packages
+to the native installer. Unlike that CLI's explicit prerelease channel switch,
+this interface does not offer a stable-to-RC downgrade.
+
+The [octos kernel updater](https://github.com/octos-org/octos/blob/084baa522a508cf3af7d7ce9585868ac710eb1d9/crates/octos-services/src/updater.rs)
+backs up and replaces a bounded set of CLI binaries with rollback. It is not a
+full Makepad app-bundle or Android installer. OctoSense also requires checksums
+even for older releases, rather than allowing the kernel updater's legacy
+missing-checksum fallback.
+
+## Validation scope
+
+Desktop handoff tests check executable names and literal arguments without
+launching installers. Packaging tests verify identities, stale-binary refusal
+and build planning without producing release packages.
+
+A hidden native macOS UI checked the live RC1 → RC2 offer and downloaded and
+verified its 297 MB disk image. A deliberate change to that isolated download
+made **Install** reject it before any installer opened. Channel changes and
+download cancellation were exercised; the actual desktop shell also opened the
+Updates module and checked the live RC2 offer with an isolated profile.
+Replacing the installed macOS app remains **unverified**, as do real Windows
+and Linux installations.
+
+On an owned API 35 Android emulator, a fixture using the actual Java updater
+adapter and OS PackageInstaller passed 25 acceptance checks, including cleanup: permission changes
+did not replay installation automatically; wrong signatures, packages, hashes,
+version codes and minimum SDK levels were rejected; the visible OS confirmation
+could be cancelled and retried; version code 1000 → 1001 retained the app UID
+and synthetic app data, with status recovery after replacement. This exercised
+the native adapter, **not** the complete Rust Home binary. The physical phone
+was untouched; end-to-end installation from a released Home build remains
+**unverified**.
diff --git a/docs/updater.zh-CN.md b/docs/updater.zh-CN.md
new file mode 100644
index 000000000..de6cb9f23
--- /dev/null
+++ b/docs/updater.zh-CN.md
@@ -0,0 +1,115 @@
+# 更新 OctoSense
+
+[English](updater.md) | 简体中文
+
+在桌面版或 Home 启动器中打开 **OctoSense Updates**。Android Home 也提供
+**Settings → Updates → Open OctoSense app updater** 入口。先选择通道,再点
+**Check for updates**。查看版本号与下载大小,点 **Download update**;校验完成
+后,还需单独操作才能打开安装程序。
+
+| 通道 | 可选版本 |
+| --- | --- |
+| Stable(稳定版) | 仅稳定版。 |
+| Release candidate(候选版) | 稳定版和 `rc` 预发布;排除 beta、alpha、nightly。 |
+| Preview(预览版) | 稳定版及所有符合语义版本格式的预发布版。 |
+
+已安装 RC 时默认选择候选版通道。版本按数值比较:`rc.10` 高于 `rc.9`,相应
+正式版高于其 RC。切换通道不会允许降级。
+
+更新器随本次源代码变更引入。此前发布的桌面版 `0.1.0-rc.2` 和 Home
+`0.1.0-beta.2` 不含此功能;需要先手动安装一次包含更新器的新版本。没有发布
+标签的开发构建无法确定当前安装的发行版,因此不提供更新。
+
+## 安装
+
+| 平台 | 交接方式 | 用户还需完成的操作 |
+| --- | --- | --- |
+| macOS,Apple silicon | 打开已校验的 `.dmg` | 退出 OctoSense,将新应用拖入 Applications,确认替换,再打开。 |
+| Windows,x64 | 直接启动已校验的 NSIS `.exe` | 按提示安装,在要求时关闭 OctoSense。 |
+| Linux,x86_64,Debian 包 | 用桌面包处理程序打开 `.deb` | 在包管理器中确认安装并重启。没有图形包处理程序时使用发行版的包管理器。 |
+| Linux,x86_64,AppImage | 打开下载目录 | 退出 OctoSense,替换旧 AppImage,在文件属性中允许执行,再打开。更新器不覆盖运行中的镜像。 |
+| Android,ARM64 | 对照已安装 Home 检查 APK,通过 Android 包安装程序暂存 | 查看并确认操作系统的安装提示。 |
+
+打开安装程序**不代表**安装完成。首个版本不会定期检查、静默退出、自行提权、
+覆盖运行中的应用或删除用户配置。操作系统原有的安装与签名检查仍然生效。
+
+需要 Android 13(API 33)或更新版本。Android 仅更新**独立版 Home**,System Bridge 与 ROM 继续使用各自的更新途径。
+已安装包与候选包必须使用公开独立版 Home 的签名身份;系统内置 Home、ROM
+签名、隔离测试包或其他签名均会被拒绝。候选包的 Android 版本代码必须更高,
+最低 Android 版本必须兼容。若尚未允许 OctoSense 安装应用,用户可以打开系统
+权限设置,返回后再次明确点击安装。更改权限后不会自动恢复安装,也不会先卸载
+Home 来绕过签名不匹配。
+
+## 校验与隐私
+
+宿主从 GitHub 的 `OctoSense-org/OctoSense` 读取公开发布。桌面版选择
+`desktop-v…` 标签,Home 选择 `home-v…`。语义版本与确切的平台文件名决定候选
+更新;发布时间先后不决定版本高低。
+
+发布必须同时提供 GitHub 的 SHA-256 文件摘要与 `SHA256SUMS`。更新器检查
+校验和文件本身的摘要,确认其中的安装包哈希与 GitHub 元数据一致,再校验整个
+下载文件及声明的大小。文件下载到私有暂存目录,交给操作系统前再次校验。无效
+元数据、非预期 URL、符号链接、不完整或已修改文件均会被拒绝。此机制通过
+HTTPS 信任项目的 GitHub 发布账户;校验和不是独立的发布者签名。
+
+桌面版下载位于 shell 的 `updates/` 目录(设置了 `OCTOSENSE_HOME` 时以其为
+根目录,否则是 `~/.octosense/updates`)。Android 使用应用私有缓存中的
+`octosense-updates/`。检查发布不需要 OctoSense、GitHub 或 Google 登录。
+不会发送邮件、应用内容、模型凭证或代理对话。更新器是宿主原生界面,不是
+App Hub 应用或代理工具。
+
+## 代码与发布身份
+
+- [`crates/updater`](../crates/updater/src/lib.rs) 负责通道、版本选择、受限下载、
+  取消和已校验文件。
+- [`crates/updater-ui`](../crates/updater-ui/src/lib.rs) 负责原生模块与明确的用户
+  操作。网络和哈希任务不占用绘制线程。
+- [`install.rs`](../crates/updater-ui/src/install.rs) 用原样参数交给桌面安装程序,
+  不把发布内容插入 shell 命令。
+- Android 原生扩展独立检查包名、版本、签名和文件字节,再提交需要操作系统
+  用户确认的安装会话。
+- [`native-apps.json`](../native-apps.json) 为桌面版和 Home 注册模块,不提供代理
+  更新工具。
+
+Cargo 通用的 `0.1.0` 包版本不是实际发行版身份,因此打包时嵌入
+`OCTOSENSE_RELEASE_TAG`。[桌面打包程序](../desktop/scripts/package.py) 从显式
+的 `--version-from-tag` 或 `--version` 设置标签。带标签的 `--skip-build` 必须
+匹配先前构建的标签和二进制 SHA-256 收据,防止旧程序被标成新版本。普通构建会
+清除继承标签。桌面构建和打包程序显式选择本机 Rust 目标,二进制与身份收据来自
+`target//release/`。继承的 `CARGO_TARGET_DIR`、
+`CARGO_BUILD_TARGET` 或 Cargo 配置不能把构建重定向后,让打包继续读取旧的
+`target/release` 程序。[Home 构建程序](../rom/scripts/build-home.py) 只对指定版本、正式
+签名的独立构建设置标签;开发与 ROM 构建留空。
+
+## 借鉴 OctosCode
+
+原生 OctosCode 应用在 `d47fade94498` 的文档中要求手动下载 RC,并不包含 CLI
+更新器。独立的 [`octoscode update` CLI](https://github.com/octos-org/octoscode/blob/8722b551a7ee653c2457f1152a636160351b9b1f/src/cmd/update.rs)
+使用 `axoupdater` 更新带 cargo-dist 安装收据的程序;由 Homebrew、npm 或 Cargo
+管理的安装则交还相应管理器。它的预发布选项解析确切通道标签,并测试 RC 序号的
+数值顺序。OctoSense 采用这些安装归属与明确通道的原则,但把完整应用包交给原生
+安装程序。与该 CLI 显式切换预发布通道不同,本界面不提供正式版降级到 RC。
+
+[octos 内核更新器](https://github.com/octos-org/octos/blob/084baa522a508cf3af7d7ce9585868ac710eb1d9/crates/octos-services/src/updater.rs)
+可备份、替换指定 CLI 二进制并回滚,但不是完整 Makepad 应用包或 Android
+安装器。OctoSense 对旧发布也强制要求校验和,不采用内核更新器允许旧版缺失
+校验和的兼容策略。
+
+## 验证范围
+
+桌面交接测试检查程序名和原样参数,不启动安装程序。打包测试检查发布身份、拒绝
+旧二进制和构建计划,不生成发布包。
+
+隐藏窗口中的原生 macOS 界面检查了真实的 RC1 → RC2 候选更新,下载并校验了
+297 MB 磁盘镜像。故意修改隔离下载文件后,点击 **Install** 在任何安装程序打开
+之前即拒绝该文件。还验证了切换通道和取消下载;实际桌面 shell 也在隔离配置中
+打开了 Updates 模块并检查了真实 RC2 候选更新。
+替换已安装的 macOS 应用仍属**未经验证**,Windows 与 Linux
+真实安装也尚未验证。
+
+在自有 API 35 Android 模拟器中,使用实际 Java 更新适配器和操作系统
+PackageInstaller 的测试应用通过了含清理步骤在内的 25 项验收:更改安装权限后不自动重放安装;
+拒绝错误签名、包名、哈希、版本代码和最低 SDK;可取消并重试可见的系统确认;
+版本代码 1000 → 1001 更新保留了 UID 与模拟应用数据,替换后可恢复状态。
+此验收覆盖原生适配器,**不是完整的 Rust Home 二进制**。没有改动实体手机;
+从已发布 Home 构建发起的完整安装流程仍属**未经验证**。
diff --git a/native-apps.json b/native-apps.json
index dc2469715..22c4cb36d 100644
--- a/native-apps.json
+++ b/native-apps.json
@@ -1,6 +1,44 @@
 {
   "schema": 1,
   "apps": [
+    {
+      "id": "updater",
+      "crate": "octosense-updater-ui",
+      "source": {
+        "path": "crates/updater-ui"
+      },
+      "module": "octosense_updater_ui::UPDATER_MODULE",
+      "bin": null,
+      "default_features": true,
+      "crate_features": [],
+      "implies": [],
+      "hosting": {
+        "macos": "module",
+        "windows": "module",
+        "linux": "module",
+        "android": "module",
+        "ios": "module",
+        "ohos": "module"
+      },
+      "shells": {
+        "desktop": "default",
+        "phone": "default"
+      },
+      "native_mobile": "always",
+      "sandbox": {
+        "network": "any",
+        "processes": false
+      },
+      "storage": {
+        "accounts": false,
+        "agent_workspace": "none",
+        "external": []
+      },
+      "agent": {
+        "octos": [],
+        "tools": null
+      }
+    },
     {
       "id": "rinx",
       "crate": "rinx",
diff --git a/phone/Cargo.toml b/phone/Cargo.toml
index 3081644b9..7e8e0fcf0 100644
--- a/phone/Cargo.toml
+++ b/phone/Cargo.toml
@@ -83,8 +83,9 @@ octos-core = ["octosense-shell/octos-core"]
 # phone builds link, also usable on a desktop; AppCard is not among them
 # (`--features app-appcard`).
 # BEGIN native-apps: features (generated by tools/native_apps.py from native-apps.json; do not edit)
-default = ["octos-core", "wasm-functions", "toolbox-peers", "app-rinx", "app-hub", "app-calculator", "app-clock", "app-notes", "app-reminders", "app-weather", "app-octoscode"]
+default = ["octos-core", "wasm-functions", "toolbox-peers", "app-updater", "app-rinx", "app-hub", "app-calculator", "app-clock", "app-notes", "app-reminders", "app-weather", "app-octoscode"]
 mobile-apps = ["app-reference", "app-hub", "octosense-shell/mobile-apps"]
+app-updater = ["octosense-shell/app-updater"]
 app-rinx = ["octosense-shell/app-rinx"]
 app-reference = ["dep:octosense-reference", "octosense-shell/app-reference"]
 app-sheets = ["octosense-shell/app-sheets"]
diff --git a/phone/resources/android/AndroidManifest.xml.template b/phone/resources/android/AndroidManifest.xml.template
index 56aa3c7a0..b0b6da96c 100644
--- a/phone/resources/android/AndroidManifest.xml.template
+++ b/phone/resources/android/AndroidManifest.xml.template
@@ -173,6 +173,12 @@
             android:exported="false" android:excludeFromRecents="true"
             android:taskAffinity="dev.makepad.octosense.qrpick"
             android:theme="@android:style/Theme.Translucent.NoTitleBar" />
+        
+        
         
         
     
     
+    
+    
     
     
     
diff --git a/phone/resources/android/java/dev/makepad/octosense/HomeUpdateInstallActivity.java b/phone/resources/android/java/dev/makepad/octosense/HomeUpdateInstallActivity.java
new file mode 100644
index 000000000..6ea6b9012
--- /dev/null
+++ b/phone/resources/android/java/dev/makepad/octosense/HomeUpdateInstallActivity.java
@@ -0,0 +1,145 @@
+package dev.makepad.octosense;
+
+import android.app.Activity;
+import android.app.ActivityOptions;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageInstaller;
+import android.net.Uri;
+import android.os.Bundle;
+
+/** A non-exported callback for one reviewed PackageInstaller session. A persisted,
+ * unpredictable token binds every callback to that session even after process death.
+ * The system installer, not this Activity or the Rust UI, approves the APK update. */
+public final class HomeUpdateInstallActivity extends Activity {
+    private static final String TOKEN="octosense.update.token";
+    private static final String START="dev.makepad.octosense.update.START";
+    private static final String RESULT="dev.makepad.octosense.update.RESULT";
+    private static final int CONFIRM=0x5531;
+    private boolean confirmationOpen;
+
+    static SharedPreferences state(Context context) {return context.getSharedPreferences("home-update-session",Context.MODE_PRIVATE);}
+    static synchronized void prepare(Context context,int session,String token,long version) throws java.io.IOException {
+        if(!state(context).edit().clear().putInt("session",session).putString("token",token)
+                .putLong("version",version).putLong("started_ms",System.currentTimeMillis()).putString("status","preparing").putString("message","Preparing Android confirmation.").commit())
+            throw new java.io.IOException("Update state could not be saved.");
+    }
+    static void start(Context context,String token) {
+        context.startActivity(new Intent(context,HomeUpdateInstallActivity.class).setAction(START)
+            .putExtra(TOKEN,token).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
+    }
+    static synchronized void record(Context context,String status,String message) {
+        state(context).edit().putString("status",status).putString("message",message).apply();
+    }
+    static synchronized void cancel(Context context,int session,String message) {
+        try{context.getPackageManager().getPackageInstaller().abandonSession(session);}catch(Exception ignored){}
+        // A queued main-thread callback may belong to an earlier preparation.
+        // Its cleanup must not overwrite the status of a newer session.
+        if(state(context).getInt("session",-1)==session)record(context,"cancelled",message);
+    }
+    /** Native window closure only cancels preparation. Once commit begins the
+     * Android-owned confirmation and result must survive renderer shutdown. */
+    static synchronized void cancelPreparation(Context context,int session) {
+        SharedPreferences prefs=state(context);
+        if(prefs.getInt("session",-1)==session&&prefs.getString("status","").equals("preparing"))
+            cancel(context,session,"Update preparation cancelled.");
+    }
+    static synchronized void reconcile(Context context) {
+        SharedPreferences prefs=state(context);String status=prefs.getString("status","idle");
+        if(!status.equals("preparing")&&!status.equals("awaiting_confirmation"))return;
+        int session=prefs.getInt("session",-1);
+        try {
+            if(context.getPackageManager().getPackageInfo(context.getPackageName(),0).getLongVersionCode()>=prefs.getLong("version",Long.MAX_VALUE)) {
+                record(context,"installed","OctoSense is up to date.");return;
+            }
+            if(session<0||context.getPackageManager().getPackageInstaller().getSessionInfo(session)==null)
+                record(context,"cancelled","The previous update did not complete. Choose Install to retry.");
+            else {
+                long elapsed=System.currentTimeMillis()-prefs.getLong("started_ms",0);
+                long deadline=status.equals("preparing")?120000L:86400000L;
+                if(elapsed<0||elapsed>deadline)cancel(context,session,"The previous installation expired. Choose Install to retry.");
+            }
+        } catch(Exception unavailable) {record(context,"error","Could not read the previous Android installation result.");}
+    }
+    @Override protected void onCreate(Bundle saved) {
+        super.onCreate(saved);
+        if(android.os.Build.VERSION.SDK_INT>=31)getWindow().setHideOverlayWindows(true);
+        confirmationOpen=saved!=null&&saved.getBoolean("confirmation_open",false);
+        if(saved==null)handle(getIntent());
+    }
+    @Override protected void onNewIntent(Intent intent) {super.onNewIntent(intent);setIntent(intent);handle(intent);}
+    @Override protected void onSaveInstanceState(Bundle out) {super.onSaveInstanceState(out);out.putBoolean("confirmation_open",confirmationOpen);}
+
+    private void handle(Intent intent) {
+        synchronized(HomeUpdateInstallActivity.class) {handleLocked(intent);}
+    }
+    private void handleLocked(Intent intent) {
+        SharedPreferences prefs=state(this);
+        String token=intent==null?null:intent.getStringExtra(TOKEN);
+        String expected=prefs.getString("token",null);
+        int sessionId=prefs.getInt("session",-1);
+        if(token==null||expected==null||!expected.equals(token)||sessionId<0) {finish();return;}
+        String current=prefs.getString("status","idle");
+        if(!current.equals("preparing")&&!current.equals("awaiting_confirmation")){finish();return;}
+        try {
+            if(START.equals(intent.getAction())) {
+                if(!current.equals("preparing")){finish();return;}
+                Intent callback=new Intent(this,HomeUpdateInstallActivity.class).setAction(RESULT)
+                    .setData(Uri.parse("octosense-update:"+token)).putExtra(TOKEN,token)
+                    .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_ACTIVITY_SINGLE_TOP);
+                // PackageInstaller fills in its result, so this explicit, non-exported
+                // target needs a mutable PendingIntent (immutable is rejected on API35).
+                PendingIntent pending=PendingIntent.getActivity(this,sessionId,callback,
+                    PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_MUTABLE,callbackOptions());
+                record(this,"awaiting_confirmation","Review and confirm the update in Android's installer.");
+                try(PackageInstaller.Session session=getPackageManager().getPackageInstaller().openSession(sessionId)) {
+                    session.commit(pending.getIntentSender());
+                }
+                return;
+            }
+            if(!RESULT.equals(intent.getAction())||intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID,-1)!=sessionId) {finish();return;}
+            int result=intent.getIntExtra(PackageInstaller.EXTRA_STATUS,PackageInstaller.STATUS_FAILURE);
+            if(result==PackageInstaller.STATUS_PENDING_USER_ACTION) {
+                Intent confirmation=intent.getParcelableExtra(Intent.EXTRA_INTENT);
+                if(confirmation==null)throw new IllegalStateException("missing_confirmation");
+                confirmationOpen=true;startActivityForResult(confirmation,CONFIRM);
+            } else {
+                if(result==PackageInstaller.STATUS_SUCCESS)record(this,"installed","OctoSense was updated successfully.");
+                else if(result==PackageInstaller.STATUS_FAILURE_ABORTED)record(this,"cancelled","Installation was cancelled. Your current OctoSense is unchanged.");
+                else record(this,"error","Android rejected the update (status "+result+"). Your current OctoSense is unchanged.");
+                finish();
+            }
+        } catch(Exception unavailable) {
+            cancel(this,sessionId,"Android's installer could not complete the update.");
+            record(this,"error","Android's installer could not complete the update.");finish();
+        }
+    }
+    /** Android 15 requires the PendingIntent creator to opt in to launching the
+     * callback. Keep API33 packaging compatibility; reflect only this API35 call.
+     * The mutable token-bearing intent is handed only to PackageInstaller. */
+    private static Bundle callbackOptions() throws Exception {
+        if(android.os.Build.VERSION.SDK_INT<35)return null;
+        ActivityOptions options=ActivityOptions.makeBasic();
+        int allowed=ActivityOptions.class.getField("MODE_BACKGROUND_ACTIVITY_START_ALLOWED").getInt(null);
+        ActivityOptions.class.getMethod("setPendingIntentCreatorBackgroundActivityStartMode",int.class).invoke(options,allowed);
+        return options.toBundle();
+    }
+    @Override protected void onActivityResult(int request,int result,Intent data) {
+        super.onActivityResult(request,result,data);
+        if(request!=CONFIRM)return;
+        confirmationOpen=false;
+        // PackageInstaller's status callback is authoritative. Some confirmation
+        // Activities return RESULT_CANCELED even after submitting an accepted
+        // session; abandoning here could cancel an update the person approved.
+        reconcile(this);
+        finish();
+    }
+    @Override public void onBackPressed() {
+        String token=getIntent()==null?null:getIntent().getStringExtra(TOKEN);
+        if(!confirmationOpen&&token!=null&&token.equals(state(this).getString("token",null)))
+            cancel(this,state(this).getInt("session",-1),"Installation was cancelled. Your current OctoSense is unchanged.");
+        super.onBackPressed();
+    }
+}
diff --git a/phone/resources/android/java/dev/makepad/octosense/HomeUpdatePolicy.java b/phone/resources/android/java/dev/makepad/octosense/HomeUpdatePolicy.java
new file mode 100644
index 000000000..8fc8f5b99
--- /dev/null
+++ b/phone/resources/android/java/dev/makepad/octosense/HomeUpdatePolicy.java
@@ -0,0 +1,104 @@
+package dev.makepad.octosense;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.security.MessageDigest;
+import java.util.Arrays;
+
+/** Local updater policy, independent of Android so rejection paths run on the JVM. */
+final class HomeUpdatePolicy {
+    static final String PACKAGE="dev.makepad.octosense";
+    // Public standalone release identity, not a secret. ROM updates use their own service.
+    static final String RELEASE_SIGNER="6bf3927755714edb325ea7fbdc7c7e8780f4a103589d3ee5926b982773f11e34";
+    static final long MAX_BYTES=512L*1024*1024;
+
+    static final class Package {
+        final String name;
+        final long version;
+        final int minSdk;
+        final String[] signers;
+        Package(String name,long version,int minSdk,String[] signers) {
+            this.name=name;this.version=version;this.minSdk=minSdk;
+            this.signers=signers==null?new String[0]:signers.clone();
+            Arrays.sort(this.signers);
+        }
+    }
+
+    static boolean standalone(Package installed,boolean systemPackage) {
+        return !systemPackage&&PACKAGE.equals(installed.name)&&installed.signers.length==1
+            &&RELEASE_SIGNER.equals(installed.signers[0]);
+    }
+
+    static void validatePackage(Package installed,Package candidate,int sdk) throws IOException {
+        if(candidate==null||!installed.name.equals(candidate.name)||!PACKAGE.equals(candidate.name))
+            throw new IOException("package_mismatch");
+        if(candidate.version<=installed.version)throw new IOException("not_newer");
+        if(candidate.minSdk<=0||candidate.minSdk>sdk)throw new IOException("android_version_unsupported");
+        if(installed.signers.length==0||candidate.signers.length==0||!Arrays.equals(installed.signers,candidate.signers))
+            throw new IOException("signature_mismatch");
+    }
+
+    static String digest(byte[] value) throws Exception {
+        return hex(MessageDigest.getInstance("SHA-256").digest(value));
+    }
+
+    static String hex(byte[] value) {
+        StringBuilder out=new StringBuilder(value.length*2);
+        for(byte b:value)out.append(Character.forDigit((b>>>4)&15,16)).append(Character.forDigit(b&15,16));
+        return out.toString();
+    }
+
+    /** Resolve only a regular APK below our private cache; reject every symlink component.
+     * Android itself can alias /data/user/0 to /data/data, so first canonicalize the
+     * OS-provided cache parent, then enforce the caller's path below that trusted root. */
+    static File checkedFile(File cacheRoot,String supplied) throws IOException {
+        Path root=cacheRoot.toPath().toAbsolutePath().normalize();
+        if(Files.isSymbolicLink(root)||!Files.isDirectory(root,LinkOption.NOFOLLOW_LINKS))
+            throw new IOException("invalid_cache");
+        root=cacheRoot.getCanonicalFile().toPath();
+        Path candidate=new File(supplied).toPath();
+        if(!candidate.isAbsolute()||!candidate.equals(candidate.normalize())||!candidate.startsWith(root)
+                ||candidate.equals(root)||!candidate.getFileName().toString().endsWith(".apk"))
+            throw new IOException("invalid_path");
+        Path cursor=root;
+        for(Path component:root.relativize(candidate)) {
+            cursor=cursor.resolve(component);
+            if(Files.isSymbolicLink(cursor))throw new IOException("invalid_path");
+        }
+        if(!Files.isRegularFile(candidate,LinkOption.NOFOLLOW_LINKS))throw new IOException("invalid_path");
+        long size=Files.size(candidate);
+        if(size<=0||size>MAX_BYTES)throw new IOException("invalid_size");
+        return candidate.toFile();
+    }
+
+    /** Hash exactly the bytes staged for PackageInstaller as well as the initial file.
+     * Any mutation between metadata inspection and copying abandons the session. */
+    static long copyVerified(File source,OutputStream sink,String expected) throws Exception {
+        return copyVerified(source,sink,expected,() -> false);
+    }
+    static long copyVerified(File source,OutputStream sink,String expected,java.util.function.BooleanSupplier cancelled) throws Exception {
+        if(expected==null||!expected.matches("[0-9a-f]{64}"))throw new IOException("invalid_digest");
+        MessageDigest digest=MessageDigest.getInstance("SHA-256");
+        long total=0;
+        try(InputStream in=Files.newInputStream(source.toPath(),StandardOpenOption.READ,LinkOption.NOFOLLOW_LINKS)) {
+            byte[] buffer=new byte[64*1024];
+            int count;
+            while((count=in.read(buffer))!=-1) {
+                if(Thread.currentThread().isInterrupted()||cancelled.getAsBoolean())throw new IOException("cancelled");
+                total+=count;
+                if(total>MAX_BYTES)throw new IOException("invalid_size");
+                digest.update(buffer,0,count);
+                if(sink!=null)sink.write(buffer,0,count);
+            }
+        }
+        if(total<=0)throw new IOException("invalid_size");
+        if(!hex(digest.digest()).equals(expected))throw new IOException("digest_mismatch");
+        return total;
+    }
+}
diff --git a/phone/resources/android/java/dev/makepad/octosense/HomeUpdaterClient.java b/phone/resources/android/java/dev/makepad/octosense/HomeUpdaterClient.java
new file mode 100644
index 000000000..8777f0da0
--- /dev/null
+++ b/phone/resources/android/java/dev/makepad/octosense/HomeUpdaterClient.java
@@ -0,0 +1,216 @@
+package dev.makepad.octosense;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageInstaller;
+import android.content.pm.PackageManager;
+import android.content.pm.Signature;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.Settings;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.UUID;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.BiConsumer;
+import java.util.function.BooleanSupplier;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+/** Home's own updater. Only the trusted native updater UI sends this private
+ * integration channel; this is not an OctoScript capability or an agent tool.
+ * It never contacts the ROM updater and never installs Bridge or another app. */
+final class HomeUpdaterClient {
+    private final Activity activity;
+    private final BiConsumer emit;
+    private final BooleanSupplier foreground;
+    private final Handler main=new Handler(Looper.getMainLooper());
+    private final AtomicBoolean preparing=new AtomicBoolean();
+    private final AtomicLong generation=new AtomicLong();
+    private final Object stagingLock=new Object();
+    private int stagedSession=-1;
+    private long stagedGeneration=-1;
+    private final ThreadPoolExecutor worker=new ThreadPoolExecutor(1,1,0,TimeUnit.MILLISECONDS,
+        new ArrayBlockingQueue<>(8),r -> new Thread(r,"HomeUpdater"),new ThreadPoolExecutor.AbortPolicy());
+    private volatile boolean closed;
+
+    HomeUpdaterClient(Activity activity,BiConsumer emit,BooleanSupplier foreground) {
+        this.activity=activity;this.emit=emit;this.foreground=foreground;
+    }
+    void close() {closed=true;cancelPreparation();worker.shutdownNow();}
+    private void cancelPreparation() {
+        long retired=generation.getAndIncrement();
+        synchronized(stagingLock) {
+            if(stagedSession>=0&&stagedGeneration<=retired)
+                HomeUpdateInstallActivity.cancelPreparation(activity,stagedSession);
+        }
+    }
+    private boolean cancelled(long requestedGeneration) {return closed||generation.get()!=requestedGeneration;}
+    private void live(long requestedGeneration) throws IOException {
+        if(cancelled(requestedGeneration))throw new IOException("cancelled");
+    }
+
+    void command(String payload) {
+        if(closed||payload.length()>16384)return;
+        long id=0;String operation="";
+        try {
+            JSONObject request=new JSONObject(payload);
+            Object rawId=request.get("id");
+            if(!(rawId instanceof Integer||rawId instanceof Long)||(id=((Number)rawId).longValue())<=0)return;
+            operation=request.getString("operation");
+            final long requestId=id;final String op=operation;
+            // Closing the native updater cancels preparation immediately, including
+            // work queued behind a hash. Never queue cancellation behind that work.
+            if(op.equals("update_cancel")) {
+                cancelPreparation();reply(id,op,"cancelled","Update preparation cancelled. Any Android confirmation already open remains controlled by Android.",null);return;
+            }
+            final long requestedGeneration=generation.get();
+            if(!op.equals("update_info")&&!op.equals("update_status")&&!op.equals("update_allow_install")&&!op.equals("update_install")) {
+                reply(id,operation,"error","unknown_operation",null);return;
+            }
+            worker.execute(() -> {
+                if(closed)return;
+                try {
+                    if(op.equals("update_info"))reply(requestId,op,"ok",Build.VERSION.SDK_INT<33?unsupportedReason():"",info());
+                    else if(op.equals("update_status")) {
+                        HomeUpdateInstallActivity.reconcile(activity);
+                        SharedPreferences state=HomeUpdateInstallActivity.state(activity);
+                        reply(requestId,op,state.getString("status","idle"),state.getString("message",""),info());
+                    } else {
+                        live(requestedGeneration);
+                        PackageInfo installed=installed();
+                        if(!standalone(installed)) {
+                            reply(requestId,op,"unsupported",unsupportedReason(),null);return;
+                        }
+                        if(!foreground.getAsBoolean()) {reply(requestId,op,"error","Return to the updater before installing.",null);return;}
+                        if(op.equals("update_allow_install"))allowInstall(requestId,op,requestedGeneration);
+                        else install(requestId,op,request,installed,requestedGeneration);
+                    }
+                } catch(IOException failure) {reply(requestId,op,"cancelled".equals(failure.getMessage())?"cancelled":"error",failure.getMessage(),null);}
+                  catch(Exception failure) {reply(requestId,op,"error","Android could not prepare the update. Retry after checking storage and installation settings.",null);}
+            });
+        } catch(java.util.concurrent.RejectedExecutionException busy) {reply(id,operation,"error","Updater is busy. Try again shortly.",null);}
+          catch(Exception invalid) {if(id>0)reply(id,operation,"error","invalid_request",null);}
+    }
+
+    private PackageInfo installed() throws Exception {
+        return activity.getPackageManager().getPackageInfo(activity.getPackageName(),
+            Build.VERSION.SDK_INT>=28?PackageManager.GET_SIGNING_CERTIFICATES:PackageManager.GET_SIGNATURES);
+    }
+    private static HomeUpdatePolicy.Package metadata(PackageInfo info) throws Exception {
+        if(info==null||info.applicationInfo==null)return null;
+        Signature[] certificates=Build.VERSION.SDK_INT>=28
+            ?(info.signingInfo==null?null:info.signingInfo.getApkContentsSigners()):info.signatures;
+        String[] digests=new String[certificates==null?0:certificates.length];
+        for(int i=0;i=28?info.getLongVersionCode():info.versionCode;}
+    private static String unsupportedReason() {
+        return Build.VERSION.SDK_INT<33?"Standalone OctoSense updates require Android 13 (API 33) or later."
+            :"This installation uses a different update identity. Use the ROM or distributor updater.";
+    }
+    private static boolean standalone(PackageInfo info) throws Exception {
+        HomeUpdatePolicy.Package value=metadata(info);
+        return Build.VERSION.SDK_INT>=33&&value!=null&&HomeUpdatePolicy.standalone(value,
+            (info.applicationInfo.flags&(ApplicationInfo.FLAG_SYSTEM|ApplicationInfo.FLAG_UPDATED_SYSTEM_APP))!=0);
+    }
+    private File cacheDir() throws IOException {
+        File root=new File(activity.getCacheDir(),"octosense-updates");
+        if(java.nio.file.Files.isSymbolicLink(root.toPath())||(!root.isDirectory()&&!root.mkdir()))throw new IOException("invalid_cache");
+        try {android.system.Os.chmod(root.getPath(),0700);}
+        catch(android.system.ErrnoException failure) {throw new IOException("private_cache_unavailable",failure);}
+        return root.getCanonicalFile();
+    }
+    private JSONObject info() throws Exception {
+        PackageInfo installed=installed();HomeUpdatePolicy.Package value=metadata(installed);
+        if(value==null)throw new IOException("package_unavailable");
+        return new JSONObject().put("cache_dir",cacheDir().getPath()).put("package",installed.packageName)
+            .put("version_name",installed.versionName==null?"":installed.versionName).put("version_code",versionCode(installed))
+            .put("sdk",Build.VERSION.SDK_INT).put("signature",new JSONArray(value.signers))
+            .put("standalone",standalone(installed)).put("can_install",activity.getPackageManager().canRequestPackageInstalls());
+    }
+    private void allowInstall(long id,String op,long requestedGeneration) {
+        main.post(() -> {
+            if(cancelled(requestedGeneration)||!foreground.getAsBoolean()){reply(id,op,"cancelled","Return to the updater before changing installation settings.",null);return;}
+            try {
+                activity.startActivity(new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,Uri.parse("package:"+activity.getPackageName())));
+                reply(id,op,"settings_opened","Allow updates from OctoSense, then return here and choose Install again.",null);
+            } catch(Exception unavailable) {reply(id,op,"error","Android installation settings are unavailable.",null);}
+        });
+    }
+    private void install(long id,String op,JSONObject request,PackageInfo installed,long requestedGeneration) throws Exception {
+        if(!preparing.compareAndSet(false,true)) {reply(id,op,"error","An update is already being prepared.",null);return;}
+        int sessionId=-1;boolean handedOff=false;
+        PackageInstaller installer=activity.getPackageManager().getPackageInstaller();
+        try {
+            live(requestedGeneration);
+            HomeUpdateInstallActivity.reconcile(activity);
+            String state=HomeUpdateInstallActivity.state(activity).getString("status","idle");
+            if(state.equals("preparing")||state.equals("awaiting_confirmation"))throw new IOException("An installation is already pending. Finish or cancel Android's confirmation first.");
+            if(!activity.getPackageManager().canRequestPackageInstalls()) {reply(id,op,"permission_required","Allow OctoSense to install its update in Android Settings, then choose Install again.",null);return;}
+            File file=HomeUpdatePolicy.checkedFile(cacheDir(),request.getString("path"));
+            String sha=request.getString("sha256");
+            long size=HomeUpdatePolicy.copyVerified(file,null,sha,() -> cancelled(requestedGeneration));
+            PackageInfo candidate=activity.getPackageManager().getPackageArchiveInfo(file.getPath(),PackageManager.GET_SIGNING_CERTIFICATES);
+            HomeUpdatePolicy.validatePackage(metadata(installed),metadata(candidate),Build.VERSION.SDK_INT);
+            live(requestedGeneration);
+            if(!foreground.getAsBoolean())throw new IOException("Update cancelled because the updater left the foreground.");
+            PackageInstaller.SessionParams params=new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
+            params.setAppPackageName(installed.packageName);params.setSize(size);
+            params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED);
+            sessionId=installer.createSession(params);
+            String token=UUID.randomUUID().toString();
+            // Persist before the large copy so process death leaves a recoverable,
+            // expiring session rather than an untracked installer allocation.
+            synchronized(stagingLock) {
+                live(requestedGeneration);
+                stagedSession=sessionId;stagedGeneration=requestedGeneration;
+                HomeUpdateInstallActivity.prepare(activity,sessionId,token,versionCode(candidate));
+            }
+            try(PackageInstaller.Session session=installer.openSession(sessionId);
+                OutputStream out=session.openWrite("base.apk",0,size)) {
+                HomeUpdatePolicy.copyVerified(HomeUpdatePolicy.checkedFile(cacheDir(),request.getString("path")),out,sha,() -> cancelled(requestedGeneration));
+                session.fsync(out);
+            }
+            live(requestedGeneration);
+            if(!foreground.getAsBoolean())throw new IOException("Update cancelled because the updater left the foreground.");
+            final int preparedSession=sessionId;
+            main.post(() -> {
+                if(cancelled(requestedGeneration)||!foreground.getAsBoolean()) {
+                    HomeUpdateInstallActivity.cancel(activity,preparedSession,"Update cancelled before Android confirmation.");
+                    reply(id,op,"cancelled","Update cancelled before Android confirmation.",null);return;
+                }
+                try {
+                    HomeUpdateInstallActivity.start(activity,token);
+                    reply(id,op,"awaiting_confirmation","Review the update in Android's installer. Installation requires your confirmation.",null);
+                } catch(Exception unavailable) {
+                    HomeUpdateInstallActivity.cancel(activity,preparedSession,"Android's installer could not open.");
+                    reply(id,op,"error","Android's installer could not open.",null);
+                }
+            });
+            handedOff=true;
+        } finally {
+            if(sessionId>=0&&!handedOff)HomeUpdateInstallActivity.cancel(activity,sessionId,"Update preparation did not complete. Choose Install to retry.");
+            preparing.set(false);
+        }
+    }
+    private void reply(long id,String operation,String status,String message,JSONObject extra) {
+        if(closed)return;
+        try {
+            JSONObject value=extra==null?new JSONObject():extra;
+            value.put("id",id).put("operation",operation).put("status",status).put("message",message);
+            emit.accept("home_updater.result",value);
+        } catch(Exception ignored) {}
+    }
+}
diff --git a/phone/resources/android/java/dev/makepad/octosense/MakepadAppExtension.java b/phone/resources/android/java/dev/makepad/octosense/MakepadAppExtension.java
index f8fcc78e3..7701db744 100644
--- a/phone/resources/android/java/dev/makepad/octosense/MakepadAppExtension.java
+++ b/phone/resources/android/java/dev/makepad/octosense/MakepadAppExtension.java
@@ -58,6 +58,7 @@
 public final class MakepadAppExtension implements MakepadActivity.ApplicationExtension {
     private final MakepadActivity activity;
     private final DeviceCalendarClient deviceCalendar;
+    private final HomeUpdaterClient homeUpdater;
     private Runnable unregisterSystemBack;
     private final ObscuredTouchGuard touchGuard=new ObscuredTouchGuard();
     @Override public boolean filterTouchEvent(android.view.MotionEvent event) {return touchGuard.accept(event);}
@@ -145,6 +146,7 @@ public final class MakepadAppExtension implements MakepadActivity.ApplicationExt
     public MakepadAppExtension(MakepadActivity activity) {
         this.activity=activity;
         deviceCalendar=new DeviceCalendarClient(activity,(channel,value) -> {offer(() -> emit(channel,value));},() -> resumed&&windowFocused&&!destroyed);
+        homeUpdater=new HomeUpdaterClient(activity,(channel,value) -> {offer(() -> emit(channel,value));},() -> resumed&&windowFocused&&!destroyed);
         // Home also hosts trusted Settings, theme controls and native overlays.
         // Protect its window before an external Settings intent can be handled.
         if(android.os.Build.VERSION.SDK_INT>=31) activity.getWindow().setHideOverlayWindows(true);
@@ -491,6 +493,7 @@ private void updateReplyTargets(Bundle state) {
         main.post(() -> replyComposer.updateTargets(current));
     }
     @Override public void command(String channel,String payload) {
+        if("home_updater.command".equals(channel)){homeUpdater.command(payload);return;}
         if("device_calendar.probe".equals(channel)){offer(() -> emit("device_calendar.ready",new JSONObject()));return;}
         if("device_calendar.command".equals(channel)){deviceCalendar.command(payload);return;}
         if(validationBuild&&"validation.ui".equals(channel)) {
@@ -1634,6 +1637,7 @@ private void sendSettingsEntry(SettingsEntryContract.Entry entry) {
     }
     @Override public void onDestroy() {
         deviceCalendar.close();
+        homeUpdater.close();
         homeGestureEdges.close();
         if(unregisterSystemBack!=null) {unregisterSystemBack.run();unregisterSystemBack=null;}
         QrImagePickActivity.setListener(null);
diff --git a/phone/resources/settings/controller/updates.octoscript b/phone/resources/settings/controller/updates.octoscript
index ba956857b..ec749eeda 100644
--- a/phone/resources/settings/controller/updates.octoscript
+++ b/phone/resources/settings/controller/updates.octoscript
@@ -32,6 +32,7 @@ fn settings_updates_event(s,e,o,out) {
     if e.kind=="press" { ui.pressed={id:e.id,request:candidate}; return candidate!=nil }
     if e.kind=="release" { ui.pressed=nil; return false }
     if e.kind!="click" || s.pending { return false }
+    if e.id=="updates_app" && o.updater { settings_request(out,{kind:"open_app",app:"updater"}); return true }
     if e.id=="updates_cancel_review" { ui.review=nil; return true }
     if e.id=="updates_refresh" { if o.android { settings_request(out,{kind:"read",domain:"updates"}) }; return true }
     if candidate==nil { return false }
@@ -67,6 +68,7 @@ fn settings_updates_refresh(s,o,out) {
     }
     settings_text(out,"updates_availability",availability); settings_text(out,"updates_current",current); settings_text(out,"updates_check_state",check); settings_text(out,"updates_offer",offer)
     settings_text(out,"updates_rom_state","System update: "+rom); settings_text(out,"updates_home_state","Home update: "+home)
+    settings_enabled(out,"updates_app",o.updater && !s.pending)
     settings_enabled(out,"updates_refresh",ready)
     for id in ["updates_check","updates_rom","updates_home","updates_reboot","updates_confirm"] { settings_enabled(out,id,ready && settings_updates_candidate(s,o,id)!=nil) }
     settings_visible(out,"updates_confirmation",ui.review!=nil); settings_enabled(out,"updates_cancel_review",!s.pending)
diff --git a/phone/resources/settings/settings.splash b/phone/resources/settings/settings.splash
index b40fc526e..d04b09221 100644
--- a/phone/resources/settings/settings.splash
+++ b/phone/resources/settings/settings.splash
@@ -1323,6 +1323,7 @@
     ]}
 ,
     {t: "scroll", id: "updates_page", fillw: 1, fillh: 1, c: [{t: "column", fillw: 1, spacing: 12, c: [
+        {t: "button", id: "updates_app", text: "Open OctoSense app updater", fillw: 1},
         {t: "text", id: "updates_availability", fillw: 1},
         {t: "text", id: "updates_current", fillw: 1},
         {t: "button", id: "updates_refresh", text: "Refresh status", fillw: 1},
diff --git a/phone/src/settings_app.rs b/phone/src/settings_app.rs
index d81bc24fd..bed054280 100644
--- a/phone/src/settings_app.rs
+++ b/phone/src/settings_app.rs
@@ -55,14 +55,14 @@ impl Destination {
 /// manifest ID crosses the script boundary, and opening one is navigation
 /// inside Home (the shell launcher), never an Android intent.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum SystemApp { AiProviders }
+pub enum SystemApp { AiProviders, Updater }
 impl SystemApp {
-    pub const ALL: [SystemApp; 1] = [SystemApp::AiProviders];
-    pub fn wire(self) -> &'static str { match self { Self::AiProviders => "ai_providers" } }
+    pub const ALL: [SystemApp; 2] = [SystemApp::AiProviders, SystemApp::Updater];
+    pub fn wire(self) -> &'static str { match self { Self::AiProviders => "ai_providers", Self::Updater => "updater" } }
     /// The launcher row the shell opens, as tapping its icon does.
-    pub fn launcher_id(self) -> &'static str { match self { Self::AiProviders => "ai-providers" } }
+    pub fn launcher_id(self) -> &'static str { match self { Self::AiProviders => "ai-providers", Self::Updater => "updater" } }
     /// The App Hub system bundle (ADR 0004) behind that row.
-    pub fn manifest_id(self) -> &'static str { match self { Self::AiProviders => "os.ai-providers" } }
+    pub fn manifest_id(self) -> Option<&'static str> { match self { Self::AiProviders => Some("os.ai-providers"), Self::Updater => None } }
 }
 
 /// No raw command, intent, settings key, path or module ID is accepted.
@@ -292,6 +292,7 @@ pub struct SettingsSnapshot {
     /// The AI providers system app ships in this build and the Card runner
     /// that hosts it is linked. Availability only: no provider data.
     pub ai_providers:bool,
+    pub updater:bool,
     /// Developer options (About phone), where this build offers them.
     pub developer:Option,
 }
@@ -300,7 +301,7 @@ impl SettingsSnapshot {
         self.android && self.connected && self.capabilities.contains(operation)
     }
     pub fn permits_device(&self, setting: &DeviceSetting) -> bool { self.android && self.device.as_ref().is_some_and(|state| state.permits(setting)) }
-    pub fn system_app(&self, app: SystemApp) -> bool { match app { SystemApp::AiProviders => self.ai_providers } }
+    pub fn system_app(&self, app: SystemApp) -> bool { match app { SystemApp::AiProviders => self.ai_providers, SystemApp::Updater => self.updater } }
 }
 
 script_mod! {
diff --git a/phone/src/settings_host.rs b/phone/src/settings_host.rs
index 0eba61885..e204fb15c 100644
--- a/phone/src/settings_host.rs
+++ b/phone/src/settings_host.rs
@@ -185,10 +185,13 @@ impl App {
     /// that hosts it is linked, i.e. the launcher could open it.
     fn settings_system_app_available(&self, app: SystemApp) -> bool {
         let lookup = || crate::clients::find_app(app.launcher_id())
-            .is_some_and(|def| crate::apps::card_manifest_id(&def) == Some(app.manifest_id()))
+            .is_some_and(|def| crate::apps::card_manifest_id(&def) == app.manifest_id())
             && self.apps.hosting(app.launcher_id()) == crate::apps::Hosting::Module
             && self.apps.module(app.launcher_id()).is_some();
-        match app { SystemApp::AiProviders => *self.settings_runtime.ai_providers.get_or_init(lookup) }
+        match app {
+            SystemApp::AiProviders => *self.settings_runtime.ai_providers.get_or_init(lookup),
+            SystemApp::Updater => self.apps.module("updater").is_some() && self.apps.hosting("updater") == crate::apps::Hosting::Module,
+        }
     }
     fn settings_snapshot(&self) -> SettingsSnapshot {
         let Some(state) = &self.state else { return SettingsSnapshot::default(); };
@@ -224,6 +227,7 @@ impl App {
             sounds:self.settings_runtime.sounds.snapshot.clone(),sounds_loading:self.settings_runtime.sounds.loading(),sounds_error:self.settings_runtime.sounds.error.clone(),
             notification_history:self.settings_runtime.history.snapshot.clone(),history_loading:self.settings_runtime.history.loading(),history_error:self.settings_runtime.history.error.clone(),
             ai_providers:self.settings_system_app_available(SystemApp::AiProviders),
+            updater:self.settings_system_app_available(SystemApp::Updater),
             developer:developer_options(self.developer_options_shown()),
         }
     }
@@ -823,7 +827,7 @@ mod tests {
         // The launcher id is a registered system app backed by its App Hub bundle.
         #[cfg(feature = "app-hub")]
         assert!(crate::apps::bundled_catalog().iter().any(|app| app.id == SystemApp::AiProviders.launcher_id()
-            && crate::apps::card_manifest_id(app) == Some(SystemApp::AiProviders.manifest_id())));
+            && crate::apps::card_manifest_id(app) == SystemApp::AiProviders.manifest_id()));
     }
     fn pending(id: i64, device: bool) -> Pending {
         Pending { id, client: 3, root: WidgetUid(7), deadline: 20.0, device }
diff --git a/phone/src/settings_script_bridge.rs b/phone/src/settings_script_bridge.rs
index eef057c10..0736ab679 100644
--- a/phone/src/settings_script_bridge.rs
+++ b/phone/src/settings_script_bridge.rs
@@ -44,7 +44,7 @@ pub fn observation(state: &SettingsSnapshot, pending: bool) -> ResultSettingsSnapshot{
  let f=step(&mut c,&o,"entry_resolved","app_notifications",entry("9223372036854775806"));assert!(f.handled);assert_eq!(f.requests[0].get("domain").and_then(Value::as_str),Some("app_notifications"));
  step(&mut c,&o,"entry","app_notifications",entry("2"));step(&mut c,&o,"back","",Value::Null);let f=step(&mut c,&o,"entry_resolved","app_notifications",entry("2"));assert!(!f.handled);assert_eq!(page(&f),"Apps");
 }
+
+#[test]
+fn updater_navigation_does_not_depend_on_rom_service_or_authorize_installation() {
+    for android in [false, true] {
+        let mut controller = new();
+        let mut observed = SettingsSnapshot { android, updater: true, ..Default::default() };
+        step(&mut controller, &observed, "navigate", "updates", Value::Null);
+        let frame = click(&mut controller, &observed, "updates_app");
+        assert_eq!(frame.requests, vec![obj(vec![("kind", s("open_app")), ("app", s("updater"))])]);
+        observed.updater = false;
+        assert!(click(&mut controller, &observed, "updates_app").requests.is_empty());
+    }
+}
diff --git a/rom/scripts/build-home.py b/rom/scripts/build-home.py
index 4a7a5667d..0909d4cc4 100644
--- a/rom/scripts/build-home.py
+++ b/rom/scripts/build-home.py
@@ -141,6 +141,22 @@ def rustflags(env):
     return " ".join([env.get("RUSTFLAGS", "").strip(), *remaps]).strip()
 
 
+def updater_identity(args):
+    """Only the explicit standalone release version is a public updater
+    identity. ROM and development builds cannot inherit a stale release tag
+    from the shell and then request an incompatible standalone package."""
+    version = args.version_name
+    number = r"(?:0|[1-9][0-9]*)"
+    if (args.variant == "standalone" and not args.development and version
+            and re.fullmatch(rf"{number}\.{number}\.{number}(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?", version)):
+        if "-" in version:
+            prerelease = version.split("-", 1)[1]
+            if any(part.isdigit() and len(part) > 1 and part.startswith("0") for part in prerelease.split(".")):
+                return ""
+        return f"home-v{version}"
+    return ""
+
+
 # What a published (ROM) Home must not carry in its native libraries.
 # `--remap-path-prefix` cannot reach env!("CARGO_MANIFEST_DIR"), which every
 # `script_mod!` compiles in, so a ROM build must run from a checkout (and
@@ -199,7 +215,8 @@ def main(argv=None):
                           "output": str(args.output), "version_code": int(args.version_code),
                           "version_name": args.version_name, "expected_signer": args.expected_signer,
                           "octos_kernel": str(kernel) if kernel else None,
-                          "android_env": {"MAKEPAD_ANDROID_EXTRA_LIBS": extra_libs(kernel)},
+                          "android_env": {"MAKEPAD_ANDROID_EXTRA_LIBS": extra_libs(kernel),
+                                          "OCTOSENSE_RELEASE_TAG": updater_identity(args)},
                           "steps": [
                               {"cwd": str(cwd), "argv": command} for cwd, command in plan],
                           "artifacts": ["OctoSenseHome.apk", "OctoSenseBridge.apk", "build.json"],
@@ -222,7 +239,8 @@ def main(argv=None):
                 raise RuntimeError("Keep signing keys and certificates outside the product checkout")
     env = dict(os.environ)
     env.update(JAVA_HOME=str(args.java_home), ANDROID_HOME=str(args.android_sdk),
-               ANDROID_SDK_ROOT=str(args.android_sdk), OCTOSENSE_WORKSPACE=str(sources))
+               ANDROID_SDK_ROOT=str(args.android_sdk), OCTOSENSE_WORKSPACE=str(sources),
+               OCTOSENSE_RELEASE_TAG=updater_identity(args))
     env["PATH"] = str(args.java_home / "bin") + os.pathsep + env.get("PATH", "")
     # Always build the unsigned Bridge release and sign the pair together below.
     env.pop("OCTOSENSE_KEYSTORE", None)
@@ -275,6 +293,7 @@ def main(argv=None):
     bridge_version = artifacts["OctoSenseBridge.apk"]
     packager = args.packager or sources / "makepad/target/release/cargo-makepad"
     receipt = {"schema_version": 1, "variant": args.variant, "development": args.development,
+               "release_tag": updater_identity(args) or None,
                "home_version_code": home_version["version_code"], "home_version_name": home_version["version_name"],
                "bridge_version_code": bridge_version["version_code"], "bridge_version_name": bridge_version["version_name"],
                "source_revision": subprocess.check_output(["git", "-C", str(REPO), "rev-parse", "HEAD"], text=True).strip(),
diff --git a/rom/tests/java/HomeUpdateInstallTest.java b/rom/tests/java/HomeUpdateInstallTest.java
new file mode 100644
index 000000000..24223f74c
--- /dev/null
+++ b/rom/tests/java/HomeUpdateInstallTest.java
@@ -0,0 +1,102 @@
+package dev.makepad.octosense;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInstaller;
+
+/** Runs the actual callback Activity against a recording fake Android boundary. */
+public final class HomeUpdateInstallTest {
+    static final int SESSION=73;
+    static final String TOKEN="synthetic-one-use-token";
+    static final String TOKEN_KEY="octosense.update.token";
+    static final String START="dev.makepad.octosense.update.START",RESULT="dev.makepad.octosense.update.RESULT";
+    static final Context CONTEXT=new Context();
+    static void check(boolean condition,String detail){if(!condition)throw new AssertionError(detail);}
+    static String status(){return HomeUpdateInstallActivity.state(CONTEXT).getString("status","idle");}
+    static HomeUpdateInstallActivity launch(Intent intent){HomeUpdateInstallActivity activity=new HomeUpdateInstallActivity();activity.setIntent(intent);activity.onCreate(null);return activity;}
+    static Intent result(int status){return new Intent().setAction(RESULT).putExtra(TOKEN_KEY,TOKEN).putExtra(PackageInstaller.EXTRA_SESSION_ID,SESSION).putExtra(PackageInstaller.EXTRA_STATUS,status);}
+    static void begin() throws Exception {
+        Context.PM.installer.sessions.add(SESSION);
+        HomeUpdateInstallActivity.prepare(CONTEXT,SESSION,TOKEN,11);
+    }
+    static void commit() throws Exception {begin();launch(new Intent().setAction(START).putExtra(TOKEN_KEY,TOKEN));}
+    public static void main(String[] args) throws Exception {
+        switch(args[0]) {
+        case "token": {
+            begin();HomeUpdateInstallActivity bad=launch(new Intent().setAction(START).putExtra(TOKEN_KEY,"wrong"));
+            check(bad.finished,"forged callback remained open");check(Context.PM.installer.commits==0,"forged callback committed");
+            check(status().equals("preparing"),"forged callback changed state");break;
+        }
+        case "commit": {
+            commit();check(Context.PM.installer.commits==1,"session was not committed exactly once");
+            check(PendingIntent.lastIntent.target==HomeUpdateInstallActivity.class,"implicit callback target");
+            check((PendingIntent.lastFlags&PendingIntent.FLAG_MUTABLE)!=0,"immutable callback rejected by Android35");
+            check(android.app.ActivityOptions.mode==1,"Android35 background callback opt-in missing");
+            launch(new Intent().setAction(START).putExtra(TOKEN_KEY,TOKEN));
+            check(Context.PM.installer.commits==1,"duplicate start committed twice");break;
+        }
+        case "wrong_session": {
+            commit();HomeUpdateInstallActivity bad=launch(result(PackageInstaller.STATUS_PENDING_USER_ACTION)
+                .putExtra(PackageInstaller.EXTRA_SESSION_ID,SESSION+1).putExtra(Intent.EXTRA_INTENT,new Intent()));
+            check(bad.finished&&bad.confirmation==null,"other session opened confirmation");break;
+        }
+        case "confirm": {
+            commit();Intent platform=new Intent();HomeUpdateInstallActivity callback=launch(result(PackageInstaller.STATUS_PENDING_USER_ACTION).putExtra(Intent.EXTRA_INTENT,platform));
+            check(callback.confirmation==platform,"system confirmation not opened");
+            // Some OS confirmation activities return RESULT_CANCELED after handing the
+            // accepted session back to PackageInstaller; only its callback is final.
+            callback.onActivityResult(callback.request,android.app.Activity.RESULT_CANCELED,null);
+            check(Context.PM.installer.abandoned==0,"activity return cancelled a possibly approved installation");
+            check(status().equals("awaiting_confirmation"),"activity return invented a result");break;
+        }
+        case "cancel": {
+            commit();launch(result(PackageInstaller.STATUS_FAILURE_ABORTED));
+            check(status().equals("cancelled"),"system cancellation not retained");break;
+        }
+        case "success": {
+            commit();launch(result(PackageInstaller.STATUS_SUCCESS));check(status().equals("installed"),"system success not retained");
+            launch(result(PackageInstaller.STATUS_PENDING_USER_ACTION).putExtra(Intent.EXTRA_INTENT,new Intent()));
+            check(status().equals("installed"),"late pending callback rewound terminal result");break;
+        }
+        case "failure": {
+            commit();launch(result(PackageInstaller.STATUS_FAILURE));check(status().equals("error"),"system failure not retained");break;
+        }
+        case "recovery": {
+            commit();Context.PM.info.version=11;HomeUpdateInstallActivity.reconcile(CONTEXT);
+            check(status().equals("installed"),"successful self-replacement not recognized after process restart");break;
+        }
+        case "missing_session": {
+            commit();Context.PM.installer.sessions.clear();HomeUpdateInstallActivity.reconcile(CONTEXT);
+            check(status().equals("cancelled"),"missing session left installer waiting forever");break;
+        }
+        case "expired": {
+            begin();HomeUpdateInstallActivity.state(CONTEXT).edit().putLong("started_ms",0).commit();HomeUpdateInstallActivity.reconcile(CONTEXT);
+            check(Context.PM.installer.abandoned==1&&status().equals("cancelled"),"stale uncommitted session not cleaned up");break;
+        }
+        case "cancel_preparation": {
+            begin();HomeUpdateInstallActivity.cancelPreparation(CONTEXT,SESSION);
+            check(status().equals("cancelled")&&Context.PM.installer.abandoned==1,"native closure did not cancel preparation");
+            launch(new Intent().setAction(START).putExtra(TOKEN_KEY,TOKEN));
+            check(Context.PM.installer.commits==0,"cancelled preparation committed later");break;
+        }
+        case "preserve_committed": {
+            commit();HomeUpdateInstallActivity.cancelPreparation(CONTEXT,SESSION);
+            check(status().equals("awaiting_confirmation")&&Context.PM.installer.abandoned==0,"native closure abandoned an OS-owned confirmation");break;
+        }
+        case "cancel_other_session": {
+            begin();HomeUpdateInstallActivity.cancelPreparation(CONTEXT,SESSION+1);
+            check(status().equals("preparing")&&Context.PM.installer.abandoned==0,"stale closure cancelled a different preparation");break;
+        }
+        case "stale_cleanup": {
+            begin();HomeUpdateInstallActivity.cancel(CONTEXT,SESSION-1,"old session cleanup");
+            check(status().equals("preparing"),"old main-thread callback overwrote newer session");break;
+        }
+        case "missing_confirmation": {
+            commit();launch(result(PackageInstaller.STATUS_PENDING_USER_ACTION));
+            check(Context.PM.installer.abandoned==1&&status().equals("error"),"missing system confirmation did not fail closed");break;
+        }
+        default:throw new AssertionError("unknown case");
+        }
+    }
+}
diff --git a/rom/tests/java/HomeUpdatePolicyTest.java b/rom/tests/java/HomeUpdatePolicyTest.java
new file mode 100644
index 000000000..44c0ba84b
--- /dev/null
+++ b/rom/tests/java/HomeUpdatePolicyTest.java
@@ -0,0 +1,105 @@
+package dev.makepad.octosense;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+
+public final class HomeUpdatePolicyTest {
+    interface Checked {void run() throws Exception;}
+    private static void check(boolean value,String message) {if(!value)throw new AssertionError(message);}
+    private static void rejects(String reason,Checked action) throws Exception {
+        try {action.run();throw new AssertionError("accepted "+reason);}catch(IOException denied){check(reason.equals(denied.getMessage()),denied.toString());}
+    }
+    private static HomeUpdatePolicy.Package pkg(String name,long version,int sdk,String...signers) {
+        return new HomeUpdatePolicy.Package(name,version,sdk,signers);
+    }
+    private static HomeUpdatePolicy.Package installed() {return pkg(HomeUpdatePolicy.PACKAGE,10,33,HomeUpdatePolicy.RELEASE_SIGNER);}
+    public static void main(String[] args) throws Exception {
+        Path root=Files.createTempDirectory("home-updates-test").toRealPath();
+        try {
+            switch(args[0]) {
+            case "newer":
+                HomeUpdatePolicy.validatePackage(installed(),pkg(HomeUpdatePolicy.PACKAGE,11,33,HomeUpdatePolicy.RELEASE_SIGNER),35);break;
+            case "identity":
+                check(HomeUpdatePolicy.standalone(installed(),false),"public install rejected");
+                check(!HomeUpdatePolicy.standalone(installed(),true),"system app accepted");
+                check(!HomeUpdatePolicy.standalone(pkg(HomeUpdatePolicy.PACKAGE,10,33,"other"),false),"ROM signer accepted");
+                check(!HomeUpdatePolicy.standalone(pkg("test.isolated",10,33,HomeUpdatePolicy.RELEASE_SIGNER),false),"isolated app accepted");
+                check(!HomeUpdatePolicy.standalone(pkg(HomeUpdatePolicy.PACKAGE,10,33),false),"unsigned accepted");break;
+            case "package":
+                rejects("package_mismatch",() -> HomeUpdatePolicy.validatePackage(installed(),pkg("dev.makepad.octosense.bridge",11,33,HomeUpdatePolicy.RELEASE_SIGNER),35));
+                rejects("package_mismatch",() -> HomeUpdatePolicy.validatePackage(installed(),null,35));break;
+            case "version":
+                for(long version:new long[]{-1,0,9,10})rejects("not_newer",() -> HomeUpdatePolicy.validatePackage(installed(),pkg(HomeUpdatePolicy.PACKAGE,version,33,HomeUpdatePolicy.RELEASE_SIGNER),35));break;
+            case "sdk":
+                for(int sdk:new int[]{-1,0,36})rejects("android_version_unsupported",() -> HomeUpdatePolicy.validatePackage(installed(),pkg(HomeUpdatePolicy.PACKAGE,11,sdk,HomeUpdatePolicy.RELEASE_SIGNER),35));break;
+            case "signature":
+                rejects("signature_mismatch",() -> HomeUpdatePolicy.validatePackage(installed(),pkg(HomeUpdatePolicy.PACKAGE,11,33,"other"),35));
+                rejects("signature_mismatch",() -> HomeUpdatePolicy.validatePackage(installed(),pkg(HomeUpdatePolicy.PACKAGE,11,33),35));
+                rejects("signature_mismatch",() -> HomeUpdatePolicy.validatePackage(installed(),pkg(HomeUpdatePolicy.PACKAGE,11,33,HomeUpdatePolicy.RELEASE_SIGNER,"other"),35));
+                HomeUpdatePolicy.validatePackage(pkg(HomeUpdatePolicy.PACKAGE,10,33,"a","b"),pkg(HomeUpdatePolicy.PACKAGE,11,33,"b","a"),35);break;
+            case "nested": {
+                Path apk=Files.createDirectories(root.resolve("version/platform")).resolve("home.apk");Files.write(apk,new byte[]{1});
+                check(HomeUpdatePolicy.checkedFile(root.toFile(),apk.toString()).equals(apk.toFile()),"valid nested file rejected");break;
+            }
+            case "escape": {
+                Path apk=root.resolve("home.apk");Files.write(apk,new byte[]{1});
+                rejects("invalid_path",() -> HomeUpdatePolicy.checkedFile(root.toFile(),"home.apk"));
+                rejects("invalid_path",() -> HomeUpdatePolicy.checkedFile(root.toFile(),root.resolve("../home.apk").toString()));
+                rejects("invalid_path",() -> HomeUpdatePolicy.checkedFile(root.toFile(),root.resolve("child/../home.apk").toString()));
+                rejects("invalid_path",() -> HomeUpdatePolicy.checkedFile(root.toFile(),root+"-sibling/home.apk"));
+                rejects("invalid_path",() -> HomeUpdatePolicy.checkedFile(root.toFile(),root.toString()));
+                rejects("invalid_path",() -> HomeUpdatePolicy.checkedFile(root.toFile(),root.resolve("home.zip").toString()));break;
+            }
+            case "symlink": {
+                Path real=Files.createDirectories(root.resolve("real"));Path apk=real.resolve("home.apk");Files.write(apk,new byte[]{1});
+                Path link=root.resolve("linked.apk");Files.createSymbolicLink(link,apk);
+                rejects("invalid_path",() -> HomeUpdatePolicy.checkedFile(root.toFile(),link.toString()));
+                Path dir=root.resolve("link-dir");Files.createSymbolicLink(dir,real);
+                rejects("invalid_path",() -> HomeUpdatePolicy.checkedFile(root.toFile(),dir.resolve("home.apk").toString()));
+                rejects("invalid_cache",() -> HomeUpdatePolicy.checkedFile(dir.toFile(),apk.toString()));break;
+            }
+            case "size": {
+                Path apk=root.resolve("home.apk");Files.write(apk,new byte[0]);
+                rejects("invalid_size",() -> HomeUpdatePolicy.checkedFile(root.toFile(),apk.toString()));
+                try(java.io.RandomAccessFile sparse=new java.io.RandomAccessFile(apk.toFile(),"rw")){sparse.setLength(HomeUpdatePolicy.MAX_BYTES+1);}
+                rejects("invalid_size",() -> HomeUpdatePolicy.checkedFile(root.toFile(),apk.toString()));break;
+            }
+            case "hash": {
+                byte[] bytes="exact staged bytes".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+                Path apk=root.resolve("home.apk");Files.write(apk,bytes);String hash=HomeUpdatePolicy.digest(bytes);
+                ByteArrayOutputStream sink=new ByteArrayOutputStream();
+                check(HomeUpdatePolicy.copyVerified(apk.toFile(),sink,hash)==bytes.length,"size mismatch");
+                check(Arrays.equals(bytes,sink.toByteArray()),"different bytes copied");
+                rejects("invalid_digest",() -> HomeUpdatePolicy.copyVerified(apk.toFile(),null,"x"));
+                rejects("invalid_digest",() -> HomeUpdatePolicy.copyVerified(apk.toFile(),null,hash.toUpperCase(java.util.Locale.ROOT)));
+                Files.write(apk,new byte[]{1,2,3});
+                rejects("digest_mismatch",() -> HomeUpdatePolicy.copyVerified(apk.toFile(),sink,hash));break;
+            }
+            case "cancelled_during_copy": {
+                byte[] bytes=new byte[128*1024];Path apk=root.resolve("home.apk");Files.write(apk,bytes);
+                java.util.concurrent.atomic.AtomicBoolean cancelled=new java.util.concurrent.atomic.AtomicBoolean();
+                ByteArrayOutputStream sink=new ByteArrayOutputStream() {
+                    @Override public void write(byte[] value,int start,int count) {super.write(value,start,count);cancelled.set(true);}
+                };
+                rejects("cancelled",() -> HomeUpdatePolicy.copyVerified(apk.toFile(),sink,HomeUpdatePolicy.digest(bytes),cancelled::get));
+                check(sink.size()==64*1024,"cancelled copy wrote another chunk");break;
+            }
+            case "interrupted": {
+                Path apk=root.resolve("home.apk");Files.write(apk,new byte[]{1});
+                Thread.currentThread().interrupt();
+                try {rejects("cancelled",() -> HomeUpdatePolicy.copyVerified(apk.toFile(),null,HomeUpdatePolicy.digest(new byte[]{1})));}
+                finally {Thread.interrupted();}break;
+            }
+            default:throw new AssertionError("unknown case");
+            }
+        } finally {
+            try(java.util.stream.Stream paths=Files.walk(root)) {
+                paths.sorted(java.util.Comparator.reverseOrder()).forEach(path -> {try{Files.delete(path);}catch(IOException e){throw new RuntimeException(e);}});
+            }
+        }
+    }
+}
diff --git a/rom/tests/java/home-updater-stubs/android/app/Activity.java b/rom/tests/java/home-updater-stubs/android/app/Activity.java
new file mode 100644
index 000000000..799d892d5
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/app/Activity.java
@@ -0,0 +1,10 @@
+package android.app;
+public class Activity extends android.content.Context {
+ public static final int RESULT_CANCELED=0,RESULT_OK=-1;public boolean finished;public android.content.Intent intent;public android.content.Intent confirmation;public int request;
+ public android.view.Window getWindow(){return new android.view.Window();}
+ public android.content.Intent getIntent(){return intent;}public void setIntent(android.content.Intent i){intent=i;}
+ public void finish(){finished=true;}
+ protected void onCreate(android.os.Bundle state){}protected void onNewIntent(android.content.Intent i){}
+ protected void onSaveInstanceState(android.os.Bundle state){}protected void onActivityResult(int q,int r,android.content.Intent i){}
+ public void startActivityForResult(android.content.Intent i,int r){confirmation=i;request=r;}public void onBackPressed(){finish();}
+}
diff --git a/rom/tests/java/home-updater-stubs/android/app/ActivityOptions.java b/rom/tests/java/home-updater-stubs/android/app/ActivityOptions.java
new file mode 100644
index 000000000..2b7d1d2ef
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/app/ActivityOptions.java
@@ -0,0 +1,7 @@
+package android.app;
+public class ActivityOptions {
+ public static final int MODE_BACKGROUND_ACTIVITY_START_ALLOWED=1;public static int mode;
+ public static ActivityOptions makeBasic(){return new ActivityOptions();}
+ public ActivityOptions setPendingIntentCreatorBackgroundActivityStartMode(int m){mode=m;return this;}
+ public android.os.Bundle toBundle(){return new android.os.Bundle();}
+}
diff --git a/rom/tests/java/home-updater-stubs/android/app/PendingIntent.java b/rom/tests/java/home-updater-stubs/android/app/PendingIntent.java
new file mode 100644
index 000000000..7ba7dbfff
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/app/PendingIntent.java
@@ -0,0 +1,6 @@
+package android.app;
+public class PendingIntent {
+ public static final int FLAG_UPDATE_CURRENT=1,FLAG_MUTABLE=2;public static android.content.Intent lastIntent;public static int lastFlags;
+ public static PendingIntent getActivity(android.content.Context c,int id,android.content.Intent intent,int flags,android.os.Bundle options){lastIntent=intent;lastFlags=flags;return new PendingIntent();}
+ public android.content.IntentSender getIntentSender(){return new android.content.IntentSender();}
+}
diff --git a/rom/tests/java/home-updater-stubs/android/content/Context.java b/rom/tests/java/home-updater-stubs/android/content/Context.java
new file mode 100644
index 000000000..a0361e3c7
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/content/Context.java
@@ -0,0 +1,10 @@
+package android.content;
+public class Context {
+ public static final int MODE_PRIVATE=0;public static final SharedPreferences PREFS=new SharedPreferences();
+ public static final android.content.pm.PackageManager PM=new android.content.pm.PackageManager();
+ public static Intent started;
+ public SharedPreferences getSharedPreferences(String name,int mode){return PREFS;}
+ public android.content.pm.PackageManager getPackageManager(){return PM;}
+ public String getPackageName(){return "dev.makepad.octosense";}
+ public void startActivity(Intent intent){started=intent;}
+}
diff --git a/rom/tests/java/home-updater-stubs/android/content/Intent.java b/rom/tests/java/home-updater-stubs/android/content/Intent.java
new file mode 100644
index 000000000..a2355c038
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/content/Intent.java
@@ -0,0 +1,11 @@
+package android.content;
+public class Intent {
+ public static final String EXTRA_INTENT="intent";public static final int FLAG_ACTIVITY_NEW_TASK=1,FLAG_ACTIVITY_SINGLE_TOP=2;
+ final java.util.Map extras=new java.util.HashMap<>();String action;public Class target;public android.net.Uri data;
+ public Intent(){}public Intent(Context c,Class cls){target=cls;}
+ public Intent setAction(String a){action=a;return this;}public String getAction(){return action;}
+ public Intent setData(android.net.Uri d){data=d;return this;}public Intent putExtra(String k,Object v){extras.put(k,v);return this;}
+ public String getStringExtra(String k){return (String)extras.get(k);}public int getIntExtra(String k,int d){Object v=extras.get(k);return v instanceof Integer?(Integer)v:d;}
+ @SuppressWarnings("unchecked") public T getParcelableExtra(String k){return (T)extras.get(k);}
+ public Intent addFlags(int f){return this;}
+}
diff --git a/rom/tests/java/home-updater-stubs/android/content/IntentSender.java b/rom/tests/java/home-updater-stubs/android/content/IntentSender.java
new file mode 100644
index 000000000..9fd2db364
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/content/IntentSender.java
@@ -0,0 +1 @@
+package android.content;public class IntentSender {}
diff --git a/rom/tests/java/home-updater-stubs/android/content/SharedPreferences.java b/rom/tests/java/home-updater-stubs/android/content/SharedPreferences.java
new file mode 100644
index 000000000..474e7e3c6
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/content/SharedPreferences.java
@@ -0,0 +1,14 @@
+package android.content;
+public class SharedPreferences {
+ private final java.util.Map data=new java.util.HashMap<>();
+ public String getString(String k,String d){Object v=data.get(k);return v instanceof String?(String)v:d;}
+ public int getInt(String k,int d){Object v=data.get(k);return v instanceof Integer?(Integer)v:d;}
+ public long getLong(String k,long d){Object v=data.get(k);return v instanceof Long?(Long)v:d;}
+ public Editor edit(){return new Editor();}
+ public class Editor {
+  final java.util.Map changes=new java.util.HashMap<>();boolean clear;
+  public Editor clear(){clear=true;return this;} public Editor putString(String k,String v){changes.put(k,v);return this;}
+  public Editor putInt(String k,int v){changes.put(k,v);return this;}public Editor putLong(String k,long v){changes.put(k,v);return this;}
+  public boolean commit(){if(clear)data.clear();data.putAll(changes);return true;}public void apply(){commit();}
+ }
+}
diff --git a/rom/tests/java/home-updater-stubs/android/content/pm/PackageInfo.java b/rom/tests/java/home-updater-stubs/android/content/pm/PackageInfo.java
new file mode 100644
index 000000000..6c6c6c621
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/content/pm/PackageInfo.java
@@ -0,0 +1 @@
+package android.content.pm;public class PackageInfo {public long version=10;public long getLongVersionCode(){return version;}}
diff --git a/rom/tests/java/home-updater-stubs/android/content/pm/PackageInstaller.java b/rom/tests/java/home-updater-stubs/android/content/pm/PackageInstaller.java
new file mode 100644
index 000000000..c660b2987
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/content/pm/PackageInstaller.java
@@ -0,0 +1,10 @@
+package android.content.pm;
+public class PackageInstaller {
+ public static final String EXTRA_SESSION_ID="session_id",EXTRA_STATUS="status";
+ public static final int STATUS_SUCCESS=0,STATUS_PENDING_USER_ACTION=-1,STATUS_FAILURE=1,STATUS_FAILURE_ABORTED=3;
+ public final java.util.Set sessions=new java.util.HashSet<>();public int commits,abandoned;
+ public Object getSessionInfo(int id){return sessions.contains(id)?new Object():null;}
+ public void abandonSession(int id){sessions.remove(id);abandoned++;}
+ public Session openSession(int id){if(!sessions.contains(id))throw new IllegalArgumentException();return new Session();}
+ public class Session implements AutoCloseable {public void commit(android.content.IntentSender sender){commits++;}public void close(){}}
+}
diff --git a/rom/tests/java/home-updater-stubs/android/content/pm/PackageManager.java b/rom/tests/java/home-updater-stubs/android/content/pm/PackageManager.java
new file mode 100644
index 000000000..1c54cf422
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/content/pm/PackageManager.java
@@ -0,0 +1,3 @@
+package android.content.pm;
+public class PackageManager {public final PackageInstaller installer=new PackageInstaller();public final PackageInfo info=new PackageInfo();
+ public PackageInstaller getPackageInstaller(){return installer;}public PackageInfo getPackageInfo(String p,int f){return info;}}
diff --git a/rom/tests/java/home-updater-stubs/android/net/Uri.java b/rom/tests/java/home-updater-stubs/android/net/Uri.java
new file mode 100644
index 000000000..a83554395
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/net/Uri.java
@@ -0,0 +1 @@
+package android.net; public class Uri {final String value;private Uri(String v){value=v;}public static Uri parse(String v){return new Uri(v);}public String toString(){return value;}}
diff --git a/rom/tests/java/home-updater-stubs/android/os/Build.java b/rom/tests/java/home-updater-stubs/android/os/Build.java
new file mode 100644
index 000000000..a7c63555d
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/os/Build.java
@@ -0,0 +1 @@
+package android.os; public class Build {public static class VERSION {public static int SDK_INT=35;}}
diff --git a/rom/tests/java/home-updater-stubs/android/os/Bundle.java b/rom/tests/java/home-updater-stubs/android/os/Bundle.java
new file mode 100644
index 000000000..896f7fb4c
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/os/Bundle.java
@@ -0,0 +1,5 @@
+package android.os;
+public class Bundle extends java.util.HashMap {
+ public boolean getBoolean(String k,boolean d){Object v=get(k);return v instanceof Boolean?(Boolean)v:d;}
+ public void putBoolean(String k,boolean v){put(k,v);}
+}
diff --git a/rom/tests/java/home-updater-stubs/android/view/Window.java b/rom/tests/java/home-updater-stubs/android/view/Window.java
new file mode 100644
index 000000000..7138b7b47
--- /dev/null
+++ b/rom/tests/java/home-updater-stubs/android/view/Window.java
@@ -0,0 +1 @@
+package android.view;public class Window {public void setHideOverlayWindows(boolean b){}}
diff --git a/rom/tests/test_home_app_updater.py b/rom/tests/test_home_app_updater.py
new file mode 100644
index 000000000..1e08245ce
--- /dev/null
+++ b/rom/tests/test_home_app_updater.py
@@ -0,0 +1,111 @@
+"""Executable JVM policy tests for Home's own APK updater (no device mutation)."""
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import unittest
+import xml.etree.ElementTree as ET
+
+ROOT = Path(__file__).resolve().parents[2]
+JAVA = ROOT / "phone/resources/android/java/dev/makepad/octosense"
+ANDROID = "{http://schemas.android.com/apk/res/android}"
+
+
+class HomeUpdaterPolicyTests(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        jdk = os.environ.get("JAVA_HOME")
+        cls.javac = str(Path(jdk) / "bin/javac") if jdk else shutil.which("javac")
+        cls.java = str(Path(jdk) / "bin/java") if jdk else shutil.which("java")
+        if not cls.javac or not cls.java:
+            raise unittest.SkipTest("JDK required for Home updater policy")
+        cls.classes = tempfile.TemporaryDirectory()
+        cls.addClassCleanup(cls.classes.cleanup)
+        run = subprocess.run([cls.javac, "-d", cls.classes.name,
+            str(JAVA / "HomeUpdatePolicy.java"), str(ROOT / "rom/tests/java/HomeUpdatePolicyTest.java")],
+            capture_output=True, text=True)
+        if run.returncode:
+            raise AssertionError(run.stdout + run.stderr)
+
+    def run_policy(self, case):
+        result = subprocess.run([self.java, "-cp", self.classes.name,
+            "dev.makepad.octosense.HomeUpdatePolicyTest", case], capture_output=True, text=True)
+        self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+
+    def test_newer_same_identity(self): self.run_policy("newer")
+    def test_standalone_vs_rom_and_isolated_identity(self): self.run_policy("identity")
+    def test_other_package_or_unreadable_archive(self): self.run_policy("package")
+    def test_downgrade_and_reinstall_rejected(self): self.run_policy("version")
+    def test_incompatible_android_version(self): self.run_policy("sdk")
+    def test_signer_mismatch_unsigned_and_extra_signer(self): self.run_policy("signature")
+    def test_nested_private_cache_file(self): self.run_policy("nested")
+    def test_traversal_relative_and_sibling_paths(self): self.run_policy("escape")
+    def test_symlink_file_directory_and_root(self): self.run_policy("symlink")
+    def test_empty_and_oversized_apk(self): self.run_policy("size")
+    def test_exact_staged_bytes_and_mutated_source(self): self.run_policy("hash")
+    def test_window_closure_cancels_between_chunks(self): self.run_policy("cancelled_during_copy")
+    def test_interrupted_copy_is_cancelled(self): self.run_policy("interrupted")
+
+
+class HomeUpdaterBoundaryTests(unittest.TestCase):
+    def test_manifest_callback_is_private_and_install_is_not_privileged(self):
+        manifest = ET.parse(ROOT / "phone/resources/android/AndroidManifest.xml.template")
+        callback = next(node for node in manifest.findall("application/activity")
+            if node.get(ANDROID + "name") == "dev.makepad.octosense.HomeUpdateInstallActivity")
+        self.assertEqual(callback.get(ANDROID + "exported"), "false")
+        self.assertFalse(callback.findall("intent-filter"))
+        permissions = {node.get(ANDROID + "name") for node in manifest.findall("uses-permission")}
+        self.assertIn("android.permission.REQUEST_INSTALL_PACKAGES", permissions)
+        self.assertNotIn("android.permission.INSTALL_PACKAGES", permissions)
+        self.assertNotIn("android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION", permissions)
+
+    def test_private_extension_owns_channel_and_closes_worker(self):
+        source = (JAVA / "MakepadAppExtension.java").read_text()
+        self.assertIn('"home_updater.command".equals(channel)', source)
+        self.assertIn('homeUpdater.command(payload)', source)
+        self.assertIn('homeUpdater.close()', source)
+        client = (JAVA / "HomeUpdaterClient.java").read_text()
+        self.assertIn('setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED)', client)
+        self.assertIn('HomeUpdatePolicy.copyVerified', client)
+        self.assertNotIn('installReviewedUpdate', client)
+        self.assertNotIn('Runtime.getRuntime', client)
+
+
+class HomeUpdaterSessionTests(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        jdk = os.environ.get("JAVA_HOME")
+        javac = str(Path(jdk) / "bin/javac") if jdk else shutil.which("javac")
+        cls.java = str(Path(jdk) / "bin/java") if jdk else shutil.which("java")
+        if not javac or not cls.java:
+            raise unittest.SkipTest("JDK required for Home updater session callback")
+        cls.classes = tempfile.TemporaryDirectory()
+        cls.addClassCleanup(cls.classes.cleanup)
+        sources = sorted((ROOT / "rom/tests/java/home-updater-stubs").rglob("*.java")) + [
+            JAVA / "HomeUpdateInstallActivity.java", ROOT / "rom/tests/java/HomeUpdateInstallTest.java"]
+        result = subprocess.run([javac, "-d", cls.classes.name, *map(str, sources)], capture_output=True, text=True)
+        if result.returncode:
+            raise AssertionError(result.stdout + result.stderr)
+
+    def run_session(self, case):
+        result = subprocess.run([self.java, "-cp", self.classes.name,
+            "dev.makepad.octosense.HomeUpdateInstallTest", case], capture_output=True, text=True)
+        self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+
+    def test_callback_token_is_required(self): self.run_session("token")
+    def test_commit_once_and_explicit_android35_callback(self): self.run_session("commit")
+    def test_wrong_session_cannot_open_confirmation(self): self.run_session("wrong_session")
+    def test_activity_return_does_not_override_installer(self): self.run_session("confirm")
+    def test_system_cancellation(self): self.run_session("cancel")
+    def test_system_success_and_late_callback(self): self.run_session("success")
+    def test_system_failure(self): self.run_session("failure")
+    def test_process_restart_after_self_replacement(self): self.run_session("recovery")
+    def test_missing_session_recovery(self): self.run_session("missing_session")
+    def test_expired_preparation_is_cleaned(self): self.run_session("expired")
+    def test_missing_confirmation_fails_closed(self): self.run_session("missing_confirmation")
+
+    def test_native_closure_cancels_preparation(self): self.run_session("cancel_preparation")
+    def test_native_closure_preserves_committed_session(self): self.run_session("preserve_committed")
+    def test_stale_closure_cannot_cancel_other_session(self): self.run_session("cancel_other_session")
+    def test_stale_callback_cleanup_cannot_overwrite_newer_session(self): self.run_session("stale_cleanup")
diff --git a/rom/tests/test_home_build.py b/rom/tests/test_home_build.py
index 6be196fa5..5e6f38737 100644
--- a/rom/tests/test_home_build.py
+++ b/rom/tests/test_home_build.py
@@ -96,6 +96,26 @@ def test_release_pair_receives_the_same_explicit_version(self):
         self.assertIn("--version-code=2026100908", plan[-1][1])
         self.assertIn("--version-name=0.1.0-beta.2", plan[-1][1])
 
+    def test_only_versioned_standalone_release_gets_public_updater_identity(self):
+        release = self.args("--variant", "standalone", "--sign-key", "/keys/release.pk8",
+                            "--sign-cert", "/keys/release.x509.pem", "--version-name", "0.1.0-beta.3")
+        self.assertEqual(build.updater_identity(release), "home-v0.1.0-beta.3")
+        for changes in ({"development": True}, {"variant": "rom"}, {"version_name": None},
+                        {"version_name": "local-build"}, {"version_name": "01.2.3"},
+                        {"version_name": "1.2.3-rc..1"}, {"version_name": "1.2.3-rc.01"}):
+            altered = type(release)(**{**vars(release), **changes})
+            self.assertEqual(build.updater_identity(altered), "")
+
+    def test_dry_run_exposes_release_tag_without_deploying(self):
+        output = io.StringIO()
+        with contextlib.redirect_stdout(output):
+            build.main(["--variant", "standalone", "--sdk", "/sdk", "--android-sdk", "/android",
+                        "--sign-key", "/keys/release.pk8", "--sign-cert", "/keys/release.x509.pem",
+                        "--no-octos-kernel", "--version-name", "0.1.0-beta.3", "--dry-run"])
+        plan = json.loads(output.getvalue())
+        self.assertEqual(plan["android_env"]["OCTOSENSE_RELEASE_TAG"], "home-v0.1.0-beta.3")
+        self.assertFalse(plan["installs_or_flashes"])
+
     def test_auto_version_is_resolved_once_before_either_build(self):
         with mock.patch.object(build, "datetime") as clock:
             clock.now.return_value = datetime(2026, 10, 9, 8, 59, 59, tzinfo=timezone.utc)
diff --git a/tools/test_native_apps.py b/tools/test_native_apps.py
index 132f60717..b7943ade0 100644
--- a/tools/test_native_apps.py
+++ b/tools/test_native_apps.py
@@ -31,7 +31,7 @@ def test_every_generated_place_matches_the_manifest(self):
 
     def test_the_manifest_declares_todays_native_apps(self):
         apps = native_apps.load(ROOT)
-        self.assertEqual([app["id"] for app in apps], ["rinx", "reference", "sheets", "terminal", "appcard", "apphub",
+        self.assertEqual([app["id"] for app in apps], ["updater", "rinx", "reference", "sheets", "terminal", "appcard", "apphub",
                                                        "calculator", "clock", "notes", "reminders", "weather",
                                                        "octoscode", "task"])
         hosting = {app["id"]: app["hosting"] for app in apps}

From 9fd4300643a5f5d49b3f670cc9100d6be1ca0e93 Mon Sep 17 00:00:00 2001
From: ymote <151983+ymote@users.noreply.github.com>
Date: Sat, 10 Oct 2026 23:22:11 -0700
Subject: [PATCH 2/3] Updater: GitHub's signature on desktop releases, and
 review fixes

From the review of #446:

- A desktop release is offered only when its SHA256SUMS carries GitHub's
  signature from the new sign-desktop-release workflow, run on main in the
  desktop-release-signing environment, whose reviewers approve each run:
  the model App Hub uses for its catalog. GitHub's digests and SHA256SUMS
  alone only prove the files are the release's own; anyone who can publish
  a release can make them agree, and updater downloads skip the warnings
  macOS and Windows give a browser download. signature.rs verifies the
  Sigstore bundle offline (certificate, timestamps, transparency log,
  GitHub's claims, the subject .SHA256SUMS with its exact digest),
  with the same pinned Sigstore crates as App Hub. Android Home keeps its
  pinned APK signer instead.
- A release page may hold 100 releases of up to 160 KiB each. At 2 MiB a
  page, every check failed once the repository had about 73 releases (28
  KiB each today), and installed updaters could not have fetched a fix.
  The ten-page limit now stops reading instead of failing.
- Stale downloads are removed when the shell starts and before each check:
  verified ones a day after their last write, interrupted ones after an
  hour; only the updater's own pending-/verified- folders of plain files,
  never recursively.
- Android's Install and Allow buttons now read their clicks while the tap
  is being handled (capture_actions, press and release), as the host's
  other approval sheets do. Read from Event::Actions, trusted_user_input()
  was always false, so both buttons always refused.
- Linux packages are handed over from the Downloads folder: a snap package
  handler (Ubuntu's App Center) cannot read ~/.octosense, and an AppImage's
  owner must find it. The copy never replaces a file and is checked against
  the release digest; an AppImage copy is executable.
- docs/updater.md (en/zh): the signature, the corrected statement on OS
  download checks, cleanup, Linux handoff, what is verified. desktop/README
  (en/zh): the signing step before publishing. ci_local: the signing
  workflow is a release workflow it does not run.

Tested: cargo test --locked -p octosense-updater -p octosense-updater-ui
(43 + 8 pass); clippy -D warnings on both; cargo check --example updates;
python3 tools/ci_local.py --check-drift and tools/test_ci_local.py; the
workflow's release check, run read-only against the published
desktop-v0.1.0-rc.5 and rc.2 (pass) and beta.2 (refused: no SHA256SUMS).
Unverified: a signed release end to end (none is signed yet), Android's
tap check on a device, the Linux handoff on a Linux desktop.

Co-Authored-By: Claude Opus 5.5 
---
 .github/workflows/sign-desktop-release.yml    | 113 ++++++
 Cargo.lock                                    |   6 +
 crates/updater-ui/Cargo.toml                  |   4 +
 crates/updater-ui/src/install.rs              | 253 +++++++++++-
 crates/updater-ui/src/lib.rs                  | 132 +++---
 crates/updater/Cargo.toml                     |   6 +
 crates/updater/src/lib.rs                     | 162 +++++++-
 crates/updater/src/signature.rs               | 378 ++++++++++++++++++
 crates/updater/src/tests.rs                   | 131 ++++++
 crates/updater/tests/fixtures/README.md       |   1 +
 .../fixtures/cosign-v3-blob.sigstore.json     |   1 +
 .../updater/tests/fixtures/cosign-v3-blob.txt |   1 +
 desktop/README.md                             |   3 +-
 desktop/README.zh-CN.md                       |   3 +-
 docs/local-ci.md                              |   2 +-
 docs/updater.md                               |  76 +++-
 docs/updater.zh-CN.md                         |  57 ++-
 tools/ci_local.py                             |   3 +
 18 files changed, 1237 insertions(+), 95 deletions(-)
 create mode 100644 .github/workflows/sign-desktop-release.yml
 create mode 100644 crates/updater/src/signature.rs
 create mode 100644 crates/updater/tests/fixtures/README.md
 create mode 100644 crates/updater/tests/fixtures/cosign-v3-blob.sigstore.json
 create mode 100644 crates/updater/tests/fixtures/cosign-v3-blob.txt

diff --git a/.github/workflows/sign-desktop-release.yml b/.github/workflows/sign-desktop-release.yml
new file mode 100644
index 000000000..06cf07ffb
--- /dev/null
+++ b/.github/workflows/sign-desktop-release.yml
@@ -0,0 +1,113 @@
+# Sign a desktop release with GitHub's signature, so the in-app updater
+# (docs/updater.md) offers it. Run it on the draft that release-desktop.yml
+# made, before publishing the draft (desktop/README.md, "Cutting a desktop
+# release"):
+#
+#   gh workflow run sign-desktop-release.yml --ref main -f tag=desktop-v0.2.0
+#
+# The job waits for a reviewer of the desktop-release-signing environment.
+# It checks that the release's SHA256SUMS lists exactly the release's files,
+# each with its GitHub digest; GitHub then signs SHA256SUMS (actions/attest:
+# a Sigstore bundle, recorded in the public transparency log), and the job
+# attaches that bundle to the release as SHA256SUMS.sigstore.json. The
+# updater accepts a signature from this workflow only, run on main in that
+# environment (crates/updater/src/signature.rs), the way devices accept App
+# Hub's catalog.
+name: Sign desktop release
+
+on:
+  workflow_dispatch:
+    inputs:
+      tag:
+        description: "The desktop release to sign: desktop-v..[-
]"
+        required: true
+        type: string
+
+permissions: {}
+
+concurrency:
+  group: sign-desktop-release
+  cancel-in-progress: false
+
+jobs:
+  sign:
+    # The updater trusts this workflow on main only.
+    if: github.ref == 'refs/heads/main'
+    runs-on: ubuntu-24.04
+    timeout-minutes: 15
+    environment: desktop-release-signing
+    permissions:
+      contents: write
+      id-token: write
+      attestations: write
+    steps:
+      - name: Check the release and its checksums
+        id: release
+        env:
+          GH_TOKEN: ${{ github.token }}
+          TAG: ${{ inputs.tag }}
+        run: |
+          python3 - <<'PY'
+          import hashlib, json, os, re, subprocess, sys
+
+          tag, repo = os.environ["TAG"], os.environ["GITHUB_REPOSITORY"]
+          number = r"(?:0|[1-9][0-9]*)"
+          if not re.fullmatch(rf"desktop-v{number}\.{number}\.{number}(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?", tag):
+              sys.exit(f"{tag!r} is not a desktop release tag")
+
+          def api(path, *args):
+              return subprocess.run(["gh", "api", *args, path], check=True, capture_output=True).stdout
+
+          # Drafts are listed for this token; a draft has no tag URL yet.
+          pages = json.loads(api(f"repos/{repo}/releases?per_page=100", "--paginate", "--slurp"))
+          matches = [r for page in pages for r in page if r["tag_name"] == tag]
+          if len(matches) != 1:
+              sys.exit(f"{tag}: expected one release, found {len(matches)}")
+          assets = {a["name"]: a for a in matches[0]["assets"]}
+          if "SHA256SUMS.sigstore.json" in assets:
+              sys.exit(f"{tag} is already signed; delete its SHA256SUMS.sigstore.json to sign it again")
+          if "SHA256SUMS" not in assets:
+              sys.exit(f"{tag} has no SHA256SUMS")
+          sums_asset = assets.pop("SHA256SUMS")
+          sums = api(f"repos/{repo}/releases/assets/{sums_asset['id']}", "-H", "Accept: application/octet-stream")
+          if sums_asset.get("digest") != "sha256:" + hashlib.sha256(sums).hexdigest():
+              sys.exit("SHA256SUMS does not match its GitHub digest")
+
+          listed = {}
+          for line in sums.decode("utf-8").splitlines():
+              if not line:
+                  continue
+              m = re.fullmatch(r"([0-9a-f]{64}) [ *]([0-9A-Za-z_+-][0-9A-Za-z._+-]*)", line)
+              if not m or m.group(2) in listed:
+                  sys.exit(f"malformed SHA256SUMS line: {line!r}")
+              listed[m.group(2)] = m.group(1)
+          # What GitHub signs must describe the whole release, file for file.
+          if set(listed) != set(assets):
+              sys.exit("SHA256SUMS does not list exactly the release's files: "
+                       f"unlisted {sorted(set(assets) - set(listed))}, missing {sorted(set(listed) - set(assets))}")
+          for name, asset in assets.items():
+              if asset.get("digest") != "sha256:" + listed[name]:
+                  sys.exit(f"{name} does not match its line in SHA256SUMS")
+
+          subject = os.path.join(os.environ["RUNNER_TEMP"], "subject", f"{tag}.SHA256SUMS")
+          os.makedirs(os.path.dirname(subject))
+          with open(subject, "wb") as out:
+              out.write(sums)
+          with open(os.environ["GITHUB_OUTPUT"], "a") as out:
+              out.write(f"subject={subject}\n")
+          state = "draft" if matches[0]["draft"] else "published"
+          print(f"{tag} ({state}): SHA256SUMS lists its {len(assets)} files with their GitHub digests")
+          PY
+      - name: GitHub signs the checksums
+        id: attest
+        uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
+        with:
+          subject-path: ${{ steps.release.outputs.subject }}
+      - name: Attach the signature to the release
+        env:
+          GH_TOKEN: ${{ github.token }}
+          TAG: ${{ inputs.tag }}
+          BUNDLE: ${{ steps.attest.outputs.bundle-path }}
+        run: |
+          cp "$BUNDLE" "$RUNNER_TEMP/SHA256SUMS.sigstore.json"
+          gh release upload "$TAG" "$RUNNER_TEMP/SHA256SUMS.sigstore.json" -R "$GITHUB_REPOSITORY"
diff --git a/Cargo.lock b/Cargo.lock
index cbd8de0fd..317c4688b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11976,11 +11976,15 @@ dependencies = [
 name = "octosense-updater"
 version = "0.1.0"
 dependencies = [
+ "base64 0.22.1",
  "reqwest 0.12.28",
  "semver",
  "serde",
  "serde_json",
  "sha2 0.10.9",
+ "sigstore-trust-root",
+ "sigstore-types",
+ "sigstore-verify",
  "tempfile",
  "tokio",
  "uuid",
@@ -11994,6 +11998,8 @@ dependencies = [
  "makepad-widgets",
  "octosense-updater",
  "serde_json",
+ "sha2 0.10.9",
+ "tempfile",
 ]
 
 [[package]]
diff --git a/crates/updater-ui/Cargo.toml b/crates/updater-ui/Cargo.toml
index 283885643..884091c3c 100644
--- a/crates/updater-ui/Cargo.toml
+++ b/crates/updater-ui/Cargo.toml
@@ -10,3 +10,7 @@ makepad-widgets = { workspace = true }
 makepad-app-module = { workspace = true }
 octosense-updater = { workspace = true }
 serde_json = { workspace = true }
+sha2 = "0.10"
+
+[dev-dependencies]
+tempfile = "3"
diff --git a/crates/updater-ui/src/install.rs b/crates/updater-ui/src/install.rs
index 3e754f0a5..b79d08b7c 100644
--- a/crates/updater-ui/src/install.rs
+++ b/crates/updater-ui/src/install.rs
@@ -1,6 +1,12 @@
 //! Handoff to the operating system after download verification. This module
 //! never runs a shell, replaces the running app, or reports that an opened
 //! installer has completed installation.
+//!
+//! Linux packages are handed over from the person's Downloads folder, where a
+//! browser would have saved them: a desktop package handler may be unable to
+//! read the hidden profile folder the download lives in (Ubuntu's App Center
+//! is a snap, and snaps cannot see hidden folders), and someone replacing an
+//! AppImage must find the new one.
 
 use octosense_updater::{Cancellation, Platform, VerifiedArtifact};
 use std::{
@@ -43,19 +49,169 @@ fn plan(platform: Platform, path: &Path) -> Result {
             program: "xdg-open".into(),
             args: vec![path.as_os_str().into()],
             wait: true,
-            message: "The verified package was handed to your desktop's package installer. Review and install it there, then restart OctoSense. If no installer opens, use your distribution's package manager on the downloaded file.",
+            message: "The verified package, saved in your Downloads folder, was handed to your desktop's package installer. Review and install it there, then restart OctoSense. If no installer opens, install that file with your distribution's package manager.",
         }),
         Platform::LinuxX64AppImage if has_extension("AppImage") => Ok(InstallPlan {
             program: "xdg-open".into(),
             args: vec![path.parent().ok_or("The update folder is unavailable.")?.as_os_str().into()],
             wait: true,
-            message: "The folder containing the verified AppImage is open. Quit OctoSense, replace your old AppImage with this file, allow execution in its file properties, and reopen it. The running app has not been replaced.",
+            message: "The verified AppImage is in your Downloads folder, ready to run. Quit OctoSense, replace your old AppImage with it, then open it. The running app has not been replaced.",
         }),
         Platform::AndroidArm64 => Err("Android updates use the host's package installer.".into()),
         _ => Err("This file is not an installer for the selected platform.".into()),
     }
 }
 
+/// The person's Downloads folder, and a verified copy of a download in it.
+#[cfg(any(target_os = "linux", test))]
+mod downloads {
+    use sha2::{Digest, Sha256};
+    use std::{
+        fs::{self, File, OpenOptions},
+        io::{Read, Write},
+        path::{Path, PathBuf},
+    };
+
+    /// `XDG_DOWNLOAD_DIR` from `user-dirs.dirs`, else `~/Downloads`, else home.
+    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
+    pub(super) fn folder() -> Option {
+        let home = std::env::var_os("HOME")
+            .map(PathBuf::from)
+            .filter(|path| path.is_absolute())?;
+        let config = std::env::var_os("XDG_CONFIG_HOME")
+            .map(PathBuf::from)
+            .filter(|path| path.is_absolute())
+            .unwrap_or_else(|| home.join(".config"));
+        let configured = fs::read_to_string(config.join("user-dirs.dirs"))
+            .ok()
+            .and_then(|text| configured_folder(&text, &home));
+        [configured, Some(home.join("Downloads")), Some(home)]
+            .into_iter()
+            .flatten()
+            .find(|path| path.is_dir())
+    }
+
+    /// The `XDG_DOWNLOAD_DIR="$HOME/…"` (or absolute) line of `user-dirs.dirs`.
+    pub(super) fn configured_folder(text: &str, home: &Path) -> Option {
+        let value = text
+            .lines()
+            .map(str::trim)
+            .find_map(|line| line.strip_prefix("XDG_DOWNLOAD_DIR="))?
+            .strip_prefix('"')?
+            .strip_suffix('"')?;
+        let path = match value.strip_prefix("$HOME") {
+            Some("") => home.to_path_buf(),
+            Some(rest) => home.join(rest.strip_prefix('/')?),
+            None => PathBuf::from(value),
+        };
+        (path.is_absolute() && !value[1..].contains('$')).then_some(path)
+    }
+
+    /// Copy `source` into `folder` under a name nothing there uses (or reuse an
+    /// identical file), checking the copy's bytes against `sha256`.
+    pub(super) fn stage(
+        source: &Path,
+        sha256: &str,
+        folder: &Path,
+        executable: bool,
+    ) -> Result {
+        let name = source
+            .file_name()
+            .and_then(|name| name.to_str())
+            .ok_or("The verified update has no file name.")?;
+        let (stem, extension) = name.rsplit_once('.').unwrap_or((name, ""));
+        for n in 1..100 {
+            let candidate = folder.join(if n == 1 {
+                name.to_string()
+            } else {
+                format!("{stem} ({n}).{extension}")
+            });
+            match create(&candidate, executable) {
+                Ok(file) => return copy_verified(source, file, &candidate, sha256),
+                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
+                    if is_regular(&candidate) && digest(&candidate).as_deref() == Some(sha256) {
+                        mark_executable(&candidate, executable)?;
+                        return Ok(candidate);
+                    }
+                }
+                Err(_) => return Err("Could not save the update in your Downloads folder.".into()),
+            }
+        }
+        Err("Your Downloads folder already holds too many files with this update's name.".into())
+    }
+
+    fn create(path: &Path, executable: bool) -> std::io::Result {
+        let mut options = OpenOptions::new();
+        // Never follows or replaces an existing file or link.
+        options.create_new(true).write(true);
+        #[cfg(unix)]
+        {
+            use std::os::unix::fs::OpenOptionsExt;
+            options.mode(if executable { 0o755 } else { 0o644 });
+        }
+        #[cfg(not(unix))]
+        let _ = executable;
+        options.open(path)
+    }
+
+    fn copy_verified(
+        source: &Path,
+        mut file: File,
+        path: &Path,
+        sha256: &str,
+    ) -> Result {
+        let result = (|| {
+            let mut input = File::open(source).ok()?;
+            let mut hasher = Sha256::new();
+            let mut buffer = vec![0u8; 1024 * 1024];
+            loop {
+                let read = input.read(&mut buffer).ok()?;
+                if read == 0 {
+                    break;
+                }
+                hasher.update(&buffer[..read]);
+                file.write_all(&buffer[..read]).ok()?;
+            }
+            file.sync_all().ok()?;
+            (format!("{:x}", hasher.finalize()) == sha256).then_some(())
+        })();
+        if result.is_none() {
+            let _ = fs::remove_file(path);
+            return Err("The update could not be saved in your Downloads folder unchanged.".into());
+        }
+        Ok(path.to_path_buf())
+    }
+
+    fn is_regular(path: &Path) -> bool {
+        fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_file())
+    }
+
+    fn digest(path: &Path) -> Option {
+        let mut input = File::open(path).ok()?;
+        let mut hasher = Sha256::new();
+        let mut buffer = vec![0u8; 1024 * 1024];
+        loop {
+            let read = input.read(&mut buffer).ok()?;
+            if read == 0 {
+                return Some(format!("{:x}", hasher.finalize()));
+            }
+            hasher.update(&buffer[..read]);
+        }
+    }
+
+    fn mark_executable(path: &Path, executable: bool) -> Result<(), String> {
+        #[cfg(unix)]
+        if executable {
+            use std::os::unix::fs::PermissionsExt;
+            fs::set_permissions(path, fs::Permissions::from_mode(0o755))
+                .map_err(|_| "Could not make the AppImage executable.".to_string())?;
+        }
+        #[cfg(not(unix))]
+        let _ = (path, executable);
+        Ok(())
+    }
+}
+
 fn host_supports(platform: Platform) -> bool {
     match platform {
         Platform::MacArm64 => cfg!(all(target_os = "macos", target_arch = "aarch64")),
@@ -74,6 +230,20 @@ pub fn install(artifact: &VerifiedArtifact, cancellation: &Cancellation) -> Resu
         return Err("This update cannot be installed on the running platform.".into());
     }
     let path = artifact.revalidate().map_err(|error| error.to_string())?;
+    #[cfg(target_os = "linux")]
+    let path = match artifact.platform() {
+        Platform::LinuxX64Deb | Platform::LinuxX64AppImage => {
+            if cancellation.is_cancelled() {
+                return Err(
+                    "Update installation was cancelled before opening the installer.".into(),
+                );
+            }
+            let downloads = downloads::folder().ok_or("Your Downloads folder is unavailable.")?;
+            let executable = artifact.platform() == Platform::LinuxX64AppImage;
+            downloads::stage(&path, artifact.sha256(), &downloads, executable)?
+        }
+        _ => path,
+    };
     let plan = plan(artifact.platform(), &path)?;
     // Hashing a large installer takes time. A close/cancel during verification
     // must not leave a deferred OS installer prompt behind.
@@ -171,6 +341,85 @@ mod tests {
         assert_eq!(image.args, vec![image_path.parent().unwrap().as_os_str()]);
     }
 
+    #[test]
+    fn the_downloads_folder_comes_from_user_dirs() {
+        let home = Path::new("/home/person");
+        for (text, expected) in [
+            (
+                "XDG_DOWNLOAD_DIR=\"$HOME/Downloads\"",
+                Some("/home/person/Downloads"),
+            ),
+            (
+                "# comment\nXDG_DESKTOP_DIR=\"$HOME/Desktop\"\n  XDG_DOWNLOAD_DIR=\"$HOME/下载\"",
+                Some("/home/person/下载"),
+            ),
+            ("XDG_DOWNLOAD_DIR=\"$HOME\"", Some("/home/person")),
+            (
+                "XDG_DOWNLOAD_DIR=\"/data/downloads\"",
+                Some("/data/downloads"),
+            ),
+            ("XDG_DOWNLOAD_DIR=\"Downloads\"", None),
+            ("XDG_DOWNLOAD_DIR=\"$HOME/$OTHER\"", None),
+            ("XDG_DOWNLOAD_DIR=$HOME/Downloads", None),
+            ("XDG_DOCUMENTS_DIR=\"$HOME/Documents\"", None),
+        ] {
+            assert_eq!(
+                downloads::configured_folder(text, home),
+                expected.map(std::path::PathBuf::from),
+                "{text}"
+            );
+        }
+    }
+
+    #[cfg(unix)]
+    #[test]
+    fn linux_packages_are_copied_to_downloads_unchanged_without_replacing_anything() {
+        use sha2::{Digest, Sha256};
+        use std::os::unix::fs::PermissionsExt;
+        let cache = tempfile::tempdir().unwrap();
+        let folder = tempfile::tempdir().unwrap();
+        let source = cache.path().join("octosense_0.2.0_x86_64.AppImage");
+        std::fs::write(&source, b"new image").unwrap();
+        let sha = format!("{:x}", Sha256::digest(b"new image"));
+        let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
+
+        // A person's own file with that name stays; the copy takes the next name.
+        let theirs = folder.path().join("octosense_0.2.0_x86_64.AppImage");
+        std::fs::write(&theirs, b"their file").unwrap();
+        let staged = downloads::stage(&source, &sha, folder.path(), true).unwrap();
+        assert_eq!(
+            staged,
+            folder.path().join("octosense_0.2.0_x86_64 (2).AppImage")
+        );
+        assert_eq!(std::fs::read(&staged).unwrap(), b"new image");
+        assert_eq!(std::fs::read(&theirs).unwrap(), b"their file");
+        assert_eq!(mode(&staged), 0o755);
+        // Staging again reuses the identical copy.
+        assert_eq!(
+            downloads::stage(&source, &sha, folder.path(), true).unwrap(),
+            staged
+        );
+
+        // A link is never followed or replaced.
+        let elsewhere = tempfile::tempdir().unwrap();
+        let target = elsewhere.path().join("target");
+        std::fs::write(&target, b"untouched").unwrap();
+        let deb = cache.path().join("octosense_0.2.0_amd64.deb");
+        std::fs::write(&deb, b"package").unwrap();
+        std::os::unix::fs::symlink(&target, folder.path().join("octosense_0.2.0_amd64.deb"))
+            .unwrap();
+        let deb_sha = format!("{:x}", Sha256::digest(b"package"));
+        let staged = downloads::stage(&deb, &deb_sha, folder.path(), false).unwrap();
+        assert_eq!(staged, folder.path().join("octosense_0.2.0_amd64 (2).deb"));
+        assert_eq!(std::fs::read(&target).unwrap(), b"untouched");
+        assert_eq!(mode(&staged), 0o644);
+
+        // A copy that does not match the verified digest is removed.
+        let wrong = "0".repeat(64);
+        assert!(downloads::stage(&deb, &wrong, elsewhere.path(), false).is_err());
+        assert!(!elsewhere.path().join("octosense_0.2.0_amd64.deb").exists());
+    }
+
     #[test]
     fn wrong_extensions_relative_paths_and_android_are_refused() {
         assert!(plan(Platform::MacArm64, Path::new("relative.dmg")).is_err());
diff --git a/crates/updater-ui/src/lib.rs b/crates/updater-ui/src/lib.rs
index 7b33efe96..f7c38e548 100644
--- a/crates/updater-ui/src/lib.rs
+++ b/crates/updater-ui/src/lib.rs
@@ -26,7 +26,13 @@ static REQUEST: AtomicU64 = AtomicU64::new(1);
 static ANDROID: Mutex> = Mutex::new(Vec::new());
 
 /// The shell supplies a host-private directory, outside every app's storage jail.
+/// Downloads an earlier run opened or left behind are removed off the UI
+/// thread (`octosense_updater::sweep`).
 pub fn configure(cache: PathBuf) {
+    let stale = cache.clone();
+    let _ = std::thread::Builder::new()
+        .name("octosense-updater-sweep".into())
+        .spawn(move || octosense_updater::sweep(&stale));
     let _ = CACHE.set(cache);
 }
 
@@ -166,6 +172,11 @@ pub struct UpdaterView {
     android_install_pending: bool,
     #[rust]
     install_opened: bool,
+    /// Whether the press of the Install and Allow buttons was a real tap.
+    #[rust]
+    install_down: bool,
+    #[rust]
+    permission_down: bool,
 }
 
 impl UpdaterView {
@@ -361,7 +372,8 @@ impl UpdaterView {
         }));
     }
 
-    fn install(&mut self) {
+    /// `tapped`: the click's press and release were both real taps.
+    fn install(&mut self, tapped: bool) {
         if self.task.is_some() || self.android_install_pending {
             return;
         }
@@ -369,7 +381,7 @@ impl UpdaterView {
             return;
         };
         // Android's PackageInstaller requires its own human confirmation as well.
-        if cfg!(target_os = "android") && !makepad_widgets::makepad_platform::trusted_user_input() {
+        if cfg!(target_os = "android") && !tapped {
             self.message = "Tap Install directly on the phone to review this update.".into();
             return;
         }
@@ -549,61 +561,73 @@ impl Widget for UpdaterView {
     fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
         self.initialize(cx);
         self.poll(cx);
-        self.view.handle_event(cx, event, scope);
-        if let Event::Actions(actions) = event {
-            let changed = [
-                ids!(channel),
-                ids!(check),
-                ids!(download),
-                ids!(cancel),
-                ids!(install),
-                ids!(notes),
-                ids!(permission),
-                ids!(retry_install),
-            ]
-            .iter()
-            .any(|id| self.view.button(cx, *id).clicked(actions));
-            if !changed {
-                return;
-            }
-            if self.view.button(cx, ids!(channel)).clicked(actions)
-                && self.task.is_none()
-                && !self.android_install_pending
-            {
-                self.channel = (self.channel + 1) % 3;
-                self.retire_offer();
-                self.message = "Channel changed. Check for updates to continue.".into();
-            }
-            if self.view.button(cx, ids!(check)).clicked(actions) {
-                self.check();
-            }
-            if self.view.button(cx, ids!(download)).clicked(actions) {
-                self.download();
-            }
-            if self.view.button(cx, ids!(cancel)).clicked(actions) {
-                if let Some(task) = &self.task {
-                    task.cancel.cancel();
-                    self.message = "Cancelling…".into();
-                }
-            }
-            if self.view.button(cx, ids!(install)).clicked(actions) {
-                self.install();
-            }
-            if self.view.button(cx, ids!(notes)).clicked(actions) {
-                if let Some(offer) = &self.offer {
-                    cx.open_url(&offer.release_url(), OpenUrlInPlace::No);
-                }
-            }
-            if self.view.button(cx, ids!(permission)).clicked(actions)
-                && makepad_widgets::makepad_platform::trusted_user_input()
-            {
-                self.android_command(cx, "update_allow_install", serde_json::json!({}));
+        // Read this view's clicks while the input that made them is still
+        // being handled: only then does `trusted_user_input` report a real
+        // tap, which Android's install and permission steps require, as in
+        // the host's other approval sheets (`connected_review.rs`). A click
+        // read later from `Event::Actions` is never a trusted one.
+        let actions = cx.capture_actions(|cx| self.view.handle_event(cx, event, scope));
+        let trusted = makepad_widgets::makepad_platform::trusted_user_input();
+        if self.view.button(cx, ids!(install)).pressed(&actions) {
+            self.install_down = trusted;
+        }
+        if self.view.button(cx, ids!(permission)).pressed(&actions) {
+            self.permission_down = trusted;
+        }
+        let changed = [
+            ids!(channel),
+            ids!(check),
+            ids!(download),
+            ids!(cancel),
+            ids!(install),
+            ids!(notes),
+            ids!(permission),
+            ids!(retry_install),
+        ]
+        .iter()
+        .any(|id| self.view.button(cx, *id).clicked(&actions));
+        if !changed {
+            return;
+        }
+        if self.view.button(cx, ids!(channel)).clicked(&actions)
+            && self.task.is_none()
+            && !self.android_install_pending
+        {
+            self.channel = (self.channel + 1) % 3;
+            self.retire_offer();
+            self.message = "Channel changed. Check for updates to continue.".into();
+        }
+        if self.view.button(cx, ids!(check)).clicked(&actions) {
+            self.check();
+        }
+        if self.view.button(cx, ids!(download)).clicked(&actions) {
+            self.download();
+        }
+        if self.view.button(cx, ids!(cancel)).clicked(&actions) {
+            if let Some(task) = &self.task {
+                task.cancel.cancel();
+                self.message = "Cancelling…".into();
             }
-            if self.view.button(cx, ids!(retry_install)).clicked(actions) {
-                self.android_command(cx, "update_status", serde_json::json!({}));
+        }
+        if self.view.button(cx, ids!(install)).clicked(&actions) {
+            let down = std::mem::take(&mut self.install_down);
+            self.install(down && trusted);
+        }
+        if self.view.button(cx, ids!(notes)).clicked(&actions) {
+            if let Some(offer) = &self.offer {
+                cx.open_url(&offer.release_url(), OpenUrlInPlace::No);
             }
-            self.refresh(cx);
         }
+        if self.view.button(cx, ids!(permission)).clicked(&actions)
+            && std::mem::take(&mut self.permission_down)
+            && trusted
+        {
+            self.android_command(cx, "update_allow_install", serde_json::json!({}));
+        }
+        if self.view.button(cx, ids!(retry_install)).clicked(&actions) {
+            self.android_command(cx, "update_status", serde_json::json!({}));
+        }
+        self.refresh(cx);
     }
 }
 
diff --git a/crates/updater/Cargo.toml b/crates/updater/Cargo.toml
index 8c016bf5f..6b9950ba0 100644
--- a/crates/updater/Cargo.toml
+++ b/crates/updater/Cargo.toml
@@ -12,6 +12,12 @@ tokio.workspace = true
 sha2 = "0.10"
 semver = "1"
 uuid.workspace = true
+# GitHub's signature on a desktop release's SHA256SUMS, verified as App Hub
+# verifies its catalog: the same exact Sigstore crates, already in the lock.
+sigstore-verify = { version = "=0.14.0", default-features = false }
+sigstore-trust-root = { version = "=0.14.0", default-features = false }
+sigstore-types = "=0.14.0"
 
 [dev-dependencies]
+base64 = "0.22"
 tempfile = "3"
diff --git a/crates/updater/src/lib.rs b/crates/updater/src/lib.rs
index ed32f4918..fe4f4d558 100644
--- a/crates/updater/src/lib.rs
+++ b/crates/updater/src/lib.rs
@@ -3,6 +3,8 @@
 //! This crate checks and downloads; it never launches installers or replaces the
 //! running application. Run its blocking public methods on a worker thread. No
 //! account, GitHub token, shell command or application-agent capability is used.
+//! A desktop release is offered only with GitHub's signature on its checksums
+//! (`signature.rs`).
 
 use semver::Version;
 use serde::Deserialize;
@@ -17,17 +19,33 @@ use std::{
         atomic::{AtomicBool, Ordering},
         Arc,
     },
-    time::{Duration, Instant},
+    time::{Duration, Instant, SystemTime},
 };
 
+mod signature;
+
 const RELEASES_API: &str = "https://api.github.com/repos/OctoSense-org/OctoSense/releases";
 const RELEASES_WEB: &str = "https://github.com/OctoSense-org/OctoSense/releases";
-const MAX_METADATA: u64 = 2 * 1024 * 1024;
+/// GitHub's largest page of releases.
+const RELEASES_PER_PAGE: usize = 100;
+/// One release's JSON carries its notes, its author and every asset with its
+/// uploader: OctoSense's first eight releases took 229,853 bytes, 28 KiB
+/// each. A page must hold a hundred much larger ones, or every check would
+/// fail once the repository has enough releases, and no update could fix it.
+const MAX_RELEASE_JSON: u64 = 160 * 1024;
+const MAX_METADATA: u64 = RELEASES_PER_PAGE as u64 * MAX_RELEASE_JSON;
+/// GitHub lists releases newest first; an update is never older than this.
+const MAX_RELEASE_PAGES: usize = 10;
 const MAX_SUMS: u64 = 256 * 1024;
 const MAX_INSTALLER: u64 = 1024 * 1024 * 1024;
 const MAX_ANDROID_INSTALLER: u64 = 512 * 1024 * 1024;
 const NETWORK_IDLE: Duration = Duration::from_secs(20);
 const DOWNLOAD_DEADLINE: Duration = Duration::from_secs(15 * 60);
+/// A verified download is kept a day after it was last written: an opened
+/// installer or disk image may still be reading it.
+const KEEP_VERIFIED: Duration = Duration::from_secs(24 * 60 * 60);
+/// A partial download outlives its own deadline only when its run ended.
+const KEEP_PENDING: Duration = Duration::from_secs(60 * 60);
 
 pub type Result = std::result::Result;
 
@@ -423,6 +441,7 @@ impl Client {
         if platform.product() != current.product {
             return Err(Error::Unsupported);
         }
+        sweep(&self.cache_dir);
         run(self.check_async(current, channel, platform, cancel))
     }
     pub fn download(
@@ -444,25 +463,16 @@ impl Client {
     ) -> Result {
         let deadline = Instant::now() + Duration::from_secs(120);
         let mut releases = Vec::new();
-        for page in 1..=10 {
-            let url = format!("{RELEASES_API}?per_page=100&page={page}");
+        for page in 1..=MAX_RELEASE_PAGES {
+            let url = format!("{RELEASES_API}?per_page={RELEASES_PER_PAGE}&page={page}");
             let response = self.request(&url, cancel, deadline).await?;
             let bytes = read_response(response, MAX_METADATA, cancel, deadline).await?;
-            let page_releases: Vec = serde_json::from_slice(&bytes)
-                .map_err(|_| Error::InvalidMetadata("invalid release list"))?;
-            if page_releases.len() > 100 {
-                return Err(Error::InvalidMetadata("oversized release list"));
-            }
+            let page_releases = parse_release_page(&bytes)?;
             let count = page_releases.len();
             releases.extend(page_releases);
-            if count < 100 {
+            if count < RELEASES_PER_PAGE {
                 break;
             }
-            if page == 10 {
-                return Err(Error::InvalidMetadata(
-                    "release history exceeds safety limit",
-                ));
-            }
         }
         let Some((release, version, asset)) =
             select_release(releases, current.product, channel, platform)?
@@ -490,6 +500,10 @@ impl Client {
             .await?;
         let bytes = read_response(response, MAX_SUMS, cancel, deadline).await?;
         verify_bytes(&bytes, sums.size, &sums_digest)?;
+        if current.product == Product::Desktop {
+            self.verify_signature(&release, &bytes, cancel, deadline)
+                .await?;
+        }
         verify_checksum_manifest(&bytes, &asset.name, &expected_digest)?;
         let status = update_status(current, &version);
         Ok(CheckResult {
@@ -503,6 +517,28 @@ impl Client {
             }),
         })
     }
+    /// A desktop release's checksums must carry GitHub's signature from
+    /// OctoSense's release signing workflow (`signature.rs`). Home needs none:
+    /// Android installs only an APK from the pinned release signer.
+    async fn verify_signature(
+        &self,
+        release: &Release,
+        sums: &[u8],
+        cancel: &Cancellation,
+        deadline: Instant,
+    ) -> Result<()> {
+        let (proof, digest) = signature_asset(release)?;
+        let response = self
+            .request(&proof.browser_download_url, cancel, deadline)
+            .await?;
+        let bytes = read_response(response, signature::MAX_SIGNATURE, cancel, deadline).await?;
+        if bytes.len() as u64 != proof.size || format!("{:x}", Sha256::digest(&bytes)) != digest {
+            return Err(Error::Integrity(
+                "the release signature does not match GitHub metadata",
+            ));
+        }
+        signature::verify(&release.tag_name, sums, &bytes).map_err(Error::Integrity)
+    }
     async fn download_async(
         &self,
         offer: &Offer,
@@ -646,6 +682,28 @@ struct Release {
     prerelease: bool,
     assets: Vec,
 }
+fn signature_asset(release: &Release) -> Result<(&ReleaseAsset, String)> {
+    let proofs: Vec<_> = release
+        .assets
+        .iter()
+        .filter(|a| a.name == signature::SIGNATURE_ASSET)
+        .collect();
+    let [proof] = proofs.as_slice() else {
+        return Err(Error::Integrity(
+            "the release is not signed by OctoSense's release workflow",
+        ));
+    };
+    let digest = validate_asset(&release.tag_name, proof, signature::MAX_SIGNATURE)?;
+    Ok((proof, digest))
+}
+fn parse_release_page(bytes: &[u8]) -> Result> {
+    let releases: Vec = serde_json::from_slice(bytes)
+        .map_err(|_| Error::InvalidMetadata("invalid release list"))?;
+    if releases.len() > RELEASES_PER_PAGE {
+        return Err(Error::InvalidMetadata("oversized release list"));
+    }
+    Ok(releases)
+}
 fn parse_tag(product: Product, tag: &str) -> Result {
     if tag.len() > 100
         || !tag
@@ -845,6 +903,80 @@ fn prepare_root(path: &Path) -> Result {
     path.canonicalize()
         .map_err(|_| Error::Storage("cannot locate update directory"))
 }
+/// Delete the downloads nothing needs any more: a verified installer a day
+/// after it was last written (the person opened it, or never did) and what
+/// an interrupted run left behind. Only `pending-` and
+/// `verified-` directories holding nothing but regular files are
+/// removed, never recursively; anything else in the cache is left alone.
+/// Returns how many directories were removed.
+pub fn sweep(cache_dir: &Path) -> usize {
+    sweep_at(cache_dir, SystemTime::now())
+}
+fn sweep_at(root: &Path, now: SystemTime) -> usize {
+    if validate_directory(root).is_err() {
+        return 0;
+    }
+    let Ok(entries) = fs::read_dir(root) else {
+        return 0;
+    };
+    let mut removed = 0;
+    for entry in entries.flatten() {
+        let name = entry.file_name();
+        let Some(name) = name.to_str() else {
+            continue;
+        };
+        let keep = if is_operation_directory(name, "verified-") {
+            KEEP_VERIFIED
+        } else if is_operation_directory(name, "pending-") {
+            KEEP_PENDING
+        } else {
+            continue;
+        };
+        if remove_if_stale(&entry.path(), now, keep) {
+            removed += 1;
+        }
+    }
+    removed
+}
+fn is_operation_directory(name: &str, prefix: &str) -> bool {
+    name.strip_prefix(prefix)
+        .is_some_and(|id| uuid::Uuid::parse_str(id).is_ok())
+}
+fn remove_if_stale(directory: &Path, now: SystemTime, keep: Duration) -> bool {
+    let Ok(meta) = fs::symlink_metadata(directory) else {
+        return false;
+    };
+    if !meta.file_type().is_dir() {
+        return false;
+    }
+    let mut newest = meta.modified().ok();
+    let mut files = Vec::new();
+    let Ok(entries) = fs::read_dir(directory) else {
+        return false;
+    };
+    for entry in entries {
+        let Ok((path, meta)) = entry.and_then(|entry| {
+            let path = entry.path();
+            fs::symlink_metadata(&path).map(|meta| (path, meta))
+        }) else {
+            return false;
+        };
+        if !meta.file_type().is_file() {
+            return false;
+        }
+        newest = newest.max(meta.modified().ok());
+        files.push(path);
+    }
+    // A clock set back, or a time in the future, keeps the files.
+    let Some(age) = newest.and_then(|newest| now.duration_since(newest).ok()) else {
+        return false;
+    };
+    if age < keep {
+        return false;
+    }
+    // An installer still running on Windows cannot be removed; next time.
+    files.iter().all(|file| fs::remove_file(file).is_ok()) && fs::remove_dir(directory).is_ok()
+}
 struct PendingDownload {
     offer: Offer,
     root: PathBuf,
diff --git a/crates/updater/src/signature.rs b/crates/updater/src/signature.rs
new file mode 100644
index 000000000..3d52a5ef9
--- /dev/null
+++ b/crates/updater/src/signature.rs
@@ -0,0 +1,378 @@
+//! GitHub's signature on a desktop release's `SHA256SUMS`.
+//!
+//! GitHub's asset digests and the checksum file only prove that the files are
+//! the ones in the release: anyone who can publish a release can make them
+//! agree. A desktop release is offered only when its `SHA256SUMS` also carries
+//! GitHub's signature from the `sign-desktop-release` workflow on `main`, run
+//! in the `desktop-release-signing` environment, whose reviewers approve each
+//! run. This is the model App Hub uses for its catalog (`github_catalog.rs`):
+//! the Sigstore library verifies the certificate, its timestamps and the
+//! transparency-log proof offline against its embedded public-good trust root,
+//! and this module checks GitHub's claims and the exact signed subject.
+//!
+//! Android Home needs no such signature: its installer accepts only an APK
+//! signed by the pinned release signer (`HomeUpdatePolicy.java`).
+
+use sha2::{Digest, Sha256};
+use sigstore_trust_root::{SigstoreInstance, TrustedRoot};
+use sigstore_types::{Bundle, MediaType, SignatureContent};
+use sigstore_verify::{VerificationPolicy, Verifier};
+
+/// The release asset holding the Sigstore bundle for `SHA256SUMS`.
+pub(crate) const SIGNATURE_ASSET: &str = "SHA256SUMS.sigstore.json";
+pub(crate) const MAX_SIGNATURE: u64 = 2 * 1024 * 1024;
+
+const GITHUB_ISSUER: &str = "https://token.actions.githubusercontent.com";
+const WORKFLOW_PATH: &str = ".github/workflows/sign-desktop-release.yml";
+const WORKFLOW: &str = "https://github.com/OctoSense-org/OctoSense/.github/workflows/sign-desktop-release.yml@refs/heads/main";
+const REPOSITORY: &str = "https://github.com/OctoSense-org/OctoSense";
+const OWNER: &str = "https://github.com/OctoSense-org";
+const REPOSITORY_ID: &str = "1361736338";
+const OWNER_ID: &str = "328148893";
+const ENVIRONMENT: &str = "desktop-release-signing";
+
+/// What the workflow signs: the release's `SHA256SUMS`, named after its tag,
+/// so a signature names the one release it was made for.
+pub(crate) fn subject_name(tag: &str) -> String {
+    format!("{tag}.SHA256SUMS")
+}
+
+/// Verify `proof`, a Sigstore bundle, over the exact bytes of `tag`'s
+/// `SHA256SUMS`.
+pub(crate) fn verify(tag: &str, sums: &[u8], proof: &[u8]) -> Result<(), &'static str> {
+    if proof.len() as u64 > MAX_SIGNATURE {
+        return Err("the release signature exceeds its size limit");
+    }
+    let bundle: Bundle = serde_json::from_slice(proof)
+        .map_err(|_| "the release signature is not a Sigstore bundle")?;
+    let root = TrustedRoot::from_embedded(SigstoreInstance::PublicGood)
+        .map_err(|_| "the Sigstore trust root is invalid")?;
+    let policy = VerificationPolicy::new(WORKFLOW, GITHUB_ISSUER);
+    let result = verify_proof(sums, &bundle, &root, &policy)?;
+    authorize_claims(
+        &result
+            .certificate()
+            .ok_or("the release signature has no verified certificate")?
+            .ci_claims,
+    )?;
+    verify_subject(
+        &bundle,
+        &subject_name(tag),
+        &format!("{:x}", Sha256::digest(sums)),
+    )
+}
+
+fn verify_proof(
+    payload: &[u8],
+    bundle: &Bundle,
+    root: &TrustedRoot,
+    policy: &VerificationPolicy,
+) -> Result {
+    if bundle.media_type != MediaType::Bundle0_3 {
+        return Err("the release signature must be a Sigstore v0.3 bundle");
+    }
+    let verifier = Verifier::new(root).map_err(|_| "the Sigstore trust root is invalid")?;
+    let result = verifier
+        .verify(payload, bundle, policy)
+        .map_err(|_| "the release is not signed by OctoSense's release workflow")?;
+    if !result.certificate_verified()
+        || !result.sct_verified()
+        || !result.tlog_verified()
+        || !result.identity_policy_checked()
+    {
+        return Err("the release signature was not fully verified");
+    }
+    Ok(result)
+}
+
+fn authorize_claims(claims: &sigstore_verify::crypto::FulcioCiClaims) -> Result<(), &'static str> {
+    for (actual, expected) in [
+        (claims.build_signer_uri.as_deref(), WORKFLOW),
+        (claims.source_repository_uri.as_deref(), REPOSITORY),
+        (
+            claims.source_repository_identifier.as_deref(),
+            REPOSITORY_ID,
+        ),
+        (claims.source_repository_owner_uri.as_deref(), OWNER),
+        (
+            claims.source_repository_owner_identifier.as_deref(),
+            OWNER_ID,
+        ),
+        (claims.source_repository_ref.as_deref(), "refs/heads/main"),
+        (claims.build_config_uri.as_deref(), WORKFLOW),
+        (claims.build_trigger.as_deref(), "workflow_dispatch"),
+        (claims.runner_environment.as_deref(), "github-hosted"),
+        (
+            claims.source_repository_visibility_at_signing.as_deref(),
+            "public",
+        ),
+        (claims.deployment_environment.as_deref(), ENVIRONMENT),
+    ] {
+        if actual != Some(expected) {
+            return Err("the release was signed outside OctoSense's release workflow");
+        }
+    }
+    Ok(())
+}
+
+fn verify_subject(bundle: &Bundle, name: &str, digest: &str) -> Result<(), &'static str> {
+    let SignatureContent::DsseEnvelope(envelope) = &bundle.content else {
+        return Err("the release signature is not an in-toto attestation");
+    };
+    let statement: serde_json::Value = serde_json::from_slice(envelope.payload.as_bytes())
+        .map_err(|_| "the release signature's statement is invalid")?;
+    if statement.get("_type").and_then(|v| v.as_str()) != Some("https://in-toto.io/Statement/v1")
+        || statement.get("predicateType").and_then(|v| v.as_str())
+            != Some("https://slsa.dev/provenance/v1")
+    {
+        return Err("the release signature has an unexpected statement type");
+    }
+    let subjects = statement
+        .get("subject")
+        .and_then(|v| v.as_array())
+        .ok_or("the release signature names no subject")?;
+    if subjects.len() != 1
+        || subjects[0].get("name").and_then(|v| v.as_str()) != Some(name)
+        || subjects[0]
+            .pointer("/digest/sha256")
+            .and_then(|v| v.as_str())
+            != Some(digest)
+    {
+        return Err("the release signature does not name this release's SHA256SUMS");
+    }
+    // The maintained actions/attest SLSA workflow/v1 predicate: its builder
+    // is the job's workflow, not merely a GitHub runner.
+    for (pointer, expected) in [
+        ("/predicate/runDetails/builder/id", WORKFLOW),
+        (
+            "/predicate/buildDefinition/buildType",
+            "https://actions.github.io/buildtypes/workflow/v1",
+        ),
+        (
+            "/predicate/buildDefinition/externalParameters/workflow/repository",
+            REPOSITORY,
+        ),
+        (
+            "/predicate/buildDefinition/externalParameters/workflow/ref",
+            "refs/heads/main",
+        ),
+        (
+            "/predicate/buildDefinition/externalParameters/workflow/path",
+            WORKFLOW_PATH,
+        ),
+        (
+            "/predicate/buildDefinition/internalParameters/github/event_name",
+            "workflow_dispatch",
+        ),
+        (
+            "/predicate/buildDefinition/internalParameters/github/repository_id",
+            REPOSITORY_ID,
+        ),
+        (
+            "/predicate/buildDefinition/internalParameters/github/repository_owner_id",
+            OWNER_ID,
+        ),
+        (
+            "/predicate/buildDefinition/internalParameters/github/runner_environment",
+            "github-hosted",
+        ),
+    ] {
+        if statement.pointer(pointer).and_then(|v| v.as_str()) != Some(expected) {
+            return Err("the release signature names another workflow");
+        }
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use base64::{engine::general_purpose::STANDARD, Engine};
+
+    // A public Sigstore bundle over synthetic text (see tests/fixtures).
+    const PROOF: &str = include_str!("../tests/fixtures/cosign-v3-blob.sigstore.json");
+    const PAYLOAD: &[u8] = include_bytes!("../tests/fixtures/cosign-v3-blob.txt");
+
+    fn public_fixture() -> (Bundle, TrustedRoot, VerificationPolicy) {
+        let bundle = Bundle::from_json(PROOF).unwrap();
+        let cert =
+            sigstore_verify::crypto::parse_certificate_info(bundle.signing_certificate().unwrap())
+                .unwrap();
+        let policy = VerificationPolicy::new(cert.identity.unwrap().as_str(), cert.issuer.unwrap());
+        (
+            bundle,
+            TrustedRoot::from_embedded(SigstoreInstance::PublicGood).unwrap(),
+            policy,
+        )
+    }
+
+    #[test]
+    fn a_valid_public_signature_from_another_signer_is_refused() {
+        let (bundle, root, policy) = public_fixture();
+        // The cryptography holds for its own signer...
+        let result = verify_proof(PAYLOAD, &bundle, &root, &policy).unwrap();
+        assert!(result.certificate_verified() && result.sct_verified() && result.tlog_verified());
+        // ...but it is not OctoSense's release workflow.
+        assert!(authorize_claims(&result.certificate().unwrap().ci_claims).is_err());
+        assert!(verify_proof(
+            PAYLOAD,
+            &bundle,
+            &root,
+            &VerificationPolicy::new(WORKFLOW, GITHUB_ISSUER)
+        )
+        .is_err());
+        assert!(verify("desktop-v1.0.0", PAYLOAD, PROOF.as_bytes()).is_err());
+    }
+
+    #[test]
+    fn tampered_checksums_signature_or_transparency_proof_are_refused() {
+        let (bundle, root, policy) = public_fixture();
+        assert!(verify_proof(b"modified checksums", &bundle, &root, &policy).is_err());
+        for pointer in [
+            "/messageSignature/signature",
+            "/verificationMaterial/tlogEntries/0/inclusionPromise/signedEntryTimestamp",
+            "/verificationMaterial/tlogEntries/0/inclusionProof/rootHash",
+        ] {
+            let mut value: serde_json::Value = serde_json::from_str(PROOF).unwrap();
+            let field = value
+                .pointer_mut(pointer)
+                .expect("fixture must contain attacked field");
+            let mut bytes = STANDARD.decode(field.as_str().unwrap()).unwrap();
+            bytes[0] ^= 1;
+            *field = serde_json::Value::String(STANDARD.encode(bytes));
+            let broken: Bundle = serde_json::from_value(value).unwrap();
+            assert!(
+                verify_proof(PAYLOAD, &broken, &root, &policy).is_err(),
+                "{pointer}"
+            );
+        }
+        let mut no_log = bundle;
+        no_log.verification_material.tlog_entries.clear();
+        assert!(verify_proof(PAYLOAD, &no_log, &root, &policy).is_err());
+    }
+
+    #[test]
+    fn other_workflows_branches_issuers_and_roots_are_refused() {
+        let (bundle, root, policy) = public_fixture();
+        for (identity, issuer) in [
+            ("https://github.com/other/repo/.github/workflows/sign-desktop-release.yml@refs/heads/main", GITHUB_ISSUER),
+            (WORKFLOW, "https://untrusted.example"),
+            ("https://github.com/OctoSense-org/OctoSense/.github/workflows/release-desktop.yml@refs/heads/main", GITHUB_ISSUER),
+            ("https://github.com/OctoSense-org/OctoSense/.github/workflows/sign-desktop-release.yml@refs/tags/desktop-v1.0.0", GITHUB_ISSUER),
+        ] {
+            assert!(verify_proof(PAYLOAD, &bundle, &root, &VerificationPolicy::new(identity, issuer)).is_err());
+        }
+        let staging = TrustedRoot::from_embedded(SigstoreInstance::Staging).unwrap();
+        assert!(verify_proof(PAYLOAD, &bundle, &staging, &policy).is_err());
+    }
+
+    #[test]
+    fn every_github_claim_of_the_signing_run_is_required() {
+        let mut claims = sigstore_verify::crypto::FulcioCiClaims::default();
+        claims.build_signer_uri = Some(WORKFLOW.into());
+        claims.source_repository_uri = Some(REPOSITORY.into());
+        claims.source_repository_identifier = Some(REPOSITORY_ID.into());
+        claims.source_repository_owner_uri = Some(OWNER.into());
+        claims.source_repository_owner_identifier = Some(OWNER_ID.into());
+        claims.source_repository_ref = Some("refs/heads/main".into());
+        claims.build_config_uri = Some(WORKFLOW.into());
+        claims.build_trigger = Some("workflow_dispatch".into());
+        claims.runner_environment = Some("github-hosted".into());
+        claims.source_repository_visibility_at_signing = Some("public".into());
+        claims.deployment_environment = Some(ENVIRONMENT.into());
+        assert!(authorize_claims(&claims).is_ok());
+        for i in 0..11 {
+            for value in [Some("untrusted".to_string()), None] {
+                let mut broken = claims.clone();
+                let field = match i {
+                    0 => &mut broken.build_signer_uri,
+                    1 => &mut broken.source_repository_uri,
+                    2 => &mut broken.source_repository_identifier,
+                    3 => &mut broken.source_repository_owner_uri,
+                    4 => &mut broken.source_repository_owner_identifier,
+                    5 => &mut broken.source_repository_ref,
+                    6 => &mut broken.build_config_uri,
+                    7 => &mut broken.build_trigger,
+                    8 => &mut broken.runner_environment,
+                    9 => &mut broken.source_repository_visibility_at_signing,
+                    _ => &mut broken.deployment_environment,
+                };
+                *field = value;
+                assert!(authorize_claims(&broken).is_err(), "claim {i}");
+            }
+        }
+    }
+
+    #[test]
+    fn the_signature_must_name_this_tags_exact_checksums_and_the_workflow() {
+        // The policy after cryptographic verification, on a synthetic statement.
+        let tag = "desktop-v0.2.0-rc.1";
+        let digest = format!("{:x}", Sha256::digest(b"checksums"));
+        let statement = serde_json::json!({"_type":"https://in-toto.io/Statement/v1",
+            "subject":[{"name":subject_name(tag),"digest":{"sha256":digest}}],
+            "predicateType":"https://slsa.dev/provenance/v1","predicate":{
+                "runDetails":{"builder":{"id":WORKFLOW}},
+                "buildDefinition":{"buildType":"https://actions.github.io/buildtypes/workflow/v1",
+                    "externalParameters":{"workflow":{"repository":REPOSITORY,"ref":"refs/heads/main","path":WORKFLOW_PATH}},
+                    "internalParameters":{"github":{"event_name":"workflow_dispatch","repository_id":REPOSITORY_ID,"repository_owner_id":OWNER_ID,"runner_environment":"github-hosted"}}}}});
+        let bundle_for = |statement: &serde_json::Value| {
+            let mut value: serde_json::Value = serde_json::from_str(PROOF).unwrap();
+            let signature = value["messageSignature"]["signature"].clone();
+            value.as_object_mut().unwrap().remove("messageSignature");
+            value["dsseEnvelope"] = serde_json::json!({"payloadType":"application/vnd.in-toto+json",
+                "payload":STANDARD.encode(serde_json::to_vec(statement).unwrap()),"signatures":[{"sig":signature}]});
+            serde_json::from_value::(value).unwrap()
+        };
+        assert!(verify_subject(&bundle_for(&statement), &subject_name(tag), &digest).is_ok());
+        // Another release's checksums, or other bytes, are not this release's.
+        assert!(verify_subject(
+            &bundle_for(&statement),
+            &subject_name("desktop-v0.1.0"),
+            &digest
+        )
+        .is_err());
+        assert!(
+            verify_subject(&bundle_for(&statement), &subject_name(tag), &"a".repeat(64)).is_err()
+        );
+        for pointer in [
+            "/subject/0/name",
+            "/subject/0/digest/sha256",
+            "/predicateType",
+            "/_type",
+            "/predicate/runDetails/builder/id",
+            "/predicate/buildDefinition/buildType",
+            "/predicate/buildDefinition/externalParameters/workflow/repository",
+            "/predicate/buildDefinition/externalParameters/workflow/ref",
+            "/predicate/buildDefinition/externalParameters/workflow/path",
+            "/predicate/buildDefinition/internalParameters/github/event_name",
+            "/predicate/buildDefinition/internalParameters/github/repository_id",
+            "/predicate/buildDefinition/internalParameters/github/repository_owner_id",
+            "/predicate/buildDefinition/internalParameters/github/runner_environment",
+        ] {
+            let mut bad = statement.clone();
+            *bad.pointer_mut(pointer).unwrap() = "wrong".into();
+            assert!(
+                verify_subject(&bundle_for(&bad), &subject_name(tag), &digest).is_err(),
+                "{pointer}"
+            );
+        }
+        let mut two = statement.clone();
+        two["subject"]
+            .as_array_mut()
+            .unwrap()
+            .push(statement["subject"][0].clone());
+        assert!(verify_subject(&bundle_for(&two), &subject_name(tag), &digest).is_err());
+    }
+
+    #[test]
+    fn unsigned_oversized_and_malformed_signatures_are_refused() {
+        for proof in [b"".as_slice(), b"{}", b"not json"] {
+            assert!(verify("desktop-v1.0.0", b"sums", proof).is_err());
+        }
+        let oversized = vec![b' '; MAX_SIGNATURE as usize + 1];
+        assert_eq!(
+            verify("desktop-v1.0.0", b"sums", &oversized),
+            Err("the release signature exceeds its size limit")
+        );
+    }
+}
diff --git a/crates/updater/src/tests.rs b/crates/updater/src/tests.rs
index 728ef0dc3..a430f6471 100644
--- a/crates/updater/src/tests.rs
+++ b/crates/updater/src/tests.rs
@@ -585,3 +585,134 @@ fn finished_worker_does_not_wait_indefinitely_for_os_resolver_tasks() {
     worker.join().unwrap();
     assert!(bounded.expect("runtime shutdown must be bounded").is_ok());
 }
+
+/// A page as GitHub sends it: each release with its notes, author and assets
+/// with their uploaders, as large as OctoSense's own (28 KiB each).
+fn realistic_page(count: usize) -> Vec {
+    let user = serde_json::json!({"login": "github-actions[bot]", "id": 41898282,
+        "node_id": "MDM6Qm90NDE4OTgyODI=", "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4",
+        "url": "https://api.github.com/users/github-actions%5Bbot%5D", "html_url": "https://github.com/apps/github-actions",
+        "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers",
+        "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", "type": "Bot", "site_admin": false});
+    let releases: Vec<_> = (0..count)
+        .map(|i| {
+            let tag = format!("desktop-v0.{i}.0");
+            let assets: Vec<_> = (0..9)
+                .map(|n| {
+                    let name = format!("OctoSense_0.{i}.0_asset-{n}.receipt.json");
+                    serde_json::json!({"url": format!("https://api.github.com/repos/OctoSense-org/OctoSense/releases/assets/{n}"),
+                        "id": n, "node_id": "RA_kwDOUJ6Gss4OctoSense", "name": name, "label": "", "uploader": user,
+                        "content_type": "application/json", "state": "uploaded", "size": 1000,
+                        "digest": format!("sha256:{}", "a".repeat(64)), "download_count": 3,
+                        "created_at": "2026-10-10T00:00:00Z", "updated_at": "2026-10-10T00:00:00Z",
+                        "browser_download_url": format!("{RELEASES_WEB}/download/{tag}/{name}")})
+                })
+                .collect();
+            serde_json::json!({"url": "https://api.github.com/repos/OctoSense-org/OctoSense/releases/1",
+                "html_url": format!("{RELEASES_WEB}/tag/{tag}"), "id": i, "author": user, "node_id": "RE_kwDOUJ6Gss4",
+                "tag_name": tag, "target_commitish": "main", "name": format!("OctoSense desktop 0.{i}.0"),
+                "draft": false, "prerelease": false, "created_at": "2026-10-10T00:00:00Z",
+                "published_at": "2026-10-10T00:00:00Z", "assets": assets, "body": "Release notes. ".repeat(1200)})
+        })
+        .collect();
+    serde_json::to_vec(&releases).unwrap()
+}
+#[test]
+fn a_full_page_of_real_sized_releases_fits() {
+    // The first limit, 2 MiB a page, refused a page of 73 such releases.
+    let page = realistic_page(RELEASES_PER_PAGE);
+    assert!(page.len() as u64 > 2 * 1024 * 1024);
+    assert!(page.len() as u64 <= MAX_METADATA, "{} bytes", page.len());
+    let releases = parse_release_page(&page).unwrap();
+    assert_eq!(releases.len(), RELEASES_PER_PAGE);
+    assert!(parse_release_page(&realistic_page(RELEASES_PER_PAGE + 1)).is_err());
+}
+
+#[test]
+fn a_desktop_release_needs_one_signature_from_its_own_release() {
+    let tag = "desktop-v0.2.0";
+    let mut signed = release(tag, Platform::MacArm64);
+    assert_eq!(
+        signature_asset(&signed).unwrap_err(),
+        Error::Integrity("the release is not signed by OctoSense's release workflow")
+    );
+    signed
+        .assets
+        .push(asset(tag, signature::SIGNATURE_ASSET, b"{}"));
+    let (proof, digest) = signature_asset(&signed).unwrap();
+    assert_eq!(proof.name, signature::SIGNATURE_ASSET);
+    assert_eq!(digest, format!("{:x}", Sha256::digest(b"{}")));
+    // A signature hosted by another release is not this release's.
+    let mut foreign = release(tag, Platform::MacArm64);
+    foreign
+        .assets
+        .push(asset("desktop-v0.1.0", signature::SIGNATURE_ASSET, b"{}"));
+    assert!(signature_asset(&foreign).is_err());
+    signed
+        .assets
+        .push(asset(tag, signature::SIGNATURE_ASSET, b"{}"));
+    assert!(signature_asset(&signed).is_err());
+}
+
+fn operation_directory(root: &Path, prefix: &str, file: Option<&[u8]>) -> PathBuf {
+    let directory = root.join(format!("{prefix}{}", uuid::Uuid::new_v4()));
+    create_private_directory(&directory).unwrap();
+    if let Some(bytes) = file {
+        fs::write(directory.join("OctoSense_0.2.0_aarch64.dmg"), bytes).unwrap();
+    }
+    directory
+}
+#[test]
+fn stale_downloads_are_swept_and_everything_else_is_left_alone() {
+    let tmp = tempfile::tempdir().unwrap();
+    let root = prepare_root(&tmp.path().join("updates")).unwrap();
+    let verified = operation_directory(&root, "verified-", Some(b"installer"));
+    let pending = operation_directory(&root, "pending-", Some(b"partial"));
+    let empty = operation_directory(&root, "pending-", None);
+    let nested = operation_directory(&root, "verified-", Some(b"installer"));
+    fs::create_dir(nested.join("inside")).unwrap();
+    let foreign = root.join("verified-not-a-uuid");
+    fs::create_dir(&foreign).unwrap();
+    fs::write(root.join("notes.txt"), b"kept").unwrap();
+
+    // Fresh downloads stay.
+    assert_eq!(sweep_at(&root, SystemTime::now()), 0);
+    // After two hours only the interrupted ones go...
+    let later = SystemTime::now() + Duration::from_secs(2 * 60 * 60);
+    assert_eq!(sweep_at(&root, later), 2);
+    assert!(!pending.exists() && !empty.exists());
+    assert!(verified.exists());
+    // ...and after two days the verified installer too.
+    let much_later = SystemTime::now() + Duration::from_secs(2 * 24 * 60 * 60);
+    assert_eq!(sweep_at(&root, much_later), 1);
+    assert!(!verified.exists());
+    // Never recursive, never a directory this crate did not name.
+    assert!(nested.join("inside").exists() && foreign.exists());
+    assert!(root.join("notes.txt").exists());
+    // A missing cache is not an error.
+    assert_eq!(sweep(&tmp.path().join("missing")), 0);
+}
+#[cfg(unix)]
+#[test]
+fn sweeping_never_follows_a_symlink() {
+    use std::os::unix::fs::symlink;
+    let tmp = tempfile::tempdir().unwrap();
+    let outside = tempfile::tempdir().unwrap();
+    fs::write(outside.path().join("keep.dmg"), b"precious").unwrap();
+    let root = prepare_root(&tmp.path().join("updates")).unwrap();
+    let linked = root.join(format!("verified-{}", uuid::Uuid::new_v4()));
+    symlink(outside.path(), &linked).unwrap();
+    let directory = operation_directory(&root, "verified-", None);
+    symlink(
+        outside.path().join("keep.dmg"),
+        directory.join("installer.dmg"),
+    )
+    .unwrap();
+    let much_later = SystemTime::now() + Duration::from_secs(3 * 24 * 60 * 60);
+    assert_eq!(sweep_at(&root, much_later), 0);
+    assert!(outside.path().join("keep.dmg").exists());
+    // Nor sweeps a cache reached through a symlink.
+    let alias = tmp.path().join("alias");
+    symlink(&root, &alias).unwrap();
+    assert_eq!(sweep_at(&alias, much_later), 0);
+}
diff --git a/crates/updater/tests/fixtures/README.md b/crates/updater/tests/fixtures/README.md
new file mode 100644
index 000000000..6091948b7
--- /dev/null
+++ b/crates/updater/tests/fixtures/README.md
@@ -0,0 +1 @@
+Public verification fixtures from [sigstore-rust](https://github.com/sigstore/sigstore-rust/tree/8d1506991f63290f584bc0edebd7e47bf7fdc0ad/crates/sigstore-verify/test_data/bundles), Apache-2.0, the same files App Hub's catalog tests use (`crates/app-hub/tests/fixtures/github-catalog/`). They are existing public signatures over synthetic text, not OctoSense release signatures. The tests use the maintained Sigstore verifier; no private keys are included.
diff --git a/crates/updater/tests/fixtures/cosign-v3-blob.sigstore.json b/crates/updater/tests/fixtures/cosign-v3-blob.sigstore.json
new file mode 100644
index 000000000..b5962ff6f
--- /dev/null
+++ b/crates/updater/tests/fixtures/cosign-v3-blob.sigstore.json
@@ -0,0 +1 @@
+{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json", "verificationMaterial":{"certificate":{"rawBytes":"MIIC1TCCAlygAwIBAgIUQ6rCmpLcP7MAAjGQvMmPgMtSJdQwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjUxMjAzMTgzNjQyWhcNMjUxMjAzMTg0NjQyWjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFodOSridzGjgIAIl3/2N+eP4dMBEM0oMNJnbWLPBnASGSdjtYr8KvEoxYXTqc47tu22hKYyfnNPkADR1Q9FXeKOCAXswggF3MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUJjVZgdkHLo7sM1/lIx5dEthq9mgwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wJAYDVR0RAQH/BBowGIEWdy52b2xscHJlY2h0QGdtYWlsLmNvbTAsBgorBgEEAYO/MAEBBB5odHRwczovL2dpdGh1Yi5jb20vbG9naW4vb2F1dGgwLgYKKwYBBAGDvzABCAQgDB5odHRwczovL2dpdGh1Yi5jb20vbG9naW4vb2F1dGgwgYsGCisGAQQB1nkCBAIEfQR7AHkAdwDdPTBqxscRMmMZHhyZZzcCokpeuN48rf+HinKALynujgAAAZrlgJ0SAAAEAwBIMEYCIQC19RUfoY4zDUcXuEFD+jCs123iUaL3QzSC//Kf67mp5QIhAMtP95BFoDh17zzIVI5Dz2PJJx9KXG5eVdPrpkV2suvAMAoGCCqGSM49BAMDA2cAMGQCMHN5sdKmaUOF2pBGygVP8xlrxTHjh9A1y6B6YkXzpB8WAsjX0vqsPJ8s8gWFjfLo0wIwFPXpDGO0hentwcuKCnl10/Vk1yFUrb6BB0/Fg+1yJiiBn4FsJUbWacpt7KHCqQu1"}, "tlogEntries":[{"logIndex":"738312748", "logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}, "kindVersion":{"kind":"hashedrekord", "version":"0.0.1"}, "integratedTime":"1764787003", "inclusionPromise":{"signedEntryTimestamp":"MEUCIGduMyNHwdmNItO2glTI3vsJcX0UZJ7gPl9mb/+9gCYRAiEAvwcs6NeB049MkPeRa/t9tbkBUzVijo+fYxriOiEX994="}, "inclusionProof":{"logIndex":"616408486", "rootHash":"dMhojWLwKZjp3xdLBFgCysbIYp4FTe94vs70zFB4nAM=", "treeSize":"616408488", "hashes":["PXpZ49ay970tZNI9tKk2a8N3w2YdhDeXo4xr4fldbTs=", "1E5+TJDHAuuUwwZBcfvFYKuwJbBirKU17fRl1zH48hg=", "NkvH/aihlVQn4eqiKz2NVaPTPYslEStiaKwdOm/G9zY=", "aQmQQHU/7x9gnmLiB6M2jAGKuw73ALuyUm+wCfZW7bg=", "fcYaHYkZkRlEMpxUKAOD33AH4yBLcLEOBC6L5Lkv6/I=", "+jY3qOWPfdh1hLyOWUgtjykWOAlnyX2KIHU5+ONqVUI=", "toOguUn75si9YDuf9zTmXNv47noi41dcEcdegoMtJgo=", "rQVrtBDyDEqA6tmX1hbd2SHoYdcw1cKBoastRdMC0Lg=", "2lqmP0g7dTwttCuD3m5rAbf8af9Ydl/2Ct3fz7wwCJo=", "P07VUPWy7Jq8+bSaQ5xCZVQhhdVzYywP94WlTMaAifU=", "QRYmzeY4Zi71BnW0Mh1SWT9pavlAn9DhRMiRIgiTX9Q=", "4uYCK3kl03eiD37zCe/PGO3kCP/yLCSWEaTYkLGifio=", "aosWMjs6qwpL80j4koy1dyO/vNa5Q6NlvxMT+2T2GwA=", "Wf0Z+YbBL8mPn9AeMCdcxxuSxc7DuhUVP1iFflyC/hc=", "huaH1ZSkRyP4+vpmGtpmkkL845lhcmN9io8MIe6Sob0=", "ZmUkYkHBy1B723JrEgiKvepTdHYrP6y2a4oODYvi5VY=", "T4DqWD42hAtN+vX8jKCWqoC4meE4JekI9LxYGCcPy1M="], "checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n616408488\ndMhojWLwKZjp3xdLBFgCysbIYp4FTe94vs70zFB4nAM=\n\n— rekor.sigstore.dev wNI9ajBEAiB9yqLZxKPPSG4EYgyJq9C884egwJ32aYU8MyqeYXYiJQIgebrTUhIavn7+VyFhLnWNx7yC+ftNqRNfu+IzwTv08KM=\n"}}, "canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJlMTk0Yjk5ZGY2NjM5NTAxMzI1YzVmNDRkYTc5ZmUzYThlZmFlNmYyMzliZDc5NDQzYTE5Nzg3ZTA2ZmIxNzY0In19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJRkx0SmQ2WCtUU3pxOURFM3Z1eGltWkNuSmVZTTM0RnJMa3hVK3dpTE1MSkFpRUFneFQ3VnQzbVh1Tm5jT25vM3lvcFkwUW5vTlpMODE5aVoxT0xhcGNrYUJ3PSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTXhWRU5EUVd4NVowRjNTVUpCWjBsVlVUWnlRMjF3VEdOUU4wMUJRV3BIVVhaTmJWQm5UWFJUU21SUmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFZlRTFxUVhwTlZHZDZUbXBSZVZkb1kwNU5hbFY0VFdwQmVrMVVaekJPYWxGNVYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZHYjJSUFUzSnBaSHBIYW1kSlFVbHNNeTh5VGl0bFVEUmtUVUpGVFRCdlRVNUtibUlLVjB4UVFtNUJVMGRUWkdwMFdYSTRTM1pGYjNoWldGUnhZelEzZEhVeU1taExXWGxtYms1UWEwRkVVakZST1VaWVpVdFBRMEZZYzNkblowWXpUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZLYWxaYUNtZGthMGhNYnpkelRURXZiRWw0TldSRmRHaHhPVzFuZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBwQldVUldVakJTUVZGSUwwSkNiM2RIU1VWWFpIazFNbUl5ZUhOalNFcHNXVEpvTUZGSFpIUlpWMnh6VEcxT2RtSlVRWE5DWjI5eVFtZEZSUXBCV1U4dlRVRkZRa0pDTlc5a1NGSjNZM3B2ZGt3eVpIQmtSMmd4V1drMWFtSXlNSFppUnpsdVlWYzBkbUl5UmpGa1IyZDNUR2RaUzB0M1dVSkNRVWRFQ25aNlFVSkRRVkZuUkVJMWIyUklVbmRqZW05MlRESmtjR1JIYURGWmFUVnFZakl3ZG1KSE9XNWhWelIyWWpKR01XUkhaM2RuV1hOSFEybHpSMEZSVVVJS01XNXJRMEpCU1VWbVVWSTNRVWhyUVdSM1JHUlFWRUp4ZUhOalVrMXRUVnBJYUhsYVducGpRMjlyY0dWMVRqUTRjbVlyU0dsdVMwRk1lVzUxYW1kQlFRcEJXbkpzWjBvd1UwRkJRVVZCZDBKSlRVVlpRMGxSUXpFNVVsVm1iMWswZWtSVlkxaDFSVVpFSzJwRGN6RXlNMmxWWVV3elVYcFRReTh2UzJZMk4yMXdDalZSU1doQlRYUlFPVFZDUm05RWFERTNlbnBKVmtrMVJIb3lVRXBLZURsTFdFYzFaVlprVUhKd2ExWXljM1YyUVUxQmIwZERRM0ZIVTAwME9VSkJUVVFLUVRKalFVMUhVVU5OU0U0MWMyUkxiV0ZWVDBZeWNFSkhlV2RXVURoNGJISjRWRWhxYURsQk1YazJRalpaYTFoNmNFSTRWMEZ6YWxnd2RuRnpVRW80Y3dvNFoxZEdhbVpNYnpCM1NYZEdVRmh3UkVkUE1HaGxiblIzWTNWTFEyNXNNVEF2Vm1zeGVVWlZjbUkyUWtJd0wwWm5LekY1U21scFFtNDBSbk5LVldKWENtRmpjSFEzUzBoRGNWRjFNUW90TFMwdExVVk9SQ0JEUlZKVVNVWkpRMEZVUlMwdExTMHRDZz09In19fX0="}], "timestampVerificationData":{"rfc3161Timestamps":[{"signedTimestamp":"MIICyTADAgEAMIICwAYJKoZIhvcNAQcCoIICsTCCAq0CAQMxDTALBglghkgBZQMEAgEwgbcGCyqGSIb3DQEJEAEEoIGnBIGkMIGhAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQguqhf7Onr5+rrhPgQSAQ2M98wRN4SunAlt3DRfOdeem4CFEvA/X2Y+Q7SgSeIzV1sd9Lg1hLkGA8yMDI1MTIwMzE4MzY0MlowAwIBAaAypDAwLjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MRUwEwYDVQQDEwxzaWdzdG9yZS10c2GgADGCAdswggHXAgEBMFEwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZAIUOhNULwyQYe68wUMvy4qOiyojiwwwCwYJYIZIAWUDBAIBoIH8MBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcNMjUxMjAzMTgzNjQyWjAvBgkqhkiG9w0BCQQxIgQgP6xU4az/FUNWOr+jjTj3KXhOKoNUQG3ZGnpZcUvMg/YwgY4GCyqGSIb3DQEJEAIvMX8wfTB7MHkEIIX5J7wHq2LKw7RDVsEO/IGyxog/2nq55thw2dE6zQW3MFUwPaQ7MDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQCFDoTVC8MkGHuvMFDL8uKjosqI4sMMAoGCCqGSM49BAMCBGcwZQIxANQPSum/tL0sOFNuMzbhrzdfPxu5/cQ5s0KoILeeaygvJS6HLFxL1/vI7FpIXZUO3QIwRLuZtNOwyQ8Dr8YWhaqred+vhJXCADGEEExEkiHX1kYEYkPZOvk/j85M2aBrYYHA"}]}}, "messageSignature":{"messageDigest":{"algorithm":"SHA2_256", "digest":"4ZS5nfZjlQEyXF9E2nn+Oo765vI5vXlEOhl4fgb7F2Q="}, "signature":"MEUCIFLtJd6X+TSzq9DE3vuximZCnJeYM34FrLkxU+wiLMLJAiEAgxT7Vt3mXuNncOno3yopY0QnoNZL819iZ1OLapckaBw="}}
\ No newline at end of file
diff --git a/crates/updater/tests/fixtures/cosign-v3-blob.txt b/crates/updater/tests/fixtures/cosign-v3-blob.txt
new file mode 100644
index 000000000..4aacff685
--- /dev/null
+++ b/crates/updater/tests/fixtures/cosign-v3-blob.txt
@@ -0,0 +1 @@
+test content for cosign
diff --git a/desktop/README.md b/desktop/README.md
index 00d88e66c..3e0f1556f 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -205,7 +205,8 @@ Connected-app sign-in needs a separate distributor setup: the current release wo
 2. Tag the commit and push the tag: `git tag desktop-v0.1.0  && git push origin desktop-v0.1.0`. The version is the tag's (`desktop-v..[-
]`). A manual run from `main` with `tag` set and `dry_run` off does the same for an existing tag; either way the workflow checks out **the tag**, not the branch, and verifies the commit before attaching anything.
 3. The `package` jobs (macOS 14 arm64, Windows 2022, Ubuntu 22.04 x86_64, whose older glibc keeps the `.deb` and AppImage usable on older distributions) run `tools/setup.py`, the graph checks, `package.py` in a neutral directory and the scan, and upload **unsigned** packages. They have no secrets.
 4. `sign-macos` and `sign-windows` run in the `release` environment and only download those packages: codesign (hardened runtime, `entitlements.plist`) of the kernel and the app, the kernel's receipt updated to the signed bytes, a new `.dmg`, notarytool and stapling; signtool for the installer. They never check out or build code.
-5. `release` checks out the tag, verifies HEAD is the tag's commit, scans again and attaches every package, a receipt per platform and `SHA256SUMS` to a **draft** release for the tag, not marked latest (`home-v*` and `rom-v*` share this repository). Review the draft, try the packages, and publish it by hand.
+5. `release` checks out the tag, verifies HEAD is the tag's commit, scans again and attaches every package, a receipt per platform and `SHA256SUMS` to a **draft** release for the tag, not marked latest (`home-v*` and `rom-v*` share this repository). Review the draft and try the packages.
+6. Sign it for the in-app updater: `gh workflow run sign-desktop-release.yml --ref main -f tag=desktop-v0.1.0`. The run waits for a reviewer of the `desktop-release-signing` environment to approve it. It checks that `SHA256SUMS` lists exactly the release's files with their GitHub digests; GitHub then signs `SHA256SUMS`, and the run attaches the signature as `SHA256SUMS.sigstore.json`. [OctoSense Updates](../docs/updater.md#verification-and-privacy) refuses a desktop release without it. Then publish the draft by hand. The signing workflow has not run on a release yet: **unverified**.
 
 Pull requests that change the packaging run the `package` jobs only: no secrets, no signing, nothing released. x86_64 macOS builds are not produced (the macOS runner is arm64).
 
diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md
index e27f73c95..730fb670e 100644
--- a/desktop/README.zh-CN.md
+++ b/desktop/README.zh-CN.md
@@ -190,7 +190,8 @@ python3 tools/release-scan.py target/octosense-package/dist/*   # refuse private
 2. 给提交打标签并推送:`git tag desktop-v0.1.0  && git push origin desktop-v0.1.0`。版本号取自标签(`desktop-v..[-
]`)。从 `main` 手动运行并设置 `tag`、关闭 `dry_run`,对已有标签效果相同;两种方式都会检出**该标签**而不是分支,并在附加任何文件前核对提交。
 3. `package` 任务(macOS 14 arm64、Windows 2022、Ubuntu 22.04 x86_64,较旧的 glibc 让 `.deb` 和 AppImage 能在旧发行版上使用)运行 `tools/setup.py`、依赖图检查、在中性目录中运行 `package.py` 和扫描,并上传**未签名**的包。这些任务没有任何密钥。
 4. `sign-macos` 和 `sign-windows` 在 `release` 环境中运行,只下载这些包:用 codesign(hardened runtime,`entitlements.plist`)签名内核和应用、把内核收据更新为签名后的字节、重新生成 `.dmg`、notarytool 公证并装订;用 signtool 签名安装包。它们从不检出或构建代码。
-5. `release` 检出标签,核对 HEAD 就是标签的提交,再扫描一次,把所有包、每个平台的收据和 `SHA256SUMS` 附加到该标签的**草稿** release(不标记为 latest,`home-v*` 和 `rom-v*` 也在本仓库)。检查草稿、试用安装包后手动发布。
+5. `release` 检出标签,核对 HEAD 就是标签的提交,再扫描一次,把所有包、每个平台的收据和 `SHA256SUMS` 附加到该标签的**草稿** release(不标记为 latest,`home-v*` 和 `rom-v*` 也在本仓库)。检查草稿并试用安装包。
+6. 为应用内更新器签名:`gh workflow run sign-desktop-release.yml --ref main -f tag=desktop-v0.1.0`。该运行需等 `desktop-release-signing` 环境的审核人批准。它检查 `SHA256SUMS` 是否恰好列出该发布的所有文件及其 GitHub 摘要;随后 GitHub 为 `SHA256SUMS` 签名,运行把签名作为 `SHA256SUMS.sigstore.json` 附到发布中。[OctoSense Updates](../docs/updater.zh-CN.md#校验与隐私) 会拒绝没有此签名的桌面版发布。之后再手动发布草稿。签名工作流尚未在实际发布上运行:**未经验证**。
 
 修改打包的 pull request 只运行 `package` 任务:没有密钥、不签名、不发布。不构建 x86_64 macOS 版本(macOS 运行器是 arm64)。
 
diff --git a/docs/local-ci.md b/docs/local-ci.md
index eb3c885db..9849718a4 100644
--- a/docs/local-ci.md
+++ b/docs/local-ci.md
@@ -14,7 +14,7 @@ tools/ci-local.sh --check-drift            # the local mapping still fits the wo
 
 **The same commands as GitHub.** `tools/ci_local.py` reads `.github/workflows/` and runs each job's `run:` steps verbatim, with the step's working directory and `env:`, in GitHub's `bash --noprofile --norc -eo pipefail`. Nothing is copied, so nothing drifts. GitHub's actions map to local stand-ins: the working tree, this clone's `target/`, a shared kernel cache and the tools on `PATH`. `--check-drift`, every run and `tools/test_ci_local.py` check that mapping: a workflow that starts using something the runner does not model, such as a new action or a matrix, fails until `tools/ci_local.py` learns it.
 
-**One workflow is left out:** `release-desktop.yml`, a release workflow with a matrix, environments and secrets (`NOT_LOCAL` gives the reason). The desktop job runs its packaging scripts' tests. GitHub still runs its `plan` and `package` jobs on pull requests that change the packaging; the merge script ignores them.
+**Release workflows are left out:** `release-desktop.yml`, a release workflow with a matrix, environments and secrets, and `sign-desktop-release.yml`, which GitHub runs on `main` to sign a release (`NOT_LOCAL` gives each reason, and the other workflows it lists). The desktop job runs the packaging scripts' tests, and apps runs the updater's, which check what the signing workflow signs. GitHub still runs release-desktop's `plan` and `package` jobs on pull requests that change the packaging; the merge script ignores them.
 
 **Output.** Each step prints PASS, FAIL, SKIPPED or NOT RUN (after a failure, without `--keep-going`), then a summary table with times. The full log goes to `target/ci-local/.log`, and `target/ci-local/last.json` records the commit, whether the tree was dirty, the workflows run and each step's result. Any FAIL makes the exit status non-zero.
 
diff --git a/docs/updater.md b/docs/updater.md
index 2b49da911..a632cdac8 100644
--- a/docs/updater.md
+++ b/docs/updater.md
@@ -17,9 +17,9 @@ An installed RC defaults to the release-candidate channel. Versions compare
 numerically: `rc.10` follows `rc.9`, and the corresponding stable version follows
 its RCs. Switching channels does not permit a downgrade.
 
-This updater is introduced by this source change. Published desktop
-`0.1.0-rc.2` and Home `0.1.0-beta.2` do not contain it; install a newer release
-containing the updater once to get this interface. Unversioned development
+This updater is introduced by this source change. Published desktop releases
+up to `0.1.0-rc.5` and Home `0.1.0-beta.2` do not contain it; install a newer
+release containing the updater once to get this interface. Unversioned development
 builds do not know their installed release and cannot offer an update.
 
 ## Installation
@@ -28,14 +28,25 @@ builds do not know their installed release and cannot offer an update.
 | --- | --- | --- |
 | macOS, Apple silicon | Opens the verified `.dmg` | Quit OctoSense, drag the new app into Applications, confirm replacement, then reopen it. |
 | Windows, x64 | Starts the verified NSIS `.exe` directly | Follow its prompts; close OctoSense when requested. |
-| Linux, x86_64, Debian package | Opens the verified `.deb` with the desktop's package handler | Approve the package installation, then restart. Without a desktop package handler, use the distribution's package manager. |
-| Linux, x86_64, AppImage | Opens the download folder | Quit OctoSense, replace the old AppImage, allow execution in its file properties, then reopen it. The updater does not overwrite the running image. |
+| Linux, x86_64, Debian package | Saves a copy of the verified `.deb` in the Downloads folder, checks it, and opens it with the desktop's package handler | Approve the package installation, then restart. Without a desktop package handler, install that file with the distribution's package manager. |
+| Linux, x86_64, AppImage | Saves a checked, executable copy in the Downloads folder and opens that folder | Quit OctoSense, replace the old AppImage with the new one, then open it. The updater does not overwrite the running image. |
 | Android, ARM64 | Checks the verified APK against installed Home and stages it with Android's package installer | Review and confirm the operating system's installation prompt. |
 
 Opening an installer does **not** report a completed installation. This first
 version does not check periodically, silently quit, elevate itself, overwrite
-the running application, or remove the user's profile. OS installation and
-signature checks still apply.
+the running application, or remove the user's profile. The operating system's
+installer still runs its own checks, but macOS and Windows do not show the
+warnings they give a file downloaded in a browser (Gatekeeper's, SmartScreen's):
+the updater does not mark its downloads as coming from the internet. It relies
+on GitHub's signature on the release instead ([Verification and
+privacy](#verification-and-privacy)).
+
+Linux package handlers may be unable to read the hidden profile folder the
+download lives in (Ubuntu's App Center is a snap, and snaps cannot see hidden
+folders), so Linux packages are handed over from the Downloads folder
+(`XDG_DOWNLOAD_DIR`, else `~/Downloads`). A file of the same name there is never
+replaced: the copy takes the next free name, such as
+`octosense_0.2.0_amd64 (2).deb`. Copies in Downloads are yours to delete.
 
 Android 13 (API 33) or newer is required. Android updates **standalone Home** only. System Bridge and ROM updates remain
 separate. The installed package and candidate must have the public standalone
@@ -45,7 +56,10 @@ version code and a compatible minimum Android version. If Android has not
 allowed OctoSense to install packages, the user can open its permission settings,
 return, and explicitly try installation again. No automatic installation resumes
 after changing that permission. An APK is never installed by first uninstalling
-Home to bypass a signer mismatch.
+Home to bypass a signer mismatch. **Review and install update** and **Allow
+updates in Android Settings** act only on a real tap on the screen: Makepad's
+input provenance, which a script, a remote control or a synthetic click cannot
+produce.
 
 ## Verification and privacy
 
@@ -58,19 +72,46 @@ checks the checksum file's own digest, requires its installer hash to match
 GitHub's metadata, then verifies the complete downloaded installer and declared
 size. Downloads use private staging and are rechecked before OS handoff.
 Malformed metadata, unexpected URLs, symlinks, incomplete or modified files are
-refused. This trusts the project's GitHub release account over HTTPS; a checksum
-is not an independent publisher signature.
+refused.
+
+Digests and checksums only prove that the files are the release's own: anyone
+who can publish a release can make them agree. So a desktop release is offered
+only when its `SHA256SUMS` also carries **GitHub's signature** from the
+[`sign-desktop-release`](../.github/workflows/sign-desktop-release.yml)
+workflow, run on `main` in the `desktop-release-signing` environment, whose
+reviewers approve each run. The signature is a Sigstore bundle attached to the
+release as `SHA256SUMS.sigstore.json`. The updater verifies it offline, the way
+a device verifies App Hub's catalog: the certificate, its timestamps and the
+transparency-log proof against Sigstore's public trust root; then GitHub's
+claims about the run (this repository and its owner, that workflow on `main`, a
+manual run, that environment, a GitHub-hosted runner); then the signed subject,
+`.SHA256SUMS` with the exact digest of the file it downloaded. A desktop
+release without that signature is refused with an error, not skipped. Android
+Home needs none: Android installs only an APK signed with the pinned standalone
+release key.
+
+The release list is read newest first, at most ten pages of 100 releases; a
+page may hold up to 16 MB (a release's notes and asset list took about 28 KB in
+October 2026).
 
 Desktop downloads live in the shell's `updates/` directory (`OCTOSENSE_HOME` if
 set, otherwise `~/.octosense/updates`). Android uses app-private cache under
-`octosense-updates/`. Checking releases needs no OctoSense, GitHub or Google
+`octosense-updates/`. A verified download is deleted a day after it was last
+written, whether or not its installer was opened, and an interrupted one after
+an hour; this happens when OctoSense starts and before each check. Only the
+updater's own `verified-…` and `pending-…` folders holding plain files are
+removed, never recursively. Checking releases needs no OctoSense, GitHub or Google
 login. No mail, app contents, model credentials or agent conversations are sent.
 The updater is a native host interface, not an App Hub app or an agent tool.
 
 ## Code and release identity
 
 - [`crates/updater`](../crates/updater/src/lib.rs) owns channels, release
-  selection, bounded downloads, cancellation and verified artifacts.
+  selection, bounded downloads, cancellation, verified artifacts and removing
+  stale downloads. Its [`signature.rs`](../crates/updater/src/signature.rs)
+  verifies GitHub's signature on a desktop release's `SHA256SUMS`, which
+  [`sign-desktop-release.yml`](../.github/workflows/sign-desktop-release.yml)
+  makes for each release ([desktop/README.md](../desktop/README.md#cutting-a-desktop-release)).
 - [`crates/updater-ui`](../crates/updater-ui/src/lib.rs) owns the native module
   and explicit user actions. Network work and hashing run off the drawing thread.
 - [`install.rs`](../crates/updater-ui/src/install.rs) uses literal arguments
@@ -117,6 +158,17 @@ Desktop handoff tests check executable names and literal arguments without
 launching installers. Packaging tests verify identities, stale-binary refusal
 and build planning without producing release packages.
 
+The signature check is tested with a public Sigstore bundle: it verifies
+cryptographically, and is refused as another workflow's. Tampered signatures
+and transparency proofs, other workflows, branches, issuers and trust roots,
+every missing or wrong GitHub claim, and a statement naming another release or
+other bytes are refused too. The signing workflow's release check passed on
+the published `desktop-v0.1.0-rc.5` and `rc.2` and refused `beta.2`, which has
+no `SHA256SUMS`. No release has been signed yet, so a signed release checked
+end to end remains **unverified**, as does Android's real-tap check on a
+device. Removing stale downloads and the Linux copy into Downloads are tested
+in temporary folders; the actual Linux handoff remains **unverified**.
+
 A hidden native macOS UI checked the live RC1 → RC2 offer and downloaded and
 verified its 297 MB disk image. A deliberate change to that isolated download
 made **Install** reject it before any installer opened. Channel changes and
diff --git a/docs/updater.zh-CN.md b/docs/updater.zh-CN.md
index de6cb9f23..10ff9dee9 100644
--- a/docs/updater.zh-CN.md
+++ b/docs/updater.zh-CN.md
@@ -16,7 +16,7 @@
 已安装 RC 时默认选择候选版通道。版本按数值比较:`rc.10` 高于 `rc.9`,相应
 正式版高于其 RC。切换通道不会允许降级。
 
-更新器随本次源代码变更引入。此前发布的桌面版 `0.1.0-rc.2` 和 Home
+更新器随本次源代码变更引入。已发布的桌面版(至 `0.1.0-rc.5`)和 Home
 `0.1.0-beta.2` 不含此功能;需要先手动安装一次包含更新器的新版本。没有发布
 标签的开发构建无法确定当前安装的发行版,因此不提供更新。
 
@@ -26,19 +26,30 @@
 | --- | --- | --- |
 | macOS,Apple silicon | 打开已校验的 `.dmg` | 退出 OctoSense,将新应用拖入 Applications,确认替换,再打开。 |
 | Windows,x64 | 直接启动已校验的 NSIS `.exe` | 按提示安装,在要求时关闭 OctoSense。 |
-| Linux,x86_64,Debian 包 | 用桌面包处理程序打开 `.deb` | 在包管理器中确认安装并重启。没有图形包处理程序时使用发行版的包管理器。 |
-| Linux,x86_64,AppImage | 打开下载目录 | 退出 OctoSense,替换旧 AppImage,在文件属性中允许执行,再打开。更新器不覆盖运行中的镜像。 |
+| Linux,x86_64,Debian 包 | 把已校验的 `.deb` 复制到“下载”文件夹并再次校验,再用桌面包处理程序打开 | 在包管理器中确认安装并重启。没有图形包处理程序时,用发行版的包管理器安装该文件。 |
+| Linux,x86_64,AppImage | 把校验过的可执行副本放进“下载”文件夹,并打开该文件夹 | 退出 OctoSense,用新文件替换旧 AppImage,再打开。更新器不覆盖运行中的镜像。 |
 | Android,ARM64 | 对照已安装 Home 检查 APK,通过 Android 包安装程序暂存 | 查看并确认操作系统的安装提示。 |
 
 打开安装程序**不代表**安装完成。首个版本不会定期检查、静默退出、自行提权、
-覆盖运行中的应用或删除用户配置。操作系统原有的安装与签名检查仍然生效。
+覆盖运行中的应用或删除用户配置。操作系统的安装程序仍会执行自身检查,但
+macOS 和 Windows 不会像对浏览器下载的文件那样显示警告(Gatekeeper、
+SmartScreen):更新器不把下载标记为来自互联网,而是依靠发布上的 GitHub 签名
+(见[校验与隐私](#校验与隐私))。
+
+Linux 包处理程序可能读不到下载所在的隐藏配置目录(Ubuntu 的 App Center 是
+snap 应用,看不到隐藏文件夹),因此 Linux 包从“下载”文件夹交接
+(`XDG_DOWNLOAD_DIR`,否则为 `~/Downloads`)。那里的同名文件不会被替换:副本
+使用下一个可用名称,例如 `octosense_0.2.0_amd64 (2).deb`。“下载”中的副本由您
+自行删除。
 
 需要 Android 13(API 33)或更新版本。Android 仅更新**独立版 Home**,System Bridge 与 ROM 继续使用各自的更新途径。
 已安装包与候选包必须使用公开独立版 Home 的签名身份;系统内置 Home、ROM
 签名、隔离测试包或其他签名均会被拒绝。候选包的 Android 版本代码必须更高,
 最低 Android 版本必须兼容。若尚未允许 OctoSense 安装应用,用户可以打开系统
 权限设置,返回后再次明确点击安装。更改权限后不会自动恢复安装,也不会先卸载
-Home 来绕过签名不匹配。
+Home 来绕过签名不匹配。**Review and install update** 和 **Allow updates in
+Android Settings** 只响应真实的屏幕点按:这由 Makepad 的输入来源判断,脚本、
+远程控制或模拟点击都无法伪造。
 
 ## 校验与隐私
 
@@ -49,19 +60,39 @@ Home 来绕过签名不匹配。
 发布必须同时提供 GitHub 的 SHA-256 文件摘要与 `SHA256SUMS`。更新器检查
 校验和文件本身的摘要,确认其中的安装包哈希与 GitHub 元数据一致,再校验整个
 下载文件及声明的大小。文件下载到私有暂存目录,交给操作系统前再次校验。无效
-元数据、非预期 URL、符号链接、不完整或已修改文件均会被拒绝。此机制通过
-HTTPS 信任项目的 GitHub 发布账户;校验和不是独立的发布者签名。
+元数据、非预期 URL、符号链接、不完整或已修改文件均会被拒绝。
+
+摘要和校验和只能证明文件属于该发布:任何能发布 Release 的人都能让它们彼此
+一致。因此,桌面版发布只有在其 `SHA256SUMS` 还带有 **GitHub 签名**时才会被
+提供。该签名由 [`sign-desktop-release`](../.github/workflows/sign-desktop-release.yml)
+工作流生成,它在 `main` 上、在 `desktop-release-signing` 环境中运行,每次运行都需
+该环境的审核人批准。签名是一个 Sigstore 包,作为 `SHA256SUMS.sigstore.json`
+附在发布中。更新器离线校验它,方式与设备校验 App Hub 目录相同:先用 Sigstore
+公共信任根校验证书、时间戳和透明日志证明;再校验 GitHub 对这次运行的声明(本
+仓库及其所有者、`main` 上的该工作流、手动触发、该环境、GitHub 托管的运行器);
+最后校验签名的对象是 `.SHA256SUMS`,且摘要与实际下载的文件完全一致。没有
+该签名的桌面版发布会报错,而不是被跳过。Android Home 不需要它:Android 只安装
+用固定的独立版发布密钥签名的 APK。
+
+发布列表按从新到旧读取,最多 10 页、每页 100 个发布;每页最多 16 MB(2026 年
+10 月,一个发布的说明和文件列表约占 28 KB)。
 
 桌面版下载位于 shell 的 `updates/` 目录(设置了 `OCTOSENSE_HOME` 时以其为
 根目录,否则是 `~/.octosense/updates`)。Android 使用应用私有缓存中的
-`octosense-updates/`。检查发布不需要 OctoSense、GitHub 或 Google 登录。
+`octosense-updates/`。已校验的下载在最后写入一天后删除,不论是否打开过安装
+程序;中断的下载在一小时后删除。删除发生在 OctoSense 启动时和每次检查前,
+只删除更新器自己的、只含普通文件的 `verified-…` 和 `pending-…` 文件夹,且不
+递归删除。检查发布不需要 OctoSense、GitHub 或 Google 登录。
 不会发送邮件、应用内容、模型凭证或代理对话。更新器是宿主原生界面,不是
 App Hub 应用或代理工具。
 
 ## 代码与发布身份
 
 - [`crates/updater`](../crates/updater/src/lib.rs) 负责通道、版本选择、受限下载、
-  取消和已校验文件。
+  取消、已校验文件和清理过期下载。其中的 [`signature.rs`](../crates/updater/src/signature.rs)
+  校验桌面版发布 `SHA256SUMS` 上的 GitHub 签名,签名由
+  [`sign-desktop-release.yml`](../.github/workflows/sign-desktop-release.yml) 为每个
+  发布生成(见 [desktop/README.zh-CN.md](../desktop/README.zh-CN.md))。
 - [`crates/updater-ui`](../crates/updater-ui/src/lib.rs) 负责原生模块与明确的用户
   操作。网络和哈希任务不占用绘制线程。
 - [`install.rs`](../crates/updater-ui/src/install.rs) 用原样参数交给桌面安装程序,
@@ -100,6 +131,14 @@ Cargo 通用的 `0.1.0` 包版本不是实际发行版身份,因此打包时
 桌面交接测试检查程序名和原样参数,不启动安装程序。打包测试检查发布身份、拒绝
 旧二进制和构建计划,不生成发布包。
 
+签名校验用一个公开的 Sigstore 包测试:它能通过密码学校验,但作为其他工作流的
+签名被拒绝。被篡改的签名与透明日志证明,其他工作流、分支、签发者和信任根,任何
+缺失或错误的 GitHub 声明,以及指向其他发布或其他字节的声明也都会被拒绝。签名
+工作流的发布检查在已发布的 `desktop-v0.1.0-rc.5` 和 `rc.2` 上通过,并拒绝了没有
+`SHA256SUMS` 的 `beta.2`。目前还没有签过名的发布,因此对已签名发布的端到端检查
+仍属**未经验证**;Android 在真机上的真实点按检查也未验证。过期下载清理和 Linux
+复制到“下载”文件夹在临时目录中测试;实际 Linux 交接仍属**未经验证**。
+
 隐藏窗口中的原生 macOS 界面检查了真实的 RC1 → RC2 候选更新,下载并校验了
 297 MB 磁盘镜像。故意修改隔离下载文件后,点击 **Install** 在任何安装程序打开
 之前即拒绝该文件。还验证了切换通道和取消下载;实际桌面 shell 也在隔离配置中
diff --git a/tools/ci_local.py b/tools/ci_local.py
index e60f61a00..43df170cf 100644
--- a/tools/ci_local.py
+++ b/tools/ci_local.py
@@ -87,6 +87,9 @@
     "release-desktop.yml": "a release workflow (tag push or manual run): signed packages for three OSes, "
                            "with a matrix, environments, secrets and artifacts; nothing a pull request merges "
                            "on depends on it, and its packaging scripts' tests run in desktop.yml",
+    "sign-desktop-release.yml": "a release step (manual run on main, in a reviewed environment): GitHub signs a "
+                                "desktop release's SHA256SUMS; nothing a pull request merges on depends on it, "
+                                "and the updater's tests in apps.yml check what it signs",
 }
 KERNEL_BINARY = "octos-kernel/target/release/octos"
 EXIT_BUSY = 75

From efbec29f65acbdd725f3948e893543d77deb3c72 Mon Sep 17 00:00:00 2001
From: ymote <151983+ymote@users.noreply.github.com>
Date: Sat, 10 Oct 2026 23:23:28 -0700
Subject: [PATCH 3/3] Repin App Hub to 938e85e7: updater is a reserved name
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

App Hub #210 adds `updater` to the contract's RESERVED_NAMES, so no store
app may take the native Updates window's id or namespace (ADR 0004 §3).
The shell test a_script_app_may_not_take_a_native_apps_id_or_namespace,
which failed in phone.yml / home on this branch, checks that list against
native-apps.json.

The four App Hub crates, the [patch.crates-io] contract line and
native-apps.json's App Hub source; in Cargo.lock only the five App Hub
source lines. Between d86101d7 and 938e85e7 App Hub only published catalog
16 (data) and made that change.

Co-Authored-By: Claude Opus 5.5 
---
 Cargo.lock       | 10 +++++-----
 Cargo.toml       | 10 +++++-----
 native-apps.json |  2 +-
 3 files changed, 11 insertions(+), 11 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 317c4688b..a886c4113 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11395,7 +11395,7 @@ dependencies = [
 [[package]]
 name = "octosense-app-contract"
 version = "1.12.0"
-source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d86101d7206162b73ea873e2a31b48364600a110#d86101d7206162b73ea873e2a31b48364600a110"
+source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=938e85e7caea2546dd9bda23731f17b6b3e04304#938e85e7caea2546dd9bda23731f17b6b3e04304"
 dependencies = [
  "blake3",
  "serde",
@@ -11406,7 +11406,7 @@ dependencies = [
 [[package]]
 name = "octosense-app-hub"
 version = "0.1.0"
-source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d86101d7206162b73ea873e2a31b48364600a110#d86101d7206162b73ea873e2a31b48364600a110"
+source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=938e85e7caea2546dd9bda23731f17b6b3e04304#938e85e7caea2546dd9bda23731f17b6b3e04304"
 dependencies = [
  "base64 0.22.1",
  "blake3",
@@ -11433,7 +11433,7 @@ dependencies = [
 [[package]]
 name = "octosense-app-hub-app"
 version = "0.1.0"
-source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d86101d7206162b73ea873e2a31b48364600a110#d86101d7206162b73ea873e2a31b48364600a110"
+source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=938e85e7caea2546dd9bda23731f17b6b3e04304#938e85e7caea2546dd9bda23731f17b6b3e04304"
 dependencies = [
  "makepad-app-module",
  "makepad-widgets",
@@ -11461,7 +11461,7 @@ dependencies = [
 [[package]]
 name = "octosense-app-policy"
 version = "0.1.0"
-source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d86101d7206162b73ea873e2a31b48364600a110#d86101d7206162b73ea873e2a31b48364600a110"
+source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=938e85e7caea2546dd9bda23731f17b6b3e04304#938e85e7caea2546dd9bda23731f17b6b3e04304"
 dependencies = [
  "makepad-widgets",
  "octosense-app-contract",
@@ -11483,7 +11483,7 @@ dependencies = [
 [[package]]
 name = "octosense-appstore"
 version = "0.1.0"
-source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=d86101d7206162b73ea873e2a31b48364600a110#d86101d7206162b73ea873e2a31b48364600a110"
+source = "git+https://github.com/OctoSense-org/OctoSense-App-Hub?rev=938e85e7caea2546dd9bda23731f17b6b3e04304#938e85e7caea2546dd9bda23731f17b6b3e04304"
 dependencies = [
  "makepad-app-module",
  "makepad-widgets",
diff --git a/Cargo.toml b/Cargo.toml
index 54ca77894..bf9fad8fa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -94,7 +94,7 @@ octosense-reference = { path = "apps/reference" }
 makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false }
 makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19" }
 octosense-appcard = { path = "apps/appcard/module" }
-octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "d86101d7206162b73ea873e2a31b48364600a110" }
+octosense-app-hub-app = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "938e85e7caea2546dd9bda23731f17b6b3e04304" }
 makepad-calculator = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false }
 makepad-clock = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false }
 makepad-notes = { git = "https://github.com/OctoSense-org/makepad.git", rev = "d653bc4493619ab979ccff165cf48c0c63b77d19", default-features = false }
@@ -134,12 +134,12 @@ makepad-d3 = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git",
 octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "6cf2d90aa33ff4ae603f49c7557f3ecf986207f0" }
 # --- App Hub's host service registry the Mail and llm services register
 # into; the same revision as App Hub's app crate (a native app, above).
-octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "d86101d7206162b73ea873e2a31b48364600a110" }
+octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "938e85e7caea2546dd9bda23731f17b6b3e04304" }
 # The app policy (resolution, isolate settings, the Splash adapter) and the
 # Hub client the Card runner resolves an app's policy with; the shell's
 # glance tiles run cards under it (crates/shell/src/glance_card.rs).
-octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "d86101d7206162b73ea873e2a31b48364600a110" }
-octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "d86101d7206162b73ea873e2a31b48364600a110" }
+octosense-app-policy = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "938e85e7caea2546dd9bda23731f17b6b3e04304" }
+octosense-app-hub = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "938e85e7caea2546dd9bda23731f17b6b3e04304" }
 # The app contract (ADR 0005), from crates.io by version: App Hub, the shell
 # and Rinx all depend on `octosense-app-contract = "1"`, so Cargo links ONE
 # 1.x for the whole build (Cargo.lock pins the exact version OctoSense ships)
@@ -443,7 +443,7 @@ ruma = { git = "https://github.com/project-robius/ruma.git", branch = "tsp" }
 # -i octos-core` still sees one octos source.
 [patch.crates-io]
 # Keep the contract identical to the pinned SDK for this release candidate.
-octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "d86101d7206162b73ea873e2a31b48364600a110" }
+octosense-app-contract = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "938e85e7caea2546dd9bda23731f17b6b3e04304" }
 nix = { git = "https://github.com/octos-org/octos.git", rev = "18fcd3f16e527d2b601d7ae244f056fb711bb6b8" }
 
 # pdfcraft's bootstrap PDF stack: its workspace patches these five
diff --git a/native-apps.json b/native-apps.json
index f9416377d..e6b91b7a4 100644
--- a/native-apps.json
+++ b/native-apps.json
@@ -152,7 +152,7 @@
       "id": "apphub",
       "feature": "app-hub",
       "crate": "octosense-app-hub-app",
-      "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "d86101d7206162b73ea873e2a31b48364600a110", "local": null },
+      "source": { "git": "https://github.com/OctoSense-org/OctoSense-App-Hub", "rev": "938e85e7caea2546dd9bda23731f17b6b3e04304", "local": null },
       "module": ["octosense_app_hub_app::APP_HUB_MODULE", "octosense_app_hub_app::CARD_MODULE"],
       "bin": null,
       "default_features": true,