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 fallbacknone 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).

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 stampingCFBundleURLSchemes (→ ["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-demo-builds 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 parameterGET /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.

  • trustedOriginsworkers/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:

  • OutboundbeginBetterAuthSignIn(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().

  • InboundhandleBetterAuthCallback 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>).