feat(mobile): route iOS local API requests through a native WKURLSchemeHandler#10439
feat(mobile): route iOS local API requests through a native WKURLSchemeHandler#10439eliandoran wants to merge 2 commits into
Conversation
…meHandler Replace the in-page iOS interceptors (fetch, XHR, <img>, stylesheets) with interception at the WKURLSchemeHandler level. Each interceptor reimplemented request routing for one initiation channel and silently missed the rest (audio/video sources, iframes, CSS @import, …); the scheme handler sits below the content layer and catches every request on the capacitor:// scheme by construction. Native side (ViewController.swift): Capacitor registers its WebViewAssetHandler before any subclass hook runs and WebKit forbids replacing a registered scheme handler, so the live instance is re-typed to TriliumAssetHandler via object_setClass (the same isa-swizzle pattern already used for WKContentView). Local API paths are forwarded into the page through evaluateJavaScript; TriliumSchemeBridge queues requests until the JS side announces readiness and completes the WKURLSchemeTasks from "triliumScheme" script messages. Bodies cross the bridge as base64 in both directions. JS side (apps/standalone): ios-native-bridge.ts answers the forwarded requests via localFetch against the in-page SQLite worker. Known prototype limitations: whole-body buffering with base64 marshalling per request (measure large sync/attachments; hybrid fetch/XHR fast path is the planned mitigation), no watchdog timeout for pending tasks, and requests in flight across a page reload fail fast rather than being retried. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dler.swift Move TriliumAssetHandler, TriliumSchemeBridge, and the local API prefix list out of ViewController.swift into a dedicated file; the view controller keeps only the webView(with:configuration:) hook that isa-swizzles Capacitor's registered handler. Registers the new file in the Xcode project. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🖥️ App preview is ready! 🔗 Preview URL: https://pr-10439.trilium-app.pages.dev ✅ All checks passed This preview will be updated automatically with new commits. |
Bundle ReportChanges will decrease total bundle size by 5.48kB (-0.01%) ⬇️. This is within the configured threshold ✅ Detailed changes
Affected Assets, Files, and Routes:view changes for bundle: standalone-esmAssets Changed:
Files in
view changes for bundle: client-esmAssets Changed:
|
There was a problem hiding this comment.
Code Review
This pull request replaces the previous in-page request interceptors for iOS with a native-level interception mechanism using WKURLSchemeHandler (TriliumSchemeHandler.swift) and a simplified JS bridge (ios-native-bridge.ts). This ensures all local API requests are reliably routed to the SQLite worker regardless of how they are initiated. The review feedback highlights a few correctness issues: first, the isJsReady flag in the TriliumSchemeBridge singleton is not reset on page reloads, which can cause early requests to fail; second, relying on stream.hasBytesAvailable when reading the request body stream is unreliable and should be replaced with a standard loop until EOF.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| override func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { | ||
| if let path = urlSchemeTask.request.url?.path, | ||
| localApiPrefixes.contains(where: { path.hasPrefix($0) }) { | ||
| TriliumSchemeBridge.shared.intercept(urlSchemeTask, from: webView) | ||
| return | ||
| } | ||
| super.webView(webView, start: urlSchemeTask) | ||
| } |
There was a problem hiding this comment.
Correctness Issue: JS Bridge Readiness State Not Reset on Reload
Since TriliumSchemeBridge is a singleton, the isJsReady flag remains true even when the web view navigates or reloads. On a reload, early requests (such as /bootstrap or initial asset loads) will be delivered immediately via evaluateJavaScript instead of being queued. Since the new page has not yet loaded the JS bridge, these requests will fail immediately with a "JS bridge unavailable" error.
To fix this, we should reset the bridge's readiness state and clear any queued requests whenever the main page (/ or /index.html) is requested.
override func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
if let path = urlSchemeTask.request.url?.path {
if path.isEmpty || path == "/" || path == "/index.html" {
TriliumSchemeBridge.shared.reset()
}
if localApiPrefixes.contains(where: { path.hasPrefix($0) }) {
TriliumSchemeBridge.shared.intercept(urlSchemeTask, from: webView)
return
}
}
super.webView(webView, start: urlSchemeTask)
}| static let shared = TriliumSchemeBridge() | ||
|
|
There was a problem hiding this comment.
Correctness Issue: Add reset() Method to TriliumSchemeBridge
To support resetting the bridge state on page reload, we need to add a reset() method to TriliumSchemeBridge that resets isJsReady to false and clears any queued requests.
| static let shared = TriliumSchemeBridge() | |
| static let shared = TriliumSchemeBridge() | |
| func reset() { | |
| isJsReady = false | |
| queuedRequests.removeAll() | |
| } |
| while stream.hasBytesAvailable { | ||
| let read = stream.read(&buffer, maxLength: bufferSize) | ||
| if read <= 0 { break } | ||
| data.append(buffer, count: read) | ||
| } |
There was a problem hiding this comment.
Correctness Issue: Unreliable hasBytesAvailable Check on InputStream
Using stream.hasBytesAvailable as a loop condition can be unreliable. According to Apple's documentation, some InputStream subclass implementations do not support this property and will always return false, or it may return false before any bytes are read if the stream hasn't been read from yet. This can cause POST requests with stream bodies to be sent with empty bodies.
The standard, robust way to read from an InputStream is to loop until read returns 0 (EOF) or a negative value (error).
| while stream.hasBytesAvailable { | |
| let read = stream.read(&buffer, maxLength: bufferSize) | |
| if read <= 0 { break } | |
| data.append(buffer, count: read) | |
| } | |
| while true { | |
| let read = stream.read(&buffer, maxLength: bufferSize) | |
| if read <= 0 { break } | |
| data.append(buffer, count: read) | |
| } |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR replaces four in-page JavaScript request interceptors (fetch, XHR,
Confidence Score: 2/5Not ready to merge — the scheme bridge has correctness bugs around page reload and task cancellation, plus a security gap in the script message handler that allows any in-page script to forge API responses. The core architecture is sound and the JS-side bridge is well-tested, but TriliumSchemeHandler.swift has several issues: isJsReady is never cleared on page reload so requests fail during the reload window; the stop override calls super for intercepted-but-completed tasks that super never started; and the WKScriptMessageHandler is registered in the default WKContentWorld with sequential predictable IDs, meaning any injected script can forge completions for in-flight API tasks. apps/mobile/ios/App/App/TriliumSchemeHandler.swift needs the most attention — the bridge lifecycle, stop-handler routing, and script message handler isolation all require fixes.
|
| Filename | Overview |
|---|---|
| apps/mobile/ios/App/App/TriliumSchemeHandler.swift | New WKURLSchemeHandler + JS bridge; has issues with isJsReady not resetting on navigation, stop forwarded to super for already-completed intercepted tasks, cancelled-but-queued task waste, unauthenticated script message handler, and synchronous main-thread stream I/O. |
| apps/mobile/ios/App/App/ViewController.swift | Adds webView(with:configuration:) override that isa-swizzles Capacitor's WebViewAssetHandler to TriliumAssetHandler and registers the triliumScheme message handler; consistent with existing isa-swizzle patterns. |
| apps/standalone/src/ios-native-bridge.ts | New JS-side bridge; well-tested with 8 spec cases covering roundtrip, error paths, and binary integrity. |
| apps/standalone/src/ios-native-bridge.spec.ts | New test suite covering happy path, POST body decode, GET-body guard, large binary roundtrip, error posting, and prefix-mismatch guard. |
| apps/standalone/src/main.ts | Switches iOS bootstrap from installIosInterceptors to installIosNativeBridge; capacitor:// protocol guard unchanged. |
| apps/standalone/src/ios-interceptors.ts | Deleted — replaced by the native WKURLSchemeHandler approach. |
| apps/standalone/src/ios-interceptors.spec.ts | Deleted alongside ios-interceptors.ts; coverage superseded by ios-native-bridge.spec.ts. |
| apps/mobile/ios/App/App.xcodeproj/project.pbxproj | Adds TriliumSchemeHandler.swift to the Xcode build target. |
Reviews (1): Last reviewed commit: "refactor(mobile): extract scheme-handler..." | Re-trigger Greptile
| /// stopped WKURLSchemeTask raises an exception, so it must leave the pending map | ||
| /// before WebKit returns from stop(). | ||
| func cancel(_ urlSchemeTask: WKURLSchemeTask) -> Bool { | ||
| guard let id = taskIds.removeValue(forKey: ObjectIdentifier(urlSchemeTask)) else { | ||
| return false | ||
| } | ||
| pendingTasks.removeValue(forKey: id) | ||
| return true | ||
| } |
There was a problem hiding this comment.
isJsReady never reset on page navigation/reload
isJsReady is set to true the first time the JS bridge announces readiness and is never cleared. After a page reload, any local-API request arriving before the new page's installIosNativeBridge() runs is immediately fed to evaluateJavaScript. Since window.__triliumNativeRequest no longer exists at that point, the evaluation fails and deliver calls fail(id:message:) — the request is dropped with "JS bridge unavailable" rather than being queued. The bridge then receives the new "ready" message but queuedRequests is empty, so nothing is replayed.
| guard let json = try? JSONSerialization.data(withJSONObject: payload), | ||
| let jsonString = String(data: json, encoding: .utf8) else { | ||
| urlSchemeTask.didFailWithError(bridgeError("Could not encode request payload")) | ||
| return | ||
| } | ||
| // JSON is not quite a JS literal: U+2028/U+2029 are legal unescaped in JSON |
There was a problem hiding this comment.
stop forwarded to super for tasks TriliumAssetHandler already completed
When takeTask removes a task from both maps (inside complete or fail) and WebKit subsequently calls stop for the same task, cancel returns false and super.webView(webView, stop:urlSchemeTask) is called. Because TriliumAssetHandler.webView(_:start:) handled the task without calling super, the parent WebViewAssetHandler never registered it — forwarding stop to super for an unknown task may crash depending on Capacitor's implementation.
| func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { | ||
| guard let body = message.body as? [String: Any], let type = body["type"] as? String else { return } | ||
| switch type { | ||
| case "ready": | ||
| isJsReady = true | ||
| let queued = queuedRequests | ||
| queuedRequests = [] | ||
| for item in queued { | ||
| deliver(item.json, id: item.id) | ||
| } | ||
| case "response": | ||
| guard let id = body["id"] as? String, let task = takeTask(id: id) else { return } | ||
| complete(task, with: body) | ||
| case "error": | ||
| guard let id = body["id"] as? String else { return } | ||
| fail(id: id, message: body["message"] as? String ?? "Request failed") | ||
| default: | ||
| break | ||
| } | ||
| } |
There was a problem hiding this comment.
Response injection via predictable sequential IDs
The handler is registered in the default WKContentWorld, so any in-page script can call window.webkit.messageHandlers.triliumScheme.postMessage({type:"response", id:"req1", status:200, headers:{}, bodyBase64:""}) to forge a completion for a live WKURLSchemeTask. Because IDs are a monotonically incrementing counter (req1, req2, …), an attacker with script execution in the page can synthesize successful API responses for in-flight note-save or sync calls. Consider isolating the handler to a private WKContentWorld or adding a per-session nonce.
How this was verified: Static source-to-sink trace — WKUserContentController.add(TriliumSchemeBridge.shared, name:"triliumScheme") registers in the default world; the "response" branch at line 113 calls takeTask and passes untrusted body fields to complete(), which calls task.didReceive/didFinish without any caller verification.
| deliver(jsSafeJson, id: id) | ||
| } else { | ||
| queuedRequests.append((id: id, json: jsSafeJson)) | ||
| } |
There was a problem hiding this comment.
Cancelled queued tasks remain in
queuedRequests
cancel removes the task from pendingTasks and taskIds, but if the task was added to queuedRequests (because isJsReady was false), the entry is never pruned. When the queue is later flushed, deliver dispatches the stale JSON to the JS bridge and localFetch runs — only when the response arrives does takeTask return nil and the work is discarded. The wasted SQLite-worker round-trip matters most during bulk-cancel scenarios.
| /// body arrives as a stream instead. | ||
| private func requestBody(of request: URLRequest) -> Data? { | ||
| if let body = request.httpBody { | ||
| return body | ||
| } | ||
| guard let stream = request.httpBodyStream else { return nil } | ||
| stream.open() | ||
| defer { stream.close() } | ||
| var data = Data() | ||
| let bufferSize = 64 * 1024 | ||
| var buffer = [UInt8](repeating: 0, count: bufferSize) | ||
| while stream.hasBytesAvailable { | ||
| let read = stream.read(&buffer, maxLength: bufferSize) | ||
| if read <= 0 { break } | ||
| data.append(buffer, count: read) | ||
| } | ||
| return data | ||
| } |
There was a problem hiding this comment.
Synchronous main-thread I/O and silent truncation on stream error
requestBody(of:) reads httpBodyStream synchronously on the main thread inside a WebKit callback. For large payloads this blocks the UI. Additionally, a -1 return from InputStream.read(_:maxLength:) (stream error) only breaks the loop — the partially-read data is silently returned as the body rather than signalling failure.
Motivation
On iOS, Capacitor loads the app on the
capacitor://scheme, where WebKit refuses to register a service worker — so the request routing the SW performs on Android/web (page ↔ in-page SQLite worker) was reimplemented as in-page interceptors, one per request-initiation channel:fetch, XHR,<img>, and most recently<style>/<link>(icon pack fonts, custom themes). Each new channel had to be discovered as a user-facing bug; engine-initiated loads such as audio/video sources, iframes, and CSS@importwere still uncovered.Approach
Intercept at the WKURLSchemeHandler level instead — below the content layer, where every request on the
capacitor://scheme arrives regardless of which engine subsystem issued it. Local API paths (/api/,/sync/,/search/,/bootstrap) are forwarded into the page, answered by the SQLite worker, and completed natively:All four in-page interceptors are removed.
Native notes
loadView()isfinaland WebKit raisesNSInvalidArgumentExceptionwhen re-registering a scheme handler, so the registeredWebViewAssetHandlerinstance is re-typed toTriliumAssetHandlerviaobject_setClass(same isa-swizzle pattern the app already uses forWKContentView). The subclass holds no stored properties; state lives inTriliumSchemeBridge.readymessage.stop(completing a stoppedWKURLSchemeTaskraises).httpBodyStreamwhenhttpBodyis nil;Content-Lengthis normalized to the delivered bytes.Verification
ios-native-bridge.spec.ts(8 tests: roundtrip, body decode, GET-body guard, >32 KB binary integrity, error paths, prefix-mismatch guard); full standalone suite green (210 files / 2,985 tests).Outstanding before marking ready
🤖 Generated with Claude Code