Skip to content

feat(mobile): route iOS local API requests through a native WKURLSchemeHandler#10439

Draft
eliandoran wants to merge 2 commits into
mainfrom
feat/ios-native-scheme-handler
Draft

feat(mobile): route iOS local API requests through a native WKURLSchemeHandler#10439
eliandoran wants to merge 2 commits into
mainfrom
feat/ios-native-scheme-handler

Conversation

@eliandoran

Copy link
Copy Markdown
Contributor

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 @import were 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:

WebKit engine → TriliumAssetHandler (native)
  → evaluateJavaScript(window.__triliumNativeRequest(payload))
    → localFetch → SQLite worker
      → postMessage("triliumScheme" script message)
        → native completes the WKURLSchemeTask

All four in-page interceptors are removed.

Native notes

  • Capacitor's loadView() is final and WebKit raises NSInvalidArgumentException when re-registering a scheme handler, so the registered WebViewAssetHandler instance is re-typed to TriliumAssetHandler via object_setClass (same isa-swizzle pattern the app already uses for WKContentView). The subclass holds no stored properties; state lives in TriliumSchemeBridge.
  • Requests intercepted before the JS bridge loads are queued and flushed on its ready message.
  • Cancelled tasks leave the pending map in stop (completing a stopped WKURLSchemeTask raises).
  • POST bodies are drained from httpBodyStream when httpBody is nil; Content-Length is normalized to the delivered bytes.
  • Bodies cross the bridge as base64 in both directions.

Verification

  • New 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).
  • Full Xcode simulator build succeeds (Capacitor 8.4.1, iOS 18 SDK).

Outstanding before marking ready

  • On-device validation: boot, icon pack fonts, images, note saves (body path), media, and a full sync of a large DB
  • Perf measurement: every request now crosses to native with base64 marshalling on the main thread; if large sync/attachments regress, the planned mitigation is a hybrid fast path (fetch/XHR handled in-page again, scheme handler as catch-all for engine-initiated loads)
  • Consider a native watchdog timeout for pending tasks and failing pending tasks on page navigation
  • Update the CLAUDE.md mobile section, which still documents the removed interceptors

🤖 Generated with Claude Code

eliandoran and others added 2 commits July 8, 2026 20:34
…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>
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🖥️ App preview is ready!

🔗 Preview URL: https://pr-10439.trilium-app.pages.dev
📖 Production URL: https://app.triliumnotes.org

✅ All checks passed

This preview will be updated automatically with new commits.

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bundle Report

Changes will decrease total bundle size by 5.48kB (-0.01%) ⬇️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
standalone-esm 53.31MB -5.48kB (-0.01%) ⬇️

Affected Assets, Files, and Routes:

view changes for bundle: standalone-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/abstract_provider-CqznvjWo.js (New) 2.08MB 2.08MB 100.0% 🚀
assets/src-BRwUdYNp.js (New) 194.22kB 194.22kB 100.0% 🚀
assets/crypto_provider-Dw7EPCY3.js (New) 97.66kB 97.66kB 100.0% 🚀
assets/in_app_help_provider-BLz2yG8k.js (New) 73.87kB 73.87kB 100.0% 🚀
assets/zip-D2-9XAYV.js (New) 55.92kB 55.92kB 100.0% 🚀
assets/browser_routes-BHUYjWoX.js (New) 8.23kB 8.23kB 100.0% 🚀
assets/becca_loader-DrdCCUzr.js (New) 5.71kB 5.71kB 100.0% 🚀
assets/local-server-worker-cmrlGjmr.js (New) 4.65kB 4.65kB 100.0% 🚀
src/main.js -5.48kB 4.48kB -54.98%
assets/html-CwAOs8if.js (New) 2.93kB 2.93kB 100.0% 🚀
assets/ru-B_cfDrNz.js (New) 2.39kB 2.39kB 100.0% 🚀
assets/backup_provider-NQ3m6oH2.js (New) 2.32kB 2.32kB 100.0% 🚀
assets/uk-HU3MHAAs.js (New) 2.31kB 2.31kB 100.0% 🚀
assets/log_provider-D6PsdsE1.js (New) 1.96kB 1.96kB 100.0% 🚀
assets/0216__move_content_into_blobs-Cj8TjdZr.js (New) 1.9kB 1.9kB 100.0% 🚀
assets/zip_export_provider_factory-B9p4Xboj.js (New) 1.83kB 1.83kB 100.0% 🚀
assets/ar-BL8nFHrM.js (New) 1.81kB 1.81kB 100.0% 🚀
assets/cs-DEbeQVhf.js (New) 1.78kB 1.78kB 100.0% 🚀
assets/pl-BnwrBZRe.js (New) 1.74kB 1.74kB 100.0% 🚀
assets/hi-DjtZZq2x.js (New) 1.74kB 1.74kB 100.0% 🚀
assets/zh-cn-B8VcS3vZ.js (New) 1.54kB 1.54kB 100.0% 🚀
assets/de-BCkMtt8f.js (New) 1.52kB 1.52kB 100.0% 🚀
assets/zh-tw-CNA_AxBS.js (New) 1.51kB 1.51kB 100.0% 🚀
assets/ja-p__VcKiS.js (New) 1.35kB 1.35kB 100.0% 🚀
assets/pt-BoT5ocXp.js (New) 1.3kB 1.3kB 100.0% 🚀
assets/ga-nuuVP91F.js (New) 1.29kB 1.29kB 100.0% 🚀
assets/en-gb-DEyuadml.js (New) 1.29kB 1.29kB 100.0% 🚀
assets/pt-br-Ci8TiYLE.js (New) 1.28kB 1.28kB 100.0% 🚀
assets/fr-BgMO0tDk.js (New) 1.27kB 1.27kB 100.0% 🚀
assets/es-BaTWzs-x.js (New) 1.25kB 1.25kB 100.0% 🚀
assets/it-zrrxOP6H.js (New) 1.23kB 1.23kB 100.0% 🚀
assets/ro-CuigLY8B.js (New) 1.22kB 1.22kB 100.0% 🚀
assets/id-BPx-tkmM.js (New) 1.22kB 1.22kB 100.0% 🚀
assets/0233__migrate_geo_map_to_collection-AqzYnRvr.js (New) 777 bytes 777 bytes 100.0% 🚀
assets/0220__migrate_images_to_attachments-C3TIST1E.js (New) 672 bytes 672 bytes 100.0% 🚀
assets/0234__migrate_ai_chat_to_code-C4c-HqZx.js (New) 443 bytes 443 bytes 100.0% 🚀
assets/markdown-CVVcurwq.js (New) 323 bytes 323 bytes 100.0% 🚀
assets/abstract_provider-C_vKNpZO.js (Deleted) -2.08MB 0 bytes -100.0% 🗑️
assets/src-T5Np3XOg.js (Deleted) -194.22kB 0 bytes -100.0% 🗑️
assets/crypto_provider-CEUhmyKv.js (Deleted) -97.66kB 0 bytes -100.0% 🗑️
assets/in_app_help_provider-CrpAYDuz.js (Deleted) -73.87kB 0 bytes -100.0% 🗑️
assets/zip-BsZTbvtz.js (Deleted) -55.92kB 0 bytes -100.0% 🗑️
assets/browser_routes-BEWh1yTl.js (Deleted) -8.23kB 0 bytes -100.0% 🗑️
assets/becca_loader-DTHPJXSC.js (Deleted) -5.71kB 0 bytes -100.0% 🗑️
assets/local-server-worker-ClU1DNBG.js (Deleted) -4.65kB 0 bytes -100.0% 🗑️
assets/html-PH6OmEld.js (Deleted) -2.93kB 0 bytes -100.0% 🗑️
assets/ru-CAavDD1P.js (Deleted) -2.39kB 0 bytes -100.0% 🗑️
assets/backup_provider-C0ksH06h.js (Deleted) -2.32kB 0 bytes -100.0% 🗑️
assets/uk-C_8KJB9U.js (Deleted) -2.31kB 0 bytes -100.0% 🗑️
assets/log_provider-fEBBuj7S.js (Deleted) -1.96kB 0 bytes -100.0% 🗑️
assets/0216__move_content_into_blobs-CZmnm5OV.js (Deleted) -1.9kB 0 bytes -100.0% 🗑️
assets/zip_export_provider_factory-tud03M_d.js (Deleted) -1.83kB 0 bytes -100.0% 🗑️
assets/ar-B1pN_ZOC.js (Deleted) -1.81kB 0 bytes -100.0% 🗑️
assets/cs-CfWMhUmy.js (Deleted) -1.78kB 0 bytes -100.0% 🗑️
assets/pl-WhlxTSaZ.js (Deleted) -1.74kB 0 bytes -100.0% 🗑️
assets/hi-BsawLIDW.js (Deleted) -1.74kB 0 bytes -100.0% 🗑️
assets/zh-cn-B7nRTsAu.js (Deleted) -1.54kB 0 bytes -100.0% 🗑️
assets/de-Cn_0R141.js (Deleted) -1.52kB 0 bytes -100.0% 🗑️
assets/zh-tw-CbcytAkw.js (Deleted) -1.51kB 0 bytes -100.0% 🗑️
assets/ja-0eUqVdDz.js (Deleted) -1.35kB 0 bytes -100.0% 🗑️
assets/pt-B9HbH6gN.js (Deleted) -1.3kB 0 bytes -100.0% 🗑️
assets/ga-Cym6MlcC.js (Deleted) -1.29kB 0 bytes -100.0% 🗑️
assets/en-gb-DHbrbmlz.js (Deleted) -1.29kB 0 bytes -100.0% 🗑️
assets/pt-br-CxoEZoTz.js (Deleted) -1.28kB 0 bytes -100.0% 🗑️
assets/fr-DhloFO6_.js (Deleted) -1.27kB 0 bytes -100.0% 🗑️
assets/es-CpQo49p8.js (Deleted) -1.25kB 0 bytes -100.0% 🗑️
assets/it-DxOKBzO6.js (Deleted) -1.23kB 0 bytes -100.0% 🗑️
assets/ro-CuKxXqbg.js (Deleted) -1.22kB 0 bytes -100.0% 🗑️
assets/id-DA4ObcS-.js (Deleted) -1.22kB 0 bytes -100.0% 🗑️
assets/0233__migrate_geo_map_to_collection-Dqp2JbfT.js (Deleted) -777 bytes 0 bytes -100.0% 🗑️
assets/0220__migrate_images_to_attachments-6P3hX8Ux.js (Deleted) -672 bytes 0 bytes -100.0% 🗑️
assets/0234__migrate_ai_chat_to_code-CIwFBOuk.js (Deleted) -443 bytes 0 bytes -100.0% 🗑️
assets/markdown-R8UigsPL.js (Deleted) -323 bytes 0 bytes -100.0% 🗑️

Files in src/main.js:

  • ./src/ios-native-bridge.ts → Total Size: 1.82kB

  • ./src/main.ts → Total Size: 3.22kB

view changes for bundle: client-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
src/dist-*.js 1 bytes 57 bytes 1.79%
src/dist-*.js -1 bytes 56 bytes -1.75%

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +20 to +27
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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)
    }

Comment on lines +45 to +46
static let shared = TriliumSchemeBridge()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
static let shared = TriliumSchemeBridge()
static let shared = TriliumSchemeBridge()
func reset() {
isJsReady = false
queuedRequests.removeAll()
}

Comment on lines +185 to +189
while stream.hasBytesAvailable {
let read = stream.read(&buffer, maxLength: bufferSize)
if read <= 0 { break }
data.append(buffer, count: read)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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).

Suggested change
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

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.87500% with 1 line in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
apps/standalone/src/ios-native-bridge.ts 96.87% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces four in-page JavaScript request interceptors (fetch, XHR, <img>, stylesheet) on iOS with a single WKURLSchemeHandler-level interception, eliminating an entire class of bugs where engine-initiated loads (audio, video, iframes, CSS @import) fell through uncovered.

  • Native side (TriliumSchemeHandler.swift): TriliumAssetHandler isa-swizzles Capacitor's WebViewAssetHandler to intercept every capacitor:// request, forwarding local-API paths through TriliumSchemeBridge, which serializes requests to JSON, evaluates them in the page, awaits a script-message response, and completes the WKURLSchemeTask.
  • JS side (ios-native-bridge.ts): Installs window.__triliumNativeRequest, routes each payload through localFetch to the SQLite worker, and posts the base64-encoded response back via window.webkit.messageHandlers.triliumScheme.
  • Cleanup: ios-interceptors.ts and its 672-line spec are deleted; a new 8-test spec covers the bridge roundtrip.

Confidence Score: 2/5

Not 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.

Security Review

The WKScriptMessageHandler for "triliumScheme" is registered in the default WKContentWorld, meaning every script running in the page can post messages to it. Request IDs are a predictable sequential counter (req1, req2, \u2026). An attacker with script execution in the page can forge a response message to complete an in-flight WKURLSchemeTask with attacker-controlled data. Mitigations: isolate the handler to a private WKContentWorld, or add a per-session nonce to each request that the JS side must echo back.

Important Files Changed

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.

Fix All in Claude Code

Reviews (1): Last reviewed commit: "refactor(mobile): extract scheme-handler..." | Re-trigger Greptile

Comment on lines +93 to +101
/// 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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Fix in Claude Code

Comment on lines +71 to +76
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Fix in Claude Code

Comment on lines +103 to +122
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
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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.

Fix in Claude Code

Comment on lines +86 to +89
deliver(jsSafeJson, id: id)
} else {
queuedRequests.append((id: id, json: jsSafeJson))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Fix in Claude Code

Comment on lines +174 to +191
/// 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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant