Skip to content

0010 — Mac packaging (.dmg) & in-studio self-update

Status: IMPLEMENTED (2026-07-10) — Slices 1, 2, 3 landed. Slice 3's .dmg now builds, signs and notarizes in CI on the self-hosted macos runner (first release: v0.4.4, via phpboyscout/cicd/goreleaser@v0.20.0 with pro: true): the bare CLI binaries are quill-notarized (cross-platform, keeps krites update Gatekeeper-clean) and Krites.app/.dmg are natively codesigned + notarized. The runner is a launchd service with no login-keychain access, so the goreleaser job's before_script builds a dedicated self-unlocked keychain from the CI secrets — see the headless-signing chain documented in .gitlab-ci.yml. just package-mac remains the local path (also staples). Distribution & lifecycle for the single-user Mac build. Companion to 0001-krites.md (§13-Q6 Apple-Silicon target, §12 single binary), 0003-studio.md (the studio server + SPA this extends), and the GTB scaffold (update, browser-open, service lifecycle primitives krites inherits).

§6 resolved; precursors done. The blocking precursor (Q1c: GTB v0.21→v0.27.2 bump + re-platform onto GTB's controls service stack) landed via 0011 — GTB is on v0.28.0 and pkg/cmd/studio/main.go already runs on controls.NewController + gtbhttp.Register + the cookie/bearer authChain. Implementation may begin.

Headless-Mac reality: Slices 1, 2, 4 (the studio lifecycle API + update UX) are built and tested on the Linux dev box (the re-exec behind a fakeable seam). Slice 3's .app/.dmg build, code-signing, and notarization run on a Mac / CI — the goreleaser config + launcher/singleton logic are authored and unit-tested here, but the signed artifact is produced in CI.

Goal. krites is built for Hailey on her Mac. She should double-click a launcher and land in the studio (browser opens automatically), and be able to keep it current without touching a terminal — an in-studio update that upgrades the binary, restarts the server, and reloads the UI, plus a periodic "update available" nudge. This is packaging + lifecycle, orthogonal to the cull→develop→remove→learn value line, but the update UX lives in the studio.


1. Three pieces

  1. A clickable Mac app + .dmg — a macOS .app that launches the studio and opens the browser, delivered as a .dmg.
  2. In-studio self-update — a settings-menu action that runs GTB's self-update, blocks the UI with an "updating…" screen while the server restarts, then reloads.
  3. Periodic version check + notification — a passive "update available" nudge.

2. Packaging — the .app + .dmg

  • krites is already a single Go binary (0001 §12). The .app is a thin bundle: Krites.app/Contents/MacOS/krites (the binary) + Info.plist + icon. On launch it brings up the studio and opens the browser (GTB's browser/URL-open primitive); the process model (foreground vs a detached singleton server) is scoped in §2.1, because it interacts with the update restart.
  • Build via goreleaser — krites already has a snapshot goreleaser target. Add a macOS .app + .dmg artifact (arm64 first, 0001 §13-Q6), embedding the go:embed-ed studio SPA so the bundle is self-contained.
  • Signing & notarization (decision 2026-07-04, Matt): reuse the existing GTB Developer ID. The phpboyscout toolchain already signs + notarizes the GTB CLI with an Apple Developer ID; krites reuses the same certificate + notarization flow in goreleaser/CI, so the .app/.dmg open cleanly through Gatekeeper with no "unidentified developer" warning and no per-user workaround. Signing identity + notarization credentials come from CI secrets, never the repo.

2.1 Launch & lifecycle model — and its interaction with update (R-PKG-1)

The launch model and the update restart interact, so they are scoped together.

Given fact (from GTB, §3): GTB's Updater.Update() downloads → verifies → replaces the on-disk binary and returns; it does not restart the process. So whichever model we pick, the running server restarts itself by syscall.Exec of its own (now-replaced) path — which replaces the process image in place (same PID, same open port after re-bind). The re-exec works identically under both launch models; it is not the differentiator.

The real differentiator is the reopen-the-UI experience:

A — Foreground (.app is the server) B — Launcher + detached singleton
Launch double-click runs studio in the foreground; quitting the app stops it launcher checks for a running server (lock file + health check), reopens the browser if up, else spawns a detached server, then exits
Reopen after closing the browser tab poor — clicking the dock icon won't reopen the browser without a native app-reopen handler (which pushes toward a Wails-style shell) good — clicking the launcher any time brings the studio back up
Update re-exec server re-execs itself; dock icon persists detached server re-execs itself; launcher uninvolved
Lifecycle cost trivial (one process = the app) needs a singleton (PID/lock + port), a stop path, and stale-lock recovery (dead PID → relaunch)
Survives browser close / session tied to the app window runs until stopped or session end

Recommendation (matches Matt's instinct): Model B — launcher + detached singleton. It delivers the "just click Krites and I'm in the studio, whether or not it was already running" UX Hailey wants, and pairs cleanly with the re-exec update. Its costs are bounded, well-understood mechanics:

  • First-run config (R-PKG-3, see 0012 §2): GTB's root pre-run originally aborted on a missing config ("please run init") before any sub-command ran, so a first-ever double-click did nothing. GTB v0.29.0 added Tool.Bootstrap.AutoInitialise (2026-07-06) — enabled via auto_initialise: true in .gtb/manifest.yaml, round-tripped into pkg/cmd/root/cmd.go by gtb regenerate project. The root pre-run now auto-runs a non-interactive init (writes the default config, no credential wizards) instead of aborting, so any invocation self-configures on a fresh machine — the double-click, krites studio, krites launch, all with zero terminal steps. This replaced two earlier workarounds — a PreRunStudio init hook and a config-ensure in cmd/krites-app's main() — both removed 2026-07-06 (verified: fresh no-config launch auto-configures + serves). cmd/krites-app now provides only the no-arg → launch default. Interactive provider setup stays in krites init.
  • Singleton: a lock file (~/Library/Application Support/krites/studio.lock) recording PID + port; a health probe (GET /api/version) confirms liveness. A second launch with a live server just re-opens the browser.
  • Stale-lock recovery: a lock whose PID is dead (crash) is reclaimed and the server relaunched.
  • Stop path — a studio "Shutdown krites" action (R-PKG-2, decision 2026-07-04): the detached server needs a clean off switch, so the studio settings carry a Shutdown button → POST /api/shutdown → a graceful stop. This is trivial and safe on GTB's lifecycle: the studio registers its drain as a controls StopFunc (WithStop) and the endpoint calls controls.Controller.Stop() (honouring SetShutdownTimeout) — no signals, no kill. The SPA then shows a "krites has shut down — you can close this tab" screen; next launch, the launcher finds no live server and starts a fresh one. On exit the singleton lock is released (a crash leaves a stale lock the launcher reclaims). Prerequisite — re-platform the studio onto GTB's service stack (today it is a hand-rolled http.Server with a signal-driven Shutdown, pkg/cmd/studio/main.go), which GTB already provides: the controls-integrated http server (NewServer + Start/Stop/Statuscontrols Start/Stop/Status funcs, health/readiness handlers, a middleware Chain) and the http.AuthMiddleware (cookie + bearer, pkg/authn) securing the endpoints over the loopback — the same auth 0009 §3.1 relies on, so no stray local process can drive shutdown or update. Both land in GTB ≥v0.27.2 (krites is on v0.21.0), so the bump is a shared precursor (§6-Q1c). This resolves the stop-path half of Q1a; the launchd-vs-session question (auto-restart, survive-logout) is separate (§6).
  • Update under B: the detached server drains → re-execs; the SPA's blocking overlay polls /api/version through the brief unbound window, then reloads — the launcher is not involved.

Deferred: Model A stays viable if we later add a proper native shell (Wails, 0001 §13-Q2), which would give a real dock-reopen handler; until then B is the better fit.

3. In-studio self-update

The studio is a localhost Go server + SPA (0003). Update is a server endpoint the SPA drives, reusing GTB's update self-update rather than reimplementing it.

  • POST /api/update (R-UPD-1): runs the GTB self-update — check the release feed, download the newer signed binary, verify, and replace the on-disk binary in place (the same logic as the krites update CLI command). Returns the outcome (already-current / updated-to-X / failed). It is a localhost-only, single-user, bearer-authenticated action (the studio's loopback bind + the API auth of §2.1/§6-Q1b); it is not exposed on MCP.
  • Restart by re-exec (R-UPD-2, resolved 2026-07-04 by reading GTB): GTB's Updater.Update() replaces the binary and returns — it does not restart the process, so the restart is ours. After a successful update the server drains in-flight requests and re-execs itself (syscall.Exec of the same, now-replaced, .app binary path), preserving the studio flags (port, shoot), so the new code runs on the same port. The re-exec seam is faked in tests.
  • The blocking UI (R-UPD-3): on invoking update the SPA shows a full-screen "Updating krites…" overlay that disables interaction, then polls /api/version until the server answers as the new version, then reloads the page onto the upgraded SPA. A timeout surfaces a "restart didn't come back — reopen Krites" fallback rather than hanging.
  • Failure is safe: a failed download/verify leaves the running binary and UI untouched (the update is atomic — replace only after verify), and the overlay reports the error.

4. Periodic version check + notification

  • GET /api/version (R-UPD-4): reports the running version and, from the release feed, whether a newer one exists (reusing GTB's update-check — the same check the --ci flag disables). Cheap; cached briefly.
  • The nudge (R-UPD-5): the SPA checks on load and on a periodic interval and, when an update exists, shows a non-blocking banner ("krites vX is available — Update") that opens the settings update action. Never auto-updates — Hailey chooses when.
  • The version check is a low-sensitivity network call (no image data); it is still disclosed in settings and can be turned off.

5. Implementation plan (layered)

  • Slice 1 — the lifecycle + update API (R-PKG-2, R-UPD-1/2/4):
  • Shutdown (R-PKG-2, 2026-07-05): POST /api/v1/shutdown → graceful controls drain via a cancelable run-context passed as WithShutdown.
  • Version (R-UPD-4, 2026-07-05): GET /api/v1/version (public — the launcher probe + post-update poll reach it cookieless across the re-exec's fresh token).
  • Update + re-exec (R-UPD-1/2, 2026-07-05): POST /api/v1/update runs GTB's SelfUpdater, and on a version change the drained command syscall.Execs its own binary (atomic reexec flag + run-context cancel; WithSelfUpdate / WithReexec seams keep it testable).
  • (Also landed while wiring: context now threads through the model + ONNX- runtime downloads, so a cancelled cull/remove cancels an in-flight fetch.)
  • Slice 2 — the studio update UX (R-UPD-3/5):
  • Check + blocking overlay (R-UPD-3, 2026-07-05): settings Updates section (current version + "Check for updates"); a successful update shows the full-screen "Updating krites…" overlay that polls /api/version until the new build answers, then reloads (90s → reopen fallback). "Already latest" + failures report inline.
  • Passive "update available" banner (R-UPD-5): deferred — needs /api/version enriched with a cached latest-version check (a small seam).
  • Slice 3 — the .app + .dmg + launcher (macOS-only; built + verified on a Mac, then wired to a macOS CI runner). Unblocked 2026-07-05: goreleaser Pro licensed + installed, the Developer ID (Developer ID Application: MATTHEW JOHN COCKAYNE, 7M39CAN7U6) is in the login keychain, and a notarytool keychain profile (krites-notary) is stored + Apple-validated. Path: goreleaser Pro native packaging — app_bundlesdmg (use: appbundle) → notarize.macos_native (sign.identity + sign.options: [runtime] + notarize.profile_name), which signs the bundle and notarizes the .dmg.
  • Singleton lock (2026-07-05): internal/singleton — the studio's single-instance PID+port lock with stale-lock reclaim and injectable liveness (the reusable, Linux-testable core of Model B). Tested.
  • Launcher (2026-07-05) — the generated cmd/krites main is DO NOT EDIT, so it can't "default to launch", and an Info.plist can't pass args. So the .app's single executable is a second entry point (cmd/krites-app, binary krites) that reuses the same generated root — it is the full CLI, but a no-arg double-click defaults to launch. (A separate stub binary was rejected: a second Krites binary would collide with krites on the case-insensitive macOS filesystem, and app_bundles wraps exactly one binary.) krites launch (pkg/cmd/launch, decision logic behind injectable seams + unit-tested) Peeks the singleton → reopens the browser if a live studio holds it, else spawns krites studio detached (Q1a: plain session process, Setsid + context.WithoutCancel so it outlives the launcher), polls GET /api/v1/version until up, opens the browser, and exits. The detached studio re-execs this same binary as krites studio; config is ensured by GTB's auto_initialise root pre-run (above), so both the launcher and the detached studio self-configure regardless of who started first.
  • Studio singleton wiring (2026-07-05) — RunStudio resolves a concrete port (a --port 0 request is turned into a reserved free port), Acquires the lock recording the actual port (so the launcher reopens the right URL), and releases on clean stop. singleton.Acquire tolerates a lock already owned by this PID (the self-update re-exec keeps the same PID), and reexecSelf pins --port into the re-exec args so it rebinds the same port (Q4 — the SPA's post-update poll reloads the same origin).
  • .app/.dmg build + Developer ID signing + notarization (2026-07-06) via the Pro native path. just package-mac builds on an ephemeral tag and was verified end-to-end on the Mac: codesign --verify --deep --strict passes, the krites binary and the bundled dylib carry the Developer ID + hardened runtime (flags=0x10000(runtime)), Apple notarized the dmg, and stapler validate passes. Finding: goreleaser's native notarize does NOT staple — so just package-mac runs xcrun stapler staple on the dmg afterwards. The CI .dmg is notarized but left unstapled (the job doesn't staple), which is fine for an online download — Gatekeeper does the online notarization check on first open. (The app inside the dmg is likewise notarized but not individually stapled; a later refinement could staple the app pre-dmg, and add a staple step to CI.)
  • Bundle libonnxruntime.dylib (2026-07-06; pinned 1.23.0 darwin/arm64,
    • its MIT LICENSE) beside krites in Contents/MacOS/ via scripts/fetch-onnxruntime-dylib.sh (a goreleaser before hook) so the resolver's bundled-beside-exe step finds it — zero-download on Hailey's Mac (0013 Slice 2 / R-ORT-5). The LICENSE goes in Resources/ (a text file in MacOS/ breaks codesign).
  • Also notarize the bare darwin binary (2026-07-10) in the tar.gz archive via the cross-platform notarize.macos (quill) block — the same one GTB uses — so the raw CLI binary and the self-update replacement (which krites update pulls from the release and syscall.Execs inside the .app, breaking the bundle seal for that file) stay independently signed + notarized, not just the .dmg wrapper (decision 2026-07-05, Matt). Both krites_Darwin_arm64/x86_64.tar.gz are quill-signed + notarized in the v0.4.4 release.
  • CI signing on the macos runner (2026-07-10) — the tag-gated goreleaser job (pro: true) runs the whole native path headless. The runner is a launchd service with no login-keychain access, so its before_script builds a dedicated self-unlocked keychain from the CI secrets (APPLE_DEV_CERT+_PASSWORD, APPLE_NOTARY_*) and after_script tears it down. Four non-obvious fixes were needed (each documented inline in .gitlab-ci.yml): (1) dedicated keychain + search-list so codesign/notarytool find the identity + profile; (2) the p12 is OpenSSL-3 format → re-encode to legacy before security import (else "MAC verification failed"); (3) notarytool --keychain needs an absolute path; (4) APPLE_NOTARY_KEY is base64 → decode to a PEM .p8 for notarytool store-credentials. First green release: v0.4.4.
  • Slice 4 — periodic check polish and settings toggles.

6. Open questions

  • Q1 — launch & lifecycle model.RESOLVED (2026-07-04, Matt): launcher + detached singleton (Model B), scoped in §2.1, with a bearer-authenticated studio Shutdown action as the stop path (R-PKG-2) via controls.Controller.Stop(). Q1a ✅ RESOLVED (2026-07-05, Matt): a plain session-scoped detached process (the launcher spawns krites studio with SysProcAttr{Setsid: true} so it outlives the launcher), not a launchd user agent. Simplest first cut for a single-user desktop tool; the singleton lock + the studio Shutdown action already give reopen + a clean stop, and the studio self-updates in place, so the auto-restart/survive-logout that launchd would add is not needed yet. A launchd agent stays a later option if Hailey wants the studio always-resident across logins.
  • Q1b — studio API auth.RESOLVED: reuse GTB's http.AuthMiddleware (cookie + bearer + api-key + mTLS, backed by pkg/authn). The update + shutdown endpoints (and 0009's credential entry) sit behind it over the loopback so no stray local process can drive them. Token handoff: GTB's Jupyter-style token-in-URL bootstrap — the launcher opens http://localhost:<port>/?token= <t>, which sets the session cookie; the cookie (ambient, below the bearer) then authenticates <img> preview loads that can't set an Authorization header, while explicit API calls carry the bearer header. WithMTLSVerifier is the seam for the future multi-tenant/TLS path (0009 §3.1).
  • Q1c — GTB dependency bump (shared precursor). The controls-integrated HTTP server and the AuthMiddleware live in GTB ≥v0.27.2; krites is pinned to v0.21.0. Bumping GTB (v0.21→v0.27.2) + regenerating the scaffolded files is the precursor to §2.1's prerequisites and to 0009's loopback auth — one shared studio-hardening step to schedule before either feature's server work.
  • Q2 — GTB self-update's restart contract.RESOLVED (2026-07-04, by reading GTB v0.21.0): Updater.Update() replaces the binary and returns; it does not restart. The re-exec is ours (§3, R-UPD-2).
  • Q3 — release feed.RESOLVED: GTB updates from a signed GitHub/GitLab release source → krites uses GitLab Releases (phpboyscout/krites). goreleaser must publish the signed .dmg/binary there; the updater verifies the signature (openpgp) before applying. (Confirm the exact feed URL/format field on first wiring.)
  • Q4 — port stability across restart. The re-exec must rebind the same port; the SPA poll already tolerates the brief unbound window. Remaining detail: handle the case where the port is transiently held during handover (retry/backoff).
  • Q5 — update channel.RESOLVED: stable only. One stable channel; Hailey only ever receives released, signed builds.

7. Non-goals (this spec)

  • Cross-platform packaging. macOS/arm64 only (0001 §13-Q6); Windows/Linux bundles are out of scope.
  • Multi-user / auto-update-without-consent. Single user; updates are always Hailey-initiated (the periodic check only notifies).
  • A Wails/native shell. The .app wraps the existing studio server + browser (0001 §13-Q2 keeps Wails as a later, near-zero-rewrite option); this spec does not build it.
  • App Store distribution. Direct .dmg only.