Repository navigation
ADR 0036 Native App Shells With Capacitor
Accepted (2026-10-11)
The owner wants MowDeck as an app on the iPhone and on Android, built from the same TypeScript code base as the web app (ADR 0003, ADR 0020) and installed the way their other project (StudyLife, .NET MAUI) is: iOS builds go straight from the Mac to the phone with a paid Apple developer account (no TestFlight, no App Store), Android gets an APK attached to the GitHub release, and notifications must work without Firebase.
The forces:
-
Authentication. The web app and the API share one origin behind Traefik (ADR 0008,
infra/compose/docker-compose.yml, no CORS). The refresh token lives only in Identity's HttpOnly cookiemowdeck_refreshon that origin, access tokens only in memory (ADR 0021). A web view showing a bundled copy of the web app runs on another origin (capacitor://localhost,https://localhost): the cookie would be third-party, and every service would need CORS with credentials. - Updates. The server is updated often; a bundled web app would need a new app build and install per server release, and an app older or newer than its server breaks the generated API types.
- Notifications. Web Push does not reach a WKWebView. iOS background delivery needs APNs; on Android the only system push transport is Firebase Cloud Messaging, which the owner rejects.
- Internal servers. A MowDeck server usually lives in the home network or behind a VPN, often with a private CA or plain HTTP in the LAN, and its address differs per installation.
Capacitor 8 shells (apps/mobile) that load the web app from the owner's MowDeck server (option A). The shells bundle only two small pages: the first-launch screen and the offline page.
-
Server address at runtime. Capacitor's
server.urlis fixed when the bridge starts, so the shells set it natively:MainViewController(iOS, overridesinstanceDescriptor()) andMainActivity(Android, builds aCapConfigfrom the bundledcapacitor.config.jsonplus the stored origin) read the origin from native storage (UserDefaults, SharedPreferences) before the web view loads, set it asserver.urland add its host toallowNavigation. The bundled first-launch screen (www/index.html) asks for the address, checks<origin>/manifest.webmanifestthrough Capacitor's native HTTP (the page's own origin would be blocked by CORS) and hands the origin to the shell's own pluginMowDeckShell(setServerUrl,resetServerUrl,getServerUrl,reload), which stores it and restarts the bridge (iOS: a new root view controller; Android:recreate()).server.errorPathshowsoffline.html(retry, change server) when the server cannot be reached. The settings page of the web app offers "change server". -
Plugins on remote content (verified in the Capacitor 8.5 sources): iOS injects the bridge as a
WKUserScriptinto every main-frame document and accepts its messages from any page the web view shows; Android injects it withaddDocumentStartJavaScriptfor the app origin, which is the server origin onceserver.urlis set, and itsWebViewLocalServerdoes not proxy remote requests on a current WebView (the request goes straight to the server, cookies are first-party). So every plugin works on the remote web app. -
The web app detects the shell through the injected
window.Capacitor(isNativePlatform(),getPlatform(),PluginHeaders) and calls plugins through the bridge'snativePromiseandaddListener; it does not bundle@capacitor/core, and all Capacitor packages are dependencies ofapps/mobileonly. The bridge module isapps/web/src/lib/native/(detection, deep links, notification taps, local notifications, badge, APNs registration, server change); in a browser it is inert. Inside the shell the Web Push panel gives way to the app panel. -
Notifications without Firebase:
-
Foreground on both platforms: the inbox store (polled every 60 s while the app runs) feeds
@capacitor/local-notificationswith every new unread item (baseline on first start, ids remembered, no repeats); the icon badge (@capawesome/capacitor-badge) follows the unread count. -
iOS background: a new Notifications channel
apnssends directly to Apple with the owner's APNs auth key (token-based, ES256 JWT, HTTP/2;MOWDECK_APNS_KEY_FILE,_KEY_ID,_TEAM_ID,_BUNDLE_ID,_ENVIRONMENT), with the same preferences, quiet hours and alarm rules as Web Push and ntfy. The shell registers through@capacitor/push-notificationsand posts the device token toPOST /api/v1/notifications/me/push/apns; logout deletes it (DELETE .../me/push/apns/{id}); Apple'sUnregisteredorBadDeviceTokendeletes it as well. Theft alarms aretime-sensitive. With APNs on, the local notifications stay off (no duplicates). -
Android background: ntfy (already a channel, ADR 0022) in the ntfy app; the shell links to the ntfy app and offers the
ntfy://<server>/<topic>subscribe link. The push plugin is excluded from the Android build (android.includePlugins), so the APK carries no Firebase code. With an ntfy target set up, the app's local notifications stay off.
-
Foreground on both platforms: the inbox store (polled every 60 s while the app runs) feeds
-
Deep links use the custom scheme
mowdeck://(mowdeck://mowers/<id>opens/mowers/<id>); notification taps open the payload's app path through the same same-origin rule as Web Push. Universal links / App Links are not possible: Apple fetchesapple-app-site-associationonly through its CDN from a public domain, and Android verifiesassetlinks.jsonthe same way; an internal server can never pass. -
Distribution. iOS:
apps/mobile/scripts/provision.sh(automatic signing with-allowProvisioningUpdates -allowProvisioningDeviceRegistrationcreates the development certificate, registers the iPhone and the profile with Push and Time Sensitive Notifications),build-ios.sh(cap sync ios,xcodebuild archive),sign-and-install.sh(xcrun devicectl device install app), all usable over SSH with the keychain password in~/.mowdeck-sign-pass. Paid profiles last a year, so no 7-day renewal agent. Android:build-android.sh(gradlew assembleReleasewith the release keystore from the environment) and the jobandroidofrelease.ymlonv*tags (ubuntu), whose APK the release attaches; never on pull requests. The CI jobwebrunsapps/mobile's lint,tsccheck and tests with the rest of the workspace. -
App id
app.mowdeck.mobile, name "MowDeck"; icons and splash screens come from the web app's icon (apps/mobile/scripts/make-resources.pyrenders the sources,@capacitor/assetsthe sizes); the shell follows the system light/dark setting.
Options considered:
-
(B) Bundled web build with
CapacitorHttp/CapacitorCookiesand an API base URL: the refresh cookie becomes a cross-site cookie (or the native cookie jar), every service needs CORS with credentials and a configurable base URL, the realtime WebSocket needs another origin check, and each server update needs a new app build. Rejected; it duplicates the web build and weakens the cookie model of ADR 0021. -
A generous
allowNavigation: ['*']with a bundled launcher that just navigates to the server: works on iOS, but Android injects the bridge only for the app origin, so plugins would be missing until the next cold start, and every link to any site would open inside the app. Rejected in favour of settingserver.urlnatively. -
Firebase Cloud Messaging for Android: rejected by the owner (Google dependency, Firebase project,
google-services.json); ntfy covers the background without it. - TestFlight or the App Store: the owner installs directly from the Mac; not needed for an app that points at a private server.
- React Native, Flutter or MAUI: a second UI code base. Rejected.
- Server updates reach the apps at once; the app only changes for native changes (plugins, icons, permissions).
- The app needs the server: without a connection it shows the offline page, not a cached web app (the web app's service worker may serve cached assets on Android; on iOS a WKWebView service worker needs App-Bound Domains and is not used).
- The server must present a certificate the phone trusts for login (the refresh cookie is
Secure): a public CA, or a private CA installed and trusted on the phone; Android trusts user-installed CAs for this app (network_security_config.xml). Plain HTTP is allowed in the LAN but login fails there. - iOS background notifications need the owner's APNs auth key on the server and
MOWDECK_APNS_ENVIRONMENT=development, because a build installed from Xcode with a development profile registers with the APNs sandbox. Android background notifications need the ntfy app and a topic; while the app is open, it notifies by itself. - A paid developer account is required for APNs on iOS; the profiles are renewed once a year by running
provision.shagain. - Verified only by reading the sources and with the unit tests: the iOS and Android builds, the device install, APNs delivery and the remote bridge behaviour run on the owner's Mac and phones (docs/ops/mobile-apps.md lists the checks).
Mirrored from docs/adr/0036-native-app-shells-with-capacitor.md at commit 6a87264c6123f07c8474e153053525dc1816feb7. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit 6a87264c6123f07c8474e153053525dc1816feb7. Edit the files in the repository, not this wiki.
MowDeck documentation
- Home
- Phases
- Architecture
-
Decisions (ADR)
- Architecture decisions
- ADR 0001: OpenFGA instead of self-built authorization
- ADR 0002: Self-built Identity service in FastAPI instead of Keycloak or Zitadel
- ADR 0003: SvelteKit for the web app
- ADR 0004: Monorepo with a uv workspace
- ADR 0005: AGPL-3.0 for services and apps, Apache-2.0 for libraries
- ADR 0006: PostgreSQL for all persistence, no SQLite
- ADR 0007: NATS JetStream as event bus
- ADR 0008: Traefik as gateway
- ADR 0009: Command and event envelopes, subject naming and de-duplication
- ADR 0010: Python 3.13 for all services and libraries
- ADR 0011: Separate GPL-3.0 package for the PyMammotion adapter
- ADR 0012: Mower control interface and safety rules
- ADR 0013: Device-Gateway registry, sharding and per-mower concurrency
- ADR 0014: Authorization model, tuple ownership and the mowdeck-authz client
- ADR 0015: Tokens, sessions and key rotation
- ADR 0016: Fleet commands and authorization per command kind
- ADR 0017: Telemetry storage and retention
- ADR 0018: Realtime WebSocket protocol
- ADR 0019: Mammotion adapter transports, manual drive and secrets
- ADR 0020: Web app architecture
- ADR 0021: Browser authentication flow
- ADR 0022: Notifications: rules, recipients and delivery
- ADR 0023: Map rendering and projection
- ADR 0024: Scheduler: recurrence, rain pause and command issuing
- ADR 0025: Home Assistant integration as a pure API client
- ADR 0026: Manual drive channel over the Realtime WebSocket
- ADR 0027: Health history and theft alarm
- ADR 0028: Entitlements: plans as data and enforcement
- ADR 0029: Helm chart and release pipeline
- ADR 0030: Mowing patterns as plug-ins and the Planner
- ADR 0031: Billing with a Stripe adapter
- ADR 0032: Live camera over Agora, joined directly by the browser
- ADR 0033: Vision-assisted recovery with human approval
- ADR 0034: Linked cloud accounts and device discovery
- ADR 0035: Server-side camera frames and automatic AI analysis on errors
- ADR 0036: Native app shells with Capacitor, loading the web app from the server
- ADR 0037: Camera panorama calibration in the browser, with the AI supplying points only
- ADR 0038: Dual link per mower and firmware updates
- ADR 0039: Spot mowing by MowDeck-driven paths
- ADR 0040: Link heartbeat for online status
- ADR 0041: Spot runs from the dock: undock, transit and return
- ADR 0042: Drive calibration
- ADR 0043: Turn styles
- Development
- Operations
- Spikes
- Guides
- Original plan (German)