zudo-text

検索したい単語を入力

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

Deep-Link Scheme Protocol

Normative spec for the per-app custom URL schemes, locked by epic #4472 decision D1. Registration for ROOT and the developer configs was implemented by #4474; LEAF-generation stamping and the server-side redirect validation are implemented against this page by #4475.

The problem this fixes

Better Auth desktop sign-in is deep-link-only: the system browser finishes the sign-in and redirects to a custom-scheme URL that macOS routes to whichever app bundle registered that scheme. Before this protocol, ROOT (zudotext.app) registered no scheme while every generated LEAF claimed the same zudotext:// (hardcoded in the stub-skeleton Info.plist.template). macOS resolves a multi-claimant scheme to an essentially arbitrary bundle, so the callback landed in a random LEAF whose auth flow was not pending — a silent failure with ROOT waiting forever.

The fix: every app claims exactly one scheme of its own, and no two distinct apps can ever map to the same scheme.

Scheme mapping (normative)

AppSchemeExample callback
ROOT (zudotext.app)zudotextzudotext://auth/callback?ott=…&state=…
LEAF <name>zudotext-<name>zudotext-modmsg://auth/callback?ott=…&state=…
Developer-config builds (tauri.conf.<name>.json)same rule: zudotext-<name>zudotext-writing://…, zudotext-ztadmin://…
  • The bare scheme zudotext is reserved for ROOT. No LEAF or dev build may claim it.

  • A LEAF scheme is the fixed prefix zudotext- followed by the app name, unchanged. The app name must already satisfy the canonical app-name grammar (see below) — the mapping never rewrites it.

Grammar

App names are validated (not normalized — see next section) against the canonical pattern, enforced identically in three mirrored places:

APP_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/
  • TypeScript: APP_NAME_PATTERN / validateAppName() in packages/app-scaffold/src/app-name.ts

  • Rust: is_valid_app_name() in tauri-app/core/src/generator/app_name.rs

  • Renderer dialog: APP_NAME_PATTERN via the @takazudo/app-scaffold/app-name subpath export

The scheme family is therefore:

SCHEME      = ROOT_SCHEME | LEAF_SCHEME
ROOT_SCHEME = "zudotext"
LEAF_SCHEME = "zudotext-" APP_NAME

as one regex:  /^zudotext(-[a-z0-9]+(-[a-z0-9]+)*)?$/

Recovering the app name from a LEAF scheme is unambiguous: strip the fixed-length prefix zudotext- (never split on dashes — app names may contain dashes, e.g. zudotext-zt-notes → app zt-notes).

Every scheme in the family is valid per RFC 3986 §3.1 (scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )): the fixed prefix supplies the required leading letter, so even a digit-leading app name like 2do yields a valid scheme (zudotext-2do).

Normalization rules (normative)

The load-bearing design decision: there is no lossy normalization layer. The name→scheme mapping is prefix-concatenation over an already-validated name. Input that fails APP_NAME is rejected at generation time — never lowercased, stripped, truncated, or otherwise coerced into a valid name.

Why rejection instead of coercion: any lossy normalizer makes two distinct accepted inputs collide (ZT-Notes and zt-notes would both fold to zudotext-zt-notes), which would then require a collision-fallback policy, per-machine collision state, and a way to explain to the user why their app got a mangled scheme. Rejection keeps the mapping injective by construction, so:

  1. Case — app names are lowercase by validation; no case folding is ever applied on the way in. On receipt, treat the scheme part of an incoming URL case-insensitively (RFC 3986 §3.1 — browsers and the OS may alter scheme case in transit; ZUDOTEXT-MODMSG://auth/callback must be accepted and treated as zudotext-modmsg). The path/query are untouched by this rule.

  2. Length — the scheme layer imposes no length limit of its own; a scheme is exactly 9 + len(name) characters ("zudotext-" is 9). Practical bounds come from the app-name layer (macOS caps the <name>.app filename at 255 bytes, so a name is at most 251 chars and a scheme at most 260). If a hard cap is ever wanted, add it to the three app-name validators, not to the scheme mapper. Servers validating an incoming scheme string SHOULD apply a defensive cap of 260 chars before the regex — the maximum reachable scheme length, so no name that can produce an installable app is ever rejected by the cap.

  3. Illegal characters — anything outside [a-z0-9-], plus leading dash, trailing dash, double dash, and the empty string: rejected upstream, never sanitized.

  4. Collision fallback — none exists, deliberately. Two different app names cannot produce the same scheme (injectivity), and two LEAVES with the same name cannot coexist (the assembler refuses when ~/Applications/<name>.app already exists). The only remaining collision class is duplicate bundles of the same app (a stale copy in ~/Downloads, a Time Machine restore) — that is a macOS LaunchServices ambiguity, not a mapping collision; see Verifying registration for remediation.

  5. Reserved name — the app name zudotext is reserved; generators MUST reject it as a LEAF name (#4475 adds this check). Not because of a scheme collision — a hypothetical zudotext LEAF would map to zudotext-zudotext — but because everything else about its identity collides with ROOT: its bundle identifier would be com.takazudo.zudotext, and runtime role detection (resolve_exe_app_name() == "zudotext") would make the LEAF believe it is ROOT and expose the generator surface.

Worked examples

Input app nameVerdictScheme
modmsgvalidzudotext-modmsg
zt-notesvalidzudotext-zt-notes (name recovered by prefix-strip, not dash-split)
notes2validzudotext-notes2
2dovalidzudotext-2do (prefix supplies the RFC 3986 leading letter)
avalidzudotext-a
zudotextrejected — reserved for ROOT—
ZTOfficerejected (uppercase) — never case-folded to ztoffice—
zt_notesrejected (underscore) — never rewritten to zt-notes—
zt--notesrejected (double dash)—
-zt, zt-rejected (leading/trailing dash)—
zt notesrejected (space)—
メモrejected (non-ASCII)—
`` (empty)rejected—

Registration points

ROOT — tauri-app/tauri.conf.json

"plugins": {
  "deep-link": {
    "desktop": {
      "schemes": ["zudotext"]
    }
  }
}

The Tauri v2 bundler reads plugins.deep-link.desktop.schemes at bundle time and bakes a CFBundleURLTypes entry into the produced Info.plist (with CFBundleURLName set to the bundle identifier). pnpm tauri:build (scripts/build-root-thin.sh) produces ROOT's bundle via cargo tauri build --bundles app on this base config, so the built zudotext.app carries:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>com.takazudo.zudotext</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>zudotext</string>
    </array>
  </dict>
</array>

The Rust side needs only tauri_plugin_deep_link::init() (already registered in tauri-app/src/lib.rs) and the deep-link:default capability (already in tauri-app/capabilities/default.json). On macOS the plugin performs no runtime registration — the OS learns the scheme from Info.plist when the bundle is installed/scanned by LaunchServices.

Per-app developer configs — the config-merge hazard

cargo tauri build --config tauri.conf.<name>.json JSON-merges the override file onto the base tauri.conf.json: objects merge deep, arrays replace wholesale. Without an override of its own, every per-app config would silently inherit ROOT's ["zudotext"] and mint another bundle fighting over ROOT's scheme.

Rule: every tauri.conf.<name>.json MUST override plugins.deep-link.desktop.schemes with ["zudotext-<name>"]. This mirrors the existing bundle.resources: [] override (which uses the same array-replacement semantics to drop the stub-skeleton resources).

Current overrides: tauri.conf.writing.json → ["zudotext-writing"] (the writing dev entry embeds the full renderer including the auth flow, so a bundled writing.app genuinely receives callbacks), and tauri.conf.ztadmin.json → ["zudotext-ztadmin"] (the admin renderer has no deep-link consumer today; the override exists to keep the merged config from claiming ROOT's scheme).

Android intent filter

Android (epic #6757) is the single monolithic app described in tauri-app/CLAUDE.md — one build, role forced to LEAF, no generator surface — so there is no per-app scheme family to register there: the Android build claims exactly one scheme, the bare zudotext, the same as ROOT.

Registration is config, not code: tauri-app/tauri.android.conf.json's plugins.deep-link.mobile sets {"scheme": ["zudotext"], "appLink": false}. Unlike the macOS Info.plist path above, the installed tauri-plugin-deep-link version's build.rs reads this at build time and auto-injects the matching <intent-filter> into gen/android/app/src/main/AndroidManifest.xml — look for the <!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. --> markers there; no manual manifest edit is needed for this part. appLink: false means a bare custom-scheme intent filter (android:scheme="zudotext", no autoVerify/host App Links machinery) rather than a verified HTTPS App Link — matching the epic's deep-link decision (custom scheme over App Links).

Tauri's Android template already sets android:launchMode="singleTask" on MainActivity, so a callback while the activity is already running is delivered to the same task instance rather than spawning a duplicate — the Android analogue of macOS's LaunchServices routing a scheme to the one running bundle. What macOS never has to handle is Android routinely killing the backgrounded app while the system browser holds the sign-in page open; the callback then cold-starts a fresh process, so the running-activity reuse above doesn't apply and the app must recover the pending sign-in from a fresh boot instead. See Android WebView runtime → Sign-in across process death for how the client side of this protocol (the client_state nonce persistence, useAuthDeepLink's getCurrent() read) covers that case; the wire contract and scheme-matching rules on this page are unchanged by it — Android reads the same <scheme>://auth/callback?ott=…&state=… shape as every other client, just always with scheme=zudotext.

Generated LEAVES — stub skeleton + child assembler (implemented by #4475)

Current state (the bug this protocol retires): tauri-app/stub-skeleton/Info.plist.template hardcodes a CFBundleURLTypes entry claiming zudotext under CFBundleURLName com.takazudo.zudotext, and the assembler (tauri-app/core/src/generator/child_assembly.rs) carries it through unchanged — which is exactly how seven installed LEAVES all ended up claiming ROOT's scheme.

#4475 MUST change both halves:

  1. Assembler stamping — CFBundleURLSchemes (→ ["zudotext-<name>"]) and CFBundleURLName (→ com.takazudo.<name>, i.e. child_bundle_identifier(app_name)) join the identity keys the assembler overwrites per LEAF, alongside CFBundleName / CFBundleDisplayName / CFBundleIdentifier / CFBundleExecutable / CFBundleIconFile. Both the ROOT runtime assembler (assembleChild) and the headless stamp-leaf CLI go through this transform.

  2. Template placeholder — the skeleton template must stop claiming zudotext. Use the placeholder-identity scheme zudotext-textapp (consistent with the template's placeholder com.takazudo.textapp / "Text App"), so even a raw un-stamped skeleton can never shadow ROOT.

  3. Reserved-name rejection — reject the app name zudotext at generation entry (see rule 5 above).

  4. Existing installs — LEAVES already on disk keep their stale zudotext:// claim until regenerated/re-stamped (pre-release: acceptable; /l-build rebuilds the local set).

Runtime self-identification

An app derives its own scheme at runtime from its .app stem, the same discriminator the role model uses (tauri-app/src/app_mode.rs):

name   = resolve_exe_app_name()          // ".app" stem walk from current_exe()
scheme = if name == "zudotext" { "zudotext" } else { "zudotext-" + name }

Dev-mode fallback: with no .app ancestor (e.g. cargo tauri dev), resolve_exe_app_name() resolves to ROOT (zudotext) unless ZUDOTEXT_APP_NAME overrides it — matching the role model's policy.

The two halves of that formula deliberately live on opposite sides of the bridge, so neither is duplicated:

  • Name — Rust. app_mode::resolve_self_app_name() applies the dev-mode policy above; the app_name_get command hands it to the renderer as BackendAPI.appName(). Browser-context adapters (mock, REST) report ROOT's name: they have no bundle, register no scheme, and never receive a deep link.

  • Scheme — TypeScript. appDeepLinkScheme() in packages/app-scaffold/src/app-name.ts is the mirror of Rust's leaf_scheme() (tauri-app/core/src/generator/child_assembly.rs), which stamps the same value into each LEAF's CFBundleURLSchemes. getAppDeepLinkScheme() in @takazudo/backend-bridge composes the two and caches the result for the process lifetime.

An unknown identity is an error, never ROOT. Every adapter answers with a name, so a failure to resolve one is genuine — and defaulting it to zudotext would silently reproduce the bug the protocol exists to prevent (a LEAF redirecting its callback to ROOT, or subscribing to a scheme it does not register). getAppDeepLinkScheme() rejects, sign-in surfaces the error, and a deep-link listener declines to register rather than bind to the wrong scheme. This is the same reasoning as the server's fail-closed rule for an invalid scheme parameter: only true absence may default.

macOS dev builds cannot receive deep links

On macOS, custom-scheme registration happens only via a bundle'sInfo.plist; a bare cargo tauri dev / pnpm writing:dev binary has no bundle and cannot be a deep-link target at all. End-to-end callback testing requires a built, installed bundle.

Auth-callback wire contract (server side, implemented by #4475)

The Better Auth desktop handoff (workers/sync-server/src/desktop-handoff.ts) currently hardcodes the redirect target zudotext://auth/callback. Under per-app schemes:

  • Callback URL shape — <scheme>://auth/callback?ott=…&state=…. Only the scheme varies per app; path and params are fixed.

  • scheme query parameter — GET /auth/desktop-handoff gains an optional scheme parameter carrying the caller's runtime-derived scheme.

    • MUST match /^zudotext(-[a-z0-9]+(-[a-z0-9]+)*)?$/ as a full-string, case-sensitive match (the server enforces canonical lowercase — it is generating a URL, not parsing one), after the defensive length cap (≤ 260 chars, see the Length rule above) applied before the regex.

    • Invalid → 400, fail closed. Never fall back to a default on an invalid value — a malformed scheme reaching the redirect would be an open-redirect primitive.

    • Absent → default zudotext (ROOT). This kept the pre-per-app client working during the cutover window. The desktop client now always sends its own scheme (see "Client side" below), so absence is a legacy-client path only — it stays for robustness, not because anything relies on it.

  • trustedOrigins — workers/sync-server/src/better-auth.ts currently trusts the literal zudotext:// origin only. #4475 must make every per-app scheme origin acceptable; whether the shipped Better Auth version supports wildcard custom-scheme origins (zudotext-*://) or needs a dynamic trustedOrigins function is an implementation detail to verify there, not assume.

  • The handoff page builds the redirect from the validated scheme; the ott and state embedding rules are unchanged.

Client side (implemented by #4508)

The server half above is inert unless the app names its own scheme on both legs of the handshake. Producer and consumer live in packages/backend-bridge/src/better-auth-desktop.ts:

  • Outbound — beginBetterAuthSignIn(serverUrl, scheme) appends &scheme=<own scheme> to the handoff URL and remembers the scheme on the pending flow. The caller (tauri-adapter.ts's auth.login) resolves it through getAppDeepLinkScheme().

  • Inbound — handleBetterAuthCallback rejects a callback whose scheme is not the one it asked for, comparing case-insensitively per the Case rule above. It deliberately does not clear the pending flow on that rejection: a URL bearing another app's scheme is not this app's to consume, and consuming it would let a stray deep link cancel a sign-in still in flight.

  • Listeners — the renderer's two onOpenUrl subscriptions (renderer/hooks/use-auth-deep-link.ts at boot scope, use-deep-link-routing.ts inside <App>) both filter on the app's own scheme rather than a hardcoded zudotext:, so a LEAF hears its own callbacks and ignores every other app's.

Every registered app scheme can address a workspace item through the generic open route:

<scheme>://open?workspace=<workspaceId>&path=<relPath>&itemId=<idStable>
ParameterValue
workspaceThe workspace id used by the app's current workspace.
pathRelative path of the source Markdown file within the workspace.
itemIdOptional stable item id (idStable) for a card or todo item.

When the workspace matches, the app comes to the foreground and navigates to the source document. A link for another workspace is ignored with a non-blocking toast; the app does not switch workspaces automatically.

Authentication is separate: the Better Auth desktop handoff receives the runtime scheme and returns the OTT to that exact ROOT or LEAF callback.

Verifying registration

Inspect what a built bundle claims:

/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes" \
  /Applications/zudotext.app/Contents/Info.plist

Find every bundle LaunchServices thinks claims a zudotext scheme:

/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister \
  -dump | grep -B 10 "zudotext"

When two copies of the same app claim one scheme (stale copy, restored backup), macOS picks one arbitrarily — delete the extra bundle; LaunchServices re-scans on its own (or force it with lsregister -f <path-to-kept.app>).