zudo-text

検索したい単語を入力

いつでも検索バーを開ける

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-GO on 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 into app-generation.mdx / manager-app.mdx after 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-.framework packaging, the exact template Info.plist key 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 (execv the core) is dead. It collapses identity to the core (current_exe / NSBundle.mainBundle become 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/MacOS/zudotext 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.app is itself a launcher stub whose code lives in Contents/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 the dlopen'd core resolves to the stub (_NSGetExecutablePath returns the main executable), so the existing stem-based role detection works with zero app_mode.rs changes.

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/build.rs: 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 cdylib like the spike, or a versioned .framework) as long as it satisfies the resolution rule below. The spike's core was a single self-contained cdylib (Rust deps static-linked; only system frameworks dynamic), so @rpath/install_name work is light; whatever S2 chooses, the core must dlopen cleanly 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/Frameworks/ZudotextCore.framework/Versions/A/ZudotextCore — 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:

  1. If a sidecar file Contents/Resources/core-path exists in the stub's own .app (a plain UTF-8 absolute path), dlopen that path. (LEAVES.)

  2. Otherwise, dlopen <own .app>/Contents/Frameworks/libzudotext_core.dylib. (ROOT.)

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/Resources/core-path 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/Resources/core-path — 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 /Applications (e.g. ~/Downloads/zudotext.app) 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 /Applications/zudotext.app/Contents/Frameworks/). 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/MacOS/modmsg). ROOT's stub is 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 was Contents/MacOS/zudotext. A stub has its own executable named per-app, so CFBundleExecutable must equal it or the bundle won't launch. ROOT's CFBundleExecutable stays zudotext.

  • Icon. The stub bundle carries its own Contents/Resources/icon.icns; apply_child_plist_keys must declare CFBundleIconFile (value "icon"). A default .icns ships with the stub skeleton; the assembler swaps it when the user supplies icon_path (existing behavior preserved).

  • Info.plist source — skeleton, not a COW of ROOT's plist. LEAVES are no longer byte-copies, so they can't inherit ROOT's full Info.plist. S2 ships a "stub skeleton" as a ROOT bundle resource: the prebuilt stub Mach-O + a complete, launchable template Info.plist + a default .icns. apply_child_plist_keys is 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.

  • CFBundleIdentifier formula unchanged: com.takazudo.<app_name> (shared child_bundle_identifier). The spike's gate (d) proved per-LEAF data isolation keys off this stub identifier, not the core's compile-baked com.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 / writing fate is not spike-dependent and is owned by S6a (#2285) (which retains tauri.conf.writing.json + tauri.conf.ztadmin.json as 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.mdx step 5 says install goes to /Applications/{name}.app, but the code installs to the per-user ~/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 unchangedThe 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-protocoldylib-vs-.framework packaging; exact @rpath/install_name so it dlopens from a foreign bundle
Resolution rule: sidecar Contents/Resources/core-path (abs path) → else bundled Frameworks coreThe stub actually reading the sidecar + dlopen + main-thread hand-off
Carrier = sidecar file (producer S3, consumer stub); ROOT has noneThe 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)

IssueLocked specifics applied
S2 #2280ROOT-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 #2281clone the stub skeleton; set CFBundleExecutable=<name> + CFBundleIconFile; write the Contents/Resources/core-path 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 #2282NO-OP — close out; document; core-path carries location, not identity
S5 #2284new 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 #2285the headless stamping CLI reuses the same stub-skeleton-clone + core-locate primitives; ztadmin/writing retention unchanged
S7a #2286flip the CFBundleExecutable assertion (now <name>) + assert CFBundleIconFile; add core-path sidecar + locate-or-error assertions; drop the COW is_independent_copy guard tests
S7b #2287renderer tests updated for the new step ids; mock e2e gen spec
S8 #2288document 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 /Applications vs ~/Applications bug

Human checkpoints (still in force)

  1. Post-W0: B-GO (cleared).

  2. 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.