Deep links (OS integration)
How a URL outside the app becomes a navigate inside it. This document covers the OS side:
scheme registration, delivery into the process, per-platform capabilities, and testing. The
route grammar, absolute/relative addressing, query params, and how a pending link interacts
with .restore are specified in docs/navigation.md
and are not repeated here.
Each section below is marked Shipped or Planned. Planned sections describe a design that may change with review.
The URL model — Shipped
<scheme>://<route>[?<params>] e.g. fieldnotes://mail/inbox/msg-42?hint=shared
The host + path after :// is the day route string, verbatim. Query params ride through to
route_param(..)/route_params(). day new derives <scheme> from the app name (letters
and digits only, lowercased: Field Notes ⇒ fieldnotes) and writes it into every host
project that registers schemes.
URL parsers treat the first segment as a host and lowercase it on the component-based intakes (NSURL, android.net.Uri), so a deep-linked surface’s first-segment keys must be lowercase, which day’s route-key convention already requires. Later segments pass through case-preserved.
Short schemes collide: nothing stops another app from claiming fieldnotes://, and the OS
resolves the tie (see the per-platform notes). Apps that care should set an explicit,
longer scheme. Planned: a scheme = "…" key under [app] in Day.toml, conveyed to each
host project the same way the id is; today the scheme is fixed at scaffold time.
The delivery contract — Shipped
However a link arrives, the behavior inside the app is the same:
- Cold start (the link launched the app): the route is recorded before the tree mounts
(
DAY_DEEPLINKin the environment where launch environments exist, orday_core::set_launch_deeplinkfrom the platform entry where they don’t) and navigates one turn after the first mount. It wins over.restore. - Warm delivery (the app was running): the platform layer emits
RouteRequested(route)to the active nav host, which navigates immediately. - Navigation only. A scheme URL can be sent by any app or web page, unauthenticated. Deep links never execute an action; they only address a surface. Anything destructive must sit behind the app’s own UI once the user arrives.
- An unknown route falls back exactly like any bad
navigatecall: the unmatched segments are dropped andday lint’sunknown-routecheck catches literal mistakes at build time.
Where each platform stands
| Platform | Registration | Cold | Warm | Status |
|---|---|---|---|---|
| ios-uikit | CFBundleURLTypes (scaffold) | ✓ | ✓ application:openURL:options: | Shipped |
| android-mdc | intent-filter VIEW+BROWSABLE (scaffold), singleTask | ✓ | ✓ onNewIntent → kind 7 | Shipped |
| web-dom | the page URL is the link | ✓ hash/?route= | ✓ RouteRequested on hash change | Shipped |
| harmony-arkui | uris skill (scaffold) | ✓ want.uri → buffered | ✓ onNewWant | Shipped |
| macos-appkit | CFBundleURLTypes (platform/macos scaffold) | — | — | Planned |
| windows-xaml | none | — | — | Planned |
| linux-gtk / linux-qt | none | — | — | Planned |
iOS — Shipped, two concerns
Under the scene lifecycle the app runs, URLs arrive at the scene delegate, and that is where
day-uikit takes them: cold from the connection options’ URLContexts (and a quick action’s
shortcutItem), warm from scene:openURLContexts: (and
windowScene:performActionForShortcutItem:), every arm one call into
day_core::request_route. The app-delegate application:openURL:options: intake remains for
the pre-scene path. URLContexts is declared non-null but a plain launch returns nil, so the
cold arm reads it through a nullable send; the strict binding panicked on every ordinary
launch until the dayscript walkthrough caught it. Concerns:
- Scheme exclusivity does not exist. If two installed apps claim one scheme, iOS picks one, silently. The fix at the platform level is Universal Links (below).
- Universal Links are a separate tier. They need an
applinks:entitlement, a team id in signing config, and anapple-app-site-associationfile served from the app’s domain. The natural host for that file is the daysite deployment the app already publishes;day packknows the signing config and daysite knows the domain, so the pieces exist. Not started.
Android — Shipped, two concerns
The scaffold’s manifest registers the scheme with BROWSABLE (so links in a browser work) and
launchMode="singleTask", which is what routes a warm link through onNewIntent instead of
stacking a second activity. DayActivity forwards both cold and warm intents as deep links.
Concerns:
- The chooser dialog. Two apps claiming one scheme puts a disambiguation sheet in front
of the user. Verified App Links (an
assetlinks.jsonon the app’s domain, same daysite hosting requirements as iOS) bypass it forhttpslinks. - Intent extras are not the URL. Only the
dataURI is treated as a link; anything else in the intent is ignored, per the delivery contract above.
HarmonyOS — Shipped
The module’s skills declare a uris entry with the app scheme, and both temperatures are
one call: the ArkTS host forwards a cold want.uri (in onCreate, before start()) and a
warm onNewWant one to the shim’s deepLink(uri), which lands in day_core::request_route
(buffered until the first mount, applied on the UI thread after it). A want with no URI
carries want.uri as an empty string rather than undefined, so the ability also reads the
parameters["day.uri"] fallback a [[shortcuts]] want uses through ||, never ??. Verified on the Oniro
emulator with aa start -U "<scheme>://<route>", cold and warm. One concern is that aa start -U is also the only local delivery tool, since the emulator image has no system browser to
exercise link-from-a-page flows.
macOS — Planned, with one structural caveat
Registration already ships in the platform/macos scaffold’s Info.plist. Intake does not: the
runtime needs the Apple Event handler (kAEGetURL) registered at startup, emitting the same
cold/warm paths as iOS. Two macOS-specific concerns:
- Launch Services registers bundles, not binaries. macos-appkit always builds as the
Xcode
.appnow, so everyday launchproduces a bundle a scheme can reach; the caveat applies only to anyone running the raw executable out ofContents/MacOSby hand. - Old copies shadow each other. Launch Services indexes every copy of the bundle it
has seen (a Debug build in
build/day/, a packed copy in/Applications) and picks one by its own rules. During development,open <url>may target a copy other than the one just built. Aday doctornote should cover this when the handler lands.
Windows — Planned, the most work
Nothing ships yet. The pieces, in order of effort, are:
- Packaged (MSIX): a
uap:Protocolextension in the generated manifest, which is small and the store-grade path. - Unpackaged (dev + NSIS): protocol registration is an
HKCUregistry ProgId written at install or first run;day launchdev builds would self-register on start, which is a machine-state write day currently never does. This deserves an explicit opt-in. - Single-instance forwarding (below) is not optional here: without it every link spawns
another copy of the app. Packaged apps can use
AppInstanceredirection; unpackaged needs a named mutex + pipe in the C++/WinRT shim. This is the bulk of the work.
Linux — Planned, with one limit
Registration is two lines in the generated .desktop file (MimeType=x-scheme-handler/<scheme>;
plus DBusActivatable=true), and both packers already generate that file. Delivery concerns:
- AppImage integration is opt-in by the user. An AppImage registers no
.desktopentry unless the user runs an integration tool, so scheme links reaching an AppImage build are not dependable. Flatpak installs integrate normally and are the reliable path. - Dev launches cannot receive links for the same reason as macOS’s cargo path: there is
no installed
.desktoppointing at the build tree. Testing needs an installed build, or the dayscript tier below. - Warm delivery should ride DBus activation (
org.freedesktop.Application.Open), which both desktops’ launchers use whenDBusActivatableis set; a fallback single-instance socket would cover launchers that exec directly.
Single-instance forwarding — Planned
Desktop platforms need a policy for a link arriving while the app runs, or arriving twice. macOS forwards through Launch Services automatically. Linux gets it from DBus activation. Windows must build it (above). The contract in all three cases is the same: the second invocation hands its URL to the running instance and exits; the running instance treats it as a warm link. day should own this in the platform layer so apps never see two processes.
Shortcuts are saved deep links — Shipped (ios / android / harmony)
The persistent icon-menu surfaces (home-screen quick actions, launcher shortcuts, jump lists,
.desktop actions — docs/menus.md “Future surfaces”) each hold a label and a URL of exactly
this form. They add no new delivery machinery; they are declarations that emit these URLs
into the intake above.
Declared in Day.toml, in display order:
[[shortcuts]]
route = "menus" # the route the shortcut opens; query params allowed
label = "nav_menus" # a Fluent message id from resource/locales/
day build resolves each label in every locale (a missing translation, a multi-line
message, or a placeable is a build error, because the native launcher renders the conveyed
string with no formatter behind it) and writes the platform’s native declaration:
- Android — nothing is committed:
res/xml/day_shortcuts.xmlplus per-locale string resources are staged intobuild/day/android/res(already a scaffold res srcDir), and the<meta-data android:name="android.app.shortcuts">rides the day-pieces overlay manifest, merged into the launcher activity by name. The shortcut intent is VIEW + the URL, so activation goes through the shipped intent-filter intake. Verified on the emulator: the shortcut service parses both demo shortcuts with locale-resolved labels (dumpsys shortcut), and the declared intent cold-launches onto the right page. - iOS —
UIApplicationShortcutItemsis written into the committedInfo.plist(the same managed-key editor as the permission strings), titled with the default-locale text; the scaffold’sStage Day Stringsscript phase (day xcode-backend stage-strings, injected into pre-existing scaffolds on first use) stages<locale>.lproj/InfoPlist.stringsinto the built bundle, keyed by that default text so an unlocalized device falls back to readable English. A quick action’s type string is the URL itself; the scene delegate feeds it intoday_core::request_route, warm viaperformActionForShortcutItemand cold via the connection options. Conveyance is verified in the built bundle; the tap itself cannot be automated on a simulator (no touch injection, andsimctl openurlsits behind a confirmation dialog), so OS-delivered activation relies on the same intake path the walkthrough and the other platforms exercise. - HarmonyOS — a generated
$profile:shortcuts_configplus anohos.ability.shortcutsmetadata entry on the ability, labels merged into each locale’sstring.jsonin the staged host underbuild/day/harmony/project/(day_shortcut_prefix is the ownership marker,base/carries the default locale). The want carries the URL inparameters["day.uri"]; EntryAbility forwards it through the samedeepLinkcall aurislaunch uses. Verified cold and warm on the Oniro emulator with the exact want the profile declares (aa start … --ps day.uri <url>); the emulator’s stock launcher renders no shortcut panel for any app, so the panel UI itself needs real hardware. - macOS / Windows / Linux — not yet: their surfaces (dock menu, jump list,
.desktopActions) stay gated on the missing intake above. Declaring[[shortcuts]]today is safe there and has no effect.
Launchers show at most about four entries (day lint warns past four), per-shortcut icons
are not conveyed yet (the platforms render their default glyph), and on OpenHarmony
want.uri arrives as an empty string on non-URI launches, which is why the ability checks
want.uri || parameters["day.uri"] rather than ??.
Testing — Shipped pieces, plus a dayscript plan
What works today: the dayscript step below on every backend; DAY_DEEPLINK=route day launch … for the cold env path; xcrun simctl openurl booted <url>,
adb shell am start -a android.intent.action.VIEW -d <url>, and
hdc shell aa start -U <url> for real OS delivery on the mobile targets; on web-dom the URL
hash is the entire intake and Playwright drives it. A HarmonyOS launcher-shortcut tap is
simulated exactly by aa start -b <bundle> -a EntryAbility --ps day.uri <url>, the same
want the generated profile declares. On an iOS 16/17 simulator simctl openurl sits behind
an “Open in …?” confirmation SpringBoard shows headlessly, so automated openurl runs need a
tap the simulator cannot inject; a device, or an XCUITest runner, gets past it.
deep_link: { url: "scheme://route?x=1" } — Shipped. An in-process step: the URL maps to
its route through the same day_spec::route_of_url every platform intake uses, then
navigates: a warm delivery without the OS. It proves the app’s routing, param handling, and
back-stack seeding identically on every backend including mock (cheap enough for every
app’s walkthrough), and it does not prove OS registration. day lint validates the URL’s
route against the app’s declared keys, the same check navigate: gets.
Planned: the second tier, in which the launch runner delivers the URL from outside (the
commands above) and the script asserts via assert_route. That proves registration and intake,
costs a per-platform runner arm, and belongs in the per-target CI jobs rather than every
walkthrough. The split matters because tier-1 failures are app bugs and tier-2 failures are
packaging bugs; a single step doing both would leave every failure ambiguous.