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/[email protected] 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
controlsservice stack) landed via0011— GTB is on v0.28.0 andpkg/cmd/studio/main.goalready runs oncontrols.NewController+gtbhttp.Register+ the cookie/bearerauthChain. 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/.dmgbuild, 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¶
- A clickable Mac app + .dmg — a macOS
.appthat launches the studio and opens the browser, delivered as a.dmg. - 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.
- Periodic version check + notification — a passive "update available" nudge.
2. Packaging — the .app + .dmg¶
- krites is already a single Go binary (
0001§12). The.appis 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 asnapshotgoreleaser target. Add a macOS.app+.dmgartifact (arm64 first,0001§13-Q6), embedding thego: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/.dmgopen 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, see0012§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 addedTool.Bootstrap.AutoInitialise(2026-07-06) — enabled viaauto_initialise: truein.gtb/manifest.yaml, round-tripped intopkg/cmd/root/cmd.gobygtb 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 — aPreRunStudioinit hook and a config-ensure incmd/krites-app'smain()— both removed 2026-07-06 (verified: fresh no-config launch auto-configures + serves).cmd/krites-appnow provides only the no-arg →launchdefault. Interactive provider setup stays inkrites 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 acontrolsStopFunc(WithStop) and the endpoint callscontrols.Controller.Stop()(honouringSetShutdownTimeout) — no signals, nokill. 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-rolledhttp.Serverwith a signal-drivenShutdown,pkg/cmd/studio/main.go), which GTB already provides: the controls-integratedhttpserver (NewServer+Start/Stop/Status→controlsStart/Stop/Status funcs, health/readiness handlers, a middlewareChain) and thehttp.AuthMiddleware(cookie + bearer,pkg/authn) securing the endpoints over the loopback — the same auth0009§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/versionthrough 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 thekrites updateCLI 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'sUpdater.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.Execof the same, now-replaced,.appbinary 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/versionuntil 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--ciflag 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→ gracefulcontrolsdrain via a cancelable run-context passed asWithShutdown. - ✅ 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/updateruns GTB'sSelfUpdater, and on a version change the drained commandsyscall.Execs its own binary (atomic reexec flag + run-context cancel;WithSelfUpdate/WithReexecseams 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/versionuntil the new build answers, then reloads (90s → reopen fallback). "Already latest" + failures report inline. - ⬜ Passive "update available" banner (
R-UPD-5): deferred — needs/api/versionenriched 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 anotarytoolkeychain profile (krites-notary) is stored + Apple-validated. Path: goreleaser Pro native packaging —app_bundles→dmg(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/kritesmain isDO NOT EDIT, so it can't "default to launch", and anInfo.plistcan't pass args. So the.app's single executable is a second entry point (cmd/krites-app, binarykrites) that reuses the same generated root — it is the full CLI, but a no-arg double-click defaults tolaunch. (A separate stub binary was rejected: a secondKritesbinary would collide withkriteson the case-insensitive macOS filesystem, andapp_bundleswraps 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 spawnskrites studiodetached (Q1a: plain session process,Setsid+context.WithoutCancelso it outlives the launcher), pollsGET /api/v1/versionuntil up, opens the browser, and exits. The detached studio re-execs this same binary askrites studio; config is ensured by GTB'sauto_initialiseroot pre-run (above), so both the launcher and the detached studio self-configure regardless of who started first. - ✅ Studio singleton wiring (2026-07-05) —
RunStudioresolves a concrete port (a--port 0request 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.Acquiretolerates a lock already owned by this PID (the self-update re-exec keeps the same PID), andreexecSelfpins--portinto the re-exec args so it rebinds the same port (Q4 — the SPA's post-update poll reloads the same origin). - ✅
.app/.dmgbuild + Developer ID signing + notarization (2026-07-06) via the Pro native path.just package-macbuilds on an ephemeral tag and was verified end-to-end on the Mac:codesign --verify --deep --strictpasses, thekritesbinary and the bundled dylib carry the Developer ID + hardened runtime (flags=0x10000(runtime)), Apple notarized the dmg, andstapler validatepasses. Finding: goreleaser's native notarize does NOT staple — sojust package-macrunsxcrun stapler stapleon the dmg afterwards. The CI.dmgis 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; pinned1.23.0darwin/arm64,- its MIT LICENSE) beside
kritesinContents/MacOS/viascripts/fetch-onnxruntime-dylib.sh(a goreleaserbeforehook) so the resolver's bundled-beside-exe step finds it — zero-download on Hailey's Mac (0013Slice 2 /R-ORT-5). The LICENSE goes inResources/(a text file inMacOS/breaks codesign).
- its MIT LICENSE) beside
- ✅ 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 (whichkrites updatepulls from the release andsyscall.Execs inside the.app, breaking the bundle seal for that file) stay independently signed + notarized, not just the.dmgwrapper (decision 2026-07-05, Matt). Bothkrites_Darwin_arm64/x86_64.tar.gzare quill-signed + notarized in thev0.4.4release. - ✅ CI signing on the
macosrunner (2026-07-10) — the tag-gatedgoreleaserjob (pro: true) runs the whole native path headless. The runner is a launchd service with no login-keychain access, so itsbefore_scriptbuilds a dedicated self-unlocked keychain from the CI secrets (APPLE_DEV_CERT+_PASSWORD,APPLE_NOTARY_*) andafter_scripttears 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 beforesecurity import(else "MAC verification failed"); (3)notarytool --keychainneeds an absolute path; (4)APPLE_NOTARY_KEYis base64 → decode to a PEM.p8fornotarytool 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) viacontrols.Controller.Stop(). Q1a ✅ RESOLVED (2026-07-05, Matt): a plain session-scoped detached process (the launcher spawnskrites studiowithSysProcAttr{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 bypkg/authn). The update + shutdown endpoints (and0009'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 openshttp://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.WithMTLSVerifieris 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
AuthMiddlewarelive 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 to0009'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
.appwraps the existingstudioserver + browser (0001§13-Q2 keeps Wails as a later, near-zero-rewrite option); this spec does not build it. - App Store distribution. Direct
.dmgonly.