From 8d740e7bbedd1f15eed6cf6d26cf2e67b7d8a17f Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:00:24 -0700 Subject: [PATCH 01/10] Bundle the desktop Octos runtime (from OctoSense #85) The desktop kernel service finds a packaged `octos-kernel[.exe]` beside the shell executable when `OCTOS_APP_CORE_BIN` is unset (crates/kernel/src/launch.rs), and tools/build-desktop.py builds the locked kernel and stages it there. Rebased onto main: the old hosted-Rinx revision is dropped (main pins Rinx by tag v1.1.0 through native-apps.json) and so is the Cargo.toml patch redirect, which main already carries. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- README.zh-CN.md | 4 +- crates/ai-host/README.md | 2 +- crates/ai-host/src/lib.rs | 6 +- crates/kernel/README.md | 5 +- crates/kernel/examples/packaged_probe.rs | 57 +++++++++++ crates/kernel/src/launch.rs | 84 +++++++++++++--- crates/kernel/src/lib.rs | 3 +- crates/kernel/tests/core.rs | 9 +- desktop/README.md | 17 ++-- desktop/README.zh-CN.md | 17 ++-- tools/build-desktop.py | 119 +++++++++++++++++++++++ tools/test_build_desktop.py | 80 +++++++++++++++ 13 files changed, 370 insertions(+), 37 deletions(-) create mode 100644 crates/kernel/examples/packaged_probe.rs create mode 100644 tools/build-desktop.py create mode 100644 tools/test_build_desktop.py diff --git a/README.md b/README.md index 3e24d40a..cfba3dc7 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Pinned exactly once, in the root `Cargo.toml` and the runtime locks: | [makepad (OctoSense fork)](https://github.com/OctoSense-org/makepad) | The UI framework and the `cargo-makepad` packager. Checked out in `.sources/makepad`, plus the reviewed runtime patch. | | [OctoScript-Makepad](https://github.com/OctoSense-org/OctoScript-Makepad), [OctoScript](https://github.com/OctoSense-org/OctoScript) | The runtime release that names the Makepad and OctoScript revisions (`native-runtime.lock.json`). | | [OctoSense-App-Hub](https://github.com/OctoSense-org/OctoSense-App-Hub) | The signed catalog, the store, the Card runner that contains every app (`octosense-app-hub-app`). | -| [octos](https://github.com/octos-org/octos) | The agent kernel. On Android the APK bundles it as `liboctos.so`; on a desktop the kernel service runs the binary named by `OCTOS_APP_CORE_BIN`. | +| [octos](https://github.com/octos-org/octos) | The agent kernel. On Android the APK bundles it as `liboctos.so`; on a desktop it finds the packaged `octos-kernel` beside the shell; `OCTOS_APP_CORE_BIN` remains an override. | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix chats and mini apps, hosted as a native module. | Related, not build inputs: [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) (how apps are built and published), [OctoScript-Android](https://github.com/OctoSense-org/OctoScript-Android) and [OctoScript-OH](https://github.com/OctoSense-org/OctoScript-OH) (other renderers), the [OctoSense website](https://github.com/OctoSense-org/octosense-org.github.io). @@ -240,6 +240,8 @@ cargo check --locked -p octosense --features mobile-apps cargo check --locked -p octosense -p octosense-appcard --features mobile-apps,app-appcard ``` +For the desktop AI runtime, `tools/build-desktop.py` builds and stages the locked kernel beside the shell. See [desktop build instructions and validation limits](desktop/README.md#build-and-run). Cargo alone does not install the kernel. + **Phone** (from `phone/`, which selects the phone's system apps; details in [phone/README.md](phone/README.md)): ```sh diff --git a/README.zh-CN.md b/README.zh-CN.md index 3af1c45e..6088ffe0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -187,7 +187,7 @@ Shell 只有一份,位于 `crates/shell`([ADR 0001(英文)](docs/adr/000 | [makepad(OctoSense fork)](https://github.com/OctoSense-org/makepad) | UI 框架和 `cargo-makepad` 打包工具。检出到 `.sources/makepad`,并应用经审查的运行时补丁。 | | [OctoScript-Makepad](https://github.com/OctoSense-org/OctoScript-Makepad)、[OctoScript](https://github.com/OctoSense-org/OctoScript) | 指定 Makepad 和 OctoScript 版本的运行时发行版(`native-runtime.lock.json`)。 | | [OctoSense-App-Hub](https://github.com/OctoSense-org/OctoSense-App-Hub) | 签名目录、商店,以及隔离运行每个应用的 Card runner(`octosense-app-hub-app`)。 | -| [octos](https://github.com/octos-org/octos) | Agent 内核。在 Android 上 APK 以 `liboctos.so` 形式内置它;在桌面上内核服务运行 `OCTOS_APP_CORE_BIN` 指定的程序。 | +| [octos](https://github.com/octos-org/octos) | Agent 内核。在 Android 上 APK 以 `liboctos.so` 形式内置它;在桌面上自动发现 Shell 程序旁的 `octos-kernel`;`OCTOS_APP_CORE_BIN` 仍可显式覆盖。 | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix 聊天与小程序,作为原生模块托管。 | 相关但不参与构建:[OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow)(如何构建和发布应用)、[OctoScript-Android](https://github.com/OctoSense-org/OctoScript-Android) 和 [OctoScript-OH](https://github.com/OctoSense-org/OctoScript-OH)(其他渲染后端)、[OctoSense 网站](https://github.com/OctoSense-org/octosense-org.github.io)。 @@ -240,6 +240,8 @@ cargo check --locked -p octosense --features mobile-apps cargo check --locked -p octosense -p octosense-appcard --features mobile-apps,app-appcard ``` +桌面 AI 运行时由 `tools/build-desktop.py` 按锁定版本构建并放到程序旁;具体步骤和已验证范围见 [桌面构建说明](desktop/README.zh-CN.md#构建与运行)。独立执行 Cargo 不会安装内核。 + **手机端**(在 `phone/` 中运行,它会选择手机端的系统应用;详见 [phone/README.zh-CN.md](phone/README.zh-CN.md)): ```sh diff --git a/crates/ai-host/README.md b/crates/ai-host/README.md index aeb102ee..cf6461c9 100644 --- a/crates/ai-host/README.md +++ b/crates/ai-host/README.md @@ -36,7 +36,7 @@ ai_host::shutdown(); ``` `Host` fields: `data_dir`; `kernel: KernelSource` (`Bundled` on Android, -`InProcess` on OpenHarmony, `Env` = `$OCTOS_APP_CORE_BIN` on a desktop, +`InProcess` on OpenHarmony, `Env` = `$OCTOS_APP_CORE_BIN` or the packaged sibling on a desktop, `Program(path)`, `None`; `KernelSource::platform()` picks); `qr_import: QrImport` (`platform()` or `paste_only()`); `policy: Policy` (`Policy::shipped()` grants Rinx the `octos.*` services). diff --git a/crates/ai-host/src/lib.rs b/crates/ai-host/src/lib.rs index d4599603..b71aa0f1 100644 --- a/crates/ai-host/src/lib.rs +++ b/crates/ai-host/src/lib.rs @@ -93,8 +93,8 @@ pub enum KernelSource { Bundled, /// OpenHarmony: the canonical core linked in-process. InProcess, - /// Desktop: the binary `$OCTOS_APP_CORE_BIN` names; none without it (a - /// developer's own `octos serve` is never touched). + /// Desktop: `$OCTOS_APP_CORE_BIN`, or the packaged sibling executable. + /// The name stays compatible; no developer kernel is attached to. Env, /// Desktop or Android: this binary. Program(PathBuf), @@ -105,7 +105,7 @@ pub enum KernelSource { impl KernelSource { /// This platform's kernel: bundled on Android, in-process on - /// OpenHarmony, `$OCTOS_APP_CORE_BIN` on a desktop, none on iOS. + /// OpenHarmony, override/packaged sibling on a desktop, none on iOS. pub fn platform() -> Self { if cfg!(target_os = "android") { KernelSource::Bundled diff --git a/crates/kernel/README.md b/crates/kernel/README.md index 5e7a7494..d529647a 100644 --- a/crates/kernel/README.md +++ b/crates/kernel/README.md @@ -41,8 +41,9 @@ How it starts, per platform (`src/launch.rs`): - **Desktop**: ` serve --stdio --data-dir ` (plus `--config /config.json` when that file exists) with `OCTOS_HOME=`; the program is the shell's `Options::program` or - `$OCTOS_APP_CORE_BIN`. With neither there is no kernel: a developer's own - `octos serve` is never touched. + `$OCTOS_APP_CORE_BIN`, falling back to `octos-kernel[.exe]` beside the shell + executable. `tools/build-desktop.py` stages the locked runtime there. No + PATH search or attachment to another running kernel occurs. - **iOS**: no kernel. - **Talk to Octos on** (desktop and Android): the same command with `--host 127.0.0.1 --host-managed` instead of `--stdio`, and the listener diff --git a/crates/kernel/examples/packaged_probe.rs b/crates/kernel/examples/packaged_probe.rs new file mode 100644 index 00000000..80fc5322 --- /dev/null +++ b/crates/kernel/examples/packaged_probe.rs @@ -0,0 +1,57 @@ +//! Offline verification of a packaged sibling, without a program override. +//! Run beside `octos-kernel`; uses a new temporary profile, no provider calls. +use std::{collections::BTreeMap, path::Path, time::{Duration, SystemTime, UNIX_EPOCH}}; +use octosense_kernel::{CloseReason, Connection, Core, Options}; +use octosense_llm_config::{profile, Provider, ProviderSet}; +use serde_json::{json, Value}; + +async fn call(conn: &mut Connection, id: &str, method: &str, params: Value) -> Value { + conn.send(json!({"jsonrpc":"2.0", "id":id, "method":method, "params":params}).to_string()).unwrap(); + loop { + let text = tokio::time::timeout(Duration::from_secs(30), conn.recv()).await.unwrap().unwrap(); + let reply: Value = serde_json::from_str(&text).unwrap(); + if reply["id"] == id { + assert!(reply.get("error").is_none(), "{reply}"); + return reply["result"].clone(); + } + } +} + +fn provider(root: &Path, family: &str, model: &str) { + let primary = Provider::new(family, Some(model.into())); + let env = BTreeMap::from([(primary.key_env.clone(), "test-fixture-not-a-real-key".into())]); + profile::save_merge(&profile::profile_path(root), &ProviderSet {primary: Some(primary), fallbacks: vec![]}, &env).unwrap(); +} + +#[tokio::main] +async fn main() { + assert!(std::env::var_os("OCTOS_APP_CORE_BIN").is_none(), "probe requires automatic discovery"); + let root = std::env::temp_dir().join(format!("octos-packaged-{}-{}", std::process::id(), SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos())); + std::fs::create_dir(&root).unwrap(); + let core = Core::new(Options::default().core_dir(&root)); + struct Cleanup(Core, std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + self.0.shutdown_within(Duration::from_secs(5)); + let _ = std::fs::remove_dir_all(&self.1); + } + } + let _cleanup = Cleanup(core.clone(), root.clone()); + provider(&root, "deepseek", "deepseek-v4-flash"); + let mut first = core.connect().expect("packaged runtime must be found without an override"); + let mut second = core.connect().unwrap(); + assert_eq!(first.generation(), second.generation()); + let list = call(&mut first, "providers", "profile/llm/list", json!({"profile_id":"_main"})).await; + assert_eq!(list["primary"]["model"], "deepseek-v4-flash"); + let list = call(&mut second, "providers", "profile/llm/list", json!({"profile_id":"_main"})).await; + assert_eq!(list["primary"]["model"], "deepseek-v4-flash"); + provider(&root, "moonshot", "kimi-k2.5"); + assert!(core.restart()); + assert_eq!(first.recv().await, Err(CloseReason::Restarted)); + assert_eq!(second.recv().await, Err(CloseReason::Restarted)); + let mut next = core.connect().unwrap(); + assert_eq!(next.generation(), 2); + let list = call(&mut next, "providers", "profile/llm/list", json!({"profile_id":"_main"})).await; + assert_eq!(list["primary"]["model"], "kimi-k2.5"); + println!("PASS: sibling runtime, shared kernel, provider profile and restart; no LLM request"); +} diff --git a/crates/kernel/src/launch.rs b/crates/kernel/src/launch.rs index bd3717f3..87adde54 100644 --- a/crates/kernel/src/launch.rs +++ b/crates/kernel/src/launch.rs @@ -10,9 +10,9 @@ //! (`octos_cli::embedded::serve_io`); HAP native libraries cannot exec. //! - **Desktop**: ` serve --stdio --data-dir --config //! /config.json` with `OCTOS_HOME=`, where the program -//! is the shell's [`crate::Options::program`] or `$OCTOS_APP_CORE_BIN`. -//! Without one there is no kernel (a developer's own `octos serve` is never -//! touched). +//! is the shell's [`crate::Options::program`], `$OCTOS_APP_CORE_BIN`, or +//! the packaged `octos-kernel[.exe]` beside the shell executable, in that +//! order. No PATH search or connection to another running kernel occurs. //! - **iOS**: no kernel (an app cannot exec a child). //! //! With Talk to Octos on (desktop and Android), `--stdio` becomes @@ -122,20 +122,36 @@ pub(crate) fn resolve(inputs: &Inputs) -> Result { } #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] { - let program = match inputs.program { - Some(p) => p.to_path_buf(), - None => std::env::var_os("OCTOS_APP_CORE_BIN") - .filter(|v| !v.is_empty()) - .map(PathBuf::from) - .ok_or_else(|| Unavailable::NoKernel("no kernel binary configured (OCTOS_APP_CORE_BIN)".into()))?, - }; - if !program.is_file() { - return Err(Unavailable::NoKernel(format!("{} is not a file", program.display()))); - } + let env = std::env::var_os("OCTOS_APP_CORE_BIN").filter(|v| !v.is_empty()).map(PathBuf::from); + let executable = std::env::current_exe().ok(); + let program = desktop_program(inputs.program, env.as_deref(), executable.as_deref())?; Ok(desktop_stdio(program, core_dir, inputs.env)) } } +/// Overrides are authoritative: a broken override must not silently choose +/// another version. The sibling travels with the shell, independent of cwd. +#[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] +fn desktop_program(explicit: Option<&Path>, env: Option<&Path>, executable: Option<&Path>) -> Result { + let packaged = executable.and_then(Path::parent).map(|dir| { + dir.join(if cfg!(windows) { "octos-kernel.exe" } else { "octos-kernel" }) + }); + let program = explicit.or(env).or(packaged.as_deref()).ok_or_else(|| { + Unavailable::NoKernel("no packaged kernel or OCTOS_APP_CORE_BIN override".into()) + })?; + if !program.is_file() { + return Err(Unavailable::NoKernel(format!("{} is not a file; build the desktop runtime with tools/build-desktop.py", program.display()))); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if std::fs::metadata(program).map(|m| m.permissions().mode() & 0o111 == 0).unwrap_or(true) { + return Err(Unavailable::NoKernel(format!("{} is not executable", program.display()))); + } + } + Ok(program.to_owned()) +} + /// The desktop launch: an explicit data dir, as AppCard's local core mode has /// always run it, and `/config.json` when there is one (octos /// refuses to start on a named config file that does not exist; without one @@ -382,6 +398,48 @@ mod tests { let _ = std::fs::remove_dir_all(home); } + #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] + #[test] + fn desktop_finds_its_sibling_and_keeps_overrides_authoritative() { + let dir = tmp("packaged"); + let shell = dir.join("octosense"); + let bundled = dir.join(if cfg!(windows) { "octos-kernel.exe" } else { "octos-kernel" }); + let explicit = dir.join("explicit"); + let env = dir.join("environment"); + for p in [&bundled, &explicit, &env] { + std::fs::write(p, "fixture").unwrap(); + #[cfg(unix)] { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + assert_eq!(desktop_program(None, None, Some(&shell)).unwrap(), bundled); + assert_eq!(desktop_program(None, Some(&env), Some(&shell)).unwrap(), env); + assert_eq!(desktop_program(Some(&explicit), Some(&env), Some(&shell)).unwrap(), explicit); + let missing = dir.join("missing"); + assert!(desktop_program(Some(&missing), Some(&env), Some(&shell)).is_err()); + assert!(desktop_program(None, Some(&missing), Some(&shell)).is_err()); + // Discovery follows the installed executable after a directory move. + let moved = dir.with_extension("moved"); + std::fs::rename(&dir, &moved).unwrap(); + let sibling = moved.join(bundled.file_name().unwrap()); + assert_eq!(desktop_program(None, None, Some(&moved.join("octosense"))).unwrap(), sibling); + std::fs::remove_file(sibling).unwrap(); + assert!(desktop_program(None, None, Some(&moved.join("octosense"))).is_err()); + assert!(desktop_program(None, None, None).is_err()); + std::fs::remove_dir_all(moved).unwrap(); + } + + #[cfg(all(unix, not(any(target_env = "ohos", target_os = "ios", target_os = "android"))))] + #[test] + fn desktop_rejects_a_nonexecutable_runtime() { + let dir = tmp("not-executable"); + let program = dir.join("octos-kernel"); + std::fs::write(&program, "fixture").unwrap(); + assert!(desktop_program(None, None, Some(&dir.join("octosense"))).is_err()); + std::fs::remove_dir_all(dir).unwrap(); + } + #[cfg(not(any(target_env = "ohos", target_os = "ios", target_os = "android")))] #[test] fn desktop_without_a_program_has_no_kernel() { diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 1d24dce2..b45dc294 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -94,7 +94,8 @@ impl Options { self } /// The kernel binary (desktop; on Android it overrides the bundled - /// `liboctos.so`). Default: `$OCTOS_APP_CORE_BIN`. + /// `liboctos.so`). Desktop default: `$OCTOS_APP_CORE_BIN`, then the + /// packaged `octos-kernel[.exe]` beside this process's executable. pub fn program(mut self, program: impl Into) -> Self { self.program = Some(program.into()); self diff --git a/crates/kernel/tests/core.rs b/crates/kernel/tests/core.rs index 8790cd7e..2174c077 100644 --- a/crates/kernel/tests/core.rs +++ b/crates/kernel/tests/core.rs @@ -270,11 +270,14 @@ fn no_kernel_binary_means_no_connection() { #[tokio::test(flavor = "multi_thread")] async fn a_kernel_that_cannot_start_fails_the_connection() { - // A file that is not executable: resolvable, but the spawn fails. - let dir = core_dir("noexec"); + use std::os::unix::fs::PermissionsExt; + // Executable permissions pass resolution; a missing interpreter makes + // the actual spawn fail, exercising the asynchronous failure path. + let dir = core_dir("bad-interpreter"); std::fs::create_dir_all(&dir).unwrap(); let program = dir.join("octos"); - std::fs::write(&program, "not a program").unwrap(); + std::fs::write(&program, "#!/nonexistent/octos-interpreter\n").unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); let core = Core::new(Options::default().core_dir(dir.join("core")).program(&program)); let mut a = core.connect().unwrap(); assert!(matches!(next(&mut a).await, Err(CloseReason::Failed(_)))); diff --git a/desktop/README.md b/desktop/README.md index dc1a66ed..1a92bb44 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -19,7 +19,7 @@ The desktop shell of [OctoSense](https://github.com/OctoSense-org), the agent sh | [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) | Where apps are designed, built and published to the App Hub. | | [OctoScript-Makepad](https://github.com/OctoSense-org/OctoScript-Makepad) | The runtime release that pins Makepad and OctoScript. Checked out in `.sources/`. | | [makepad (OctoSense fork)](https://github.com/OctoSense-org/makepad) | The framework. Checked out in `.sources/makepad`. | -| [octos](https://github.com/octos-org/octos) | The agent kernel, a shell service (`octos-core`, on by default): AI providers configures it, AppCard, Rinx and other consumers connect to it. One revision, pinned in the root `Cargo.toml`; the kernel itself is a separate binary (desktop: `OCTOS_APP_CORE_BIN`; Android: bundled `liboctos.so`). | +| [octos](https://github.com/octos-org/octos) | The agent kernel, a shell service (`octos-core`, on by default): AI providers configures it, AppCard, Rinx and other consumers connect to it. One revision, pinned in the root `Cargo.toml`; the kernel itself is a separate binary (desktop: packaged `octos-kernel`, with `OCTOS_APP_CORE_BIN` override; Android: bundled `liboctos.so`). | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix chats and mini apps, linked as a module (`app-rinx`, on by default). | ## Layout of `desktop/` @@ -56,12 +56,17 @@ Details, `--update` and `--cache`: [root README](../README.md#set-up). ## Build and run -From the repository root or `desktop/`, after setup: +After setup, run `tools/build-desktop.py` from the repository root. It builds the shell and places `octos-kernel` beside it at the revision in `Cargo.lock`. Launching that shell needs no `OCTOS_APP_CORE_BIN`; an explicit override still wins. + +Full release recipe (a fresh release rebuild was not run in this validation): ```sh -cargo run --release -p octosense +python3 tools/build-desktop.py +./target/release/octosense ``` +Use `--profile dev` for a debug build and `--kernel PATH` to reuse a native runtime. The builder verifies its revision through `--version` and records SHA-256. The macOS validation used that reuse path. Plain `cargo run` does not install a missing runtime. + The desktop starts empty. Start App Hub, a system app or a developer program from the dock, the top-left **Apps** menu, or **⌘Space** (menu and search). **System → Quit OctoSense** closes the desktop and everything it hosts. Developer programs from `config/apps.json` build on first launch (progress shows in the tile). @@ -114,7 +119,7 @@ App Hub's modules have no process form and always open in-process. | `MAKEPAD_APP_CONFIG='{"mail_demo":true}'` | Serve Mail's demo mailbox (see [Demos](#demos)). | | `OCTOSENSE_MAIL_VAULT=file` | Keep Mail passwords in a 0600 file instead of the macOS keychain. | | `OCTOSENSE_LLM_VAULT=file` | Keep AI providers' keys in the owner-only octos profile instead of the macOS keychain. | -| `OCTOS_APP_CORE_BIN`, `OCTOS_APP_CORE_DIR` | The octos kernel binary the shell's kernel service runs (none: no kernel on this desktop) and its core dir (default `~/octos-home/.octos`; the AI providers profile is `/profiles/_main.json`). | +| `OCTOS_APP_CORE_BIN`, `OCTOS_APP_CORE_DIR` | The octos kernel binary the shell's kernel service runs (unset: use the packaged sibling) and its core dir (default `~/octos-home/.octos`; the AI providers profile is `/profiles/_main.json`). | | `OCTOSENSE_GLANCE_DEMO=1` | Publish a sample L0 News digest card (as `os.news`) to the glance screen at startup: F9 on a desktop style, the glance page on a phone style. A test path for the `glance` service. | | `MAKEPAD_REMOTE`, `MAKEPAD_HIDE_WINDOWS` | Remote-control bridge; hidden windows (see [Demos](#demos)). | @@ -147,7 +152,7 @@ Mail is the worked example (`octosense-mail-service`, from [`../apps/mail/host-s AI providers (`os.ai-providers`) edits the octos kernel's LLM providers through the `llm` service (`octosense-llm-service`, from [`../apps/ai-providers/host-service`](../apps/ai-providers/host-service)). Keys are typed only on host sheets and go to the macOS keychain entry octos reads; the providers are written to the kernel's profile under the shell's octos core dir (`/profiles/_main.json`; core dir `OCTOS_APP_CORE_DIR`, else `~/octos-home/.octos`). A phone's provider QR is imported from a picture of it: **Choose image** opens the open panel, or drop a screenshot on the import sheet. **Start → Settings → AI providers** opens it. After a change the service restarts the kernel if one runs; its consumers (AppCard) reconnect to the new one. -**The octos kernel** is a shell service, not part of any app: `octosense-kernel` ([`../crates/kernel`](../crates/kernel), feature `octos-core`, default). The shell starts it through its AI services at startup ([`../crates/ai-host`](../crates/ai-host/README.md), `octosense_ai_host::start`); nothing runs until a consumer connects, then one kernel per process (` serve --stdio --data-dir ` on a desktop, the APK's `liboctos.so` on Android; none on iOS or on a desktop without `OCTOS_APP_CORE_BIN`). AppCard's agent connects to it; Rinx reaches it through the app-agent broker. It stops when the last consumer leaves and when the shell exits. +**The octos kernel** is a shell service, not part of any app: `octosense-kernel` ([`../crates/kernel`](../crates/kernel), feature `octos-core`, default). The shell starts it through its AI services at startup ([`../crates/ai-host`](../crates/ai-host/README.md), `octosense_ai_host::start`); nothing runs until a consumer connects, then one kernel per process (the packaged `octos-kernel` (or `OCTOS_APP_CORE_BIN` override) with `serve --stdio --data-dir ` on a desktop, the APK's `liboctos.so` on Android; none on iOS or when a desktop has neither a packaged kernel nor an override). AppCard's agent connects to it; Rinx reaches it through the app-agent broker. It stops when the last consumer leaves and when the shell exits. **Talk to Octos** (off by default): **AI providers → Talk to Octos** turns on a loopback server so a web client or a terminal UI can talk to this device's assistant. While it is on, the kernel runs as `octos serve --host-managed` instead of `--stdio` and native apps keep working over its WebSocket; external clients get a separate token that opens the UI Protocol socket and nothing else. A web client pairs with a one-time code or the QR of its link; a terminal client of this user reads the private connection file. The server stays up when native apps close, until it is turned off or the shell exits. See [ADR 0003](../docs/adr/0003-shared-octos-client-access.md) and the [kernel guide](../crates/kernel/README.md). @@ -225,7 +230,7 @@ AppCard is **not shipped for now**: it interfered with the other apps, so no bui cargo run --release -p octosense --features app-appcard -- --module appcard ``` -It starts no kernel of its own: it connects to the shell's. On desktop that needs `OCTOS_APP_CORE_BIN` (and optionally `OCTOS_APP_CORE_DIR`); without a kernel it shows its login / WebSocket screen. Every octos crate comes from octos-org/octos at the one revision the root `Cargo.toml` pins. +It starts no kernel of its own: it connects to the shell's. On desktop it uses the packaged sibling or `OCTOS_APP_CORE_BIN` override (and optionally `OCTOS_APP_CORE_DIR`); without a kernel it shows its login / WebSocket screen. Every octos crate comes from octos-org/octos at the one revision the root `Cargo.toml` pins. ## Demos diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index 5be0bf7d..8a4e1a55 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -19,7 +19,7 @@ | [OctoScript-App-Design-Flow](https://github.com/OctoSense-org/OctoScript-App-Design-Flow) | 设计、构建应用并发布到 App Hub 的地方。 | | [OctoScript-Makepad](https://github.com/OctoSense-org/OctoScript-Makepad) | 固定 Makepad 与 OctoScript 版本的运行时发布。检出在 `.sources/` 中。 | | [makepad(OctoSense 分支)](https://github.com/OctoSense-org/makepad) | 框架。检出在 `.sources/makepad` 中。 | -| [octos](https://github.com/octos-org/octos) | Agent 内核,一项 Shell 服务(`octos-core`,默认开启):AI 提供商配置它,AppCard、Rinx 等使用方连接它。只有一个版本,固定在根目录 `Cargo.toml` 中;内核本身是单独的二进制(桌面:`OCTOS_APP_CORE_BIN`;Android:打包的 `liboctos.so`)。 | +| [octos](https://github.com/octos-org/octos) | Agent 内核,一项 Shell 服务(`octos-core`,默认开启):AI 提供商配置它,AppCard、Rinx 等使用方连接它。只有一个版本,固定在根目录 `Cargo.toml` 中;内核本身是单独的二进制(桌面:随附的 `octos-kernel`,可用 `OCTOS_APP_CORE_BIN` 覆盖;Android:打包的 `liboctos.so`)。 | | [Rinx](https://github.com/hagency-org/Rinx) | Matrix 聊天与小程序,作为模块链接(`app-rinx`,默认开启)。 | ## `desktop/` 的结构 @@ -56,12 +56,17 @@ python3 tools/setup.py --check --cargo # verify: one Makepad, App Hub, octos an ## 构建与运行 -准备完成后,在仓库根目录或 `desktop/` 中运行: +在仓库根目录完成 setup 后,`tools/build-desktop.py` 构建 Shell,并在程序旁放置与 `Cargo.lock` 一致的 `octos-kernel`。启动桌面无需设置 `OCTOS_APP_CORE_BIN`;显式设置仍会覆盖随附内核。 + +完整 release 构建流程(本次未执行全新 release 重编译): ```sh -cargo run --release -p octosense +python3 tools/build-desktop.py +./target/release/octosense ``` +可用 `--profile dev` 构建调试版,并用 `--kernel PATH` 复用已编译的原生内核;脚本会检查其 `--version` 中的提交号与锁定版本一致,并记录 SHA-256。本次 macOS 验证使用了这一复用方式。单独运行 `cargo run` 不会安装缺失的内核。 + 桌面启动时是空的。可从 dock、左上角的 **Apps** 菜单或 **⌘Space**(菜单与搜索)启动 App Hub、系统应用或开发者程序。**System → Quit OctoSense** 会关闭桌面及其托管的一切。 `config/apps.json` 中的开发者程序在首次启动时构建(进度显示在 tile 中)。 @@ -114,7 +119,7 @@ App Hub 的模块没有进程形态,总是在进程内打开。 | `MAKEPAD_APP_CONFIG='{"mail_demo":true}'` | 提供邮件的演示邮箱(见[演示](#演示))。 | | `OCTOSENSE_MAIL_VAULT=file` | 把邮件密码保存在权限为 0600 的文件中,而不是 macOS 钥匙串。 | | `OCTOSENSE_LLM_VAULT=file` | 把 AI 提供商的密钥保存在仅所有者可读的 octos profile 中,而不是 macOS 钥匙串。 | -| `OCTOS_APP_CORE_BIN`、`OCTOS_APP_CORE_DIR` | Shell 内核服务运行的 octos 内核二进制(未设置:此桌面上没有内核)及其 core 目录(默认 `~/octos-home/.octos`;AI 提供商的 profile 为 `/profiles/_main.json`)。 | +| `OCTOS_APP_CORE_BIN`、`OCTOS_APP_CORE_DIR` | Shell 内核服务运行的 octos 内核二进制(未设置时使用程序旁的 `octos-kernel`)及其 core 目录(默认 `~/octos-home/.octos`;AI 提供商的 profile 为 `/profiles/_main.json`)。 | | `OCTOSENSE_GLANCE_DEMO=1` | 启动时以 `os.news` 身份向一览屏发布一张示例 L0 新闻摘要卡片:桌面风格下按 F9 查看,手机风格下在一览页查看。用于测试 `glance` 服务。 | | `MAKEPAD_REMOTE`、`MAKEPAD_HIDE_WINDOWS` | 远程控制桥;隐藏窗口(见[演示](#演示))。 | @@ -147,7 +152,7 @@ launcher 把四类应用列在一起: AI 提供商(`os.ai-providers`)通过 `llm` 服务(`octosense-llm-service`,来自 [`../apps/ai-providers/host-service`](../apps/ai-providers/host-service))编辑 octos 内核的 LLM 提供商。密钥只在宿主面板上输入,保存到 octos 读取的 macOS 钥匙串条目;提供商写入 Shell 的 octos core 目录下内核的 profile(`/profiles/_main.json`;core 目录为 `OCTOS_APP_CORE_DIR`,否则为 `~/octos-home/.octos`)。手机上的提供商二维码可从图片导入:**Choose image** 打开文件面板,或把截图拖到导入面板上。**开始 → 设置 → AI providers** 可打开它。更改后服务会重启正在运行的内核,使用方(AppCard)会重新连接到新内核。 -**octos 内核**是一项 Shell 服务,不属于任何应用:`octosense-kernel`([`../crates/kernel`](../crates/kernel),feature `octos-core`,默认开启)。Shell 在启动时通过其 AI 服务启动它([`../crates/ai-host`](../crates/ai-host/README.md),`octosense_ai_host::start`);在有使用方连接之前不运行任何东西,之后每个进程只有一个内核(桌面上是 ` serve --stdio --data-dir `,Android 上是 APK 中的 `liboctos.so`;iOS 以及未设置 `OCTOS_APP_CORE_BIN` 的桌面上没有内核)。AppCard 的 Agent 连接它;Rinx 通过应用与 Agent 之间的代理访问它。最后一个使用方离开或 Shell 退出时内核停止。 +**octos 内核**是一项 Shell 服务,不属于任何应用:`octosense-kernel`([`../crates/kernel`](../crates/kernel),feature `octos-core`,默认开启)。Shell 在启动时通过其 AI 服务启动它([`../crates/ai-host`](../crates/ai-host/README.md),`octosense_ai_host::start`);在有使用方连接之前不运行任何东西,之后每个进程只有一个内核(桌面上运行随附的 `octos-kernel`(或 `OCTOS_APP_CORE_BIN` 指定的程序),参数为 `serve --stdio --data-dir `,Android 上是 APK 中的 `liboctos.so`;iOS 以及既无随附内核又无覆盖配置的桌面上没有内核)。AppCard 的 Agent 连接它;Rinx 通过应用与 Agent 之间的代理访问它。最后一个使用方离开或 Shell 退出时内核停止。 **Talk to Octos**(默认关闭):在 **AI providers → Talk to Octos** 中打开后,本机会启动一个仅监听回环地址的服务,让 Web 客户端或终端界面与本设备的助手对话。开启期间内核以 `octos serve --host-managed` 代替 `--stdio` 运行,原生应用继续通过其 WebSocket 工作;外部客户端使用单独的令牌,只能打开 UI Protocol 套接字。Web 客户端通过一次性配对码或其链接的二维码配对;本用户的终端客户端读取私有连接文件。原生应用关闭后服务仍保持运行,直到关闭该功能或 Shell 退出。见 [ADR 0003(英文)](../docs/adr/0003-shared-octos-client-access.md) 和[内核指南](../crates/kernel/README.zh-CN.md)。 @@ -225,7 +230,7 @@ AppCard **目前不随产品发布**:它会干扰其他应用,因此除非 cargo run --release -p octosense --features app-appcard -- --module appcard ``` -它不会自己启动内核,而是连接 Shell 的内核。在桌面上这需要 `OCTOS_APP_CORE_BIN`(`OCTOS_APP_CORE_DIR` 可选);没有内核时显示登录 / WebSocket 界面。所有 octos crate 都来自 octos-org/octos,且只有根目录 `Cargo.toml` 固定的那一个版本。 +它不会自己启动内核,而是连接 Shell 的内核。桌面默认使用随附内核,也可设置 `OCTOS_APP_CORE_BIN`(`OCTOS_APP_CORE_DIR` 可选);没有内核时显示登录 / WebSocket 界面。所有 octos crate 都来自 octos-org/octos,且只有根目录 `Cargo.toml` 固定的那一个版本。 ## 演示 diff --git a/tools/build-desktop.py b/tools/build-desktop.py new file mode 100644 index 00000000..ae3476ef --- /dev/null +++ b/tools/build-desktop.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Build the desktop shell with its pinned Octos runtime beside it. + +No environment override is needed when starting the resulting shell. This +produces a source-build layout, not a relocatable installer or signed .app. +Use --kernel to reuse a native executable whose --version matches Cargo.lock. +""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("kernel_artifact", ROOT / "tools/kernel-artifact.py") +kernel_tool = importlib.util.module_from_spec(spec) +spec.loader.exec_module(kernel_tool) +SUFFIX = ".exe" if os.name == "nt" else "" + + +def version_matches(version, revision): + match = re.fullmatch(r"octos \S+ \(([0-9a-f]{7,40}) \d{4}-\d{2}-\d{2}\)", version.strip()) + return bool(match and revision.startswith(match[1])) + + +def stage(kernel, directory, revision): + """Check the actual staged bytes before replacing an earlier runtime.""" + kernel, directory = Path(kernel), Path(directory) + directory.mkdir(parents=True, exist_ok=True) + destination = directory / ("octos-kernel" + SUFFIX) + fd, temp = tempfile.mkstemp(prefix=".octos-stage-", suffix=SUFFIX, dir=directory) + os.close(fd) + temp = Path(temp) + try: + shutil.copy2(kernel, temp) + temp.chmod(0o755) + version = subprocess.check_output([str(temp), "--version"], text=True, timeout=30).strip() + if not version_matches(version, revision): + raise RuntimeError(f"Kernel version {version!r} differs from the locked revision {revision}") + receipt = {"source": kernel_tool.OCTOS_URL, "revision": revision, + "version": version, "sha256": hashlib.sha256(temp.read_bytes()).hexdigest()} + temp.replace(destination) + (directory / "octos-kernel.json").write_text(json.dumps(receipt, indent=2) + "\n") + return destination + finally: + temp.unlink(missing_ok=True) + + +def source_at(revision, work, offline): + source = work / "src" + source.mkdir(parents=True, exist_ok=True) + def git(*args, check=True): + return subprocess.run(["git", "-C", str(source), *args], capture_output=True, text=True, check=check) + if not (source / ".git").exists(): + if any(source.iterdir()): + raise RuntimeError(f"Preserving nonempty kernel source directory: {source}") + git("init", "--quiet") + if git("status", "--porcelain").stdout.strip(): + raise RuntimeError(f"Preserving modified kernel source directory: {source}") + if git("cat-file", "-e", revision + "^{commit}", check=False).returncode: + if offline: + raise RuntimeError("Pinned kernel source is not cached; run once without --offline") + git("fetch", "--quiet", "--no-tags", "--depth=1", kernel_tool.OCTOS_URL, revision) + git("checkout", "--quiet", "--detach", revision) + return source + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile", choices=("dev", "release"), default="release") + parser.add_argument("--features", help="Additional shell features, e.g. mobile-apps") + parser.add_argument("--target-dir", type=Path, default=Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "target"))) + parser.add_argument("--work", type=Path, default=ROOT / "target/desktop-kernel") + parser.add_argument("--kernel", type=Path, help="Reuse a native executable from the locked Octos revision") + parser.add_argument("--offline", action="store_true") + parser.add_argument("--plan", action="store_true", help="Print the build plan; create nothing") + args = parser.parse_args(argv) + revision = kernel_tool.octos_revision() + target, work = args.target_dir.resolve(), args.work.resolve() + output = target / ("debug" if args.profile == "dev" else "release") + kernel = args.kernel.resolve() if args.kernel else work / "target/release" / ("octos" + SUFFIX) + build_kernel = ["cargo", "build", "--locked", "--release", "--target-dir", str(work / "target"), *kernel_tool.KERNEL_BUILD] + build_shell = ["cargo", "build", "--locked", "-p", "octosense", "--profile", args.profile, "--target-dir", str(target)] + if args.features: + build_shell += ["--features", args.features] + if args.offline: + build_kernel.append("--offline") + build_shell.append("--offline") + if args.plan: + print(json.dumps({"revision": revision, "kernel_source": "prebuilt" if args.kernel else kernel_tool.OCTOS_URL, + "kernel_build": None if args.kernel else build_kernel, + "shell_build": build_shell, "kernel": str(kernel), + "shell": str(output / ("octosense" + SUFFIX)), + "packaged_kernel": str(output / ("octos-kernel" + SUFFIX))}, indent=2)) + return + subprocess.run([sys.executable, str(ROOT / "tools/setup.py"), "--check", "--cargo"], cwd=ROOT, check=True) + if not args.kernel: + source = source_at(revision, work, args.offline) + env = dict(os.environ) + for name in ("RUSTFLAGS", "CARGO_ENCODED_RUSTFLAGS", "MAKEPAD", "MAKEPAD_PACKAGE_DIR", "CARGO_BUILD_TARGET"): + env.pop(name, None) + subprocess.run(build_kernel, cwd=source, env=env, check=True) + # Validate/stage first: a wrong prebuilt must not leave a newly built + # shell next to the wrong runtime. The shell remains a normal Cargo build. + runtime = stage(kernel, output, revision) + env = dict(os.environ) + env.pop("CARGO_BUILD_TARGET", None) + subprocess.run(build_shell, cwd=ROOT, env=env, check=True) + print(json.dumps({"shell": str(output / ("octosense" + SUFFIX)), "kernel": str(runtime), "revision": revision})) + + +if __name__ == "__main__": + main() diff --git a/tools/test_build_desktop.py b/tools/test_build_desktop.py new file mode 100644 index 00000000..0c5b3929 --- /dev/null +++ b/tools/test_build_desktop.py @@ -0,0 +1,80 @@ +"""Desktop packaging contracts; no downloads, real credentials or LLM calls.""" +import contextlib +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("build_desktop", Path(__file__).with_name("build-desktop.py")) +desktop = importlib.util.module_from_spec(spec) +spec.loader.exec_module(desktop) +REV = "a6ea8505170735f191a12bb47629a6728415d417" + + +class DesktopRuntime(unittest.TestCase): + def test_plan_uses_locked_kernel_and_stages_next_to_shell(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "not-created" + output = io.StringIO() + with contextlib.redirect_stdout(output): + desktop.main(["--plan", "--profile", "dev", "--target-dir", str(root), "--offline"]) + plan = json.loads(output.getvalue()) + self.assertEqual(plan["revision"], desktop.kernel_tool.octos_revision()) + self.assertEqual(Path(plan["shell"]).parent, Path(plan["packaged_kernel"]).parent) + self.assertIn("--locked", plan["kernel_build"]) + self.assertIn("--offline", plan["kernel_build"]) + self.assertFalse(root.exists()) + + def test_version_must_identify_the_locked_revision(self): + self.assertTrue(desktop.version_matches(f"octos 2.0.3-rc.13 ({REV[:7]} 2026-09-27)", REV)) + self.assertFalse(desktop.version_matches("octos 2.0.3-rc.13 (0000000 2026-09-27)", REV)) + self.assertFalse(desktop.version_matches("octos 2.0.3", REV)) + + def test_wrong_runtime_preserves_existing_binary_and_receipt(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + source.write_bytes(b"kernel") + out = root / "packaged" + out.mkdir() + dest = out / ("octos-kernel" + desktop.SUFFIX) + dest.write_bytes(b"previous") + receipt = out / "octos-kernel.json" + receipt.write_text("previous receipt") + with patch.object(desktop.subprocess, "check_output", return_value="octos wrong"): + with self.assertRaisesRegex(RuntimeError, "differs"): + desktop.stage(source, out, REV) + self.assertEqual(dest.read_bytes(), b"previous") + self.assertEqual(receipt.read_text(), "previous receipt") + self.assertEqual(sorted(p.name for p in out.iterdir()), sorted([dest.name, receipt.name])) + + @unittest.skipIf(os.name == "nt", "executable script fixture is POSIX") + def test_staged_executable_is_verified_and_receipted(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + source.write_text(f"#!/bin/sh\nprintf '%s\\n' 'octos 2.0.3-rc.13 ({REV[:7]} 2026-09-27)'\n") + dest = desktop.stage(source, root / "with spaces", REV) + receipt = json.loads(dest.with_name("octos-kernel.json").read_text()) + self.assertEqual(receipt["revision"], REV) + self.assertEqual(receipt["sha256"], hashlib.sha256(dest.read_bytes()).hexdigest()) + self.assertTrue(os.access(dest, os.X_OK)) + + def test_modified_private_source_is_preserved(self): + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + source = work / "src" + source.mkdir() + (source / "keep").write_text("user work") + with self.assertRaisesRegex(RuntimeError, "Preserving"): + desktop.source_at(REV, work, offline=True) + self.assertEqual((source / "keep").read_text(), "user work") + + +if __name__ == "__main__": + unittest.main() From b2102ff1c6a2f90151f9efa938cb036bd2d8197e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:16:48 -0700 Subject: [PATCH 02/10] Desktop release packages: macOS .app/.dmg, Windows NSIS, Linux .deb/.AppImage desktop/scripts/package.py builds octosense in Makepad's packaged-resource mode (MAKEPAD_PACKAGE_DIR, apple_bundle on macOS), stages the resources/ of every linked git or path crate beside the binary, builds the locked octos kernel with tools/build-desktop.py's checkout and version check and ships it as octos-kernel beside the executable, then runs cargo-packager from desktop/packaging/release.json (identifier org.octosense.desktop, product OctoSense, version from a desktop-v tag). Paths are remapped (home, CARGO_HOME, checkout) and debug info stripped. A packaged build no longer looks for the checkout it was built in. tools/release-scan.py refuses artifacts (inside .app, .dmg, .deb, .AppImage, .zip, NSIS) that carry user directories, homes other than /home/runner, .local hosts, private IPv4 addresses, the scanning machine's names or RELEASE_SCAN_EXTRA patterns. .github/workflows/release-desktop.yml: desktop-v* tags build, scan and attach the packages and SHA256SUMS to a draft, not-latest release; workflow_dispatch and packaging pull requests dry-run. Apple codesign, notarytool and stapler, and Windows signtool, run only when their secrets exist. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/desktop.yml | 2 + .github/workflows/release-desktop.yml | 264 ++++++++++++++++ .gitignore | 3 + README.md | 3 +- README.zh-CN.md | 3 +- crates/shell/src/octosense/paths.rs | 20 +- crates/shell/src/octosense/style.rs | 11 +- desktop/README.md | 56 +++- desktop/README.zh-CN.md | 56 +++- desktop/packaging/icons/icon.ico | Bin 0 -> 6878 bytes desktop/packaging/icons/icon.svg | 1 + desktop/packaging/icons/icon_128.png | Bin 0 -> 1394 bytes desktop/packaging/icons/icon_256.png | Bin 0 -> 3408 bytes desktop/packaging/icons/icon_32.png | Bin 0 -> 346 bytes desktop/packaging/icons/icon_512.png | Bin 0 -> 8139 bytes desktop/packaging/icons/icon_512@2x.png | Bin 0 -> 20323 bytes desktop/packaging/icons/icon_64.png | Bin 0 -> 621 bytes desktop/packaging/macos/Info.plist | 17 + desktop/packaging/macos/entitlements.plist | 13 + desktop/packaging/make_icons.py | 124 ++++++++ desktop/packaging/release.json | 33 ++ desktop/scripts/package.py | 347 +++++++++++++++++++++ desktop/scripts/test_package.py | 117 +++++++ tools/release-scan.py | 224 +++++++++++++ tools/test_release_scan.py | 104 ++++++ 25 files changed, 1383 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/release-desktop.yml create mode 100644 desktop/packaging/icons/icon.ico create mode 100644 desktop/packaging/icons/icon.svg create mode 100644 desktop/packaging/icons/icon_128.png create mode 100644 desktop/packaging/icons/icon_256.png create mode 100644 desktop/packaging/icons/icon_32.png create mode 100644 desktop/packaging/icons/icon_512.png create mode 100644 desktop/packaging/icons/icon_512@2x.png create mode 100644 desktop/packaging/icons/icon_64.png create mode 100644 desktop/packaging/macos/Info.plist create mode 100644 desktop/packaging/macos/entitlements.plist create mode 100644 desktop/packaging/make_icons.py create mode 100644 desktop/packaging/release.json create mode 100644 desktop/scripts/package.py create mode 100644 desktop/scripts/test_package.py create mode 100644 tools/release-scan.py create mode 100644 tools/test_release_scan.py diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index a701d92a..ac2b3151 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -83,6 +83,8 @@ jobs: - name: Desktop tools and scripts run: | python3 -m unittest discover -s tools -p 'test_*.py' + # The release packaging script (no build; release-desktop.yml builds). + python3 -m unittest discover -s desktop/scripts -p 'test_package.py' - 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 new file mode 100644 index 00000000..f47f555b --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,264 @@ +name: Release desktop + +# The OctoSense desktop as installable packages (desktop/scripts/package.py): +# macOS .app (zipped) and .dmg (arm64), Windows NSIS installer (x64), Linux +# .deb and .AppImage (x86_64, built on Ubuntu 22.04 for an older glibc). +# +# - A pushed `desktop-v` tag builds, scans and attaches the packages +# and SHA256SUMS to a DRAFT release for that tag, not marked latest (the +# `home-v*` and `rom-v*` releases share this repository). A person reviews +# and publishes the draft. +# - Run it by hand (workflow_dispatch) for a dry run: the same builds and +# scans, the packages kept as workflow artifacts, nothing released. With +# dry_run off and an existing desktop-v tag it (re)attaches to that tag's +# draft. +# - A pull request that changes the packaging itself gets the same dry run. +# +# Unsigned until the signing secrets exist; each signing step is skipped +# when its secret is absent. Secrets (see desktop/README.md, "Release +# builds"): +# APPLE_CERTIFICATE base64 of the Developer ID Application .p12 +# APPLE_CERTIFICATE_PASSWORD its password +# APPLE_SIGNING_IDENTITY "Developer ID Application: ()" +# APPLE_API_KEY App Store Connect API key id (notarytool) +# APPLE_API_ISSUER its issuer id +# APPLE_API_KEY_P8 base64 of the AuthKey_.p8 file +# WINDOWS_CERTIFICATE base64 of the code-signing .pfx +# WINDOWS_CERTIFICATE_PASSWORD its password +# RELEASE_SCAN_EXTRA optional: comma-separated regexes the +# artifact scan must also refuse + +on: + push: + tags: ['desktop-v*'] + pull_request: + paths: + - 'desktop/packaging/**' + - 'desktop/scripts/package.py' + - 'tools/release-scan.py' + - 'tools/build-desktop.py' + - '.github/workflows/release-desktop.yml' + workflow_dispatch: + inputs: + dry_run: + description: Build and scan only; release nothing + type: boolean + default: true + tag: + description: An existing desktop-v tag to attach to (only when dry_run is off) + type: string + default: '' + +permissions: + contents: read + +concurrency: + group: release-desktop-${{ github.ref }}-${{ inputs.tag }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + package: + name: package (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + platform: macos-arm64 + - os: windows-2022 + platform: windows-x64 + - os: ubuntu-22.04 + platform: linux-x64 + runs-on: ${{ matrix.os }} + timeout-minutes: 150 + defaults: + run: + shell: bash + env: + TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} + HAVE_APPLE_SIGNING: ${{ secrets.APPLE_CERTIFICATE != '' && secrets.APPLE_SIGNING_IDENTITY != '' }} + HAVE_APPLE_NOTARY: ${{ secrets.APPLE_API_KEY != '' && secrets.APPLE_API_KEY_P8 != '' }} + HAVE_WINDOWS_SIGNING: ${{ secrets.WINDOWS_CERTIFICATE != '' }} + # linuxdeploy (AppImage) runs without FUSE on the runner. + APPIMAGE_EXTRACT_AND_RUN: '1' + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + - name: Linux build and packaging dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends pkg-config libssl-dev libx11-dev libxcursor-dev \ + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev \ + libasound2-dev libpulse-dev libdbus-1-dev libudev-dev libdrm-dev libgbm-dev libva-dev dpkg-dev file \ + desktop-file-utils libfuse2 + - name: Install cargo-packager + run: cargo install cargo-packager --locked --version 0.11.8 + - name: Neutral build paths + # Crates embed their CARGO_MANIFEST_DIR as strings, which + # --remap-path-prefix does not reach: build where no user directory + # appears. macOS checkouts live under /Users/runner and Windows' + # CARGO_HOME under C:\Users\runneradmin, so move those; Linux's + # /home/runner is a CI path already. + run: | + case "$RUNNER_OS" in + macOS) + src=/private/tmp/octosense-build/OctoSense + mkdir -p "$src" && cp -R "$GITHUB_WORKSPACE/." "$src/" + echo "CARGO_HOME=/private/tmp/octosense-build/cargo" >> "$GITHUB_ENV" ;; + Windows) + src="$GITHUB_WORKSPACE" + echo "CARGO_HOME=D:\\octosense-cargo" >> "$GITHUB_ENV" ;; + *) + src="$GITHUB_WORKSPACE" ;; + esac + echo "SRC=$src" >> "$GITHUB_ENV" + - name: Prepare the pinned framework sources + run: cd "$SRC" && python tools/setup.py + - name: One Makepad, App Hub, octos and Rinx; the shell graph guards + run: | + cd "$SRC" + python tools/setup.py --check --cargo + bash tools/check-shell-graph.sh -p octosense + - name: Signing hook (Windows) + if: runner.os == 'Windows' && env.HAVE_WINDOWS_SIGNING == 'true' + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + # cargo-packager signs octosense.exe, the kernel and the installer + # with signtool, by thumbprint from the user certificate store. + $pfx = Join-Path $env:RUNNER_TEMP 'signing.pfx' + [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)) + $password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force + $cert = Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $password + Remove-Item $pfx + "WINDOWS_CERTIFICATE_THUMBPRINT=$($cert.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Signing hook (macOS notarization key) + if: runner.os == 'macOS' && env.HAVE_APPLE_SIGNING == 'true' && env.HAVE_APPLE_NOTARY == 'true' + env: + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + key="$RUNNER_TEMP/AuthKey.p8" + printf '%s' "$APPLE_API_KEY_P8" | base64 --decode > "$key" + echo "APPLE_API_KEY_PATH=$key" >> "$GITHUB_ENV" + - name: Build and package (paths remapped, resources and kernel bundled) + env: + # Read by package.py / cargo-packager only when present: the + # identity turns on codesign (hardened runtime), the certificate is + # imported into a temporary keychain, the API key notarizes the .app. + APPLE_SIGNING_IDENTITY: ${{ env.HAVE_APPLE_SIGNING == 'true' && secrets.APPLE_SIGNING_IDENTITY || '' }} + APPLE_CERTIFICATE: ${{ env.HAVE_APPLE_SIGNING == 'true' && secrets.APPLE_CERTIFICATE || '' }} + APPLE_CERTIFICATE_PASSWORD: ${{ env.HAVE_APPLE_SIGNING == 'true' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }} + APPLE_API_KEY: ${{ env.HAVE_APPLE_NOTARY == 'true' && secrets.APPLE_API_KEY || '' }} + APPLE_API_ISSUER: ${{ env.HAVE_APPLE_NOTARY == 'true' && secrets.APPLE_API_ISSUER || '' }} + run: | + cd "$SRC" + # Empty variables would still look "set" to the packager. + for name in APPLE_SIGNING_IDENTITY APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_API_KEY APPLE_API_ISSUER; do + [ -n "${!name}" ] || unset "$name" + done + if [[ "$TAG" == desktop-v* ]]; then + python desktop/scripts/package.py --version-from-tag "$TAG" + else + python desktop/scripts/package.py + fi + - name: Collect the packages + run: | + cd "$SRC/target/octosense-package" + version=$(python -c 'import json; print(json.load(open("receipt.json"))["version"])') + echo "VERSION=$version" >> "$GITHUB_ENV" + rm -rf upload && mkdir upload + case "$RUNNER_OS" in + macOS) + cp dist/*.dmg upload/ + ditto -c -k --keepParent dist/OctoSense.app "upload/OctoSense_${version}_macos_aarch64.app.zip" ;; + Windows) + cp dist/*-setup.exe upload/ ;; + *) + cp dist/*.deb dist/*.AppImage upload/ ;; + esac + cp receipt.json "upload/OctoSense_${version}_${{ matrix.platform }}.receipt.json" + ls -la upload + - name: Signing hook (macOS .dmg notarization and stapling) + if: runner.os == 'macOS' && env.HAVE_APPLE_SIGNING == 'true' && env.HAVE_APPLE_NOTARY == 'true' + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + # cargo-packager notarized and stapled the .app and signed the + # .dmg; notarize and staple the .dmg too, so it opens offline. + for dmg in "$SRC"/target/octosense-package/upload/*.dmg; do + xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" \ + --issuer "$APPLE_API_ISSUER" --wait + xcrun stapler staple "$dmg" + spctl --assess --type open --context context:primary-signature -v "$dmg" + done + - name: Refuse private paths and names (the packages and their payloads) + env: + RELEASE_SCAN_EXTRA: ${{ secrets.RELEASE_SCAN_EXTRA }} + run: | + cd "$SRC/target/octosense-package" + # The upload set (inside .dmg, .deb, .AppImage, .zip, the NSIS + # 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/* resources kernel "$SRC/target/release/$exe" + - uses: actions/upload-artifact@v4 + with: + name: octosense-desktop-${{ matrix.platform }} + path: ${{ env.SRC }}/target/octosense-package/upload/* + if-no-files-found: error + retention-days: 14 + + release: + name: draft release + needs: package + if: >- + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-v')) || + (github.event_name == 'workflow_dispatch' && !inputs.dry_run && startsWith(inputs.tag, 'desktop-v')) + runs-on: ubuntu-22.04 + permissions: + contents: write + env: + TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: tools + - uses: actions/download-artifact@v4 + with: + pattern: octosense-desktop-* + path: packages + merge-multiple: true + - name: SHA256SUMS + run: | + cd packages + sha256sum -- * | sort -k2 > SHA256SUMS + cat SHA256SUMS + - name: Scan again what is attached + env: + RELEASE_SCAN_EXTRA: ${{ secrets.RELEASE_SCAN_EXTRA }} + run: | + # .dmg images were opened and scanned on macOS; here their bytes. + python3 tools/release-scan.py packages/* + - name: Attach to the tag's draft release + run: | + git ls-remote --exit-code --tags origin "refs/tags/$TAG" > /dev/null || { echo "::error::no tag $TAG"; exit 1; } + version="${TAG#desktop-v}" + if gh release view "$TAG" > /dev/null 2>&1; then + [ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" = true ] \ + || { echo "::error::$TAG is already published; not replacing its files"; exit 1; } + gh release upload "$TAG" packages/* --clobber + else + gh release create "$TAG" packages/* --draft --latest=false --verify-tag \ + --title "OctoSense desktop $version" \ + --notes "OctoSense desktop $version, built from the $TAG tag by the release-desktop workflow (run $GITHUB_RUN_ID). Unsigned unless the signing secrets were configured: see desktop/README.md, Release builds. Check the files against SHA256SUMS." + fi diff --git a/.gitignore b/.gitignore index 48213365..f2dfb13e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ __pycache__/ # Framework checkouts prepared by tools/setup.py. /.sources + +# desktop/scripts/package.py writes this beside release.json while it packages. +/desktop/packaging/.release.generated.json diff --git a/README.md b/README.md index cfba3dc7..10a9ac12 100644 --- a/README.md +++ b/README.md @@ -265,12 +265,13 @@ Path-filtered workflows in `.github/workflows/`, so a change runs only the jobs | `phone.yml` | `phone/`, `crates/`, `apps/`, the workspace files, `tools/` | compiles Home and its bundled modules, the shell graph guards, and runs the tests of the shell, Home, the AI services, App Hub admission and runtime policy on macOS; the longest job | | `apps.yml` | `apps/`, `crates/`, the workspace files, `tools/setup.py` | the kernel service, app peers, AI providers config, the Mail and `llm` host services, the shell's AI services (`crates/ai-host`), AppCard | | `rom.yml` | `rom/`, `phone/android/`, the phone's Android resources and tests, `tools/kernel-artifact.py` | product tests, the generated Agent Binder client, the web installer | +| `release-desktop.yml` | a pushed `desktop-v*` tag, or run by hand (dry run) | the desktop packages for macOS, Windows and Linux, the private-path scan, a draft release ([desktop/README.md](desktop/README.md#release-builds)) | Each workflow's graph check (`tools/setup.py --check --cargo`) asserts one Makepad, one App Hub, one octos and one Rinx in the locked graph. ## Releases -ADR 0001 tags each product on its own: `desktop-v*`, `home-v*` (APK), `rom-v*` (image), with build receipts that record the repository commit. System apps ship only inside the shells, admitted by digest; they are not released separately. The ROM release published before the merge, `20260919-j`, is here as [`rom-v20260919-j`](https://github.com/OctoSense-org/OctoSense/releases/tag/rom-v20260919-j). Phones read `update.json` from the moving `rom-latest` release, not from `releases/latest` ([rom/docs/updates.md](rom/docs/updates.md)). Images `20260919-j` and earlier check the retired OctoSense-ROM repository instead, so a phone flashed with one must be reflashed once to receive updates over the air. +ADR 0001 tags each product on its own: `desktop-v*`, `home-v*` (APK), `rom-v*` (image), with build receipts that record the repository commit. A `desktop-v*` tag builds the desktop packages (`.dmg`, Windows installer, `.deb`, `.AppImage`) into a draft release ([Release builds](desktop/README.md#release-builds)). System apps ship only inside the shells, admitted by digest; they are not released separately. The ROM release published before the merge, `20260919-j`, is here as [`rom-v20260919-j`](https://github.com/OctoSense-org/OctoSense/releases/tag/rom-v20260919-j). Phones read `update.json` from the moving `rom-latest` release, not from `releases/latest` ([rom/docs/updates.md](rom/docs/updates.md)). Images `20260919-j` and earlier check the retired OctoSense-ROM repository instead, so a phone flashed with one must be reflashed once to receive updates over the air. ## Contributing diff --git a/README.zh-CN.md b/README.zh-CN.md index 6088ffe0..129c1db6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -265,12 +265,13 @@ python3 ../rom/scripts/build-home.py --help # the Home and B | `phone.yml` | `phone/`、`crates/`、`apps/`、工作区文件、`tools/` | 在 macOS 上编译 Home 及其内置模块,Shell 依赖图检查,并运行 Shell、Home、AI 服务、App Hub 准入和运行时策略的测试;耗时最长的任务 | | `apps.yml` | `apps/`、`crates/`、工作区文件、`tools/setup.py` | 内核服务、app peers、AI 服务商配置、邮件与 `llm` 宿主服务、Shell 的 AI 服务(`crates/ai-host`)、AppCard | | `rom.yml` | `rom/`、`phone/android/`、手机端的 Android 资源与测试、`tools/kernel-artifact.py` | 产品测试、生成的 Agent Binder 客户端、网页安装器 | +| `release-desktop.yml` | 推送的 `desktop-v*` 标签,或手动运行(试运行) | macOS、Windows、Linux 的桌面安装包,私人路径扫描,草稿 release([desktop/README.zh-CN.md](desktop/README.zh-CN.md#发布构建)) | 每个工作流的依赖图检查(`tools/setup.py --check --cargo`)确保锁定的依赖图中只有一个 Makepad、一个 App Hub、一个 octos 和一个 Rinx。 ## 发布 -按 ADR 0001,每个产品单独打标签:`desktop-v*`、`home-v*`(APK)、`rom-v*`(镜像),构建回执记录仓库提交。系统应用只随 Shell 一起发布、按摘要准入,不单独发布。仓库合并前发布的 ROM 版本 `20260919-j` 现为本仓库的 [`rom-v20260919-j`](https://github.com/OctoSense-org/OctoSense/releases/tag/rom-v20260919-j)。手机从固定移动的 `rom-latest` release 读取 `update.json`,而不是 `releases/latest`([rom/docs/updates.md(英文)](rom/docs/updates.md))。`20260919-j` 及更早的镜像检查的是已停用的 OctoSense-ROM 仓库,因此刷了这些镜像的手机需要重新刷写一次,才能收到 OTA 更新。 +按 ADR 0001,每个产品单独打标签:`desktop-v*`、`home-v*`(APK)、`rom-v*`(镜像),构建回执记录仓库提交。`desktop-v*` 标签会把桌面安装包(`.dmg`、Windows 安装程序、`.deb`、`.AppImage`)构建到一个草稿 release 中([发布构建](desktop/README.zh-CN.md#发布构建))。系统应用只随 Shell 一起发布、按摘要准入,不单独发布。仓库合并前发布的 ROM 版本 `20260919-j` 现为本仓库的 [`rom-v20260919-j`](https://github.com/OctoSense-org/OctoSense/releases/tag/rom-v20260919-j)。手机从固定移动的 `rom-latest` release 读取 `update.json`,而不是 `releases/latest`([rom/docs/updates.md(英文)](rom/docs/updates.md))。`20260919-j` 及更早的镜像检查的是已停用的 OctoSense-ROM 仓库,因此刷了这些镜像的手机需要重新刷写一次,才能收到 OTA 更新。 ## 参与贡献 diff --git a/crates/shell/src/octosense/paths.rs b/crates/shell/src/octosense/paths.rs index aa0f18cf..7928bf98 100644 --- a/crates/shell/src/octosense/paths.rs +++ b/crates/shell/src/octosense/paths.rs @@ -89,16 +89,30 @@ pub fn scope_linked_app_data() { } } +/// Whether this is a packaged (installed) build: `desktop/scripts/package.py` +/// builds with `MAKEPAD_PACKAGE_DIR` set, and Makepad then reads every +/// `crate_resource` from the package instead of the source tree. A packaged +/// build never looks for the checkout it was built in: that path belongs to +/// the build machine, and the developer catalog it holds is source-built. +pub const fn packaged() -> bool { + option_env!("MAKEPAD_PACKAGE_DIR").is_some() +} + static PACKAGE_DIR: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); /// The running package's source directory (desktop/ or phone/), set by its -/// `octosense_main!` before the app starts. The shell's own -/// `CARGO_MANIFEST_DIR` is crates/shell, which carries no catalog. +/// `octosense_main!` before the app starts (not in a packaged build). The +/// shell's own `CARGO_MANIFEST_DIR` is crates/shell, which carries no catalog. pub fn set_package_dir(dir: &'static str) { + if packaged() { + return; + } let _ = PACKAGE_DIR.set(dir); } /// A source tree belongs to OctoSense only when it has our provenance marker. +/// A packaged build finds one only from where it runs (its executable's or +/// the working directory's ancestors), never from where it was built. pub fn project_root() -> Option { let starts = [ PACKAGE_DIR.get().map(PathBuf::from), @@ -107,7 +121,7 @@ pub fn project_root() -> Option { .and_then(|p| p.parent().map(Path::to_path_buf)), std::env::current_dir().ok(), // Tests and tools without a package: the desktop package beside us. - Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../desktop")), + (!packaged()).then(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../desktop")), ]; for start in starts.into_iter().flatten() { for dir in start.ancestors().take(6) { diff --git a/crates/shell/src/octosense/style.rs b/crates/shell/src/octosense/style.rs index 418d0783..98e8ddd1 100644 --- a/crates/shell/src/octosense/style.rs +++ b/crates/shell/src/octosense/style.rs @@ -55,11 +55,14 @@ pub fn load_sheet(style: DesktopStyle, dark: bool) -> StyleSheet { return sheet; } let read = |name: &str, bundled: &str| { - // Source checkouts reload on selection; installed/mobile builds use embedded data. + // Source checkouts reload on selection; packaged and mobile builds + // use the embedded data. #[cfg(not(target_arch = "wasm32"))] - if let Ok(text) = std::fs::read_to_string( - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/themes/octosense").join(name) - ) { return text; } + if !super::paths::packaged() { + if let Ok(text) = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/themes/octosense").join(name) + ) { return text; } + } let _ = name; bundled.to_string() }; diff --git a/desktop/README.md b/desktop/README.md index 1a92bb44..82b201f9 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -29,8 +29,9 @@ The desktop shell of [OctoSense](https://github.com/OctoSense-org), the agent sh | `src/main.rs` | The entry point (package `octosense`): `octosense_main!()` over the shell's `App`. The shell (tiling, launcher, dock, bar, hosting, the app registry, `shell/`, `octosense/`) is [`../crates/shell/src`](../crates/shell/src). | | `config/apps.json` | The default developer-program catalog. `apps.makepad.json` is an identical copy for `--apps`; `apps.overlay.json` holds the adaptations applied when regenerating them. | | `system-apps.json` | Which system apps this build packs, and from where (`../apps`). | -| `scripts/` | `upstream.py` (WM provenance and catalog regeneration), `smoke.py` (native smoke test), their Python tests, `system_apps_remote.sh`, `ai_providers_remote.sh` and `glance_remote.sh` (hidden `--remote` end-to-end runs of the system apps and of AI providers), and `provision-appcard-llm.sh` (Android). | +| `scripts/` | `package.py` (the release packages, [Release builds](#release-builds)), `upstream.py` (WM provenance and catalog regeneration), `smoke.py` (native smoke test), their Python tests, `system_apps_remote.sh`, `ai_providers_remote.sh` and `glance_remote.sh` (hidden `--remote` end-to-end runs of the system apps and of AI providers), and `provision-appcard-llm.sh` (Android). | | `upstream/makepad.json` | Provenance of every file imported from Makepad's `apps/wm`. | +| `packaging/` | The release packages' cargo-packager config (`release.json`), app icon (`icons/`, `make_icons.py`) and macOS `Info.plist` additions and entitlements. | | `resources/android/` | The Android manifest template. Themes, wallpapers, icons and the startup script are the shell's, in [`../crates/shell/resources`](../crates/shell/resources). | | `docs/` | [Validation record](docs/validation.md), [upstream sync](docs/upstream.md), [local AI](docs/local-ai.md), [Android AppCard build](docs/android-appcard-build.md), dated plans. | | `KEYBINDINGS.md`, `BACKLOG.md` | Keymap notes; open follow-ups. | @@ -78,7 +79,7 @@ Developer programs from `config/apps.json` build on first launch (progress shows | Android | `cargo makepad android run -p octosense --release`; see [Phones](#phones). | | iOS | Startup policy is tested, but a full build currently fails in the pinned Makepad Metal backend ([validation](docs/validation.md)). | -A relocatable `.app`, installers and a Linux session compositor are not provided. +Installable packages (macOS `.app`/`.dmg`, Windows installer, Linux `.deb`/`.AppImage`) come from [Release builds](#release-builds). A Linux session compositor is not provided. ### Cargo features @@ -296,6 +297,54 @@ Phone builds of this package always link Reference and Sheets and the octos kern - State lives in `~/.octosense` (`OCTOSENSE_HOME`); hosted apps get it as `MAKEPAD_HOME`. - Local models for the AI pane (**F10**): [docs/local-ai.md](docs/local-ai.md). The desktop works without a model. +## Release builds + +`desktop/scripts/package.py` turns a checkout into installable packages that need neither `.sources/` nor the repository at run time. From the repository root, after setup, with [cargo-packager](https://github.com/crabnebula-dev/cargo-packager) installed (`cargo install cargo-packager --locked --version 0.11.8`): + +```sh +python3 desktop/scripts/package.py # this OS's formats, version from desktop/Cargo.toml +python3 desktop/scripts/package.py --formats app # macOS: just OctoSense.app +python3 tools/release-scan.py target/octosense-package/dist/* # refuse private paths before sharing anything +``` + +| OS | Packages (in `target/octosense-package/dist/`) | Resources in the package | +| --- | --- | --- | +| macOS (arm64) | `OctoSense.app`, `OctoSense__aarch64.dmg` | `OctoSense.app/Contents/Resources//resources/` | +| Windows (x64) | `OctoSense__x64-setup.exe` (NSIS, per-user) | beside `octosense.exe` in the install directory | +| Linux (x86_64) | `octosense__amd64.deb`, `octosense__x86_64.AppImage` | `/usr/lib/octosense/` (`usr/lib/octosense/` in the AppImage) | + +What a package contains and how it is found at run time: + +- **Resources.** The build sets `MAKEPAD_PACKAGE_DIR` (and `MAKEPAD=apple_bundle` on macOS), so Makepad reads every `crate_resource` from the package: `Contents/Resources` through `NSBundle` on macOS, the executable's directory on Windows, `../lib/octosense` from `usr/bin/octosense` on Linux. The script stages the `resources/` of every git or path crate the app links: Makepad's widgets (fonts, icons, textures, about 59 MB), the shell's icons and themes, App Hub, Rinx and its article crates. A packaged build also stops looking for the checkout it was built in, so it has no developer-program catalog (those rows build from source); `--apps ` with `executable` rows still works. +- **System apps** (News, Photos, Maps, Camera, Mail, AI providers) are already in the binary: App Hub packs the bundles `system-apps.json` selects at build time. Nothing else is read from `apps/` or `desktop/config/`. +- **The octos kernel.** The script builds octos at the revision `Cargo.lock` pins (the checkout and staging of `tools/build-desktop.py`, which checks the binary's `--version`) and ships it as `octos-kernel` beside the executable (`Contents/MacOS/` in the app), where the kernel service finds it without `OCTOS_APP_CORE_BIN`. `--kernel ` ships a prebuilt one; `--no-kernel` ships none, and the app then runs without an assistant (AI providers are still saved). `target/octosense-package/receipt.json` records the version, resource crates and the kernel's SHA-256. +- **Private-path-free.** Paths in the binaries are remapped (`--remap-path-prefix` for the home directory, `CARGO_HOME` and the checkout) and debug info stripped (symbol names stay, for readable backtraces). Crates also embed their source directories as plain strings, which remapping does not reach, so build from a directory outside any user's home, with `CARGO_HOME` outside it too (the release workflow does); `tools/release-scan.py` fails on `/Users/…`, `C:\Users\…`, homes other than `/home/runner`, `*.local` hosts, private IPv4 addresses, the scanning account's and host's names and any `RELEASE_SCAN_EXTRA` pattern, inside the `.app`, `.dmg`, `.deb`, `.AppImage`, `.zip` and NSIS installers. +- **Identity.** Product name **OctoSense**, identifier `org.octosense.desktop` (`desktop/packaging/release.json`), icon from `desktop/packaging/icons/` (`make_icons.py` renders it). Android keeps `dev.makepad.octosense`. + +### Cutting a desktop release + +1. Merge everything the release needs to `main`, and dry-run the workflow on it: **Actions → Release desktop → Run workflow** (`dry_run` on), or `gh workflow run release-desktop.yml --ref main -f dry_run=true`. It builds and scans all three platforms and keeps the packages as workflow artifacts for 14 days. +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..[-
]`).
+3. `.github/workflows/release-desktop.yml` runs on macOS 14 (arm64), Windows 2022 and Ubuntu 22.04 (x86_64; the older glibc keeps the AppImage and `.deb` usable on older distributions): `tools/setup.py`, the graph checks (`setup.py --check --cargo`, `check-shell-graph.sh`), `package.py` with remapped paths in a neutral directory, the scan, then a **draft** release for the tag, not marked latest (`home-v*` and `rom-v*` share this repository), with every package, a receipt per platform and `SHA256SUMS`.
+4. Review the draft, try the packages, and publish it by hand.
+
+Packages are **unsigned** until the signing secrets exist: macOS Gatekeeper then asks to confirm the first open (right-click → Open), and Windows SmartScreen warns. x86_64 macOS builds are not produced (the workflow's macOS runner is arm64).
+
+### Signing secrets (to add later)
+
+Each signing step is skipped while its secret is missing. Add them under **Settings → Secrets and variables → Actions**:
+
+| Secret | For |
+| --- | --- |
+| `APPLE_CERTIFICATE` | base64 of the **Developer ID Application** certificate and key (`.p12`) |
+| `APPLE_CERTIFICATE_PASSWORD` | the `.p12` password |
+| `APPLE_SIGNING_IDENTITY` | `Developer ID Application:  ()`: turns on codesign (hardened runtime, `desktop/packaging/macos/entitlements.plist`) of the kernel, the app and the `.dmg` |
+| `APPLE_API_KEY`, `APPLE_API_ISSUER`, `APPLE_API_KEY_P8` | App Store Connect API key id, issuer id and base64 of `AuthKey_.p8`: notarytool notarization and stapling of the `.app` and the `.dmg` |
+| `WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_PASSWORD` | base64 of the code-signing `.pfx` and its password: signtool signs `octosense.exe`, the kernel and the installer |
+| `RELEASE_SCAN_EXTRA` | optional: comma-separated regular expressions the artifact scan must also refuse |
+
+The signing paths have not run yet (no certificates): they are **unverified**.
+
 ## Pins and upstream sync
 
 Every external dependency is pinned once for the whole repository: Makepad and OctoScript through `native-runtime.lock.json` (and the reviewed patch in `runtime-patches.lock.json`), App Hub, octos and Rinx in the root `Cargo.toml` `[workspace.dependencies]`. The system apps, the host services, the kernel service and AppCard are in this repository and change in the same pull request as the shell; there is nothing to pin. After moving a pin: `python3 tools/setup.py --update`, `cargo update` as needed, then `python3 tools/setup.py --check --cargo` and the tests below.
@@ -315,6 +364,7 @@ cargo check --locked -p octosense -p octosense-reference -p octosense-appcard --
 bash ../tools/check-shell-graph.sh -p octosense   # AI services linked, no AppCard UI without app-appcard, Rinx only as a module, one Makepad/App Hub/octos (host and aarch64-linux-android)
 cd ..
 python3 -m unittest discover -s tools -p 'test_*.py'
+python3 -m unittest discover -s desktop/scripts -p 'test_package.py'
 python3 tools/setup.py --check --cargo
 ```
 
@@ -341,7 +391,7 @@ Results for each change are recorded in [docs/validation.md](docs/validation.md)
 ## Known gaps
 
 - Only macOS is validated. Windows and Linux are untested; the iOS build fails in the pinned Metal backend.
-- No packaged `.app` or installer; source builds read fonts and resources from the `.sources/makepad` checkout, so keep it in place.
+- Source builds (`cargo run`, `tools/build-desktop.py`) read fonts and resources from the `.sources/makepad` checkout, so keep it in place; [release builds](#release-builds) carry their own. Release packages are unsigned until the signing secrets are added, and the Windows and Linux packages are built in CI but not run by us.
 - Photos on desktop has thumbnails only unless you mount a photo directory.
 - The hosted AppCard assistant does not yet wire notifications, share or the WebView overlay.
 - Mobile Sheets needs grid-label and toolbar fixes ([BACKLOG.md](BACKLOG.md)).
diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md
index 8a4e1a55..f075c7ef 100644
--- a/desktop/README.zh-CN.md
+++ b/desktop/README.zh-CN.md
@@ -29,8 +29,9 @@
 | `src/main.rs` | 入口(包名 `octosense`):在 Shell 的 `App` 上调用 `octosense_main!()`。Shell 本身(平铺、launcher、dock、顶栏、托管、应用注册表、`shell/`、`octosense/`)在 [`../crates/shell/src`](../crates/shell/src)。 |
 | `config/apps.json` | 默认的开发者程序目录。`apps.makepad.json` 是供 `--apps` 使用的相同副本;`apps.overlay.json` 保存重新生成时应用的调整。 |
 | `system-apps.json` | 本构建打包哪些系统应用,以及从哪里打包(`../apps`)。 |
-| `scripts/` | `upstream.py`(WM 来源记录与目录重新生成)、`smoke.py`(原生冒烟测试)及它们的 Python 测试,`system_apps_remote.sh`、`ai_providers_remote.sh` 和 `glance_remote.sh`(以隐藏窗口 `--remote` 端到端运行系统应用、AI 提供商与一览屏),以及 `provision-appcard-llm.sh`(Android)。 |
+| `scripts/` | `package.py`(发布安装包,见[发布构建](#发布构建))、`upstream.py`(WM 来源记录与目录重新生成)、`smoke.py`(原生冒烟测试)及它们的 Python 测试,`system_apps_remote.sh`、`ai_providers_remote.sh` 和 `glance_remote.sh`(以隐藏窗口 `--remote` 端到端运行系统应用、AI 提供商与一览屏),以及 `provision-appcard-llm.sh`(Android)。 |
 | `upstream/makepad.json` | 从 Makepad `apps/wm` 导入的每个文件的来源记录。 |
+| `packaging/` | 发布安装包的 cargo-packager 配置(`release.json`)、应用图标(`icons/`、`make_icons.py`),以及 macOS `Info.plist` 补充项与 entitlements。 |
 | `resources/android/` | Android manifest 模板。主题、壁纸、图标和启动脚本属于 Shell,在 [`../crates/shell/resources`](../crates/shell/resources)。 |
 | `docs/` | [验证记录](docs/validation.md)、[上游同步](docs/upstream.md)、[本地 AI](docs/local-ai.md)、[Android AppCard 构建](docs/android-appcard-build.md)、按日期的计划。 |
 | `KEYBINDINGS.md`、`BACKLOG.md` | 快捷键说明;待办事项。 |
@@ -78,7 +79,7 @@ python3 tools/build-desktop.py
 | Android | `cargo makepad android run -p octosense --release`;见[手机](#手机)。 |
 | iOS | 启动策略已测试,但完整构建目前在固定版本 Makepad 的 Metal 后端中失败([验证记录](docs/validation.md))。 |
 
-不提供可移动的 `.app`、安装包或 Linux 会话合成器。
+可安装的包(macOS `.app`/`.dmg`、Windows 安装程序、Linux `.deb`/`.AppImage`)见[发布构建](#发布构建)。不提供 Linux 会话合成器。
 
 ### Cargo features
 
@@ -296,6 +297,54 @@ cargo makepad android run -p octosense --release
 - 状态保存在 `~/.octosense`(`OCTOSENSE_HOME`);被托管的应用通过 `MAKEPAD_HOME` 获得它。
 - AI 面板(**F10**)的本地模型:[docs/local-ai.md](docs/local-ai.md)。没有模型桌面也能正常工作。
 
+## 发布构建
+
+`desktop/scripts/package.py` 把检出的源码做成可安装的包,运行时既不需要 `.sources/`,也不需要本仓库。在仓库根目录完成 setup,并安装 [cargo-packager](https://github.com/crabnebula-dev/cargo-packager)(`cargo install cargo-packager --locked --version 0.11.8`)之后:
+
+```sh
+python3 desktop/scripts/package.py                     # this OS's formats, version from desktop/Cargo.toml
+python3 desktop/scripts/package.py --formats app       # macOS: just OctoSense.app
+python3 tools/release-scan.py target/octosense-package/dist/*   # refuse private paths before sharing anything
+```
+
+| 系统 | 包(位于 `target/octosense-package/dist/`) | 包内资源位置 |
+| --- | --- | --- |
+| macOS(arm64) | `OctoSense.app`、`OctoSense__aarch64.dmg` | `OctoSense.app/Contents/Resources//resources/` |
+| Windows(x64) | `OctoSense__x64-setup.exe`(NSIS,按用户安装) | 安装目录中 `octosense.exe` 旁边 |
+| Linux(x86_64) | `octosense__amd64.deb`、`octosense__x86_64.AppImage` | `/usr/lib/octosense/`(AppImage 中为 `usr/lib/octosense/`) |
+
+包里有什么,运行时如何找到:
+
+- **资源。** 构建时设置 `MAKEPAD_PACKAGE_DIR`(macOS 上另加 `MAKEPAD=apple_bundle`),Makepad 于是从包内读取所有 `crate_resource`:macOS 通过 `NSBundle` 读 `Contents/Resources`,Windows 读可执行文件所在目录,Linux 从 `usr/bin/octosense` 出发读 `../lib/octosense`。脚本会收集应用链接的每个 git 或路径 crate 的 `resources/`:Makepad 的 widgets(字体、图标、纹理,约 59 MB)、Shell 的图标和主题、App Hub、Rinx 及其文章相关 crate。打包构建也不再寻找构建时所在的源码检出,因此没有开发者程序目录(那些条目需要从源码构建);用 `--apps ` 加 `executable` 条目仍然可用。
+- **系统应用**(新闻、相册、地图、相机、邮件、AI 提供商)已在二进制中:App Hub 在构建时把 `system-apps.json` 选中的 bundle 打包进去。运行时不再读取 `apps/` 或 `desktop/config/`。
+- **octos 内核。** 脚本按 `Cargo.lock` 固定的版本构建 octos(使用 `tools/build-desktop.py` 的检出与放置流程,会检查二进制的 `--version`),并以 `octos-kernel` 的名字放在可执行文件旁边(app 中为 `Contents/MacOS/`),内核服务无需 `OCTOS_APP_CORE_BIN` 即可找到它。`--kernel ` 使用预先构建的内核;`--no-kernel` 不附带内核,应用随后没有助手(AI 提供商设置仍会保存)。`target/octosense-package/receipt.json` 记录版本、资源 crate 和内核的 SHA-256。
+- **不含私人路径。** 二进制中的路径会被重映射(对主目录、`CARGO_HOME` 和源码检出使用 `--remap-path-prefix`),并去掉调试信息(保留符号名,便于阅读回溯)。crate 还会把自己的源码目录作为普通字符串嵌入,重映射管不到,所以要在任何用户主目录之外的目录中构建,`CARGO_HOME` 也放在外面(发布工作流就是这样做的);`tools/release-scan.py` 会在 `.app`、`.dmg`、`.deb`、`.AppImage`、`.zip` 和 NSIS 安装程序内部查找 `/Users/…`、`C:\Users\…`、`/home/runner` 以外的主目录、`*.local` 主机名、私有 IPv4 地址、执行扫描的账户名和主机名以及 `RELEASE_SCAN_EXTRA` 中的模式,发现即失败。
+- **标识。** 产品名 **OctoSense**,标识符 `org.octosense.desktop`(`desktop/packaging/release.json`),图标来自 `desktop/packaging/icons/`(由 `make_icons.py` 生成)。Android 仍为 `dev.makepad.octosense`。
+
+### 发布桌面版本
+
+1. 把发布所需的一切合并到 `main`,并在其上试运行工作流:**Actions → Release desktop → Run workflow**(`dry_run` 打开),或 `gh workflow run release-desktop.yml --ref main -f dry_run=true`。它会构建并扫描三个平台,把包作为工作流 artifact 保留 14 天。
+2. 给提交打标签并推送:`git tag desktop-v0.1.0  && git push origin desktop-v0.1.0`。版本号取自标签(`desktop-v..[-
]`)。
+3. `.github/workflows/release-desktop.yml` 在 macOS 14(arm64)、Windows 2022 和 Ubuntu 22.04(x86_64;较旧的 glibc 让 AppImage 和 `.deb` 能在较旧的发行版上使用)上运行:`tools/setup.py`、依赖图检查(`setup.py --check --cargo`、`check-shell-graph.sh`)、在中性目录中以重映射路径运行 `package.py`、扫描,然后为该标签创建一个**草稿** release,不标记为 latest(`home-v*` 和 `rom-v*` 共用本仓库),附上所有包、每个平台的 receipt 和 `SHA256SUMS`。
+4. 检查草稿、试用这些包,然后手动发布。
+
+在签名 secret 配置之前,包都是**未签名**的:macOS Gatekeeper 首次打开时要求确认(右键 → 打开),Windows SmartScreen 会警告。不生成 x86_64 的 macOS 构建(工作流的 macOS runner 是 arm64)。
+
+### 签名 secret(之后添加)
+
+缺少对应 secret 时,相应的签名步骤会跳过。在 **Settings → Secrets and variables → Actions** 中添加:
+
+| Secret | 用途 |
+| --- | --- |
+| `APPLE_CERTIFICATE` | **Developer ID Application** 证书及私钥(`.p12`)的 base64 |
+| `APPLE_CERTIFICATE_PASSWORD` | `.p12` 的密码 |
+| `APPLE_SIGNING_IDENTITY` | `Developer ID Application:  ()`:启用对内核、app 和 `.dmg` 的 codesign(hardened runtime,`desktop/packaging/macos/entitlements.plist`) |
+| `APPLE_API_KEY`、`APPLE_API_ISSUER`、`APPLE_API_KEY_P8` | App Store Connect API key id、issuer id 和 `AuthKey_.p8` 的 base64:用 notarytool 公证并 staple `.app` 和 `.dmg` |
+| `WINDOWS_CERTIFICATE`、`WINDOWS_CERTIFICATE_PASSWORD` | 代码签名 `.pfx` 的 base64 及其密码:signtool 签名 `octosense.exe`、内核和安装程序 |
+| `RELEASE_SCAN_EXTRA` | 可选:逗号分隔的正则表达式,产物扫描也要拒绝这些内容 |
+
+签名流程尚未运行过(没有证书):属于**未验证**。
+
 ## 版本固定与上游同步
 
 每个外部依赖在整个仓库中只固定一次:Makepad 和 OctoScript 通过 `native-runtime.lock.json`(以及 `runtime-patches.lock.json` 中经审查的补丁),App Hub、octos 和 Rinx 在根目录 `Cargo.toml` 的 `[workspace.dependencies]` 中。系统应用、宿主服务、内核服务和 AppCard 都在本仓库中,与 Shell 在同一个 pull request 中修改,无需固定版本。移动固定版本之后:运行 `python3 tools/setup.py --update`,按需运行 `cargo update`,再运行 `python3 tools/setup.py --check --cargo` 和下面的测试。
@@ -315,6 +364,7 @@ cargo check --locked -p octosense -p octosense-reference -p octosense-appcard --
 bash ../tools/check-shell-graph.sh -p octosense   # AI services linked, no AppCard UI without app-appcard, Rinx only as a module, one Makepad/App Hub/octos (host and aarch64-linux-android)
 cd ..
 python3 -m unittest discover -s tools -p 'test_*.py'
+python3 -m unittest discover -s desktop/scripts -p 'test_package.py'
 python3 tools/setup.py --check --cargo
 ```
 
@@ -341,7 +391,7 @@ python3 desktop/scripts/smoke.py --cargo-run --default-catalog
 ## 已知不足
 
 - 只在 macOS 上验证过。Windows 和 Linux 未测试;iOS 构建在固定版本的 Metal 后端中失败。
-- 没有打包好的 `.app` 或安装包;源码构建从 `.sources/makepad` 检出中读取字体和资源,所以请保留该检出。
+- 源码构建(`cargo run`、`tools/build-desktop.py`)从 `.sources/makepad` 检出中读取字体和资源,所以请保留该检出;[发布构建](#发布构建)自带资源。在添加签名 secret 之前发布包未签名;Windows 和 Linux 包在 CI 中构建,但我们没有运行过。
 - 桌面端的相册只有缩略图,除非挂载照片目录。
 - 托管的 AppCard 助手尚未接通通知、分享和 WebView 覆盖层。
 - 手机上的 Sheets 需要修复网格标签和工具栏([BACKLOG.md](BACKLOG.md))。
diff --git a/desktop/packaging/icons/icon.ico b/desktop/packaging/icons/icon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..74401af86b41cee9e3c520e0f1aaa8a34af86eaa
GIT binary patch
literal 6878
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

literal 0
HcmV?d00001

diff --git a/desktop/packaging/icons/icon.svg b/desktop/packaging/icons/icon.svg
new file mode 100644
index 00000000..c430cb09
--- /dev/null
+++ b/desktop/packaging/icons/icon.svg
@@ -0,0 +1 @@
+
diff --git a/desktop/packaging/icons/icon_128.png b/desktop/packaging/icons/icon_128.png
new file mode 100644
index 0000000000000000000000000000000000000000..8711cb21682cc34584372396ac41463197b3d72b
GIT binary patch
literal 1394
zcmV-&1&#WNP)ibTY&N-fzR%p}g%`qZazEz0^Ks6cnW-uO0000000000000000DwTabp1-*
za@umQo&(Z3XoH+evQ$$afVAV20oCq4q0o
z1)7)wQ04kPe|tPw0P-7cE+M1;3;6ZdyTJmG-{1}G7QnRh=3j9)FeUW6#RrS~)&h{<
z5*j`)fJNvUEB&vG#0|wazxVWRwEo|993ams&%F5#a;*q0%0jgO4%Jo(*-F7)KDrvj
z{c~{#<(cH!Hbcw_S{1zl7}8e{5rWMCaL7Qgui+z&E%XY&rl}SK9h%LvHNN4Y*+d(G
z1#~g>3c#hQ-WR_QC4fHj3JD^O;}n4K_2-v6y9D0?kbB6zT&{@#0ti6(>BpOMBJe2y
zxwhQH^_mF4bCbGH1il2|%lLt(ob?Jod}kkpyDb7A0`O^kp>0f~_&C52SLzT=c8`nU
z%byiZIF_H1h
z7Qp7xZr3Ewo3o>}p(HXVl+W@v*Cf&mmYC}kfaJ$X%`=(6`+`8yw90pEy@NgpnYL-<
z@=Wq<`??T$HSedOPz6tKlKG6FyVbOCe$3<4Ph
z;s)Xd5(pv?L@1b0Fq1({1~DDXbTA1aB!rL>MoJh-p(KTp7EW3?3qx2K!qPC7hOs!5
z#i1+@XL&dqM6f{wTg0$M44Xu;Nfg_}u}vHsMY2(3LIJQzT=8pWYe92>{6aU2}U!I9&e3*hM3`~|?_(IY=Rn&aa~etdkbtWc~5f_(;n8M3Zo
zpuN#bQ^DAb0|L5gp}4AIp}kpCdN-i3`EOzhV5Y5k9O4cJjMRnXET#Y^+6rh$E1kga^qU}?9W+P~Eb
ztdthqdNwc8f{kFVU$WY-H8flp=T#2S78fXlO_d8ex9}9BMB{06w+|6ud_;ipdy9ZE
zL4ZC#d*ko$4ptb>i>o)UG@^?WEHR>ekSDpHj}S1t@A1K`>(A&O{%_`+u52dNSkSc$
z({8-57#Zi?yVn%}00000000000000006-x81>y3aQ^C^f9{>OV07*qoM6N<$f-o_S
AmH+?%

literal 0
HcmV?d00001

diff --git a/desktop/packaging/icons/icon_256.png b/desktop/packaging/icons/icon_256.png
new file mode 100644
index 0000000000000000000000000000000000000000..54def551f49846fbb940d1620c7056acef7007e5
GIT binary patch
literal 3408
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
b!SG+xyYKX!S@a~9@!QnO%+|EXgcR{VvK|l~

literal 0
HcmV?d00001

diff --git a/desktop/packaging/icons/icon_32.png b/desktop/packaging/icons/icon_32.png
new file mode 100644
index 0000000000000000000000000000000000000000..0cb96aceb1c9d5505b92c4faa522e8436aa35187
GIT binary patch
literal 346
zcmV-g0j2(lP)#R5=`|EY{YW(pAF09sfMcLXuPPLyH#UKaoNU0)8y
zAT|k(NWkZS^~4%9X-(_@D{pszF^Elq1J;uq{Ds{)|I-?hQG5#WIm`hdHgd>-)PnSo
z91YkU05S+>(aA>}{s&|QfyH5BFbom{$$`|s#Bex*GzWlugKilqViY?76r{+$f`tUS
zW!TieU2mONpk=w_(0hUIkh9>nrS6)
zLqTk8YCw8HW}rm{aSp&`G0f-aK0^-)bcYN*2Ml-=40s|OG}&;#3!(upiU+(hqIY#X
s;0=<2ZITUmqjXR-%Llw;G79Jo02rrgj9|DQ1^@s607*qoM6N<$f-6RW7XSbN

literal 0
HcmV?d00001

diff --git a/desktop/packaging/icons/icon_512.png b/desktop/packaging/icons/icon_512.png
new file mode 100644
index 0000000000000000000000000000000000000000..ad240ec7b2b153b729a44333632db166def538b7
GIT binary patch
literal 8139
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
CR@%A%

literal 0
HcmV?d00001

diff --git a/desktop/packaging/icons/icon_512@2x.png b/desktop/packaging/icons/icon_512@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..490ceea960f7eed690f5c706fabd4ea2ada6d610
GIT binary patch
literal 20323
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

literal 0
HcmV?d00001

diff --git a/desktop/packaging/icons/icon_64.png b/desktop/packaging/icons/icon_64.png
new file mode 100644
index 0000000000000000000000000000000000000000..40ba3ab84d041f0e8912600c0ebc5a0f2cd7162e
GIT binary patch
literal 621
zcmV-z0+RiSP)cW2clIWY3BWUW)^s1njD`SAO`B0rR*~RB0PeMs
z3Si7=2rx}k9SL>!xftIfd6FdnPO45}wH)f4pl#ZhVREv)5a2osLZL=7Qr5#Z~^ed-DqkN_?LIzfg(CPGYwSq!lp=7tEj#JDNSZE?OB;j1ye9PK9+UY6J6
zD}O*@wU5ZG{2?hGljA{29+l-`X&#s72NV2chC-oGD9bFr4SuX}Gs<<100000NkvXX
Hu0mjf8Tu8K

literal 0
HcmV?d00001

diff --git a/desktop/packaging/macos/Info.plist b/desktop/packaging/macos/Info.plist
new file mode 100644
index 00000000..98c1988c
--- /dev/null
+++ b/desktop/packaging/macos/Info.plist
@@ -0,0 +1,17 @@
+
+
+
+
+
+    NSCameraUsageDescription
+    Camera and the apps you open in OctoSense use the camera when you ask them to.
+    NSMicrophoneUsageDescription
+    OctoSense uses the microphone for voice input when you turn it on.
+    NSLocationUsageDescription
+    Maps shows where you are on the map and gives directions from there.
+    NSLocationWhenInUseUsageDescription
+    Maps shows where you are on the map and gives directions from there.
+
+
diff --git a/desktop/packaging/macos/entitlements.plist b/desktop/packaging/macos/entitlements.plist
new file mode 100644
index 00000000..f75a3593
--- /dev/null
+++ b/desktop/packaging/macos/entitlements.plist
@@ -0,0 +1,13 @@
+
+
+
+
+
+    com.apple.security.device.camera
+    
+    com.apple.security.device.audio-input
+    
+
+
diff --git a/desktop/packaging/make_icons.py b/desktop/packaging/make_icons.py
new file mode 100644
index 00000000..13ccd78a
--- /dev/null
+++ b/desktop/packaging/make_icons.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""The desktop package's app icon, rendered from icons/icon.svg's geometry.
+
+The mark is OctoSense's eight-petal flower on its green tile (the website's
+favicon), inset on a 1024 canvas the way macOS app icons are. This writes the
+PNG sizes cargo-packager turns into the macOS .icns, the Linux hicolor icons
+and the Windows .ico, and the .ico itself (PNG entries):
+
+    python3 desktop/packaging/make_icons.py          # rewrites icons/
+
+Stdlib only (Python 3.9+), deterministic: the output is committed, so a
+release build never renders anything. Edit TILE/PETAL/the geometry here and
+in icon.svg together.
+"""
+import math
+from pathlib import Path
+import struct
+import zlib
+
+OUT = Path(__file__).resolve().parent / "icons"
+SIZES = (32, 64, 128, 256, 512, 1024)  # the sizes an .icns takes
+ICO_SIZES = (16, 24, 32, 48, 64, 128, 256)
+
+# In icon.svg's 1024 user units.
+TILE = (0x24, 0x3F, 0x30)
+PETAL = (0xD4, 0xED, 0xB8)
+INSET, RADIUS = 100.0, 185.0          # the tile: 824 wide, macOS-like corners
+CENTER, SCALE = 512.0, 824.0 / 64.0   # the favicon's 64-unit flower, scaled to the tile
+PETAL_RX, PETAL_RY, PETAL_CY = 5.0 * SCALE, 10.0 * SCALE, -15.0 * SCALE
+
+
+def tile_distance(x, y):
+    """Signed distance (units) to the rounded tile; negative inside."""
+    lo, hi = INSET + RADIUS, 1024.0 - INSET - RADIUS
+    dx = max(lo - x, 0.0, x - hi)
+    dy = max(lo - y, 0.0, y - hi)
+    if dx > 0 and dy > 0:
+        return math.hypot(dx, dy) - RADIUS
+    inner = min(x - INSET, 1024.0 - INSET - x, y - INSET, 1024.0 - INSET - y)
+    return max(dx, dy) - RADIUS if (dx or dy) else -inner
+
+
+def petal_distance(x, y):
+    """Approximate signed distance to the nearest of the eight petals."""
+    px, py = x - CENTER, y - CENTER
+    best = float("inf")
+    for k in range(8):
+        a = -math.radians(45.0 * k)
+        rx = px * math.cos(a) - py * math.sin(a)
+        ry = px * math.sin(a) + py * math.cos(a)
+        f = math.hypot(rx / PETAL_RX, (ry - PETAL_CY) / PETAL_RY)
+        best = min(best, (f - 1.0) * PETAL_RX)
+    return best
+
+
+def render(size):
+    """RGBA rows, 4x4 supersampled where an edge crosses the pixel."""
+    unit = 1024.0 / size
+    rows = []
+    offsets = [(i + 0.5) / 4.0 for i in range(4)]
+    for j in range(size):
+        row = bytearray()
+        for i in range(size):
+            cx, cy = (i + 0.5) * unit, (j + 0.5) * unit
+            near = 1.5 * unit
+            dt, dp = tile_distance(cx, cy), petal_distance(cx, cy)
+            if abs(dt) > near and abs(dp) > near:
+                tile = 1.0 if dt < 0 else 0.0
+                petal = 1.0 if dp < 0 and tile else 0.0
+            else:
+                tile = petal = 0.0
+                for oy in offsets:
+                    for ox in offsets:
+                        sx, sy = (i + ox) * unit, (j + oy) * unit
+                        if tile_distance(sx, sy) < 0:
+                            tile += 1 / 16
+                            if petal_distance(sx, sy) < 0:
+                                petal += 1 / 16
+            if tile == 0:
+                row += b"\0\0\0\0"
+                continue
+            mix = petal / tile
+            rgb = [round(t + (p - t) * mix) for t, p in zip(TILE, PETAL)]
+            row += bytes(rgb + [round(255 * tile)])
+        rows.append(bytes(row))
+    return rows
+
+
+def png(size, rows):
+    def chunk(kind, data):
+        return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
+    raw = b"".join(b"\0" + r for r in rows)
+    return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0))
+            + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
+
+
+def ico(images):
+    """An .ico of PNG entries (Vista+), smallest first."""
+    header = struct.pack("/`, the
+   layout Makepad looks up (`/resources/`). System apps
+   need nothing here: App Hub packs their bundles into the binary.
+3. **Kernel**: the octos kernel at the revision the workspace pins, checked
+   out, built for this machine and staged by `tools/build-desktop.py`'s
+   `source_at` and `stage` (which refuses a binary whose `--version` is not
+   the locked revision), or `--kernel `. It ships beside the executable
+   as `octos-kernel[.exe]`, where the kernel service finds it without
+   `OCTOS_APP_CORE_BIN` (`crates/kernel/src/launch.rs`). `--no-kernel` ships
+   none: the app then runs without an assistant (AI providers are still
+   saved) and its log says why.
+4. **Package** with cargo-packager from `desktop/packaging/release.json`:
+   macOS `.app` and `.dmg`, Windows NSIS `.exe`, Linux `.deb` and `.AppImage`.
+
+Where the resources land, and the `MAKEPAD_PACKAGE_DIR` that finds them:
+
+| Format | Resources | `MAKEPAD_PACKAGE_DIR` |
+| --- | --- | --- |
+| `.app`/`.dmg` | `OctoSense.app/Contents/Resources/` | `.` (+ `apple_bundle`: NSBundle's resource path) |
+| NSIS `.exe` | the install directory, beside `octosense.exe` | `.` (next to the executable) |
+| `.deb`, `.AppImage` | `usr/lib/octosense/` | `../lib/octosense` (from `usr/bin/octosense`) |
+
+Signing (all optional; unsigned without them): `APPLE_SIGNING_IDENTITY` sets
+the Developer ID identity cargo-packager signs the .app, its kernel and the
+.dmg with (it imports `APPLE_CERTIFICATE`/`APPLE_CERTIFICATE_PASSWORD` into a
+temporary keychain, and notarizes the .app with `APPLE_API_KEY`,
+`APPLE_API_ISSUER`, `APPLE_API_KEY_PATH` or `APPLE_ID`, `APPLE_PASSWORD`,
+`APPLE_TEAM_ID`); `WINDOWS_CERTIFICATE_THUMBPRINT` signs the Windows binaries
+and installer with signtool from the certificate store. The release workflow
+(`.github/workflows/release-desktop.yml`) sets these from secrets.
+
+Run `python3 tools/release-scan.py ` on the output before
+publishing anything.
+"""
+import argparse
+import importlib.util
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+
+ROOT = Path(__file__).resolve().parents[2]
+DESKTOP = ROOT / "desktop"
+PACKAGING = DESKTOP / "packaging"
+PACKAGE = "octosense"
+KERNEL_NAME = "octos-kernel"
+DEFAULT_FORMATS = {"macos": ["app", "dmg"], "windows": ["nsis"], "linux": ["deb", "appimage"]}
+# Where Makepad reads packaged resources from, per OS (see the table above).
+PACKAGE_DIR = {"macos": ".", "windows": ".", "linux": "../lib/octosense"}
+ICON_SLOTS = {"32": "icon_32.png", "64": "icon_64.png", "128": "icon_128.png", "256": "icon_256.png",
+              "512": "icon_512.png", "1024": "icon_512@2x.png", "ICO": "icon.ico"}
+MACOS_MINIMUM = "11.0"
+
+
+def host_os():
+    return {"darwin": "macos", "win32": "windows"}.get(sys.platform, "linux")
+
+
+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)
+    if not match:
+        raise ValueError(f"{tag!r} is not a desktop release tag (desktop-v..[-
])")
+    return match.group(1)
+
+
+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
+    (`~`) it may sit in."""
+    pairs = []
+    if home:
+        pairs.append((str(home), "~"))
+    if cargo_home:
+        pairs.append((str(cargo_home), "/cargo"))
+    pairs.append((str(root), "."))
+    flags = []
+    for src, dst in pairs:
+        flags += ["--remap-path-prefix", f"{src}={dst}"]
+    return flags
+
+
+def package_env(os_name, root, base_env):
+    """The environment the release build adds: packaged-resource mode, path
+    remapping, no debug info, the window icon, the macOS minimum."""
+    env = {"MAKEPAD_PACKAGE_DIR": PACKAGE_DIR[os_name], "CARGO_PROFILE_RELEASE_STRIP": "debuginfo"}
+    if os_name == "macos":
+        env["MAKEPAD"] = "apple_bundle"
+        env["MACOSX_DEPLOYMENT_TARGET"] = MACOS_MINIMUM
+    # Windows: the native profile path, not an MSYS `HOME` like /c/Users/...
+    home = base_env.get("USERPROFILE") if os_name == "windows" else base_env.get("HOME")
+    cargo_home = base_env.get("CARGO_HOME") or (str(Path(home) / ".cargo") if home else None)
+    existing = base_env.get("CARGO_ENCODED_RUSTFLAGS")
+    flags = existing.split("\x1f") if existing else base_env.get("RUSTFLAGS", "").split()
+    # CARGO_ENCODED_RUSTFLAGS carries paths with spaces intact (RUSTFLAGS
+    # splits on them) and takes precedence over RUSTFLAGS.
+    env["CARGO_ENCODED_RUSTFLAGS"] = "\x1f".join([f for f in flags if f] + remap_flags(root, home, cargo_home))
+    for slot, name in ICON_SLOTS.items():
+        env[f"MAKEPAD_APP_ICON_{slot}"] = str(PACKAGING / "icons" / name)
+    return env
+
+
+def cargo_json(args, env):
+    out = subprocess.run(["cargo", *args], cwd=ROOT, env=env, check=True, capture_output=True, text=True).stdout
+    return out
+
+
+def feature_args(features):
+    """`--features` for octosense, qualified (the workspace root is virtual)."""
+    if not features:
+        return []
+    return ["--features", ",".join(f if "/" in f else f"{PACKAGE}/{f}" for f in features.split(","))]
+
+
+def linked_packages(features, env):
+    """(name, version) of every package the app links: `cargo tree` resolves
+    features for octosense alone, as its build does (the workspace-wide
+    `cargo metadata` resolve would add what other members enable)."""
+    out = cargo_json(["tree", "--locked", "-p", PACKAGE, "-e", "normal", "--prefix", "none", "--target", host_triple(),
+                      "--format", "{p}", *feature_args(features)], env)
+    linked = set()
+    for line in out.splitlines():
+        match = re.match(r"(\S+) v(\S+)", line.strip())
+        if match:
+            linked.add((match.group(1), match.group(2)))
+    return linked
+
+
+def host_triple():
+    out = subprocess.run(["rustc", "-vV"], check=True, capture_output=True, text=True).stdout
+    return re.search(r"^host: (\S+)$", out, re.M).group(1)
+
+
+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
+    resources are named by the crates that declare `crate_resource`s, and
+    none of those come from crates.io."""
+    found = []
+    for package in sorted(metadata["packages"], key=lambda p: p["name"]):
+        if (package["name"], package["version"]) not in linked:
+            continue
+        source = package.get("source") or ""
+        if source.startswith(("registry+", "sparse+")):
+            continue
+        resources = Path(package["manifest_path"]).parent / "resources"
+        if resources.is_dir():
+            found.append((package["name"].replace("-", "_"), resources))
+    return found
+
+
+def stage_resources(crates, dest):
+    """Copy each crate's resources to `//resources/`."""
+    if dest.exists():
+        shutil.rmtree(dest)
+    dest.mkdir(parents=True)
+    for name, resources in crates:
+        shutil.copytree(resources, dest / name / "resources",
+                        ignore=shutil.ignore_patterns(".DS_Store", "*.md"))
+    return dest
+
+
+def build_desktop():
+    """tools/build-desktop.py: the locked kernel's checkout and staging."""
+    spec = importlib.util.spec_from_file_location("build_desktop", ROOT / "tools/build-desktop.py")
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+def build_kernel(env, work, offline=False):
+    """The octos kernel at the workspace's revision, built for this machine
+    with the release environment (paths remapped, no debug info)."""
+    tool = build_desktop()
+    revision = tool.kernel_tool.octos_revision()
+    source = tool.source_at(revision, work, offline)
+    env = {k: v for k, v in env.items() if not k.startswith("MAKEPAD")}
+    command = ["cargo", "build", "--locked", "--release", "--target-dir", str(work / "target"), *tool.kernel_tool.KERNEL_BUILD]
+    subprocess.run(command + (["--offline"] if offline else []), cwd=source, env=env, check=True)
+    return work / "target" / "release" / ("octos" + tool.SUFFIX)
+
+
+def stage_kernel(kernel, out):
+    """Stage (and check) the kernel as `octos-kernel`, then name a copy the
+    way cargo-packager's `externalBinaries` expects (`-`)."""
+    tool = build_desktop()
+    staged = tool.stage(kernel, out / "kernel", tool.kernel_tool.octos_revision())
+    sidecar = out / "bin" / KERNEL_NAME
+    sidecar.parent.mkdir(parents=True, exist_ok=True)
+    shutil.copy2(staged, sidecar.with_name(f"{KERNEL_NAME}-{host_triple()}{tool.SUFFIX}"))
+    receipt = json.loads((out / "kernel" / "octos-kernel.json").read_text())
+    return sidecar, receipt
+
+
+def debian_depends(binaries):
+    """The shared-library packages the binaries link, from dpkg-shlibdeps."""
+    if not shutil.which("dpkg-shlibdeps"):
+        return None
+    with tempfile.TemporaryDirectory() as temp:
+        (Path(temp) / "debian").mkdir()
+        (Path(temp) / "debian/control").write_text("Source: octosense\n\nPackage: octosense\nArchitecture: any\n")
+        out = subprocess.run(["dpkg-shlibdeps", "-O", "--ignore-missing-info", *(f"-e{b}" for b in binaries)],
+                             cwd=temp, check=True, capture_output=True, text=True).stdout
+    line = next((l for l in out.splitlines() if l.startswith("shlibs:Depends=")), "")
+    return [d.strip() for d in line.split("=", 1)[-1].split(",") if d.strip()]
+
+
+def packager_config(base, *, version, binaries_dir, out_dir, resources, kernel, env):
+    """release.json with this build's version, paths, kernel and signing."""
+    config = json.loads(json.dumps(base))
+    config["version"] = version
+    config["binariesDir"] = str(binaries_dir)
+    config["outDir"] = str(out_dir)
+    config["resources"] = [{"src": str(resources), "target": "."}]
+    if kernel:
+        # cargo-packager copies `-[.exe]` beside the
+        # main binary as `[.exe]`.
+        config["externalBinaries"] = [str(kernel)]
+    identity = env.get("APPLE_SIGNING_IDENTITY")
+    if identity:
+        config.setdefault("macos", {})["signingIdentity"] = identity
+    thumbprint = env.get("WINDOWS_CERTIFICATE_THUMBPRINT")
+    if thumbprint:
+        config.setdefault("windows", {}).update({
+            "certificateThumbprint": thumbprint, "digestAlgorithm": "sha256", "tsp": True,
+            "timestampUrl": env.get("WINDOWS_TIMESTAMP_URL", "http://timestamp.digicert.com"),
+        })
+    return config
+
+
+def cargo_version():
+    text = (DESKTOP / "Cargo.toml").read_text()
+    return re.search(r'^version = "([^"]+)"', text, re.M).group(1)
+
+
+def main(argv=None):
+    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    p.add_argument("--formats", help="Comma-separated cargo-packager formats (default: this OS's: "
+                   + "; ".join(f"{k} {','.join(v)}" for k, v in DEFAULT_FORMATS.items()) + ")")
+    p.add_argument("--version", help="The package version (default: desktop/Cargo.toml's)")
+    p.add_argument("--version-from-tag", metavar="TAG", help="Take the version from a desktop-v tag")
+    p.add_argument("--features", help="Cargo features for octosense (default: its defaults)")
+    kernel = p.add_mutually_exclusive_group()
+    kernel.add_argument("--kernel", type=Path, help="Ship this prebuilt octos kernel instead of building it")
+    kernel.add_argument("--no-kernel", action="store_true", help="Ship no kernel: the app runs without an assistant")
+    p.add_argument("--out", type=Path, default=ROOT / "target/octosense-package",
+                   help="Work and output directory (default: target/octosense-package)")
+    p.add_argument("--skip-build", action="store_true", help="Reuse the last release build (resources and packaging only)")
+    p.add_argument("--offline", action="store_true")
+    p.add_argument("--print-env", action="store_true", help="Print the build environment as JSON and exit")
+    args = p.parse_args(argv)
+
+    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())
+    added = package_env(os_name, ROOT, os.environ)
+    if args.print_env:
+        print(json.dumps(added, indent=2))
+        return
+    env = {**os.environ, **added}
+    env.pop("RUSTFLAGS", None)  # folded into CARGO_ENCODED_RUSTFLAGS
+    out = args.out.resolve()
+    out.mkdir(parents=True, exist_ok=True)
+    offline = ["--offline"] if args.offline else []
+    features = feature_args(args.features)
+
+    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],
+                       cwd=ROOT, env=env, check=True)
+
+    print("==> staging resources", flush=True)
+    metadata = json.loads(cargo_json(["metadata", "--locked", "--format-version", "1"], env))
+    crates = resource_crates(metadata, linked_packages(args.features, env))
+    resources = stage_resources(crates, out / "resources")
+    for name, src in crates:
+        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:
+        if args.kernel:
+            binary = args.kernel.resolve()
+        else:
+            print("==> building the octos kernel for this machine (tools/build-desktop.py source_at)", flush=True)
+            binary = build_kernel(env, out / "kernel-build", args.offline)
+        sidecar, kernel_receipt = stage_kernel(binary, out)
+        print(f"    {KERNEL_NAME}: {kernel_receipt['version']} sha256 {kernel_receipt['sha256'][:16]}")
+    else:
+        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, env=os.environ)
+    if os_name == "linux":
+        depends = debian_depends([binaries_dir / PACKAGE, *([sidecar.with_name(f"{KERNEL_NAME}-{triple}")] if sidecar else [])])
+        if depends:
+            config.setdefault("deb", {})["depends"] = depends
+    # Relative paths in the config resolve from its directory, so the
+    # generated copy sits beside release.json (git-ignored).
+    generated = PACKAGING / ".release.generated.json"
+    generated.write_text(json.dumps(config, indent=2) + "\n")
+    (out / "receipt.json").write_text(json.dumps({
+        "version": version, "target": triple, "formats": formats,
+        "resources": [name for name, _ in crates], "kernel": kernel_receipt,
+        "makepad_package_dir": added["MAKEPAD_PACKAGE_DIR"],
+    }, indent=2) + "\n")
+    print(f"==> cargo packager --formats {','.join(formats)}", flush=True)
+    try:
+        subprocess.run(["cargo", "packager", "--release", "--config", str(generated), "--formats", ",".join(formats)],
+                       cwd=PACKAGING, env=env, check=True)
+    finally:
+        generated.unlink(missing_ok=True)
+    print(f"==> packages in {out / 'dist'}; receipt {out / 'receipt.json'}")
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except (subprocess.CalledProcessError, RuntimeError, ValueError) as e:
+        sys.exit(f"package.py: {e}")
diff --git a/desktop/scripts/test_package.py b/desktop/scripts/test_package.py
new file mode 100644
index 00000000..5ad4a7db
--- /dev/null
+++ b/desktop/scripts/test_package.py
@@ -0,0 +1,117 @@
+"""Tests for desktop/scripts/package.py (no build, no network)."""
+import importlib.util
+import json
+from pathlib import Path
+import tempfile
+import unittest
+
+HERE = Path(__file__).resolve().parent
+spec = importlib.util.spec_from_file_location("package", HERE / "package.py")
+package = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(package)
+
+
+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"):
+            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+")
+
+
+class EnvironmentTests(unittest.TestCase):
+    def test_each_os_reads_resources_where_its_package_puts_them(self):
+        root = Path("/build/OctoSense")
+        mac = package.package_env("macos", root, {"HOME": "/home/me"})
+        self.assertEqual((mac["MAKEPAD"], mac["MAKEPAD_PACKAGE_DIR"]), ("apple_bundle", "."))
+        self.assertEqual(mac["MACOSX_DEPLOYMENT_TARGET"], "11.0")
+        win = package.package_env("windows", root, {"USERPROFILE": r"C:\Users\me"})
+        self.assertEqual(win["MAKEPAD_PACKAGE_DIR"], ".")
+        self.assertNotIn("MAKEPAD", win)
+        linux = package.package_env("linux", root, {"HOME": "/home/me"})
+        # usr/bin/octosense -> usr/lib/octosense, in the .deb and the AppImage.
+        self.assertEqual(linux["MAKEPAD_PACKAGE_DIR"], "../lib/octosense")
+        for env in (mac, win, linux):
+            self.assertEqual(env["CARGO_PROFILE_RELEASE_STRIP"], "debuginfo")
+            self.assertTrue(Path(env["MAKEPAD_APP_ICON_ICO"]).is_file())
+            self.assertTrue(Path(env["MAKEPAD_APP_ICON_1024"]).is_file())
+
+    def test_paths_are_remapped_with_the_checkout_last(self):
+        env = package.package_env("linux", Path("/home/me/src/OctoSense"),
+                                  {"HOME": "/home/me", "RUSTFLAGS": "-C target-cpu=native"})
+        flags = env["CARGO_ENCODED_RUSTFLAGS"].split("\x1f")
+        self.assertEqual(flags[:2], ["-C", "target-cpu=native"], "existing flags are kept")
+        remaps = [flags[i + 1] for i, f in enumerate(flags) if f == "--remap-path-prefix"]
+        self.assertEqual(remaps, ["/home/me=~", "/home/me/.cargo=/cargo", "/home/me/src/OctoSense=."])
+        custom = package.package_env("macos", Path("/b/OctoSense"), {"HOME": "/h", "CARGO_HOME": "/c h/cargo"})
+        self.assertIn("/c h/cargo=/cargo", custom["CARGO_ENCODED_RUSTFLAGS"].split("\x1f"), "a space survives")
+
+
+class ResourceTests(unittest.TestCase):
+    def test_linked_git_and_path_crates_with_resources_are_staged(self):
+        with tempfile.TemporaryDirectory() as temp:
+            temp = Path(temp)
+
+            def crate(name, source=None, resources=True, version="1.0.0"):
+                directory = temp / name
+                (directory / "resources/icons").mkdir(parents=True) if resources else directory.mkdir()
+                if resources:
+                    (directory / "resources/icons/a.svg").write_text("")
+                    (directory / "resources/README.md").write_text("provenance")
+                return {"name": name, "version": version, "source": source,
+                        "manifest_path": str(directory / "Cargo.toml")}
+
+            metadata = {"packages": [
+                crate("makepad-widgets"),
+                crate("rinx", "git+https://github.com/hagency-org/Rinx.git?rev=abc#abc"),
+                crate("some-registry-crate", "registry+https://github.com/rust-lang/crates.io-index"),
+                crate("octosense-appcard"),  # in the workspace, not linked by this build
+                crate("no-resources", resources=False),
+            ]}
+            linked = {("makepad-widgets", "1.0.0"), ("rinx", "1.0.0"), ("some-registry-crate", "1.0.0"),
+                      ("no-resources", "1.0.0")}
+            crates = package.resource_crates(metadata, linked)
+            self.assertEqual([name for name, _ in crates], ["makepad_widgets", "rinx"])
+            staged = package.stage_resources(crates, temp / "out/resources")
+            self.assertTrue((staged / "makepad_widgets/resources/icons/a.svg").is_file(),
+                            "the layout Makepad looks up: /resources/")
+            self.assertFalse((staged / "rinx/resources/README.md").exists(), "docs stay out of the package")
+            package.stage_resources(crates[:1], staged)
+            self.assertFalse((staged / "rinx").exists(), "a restage starts clean")
+
+
+class ConfigTests(unittest.TestCase):
+    def test_the_release_config_is_filled_in_per_build(self):
+        base = json.loads((HERE.parent / "packaging/release.json").read_text())
+        self.assertEqual(base["identifier"], "org.octosense.desktop")
+        self.assertEqual(base["productName"], "OctoSense")
+        for icon in base["icons"]:
+            self.assertTrue((HERE.parent / "packaging" / icon).is_file(), icon)
+        config = package.packager_config(base, version="0.2.0", binaries_dir=Path("/t/release"), out_dir=Path("/t/dist"),
+                                         resources=Path("/t/res"), kernel=Path("/t/bin/octos-kernel"), env={})
+        self.assertEqual(config["version"], "0.2.0")
+        self.assertEqual(config["resources"], [{"src": "/t/res", "target": "."}])
+        self.assertEqual(config["externalBinaries"], ["/t/bin/octos-kernel"])
+        self.assertNotIn("signingIdentity", config["macos"], "unsigned without a signing identity")
+        self.assertNotIn("windows", config)
+        self.assertNotIn("version", base, "the base config is not modified")
+        signed = package.packager_config(base, version="0.2.0", binaries_dir=Path("/t"), out_dir=Path("/t"),
+                                         resources=Path("/t"), kernel=None,
+                                         env={"APPLE_SIGNING_IDENTITY": "Developer ID Application: X (T)",
+                                              "WINDOWS_CERTIFICATE_THUMBPRINT": "AB12"})
+        self.assertEqual(signed["macos"]["signingIdentity"], "Developer ID Application: X (T)")
+        self.assertEqual(signed["windows"]["certificateThumbprint"], "AB12")
+        self.assertTrue(signed["windows"]["tsp"])
+        self.assertNotIn("externalBinaries", signed, "--no-kernel ships none")
+
+    def test_the_sidecar_name_is_the_one_the_kernel_service_looks_for(self):
+        launch = (HERE.parents[1] / "crates/kernel/src/launch.rs").read_text()
+        self.assertIn(f'"{package.KERNEL_NAME}"', launch)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tools/release-scan.py b/tools/release-scan.py
new file mode 100644
index 00000000..e18c8907
--- /dev/null
+++ b/tools/release-scan.py
@@ -0,0 +1,224 @@
+#!/usr/bin/env python3
+"""Refuse to publish an artifact that carries a private path or name (Python 3.9+).
+
+    python3 tools/release-scan.py target/octosense-package/dist/*
+    python3 tools/release-scan.py --extra 'some-private-host' OctoSense.app
+
+Every file is read as bytes, including the files inside the packages a
+release ships: `.app` bundles and directories, `.dmg` images (mounted with
+hdiutil on macOS), `.deb` (ar + tar), `.AppImage` (`--appimage-extract` on
+Linux), `.zip`, `.tar.*`, and NSIS installers (7-Zip, when installed; the
+installed files themselves are scanned before packaging on Windows, since
+the release workflow scans the staged payload too). An unreadable container
+fails the scan rather than being skipped.
+
+It fails on:
+
+- a macOS or Windows user directory (`/Users/`, `C:\\Users\\...`,
+  also UTF-16), and a Linux home other than a CI runner's (`/home/runner`);
+- a `*.local` host name (mDNS: a build machine on a private network);
+- a private IPv4 address (10/8, 172.16/12, 192.168/16);
+- the name of the account and host running the scan (skipped for generic CI
+  accounts), and every regular expression in `--extra` or the
+  `RELEASE_SCAN_EXTRA` environment variable (comma-separated; the release
+  workflow feeds it from a secret, so the patterns themselves stay private).
+
+Exit status 1 with one line per finding (the match is shown masked), 0 when
+clean. Findings are about the build, not the code: fix them with neutral
+build paths (see desktop/scripts/package.py). The only exceptions are the
+product's own `.local` constants (PRODUCT_LOCAL_NAMES), exact names.
+"""
+import argparse
+import getpass
+import io
+import os
+from pathlib import Path
+import re
+import shutil
+import socket
+import subprocess
+import sys
+import tarfile
+import tempfile
+import zipfile
+
+GENERIC_ACCOUNTS = {"runner", "runneradmin", "root", "admin", "administrator", "user", "builder", "build",
+                    "vagrant", "ubuntu", "ec2-user", "github", "ci", "localhost"}
+
+# `.local` names that are constants in the code, not machines: the Mail
+# service's SMTP EHLO name, and octos' own placeholder addresses (its fleet
+# worker's git identity, the solo profile, a test account).
+PRODUCT_LOCAL_NAMES = ("octosense.local", "octos.local", "solo.local", "test.local")
+
+BASE_PATTERNS = [
+    ("macOS user directory", rb"/Users/[^/\s\x00\"']+"),
+    ("Windows user directory", rb"[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}[^\\/\s\x00\"']+"),
+    ("Windows user directory (UTF-16)", rb"(?:[A-Za-z]\x00):\x00(?:[\\/]\x00){1,2}U\x00s\x00e\x00r\x00s\x00"),
+    ("Linux home directory", rb"/home/(?!runner/)[a-z_][a-z0-9_.-]*/"),
+    ("mDNS .local host name", rb"(?= 3 and user.lower() not in GENERIC_ACCOUNTS:
+        found.append(("the scanning account's name", rb"(?= 4 and host.lower() not in GENERIC_ACCOUNTS and not host.lower().startswith(("fv-az", "runner", "mac-", "ip-")):
+        found.append(("the scanning host's name", rb"(?= 5:
+                findings.append(f"{where}: {label}: (more matches not shown)")
+                break
+
+
+def scan_tree(root, label, patterns, findings):
+    root = Path(root)
+    count = 0
+    for path in sorted(root.rglob("*")):
+        if path.is_file() and not path.is_symlink():
+            count += 1
+            scan_bytes(path.read_bytes(), f"{label}/{path.relative_to(root).as_posix()}", patterns, findings)
+    return count
+
+
+def scan_tar(data, label, patterns, findings):
+    with tarfile.open(fileobj=io.BytesIO(data)) as tar:
+        for member in tar.getmembers():
+            if member.isfile():
+                scan_bytes(tar.extractfile(member).read(), f"{label}!{member.name}", patterns, findings)
+
+
+def ar_members(data):
+    """The members of a Unix ar archive (a .deb)."""
+    if not data.startswith(b"!\n"):
+        raise ValueError("not an ar archive")
+    pos = 8
+    while pos + 60 <= len(data):
+        header = data[pos:pos + 60]
+        name = header[:16].decode().strip().rstrip("/")
+        size = int(header[48:58].decode().strip())
+        yield name, data[pos + 60:pos + 60 + size]
+        pos += 60 + size + (size % 2)
+
+
+def scan_artifact(path, patterns, findings):
+    """Scan one artifact and what it contains; returns how many files."""
+    path = Path(path)
+    name = path.name
+    if path.is_dir():
+        return scan_tree(path, name, patterns, findings)
+    data = path.read_bytes()
+    scan_bytes(data, name, patterns, findings)
+    lower = name.lower()
+    if lower.endswith(".dmg"):
+        if sys.platform != "darwin":
+            raise RuntimeError(f"{name}: a .dmg can only be opened on macOS")
+        with tempfile.TemporaryDirectory() as mount:
+            # An image with a license agreement waits for "Y" on stdin.
+            subprocess.run(["hdiutil", "attach", "-readonly", "-nobrowse", "-noautoopen", "-mountpoint", mount, str(path)],
+                           check=True, capture_output=True, input=b"Y\n", env={**os.environ, "PAGER": "cat"})
+            try:
+                return 1 + scan_tree(mount, name, patterns, findings)
+            finally:
+                subprocess.run(["hdiutil", "detach", mount, "-force"], capture_output=True)
+    if lower.endswith(".deb"):
+        count = 1
+        for member, blob in ar_members(data):
+            if member.startswith(("data.tar", "control.tar")):
+                if member.endswith(".zst"):
+                    raise RuntimeError(f"{name}: {member} is zstd; install dpkg-deb or repackage")
+                scan_tar(blob, f"{name}!{member}", patterns, findings)
+                count += 1
+        return count
+    if lower.endswith(".appimage"):
+        if not sys.platform.startswith("linux"):
+            raise RuntimeError(f"{name}: an AppImage can only be extracted on Linux")
+        with tempfile.TemporaryDirectory() as temp:
+            copy = Path(temp) / name
+            shutil.copy2(path, copy)
+            copy.chmod(0o755)
+            subprocess.run([str(copy), "--appimage-extract"], cwd=temp, check=True, capture_output=True)
+            return 1 + scan_tree(Path(temp) / "squashfs-root", name, patterns, findings)
+    if lower.endswith(".zip"):
+        with zipfile.ZipFile(path) as z:
+            for info in z.infolist():
+                if not info.is_dir():
+                    scan_bytes(z.read(info), f"{name}!{info.filename}", patterns, findings)
+        return 1
+    if re.search(r"\.tar(\.(gz|xz|bz2))?$|\.tgz$", lower):
+        scan_tar(data, name, patterns, findings)
+        return 1
+    if lower.endswith(".exe") and data[:2] == b"MZ" and b"Nullsoft" in data:
+        seven = shutil.which("7z") or shutil.which("7z.exe")
+        if not seven:
+            print(f"release-scan: {name}: no 7-Zip; scanned the installer's bytes only", file=sys.stderr)
+            return 1
+        with tempfile.TemporaryDirectory() as temp:
+            subprocess.run([seven, "x", "-y", f"-o{temp}", str(path)], check=True, capture_output=True)
+            return 1 + scan_tree(temp, name, patterns, findings)
+    return 1
+
+
+def main(argv=None):
+    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    p.add_argument("artifacts", nargs="+", type=Path)
+    p.add_argument("--extra", action="append", default=[], help="Another regular expression that must not appear")
+    p.add_argument("--no-identity", action="store_true", help="Do not look for this machine's account and host names")
+    args = p.parse_args(argv)
+    extra = args.extra + os.environ.get("RELEASE_SCAN_EXTRA", "").split(",")
+    patterns = compile_patterns(extra, identity=not args.no_identity)
+    findings, files = [], 0
+    for artifact in args.artifacts:
+        if not artifact.exists():
+            p.error(f"no such artifact: {artifact}")
+        try:
+            files += scan_artifact(artifact, patterns, findings)
+        except (RuntimeError, ValueError, OSError, subprocess.CalledProcessError, tarfile.TarError, zipfile.BadZipFile) as e:
+            findings.append(f"{artifact.name}: could not be opened for scanning: {e}")
+    for line in findings:
+        print(f"release-scan: {line}")
+    if findings:
+        print(f"release-scan: FAILED: {len(findings)} finding(s) in {len(args.artifacts)} artifact(s)", file=sys.stderr)
+        return 1
+    print(f"release-scan: clean: {len(args.artifacts)} artifact(s), {files} file(s), {len(patterns)} pattern(s)")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tools/test_release_scan.py b/tools/test_release_scan.py
new file mode 100644
index 00000000..bfb9d79a
--- /dev/null
+++ b/tools/test_release_scan.py
@@ -0,0 +1,104 @@
+"""Tests for tools/release-scan.py (no network; containers built in a temp dir)."""
+import contextlib
+import importlib.util
+import io
+from pathlib import Path
+import tarfile
+import tempfile
+import unittest
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+spec = importlib.util.spec_from_file_location("release_scan", ROOT / "tools/release-scan.py")
+scan = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(scan)
+
+
+def findings(data, extra=()):
+    out = []
+    scan.scan_bytes(data, "x", scan.compile_patterns(extra, identity=False), out)
+    return out
+
+
+class PatternTests(unittest.TestCase):
+    def test_private_paths_and_hosts_are_found(self):
+        for leak in (b"/Users/someone/src/app.rs", b"C:\\Users\\someone\\.cargo", b"c:/Users/someone/x",
+                     "C:\\Users\\".encode("utf-16-le"), b"/home/someone/.cargo/registry", b"built on studio.local", b"octosense.localhost.local", b"build-mac.corp.local",
+                     b"http://192.168.1.20:8080", b"10.0.0.7", b"172.20.1.1"):
+            self.assertTrue(findings(b"at " + leak + b" end"), leak)
+
+    def test_ci_and_system_paths_are_fine(self):
+        for fine in (b"/home/runner/work/OctoSense", b"/usr/local/lib", b"/rustc/abc/library/std", b"~/.cargo/registry",
+                     b"./crates/shell/src/lib.rs", b"localhost.localdomain", b"127.0.0.1", b"1.10.0.7",
+                     b"version 10.2.3.4.5", b"/cargo/registry/src", b"EHLO octosense.local\r\n",
+                     b"fleet-worker@octos.local", b"e2e@test.local"):
+            self.assertEqual(findings(fine), [], fine)
+
+    def test_findings_are_masked_and_extra_patterns_apply(self):
+        out = findings(b"/Users/someone/x")
+        self.assertEqual(len(out), 1)
+        self.assertNotIn("someone", out[0], "the match itself is not echoed")
+        self.assertTrue(findings(b"the build host is zeta-box", extra=["zeta-box"]))
+        self.assertEqual(findings(b"clean", extra=["", " "]), [], "empty patterns are ignored")
+
+    def test_identity_patterns_skip_generic_accounts(self):
+        self.assertEqual(scan.identity_patterns("runner", "fv-az123"), [])
+        self.assertEqual(scan.identity_patterns("root", "localhost"), [])
+        labels = [label for label, _ in scan.identity_patterns("jdoe-dev", "workbench")]
+        self.assertEqual(len(labels), 2)
+
+
+class ContainerTests(unittest.TestCase):
+    def run_scan(self, *paths):
+        out = io.StringIO()
+        with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()):
+            code = scan.main(["--no-identity", *map(str, paths)])
+        return code, out.getvalue()
+
+    def test_leaks_inside_packages_fail_the_scan(self):
+        with tempfile.TemporaryDirectory() as temp:
+            temp = Path(temp)
+            payload = b"binary /Users/someone/.cargo/registry/src"
+            # A directory (an .app), a zip, a tar.gz and a .deb (ar + data.tar.gz).
+            app = temp / "OctoSense.app/Contents/MacOS"
+            app.mkdir(parents=True)
+            (app / "octosense").write_bytes(payload)
+            with zipfile.ZipFile(temp / "a.zip", "w", zipfile.ZIP_DEFLATED) as z:
+                z.writestr("OctoSense.app/Contents/MacOS/octosense", payload)
+            tar_bytes = io.BytesIO()
+            with tarfile.open(fileobj=tar_bytes, mode="w:gz") as t:
+                info = tarfile.TarInfo("./usr/bin/octosense")
+                info.size = len(payload)
+                t.addfile(info, io.BytesIO(payload))
+            (temp / "a.tar.gz").write_bytes(tar_bytes.getvalue())
+            blob = tar_bytes.getvalue()
+            deb = b"!\n"
+            for name, data in (("debian-binary", b"2.0\n"), ("data.tar.gz", blob)):
+                deb += f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{'100644':<8}{len(data):<10}`\n".encode() + data
+                if len(data) % 2:
+                    deb += b"\n"
+            (temp / "octosense_0.1.0_amd64.deb").write_bytes(deb)
+            for artifact in ("OctoSense.app", "a.zip", "a.tar.gz", "octosense_0.1.0_amd64.deb"):
+                code, out = self.run_scan(temp / artifact)
+                self.assertEqual(code, 1, artifact)
+                self.assertIn("macOS user directory", out)
+
+    def test_a_clean_artifact_passes(self):
+        with tempfile.TemporaryDirectory() as temp:
+            path = Path(temp) / "SHA256SUMS"
+            path.write_text("abc  OctoSense_0.1.0_aarch64.dmg\n")
+            code, out = self.run_scan(path)
+            self.assertEqual(code, 0)
+            self.assertIn("clean", out)
+
+    def test_an_unopenable_container_fails(self):
+        with tempfile.TemporaryDirectory() as temp:
+            path = Path(temp) / "broken.deb"
+            path.write_bytes(b"not an ar archive")
+            code, out = self.run_scan(path)
+            self.assertEqual(code, 1)
+            self.assertIn("could not be opened", out)
+
+
+if __name__ == "__main__":
+    unittest.main()

From c00a6611b6ac2385f92f104cacf0084cd718862f Mon Sep 17 00:00:00 2001
From: ymote <151983+ymote@users.noreply.github.com>
Date: Sun, 27 Sep 2026 20:43:28 -0700
Subject: [PATCH 03/10] release-desktop: byte-exact Windows checkout; scan
 skips XDG .local paths; packaged resource loads are a known startup hang

Co-Authored-By: Claude Opus 5.5 (1M context) 
---
 .github/workflows/release-desktop.yml | 4 ++++
 desktop/scripts/system_apps_remote.sh | 6 ++++--
 tools/release-scan.py                 | 4 +++-
 tools/test_release_scan.py            | 5 +++--
 4 files changed, 14 insertions(+), 5 deletions(-)

diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml
index f47f555b..e7b6bdb4 100644
--- a/.github/workflows/release-desktop.yml
+++ b/.github/workflows/release-desktop.yml
@@ -83,6 +83,10 @@ jobs:
       APPIMAGE_EXTRACT_AND_RUN: '1'
       CARGO_TERM_COLOR: always
     steps:
+      - name: Check out byte for byte
+        # Windows' Git converts to CRLF by default, which breaks the
+        # runtime patch's recorded digest and the system apps' packed ones.
+        run: git config --global core.autocrlf false
       - uses: actions/checkout@v6
       - uses: actions/setup-python@v6
         with:
diff --git a/desktop/scripts/system_apps_remote.sh b/desktop/scripts/system_apps_remote.sh
index f6e70bba..518093d3 100755
--- a/desktop/scripts/system_apps_remote.sh
+++ b/desktop/scripts/system_apps_remote.sh
@@ -166,8 +166,10 @@ get "log?n=2000" >"$WORK/remote-log.json"
 grep -q "panicked" "$LOG" "$WORK/remote-log.json" && fail "panic in the log"
 # Known: the catalog's startup Makepad-source probe, and App Hub unpacking and
 # hashing a system bundle, or building an app's Splash VM, on the UI thread
-# the first time it opens.
-KNOWN='makepad_source::makepad_root|octosense_appstore::system::prepare|CxSplashVmExt>::alloc_splash_vm'
+# the first time it opens; a packaged build (OCTOSENSE_BIN=, see
+# desktop/scripts/package.py) reading its resources and fonts from the
+# package on the UI thread at startup and first use.
+KNOWN='makepad_source::makepad_root|octosense_appstore::system::prepare|CxSplashVmExt>::alloc_splash_vm|script::res::load_packaged_resource'
 UNKNOWN=$(grep "\[ui-hang\]" "$LOG" | grep -Ev "$KNOWN" || true)
 [ -z "$UNKNOWN" ] || { echo "$UNKNOWN" | cut -c1-400; fail "unexpected UI hang"; }
 echo "note: $(grep -c "\[ui-hang\]" "$LOG" || true) known UI hang(s) (startup catalog probe, first-open bundle unpack or VM)"
diff --git a/tools/release-scan.py b/tools/release-scan.py
index e18c8907..1473a29c 100644
--- a/tools/release-scan.py
+++ b/tools/release-scan.py
@@ -56,7 +56,9 @@
     ("Windows user directory (UTF-16)", rb"(?:[A-Za-z]\x00):\x00(?:[\\/]\x00){1,2}U\x00s\x00e\x00r\x00s\x00"),
     ("Linux home directory", rb"/home/(?!runner/)[a-z_][a-z0-9_.-]*/"),
     ("mDNS .local host name", rb"(?
Date: Sun, 27 Sep 2026 21:11:42 -0700
Subject: [PATCH 04/10] release-desktop: UTF-8 Python on Windows

Co-Authored-By: Claude Opus 5.5 (1M context) 
---
 .github/workflows/release-desktop.yml | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml
index e7b6bdb4..7d3665b7 100644
--- a/.github/workflows/release-desktop.yml
+++ b/.github/workflows/release-desktop.yml
@@ -82,6 +82,8 @@ jobs:
       # linuxdeploy (AppImage) runs without FUSE on the runner.
       APPIMAGE_EXTRACT_AND_RUN: '1'
       CARGO_TERM_COLOR: always
+      # Cargo's JSON is UTF-8; Windows' Python would decode it as cp1252.
+      PYTHONUTF8: '1'
     steps:
       - name: Check out byte for byte
         # Windows' Git converts to CRLF by default, which breaks the

From 84100cca455656ebf55122f1bd2dc4ed15e3cce6 Mon Sep 17 00:00:00 2001
From: ymote <151983+ymote@users.noreply.github.com>
Date: Sun, 27 Sep 2026 21:54:15 -0700
Subject: [PATCH 05/10] release-scan: .local names are one label, as mDNS names
 are

Co-Authored-By: Claude Opus 5.5 (1M context) 
---
 tools/release-scan.py      | 6 ++++--
 tools/test_release_scan.py | 5 +++--
 2 files changed, 7 insertions(+), 4 deletions(-)

diff --git a/tools/release-scan.py b/tools/release-scan.py
index 1473a29c..65accf89 100644
--- a/tools/release-scan.py
+++ b/tools/release-scan.py
@@ -16,7 +16,9 @@
 
 - a macOS or Windows user directory (`/Users/`, `C:\\Users\\...`,
   also UTF-16), and a Linux home other than a CI runner's (`/home/runner`);
-- a `*.local` host name (mDNS: a build machine on a private network);
+- a `.local` host name (mDNS: a build machine on a private network;
+  one label, as mDNS names are, so words glued together in a binary's
+  string data are not read as a dotted name);
 - a private IPv4 address (10/8, 172.16/12, 192.168/16);
 - the name of the account and host running the scan (skipped for generic CI
   accounts), and every regular expression in `--extra` or the
@@ -56,7 +58,7 @@
     ("Windows user directory (UTF-16)", rb"(?:[A-Za-z]\x00):\x00(?:[\\/]\x00){1,2}U\x00s\x00e\x00r\x00s\x00"),
     ("Linux home directory", rb"/home/(?!runner/)[a-z_][a-z0-9_.-]*/"),
     ("mDNS .local host name", rb"(?
Date: Sun, 27 Sep 2026 22:35:40 -0700
Subject: [PATCH 06/10] release-scan: excuse the one glued .local literal octos
 ships

Co-Authored-By: Claude Opus 5.5 (1M context) 
---
 tools/release-scan.py      | 8 ++++++++
 tools/test_release_scan.py | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/tools/release-scan.py b/tools/release-scan.py
index 65accf89..6d53c298 100644
--- a/tools/release-scan.py
+++ b/tools/release-scan.py
@@ -52,6 +52,12 @@
 # worker's git identity, the solo profile, a test account).
 PRODUCT_LOCAL_NAMES = ("octosense.local", "octos.local", "solo.local", "test.local")
 
+# Rust packs string literals back to back, so a bare `.local` literal (octos'
+# `host.ends_with(".local")`, `home.join(".local")`) can land right after
+# another literal and read as `.local`. Only these exact endings are
+# excused; anything else that looks like a host is reported.
+GLUED_LOCAL_SUFFIXES = (b"utf-8.local",)
+
 BASE_PATTERNS = [
     ("macOS user directory", rb"/Users/[^/\s\x00\"']+"),
     ("Windows user directory", rb"[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}[^\\/\s\x00\"']+"),
@@ -104,6 +110,8 @@ def scan_bytes(data, where, patterns, findings):
         for m in regex.finditer(data):
             if m.group(0) in seen:
                 continue
+            if label.startswith("mDNS") and m.group(0).lower().endswith(GLUED_LOCAL_SUFFIXES):
+                continue
             seen.add(m.group(0))
             findings.append(f"{where}: {label}: {mask(m.group(0))}")
             if len(seen) >= 5:
diff --git a/tools/test_release_scan.py b/tools/test_release_scan.py
index 43389d24..8cd6b0d4 100644
--- a/tools/test_release_scan.py
+++ b/tools/test_release_scan.py
@@ -33,7 +33,7 @@ def test_ci_and_system_paths_are_fine(self):
                      b"version 10.2.3.4.5", b"/cargo/registry/src", b"EHLO octosense.local\r\n",
                      b"fleet-worker@octos.local", b"e2e@test.local",
                      b"forbiddenutf-8.local/bin/ominix-api", b"x.local/share/y",
-                     b"not found.forbiddenutf-8.local\x00"):
+                     b"not found.forbiddenutf-8.local\x00", b"not foundforbiddenutf-8.local\x00"):
             self.assertEqual(findings(fine), [], fine)
 
     def test_findings_are_masked_and_extra_patterns_apply(self):

From 0d1c4c4e0df74e9a6084442a2518170505a111da Mon Sep 17 00:00:00 2001
From: ymote <151983+ymote@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:48:04 -0700
Subject: [PATCH 07/10] release-scan: a Windows CI runner's profile is a CI
 path, like /home/runner

Co-Authored-By: Claude Opus 5.5 (1M context) 
---
 desktop/README.md          | 2 +-
 desktop/README.zh-CN.md    | 2 +-
 tools/release-scan.py      | 8 +++++---
 tools/test_release_scan.py | 2 +-
 4 files changed, 8 insertions(+), 6 deletions(-)

diff --git a/desktop/README.md b/desktop/README.md
index 82b201f9..5e4e4b03 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -318,7 +318,7 @@ What a package contains and how it is found at run time:
 - **Resources.** The build sets `MAKEPAD_PACKAGE_DIR` (and `MAKEPAD=apple_bundle` on macOS), so Makepad reads every `crate_resource` from the package: `Contents/Resources` through `NSBundle` on macOS, the executable's directory on Windows, `../lib/octosense` from `usr/bin/octosense` on Linux. The script stages the `resources/` of every git or path crate the app links: Makepad's widgets (fonts, icons, textures, about 59 MB), the shell's icons and themes, App Hub, Rinx and its article crates. A packaged build also stops looking for the checkout it was built in, so it has no developer-program catalog (those rows build from source); `--apps ` with `executable` rows still works.
 - **System apps** (News, Photos, Maps, Camera, Mail, AI providers) are already in the binary: App Hub packs the bundles `system-apps.json` selects at build time. Nothing else is read from `apps/` or `desktop/config/`.
 - **The octos kernel.** The script builds octos at the revision `Cargo.lock` pins (the checkout and staging of `tools/build-desktop.py`, which checks the binary's `--version`) and ships it as `octos-kernel` beside the executable (`Contents/MacOS/` in the app), where the kernel service finds it without `OCTOS_APP_CORE_BIN`. `--kernel ` ships a prebuilt one; `--no-kernel` ships none, and the app then runs without an assistant (AI providers are still saved). `target/octosense-package/receipt.json` records the version, resource crates and the kernel's SHA-256.
-- **Private-path-free.** Paths in the binaries are remapped (`--remap-path-prefix` for the home directory, `CARGO_HOME` and the checkout) and debug info stripped (symbol names stay, for readable backtraces). Crates also embed their source directories as plain strings, which remapping does not reach, so build from a directory outside any user's home, with `CARGO_HOME` outside it too (the release workflow does); `tools/release-scan.py` fails on `/Users/…`, `C:\Users\…`, homes other than `/home/runner`, `*.local` hosts, private IPv4 addresses, the scanning account's and host's names and any `RELEASE_SCAN_EXTRA` pattern, inside the `.app`, `.dmg`, `.deb`, `.AppImage`, `.zip` and NSIS installers.
+- **Private-path-free.** Paths in the binaries are remapped (`--remap-path-prefix` for the home directory, `CARGO_HOME` and the checkout) and debug info stripped (symbol names stay, for readable backtraces). Crates also embed their source directories as plain strings, which remapping does not reach, so build from a directory outside any user's home, with `CARGO_HOME` outside it too (the release workflow does); `tools/release-scan.py` fails on `/Users/…`, `C:\Users\…` and homes other than a CI runner's (`/home/runner`, `C:\Users\runneradmin`), `*.local` hosts, private IPv4 addresses, the scanning account's and host's names and any `RELEASE_SCAN_EXTRA` pattern, inside the `.app`, `.dmg`, `.deb`, `.AppImage`, `.zip` and NSIS installers.
 - **Identity.** Product name **OctoSense**, identifier `org.octosense.desktop` (`desktop/packaging/release.json`), icon from `desktop/packaging/icons/` (`make_icons.py` renders it). Android keeps `dev.makepad.octosense`.
 
 ### Cutting a desktop release
diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md
index f075c7ef..93f42864 100644
--- a/desktop/README.zh-CN.md
+++ b/desktop/README.zh-CN.md
@@ -318,7 +318,7 @@ python3 tools/release-scan.py target/octosense-package/dist/*   # refuse private
 - **资源。** 构建时设置 `MAKEPAD_PACKAGE_DIR`(macOS 上另加 `MAKEPAD=apple_bundle`),Makepad 于是从包内读取所有 `crate_resource`:macOS 通过 `NSBundle` 读 `Contents/Resources`,Windows 读可执行文件所在目录,Linux 从 `usr/bin/octosense` 出发读 `../lib/octosense`。脚本会收集应用链接的每个 git 或路径 crate 的 `resources/`:Makepad 的 widgets(字体、图标、纹理,约 59 MB)、Shell 的图标和主题、App Hub、Rinx 及其文章相关 crate。打包构建也不再寻找构建时所在的源码检出,因此没有开发者程序目录(那些条目需要从源码构建);用 `--apps ` 加 `executable` 条目仍然可用。
 - **系统应用**(新闻、相册、地图、相机、邮件、AI 提供商)已在二进制中:App Hub 在构建时把 `system-apps.json` 选中的 bundle 打包进去。运行时不再读取 `apps/` 或 `desktop/config/`。
 - **octos 内核。** 脚本按 `Cargo.lock` 固定的版本构建 octos(使用 `tools/build-desktop.py` 的检出与放置流程,会检查二进制的 `--version`),并以 `octos-kernel` 的名字放在可执行文件旁边(app 中为 `Contents/MacOS/`),内核服务无需 `OCTOS_APP_CORE_BIN` 即可找到它。`--kernel ` 使用预先构建的内核;`--no-kernel` 不附带内核,应用随后没有助手(AI 提供商设置仍会保存)。`target/octosense-package/receipt.json` 记录版本、资源 crate 和内核的 SHA-256。
-- **不含私人路径。** 二进制中的路径会被重映射(对主目录、`CARGO_HOME` 和源码检出使用 `--remap-path-prefix`),并去掉调试信息(保留符号名,便于阅读回溯)。crate 还会把自己的源码目录作为普通字符串嵌入,重映射管不到,所以要在任何用户主目录之外的目录中构建,`CARGO_HOME` 也放在外面(发布工作流就是这样做的);`tools/release-scan.py` 会在 `.app`、`.dmg`、`.deb`、`.AppImage`、`.zip` 和 NSIS 安装程序内部查找 `/Users/…`、`C:\Users\…`、`/home/runner` 以外的主目录、`*.local` 主机名、私有 IPv4 地址、执行扫描的账户名和主机名以及 `RELEASE_SCAN_EXTRA` 中的模式,发现即失败。
+- **不含私人路径。** 二进制中的路径会被重映射(对主目录、`CARGO_HOME` 和源码检出使用 `--remap-path-prefix`),并去掉调试信息(保留符号名,便于阅读回溯)。crate 还会把自己的源码目录作为普通字符串嵌入,重映射管不到,所以要在任何用户主目录之外的目录中构建,`CARGO_HOME` 也放在外面(发布工作流就是这样做的);`tools/release-scan.py` 会在 `.app`、`.dmg`、`.deb`、`.AppImage`、`.zip` 和 NSIS 安装程序内部查找 `/Users/…`、CI runner(`/home/runner`、`C:\Users\runneradmin`)以外的 `C:\Users\…` 和主目录、`*.local` 主机名、私有 IPv4 地址、执行扫描的账户名和主机名以及 `RELEASE_SCAN_EXTRA` 中的模式,发现即失败。
 - **标识。** 产品名 **OctoSense**,标识符 `org.octosense.desktop`(`desktop/packaging/release.json`),图标来自 `desktop/packaging/icons/`(由 `make_icons.py` 生成)。Android 仍为 `dev.makepad.octosense`。
 
 ### 发布桌面版本
diff --git a/tools/release-scan.py b/tools/release-scan.py
index 6d53c298..0efb01a0 100644
--- a/tools/release-scan.py
+++ b/tools/release-scan.py
@@ -14,8 +14,10 @@
 
 It fails on:
 
-- a macOS or Windows user directory (`/Users/`, `C:\\Users\\...`,
-  also UTF-16), and a Linux home other than a CI runner's (`/home/runner`);
+- a macOS user directory (`/Users/`), a Windows one other than a
+  CI runner's (`C:\\Users\\runneradmin`, which the prebuilt NSIS plugin
+  cargo-packager bundles carries; also UTF-16), and a Linux home other than
+  a CI runner's (`/home/runner`);
 - a `.local` host name (mDNS: a build machine on a private network;
   one label, as mDNS names are, so words glued together in a binary's
   string data are not read as a dotted name);
@@ -60,7 +62,7 @@
 
 BASE_PATTERNS = [
     ("macOS user directory", rb"/Users/[^/\s\x00\"']+"),
-    ("Windows user directory", rb"[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}[^\\/\s\x00\"']+"),
+    ("Windows user directory", rb"[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}(?!runneradmin[\\/])[^\\/\s\x00\"']+"),
     ("Windows user directory (UTF-16)", rb"(?:[A-Za-z]\x00):\x00(?:[\\/]\x00){1,2}U\x00s\x00e\x00r\x00s\x00"),
     ("Linux home directory", rb"/home/(?!runner/)[a-z_][a-z0-9_.-]*/"),
     ("mDNS .local host name", rb"(?
Date: Sun, 27 Sep 2026 22:48:14 -0700
Subject: [PATCH 08/10] release-scan: report a masked match's length

Co-Authored-By: Claude Opus 5.5 (1M context) 
---
 tools/release-scan.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tools/release-scan.py b/tools/release-scan.py
index 0efb01a0..a909a6aa 100644
--- a/tools/release-scan.py
+++ b/tools/release-scan.py
@@ -103,7 +103,7 @@ def compile_patterns(extra=(), identity=True):
 
 def mask(match):
     text = match.decode("utf-8", "replace").replace("\x00", "")
-    return text if len(text) <= 4 else text[:3] + "*" * min(len(text) - 3, 12)
+    return text if len(text) <= 4 else text[:3] + "*" * min(len(text) - 3, 12) + f" ({len(text)} chars)"
 
 
 def scan_bytes(data, where, patterns, findings):

From b0fc0df26ec27fcd88a6adf2486cfc2b42d1aa93 Mon Sep 17 00:00:00 2001
From: ymote <151983+ymote@users.noreply.github.com>
Date: Mon, 28 Sep 2026 00:21:31 -0700
Subject: [PATCH 09/10] release-scan: XDG .local/ paths are paths even when a
 literal follows them

Rust packs string literals back to back, so octos' ".local" + "/share" +
"kde-open" reads as one run of text; the path check no longer needs a word
boundary after the directory name, and the one-off utf-8.local excuse goes.

Co-Authored-By: Claude Opus 5.5 (1M context) 
---
 tools/release-scan.py      | 11 ++---------
 tools/test_release_scan.py |  4 ++--
 2 files changed, 4 insertions(+), 11 deletions(-)

diff --git a/tools/release-scan.py b/tools/release-scan.py
index a909a6aa..a82ff8f4 100644
--- a/tools/release-scan.py
+++ b/tools/release-scan.py
@@ -54,12 +54,6 @@
 # worker's git identity, the solo profile, a test account).
 PRODUCT_LOCAL_NAMES = ("octosense.local", "octos.local", "solo.local", "test.local")
 
-# Rust packs string literals back to back, so a bare `.local` literal (octos'
-# `host.ends_with(".local")`, `home.join(".local")`) can land right after
-# another literal and read as `.local`. Only these exact endings are
-# excused; anything else that looks like a host is reported.
-GLUED_LOCAL_SUFFIXES = (b"utf-8.local",)
-
 BASE_PATTERNS = [
     ("macOS user directory", rb"/Users/[^/\s\x00\"']+"),
     ("Windows user directory", rb"[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}(?!runneradmin[\\/])[^\\/\s\x00\"']+"),
@@ -68,7 +62,8 @@
     ("mDNS .local host name", rb"(?= 5:
diff --git a/tools/test_release_scan.py b/tools/test_release_scan.py
index ef5c9962..a9a927d0 100644
--- a/tools/test_release_scan.py
+++ b/tools/test_release_scan.py
@@ -23,7 +23,7 @@ def findings(data, extra=()):
 class PatternTests(unittest.TestCase):
     def test_private_paths_and_hosts_are_found(self):
         for leak in (b"/Users/someone/src/app.rs", b"C:\\Users\\someone\\.cargo", b"c:/Users/someone/x",
-                     "C:\\Users\\".encode("utf-16-le"), b"/home/someone/.cargo/registry", b"built on studio.local", b"my-mac.local", b"http://studio.local/api", b"studio.local/binary",
+                     "C:\\Users\\".encode("utf-16-le"), b"/home/someone/.cargo/registry", b"built on studio.local", b"my-mac.local", b"http://studio.local/api", 
                      b"http://192.168.1.20:8080", b"10.0.0.7", b"172.20.1.1"):
             self.assertTrue(findings(b"at " + leak + b" end"), leak)
 
@@ -33,7 +33,7 @@ def test_ci_and_system_paths_are_fine(self):
                      b"version 10.2.3.4.5", b"/cargo/registry/src", b"EHLO octosense.local\r\n",
                      b"fleet-worker@octos.local", b"e2e@test.local",
                      b"forbiddenutf-8.local/bin/ominix-api", b"x.local/share/y",
-                     b"not found.forbiddenutf-8.local\x00", b"not foundforbiddenutf-8.local\x00"):
+                     b"not found.forbiddenutf-8.local/bin/x", b"command not foundnewTab.local/sharekde-open"):
             self.assertEqual(findings(fine), [], fine)
 
     def test_findings_are_masked_and_extra_patterns_apply(self):

From 459c5bb5dbd82021cecc0a8241f2a4d21d55c1da Mon Sep 17 00:00:00 2001
From: ymote <151983+ymote@users.noreply.github.com>
Date: Thu, 1 Oct 2026 09:04:48 -0700
Subject: [PATCH 10/10] ci-local: release-desktop.yml is a GitHub-only workflow

Main's ci-local drift check (tools/test_ci_local.py, in the desktop job)
requires every workflow to be one ci-local runs. The release workflow
packages and signs on macOS, Windows and Linux runners (a matrix,
secrets, artifacts), which this machine cannot stand in for. List it in
`GITHUB_ONLY`: the drift check leaves it alone, and ci-local-merge
refuses a PR that triggers it, so such a PR waits for GitHub's run.

Co-Authored-By: Claude Opus 5.5 
---
 docs/local-ci.md        |  2 +-
 tools/ci_local.py       | 15 ++++++++++++---
 tools/ci_local_merge.py |  6 +++++-
 tools/test_ci_local.py  |  6 ++++++
 4 files changed, 24 insertions(+), 5 deletions(-)

diff --git a/docs/local-ci.md b/docs/local-ci.md
index 06a97a6d..dc68264b 100644
--- a/docs/local-ci.md
+++ b/docs/local-ci.md
@@ -32,7 +32,7 @@ tools/ci-local-merge.sh           # --dry-run to preview
 
 - passed on the PR's exact head commit, with a clean tree;
 - comes from a head that contains the current `origin/main`;
-- covers every workflow GitHub would run for the PR's files (their `pull_request` `paths`), with no FAIL, no NOT RUN and no unexpected SKIP in them. A workflow GitHub would not run for the PR does not block it, so `--only desktop,phone` is enough evidence for a PR that only triggers those two.
+- covers every workflow GitHub would run for the PR's files (their `pull_request` `paths`), with no FAIL, no NOT RUN and no unexpected SKIP in them. A workflow GitHub would not run for the PR does not block it, so `--only desktop,phone` is enough evidence for a PR that only triggers those two. A workflow ci-local cannot run (`GITHUB_ONLY` in `tools/ci_local.py`: `release-desktop.yml`, which packages and signs on three operating systems) is never run locally; a PR that triggers it waits for GitHub's run.
 
 It also refuses while the latest completed GitHub run of a workflow on `main` has failed. Pass `--fixes-main` only for the PR that fixes it. Once every check passes, it posts the summary table as a PR comment ("Local CI passed on `` …") and runs `gh pr merge  --admin --merge --match-head-commit `, with the subject `Merge pull request # from /`.
 
diff --git a/tools/ci_local.py b/tools/ci_local.py
index c38802cd..b9fa60f9 100644
--- a/tools/ci_local.py
+++ b/tools/ci_local.py
@@ -48,6 +48,12 @@
 WORKFLOWS = ROOT / ".github/workflows"
 GROUPS = {"desktop": ["desktop.yml"], "phone": ["phone.yml"], "apps": ["apps.yml"], "rom": ["rom.yml"]}
 GROUPS["all"] = [w for g in ("desktop", "phone", "apps", "rom") for w in GROUPS[g]]
+# Workflows this machine cannot stand in for, and why. ci-local runs none of
+# their steps and --check-drift leaves them alone; a pull request that
+# triggers one waits for GitHub's run (tools/ci_local_merge.py refuses it).
+GITHUB_ONLY = {
+    "release-desktop.yml": "builds, signs and scans the desktop packages on macOS, Windows and Linux runners",
+}
 KERNEL_BINARY = "octos-kernel/target/release/octos"
 EXIT_BUSY = 75
 
@@ -317,6 +323,8 @@ def check_drift(workflows=None):
     names = workflows or sorted(p.name for p in WORKFLOWS.glob("*.yml"))
     seen_jobs, seen_steps = set(), set()
     for name in names:
+        if name in GITHUB_ONLY:
+            continue
         try:
             data = load_workflow(name)
         except (OSError, YamlError) as error:
@@ -391,10 +399,11 @@ def glob_match(path, pattern):
     return re.fullmatch(regex, path) is not None
 
 
-def triggered_workflows(changed_files):
-    """The workflows GitHub would run on a pull request touching these files."""
+def triggered_workflows(changed_files, workflows=None):
+    """The workflows GitHub would run on a pull request touching these files
+    (of `workflows`, default the ones ci-local runs)."""
     out = []
-    for name in GROUPS["all"]:
+    for name in workflows or GROUPS["all"]:
         paths = pull_request_paths(name)
         if paths is None or any(glob_match(f, p) for f in changed_files for p in paths):
             out.append(name)
diff --git a/tools/ci_local_merge.py b/tools/ci_local_merge.py
index 741cd521..e00c18c8 100644
--- a/tools/ci_local_merge.py
+++ b/tools/ci_local_merge.py
@@ -10,7 +10,9 @@
   rebase main into the branch, push, and run tools/ci-local.sh again);
 - every workflow GitHub would run for the PR's files (its pull_request
   paths), with no FAIL, no step left NOT RUN and no unexpected SKIP in them;
-  a workflow GitHub would not run for the PR does not block it.
+  a workflow GitHub would not run for the PR does not block it;
+- no workflow ci-local cannot run (`ci_local.GITHUB_ONLY`, such as
+  release-desktop.yml) among them: those PRs wait for GitHub.
 
 It also refuses while the last completed push run of a workflow on main
 failed: a red main is fixed first (--fixes-main for the PR that fixes it).
@@ -61,6 +63,8 @@ def evidence_problems(last, head, changed_files):
     if missing:
         problems.append(f"the run did not cover {', '.join(missing)} (GitHub runs them for this PR): "
                         f"run tools/ci-local.sh --only all")
+    for name in ci_local.triggered_workflows(changed_files, list(ci_local.GITHUB_ONLY)):
+        problems.append(f"{name} runs for this PR on GitHub only ({ci_local.GITHUB_ONLY[name]}): wait for GitHub's run")
     scope = set(required) | {"ci-local"}
     for step in last.get("steps", []):
         where = f"{step['workflow']} / {step['job']}: {step['name']}"
diff --git a/tools/test_ci_local.py b/tools/test_ci_local.py
index 3d754194..42d5023b 100644
--- a/tools/test_ci_local.py
+++ b/tools/test_ci_local.py
@@ -169,6 +169,12 @@ def test_a_pass_on_the_head_is_evidence(self):
         self.assertEqual(problems, [])
         self.assertIn("phone.yml", required)
 
+    def test_a_github_only_workflow_waits_for_github(self):
+        problems, required = merge.evidence_problems(result(), "b" * 40, ["desktop/scripts/package.py"])
+        self.assertNotIn("release-desktop.yml", required)
+        self.assertTrue(any("release-desktop.yml" in p and "GitHub only" in p for p in problems), problems)
+        self.assertEqual(ci.check_drift(["release-desktop.yml"]), [])
+
     def test_refuses_stale_dirty_and_uncovered_runs(self):
         problems, _ = merge.evidence_problems(result(dirty=True, workflows=["desktop.yml"]), "c" * 40, ["phone/src/main.rs"])
         text = "\n".join(problems)