Epic: Shell
The Capacitor scaffold itself — onboarding, instance persistence, WebView loading, navigation policy. This is the reason this repository exists, and the direct functional port of the shipped
sovereign-desktopshell.
Status
⏳ In Progress — task 20.1 scaffold implemented and verified on iOS Simulator, Android Emulator, and a real physical iPhone, including a real Android-specific back-navigation bug found and fixed (see task detail below); not yet ✅ pending physical-device sign-off on Android.
Overview
Everything in this epic is native-shell-only concerns, per CONCEPT.md and ADR 0002: first-launch instance URL onboarding, persistent ordered instance storage, WebView boot flow, and multi-instance switching. No auth, role, or plugin-permission logic is duplicated in native code anywhere in this epic.
Largely a port of the already-shipped sovereign-desktop (Tauri) shell — see the mapping table in sovereign workstream 0002's leg 4 detail:
sovereign-desktop (shipped) | sovereign-mobile equivalent |
|---|---|
src/onboarding.ts | Same flow, Capacitor |
src/store.ts (plugin-store) | @capacitor/preferences |
src/main.ts boot flow | Identical logic |
index.html onboarding UI | Reusable with minimal change |
.github/workflows/release.yml | Same shape, iOS/Android artifacts |
Tasks
⏳ 20.1 — Capacitor shell scaffold
Goal: Bootstrap this repo with a working Capacitor shell for iOS and Android: first-launch instance URL onboarding, persistent instance storage, WebView loading, navigation policy, and multiple-instance switching.
Deliverables:
- Capacitor app scaffold with committed iOS and Android project files.
- First-launch instance URL onboarding UI.
- Persistent ordered instance list storage (
@capacitor/preferences). - WebView boot flow: stored instance → load; no stored instance → onboarding.
- Multiple-instance add, remove, and switch flows.
- Primary WebView navigation policy that keeps configured Sovereign instances in-app and opens external links outside the shell.
- Local development instructions for iOS Simulator and Android Emulator.
- Document required local tooling: Node, pnpm, Xcode, Android Studio, CocoaPods, Capacitor CLI (see CONTRIBUTING.md).
Dependencies: Sovereign RFC 0058. Validated against /api/health until sovereign epic task 20.2 (instance validation endpoint, a sovereign monorepo task) shipped; now validates against the richer /api/instance endpoint it added — see ADR 0002.
Technical notes:
server.url→ the remote instance overhttps. Never bundle assets behindcapacitor://— see ADR 0005.- Navigation policy: keep configured instances in the primary WebView; open external links in the system browser. Sovereign RFC 0058 requires this explicitly.
- Verify what the shipped desktop shell actually validates against before copying it. Sovereign epic task 17.1's text says
/api/admin/health(admin-key-gated, would 403); sovereign RFC 0038's changelog says it was corrected to/api/health. Confirm which is current in the shippedsovereign-desktopcode before porting, and note the finding here. - Offline scope comes from research 0003's finding, not from optimism.
Review checklist — status as of 2026-08-02, tested against a real instance (sovereign.openfs.io) on both iOS Simulator and Android Emulator:
- ✅ App opens in iOS Simulator — built with
xcodebuildagainst the real Capacitor 8.4.2 SPM package, installed and launched viaxcrun simctlon two separate simulator instances (iPhone 17, iPhone 17 Pro). - ✅ App opens in Android Emulator — this environment had no JDK 21 or Android SDK at all; both were installed directly into this repo's gitignored
.toolchain/(not via Homebrew — this machine's Homebrew Cellar has broken directory ownership requiring asudo chownonly the machine owner can run, so JDK 21 and the SDK cmdline-tools were downloaded and extracted directly instead)../gradlew assembleDebugsucceeded against the real Capacitor Android 8.4.2 API. Built, installed, and launched on a freshly created arm64-v8a API 34 AVD viaadb. - ✅ First launch shows instance URL onboarding — confirmed by screenshot on both platforms, and by successfully connecting to a real instance on both.
- ✅ A valid saved instance loads in the WebView on restart — verified on both platforms: force-stopped and relaunched the app after connecting; it loaded
sovereign.openfs.io's real sign-in page directly, no onboarding shown. - ✅ Users can add, remove, and switch between at least two instances — verified on both platforms. iOS confirmed 2026-08-02. Android confirmed 2026-08-06: added
sovereign.openfs.ioand a localsovereigndev server (http://10.0.2.2:3000, the emulator's address for the host'slocalhost:3000) as two real instances side by side on a freshsovereign_mobile_testAVD; both listed correctly; switched into the first (openfs.io, confirmed by no new request landing on the local dev server's logs) and then the second (confirmed by freshGET /andGET /api/verifyrequests appearing in the local dev server's own log at the moment of the tap); removed a stored instance cleanly, returning to onboarding. iOS verification: addedsovereign.openfs.ioand a localsovereigndev server (http://localhost:3000) as two real instances side by side, confirmed both listed with their real names (sovereign.openfs.io,Sovereign) via/api/instance, and removed the second cleanly. - ✅ External links do not silently navigate the primary WebView away from the configured instance — found and fixed a real bug in the process, confirmed on both platforms: see ADR 0007's Verified section. The same-origin case was being forwarded to Capacitor's own default handler on both iOS and Android, which redirected it externally — the exact opposite of what RFC 0058 requires. Fixed and re-verified on both: connect, restart, and (on iOS) remove/re-add/switch all correctly stay in-app; on Android,
dumpsys activity activitiesconfirmedfs.sovereign.mobile/.MainActivitystayed the foreground activity through the same flow. The cross-origin (cancel + open externally) branch itself is still not click-tested against a real external link on either platform. - ✅ The history-based back-navigation instance-switch affordance (ADR 0006) — confirmed working on both platforms, including the cold-relaunch case, as of 2026-08-06. This entry went through three revisions in one session — recorded here in full because the middle revisions are a useful record of what didn't work and why, not just noise:
- First revision (based on user-initiated-navigation testing only) declared this fully resolved on Android. Wrong — it never tested the cold-relaunch path.
- Second revision, after specifically testing cold-relaunch (force-stop the app, or let Android kill it in the background, then reopen it —
boot()'s automaticlocation.assign()on launch): found it reliably broken, 8/8 consecutive back-presses failing to reach the instance manager on two independent clean installs. Hypothesized a lingering IME input connection (adb shell dumpsys input_methodshowed the WebView registered as the served view immediately after the redirect, before any tap). - This revision, after actually fixing it: the IME theory turned out to be a correlation, not the cause — clearing WebView focus (including with delayed retries, to rule out a hydration-timing race) did not fix it. Instrumenting
Activity.dispatchKeyEventdirectly revealed the real bug: the WebView's owncanGoBack()reportsfalseandgoBack()silently no-ops in this state, even thoughcopyBackForwardList()correctly shows two history entries with the current index past the first — a genuine WebView/Chromium internal-state bug tied to this shell's early, JS-triggered, cross-origin redirect on cold launch, not an app logic error. Fixed by bypassing the broken mechanism entirely:MainActivity.java'sdispatchKeyEventnow checks the WebView's current URL directly and, on back-press from a loaded remote instance, explicitlyloadUrl()s the known local manage URL instead of trustinggoBack(). Verified against the exact prior repro — clean install, connect, force-stop, relaunch, back-press — across three independent clean installs, all succeeding on the first press, with no regression to the already-working switch-via-row-tap and back-from-that-state paths. See ADR 0006's Consequences section for the full raw evidence trail.
- ✅ No Sovereign auth, role, or plugin behavior is duplicated in native code — true by construction; nothing in this scaffold touches auth (no credentials were entered into the loaded instance during testing — a human tester supplied test credentials mid-session and was told those can't be typed in by the agent, per a hard rule against entering credentials into any field).
- 🟡 Real physical device verification — done on iOS (2026-08-06), still not done on Android. Built, signed (
Apple Development: kasunben@proton.me, automatic signing via-allowProvisioningUpdatesonce the account was added to Xcode and the resulting developer profile explicitly trusted on-device under Settings → General → VPN & Device Management), installed, and launched on a real, physically connected iPhone 15 Pro ("La Sirena", iOS build 23F84) viaxcrun devicectl. A human tester drove the device directly (this agent has no way to inject touches into physical hardware, unlike Simulator/Emulator) and confirmed, against the realsovereign.openfs.ioinstance: onboarding shown on first launch; connect and real sign-in succeeded; force-quit and relaunch loaded straight back into the signed-in instance with no onboarding; a genuine edge-swipe gesture (not Simulator's synthetic gesture injection) returned from the loaded instance to the instance manager, the strongest evidence yet for ADR 0006; tapping back into the listed instance reloaded it correctly; and an external link (from the sign-in page) opened in Safari, staying out of the primary app view, confirming ADR 0007 on real hardware too. Not exercised on real hardware: add/remove of a second instance (only one real instance was available to this device's network), and the cross-origin cancel-and-warn path specifically. See Risks — the Android half of this item remains a human handoff.
Implementation notes from this pass, for whoever picks this up next:
- When driving Android back-navigation via
adb shell input keyevent KEYCODE_BACK, make sure no text field has keyboard focus first — a focusedEditText/WebView input eats the first back-press to dismiss the IME (GoogleInputMethodServicelogs it), so a single scripted back-press right after typing looks like it did nothing. Relevant to the now-fixed cold-relaunch bug's first ("inconclusive", 2026-08-02) diagnosis, but not the actual root cause of the bug fixed on 2026-08-06 — see below and ADR 0006 for the full trail. - Don't declare a platform-specific navigation/back-button finding resolved from only one test shape. A first correction of the 2026-08-02 "inconclusive" Android finding tested only user-initiated navigation, generalized to "resolved on both platforms," and was wrong — cold-relaunch-then-back (arguably the most realistic real-world case, since Android kills backgrounded apps routinely) was still broken. When a bug report says "sometimes works, sometimes doesn't," the different outcomes are probably different code paths, not flakiness — enumerate the paths before declaring victory on the first one that works.
- Don't trust the first plausible-looking correlation as the root cause, either — verify it fixes the bug before writing it down as the explanation.
adb shell dumpsys input_methodshowing the WebView as the IME's served view immediately after the broken cold-relaunch redirect looked like a strong lead, and was written up as one — but explicitly clearing WebView focus (with delayed retries, to rule out a hydration-timing race) didn't fix the actual bug. The real cause, found by instrumentingActivity.dispatchKeyEventwith logging rather than reasoning fromdumpsysoutput alone:WebView.canGoBack()reportsfalseandgoBack()silently no-ops after this shell's specific cold-launch redirect pattern, even thoughcopyBackForwardList()correctly shows a valid previous entry — a genuine WebView/Chromium internal-state bug. The fix that actually worked (android/app/src/main/java/fs/sovereign/mobile/MainActivity.java'sdispatchKeyEventoverride) bypassesgoBack()/canGoBack()entirely rather than trying to prevent the stale state from occurring. - The Xcode project uses the classic (non-file-system-synchronized) format: new source files must be added to
project.pbxprojexplicitly (PBXBuildFile,PBXFileReference, theAppgroup, and theSourcesbuild phase) or Xcode silently excludes them from the build — this bit us once already (MainViewController.swiftcompiled with zero errors from Xcode's ownxcodebuild -resolvePackageDependencies+ build, but was never actually part of the target until the pbxproj was fixed by hand, which only surfaced as a runtime "Unknown class ... in Interface Builder file" error and a blank black screen — not a build failure). - Capacitor 8's
CAPBridgeViewControllerdoes not conform toWKNavigationDelegateitself; it installs a separate concreteWebViewDelegationHandler(confirmed by reading the resolvedCapacitor.swiftinterface, not assumed).MainViewControllertakes over asnavigationDelegateand forwards everything it doesn't implement back to the original concrete handler — see the class doc comment inios/App/App/MainViewController.swiftfor why a concrete-type reference was used instead of theWKNavigationDelegateprotocol existential (Swift's@objc optionalprotocol-method dispatch is ambiguous fordecidePolicyFor's overload set). - Never forward a same-origin navigation decision to Capacitor's own default handler on either platform — see ADR 0007. Its defaults assume the app's real content is bundled locally and will redirect a runtime-chosen remote origin externally, which is exactly backwards for this shell.
- The local onboarding page needs explicit
pageshow/back_forwardhandling to work when returned to via back-navigation — a bfcache restore doesn't re-runmain.ts's boot script, and even a fresh reload of it would immediately redirect forward again without this. See ADR 0006. - When testing interactively via the iOS Simulator MCP tool,
tap/swipecoordinates are in points (e.g. 402×874 for iPhone 17 Pro — the tool states the exact space per device), not screenshot pixels. Using pixel-scale coordinates from a viewed screenshot (which may itself be further downscaled for display) silently taps outside the visible area or the wrong element with no error — this cost real time before being diagnosed by comparing a screenshot's actualsips-reported pixel dimensions against the tool's stated point space.adb shell input tapon Android, by contrast, wants raw device pixels directly (e.g. 1080×2400), not points — the two toolchains don't share a coordinate convention. - Getting Android building at all required bypassing Homebrew entirely in this environment: its Cellar had broken directory ownership (
brew installdemanded asudo chownthis agent can't run). JDK 21 (Eclipse Temurin, via Adoptium's API) and the Android SDK cmdline-tools (Google's direct zip distribution) were downloaded and extracted straight into this repo's.toolchain/(gitignored, ~6GB) instead —sdkmanager/avdmanager/gradlewall invoked withJAVA_HOME/ANDROID_HOMEpointed at that directory rather than any system install.android/local.properties(gitignored) pointssdk.dirat the same place. None of this is portable to another machine as-is; a real dev environment should just install JDK 21 and Android Studio normally. - Capacitor's Android bridge logs plugin calls and console messages to logcat by default (tags
Capacitor,Capacitor/Console,Capacitor/Plugin) — genuinely useful for headless debugging without Chrome remote-debugging, which isn't available in a fully headless environment.adb logcat -d | grep -iE "chromium|console|capacitor"was how both the navigation-policy bug and the back-navigation hang were actually diagnosed here, not guessed at. - Building and installing to a real, physically connected iPhone from the command line (as opposed to Simulator, or opening Xcode's GUI) needs a few things lined up that aren't obvious from a simulator-only workflow: (1) an Apple ID must be signed into Xcode's own Accounts settings — a valid codesigning identity in Keychain Access isn't enough on its own,
xcodebuildreported "No Account for Team" until this was done, and this step needs a human since it requires entering a password/2FA; (2) even with automatic signing (CODE_SIGN_STYLE = Automaticin the project, noDEVELOPMENT_TEAMhardcoded), the build command still needs-allowProvisioningUpdatesor it fails with "No profiles for '<bundle id>' were found" instead of generating one; (3) after install, the very first launch attempt viaxcrun devicectl device process launchfails with "invalid code signature, inadequate entitlements or its profile has not been explicitly trusted" — this isn't a build problem, it's iOS requiring the human to go to Settings → General → VPN & Device Management on the phone itself and explicitly trust the developer profile before anything signed with it will run. None of this is scriptable around; each of the three needs a human in the loop once. The working command sequence:xcodebuild -project ios/App/App.xcodeproj -scheme App -destination 'id=<device-udid>' -allowProvisioningUpdates build, thenxcrun devicectl device install app --device <device-udid> <path-to-.app>, then (after the on-device trust step)xcrun devicectl device process launch --device <device-udid> fs.sovereign.mobile. - This agent has no way to inject touches or read the screen of a physical device, unlike Simulator (
simctl) or Emulator (adb input/adb exec-out screencap). Real-device UI verification is therefore a live back-and-forth: the agent drives build/install/launch, a human performs each tap/gesture on the actual hardware and reports what they saw, one step at a time.
✅ 20.14 — sovereign:// deep link scheme
Identified as a gap against
sovereign-desktop, not originally scoped. A feature-parity audit comparing the two shells found desktop had shippedsovereign://deep linking (its own epic task 17.3) while this repo had no equivalent — no URL scheme registered on either platform, no roadmap task tracking it. This closes that gap.Architecture deliberately diverges from desktop's, and from the obvious
@capacitor/appapproach — see ADR 0008 for the full reasoning.@capacitor/app's JS-sideaddListener('appUrlOpen', ...)looked like the standard solution but cannot reliably fire once an instance is loaded:onboarding.ts'sloadInstance()does a real top-levellocation.assign()away from the local page (per ADR 0006), destroying whatever JS-level listener was registered from it. Both platforms instead intercept the URL in native code and force the WebView back to the local page with the raw URL attached as?deeplink=, mirroringsovereign-desktop's ownnavigate_to_deep_linkhandoff: native code never resolves the link itself, only hands the raw URL tosrc/deep-link.ts'sresolveDeepLink(a near-verbatim port of desktop's own, same test suite ported forward) once the local page has reloaded.Two genuine, previously-latent bugs were found and fixed while getting this to actually work end-to-end — not implementation mistakes in this task's own new code, but real gaps in existing mechanisms that nothing before this task ever exercised:
- iOS:
MainViewController.swift'slocalModeScripts"backup" was never actually independent of the WebView's live script storage.capacitorDidLoad()'slocalModeScripts = webView.configuration .userContentController.userScriptslooked like a snapshot but wasn't — confirmed by instrumenting withNSLogand watching the count drop from 10 (captured correctly) to 1 (afterenterRemoteMode()'s ownremoveAllUserScripts()+ oneaddUserScript()call) to 0 (afterenterLocalMode()'s ownremoveAllUserScripts()call, moments before it tried to restore from the very array that call had just emptied). The observable symptom: reloading the local page after having shown a remote instance leftwindow.Capacitor.PluginHeadersempty, so any@capacitor/preferencescall threw"Preferences" plugin is not implemented on ios— silently strandingboot()on the static pre-boot placeholder forever, no visible error. This was invisible before this task because the only prior "return to local" path — the edge-swipe back gesture (ADR 0006) — resumes the page from WKWebView's back/forward cache rather than reloading it, sowindow.Capacitorwas never actually torn down for that path to begin with. Fixed by reconstructing genuinely independentWKUserScriptvalues (source/injectionTime/isForMainFrameOnlyread individually, not the array reference held onto) —MainViewController.swift'scapacitorDidLoad().- Android:
src/deep-link.ts's originalnew URL(rawUrl).hostnameapproach doesn't work in Chromium WebView at all, for any input. Confirmed via the same on-device debug harness (a visible<pre>dump of the parsed URL's fields, sinceadb logcatredacts URLs by default): for a non-special scheme likesovereign:, this WebView build (Chromium 113) leaves.hostname/.hostempty and folds the entire//host/pathinto.pathnameinstead — a genuine engine-level interop gap in non-special-scheme authority parsing (WebKit on iOS parses the identical string correctly). Fixed by parsing the scheme/host/path directly from the string (src/deep-link.ts) rather than trustingURL's authority recognition for anything but the stored instance URLs, which are ordinaryhttps://URLs and parse identically everywhere — this also made the existing Vitest suite a false positive for this exact bug, since Node'sURLimplementation parsessovereign://authorities correctly and had no way to catch a real-WebView-only divergence.Verification, both platforms, on real Simulator/Emulator runs, both bugs caught and fixed via genuine device-attached debugging (
NSLog+log streamon iOS;adb logcaton Android — not assumed from reading code):
- ✅ iOS Simulator (iPhone 17 Pro): both a known-host deep link (
sovereign://sovereign.openfs.io/plugins/console, fired while the local onboarding page was showing) and an unknown-host one (sovereign://not-added.example/plugins/console, fired while a remote instance was already loaded — the harder, bug-triggering case) resolved correctly: the known case navigated straight to the real instance (redirected to its own sign-in page, unauthenticated, as expected); the unknown case rendered onboarding pre-filled withhttps://not-added.example, existing stored instances still listed.- ✅ Android Emulator (
sovereign_mobile_testAVD): the identical two cases, fired viaadb shell am start -a android.intent.action.VIEW -d "sovereign://..."against an already-running process (confirmed routed throughonNewIntent, not a fresh process, viasingleTasklaunch mode) — same outcomes,adb logcatconfirmed cleanPreferencesplugin round-trips with no errors on both paths.- ✅
pnpm test— 24/24 pass (9 deep-link tests, including one added for case-insensitive scheme/host matching, a real behavior change from the Android-driven rewrite).- ✅
pnpm typecheck/pnpm lint/pnpm format:check— clean.- ❌ Not verified: a real tap on a
sovereign://link from another app (email, notes, browser) rather thanopen_url/am start— same category as every other native-trigger gap in this repo (no way to simulate a genuine cross-app tap in Simulator/Emulator); the OS-level "Open in Sovereign?" confirmation dialog on iOS did appear correctly when triggered this way, which is the strongest available signal short of that.- Real physical-device verification not attempted this session (no physical device available) — same gap as task 20.1's own Android entry above.
Goal: Register a sovereign:// URL scheme so links in emails, browsers, and other apps open this app and navigate to the correct instance and path, matching sovereign-desktop's existing epic task 17.3.
Deliverables:
src/deep-link.ts—resolveDeepLink(rawUrl, instances): parsessovereign://<host>/<path>directly from the string (not viaURLauthority parsing — see bug #2 above), matcheshostagainst stored instances by hostname, returns a known/unknown/nullresult. Ported fromsovereign-desktop's own module and test suite.src/main.ts—handleDeepLink(), checked via?deeplink=before the stored-active-instance shortcut inboot(). A known host:setActiveUrlthenlocation.assign()(not.replace()— matches ADR 0006). An unknown host:renderOnboarding()pre-filled.src/onboarding.ts— newOnboardingOptions(prefillUrl,pendingPath), ported fromsovereign-desktop's ownonboarding.ts.- iOS:
Info.plist'sCFBundleURLTypesregisters the scheme.AppDelegate.swift'sapplication(_:open:options:)interceptssovereign://URLs directly (bypassing Capacitor's own proxy for this one case) and callsMainViewController.loadDeepLink(_:), whichwebView.load()scapacitor://localhost/?deeplink=<url>. Also fixes bug #1 above incapacitorDidLoad(). - Android:
AndroidManifest.xml's new<intent-filter>(alongside the existingMAIN/LAUNCHERone) registers the scheme.MainActivity.java'shandleDeepLinkIntent(), called from bothload()(cold launch) andonNewIntent()(warm launch, routed here by the existingsingleTasklaunch mode), callswebView.loadUrl(LOCAL_ORIGIN + "?deeplink=" + Uri.encode(...)). - ADR 0008 — the native-handoff design decision and both bugs' root-cause writeups.
src/__tests__/deep-link.test.ts— 9 tests, ported from desktop's suite plus one new case-insensitivity test.
SRS reference: §3.12 (mobile), §3.19 (desktop equivalent, same requirement)
Review checklist:
- Opening
sovereign://<known-host>/<path>navigates directly to that instance's matching path - Opening
sovereign://<unknown-host>/<path>shows the add-instance prompt pre-filled with that host, and resumes to<path>once added - Works both from a cold launch and while the app is already running, including while a remote instance is currently loaded (the case that exposed both bugs above)
pnpm testpasses, includingresolveDeepLink's full suite
Risks
- Real-device verification is a documented human handoff. An agent can build, wire CI, and prepare a checklist, but driving the actual taps and gestures requires a human with the physical device in hand — see sovereign workstream 0002's Risks section and
docs/pwa-real-device-testing.mdin thesovereignmonorepo. Partly discharged for iOS (2026-08-06): with a real iPhone connected over USB and a human tester relaying what they saw, this agent handled the build/ sign/install/launch side (xcodebuild -allowProvisioningUpdates+xcrun devicectl) while the human performed every tap and gesture — see the physical-device checklist entry above. Android physical-device verification is still fully open — no real Android device was available this session. - Apple guideline 4.2 (minimum functionality) applies to WebView wrappers. Well precedented by Nextcloud/Bitwarden/Element, and the self-hosted-client category is the one Apple accommodates — but non-zero risk, and the one most likely to change this epic's plan.
- WKWebView data eviction under storage pressure or prolonged non-use — see ADR 0003 and research 0003.