ADR 0008 — Deep links resolved natively via WebView redirect, not @capacitor/app's JS listener
Status
Accepted. Decided in this repo directly during epic task 20.14's implementation — like ADR 0006, this has no sovereign-desktop precedent that transfers as-is, though the shape of the fix (native code hands the raw URL to the local page as a query param; TypeScript resolves it) does mirror sovereign-desktop's own navigate_to_deep_link (see that repo's epic task 17.3).
Context
@capacitor/app ships a JS-facing App.addListener('appUrlOpen', ...) API and a getLaunchUrl() method, and its own docs describe this as the standard way to handle custom URL scheme links. It looked like the obvious choice at first — no native code needed beyond registering the scheme.
It doesn't work reliably here, because of how this shell's WebView already behaves once an instance is loaded. MainViewController.swift's enterRemoteMode() (added for the RFC 0083 device bridge, workstream 0003 leg 4) explicitly removes Capacitor's own injected scripts and its "bridge" WKScriptMessageHandler — the mechanism @capacitor/app's JS API depends on — whenever the WebView shows a remote instance, and only restores them when navigating back to the local bundled page (enterLocalMode()). NavigationPolicyWebViewClient's Android equivalent doesn't remove Capacitor's bridge the same way, but the underlying problem is the same on both platforms regardless: onboarding.ts's loadInstance() calls window.location.assign(url) — a real, full top-level navigation away from the local page's own origin (capacitor://localhost on iOS, https://localhost on Android) to the remote instance's https:// origin, per ADR 0006. That destroys the local page's JS execution context entirely, including any App.addListener('appUrlOpen', ...) callback registered from it. A deep link tapped while the app is already showing a loaded instance — the most common real-world case, not an edge case — would have no live JS listener left to deliver it to.
getLaunchUrl() alone would only cover cold launch (where the local page's JS context is guaranteed live, since boot() hasn't yet called location.assign()), leaving the warm-launch case broken. Handling the two cases through two different mechanisms (JS getLaunchUrl() for cold, something else for warm) was rejected as more moving parts than a single consistent fix.
Decision
Both platforms' native entry points intercept the sovereign:// URL directly — before any JS ever sees it — and force the WebView to navigate to the local origin with the raw URL attached as ?deeplink=<url>, regardless of what the WebView currently shows:
- iOS:
AppDelegate.swift'sapplication(_:open:options:)calls a newMainViewController.loadDeepLink(_:), whichwebView?.load()scapacitor://localhost/?deeplink=<percent-encoded-url>directly. Kept onMainViewController(notAppDelegate) since that class already ownswebViewaccess and the local/remote mode bookkeeping —decidePolicyForsees this as a same-origin-to-local navigation and callsenterLocalMode()through its existing logic, no special-casing needed there. - Android:
MainActivity.java'sload()(cold launch, viagetIntent()) andonNewIntent()(warm launch) both route through a newhandleDeepLinkIntent(Intent), which — when the intent isACTION_VIEWwith asovereign:scheme — callsbridge.getWebView().loadUrl(LOCAL_ORIGIN + "?deeplink=" + Uri.encode(...)). This is the same patterndispatchKeyEvent's back-button handling already established (loadingLOCAL_ORIGIN + "?manage=1"directly rather than trusting WebView history state) — reusing a precedented, already-verified technique rather than inventing a second one.
TypeScript-side resolution (src/deep-link.ts's resolveDeepLink, consumed from main.ts) is a near-verbatim port of sovereign-desktop's own src/deep-link.ts — same scheme, same host-matching contract, same KnownDeepLink | UnknownDeepLink | null shape — since that logic has nothing platform-specific in it and already has full test coverage to copy forward. The one deliberate deviation: a known deep link resolves via window.location.assign(), not .replace(), matching ADR 0006's own history-preservation rule (onboarding.ts's loadInstance()) rather than desktop's .replace() — desktop has no back-gesture affordance depending on history to protect.
Rejected alternatives
@capacitor/app'saddListener('appUrlOpen', ...)/getLaunchUrl()only. The obvious first choice; rejected onceenterRemoteMode()'s bridge teardown made clear it cannot reliably fire once an instance is loaded — see Context above. Would need a second, different mechanism for cold launch regardless, sincegetLaunchUrl()andappUrlOpenare different APIs with different lifecycles.- Universal Links (iOS) / App Links (Android) instead of a custom
sovereign://scheme. Requires aapple-app-site-association/assetlinks.jsonfile hosted at a domain this project doesn't control the way a per-instance self-hosted deployment would need (each user's own instance domain, not a single canonical one) — thesovereign://custom scheme sovereign-desktop already established doesn't have this problem and this shell should match it for a consistent cross-platform link format users and instance operators can rely on.
Consequences
- Any future feature that also wants to force-navigate the WebView from native code must go through the same
LOCAL_ORIGIN/capacitor://localhostlocal-origin redirect pattern, not a new ad hoc mechanism — two platforms already have two slightly different flavors of this (dispatchKeyEvent's back handling, now this), and a third would be worth extracting into a shared helper rather than copying again. main.ts'sboot()now checks?deeplink=before the stored-active- instance shortcut, so a deep link always wins over silently reopening whatever was last active — matchessovereign-desktop's own ordering.MainViewController.swiftandMainActivity.javaboth gain a small amount of native code for something that, on a platform where@capacitor/app's listener actually worked end-to-end, would have needed none. Accepted as the cost of this shell's own architecture (location.assign()-based instance loading, bridge isolation at the local/remote boundary) rather than a generic Capacitor limitation.