App Lifecycle
Startup Sequence
The Tauri app startup is defined in tauri- (the run() function — the entry point for dev/iOS builds and for the shared core dylib):
1. Register plugins (deep-link, dialog, haptics, notification, clipboard;
shell on non-iOS targets)
2. Suppress the macOS press-and-hold accent menu
3. Create the splash window (frameless, transparent, always-on-top)
4. Resolve the per-app config directory
5. Read this instance's WORKSPACE BINDING from config.json v2 — Bound or Unbound
6. Resolve the local project root (only for the surviving local-file surfaces)
7. Initialize AppState (Arc-wrapped for sharing with the HTTP server)
8. Start the REST adapter HTTP server (dev only, port 3001)
9. Start the archives file watcher (non-iOS; feeds the dev SSE channel)
10. Create the main window (initially hidden)
11. Build and set the application menu, register menu event handlers
12. Show the main window and close the splash once the frontend finishes
loading (on_page_load callback)Step 5 is the one that identifies the instance. Note what is not in this list: there is no workspace registration step, and no directory is created on the user's behalf.
let app_config_dir = resolve_app_config_dir();
// Resolve this instance's WORKSPACE BINDING (epic #4204 D2). The binding — not a
// local directory path — is what identifies an app instance now.
let workspace_binding = zudotext_core::generator::app_config::read_binding_at(
&zudotext_core::generator::app_config::resolve_config_path_in(&app_config_dir),
);
match &workspace_binding {
AppBinding::Bound { workspace_id } => eprintln!("workspace binding: bound to {workspace_id}"),
AppBinding::Unbound(reason) => {
eprintln!("workspace binding: unbound ({reason:?}) — onboarding required")
}
}Both outcomes are logged with their reason, so a support report can tell the fall-throughs apart.
Workspace Binding Resolution
The backend derives the app name from the .app bundle stem (e.g. ~/ → ztoffice) and reads ~/, schema v2:
{ "workspace": { "id": "workspace-abc123" } }Bound — the workspace id parsed. The renderer proceeds to unlock that workspace.
Unbound — the file is missing, unreadable, unparseable, or has a blank/absent
workspace.id(which also catches a retired v1{"workspace": …}config — it has noworkspace.ideither). All four resolve toUnbound, which routes the renderer to onboarding. The specificUnboundReason(missing/unreadable/malformed/missing-workspace-id) rides along on the result.
Unbound is a first-class outcome, not an error, and it never auto-creates a local directory. The pre-pivot resolve_project_root() returned a bare String and silently fell back to scaffolding ~/Documents/zudo-text/<appname>/, which made the fall-throughs indistinguishable to the caller and produced empty directories a user never asked for. Both the fallback and that signature are gone (epic #4204 D2).
See Settings & Configuration for the schema and the bind-only-after-unlock rule, and App Generation for how a generated LEAF gets its binding.
Local project root
A local project root still exists, but it is a much smaller thing than it was: it exists only for the surviving local-file surfaces (assets, skills roots), not as the store for user content. It no longer comes from config.json.
fn resolve_local_project_root() -> Option<String> {
// iOS: the app sandbox documents directory.
// Dev (non-iOS): the repo root, the parent of tauri-app/.
// Production (non-iOS): None.
}The Option return is the point — in a production desktop build there simply is no local project root, and AppState.project_root is empty. The workspace registry and the scaffold / import / discovery machinery that used to key off this value were deleted wholesale in #4223.
Window Management
Main Window
Size: 1400 × 800 pixels
Starts hidden, shown after content finishes loading (via
on_page_load)In dev mode: loads the Vite dev server at
http:/ / localhost: 1420 In production: the macOS release binary is a KB-scale thin-launcher stub (
tauri-) thatapp/ src/ stub. rs dlopens the shared core dylib (Contents/); the core loads the bundled frontendFrameworks/ libzudotext_ core. dylib
Splash Window
Size: 400 × 200 pixels
Frameless, transparent, always-on-top
Loads
frontend/splash. html Closed when the main window's frontend finishes loading (
on_page_loadcallback)
macOS Lifecycle
The application menu is a declarative table, tauri- (menu_for(is_root)), rendered by tauri- (create_menu) — the Rust side owns rendering and dispatch only and must not re-derive the layout (epic #5913 D5). Six submenus:
App (macOS only) — About, Settings…, Services, Hide/Hide Others/Show All, Quit
File — New Note, New Window (
Cmd+Shift+N— deliberately notCmd+N, which the renderer's ownnewDraftshortcut owns; see thefile_new_windowcomment inmenu_spec.rs), Print, Close WindowEdit — Undo/Redo, Cut/Copy/Paste, Paste and Match Style (macOS only), Select All, Format Markdown
View — Show Workspace Switcher and Show Global Header (checkable, ROOT-only for the switcher item), Toggle Editor/Preview, Command Palette…, Reload / Force Reload, Toggle Developer Tools (
Alt+Cmd+I), Zoom controls (Actual Size/In/Out via CSS zoom), Toggle Full ScreenWindow — Minimize, Maximize, Show All (macOS only)
Help — Help feature catalog, Keyboard Shortcuts
Every entry also carries a macos_only flag independent of root_only; see the module doc in menu_spec.rs for how the two axes compose (a submenu-level flag hides the whole submenu, an entry-level flag hides just that item within an otherwise cross-platform submenu).
The renderer-command bridge
Most items are not handled in Rust at all — a handful (New Window, Reload/Force Reload, Toggle Developer Tools, Print, zoom, Paste and Match Style, Quit) are Native and run entirely on the Rust side, but everything else (Settings…, New Note, Format Markdown, both View checkboxes, Toggle Editor/Preview, Command Palette…, the Help items) is a RendererCommand or Check entry that Rust just forwards (epic #5913 D3):
register_menu_handler(native/) looks up the clicked item's id against a table built from the samemenu. rs menu_for(is_root)spec, and callsdispatch_renderer_command(app_handle, command_id)with the entry'scommand_id— a command-palette id, not the menu item's own id.That function emits a
zudotext-native-menu-commandCustomEvent(detail: { commandId }) on themainwebview window only (app_handle.get_webview_window("main")).The renderer's
useNativeMenuCommandshook (renderer/, mounted inhooks/ use- native- menu- commands. ts app.tsx) listens for that event and routescommandIdthrough the live command-palette list (runCommandById) — which already refuses disabled/status rows — or opens the palette for the one non-palette id,"command-palette".
The reverse direction — renderer state pushing a checkmark onto the native menu — goes through the window.setMenuItemChecked(id, checked) bridge method (Tauri command menu_set_checked), called by useNativeMenuCheckedSync whenever the workspace-switcher or global-header visibility preference changes (not their zen-mode-adjusted effective visibility — see that hook's doc comment). native-menu-ids.ts's NATIVE_MENU_ID map and menu_spec.rs's entry ids are the same string set on both sides — sub #5925 diffs them in CI-adjacent tooling to catch drift.
Limitation: one menu, targeted at main
The native menu is app-wide (app.set_menu(menu) in lib.rs, macOS's single global menu bar), but both halves of the bridge are written against the main window specifically: dispatch_renderer_command looks up get_webview_window("main"), and menu_set_checked walks the one shared app.menu() object regardless of which window is frontmost. A second window (via File → New Window) therefore does not get its own independent checkmark state — it shares the single menu that main's useNativeMenuCheckedSync effect last pushed to, so its display always mirrors main's workspace-switcher/header visibility rather than its own.
Frontend Initialization
When the main window loads, the React frontend bootstraps via renderer/, which calls bootstrapTauri() from renderer/.
bootstrap/ (before render):
Calls
initBackend(createTauriAdapter())to wire the Tauri IPC adapterDetects popout-window mode (hash prefix
/) and branches accordinglypopped- out/ Calls
loadAndApplyAppearanceBeforeMount()to apply the color theme synchronously before first paintCalls
createRoot().render()— mounting the boot gate (orPoppedOutPagein popout mode)
The boot gate
bootstrap/ is the one component that decides what the user sees before <App> mounts, and it is shared by the desktop, iOS, and browser builds. Desktop previously had no boot gate at all — <App> mounted directly and the unlock prompt was a dismissible overlay inside it, because a locked workspace still left local files editable. With the workspace as the only store, unlock is a precondition for a usable editor on every platform, so the gate sits above <App>:
0. Auth — sign-in until bridge.auth reports authenticated
1. Boot route — read this instance's binding + the account's workspaces,
then attempt to arm it (rearm/unlock tries the local
encrypted workspace mirror before falling back to the
network snapshot — epic #4808)
2. Offline gate — ONLY reached if the arm attempt failed with "unavailable"
while offline: no usable local mirror exists yet (first
launch on this device, or right after sign-out), so show
a blocking offline screen (all platforms, not iOS-only)
until connectivity returns
3. Screens — welcome / genesis wizard / workspace picker / unlock / error,
each able to route onward without a page reload. A successful
zero-workspace listing always selects welcome and best-effort
clears any stale binding; its CTA enters genesis explicitly.
4. <App> — once the workspace is armed and the model is seeded, whether
that arm came from the network or, offline, from the
local mirror (a "working offline" indicator shows for the
rest of the session in the latter case) An arm off the local mirror with no network never reaches step 2 — it lands straight on step 4, per D7 ("reads work fully offline once the workspace is armed"). Step 2 is reserved for the case a mirror cannot help with: nothing local to seed from at all. Once <App> mounts, the workspace model is proven seeded and reads work fully offline for the rest of the session — no guard reappears inside <App> for a transient reconnect blip. The routing rules and the bind-only-after-unlock ordering live in renderer/; app-boot.tsx is the React shell around them. See Encrypted Local Workspace Mirror for the mirror-hydrate and offline fail-closed arm mechanics, and Cloud-Primary Storage D7 for the guarantee/limitation split.
Async (after <App> mounts, via React effects):
Loads the settings document and applies the color theme + semantic colors
Reads layout preferences from settings
Sets up keyboard shortcut bindings from the saved shortcut configuration
Because settings are a workspace document, these read from the armed workspace rather than from a local file — see Settings & Configuration.
Popout Windows
A popped-out frame is a separate WKWebView and therefore a separate JS realm: every module-scoped singleton starts empty, including the workspace model and the encryption keys. Each popout runs the same arm sequence the host does (bridge.appBinding.read() → bind → re-arm from the stored key) behind a React gate, rather than proxying reads through the host window.
Windows share persistent stores: WKWebView storage is shared, and on desktop macOS every window of the same app identity reaches the same per-workspace login Keychain item. A popout must therefore not fight the host over state keyed only by workspace id: it uses a window-scoped outbox key, a window-suffixed device id, keeps its pull cursor in memory only, and never clears the stored master key on failure. See D13 in Cloud-Primary Storage for the full contract.