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..7d3665b7 --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,270 @@ +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 + # 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 + # 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: + 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 3e24d40a..10a9ac12 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 @@ -263,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 3af1c45e..129c1db6 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 @@ -263,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/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/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 dc1a66ed..5e4e4b03 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/` @@ -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. | @@ -56,12 +57,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). @@ -73,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 @@ -114,7 +120,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 +153,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 +231,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 @@ -291,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\…` 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 + +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.
@@ -310,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
 ```
 
@@ -336,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 5be0bf7d..93f42864 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/` 的结构
@@ -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` | 快捷键说明;待办事项。 |
@@ -56,12 +57,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 中)。
@@ -73,7 +79,7 @@ cargo run --release -p octosense
 | 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
 
@@ -114,7 +120,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 +153,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 +231,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` 固定的那一个版本。
 
 ## 演示
 
@@ -291,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/…`、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`。
+
+### 发布桌面版本
+
+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` 和下面的测试。
@@ -310,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
 ```
 
@@ -336,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 00000000..74401af8
Binary files /dev/null and b/desktop/packaging/icons/icon.ico differ
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 00000000..8711cb21
Binary files /dev/null and b/desktop/packaging/icons/icon_128.png differ
diff --git a/desktop/packaging/icons/icon_256.png b/desktop/packaging/icons/icon_256.png
new file mode 100644
index 00000000..54def551
Binary files /dev/null and b/desktop/packaging/icons/icon_256.png differ
diff --git a/desktop/packaging/icons/icon_32.png b/desktop/packaging/icons/icon_32.png
new file mode 100644
index 00000000..0cb96ace
Binary files /dev/null and b/desktop/packaging/icons/icon_32.png differ
diff --git a/desktop/packaging/icons/icon_512.png b/desktop/packaging/icons/icon_512.png
new file mode 100644
index 00000000..ad240ec7
Binary files /dev/null and b/desktop/packaging/icons/icon_512.png differ
diff --git a/desktop/packaging/icons/icon_512@2x.png b/desktop/packaging/icons/icon_512@2x.png
new file mode 100644
index 00000000..490ceea9
Binary files /dev/null and b/desktop/packaging/icons/icon_512@2x.png differ
diff --git a/desktop/packaging/icons/icon_64.png b/desktop/packaging/icons/icon_64.png
new file mode 100644
index 00000000..40ba3ab8
Binary files /dev/null and b/desktop/packaging/icons/icon_64.png differ
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/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/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/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/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/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/release-scan.py b/tools/release-scan.py
new file mode 100644
index 00000000..a82ff8f4
--- /dev/null
+++ b/tools/release-scan.py
@@ -0,0 +1,231 @@
+#!/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 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);
+- 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}(?!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"(?= 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_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()
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)
diff --git a/tools/test_release_scan.py b/tools/test_release_scan.py
new file mode 100644
index 00000000..a9a927d0
--- /dev/null
+++ b/tools/test_release_scan.py
@@ -0,0 +1,106 @@
+"""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"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)
+
+    def test_ci_and_system_paths_are_fine(self):
+        for fine in (b"/home/runner/work/OctoSense", b"C:\\Users\\runneradmin\\.cargo\\registry", 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",
+                     b"forbiddenutf-8.local/bin/ominix-api", b"x.local/share/y",
+                     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):
+        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()