Developing the Capacitor mobile app
apps/mobile is a Capacitor shell around the standalone build: it ships no web assets of its own — webDir in capacitor.config.json is ../standalone/dist, and the entire Trilium server runs in-process as WASM in a web worker (apps/standalone). There is no network backend inside the app; "the server" is apps/standalone/src/local-server-worker.ts. Most mobile work therefore lands in apps/standalone/src/, with the native projects (apps/mobile/android/, apps/mobile/ios/) touched only for WebView behaviour.
Key files:
apps/mobile/capacitor.config.json # appId org.triliumnotes.trilium, androidScheme https, hostname localhost, plugins
apps/mobile/android/app/src/main/java/org/triliumnotes/trilium/
MainActivity.java # installs TriliumWebViewClient + TriliumFileSink, edge-to-edge + insets, system bars
TriliumWebViewClient.java # streaming same-origin HTTP proxy for the sync worker (Android only)
TriliumFileSink.java # binary file-write channel (WebMessageListener + ArrayBuffer, Android only)
apps/mobile/ios/App/App/ViewController.swift # keyboard handling: pins outer scroll, drives --tn-keyboard-gap
apps/standalone/src/main.ts # boot: registers native HTTP handler if `Capacitor` in window; iOS interceptors
apps/standalone/src/ios-interceptors.ts # fetch / XHR / <img> / stylesheet interceptors, iOS only
apps/standalone/src/services/capacitor_http_handler.ts # NativeHttpHandler: Android proxy probe + CapacitorHttp fallback
apps/standalone/src/services/capacitor_download.ts # saveUrlToDevice(): fetch → Filesystem cache → share sheet
apps/standalone/src/local-bridge.ts # LOCAL_API_PREFIXES, localFetch(), registerNativeHttpHandler()
apps/standalone/src/sw.ts # service worker routing (Android + web); guards for capacitor:// and the native proxy
apps/client/src/services/utils.ts # isMobileApp() — running inside the native wrapper
.github/workflows/mobile.yml, .github/actions/build-mobile # APK + iOS simulator builds, nightly signing
Inbound: how the client's API calls reach the in-app worker
The client's /api, /sync, /bootstrap, /search requests (LOCAL_API_PREFIXES in local-bridge.ts) must be answered by the worker.
Most of them never leave the page. The shell is a single WebView, so its one tab always wins the database lock, and a tab that owns the worker publishes localFetch on window.standaloneApi; the client's ajax() and setupGlob()'s /bootstrap call it directly rather than issuing a request (see the standalone skill, "Request flow" step 3). That covers everything routed through apps/client/src/services/server.ts, on both platforms alike.
What still leaves the page — engine-initiated loads (<img src="api/images/…">, @font-face, themes), upload()/chunked_upload.ts, the LLM stream — is where the platforms diverge, because their WebViews resolve *Scheme: "https" differently:
- Android —
androidScheme: "https"works, the app runs athttps://localhost, a real secure origin, so the service worker (sw.ts) intercepts those requests and forwards them to the worker — the same path as the web build. - iOS — the app runs at
capacitor://localhost, and WebKit refuses to register a service worker on a non-HTTP(S) origin.main.tstherefore installs in-page interceptors (installIosInterceptors(), gated onlocation.protocol === "capacitor:"), one per way a request can leave the page:window.fetch,XMLHttpRequest(jQuery$.ajaxnever touches fetch),<img src="api/images/…">(the image loader issues its own requests) and CSS-initiated loads (@font-face url()in injected styles, custom themes via<link href="api/…">). Each rewrites a local-API request intolocalFetch().
Consequences that keep tripping people up:
iosScheme: "https"is a no-op — do not re-add it. Capacitor rejects it:CAPInstanceDescriptor.normalize()checksWKWebView.handlesURLScheme(scheme) == false, and WKWebView reserveshttp/https, so the scheme resets tocapacitor. The config line only implies an https origin that never exists on iOS.- Do not delete the iOS interceptor path as "dead code", and do not gate anything on "there is always a service worker". A reviewer assuming
iosScheme: https⇒ https origin will wrongly flag it. - A new way for the page to issue a request (a new element type loading
api/…, anew Workerfetching,EventSource,sendBeacon) needs a fourth/fifth interceptor inios-interceptors.ts, or it will silently 404 on iOS while working everywhere else. Test it inios-interceptors.spec.ts. sw.tshas a defensiveself.location.protocol === "capacitor:"guard and must let/_trilium_native_http/requests fall through to the WebView (see below); keep both when editing its fetch handler.- Image blob URLs created by the interceptor are revoked (
5332cee3fb); if you add anotherURL.createObjectURL, revoke it.
Outbound: how the worker syncs with a remote server
A fetch from the app origin to a sync server is cross-origin, so CORS and cookie rules apply and large bodies cost bridge copies; instead local-bridge.ts exposes registerNativeHttpHandler(): when a handler is registered (only inside Capacitor — main.ts checks "Capacitor" in window), the worker's BridgedRequestProvider posts HTTP_REQUEST messages to the page and the handler does the real HTTP call. capacitor_http_handler.ts is that handler:
- Android — probes
GET /_trilium_native_http/pingonce; ifTriliumWebViewClientanswers with thex-trilium-native-httpmarker, GET/HEAD requests go through the streaming same-origin proxy (/_trilium_native_http/fetch?url=…, request headers tunnelled asx-trilium-h-<name>, upstreamSet-Cookiere-exposed asx-trilium-set-cookie, proxy failures → 502 +x-trilium-proxy-error). Answered fromWebViewClient.shouldInterceptRequest, so the body streams into the page with no bridge envelope, no full-body Java string, no base64 — the plugin transport measured ~60 % of a core and ~2 MB/s during an initial sync. A failed probe is retried after 15 s because an old service worker can still own fetches right after an update. - Everything else (POSTs, binary responses, and all of iOS) uses the stock
CapacitorHttpplugin, reached via the globalwindow.Capacitor.Plugins— notimport "@capacitor/core", since bare specifiers don't resolve in the browser's native module loader. - Responses hand parsed JSON through
dataand only non-JSON throughbody; the handler must notJSON.stringify— the extra string copy OOM-ed the iOS worker on large blobs. Preserve that contract when touching either side. - iOS has no
shouldInterceptRequestequivalent for https, so it stays on the plugin transport; the geo map's tile referer workaround (apps/client/src/widgets/collections/geomap/map.tsx) has the same limitation.
Downloads: the WebView has no download manager
A navigation whose response carries Content-Disposition: attachment is handed to
WebView.setDownloadListener on Android, and nothing registers one — not Capacitor, not
MainActivity. The response is dropped with no console output and no error, so an export "succeeds"
(the task's websocket taskSucceeded still fires the toast) while no file ever appears. On iOS the
same window.location.href is worse: the interceptors patch fetch/XHR/<img>/stylesheets, never a
top-level navigation, so the URL reaches Capacitor's scheme handler and navigates the app out of the
SPA.
Registering a native DownloadListener does not fix it. The listener receives only a URL, and a
native re-request of https://localhost/api/… goes to the real network stack, which the service
worker never sees and where no server exists.
So the page does the saving: open.download() (apps/client/src/services/open.ts) routes through
window.standaloneApi.save.saveUrl() when that exists, which main.ts defines only inside the shell.
capacitor_download.ts fetches the URL — still routed to the worker by the service worker on Android
and the interceptors on iOS — writes it into Directory.Cache and hands the file to the system share
sheet.
- On Android the bytes ride
TriliumFileSink, not the plugin bridge. Every plugin call crosses as base64 inside a JSON string thatMessageHandlerre-parses whole withorg.json— ~13 MB/s no matter the chunk size. The sink is aWebViewCompat.addWebMessageListenerobject (window.triliumFileSink) carrying rawArrayBuffers: the page resolves the absolute path via the Filesystem plugin's own mapping (writeFile("")+getUri), then streamsopen/chunks/close, each step acknowledged. The base64 plugin path stays as the fallback — iOS (messageHandlerscannot carry ArrayBuffers), WebViews withoutWEB_MESSAGE_ARRAY_BUFFER, and the sink-busy case. - Fallback chunks must be a multiple of 3 bytes. The Filesystem plugin takes base64, and base64
pads any group narrower than three bytes; a padded group mid-file decodes to the wrong bytes.
rechunk()incapacitor_download.tsowns that alignment (and bounds sink messages); the encode uses nativeUint8Array.prototype.toBase64when the runtime has it. - Plugins come from
Capacitor.registerPlugin(name), not a@capacitor/*import.Capacitor.Pluginsholds only what the injected runtime registered (CapacitorHttpand the rest of core); the@capacitor/*packages exist socap syncwires the native code in. - A shared file cannot be deleted after
share()resolves — the receiving app reads the URI on its own schedule (a Drive upload can outlive the sheet by minutes). Each download therefore lands in a run folder of its own, and a save prunes all previous runs except the newest, giving the last share one save's grace. - Every save writes
<name>.partand renames into place on success. A stream can die mid-way —DatabaseChangedErrorfires if any write lands during a backup — and truncating the final name first would turn the previous good backup into a partial file. - Android needs no manifest change:
file_paths.xmlalready exposes<cache-path path="."/>to the${applicationId}.fileproviderthe Share plugin looks up, and the cache directory is not external storage, so no permission prompt. - Adding a plugin means three edits:
apps/mobile/package.json,includePluginsincapacitor.config.json(iOS only builds what is listed), andcap update androidto regenerate the trackedcapacitor.settings.gradle/app/capacitor.build.gradle. iOS'sCapApp-SPM/Package.swiftis gitignored and regenerated by CI. - The known cost profile (1.8 GB backup on a mid-range phone, Sept 2026): ~16s, split evenly
between SQLite page reads and the container's WASM SHA-256 (
hash-wasminpackages/trilium-backup-container/src/backend-web.ts); the sink's writes are not a factor. The base64 plugin bridge caps around 13 MB/s (MessageHandlerre-parses each call's JSON whole withorg.json), which is why the sink exists. - The database backup takes the same last step, from a different source.
saveDatabase()inlocal-bridge.tsconsumes the worker'sBACKUP_STREAMchannel in the page rather than relaying the port to the service worker, so no SW is involved (which is also why it works on iOS), andsetBackupPingingstays off — that keepalive exists only for a stream the SW is holding open. Two things differ from a download: it goes toDirectory.DocumentsunderTrilium/, not the cache, and nothing there is ever pruned — a repeated name replaces the file only via the.partswap. A dismissed share sheet isdone, notcancelled: the file is complete before the sheet opens. rechunk()is what makes any source safe to write. A producer picks its chunk sizes for its own reasons — a response body by packet, the backup by database page — and neither is 3-aligned.
Native shells
- Android
MainActivity: setsTriliumWebViewClient, draws edge-to-edge with transparent system bars, forwards window insets to the WebView (so the client can pad for the status/navigation bars) and re-applies system bar appearance on configuration change. Nightly and debug builds get a distinctapplicationIdsuffix (.nightly,.debug) and launcher icon so they install side by side (android/app/build.gradle). - iOS
ViewController: the layout isbody { position: fixed; height: 100vh }with an inner scrolling container, so WKWebView's reflexive scroll-to-focused-element would drag the toolbar off-screen; the controller pins the outer scroll offset while the keyboard animates and samples the keyboard's top edge every frame into the--tn-keyboard-gapCSS variable so the editor toolbar follows an interactive swipe-dismiss.Keyboard.resize: "native"in the config is part of the same contract. Change the keyboard/toolbar CSS on the client and this controller together. limitsNavigationsToAppBoundDomains: trueon iOS.
Safe-area insets: never write a bare env(safe-area-inset-*)
Android's WebView does not populate env(safe-area-inset-*) — it resolves to 0, silently, on every
Android version. MainActivity.forwardInsetsToWebView() works around it by setting
--safe-area-inset-top/-bottom/-left/-right (plus --keyboard-height) on
document.documentElement from the real WindowInsetsCompat values, in CSS pixels, on every inset
change.
So client CSS must read the variable with env() only as the fallback:
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom));
The fallback is what keeps iOS and desktop browsers right, where the variable is never injected and
env() is authoritative. Get the two axes matching — var(--safe-area-inset-left, env(safe-area-inset-left)),
never a -left var falling back to env(…-right).
- Bare
env()is a bug on Android, not a style nit. It had gone unnoticed at 27 call sites (fixed ina8f4e9f405), including--mobile-bottom-offset, which puts the mobile launcher bar under the gesture pill. - The one deliberate exception is the
body.ios--mobile-bottom-offsetoverride inapps/client/src/stylesheets/style.css— platform-scoped to whereenv()is the real source. body.desktoprules are in scope too. An Android tablet WebView has noMobiin its UA, soisMobile()is false andindex.tspicks the desktop layout for it.--keyboard-heightis injected but read by nothing.MainActivityresizes the WebView throughbottomMargininstead, so the CSS viewport shrinks on its own. iOS drives--tn-keyboard-gapfromViewControllerfor a different purpose; the two are not a pair.
Audit with grep -rn "env(safe-area" --include=*.css apps/client/src apps/standalone/src — every hit
should be wrapped in a matching var().
Client-side gating
isMobileApp()(apps/client/src/services/utils.ts) —window.Capacitor?.isNativePlatform?.(): true only inside the native wrapper. Distinct fromisMobile(), which is the layout choice and is also true for a phone browser. Use the former for "there is a native shell" behaviour (e.g. setup flow), the latter for responsive UI.window.Capacitoris typed inapps/client/src/types.d.ts.- There is no Node, no server process and no
apps/servercode at runtime — anything the mobile app needs from "the backend" is core (packages/trilium-core), which is why core carries the no-Node-built-ins rules.
Building and running
pnpm --filter @triliumnext/mobile build # = standalone build → apps/standalone/dist
pnpm --filter @triliumnext/mobile sync # build + `cap sync` (copies dist into android/ and ios/)
pnpm --filter @triliumnext/mobile run:android # emulator/device (needs ANDROID_HOME, JDK 17+)
pnpm --filter @triliumnext/mobile open:android # Android Studio
pnpm --filter @triliumnext/mobile run:ios | open:ios # Xcode (macOS)
CI: mobile.yml (pull requests) builds a debug APK via .github/actions/build-mobile and an unsigned iOS Simulator .app on macOS; nightly.yml calls the same action with nightly: "true" for the signed assembleRelease build under the .nightly app id. Neither runs unit tests — those live in the standalone suite.
Debugging on a device: a release-type build (what nightly.yml produces) suppresses WebView
console→logcat forwarding, so anything logged from JS is invisible to adb logcat and log-based
detection of what the app is doing goes blind. android.util.Log calls from the native side still
come through, so instrument the Java layer — or install a debug build — when you need to see what
is happening.
Testing
Everything JS-side is under the standalone Vitest suite (happy-dom + real sqlite-wasm):
pnpm --filter standalone test ios-interceptors # iOS interceptors
pnpm --filter standalone test capacitor_http_handler # Android proxy probe / plugin fallback
pnpm --filter standalone test capacitor_download # chunked base64 write, filename parsing, share sheet
pnpm --filter standalone test sw # service-worker routing incl. capacitor:// guard
pnpm --filter standalone test main # boot wiring (native handler registered, interceptors installed on capacitor:)
Simulate the platform in a spec by stubbing location.protocol / window.Capacitor (getPlatform, isNativePlatform, Plugins.CapacitorHttp) — see the existing specs for the fixtures. Native Java/Swift has no test harness in the repo; keep logic there minimal and mirror the protocol on the JS side where it is testable.