Settings & Workspace Binding API
Two related but distinct concerns live here: the settings commands, and the workspace binding commands that decide which workspace this app instance opens.
Read Settings & Configuration first if you have not — it explains the three-layer split these commands sit on. The short version: user preferences are a workspace document (.zudotext.settings.json), and the binding that says which workspace to open is a local file (config.json v2). The Rust commands below own the local half.
Note
The workspace_* Tauri command family — workspace_get_dir, workspace_set_dir, workspace_list_all, workspace_register, workspace_add_existing, workspace_remove, workspace_scaffold, workspace_update_metadata, workspace_list_files, workspace_switch — no longer exists. It was deleted with the workspace concept in #4223; see Retired: the workspace command family below. The two read-only survivors on the bridge were themselves renamed from bridge.workspace to bridge.localDir (#4992) so workspace was free for the account-workspace domain word this epic's later waves (#4991) introduce.
Settings Commands
settings_get
Read settings from the local settings file.
const settings = await invoke<object | null>('settings_get');Returns: object | null — the parsed JSON settings object, or null if the project root is empty, or the file doesn't exist or is invalid JSON.
Behavior: Uses an in-memory cache backed by mtime invalidation. On each call the file's modification time is compared with the cached mtime; unchanged means the cached value is returned without re-reading.
Which store is authoritative. This command is not the primary read path. The synced settings document lives in the workspace and is read through bridge.workspaceFiles (see renderer/). settings_get is the pre-unlock fallback — it serves the appearance load that runs before first paint, and the pnpm dev:rest path where no cloud workspace is armed. In a production desktop build there is no local project root at all, so AppState.project_root is empty and this returns null.
settings_save
Write settings to the local settings file.
const success = await invoke<boolean>('settings_save', {
settings: { /* full settings object */ },
});| Parameter | Type | Description |
|---|---|---|
settings | object | The complete settings JSON object |
Returns: boolean — true on success; false if the project root is empty, the value is not a JSON object, or the write fails.
Validation: The value must be a JSON object (not an array, string, etc.). No deeper schema validation happens in Rust — the frontend and @takazudo/app-defaults own schema enforcement via validateSettings().
Writes never fall back here. The renderer writes the settings document to the workspace; a write that landed in a machine-local file would resurrect the second store the cloud pivot removed and would be invisible to every other device.
settings_save_blocking
Identical body to settings_save, exposed under its own name for the renderer's quit path.
await invoke<boolean>('settings_save_blocking', { settings });The normal write path debounces and fire-and-forgets the IPC. On beforeunload the renderer tears down before an unawaited write lands (#2782), so the quit path calls this non-debounced entry point instead. The distinct name documents the across-quit contract.
The write chokepoint
Every Rust write of .zudotext.settings.json routes through one sanitizer in tauri-, which drops or blanks three groups of keys before persisting:
| Group | Keys | Why |
|---|---|---|
| Transient top-level state | activeDraft, draftCount, similarDocsPanelSize, similarDocsInnerRatio | Per-device; must not travel |
| Retired keys | general.projectRoot; sync.cloudServerUrl, cloudWorkspaceId, cloudEncryptionConfigured | Obsolete local identity fields in a document that now syncs |
| Local bootstrap identity | sync.cloudDeviceId | Device identity, not a preference |
Behavioural sync preferences — cloudRealtimeEnabled, cloudDeviceName — are deliberately not in the identity list and do persist. The workspace id comes from the runtime binding, encryption readiness from bridge state, and the sync-server URL from runtime bootstrap configuration. The remaining list mirrors LOCAL_BOOTSTRAP_IDENTITY_SYNC_FIELDS in packages/; keep the two in step.
Workspace Binding Commands
These read and write ~/ (schema v2). All logic lives in zudotext_core::generator::app_config; the command layer only resolves the running instance's config dir and maps errors for the IPC boundary.
AppBindingDto
The wire shape is a discriminated record rather than a bare Option<string>, so the renderer can report why an instance is unbound instead of guessing. The DTO's field is named workspaceId on the wire (the Rust-side residual rename landed in epic #4991 Wave 4).
interface AppBindingDto {
status: 'bound' | 'unbound';
workspaceId?: string; // present only when status === 'bound'
reason?: 'missing' | 'unreadable' | 'malformed' | 'missing-workspace-id' | 'new-workspace-intent';
}| Reason | Meaning |
|---|---|
missing | No config.json — a fresh install |
unreadable | The file exists but could not be read |
malformed | Present but not parseable as the v2 schema |
missing-workspace-id | No usable workspace.id — either v2-shaped with a blank/absent id, or a retired v1 {"workspace": …} config (pre-release policy ships no migration code, so it just falls through here) |
new-workspace-intent | The instance is unbound because the user is mid-flow toward binding a brand-new workspace |
All four are normal outcomes, not errors. Every one routes to onboarding, and none of them creates local storage as a fallback.
app_binding_read
const binding = await invoke<AppBindingDto>('app_binding_read');Returns: AppBindingDto. Never throws — an unreadable or corrupt file resolves to unbound with a reason.
app_binding_persist
Bind this instance to a workspace, replacing any existing binding.
await invoke<void>('app_binding_persist', { workspaceId: 'workspace-abc123' });| Parameter | Type | Description |
|---|---|---|
workspace_id | string | Workspace to bind to; rejected when blank |
Returns: Result<(), string>.
Behavior: Always forces the write — the caller has just proven the workspace opens, so a stale binding (or a leftover v1 config) must not win over it.
bind-only-after-unlock: call this only after a successful unlock and snapshot. Nothing in Rust enforces the ordering; the renderer's onboarding flow owns it. Persisting earlier can strand the instance on a workspace it cannot open, with no way out from inside the app.
app_binding_clear
const existed = await invoke<boolean>('app_binding_clear');Returns: Result<boolean, string> — whether a config file was actually removed. The next launch resolves as unbound and routes to onboarding.
Bridge facade
const binding = await bridge.appBinding.read(); // AppBindingState
await bridge.appBinding.persist(workspaceId);
const existed = await bridge.appBinding.clear();The bridge narrows the flat Rust DTO into a discriminated union so no caller has to:
type AppBindingState =
| { status: 'bound'; workspaceId: string }
| { status: 'unbound'; reason: AppBindingUnboundReason };Retired: the workspace command family
Every workspace_* Tauri command, the workspaces.json registry, and the Rust workspace_registry module were deleted in #4223.
They were not reimplemented against the workspace. An app instance points at exactly one workspace, so there is nothing to enumerate, register, import, or switch between — the operations lost their referent rather than changing their backing store. The workspace chooser UI went with them (#4214); Cloud-first onboarding is the flow that replaced it.
Existing workspaces.json files on disk are left untouched — per the pre-release policy there is no migration; they are simply never read.
Two read-only methods survive on the bridge's localDir namespace (renamed from workspace in #4992 — no Tauri command behind them on the cloud path), both explicitly transitional:
| Method | Post-pivot behavior |
|---|---|
getDir() | Returns WORKSPACE_SYNTHETIC_ROOT — a synthetic absolute-looking value, never a real directory |
listFiles(path, filter?) | Enumerates the workspace model beneath that synthetic root; the filter contract is unchanged |
Both exist only to keep absolute-path-shaped callers working during migration. New code should use bridge.workspaceFiles, which speaks workspace-relative POSIX paths and never sees the synthetic root. Never use the synthetic root as an identity — it is the same value in every workspace; use the workspace id.
Utility Commands
fonts_list
List available system font families.
const fonts = await invoke<FontFamily[]>('fonts_list');interface FontFamily {
family: string; // Font family name (e.g., "JetBrains Mono")
isMonospace: boolean; // Whether the font is detected as monospace
}Returns: FontFamily[] — sorted alphabetically, deduplicated. Empty array on non-macOS platforms.
Behavior: Uses the font-kit crate to enumerate system fonts, loading each to check is_monospace(). Deduplicated by family name, preferring isMonospace: true when a family appears more than once.
set_window_opacity
await invoke('set_window_opacity', { opacity: 0.8 });| Parameter | Type | Description |
|---|---|---|
opacity | number | Opacity value (clamped to 0.3–1.0) |
Returns: Result<(), string> — errors if the native window handle can't be obtained.
Behavior: On macOS, accesses the underlying NSWindow and calls setAlphaValue(). Values below 0.3 are clamped to prevent invisible windows. No-op on non-macOS.
print_webview
await invoke('print_webview');Returns: Result<(), string>. On macOS, accesses the underlying WKWebView and triggers the native print dialog. No-op elsewhere.
reveal_directory
Open a directory in the OS file manager (Finder on macOS).
await invoke('reveal_directory', { path: '/Users/me/Projects/notes' });| Parameter | Type | Description |
|---|---|---|
path | string | Absolute path to reveal |
This addresses your real filesystem, not the workspace — workspace entries have no OS path to reveal.
get_project_root
const root = await invoke<string>('get_project_root');Returns: string — AppState.project_root. This is the local root for the surviving local-file surfaces (assets, skills roots), not where user content lives. It is the repo root in dev, the sandbox documents directory on iOS, and an empty string in a production desktop build.
get_home_dir
const home = await invoke<string>('get_home_dir');Returns: string — the home directory (e.g. /), or an empty string if it can't be determined.