zudo-text

検索したい単語を入力

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

App Generation

zudo-text is a config-driven application generator. ROOT (zudotext.app) hosts the ONE shared core and generates any number of LEAF text apps — each with its own .app name and its own cloud workspace — as KB-scale stub bundles that share ROOT's core. Generating a LEAF takes seconds and requires no Rust toolchain.

What distinguishes one generated app from another is its workspace binding: ~/.config/zudotext/<name>/config.json (schema v2) names exactly one encrypted cloud workspace, and everything the instance shows — notes, archives, pins, settings, framesets — lives inside that workspace. There is no per-app workspace directory (epic #4204 D2).

ROOT / LEAF Role Model

Every .app bundle built from this codebase has one of two roles at runtime:

  • ROOTzudotext.app. A thin stub + the one shared core (Contents/Frameworks/libzudotext_core.dylib). Runs the full writing-app subsystems (editor, workspace sync, boards, AI) and additionally exposes the generator pipeline (the leaf-app icon in the toolbar).

  • LEAF — any generated text app (modmsg.app, prompts.app, worktext.app, ztoffice.app, …). A KB-scale stub bundle pointing at ROOT's core via a Contents/Resources/core-path sidecar. Runs all the same writing-app subsystems as ROOT; the generator surface is not exposed. Each LEAF depends on ROOT remaining at the path it was generated from. See Known Limitations below.

The thin-launcher model (Chrome app-shim pattern)

ROOT and every LEAF share one Rust core on disk — the same model Chrome uses for its PWA app-shims. ROOT is itself a thin stub that dlopens the shared core living in its own Contents/Frameworks/; every LEAF is a KB-scale stub that dlopens ROOT's core via an absolute-path sidecar.

ROOT:  zudotext.app/Contents/MacOS/zudotext   (stub)
                    Contents/Frameworks/libzudotext_core.dylib  ← the ONE shared core
                    [no core-path sidecar]

LEAF:  modmsg.app/Contents/MacOS/modmsg       (stub)
                  Contents/Resources/core-path  →  /Applications/zudotext.app/…/libzudotext_core.dylib

The stub resolves the core at launch via a two-branch rule:

  1. If Contents/Resources/core-path exists in the stub's bundle (a plain UTF-8 absolute path), dlopen that path. (LEAFs)

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

Runtime role detection

Role is determined from the .app bundle stem at runtime, not from a compile-time product name. The relevant Rust module is tauri-app/src/app_mode.rs; the public function resolve_exe_app_name() walks current_exe() ancestors to extract the .app stem:

ROOT  ⟺  .app stem == "zudotext"    (resolve_exe_app_name() == ROOT_APP_NAME)
LEAF  ⟺  any other stem              (modmsg, prompts, ztoffice, …)

Identity is derived from NSBundle.mainBundle / current_exe() — both resolve to the stub binary, not the shared core — so the existing stem-based role detection works with no changes to app_mode.rs.

Dev-mode fallback: under debug_assertions there is no .app ancestor, so resolve_exe_app_name() returns "default". Policy: debug defaults to ROOT. Set ZUDOTEXT_APP_NAME=<name> (where <name> is not zudotext) to exercise LEAF locally (e.g. pnpm writing:dev).

iOS: always LEAF — there is exactly one app identity per iOS install. iOS stays a single binary (no stub/core split).

No-Compile Child Assembly

Generating a LEAF does not require a Rust toolchain or a cargo build. The ROOT app assembles a LEAF entirely from its own stub skeleton:

  1. Stamp the stub — clone ROOT's prebuilt stub skeleton (a KB-scale Mach-O stub + template Info.plist + default .icns) into a hidden staging directory.

  2. Locate the core — resolve the absolute path to ROOT's shared core (Contents/Frameworks/libzudotext_core.dylib). If the core file is absent or unreachable, return a structured error immediately and abort — no dead LEAF is minted.

  3. Write the core-path sidecar — write the resolved absolute path into the staged bundle's Contents/Resources/core-path. This is the wire that binds the LEAF stub to ROOT's core.

  4. Edit Info.plist — overwrite the identity keys: CFBundleName, CFBundleDisplayName, CFBundleIdentifier, CFBundleExecutable (set to <name>), CFBundleIconFile, and the per-app deep-link scheme CFBundleURLName/CFBundleURLSchemes (zudotext-<name>; see Deep-Link Scheme Protocol).

  5. Ad-hoc signcodesign --force --sign -. The sign step runs after the plist edit and before quarantine strip; this is the last write to the bundle.

  6. Strip quarantinexattr -dr com.apple.quarantine (best-effort). Removes the Gatekeeper quarantine flag so the child launches without a Gatekeeper prompt.

  7. Install — move the staged bundle to ~/Applications/{name}.app (the per-user Applications folder, not the system /Applications/).

  8. Write the workspace binding (epic #4204 D2/D12, S23/#4227; new-workspace-intent marker added #4524/B1) — when the caller supplies an existing workspace id, write ~/.config/zudotext/{name}/config.json (schema v2, {"workspace":{"id":…}}) so the new LEAF opens that workspace on launch. When the caller instead sets newWorkspaceIntent (no workspace id — the ROOT generate dialog's "Create a new workspace…" picker option), write {"newWorkspaceIntent": true} instead: this marks the instance so its own boot flow (workspace-boot.ts) offers genesis/the picker rather than silently auto-attaching to the account's one existing workspace, which a bare unbound LEAF would do (#4517). When neither is supplied, this step is skipped entirely (any stale config from a previous install of the same name is cleared instead) and the fresh LEAF launches unbound with no particular intent. In every no-workspace-id case the fresh LEAF runs its own onboarding (workspace picker + genesis) on first launch — genesis itself never runs inside the already-booted ROOT. Pre-pivot this step scaffolded a local workspace directory instead; there is no local workspace left to scaffold under the workspace model.

This pipeline completes in seconds. No network access, no compilation, no external toolchain beyond codesign and xattr (both ship with macOS).

Install-location asymmetry

LEAF apps install to ~/Applications/<name>.app (per-user). The shared core lives in ROOT's bundle — typically /Applications/zudotext.app/Contents/Frameworks/libzudotext_core.dylib. LEAF and core are deliberately not co-located.

Gatekeeper behavior (expected — pending macOS confirmation #2256)

Children are ad-hoc signed and never notarized. With quarantine stripped by the assembler, a locally-generated bundle is expected to launch with no Gatekeeper prompt (or a one-time right-click → Open if one appears). This behavior has not been confirmed on a real macOS host. If you encounter a prompt, strip quarantine manually:

xattr -dr com.apple.quarantine ~/Applications/{name}.app

Developer-ID signing and notarization of zudotext.app itself (the ROOT app that end users download) is a separate distribution prerequisite and is not covered by the current development cycle. No signing configuration exists today.

App Name Resolution

When a .app launches, the stub's identity comes from NSBundle.mainBundle / current_exe(), which both resolve to the stub binary. The Rust backend walks up from the stub executable path to find the .app bundle directory:

~/Applications/ztoffice.app/Contents/MacOS/ztoffice
                ^^^^^^^^
                app name = "ztoffice"

Each stub binary is named after its app (e.g. ztoffice), and the .app wrapper directory reflects the app stem. The backend reads ~/.config/zudotext/ztoffice/config.json (schema v2, {"workspace":{"id":…}}) to find the workspace the instance is bound to (epic #4204 D2).

If no config exists (or it fails to parse, or is the retired v1 {"workspace":…} shape), the instance is Unbound and routes to onboarding — there is no local-directory fallback.

Developer CLI (pnpm generate)

For developers working in the repo, pnpm generate (scripts/generate-app.ts) binds an app instance's config.json v2 to a workspace. There is no local workspace directory to scaffold post-pivot, and it does not build a bundle — under the thin-launcher model there is no per-app cargo build:

# Bind to an already-known workspace (no network calls)
pnpm generate <app-name> --workspace-id workspace-abc123

# Mint a brand-new workspace and seed it, then bind to it (requires cloud auth)
pnpm generate <app-name> --genesis --preset full

# Bind to a deterministic mock-<app-name> placeholder, no network calls
pnpm generate <app-name> --mock

# Leave the instance unbound — it onboards on first launch
pnpm generate <app-name>

pnpm generate --help

The three binding modes are mutually exclusive. --genesis runs the same cloud-first genesis flow the app's onboarding uses (computeScaffold()runGenesisScaffold()) and therefore needs cloud auth — --server-url / --token / --device-id / --password, or the matching ZUDOTEXT_* env vars. --mock exists for the pnpm dev:mock browser entry, where a network-free placeholder binding is enough. Do not stamp a --mock binding for an installed LEAF: an installed .app always runs the Tauri adapter, which can never resolve a mock-<name> id — scripts/build-debug-apps.sh therefore binds a real --workspace-id or leaves the instance unbound so it boots to onboarding.

LEAF bundles come from the no-compile assembler — either the ROOT app's assembleChild (described above) or the headless stamp-leaf CLI (tauri-app/core/src/bin/stamp-leaf.rs). Both clone the prebuilt stub skeleton and point it at ROOT's shared core; neither runs cargo.

Settings

An instance's settings are a document inside its workspace (.zudotext.settings.json), not a file beside a workspace directory. Two apps bound to different workspaces can therefore have entirely different color schemes, editor settings, pins, and shortcuts, while the same app opened on two machines looks identical on both. See Settings & Configuration for the schema and the three-layer split.

Genesis presets

When a brand-new workspace is created — through onboarding or pnpm generate --genesis — a preset decides what content it is seeded with:

  • minimal — the settings document + CLAUDE.md

  • standard (default) — minimal plus an inbox seed note

  • fullstandard plus a sample pin note and a sample skill file at .zudotext/skills/example-skill.md

The preset only affects the initial content push; it is not a persistent property of the app. See @takazudo/app-scaffold for the API.

full's sample skill is ready to load as a working inline-AI skill: it carries valid name/description frontmatter and sits at .zudotext/skills/example-skill.md, the directory the inline-command loader actually scans. It does not load until the user opts in, though — the workspace skills layer is gated on AppSettings.inlineAiCommand.trustWorkspaceSkills, which defaults to off (Settings → Inline AI command → Trust workspace skills). See Inline AI command skills for how to write your own alongside it.

Known Limitations

(a) LEAFs auto-update only when ROOT is updated in place

Each LEAF points at ROOT's shared core via an absolute-path sidecar baked at generation time. If ROOT is updated in place at the same path, all LEAFs pick up the new core automatically on their next launch — no re-generation needed. However, moving ROOT to a different path kills all LEAFs (see (c)). Do not interpret "auto-update" as an unconditional guarantee.

(b) Child deletion orphans state

Deleting {name}.app from ~/Applications/ leaves ~/.config/zudotext/{name}/ behind — the workspace binding and per-device state. The workspace itself is untouched: it lives on the server and is owned by the account, not by the bundle, so deleting an app never destroys content.

The ROOT app has no cleanup UI for orphaned config — the dashboard/cleanup view was dropped in #2257 in the interest of scope. Policy: leave orphaned state on disk for data safety. Remove the config dir manually to reclaim it; delete the workspace from the account if you want the content gone too.

Note that re-generating over an existing app name does not leave a stale binding: the assembler clears any previous config.json for that name before the bundle swap, precisely so a replaced app can never silently open a stranger's workspace.

(c) LEAFs die if ROOT is moved or deleted

Each LEAF holds the absolute path to ROOT's core baked in at generation time. If ROOT is moved, renamed, or deleted after a LEAF is generated, the LEAF's stub cannot find the core at launch and exits with a structured error:

shared core not found — regenerate this app from the manager

There is no runtime recovery UI, no locate-or-prompt fallback. The fix is to reinstall ROOT at its original path or re-generate the LEAF from the new ROOT location. This is a deliberate trade-off for the single-core model — see the S1 design contract for the rationale.