Thin Launcher Apps — Contract (S1)
What this document is. The locked design contract produced by S1 (#2279) for the Thin Launcher Apps epic (#2277), grounded in the production-fidelity spike S0 (#2278) that returned
B-GOon a real Mac. It fixes the interfaces every downstream wave (S2–S10) must agree on so the parallel build waves cannot diverge. It is an internal decision record, not the user-facing model doc — S8 (#2288) folds the landed model intoapp-generation.mdx/manager-app.mdxafter the assembler ships.Discipline: this contract locks interfaces (locations, the core-resolution rule, the producer/consumer carrier, the identity plist keys, the signing posture, the no-op verdicts). It deliberately leaves implementation (dylib-vs-
.frameworkpackaging, the exact templateInfo.plistkey set, the stub's read mechanism) to S2, so the mandatory post-S2 human checkpoint stays meaningful.
Decision gate recap (S0 → B-GO)
The spike proved, on a real Mac (arm64, macOS 26.5), that a KB-scale stub .app that dlopens one shared external real Tauri core renders the full writing app in a system WKWebView while NSBundle.mainBundle, lsappinfo, NSRunningApplication, and the production resolve_exe_app_name() all attribute the process to the stub — with per-stub data isolation and no single-instance collapse. Every gate (e, a, b, c, d, f) passed.
Verdict:
B-GO— proceed with the thin-launcher framework split.Option C (
execvthe core) is dead. It collapses identity to the core (current_exe/NSBundle.mainBundlebecome the core) and defeats the migration. No "else C" branch survives anywhere in the plan.
(1) ROOT model — Option A: ROOT is itself a thin launcher (stub + shared core); ONE codepath
⚠️ Highest-stakes call in S1 — open for veto at this PR's review. It drives S2's entire scope, the core-location strategy, and S8's docs. A human reversal is cheap now and expensive after S2.
Decision. ROOT (zudotext.app) is itself a thin launcher: a small stub binary at Contents/ that dlopens the one shared core living in Contents/Frameworks/ of its own bundle. Every LEAF is the same shape — a KB-scale stub with its own Info.plist + icon — that dlopens ROOT's shared core. There is one codepath (stub → dlopen → core_main), identical for ROOT and every LEAF; the runtime role is still resolved by the .app stem (app_mode::resolve_exe_app_name()), unchanged.
Rationale (tied to S0 evidence).
This is literally the Chrome app-shim pattern the epic cites:
Chrome.appis itself a launcher stub whose code lives inContents/Frameworks/…Framework, and the PWA shims point at that framework.ONE core on disk — the Tauri stack is built and shipped exactly once (no double-build), and ROOT auto-updates with the same core every LEAF uses (only when ROOT is updated in place at the same path — see the auto-update caveat in (2); do not lift this as an uncaveated promise), so "what ROOT runs" and "what LEAVES run" can never diverge.
The spike proved
current_exe()from inside thedlopen'd core resolves to the stub (_NSGetExecutablePathreturns the main executable), so the existing stem-based role detection works with zeroapp_mode.rschanges.
Rejected alternative — monolithic ROOT + an embedded core copy as a bundle resource. It needs two codepaths (ROOT monolithic + LEAF stub/core), builds and ships the Tauri stack twice inside ROOT (≈2× the 80 MB COW baseline the S0 spike measured), and reintroduces the very ROOT-vs-LEAF divergence Option A removes. Its only merit — lower ROOT-refactor risk — is already managed by the mandatory post-S2 human checkpoint, and pre-release we have no install base or size pressure forcing the compromise.
Platform scope. The stub/core split is macOS-desktop only, fully cfg-gated. iOS stays a single always-LEAF binary — no stub, no core split, no dlopen; the [lib] zudotext_lib static-lib (rlib) link that feeds the iOS build must not be perturbed by the cdylib crate-type addition (an S2 acceptance criterion). The crate-type change S2 makes is crate-type = ["rlib", "cdylib"] (keep rlib for iOS; add cdylib for the macOS core).
(2) Core artifact, location, and the locate-or-error contract
Core artifact. The shared core exposes a core_main()-style C entry the stub dlsyms and calls on the process main thread (AppKit requirement; the spike stub did exactly this, but the production stub is new code — the main-thread hand-off is re-proven in S2 alongside the sidecar read). It is built --features tauri/custom-protocol (prod mode) — a plain cargo build --release of the cdylib defaults to Tauri dev mode (tauri/: dev = !custom-protocol), which bakes the dead devUrl and does not embed dist-renderer → blank window. This is locked; it was the spike's only (build-config) failure.
Packaging is S2's call (single self-contained
cdyliblike the spike, or a versioned.framework) as long as it satisfies the resolution rule below. The spike's core was a single self-containedcdylib(Rust deps static-linked; only system frameworks dynamic), so@rpath/install_namework is light; whatever S2 chooses, the core mustdlopencleanly from a foreign bundle.
Core location (locked interface). The shared core lives inside ROOT's bundle, under Contents/Frameworks/ (Chrome-style). The canonical filename is libzudotext_core.dylib (or, if S2 chooses a framework, Contents/ — S2 fixes the exact form and updates the resolution rule's second branch accordingly).
Stub → core resolution rule (locked; single rule shared by ROOT and LEAVES). At launch, before any core is loaded, the stub resolves the core path as:
If a sidecar file
Contents/exists in the stub's ownResources/ core- path .app(a plain UTF-8 absolute path),dlopenthat path. (LEAVES.)Otherwise,
dlopen<own .. (ROOT.)app>/ Contents/ Frameworks/ libzudotext_ core. dylib
The stub locates its own .app by walking current_exe() ancestors (the same walk app_mode uses) — no CFBundle/plist parsing required, which is why the carrier is a sidecar file, not an Info.plist key. (S2-to-prove: the stub binary actually reads the sidecar + dlopens + transfers main-thread control; the spike stub used $SPIKE_CORE_PATH/a dotfile, so the production sidecar read is new and is an S2 acceptance item.)
Interface vs implementation split. The carrier shape — the Contents/ path, its UTF-8 absolute-path encoding, and the two-branch resolution order — is a locked interface (S3 the producer and the stub the consumer must agree on the on-disk carrier). The read mechanism — opening the sidecar, dlopening the core, and the main-thread hand-off — is S2-to-prove.
Carrier (the producer/consumer wire contract). The carrier is the sidecar file Contents/ — a single line, the absolute path to ROOT's core. S3 (#2281) is the producer (writes it when stamping a LEAF); the stub (S2) is the consumer. ROOT has no sidecar (it falls to rule 2 → its own bundled core).
Which path gets baked. S3 runs inside ROOT, so it bakes ROOT's stamp-time resolved absolute path — i.e. wherever ROOT actually is at generate time (via the repurposed resolve_running_app_bundle(); see below). Generating from a ROOT that lives outside / (e.g. ~/) yields LEAVES bound to that location — honest, not assumed-canonical.
Assemble-time locate-or-structured-error (S3). Before minting a LEAF, S3 verifies ROOT's core file exists at the derived path. If absent/unreachable → return a structured tagged AssembleError (do not mint a dead LEAF). There is no runtime relocation/recovery/prompt UI anywhere.
Launch-time missing-core (stub). If the resolved core path does not exist at launch (e.g. ROOT was later moved or deleted), the stub surfaces a plain structured error ("shared core not found — regenerate this app from the manager") and exits. This honors the user's locked trade-off: apps die if the core is moved/deleted. No relocation, no recovery UI.
Auto-update caveat (for S8 — do not overclaim). A LEAF picks up a new core only if ROOT is updated in place at the same path. Updating ROOT in place → all LEAVES get the new core. Moving ROOT to a new path → LEAVES die (must be regenerated). S8 must phrase it as "auto-updates as long as ROOT is updated in-place" — the way the old docs over-claimed "self-contained", do not now over-claim "auto-update".
Install-location asymmetry (one explicit sentence for S3/S8). LEAVES install to the per-user ~/Applications/<name>.app (unchanged; reuse child_app_dest). The shared core lives in ROOT's bundle (typically /). LEAF and core are deliberately not co-located.
(3) Identity-carrier — NONE
No env-var and no argv carrier. current_exe() from inside the dlopen'd core resolves to the stub (S0 (b), proven by running the real resolve_exe_app_name() inside the loaded core), so resolve_exe_app_name / resolve_role and the four main.rs resolvers (resolve_app_name, resolve_app_config_dir → ~/.config/zudotext/<name>/, and the project-root resolver as it stood at the time) all work unchanged.
(Historical note: this contract was written before the cloud pivot. The bundle mechanics it locks are unchanged, but resolve_project_root no longer resolves a per-app workspace under ~/Documents/zudo-text/<name>/ — an instance is bound to a workspace via config.json v2. See Settings & Configuration.)
ZUDOTEXT_APP_NAME stays dev-only (it flips only the role boolean under debug_assertions) and is not repurposed for release identity.
Note (carry into the S4 edit): the core-path sidecar carries the core location, not app identity. Identity comes entirely from the .app stem. Do not conflate the two.
(4) Stub contract
Stub binary name. The prebuilt stub is a tiny generic Mach-O shipped as a ROOT bundle resource (S2). The assembler clones it to
Contents/MacOS/<app_name>per LEAF (e.g.Contents/). ROOT's stub isMacOS/ modmsg Contents/.MacOS/ zudotext CFBundleExecutable= the stub binary stem =<app_name>(for LEAVES) — CHANGED from the COW helper, which deliberately left"zudotext"because the COW inner binary wasContents/. A stub has its own executable named per-app, soMacOS/ zudotext CFBundleExecutablemust equal it or the bundle won't launch. ROOT'sCFBundleExecutablestayszudotext.Icon. The stub bundle carries its own
Contents/;Resources/ icon. icns apply_child_plist_keysmust declareCFBundleIconFile(value"icon"). A default.icnsships with the stub skeleton; the assembler swaps it when the user suppliesicon_path(existing behavior preserved).Info.plistsource — skeleton, not a COW of ROOT's plist. LEAVES are no longer byte-copies, so they can't inherit ROOT's fullInfo.plist. S2 ships a "stub skeleton" as a ROOT bundle resource: the prebuilt stub Mach-O + a complete, launchable templateInfo.plist+ a default.icns.apply_child_plist_keysis modified to transform the template plist (not ROOT's copied plist). S1 owns exactly these keys the assembler overwrites:CFBundleName,CFBundleDisplayName,CFBundleIdentifier,CFBundleExecutable,CFBundleIconFile. Everything else the template needs to be a launchable Tauri bundle (CFBundleVersion,LSMinimumSystemVersion,NSHighResolutionCapable,macOSPrivateApi-related, URL-scheme/capability entries, …) is S2 to determine empirically — S1 does not enumerate it.CFBundleIdentifierformula unchanged:com.takazudo.<app_name>(sharedchild_bundle_identifier). The spike's gate (d) proved per-LEAF data isolation keys off this stub identifier, not the core's compile-bakedcom.takazudo.zudotext— distinct identifiers ⇒ zero cross-contamination.
(5) Identity-threading verdict — NO-OP (S4 / #2282 closes out)
Per (3): current_exe() resolves to the stub, so no stub→core identity threading is required. S4 (#2282) makes no code changes and documents itself as not-required.
(6) Library-validation — NOT required (plain ad-hoc)
Signing posture is locked to plain ad-hoc, NO hardened runtime → NO entitlement (the COW posture). S2 does not add com.apple.security.cs.disable-library-validation. The spike's signing matrix proved a plain-ad-hoc stub dlopens an ad-hoc external core with no entitlement; only a hardened-runtime stub failed (different Team IDs) and needed exactly com.apple.security.cs.disable-library-validation. So:
If hardened runtime / notarization is ever adopted, the stub MUST carry
com.apple.security.cs.disable-library-validation. Until then, none.
(7) Out of S1 scope
ztadmin/writingfate is not spike-dependent and is owned by S6a (#2285) (which retainstauri.conf.writing.json+tauri.conf.ztadmin.jsonas dev-entry configs). Not decided here.
Propagated specifics (baked into downstream issues)
Build the core
--features tauri/custom-protocol(S2 / #2280) — or blank window. Locked above.Window-title polish (S5 / #2284). Every LEAF's window title bar shows the compile-baked
package_info().name="zudotext"(the Dock/OS identity is correctly the stub). Runtime-set the window title from the stub identity (resolve_exe_app_name) so LEAVES show their own name. Relevant Rust command:src/.commands/ window_ title. rs Pre-existing doc bug for S8 (#2288).
app-generation.mdxstep 5 says install goes to/, but the code installs to the per-userApplications/ {name}. app ~/Applications/{name}.app. Fix while rewriting.
What S1 locks vs. what S2 must prove
| Locked here (interfaces — agree across waves) | S2 to discover / prove (implementation) |
|---|---|
| ROOT = stub + shared core, one codepath, macOS-only; iOS unchanged | The cargo tauri build plumbing that emits ROOT as stub + core-in-Frameworks while keeping pnpm tauri:build / writing:dev runnable |
Core lives in <ROOT>/Contents/Frameworks/; built --features tauri/custom-protocol | dylib-vs-.framework packaging; exact @rpath/install_name so it dlopens from a foreign bundle |
Resolution rule: sidecar Contents/ (abs path) → else bundled Frameworks core | The stub actually reading the sidecar + dlopen + main-thread hand-off |
| Carrier = sidecar file (producer S3, consumer stub); ROOT has none | The exact stub skeleton contents + the complete template Info.plist key set |
| Identity keys the assembler overwrites: Name / DisplayName / Identifier / Executable / IconFile | — |
| Identity-threading = no-op; library-validation = none (ad-hoc) | — |
Repurpose (not remove) resolve_running_app_bundle() | — |
Progress step-id vocabulary (S3-owned, consumed by S5/S7b): stamp-stub, locate-core, icon (opt), sign, quarantine, swap, scaffold, config | — |
Downstream edit map (this S1 wave)
| Issue | Locked specifics applied |
|---|---|
| S2 #2280 | ROOT-model A; core in Frameworks (packaging S2's call); build --features tauri/custom-protocol; no library-validation entitlement (conditional removed); ship the stub skeleton (stub Mach-O + launchable template Info.plist + default .icns); stub reads core-path sidecar (S2-to-prove); iOS/[lib] rlib guard kept |
| S3 #2281 | clone the stub skeleton; set CFBundleExecutable=<name> + CFBundleIconFile; write the Contents/ sidecar (ROOT's stamp-time absolute core path); own the progress step-id vocabulary (stamp-stub/locate-core/icon opt/sign/quarantine/swap/scaffold/config); repurpose, not remove, resolve_running_app_bundle(); assemble-time locate-or-structured-error; LEAF in ~/Applications, core in ROOT |
| S4 #2282 | NO-OP — close out; document; core-path carries location, not identity |
| S5 #2284 | new step ids matching S3's locked vocabulary (stamp-stub/locate-core/icon opt/sign/quarantine/swap/scaffold/config; step is a free-form string); microcopy fix (step-assemble.tsx:83); add the window-title-from-stub-identity polish |
| S6a #2285 | the headless stamping CLI reuses the same stub-skeleton-clone + core-locate primitives; ztadmin/writing retention unchanged |
| S7a #2286 | flip the CFBundleExecutable assertion (now <name>) + assert CFBundleIconFile; add core-path sidecar + locate-or-error assertions; drop the COW is_independent_copy guard tests |
| S7b #2287 | renderer tests updated for the new step ids; mock e2e gen spec |
| S8 #2288 | document the locked model (ROOT=stub+core, core in Frameworks, sidecar resolution, die-if-moved structured error, auto-update only if ROOT updated in-place, LEAF-vs-core asymmetry); fix the / vs ~/Applications bug |
Human checkpoints (still in force)
Post-W0:
B-GO(cleared).Post-S2: mandatory second checkpoint — S2 productionizes the full stack (
@rpath/install_name, all plugins stressed, axum, pty, notify watchers). Pause before W3 fans out.