# Releasing storebound desktop How the desktop app (`apps/desktop`) is built, signed, checked and published. For what the app does, see [desktop.md](desktop.md). ## Distribution - **The desktop app is its own download.** A signed app from storebound.dev/desktop/: - macOS: a DMG with a universal app (Apple silicon and Intel), macOS 11 or later; - Windows: an NSIS installer that installs for the current user and brings the WebView2 bootstrapper; - Linux: an AppImage and a .deb, built on Ubuntu 22.04. - **It updates itself** with Tauri's updater: storebound.dev/desktop/latest.json, every bundle signed with the updater key. The app checks the signature before it replaces anything and installs only after the owner clicks Install in a native dialog (Settings > Updates). - **It brings its own CLI.** The app bundles the storebound CLI as a sidecar, so it never depends on an npm install. - **The npm package stays the CLI, the MCP server and the skills.** It contains no Tauri binaries. - **Bridge: `storebound desktop`.** - Installed: it opens the app. In an app folder it sends `storebound://open?path=`, and the app selects that folder, or adds it after a native dialog. - Not installed: it prints the download for this OS and CPU. - `--download` saves the installer to ~/Downloads after checking its size, SHA-256 and minisign signature. It never installs anything. ## One version `desktopVersion` in the root `package.json` is the desktop app's version. The npm package's own `version` belongs to the CLI and moves on its own. - `bun tools/desktop-version.ts` checks the copies: `apps/desktop/package.json`, `tauri.conf.json`, `Cargo.toml` and the app's entry in `Cargo.lock`. CI runs this check. - `bun tools/desktop-version.ts --set 0.1.1` writes a new version into all of them. - `src-tauri/tests/d5.rs` checks the same thing again from the Rust side. ## Keys and private files (outside every repository) `~/.storebound-keys/` (mode 700) on the release Mac holds: | file | what | made by | |---|---|---| | `updater.key` | minisign private key of the updater (no password; mode 600) | `bunx tauri signer generate -w ~/.storebound-keys/updater.key -p "" --ci` (done once, D5) | | `updater.key.pub` | its public key; the same text is `plugins.updater.pubkey` in tauri.conf.json and `PUBLIC_KEY` in cli/lib/desktop-dist.ts | the same command | | `forbidden-terms.txt` | names the privacy scan must never find: one per line (people, accounts, emails, machine names) | the owner | | `notary.json` | optional: `{"keyId", "issuerId", "keyPath"}` of the App Store Connect API key for notarytool (or the `ASC_*` / `APPLE_API_*` variables) | the owner | **Back up `updater.key`** (password manager or an offline copy). If it is lost, installed apps can never update again: they only accept bundles signed with this key, so everyone would have to reinstall by hand. Never commit it, never copy it to a runner. The Forgejo workflow builds unsigned installers, and the Mac signs them. ## Privacy gate `bun tools/privacy-scan.ts ` runs on every artifact before it can ship. - **Terms it looks for:** - the terms file; - this machine's account name, home folder, full name, host and computer names, and git's global identity; - the private-name rule of tools/scrub.ts; - any `/Users/`, `/home/` or `C:\Users\` that is not a generic account. - **How it reads files:** as bytes, in UTF-8 and UTF-16LE. - **Archives it opens:** DMG (mounted read-only, never shown in Finder), .tar.gz, .deb, .zip, AppImage (unsquashfs or 7-Zip) and NSIS (7-Zip). - **Debug files:** .dSYM, .pdb and source maps fail the scan. - **Exit codes:** 1 when it finds something, 2 when it cannot open an archive. A term is never printed in full. **Path-free builds.** `tools/desktop-release.ts` builds with `--remap-path-prefix` and `-ffile-prefix-map`, which turn home into `~`, the checkout into `/src`, the cargo and rustup homes into `/cargo` and `/rustup`, and `CARGO_TARGET_DIR` into `/target`. On Windows it also uses `/PDBALTPATH` and `/d1trimfile`. The target folder must be outside the home folder. Release profile: `strip = true`, LTO, `panic = "abort"`. ## macOS (on the owner's Mac) ```sh bun tools/desktop-release.ts mac --out ~/.storebound-releases/desktop/0.1.0 # from a clean commit ``` **Runs in the owner's logged-in macOS session.** It refuses SSH and background sessions: over SSH `codesign` cannot use the login keychain (errSecInternalComponent). What it does, in order: 1. Checks the session, the version, a clean checkout, the updater key and that the rustup targets are installed. 2. Picks the signing identity: - **Developer ID Application found** (`security find-identity -v -p codesigning`): hardened runtime with `src-tauri/entitlements.plist` (JIT for the Bun sidecar), notarization and stapling. Tauri notarizes the app, the script notarizes the DMG. - **None found**: an ad-hoc signed build whose files end in `-unsigned`. 3. Builds the sidecars for arm64 and x64, merges them into one universal file with `lipo`, then runs `tauri build --target universal-apple-darwin --bundles app` with the path-free environment. 4. Makes the updater bundle (`.app.tar.gz`, root-owned entries, no macOS metadata) and the DMG. The DMG is made with `hdiutil` only (no Finder scripting, nothing comes to the front). 5. **Checks the exact artifacts:** - `codesign --verify --deep --strict`; - the Gatekeeper expectation (signed: `spctl` accepts it as Notarized Developer ID and `stapler validate` passes; unsigned: ad-hoc, and `spctl` rejects it); - `lipo -archs` on every executable in the bundle (each must carry arm64 and x86_64). The x86_64 slice is never run on the build Mac: that starts Rosetta, and macOS then warns the owner that the app "includes a component that will not open" in the next macOS; - the self-test (arm64, native), run from the mounted DMG and from the unpacked updater bundle. `storebound-desktop --self-test` starts the bundled sidecar, sends `session.hello`, prints one JSON line and exits, before Tauri starts, so no window opens; - the privacy scan of the app, the DMG and the updater bundle. 6. Writes minisign `.sig` files with the updater key and checks them right away against tauri.conf.json's key. 7. Writes `latest.json`, `SHA256SUMS` and `evidence-macos-.json`. The evidence file (tool versions, checks, hashes) stays local. ### Signing identity and notarization key 1. **Developer ID certificate.** A **Developer ID Application** certificate of your Apple developer team in the login keychain (the Account Holder creates it at developer.apple.com > Certificates > +, with a CSR from Keychain Access). It is installed on the release Mac since October 2026. A machine without it still builds, but only the ad-hoc `-unsigned` preview. 2. **Notarization key.** An App Store Connect API key, found in this order: - `APPLE_API_KEY`, `APPLE_API_ISSUER` and `APPLE_API_KEY_PATH`; - storebound's own `ASC_KEY_ID`, `ASC_ISSUER_ID` and `ASC_P8` (a path or the PEM; by default `~/.appstoreconnect/private_keys/AuthKey_.p8`, where Xcode keeps it); - `~/.storebound-keys/notary.json`. The key ids are never written into the repository. 3. **Build.** Run the command above in Terminal on the Mac. **Gates on the exact artifacts.** A signed release stops unless all of these pass: - every executable in `Contents/MacOS` (the app and the CLI sidecar, signed inside out by Tauri), and the bundle, is signed with Developer ID, with the hardened runtime and a secure timestamp; - no executable carries the App Sandbox entitlement (Mac App Store) or `get-task-allow`; - `spctl -a -vvv -t exec ` says Notarized Developer ID, and `xcrun stapler validate ` passes for the built app, the app inside the mounted DMG and the app inside the updater bundle; - `spctl -a -vvv -t open --context context:primary-signature ` accepts the DMG, and `xcrun stapler validate ` passes. ### Opening an unsigned preview (what the downloads page says) macOS: copy storebound to Applications and open it. When macOS refuses, open System Settings > Privacy & Security and choose Open Anyway (once). ## Windows and Linux (Forgejo Actions) `.forgejo/workflows/desktop.yml` runs on a `desktop-v*` tag or by hand (workflow_dispatch; tick "windows" for the Windows installer too: a tag push builds Linux only). - **Linux** (`runs-on: ubuntu-22.04`, a container): AppImage and .deb built with the path-free environment, then: - self-tests of the extracted AppImage and the unpacked .deb; - the privacy scan and SHA256SUMS; - an artifact `storebound-desktop-linux`. `WEBKIT_DISABLE_DMABUF_RENDERER=1`: the app sets it itself at start on Linux unless the user set it (some GPU / VM setups leave the window blank with DMABUF), and the workflow sets it too. - **Windows** (`runs-on: windows`): an NSIS installer (`installMode: currentUser`, WebView2 `embedBootstrapper`, unsigned), then: - a silent per-user install (`/S`), the self-test of the installed exe, a silent uninstall; - the privacy scan; - an artifact `storebound-desktop-windows`. Then on the Mac: ```sh bun tools/desktop-release.ts add --out ~/.storebound-releases/desktop/0.1.0 \ storebound_0.1.0_amd64.AppImage storebound_0.1.0_amd64.deb storebound_0.1.0_x64-setup.exe ``` It scans and signs each file, renames the unsigned Windows installer to `-unsigned`, and updates latest.json (`windows-x86_64`, `linux-x86_64`) and SHA256SUMS. For the scan, install `squashfs` and `sevenzip` from Homebrew. ### Runner setup Forgejo runs in Docker on the VDS. `.forgejo/workflows/ci.yml` (`bun run ci`, Rust tests and clippy included) runs on every push and pull request to main; `desktop.yml` as above. Neither publishes anything (Jenkins still publishes site/). Workflow artifacts of this public repository can be downloaded by anyone for 14 days. 1. **Actions in Forgejo (done).** `FORGEJO__actions__ENABLED=true` and `FORGEJO__actions__DEFAULT_ACTIONS_URL=https://data.forgejo.org` in the compose file; the Actions unit is on for CRT/storebound only. 2. **Linux runner on the VDS (done).** `/srv/forgejo-runner/`: Forgejo runner 13 (`vds-linux`, registered to CRT/storebound only, label `ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04`, capacity 2, job timeout 2 h) next to its own Docker daemon (`docker:29-dind`, TLS only, 16 CPUs / 24 GB). Jobs run in throwaway containers of that daemon: no Docker socket, no host volumes, nothing of Forgejo's data. The runner never sees a signing key. `actions/cache` uses the runner's cache server. `prune.sh` (cron, daily) removes stopped containers, dangling images and cache entries unused for 14 days, and caps images at 40 GB and the cache at 20 GB. - Re-register (a new token from the repository's Actions runner settings): `forgejo-runner register --no-interactive --instance https://git.crt.fyi --token --name vds-linux` in the runner image with `/srv/forgejo-runner/data` as `/data`, then `docker compose up -d`. - Linux quirks the workflow handles: `NO_STRIP=1` (strip removes the bun sidecar's payload) and the sidecar's rpath set before bundling (linuxdeploy's own patchelf corrupts the bun executable; with the rpath already set it leaves the file alone). The self-tests start the sidecar from the AppImage and the .deb, so a broken sidecar fails the job. 3. **Windows runner on the VDS (done).** Windows Server 2022 Standard (evaluation) in a KVM virtual machine, in Docker (`dockurr/windows:6.05`), `/srv/windows-runner/`. Forgejo runner 13.2.0 (`vds-windows`, label `windows:host`, capacity 1, job timeout 3 h), registered at the **CRT user level**: every CRT repository can use it (storebound, Demo App, ...). - **Resources:** 8 vCPUs, 12 GB RAM, a 150 GB disk (`storage/data.img`, sparse). `restart: unless-stopped`. - **Network:** nothing public. The web viewer (8006), RDP (3389) and the VM's OpenSSH (22, as host port 2223) are bound to 127.0.0.1 on the VDS; use an SSH tunnel (`ssh -L 8006:127.0.0.1:8006 root@`, then http://127.0.0.1:8006). The VM reaches the internet and git.crt.fyi through the container's NAT. No host folders but its own storage and `oem/` (copied into the VM once, at install). - **Accounts:** `builder` (local admin, auto sign-in; password in `/srv/windows-runner/.env`, mode 600) and `runner` (no admin rights, random password known only to Windows) for the service. OpenSSH takes only the key in `/srv/windows-runner/ssh/` (`./vmssh`, `./vmscp` on the VDS). - **Files:** `compose.yml`; `oem/install.bat` + `oem/setup.ps1` (first boot: OpenSSH, Windows Update policy, time zone UTC+8); `provision.ps1` (build tools, idempotent, pinned downloads; log in `provision.log`); `runner-setup.ps1` (account, `config.yml`, registration with a token on stdin, WinSW service); `build/runner/` (forgejo-runner built from the v13.2.0 tag with `GOOS=windows`: Forgejo ships no Windows binary). - **Toolchain** (machine-wide, on the PATH the service sees): VS 2022 Build Tools 17.14 (MSVC x64/x86 and ARM64, Windows 11 SDK 10.0.26100), rustup in `C:\rust` (stable-x86_64-pc-windows-msvc, writable by `runner`, so jobs can `rustup target add`), Bun 1.4.0 (`bun`, `bunx`) in `C:\bun`, Git for Windows 2.56 (Git Bash's `bash` on the PATH for `shell: bash`; `core.autocrlf false`, `core.longpaths true`), PowerShell 7.6 (`shell: pwsh`), Node 24 (JavaScript actions), 7-Zip 26.04, WebView2 runtime. Tauri fetches NSIS itself. - **Service:** `forgejo-runner` (WinSW 2.12, delayed automatic start, restarts on failure) runs `C:\forgejo-runner\forgejo-runner.exe daemon` as `runner`; jobs work in `C:\actions` (a daily task removes job folders older than a day). Defender stays on, with exclusions for `C:\actions`, `C:\storebound-target`, `C:\rust` and `C:\bun`. - **Windows Update:** installs daily at 03:00 (UTC+8) and never restarts on its own while `builder` is signed in (it always is); active hours 07:00 to 23:00. A restart for updates is manual: `./vmssh "Restart-Computer -Force"` when no job runs. - **Licensing:** the evaluation edition runs 180 days, then shuts down every hour. Extend it with `slmgr /rearm` (6 rearms left at install, 180 days each; the VM was installed in October 2026) or rebuild the VM (delete `storage/`, `docker compose up -d`, run `provision.ps1` and `runner-setup.ps1` again). The evaluation license is for evaluation and testing; for a permanent build machine, buy a Windows Server (or Windows 11 Pro) license and enter the key. - **Re-register** (a new token from CRT's user settings > Actions > Runners, or `GET /api/v1/user/actions/runners/registration-token`): on the VDS, delete `C:\forgejo-runner\.runner` in the VM and pipe the token into `./vmssh "powershell -File C:\provision\runner-setup.ps1"`. - **Shared runner:** any workflow of any CRT repository with `runs-on: windows` runs here, as `runner`, with internet access. Keep workflows of fork pull requests off it (as ci.yml does for Linux). The workflow's Windows job runs only when "windows" is ticked (from the API, `"inputs": {"windows": "on"}`). 4. **Windows code signing (later).** Buy a code-signing certificate, then sign the installer before `add` and pass `--code-signed`. ## Hosting on storebound.dev | URL | served from | how it gets there | |---|---|---| | /desktop/ (downloads page) | /var/www/storebound.dev/desktop/index.html | `site/desktop/index.html`, with the site (Jenkins + /usr/local/sbin/storebound-publish) | | /desktop/latest.json, /desktop/beta/latest.json | /var/www/storebound-desktop/ | storebound-desktop-publish | | /desktop/dl// | /var/www/storebound-desktop/dl/ | storebound-desktop-publish | **Why the binaries live outside the site's document root.** The site's publish script rsyncs `site/` with `--delete-after`, so anything not in git would vanish on the next site deploy. Binaries are never committed. Apache maps the three paths with `Include /etc/apache2/storebound-desktop.conf` in the :443 vhost (`tools/vds/apache-storebound-desktop.conf`): - latest.json is sent `no-cache`; - version folders are sent `immutable`; - no directory listings. **`/usr/local/sbin/storebound-desktop-publish [stable|beta]`** (`tools/vds/storebound-desktop-publish`, root:root 755) takes `/var/lib/storebound-desktop/incoming//` (root only, mode 700) and checks it: - only plain files with plain names; - SHA256SUMS matches; - latest.json is that version, and every URL in it names an uploaded file under /desktop/dl//. Then it publishes: - a new version folder appears at once (temp folder, then rename); - a later `add` to the same version only adds files, and a published file is never replaced by other bytes; - latest.json goes in last (temp file, then rename), so apps see a version only when all its files are there. It runs nothing from the upload. **No sudoers entry is needed today.** The owner's Mac uploads and runs the script as root over SSH. If a CI job should publish one day, give its user exactly this script, as the existing Jenkins entries do: ` ALL=(root) NOPASSWD: /usr/local/sbin/storebound-desktop-publish`. Both files are installed on the VDS (D5). The Apache vhost was backed up to `/root/storebound.dev.conf.bak-*` first. To reinstall after a change: ```sh scp tools/vds/storebound-desktop-publish root@:/usr/local/sbin/storebound-desktop-publish scp tools/vds/apache-storebound-desktop.conf root@:/etc/apache2/storebound-desktop.conf ssh root@ 'chmod 755 /usr/local/sbin/storebound-desktop-publish && apache2ctl configtest && systemctl reload apache2' ``` ## Publish Publishing is the owner's call. Every installed app on the channel offers the update as soon as latest.json is live. ```sh export STOREBOUND_PUBLISH_HOST=root@ # the VDS (not the Cloudflare name) bun tools/desktop-release.ts verify --out ~/.storebound-releases/desktop/0.1.0 bun tools/desktop-release.ts publish --out ~/.storebound-releases/desktop/0.1.0 # dry run: the plan bun tools/desktop-release.ts publish --out ~/.storebound-releases/desktop/0.1.0 --yes # upload + publish ``` 1. **Checks again:** sums, signatures against tauri.conf.json's key, latest.json and the privacy scan. 2. **Uploads:** rsync into the incoming folder (evidence stays local). 3. **Publishes:** runs the publish script. 4. **Confirms:** reads the live latest.json back. `--channel beta` publishes to /desktop/beta/latest.json, which apps on the Beta channel read. **Rollback.** Tauri updates only to a higher version, so a bad release is replaced by a fixed higher version. Its files stay where they are, because links and checksums must not change. ## Going public: checklist 1. A signed, notarized macOS build from the release commit (the Developer ID certificate is on the Mac). The gates above must pass. 2. Forgejo Actions enabled, the Linux runner registered, a `desktop-v0.1.0` tag built, `add` of the AppImage and deb. 3. Windows: a runner, or ship macOS + Linux first. The page and latest.json list only what exists. 4. `publish --yes` to stable. Then open storebound.dev/desktop/ on each OS and run `storebound desktop --download`. 5. An updater round trip: publish 0.1.1 to beta, switch a 0.1.0 install to Beta, Check for updates, Install. 6. Link /desktop/ from the main page, and update README and CHANGELOG.