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.
The historical proposals are separate from this API entry point. Platform execution claims below are retained records and were not rerun during restructuring.
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.
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) | ✓ application:openURLs: | ✓ the same | Shipped |
| windows-winui | Day.toml url_schemes (MSIX, NSIS); --day-open-url <link> on the command line | ✓ | ✓ single-instance forwarding | Compile-checked |
| linux-gtk / linux-qt | Day.toml url_schemes (.desktop x-scheme-handler); --day-open-url <link> | ✓ | ✓ single-instance forwarding | Shipped |
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.
Single-instance forwarding
A second launch of a desktop app hands its command line (--day-open-url, --day-open-file,
files, URLs) to the copy already running and exits; the running copy receives them as a warm
link or file and brings its front window forward (day_core::forward_launch_args). Apps never
see two processes.
| Backend | Mechanism | Status |
|---|---|---|
| AppKit | Launch Services activates the running app and delivers URLs and files to its delegate | Shipped |
| GTK | GApplication’s own uniqueness: the second launch’s files and URIs reach the first through its open signal | Shipped |
| Qt (Linux) | day_dbus::instance: the first launch owns the app id on the session bus, a second calls its Forward method | Shipped |
| Windows XAML | A named mutex per app id; a second launch sends its arguments to the first with WM_COPYDATA | Compile-checked; runtime pending |
Two development builds of one app id forward to each other, whichever started first.
Launcher shortcuts
The persistent icon-menu surfaces (home-screen quick actions, launcher shortcuts, jump lists,
.desktop actions, the macOS Dock menu) 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. The default locale gets its own.lprojtoo: those dirs are the bundle’s localization list, and iOS 15 resolves a device language outside the list to the first entry, not toCFBundleDevelopmentRegion. 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 — the Dock menu lists the shortcuts above the app’s own
dock_menuentries (docs/menus.md).day-buildregisters the[[shortcuts]]into the binary at link time, so the menu is built while the app runs, in its current language, and choosing an entry opens its route in the running app. - Windows — the jump list’s Tasks: each entry runs the app with
--day-open-url "<scheme>://<route>", which single-instance forwarding hands to the copy already running. Built at startup and rebuilt on everyset_launcher_shortcuts. - Linux —
Actions=in the generated.desktopfile, one[Desktop Action …]group per shortcut with aName[<locale>]for every locale and the same--day-open-urlcommand line. - Web — the manifest’s
shortcutsmember, opening./#<route>, in the default locale.
Shortcuts set while the app runs
day::set_launcher_shortcuts(vec![LauncherShortcut { route, label }]) replaces the app’s
run-time shortcuts: “Resume Cap::DynamicShortcuts). Pass labels already
localized. Linux launchers and the web keep only the declared shortcuts.
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
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.
External URL handlers
Declare additional OS associations at the root of Day.toml:
url_schemes = ["feed", "web+feed"]
Register day::on_open_url(|url| { /* show validation UI */ true }) in the app root.
The callback receives the complete original URL on the UI thread, including its scheme and query.
Return false to retain existing route handling. The latest live registration receives activations;
its scope owns cleanup. Cold-start delivery occurs after mounting. Use DAY_OPEN_URL or the
open_url: { url: ... } dayscript step to exercise this rail without OS registration.
Apple bundles, Android/Harmony manifests, Linux desktop entries, Windows MSIX and NSIS packages
advertise protocol candidates. Windows development builds emit an opt-in registration file.
PWA manifests include only valid web+ names. Registration doesn’t force the user’s default or
make every HTTPS link leave a browser; MIME handling depends on browser/platform policy.