Fix iOS sandbox: use sbx:// custom scheme registered at WKWebView config time
Replace the swizzle-based approach with a proper WKURLSchemeHandler registered via DittoBridgeViewController.webViewConfiguration(for:). This runs before the WKWebView is created, so sbx:// iframe requests are intercepted correctly — unlike the previous attempt which registered the handler too late (in capacitorDidLoad). Each sandbox loads from sbx://<sandbox-id>/path, giving every sandbox a unique web origin with full localStorage/cookie isolation.
This commit is contained in:
@@ -1,8 +1,22 @@
|
||||
import UIKit
|
||||
import WebKit
|
||||
import Capacitor
|
||||
|
||||
class DittoBridgeViewController: CAPBridgeViewController {
|
||||
|
||||
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
|
||||
let config = super.webViewConfiguration(for: instanceConfiguration)
|
||||
|
||||
// Register the sbx:// custom scheme handler BEFORE the WKWebView is
|
||||
// created. Each sandbox iframe loads from sbx://<sandbox-id>/path,
|
||||
// giving every sandbox a unique web origin with full storage isolation.
|
||||
let handler = SandboxRequestHandler()
|
||||
_sandboxHandler = handler
|
||||
config.setURLSchemeHandler(handler, forURLScheme: "sbx")
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
override func capacitorDidLoad() {
|
||||
super.capacitorDidLoad()
|
||||
webView?.allowsBackForwardNavigationGestures = true
|
||||
|
||||
+39
-124
@@ -2,100 +2,24 @@ import Foundation
|
||||
import Capacitor
|
||||
import WebKit
|
||||
|
||||
// MARK: - Asset Handler Swizzling
|
||||
// MARK: - Shared Handler Singleton
|
||||
|
||||
/// On iOS, Capacitor registers a single `WebViewAssetHandler` for the
|
||||
/// `capacitor://` scheme. WKWebView only invokes `WKURLSchemeHandler` for
|
||||
/// requests whose scheme matches one registered on the configuration —
|
||||
/// and it does NOT invoke handlers for cross-scheme iframe loads.
|
||||
/// The sandbox request handler singleton.
|
||||
/// Created by `DittoBridgeViewController` at WKWebView configuration time,
|
||||
/// then connected to the `SandboxPlugin` when the plugin loads.
|
||||
var _sandboxHandler: SandboxRequestHandler?
|
||||
|
||||
// MARK: - Sandbox Scheme Handler
|
||||
|
||||
/// `WKURLSchemeHandler` for the `sbx://` custom scheme.
|
||||
///
|
||||
/// Since `iosScheme: 'https'` in capacitor.config.ts is normalised to
|
||||
/// `capacitor://` by Capacitor (because `https` is a built-in scheme),
|
||||
/// sandbox iframes must also use the `capacitor://` scheme to be
|
||||
/// intercepted by the handler.
|
||||
/// Each sandbox iframe loads from `sbx://<sandbox-id>/path`, giving every
|
||||
/// sandbox a unique web origin with full localStorage / IndexedDB / cookie
|
||||
/// isolation.
|
||||
///
|
||||
/// We swizzle `WebViewAssetHandler.webView(_:start:)` at runtime to
|
||||
/// intercept requests whose hostname ends with `.sandbox.local`, forwarding
|
||||
/// them to the JS layer for resolution. All other requests pass through to
|
||||
/// the original Capacitor implementation.
|
||||
///
|
||||
/// Sandbox iframes load from `capacitor://<sandbox-id>.sandbox.local/path`.
|
||||
/// Each hostname is a different web origin, giving full localStorage /
|
||||
/// IndexedDB / cookie isolation per sandbox.
|
||||
private let sandboxHostSuffix = ".sandbox.local"
|
||||
|
||||
/// The sandbox request handler singleton, set up by the plugin.
|
||||
private var _sandboxHandler: SandboxRequestHandler?
|
||||
|
||||
/// Original IMP of `WebViewAssetHandler.webView(_:start:)`.
|
||||
private var _originalStartIMP: IMP?
|
||||
/// Original IMP of `WebViewAssetHandler.webView(_:stop:)`.
|
||||
private var _originalStopIMP: IMP?
|
||||
|
||||
/// ObjC selectors for `WKURLSchemeHandler` methods on `WebViewAssetHandler`.
|
||||
private let startSelector = Selector(("webView:startURLSchemeTask:"))
|
||||
private let stopSelector = Selector(("webView:stopURLSchemeTask:"))
|
||||
|
||||
/// Counter for swizzle invocations (for diagnostics).
|
||||
private var _swizzleCallCount: Int = 0
|
||||
/// Last URL seen by the swizzled handler (for diagnostics).
|
||||
private var _swizzleLastURL: String = "(none)"
|
||||
|
||||
/// Swizzled replacement for `webView(_:start:)`.
|
||||
/// If the request targets `*.sandbox.local`, route it to JS. Otherwise call
|
||||
/// the original Capacitor implementation.
|
||||
private let swizzledStart: @convention(block) (AnyObject, WKWebView, WKURLSchemeTask) -> Void = { selfObj, webView, task in
|
||||
_swizzleCallCount += 1
|
||||
_swizzleLastURL = task.request.url?.absoluteString ?? "(nil)"
|
||||
|
||||
if let url = task.request.url,
|
||||
let host = url.host,
|
||||
host.hasSuffix(sandboxHostSuffix) {
|
||||
_sandboxHandler?.handleStart(webView: webView, urlSchemeTask: task)
|
||||
return
|
||||
}
|
||||
// Call the original Capacitor implementation.
|
||||
typealias OriginalFunc = @convention(c) (AnyObject, Selector, WKWebView, WKURLSchemeTask) -> Void
|
||||
let original = unsafeBitCast(_originalStartIMP!, to: OriginalFunc.self)
|
||||
original(selfObj, startSelector, webView, task)
|
||||
}
|
||||
|
||||
/// Swizzled replacement for `webView(_:stop:)`.
|
||||
private let swizzledStop: @convention(block) (AnyObject, WKWebView, WKURLSchemeTask) -> Void = { selfObj, webView, task in
|
||||
if let url = task.request.url,
|
||||
let host = url.host,
|
||||
host.hasSuffix(sandboxHostSuffix) {
|
||||
_sandboxHandler?.handleStop(urlSchemeTask: task)
|
||||
return
|
||||
}
|
||||
typealias OriginalFunc = @convention(c) (AnyObject, Selector, WKWebView, WKURLSchemeTask) -> Void
|
||||
let original = unsafeBitCast(_originalStopIMP!, to: OriginalFunc.self)
|
||||
original(selfObj, stopSelector, webView, task)
|
||||
}
|
||||
|
||||
/// Install the swizzle on `WebViewAssetHandler` (idempotent).
|
||||
private func installAssetHandlerSwizzle() {
|
||||
guard _originalStartIMP == nil else { return }
|
||||
|
||||
let cls: AnyClass = WebViewAssetHandler.self
|
||||
|
||||
guard let startMethod = class_getInstanceMethod(cls, startSelector),
|
||||
let stopMethod = class_getInstanceMethod(cls, stopSelector) else {
|
||||
return
|
||||
}
|
||||
|
||||
_originalStartIMP = method_getImplementation(startMethod)
|
||||
_originalStopIMP = method_getImplementation(stopMethod)
|
||||
|
||||
method_setImplementation(startMethod, imp_implementationWithBlock(swizzledStart))
|
||||
method_setImplementation(stopMethod, imp_implementationWithBlock(swizzledStop))
|
||||
}
|
||||
|
||||
// MARK: - Sandbox Request Handler
|
||||
|
||||
/// Handles sandbox iframe requests intercepted via the swizzled asset handler.
|
||||
/// Forwards requests to the JS layer and resolves responses back to WKWebView.
|
||||
class SandboxRequestHandler {
|
||||
/// Intercepted requests are forwarded to the JS layer via the Capacitor
|
||||
/// plugin bridge. JS resolves the file and responds with `respondToFetch()`.
|
||||
class SandboxRequestHandler: NSObject, WKURLSchemeHandler {
|
||||
private var pendingTasks: [String: WKURLSchemeTask] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
@@ -109,7 +33,9 @@ class SandboxRequestHandler {
|
||||
return count
|
||||
}
|
||||
|
||||
func handleStart(webView: WKWebView, urlSchemeTask: WKURLSchemeTask) {
|
||||
// MARK: WKURLSchemeHandler
|
||||
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
let request = urlSchemeTask.request
|
||||
guard let url = request.url else {
|
||||
urlSchemeTask.didFailWithError(NSError(
|
||||
@@ -125,9 +51,8 @@ class SandboxRequestHandler {
|
||||
pendingTasks[requestId] = urlSchemeTask
|
||||
lock.unlock()
|
||||
|
||||
// Extract the sandbox ID: "<sandbox-id>.sandbox.local" -> "<sandbox-id>"
|
||||
let host = url.host ?? "unknown"
|
||||
let sandboxId = String(host.dropLast(sandboxHostSuffix.count))
|
||||
// Extract the sandbox ID from the hostname: sbx://<sandbox-id>/path
|
||||
let sandboxId = url.host ?? "unknown"
|
||||
|
||||
var headers: [String: String] = [:]
|
||||
if let allHeaders = request.allHTTPHeaderFields {
|
||||
@@ -141,7 +66,7 @@ class SandboxRequestHandler {
|
||||
|
||||
let path = url.path.isEmpty ? "/" : url.path
|
||||
|
||||
// Rewrite URL so JS sees a parseable URL with the sandbox ID as host.
|
||||
// Rewrite URL so JS sees a consistent format matching Android.
|
||||
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
|
||||
|
||||
let serialisedRequest: [String: Any] = [
|
||||
@@ -158,7 +83,7 @@ class SandboxRequestHandler {
|
||||
)
|
||||
}
|
||||
|
||||
func handleStop(urlSchemeTask: WKURLSchemeTask) {
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
||||
lock.lock()
|
||||
let removed = pendingTasks.first(where: { $0.value === urlSchemeTask })
|
||||
if let key = removed?.key {
|
||||
@@ -167,6 +92,8 @@ class SandboxRequestHandler {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
// MARK: Response Resolution
|
||||
|
||||
func resolveRequest(
|
||||
requestId: String,
|
||||
status: Int,
|
||||
@@ -186,7 +113,7 @@ class SandboxRequestHandler {
|
||||
bodyData = Data(base64Encoded: b64)
|
||||
}
|
||||
|
||||
let responseURL = task.request.url ?? URL(string: "capacitor://unknown.sandbox.local/")!
|
||||
let responseURL = task.request.url ?? URL(string: "sbx://unknown/")!
|
||||
let response = HTTPURLResponse(
|
||||
url: responseURL,
|
||||
statusCode: status,
|
||||
@@ -220,14 +147,15 @@ class SandboxRequestHandler {
|
||||
|
||||
// MARK: - Plugin
|
||||
|
||||
/// Capacitor plugin that handles sandbox fetch responses.
|
||||
/// Capacitor plugin that bridges sandbox fetch events between native and JS.
|
||||
///
|
||||
/// On iOS, sandbox iframes use the same `capacitor://` scheme as the parent
|
||||
/// app but with `*.sandbox.local` hostnames. Requests are intercepted by
|
||||
/// swizzling Capacitor's `WebViewAssetHandler` and forwarded to the JS layer.
|
||||
/// On iOS, sandbox iframes use the `sbx://` custom URL scheme, registered
|
||||
/// on the WKWebView configuration before the web view is created. Each
|
||||
/// sandbox loads from `sbx://<sandbox-id>/path`, providing full origin
|
||||
/// isolation (separate localStorage, cookies, etc.).
|
||||
///
|
||||
/// On Android, a custom `BridgeWebViewClient` subclass intercepts requests to
|
||||
/// `*.sandbox.native` hostnames.
|
||||
/// On Android, a custom `BridgeWebViewClient` subclass intercepts requests
|
||||
/// to `https://<sandbox-id>.sandbox.native/path`.
|
||||
@objc(SandboxPlugin)
|
||||
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "SandboxPlugin"
|
||||
@@ -238,11 +166,8 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
]
|
||||
|
||||
public override func load() {
|
||||
// Install the swizzle and create the handler.
|
||||
let handler = SandboxRequestHandler()
|
||||
handler.plugin = self
|
||||
_sandboxHandler = handler
|
||||
installAssetHandlerSwizzle()
|
||||
// Connect the shared handler to this plugin so it can emit events.
|
||||
_sandboxHandler?.plugin = self
|
||||
}
|
||||
|
||||
@objc func respondToFetch(_ call: CAPPluginCall) {
|
||||
@@ -274,22 +199,12 @@ public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
/// Diagnostic method callable from JS to inspect native state.
|
||||
@objc func diagnose(_ call: CAPPluginCall) {
|
||||
let handler = _sandboxHandler
|
||||
let hasHandler = handler != nil
|
||||
let hasPlugin = handler?.plugin != nil
|
||||
let hasBridgeWebView = bridge?.webView != nil
|
||||
let hasListenersFetch = hasListeners("fetch")
|
||||
let pendingCount = handler?.pendingTaskCount ?? 0
|
||||
let swizzled = _originalStartIMP != nil
|
||||
|
||||
call.resolve([
|
||||
"sandboxHandlerSet": hasHandler,
|
||||
"pluginConnected": hasPlugin,
|
||||
"bridgeHasWebView": hasBridgeWebView,
|
||||
"hasListenersFetch": hasListenersFetch,
|
||||
"pendingTaskCount": pendingCount,
|
||||
"swizzleInstalled": swizzled,
|
||||
"swizzleCallCount": _swizzleCallCount,
|
||||
"swizzleLastURL": _swizzleLastURL,
|
||||
"sandboxHandlerSet": handler != nil,
|
||||
"pluginConnected": handler?.plugin != nil,
|
||||
"bridgeHasWebView": bridge?.webView != nil,
|
||||
"hasListenersFetch": hasListeners("fetch"),
|
||||
"pendingTaskCount": handler?.pendingTaskCount ?? 0,
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -339,16 +339,16 @@ const SandboxFrameWeb = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
/**
|
||||
* Compute the iframe origin for native platforms.
|
||||
*
|
||||
* - iOS: `capacitor://<sandbox-id>.sandbox.local` — intercepted by the
|
||||
* swizzled WebViewAssetHandler on the main WKWebView. Uses the same
|
||||
* scheme as the parent app so WKWebView routes iframe requests through
|
||||
* the scheme handler (cross-scheme iframe loads are silently ignored).
|
||||
* - iOS: `sbx://<sandbox-id>` — intercepted by the `SandboxRequestHandler`
|
||||
* registered on the WKWebView configuration as a `WKURLSchemeHandler` for
|
||||
* the `sbx://` custom scheme. Each sandbox ID is a unique origin, giving
|
||||
* full localStorage / IndexedDB / cookie isolation.
|
||||
* - Android: `https://<sandbox-id>.sandbox.native` — intercepted by the
|
||||
* custom BridgeWebViewClient subclass.
|
||||
*/
|
||||
function getNativeOrigin(id: string): string {
|
||||
if (Capacitor.getPlatform() === 'ios') {
|
||||
return `capacitor://${id}.sandbox.local`;
|
||||
return `sbx://${id}`;
|
||||
}
|
||||
// Android
|
||||
return `https://${id}.sandbox.native`;
|
||||
@@ -384,9 +384,9 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
|
||||
const post = useCallback(
|
||||
(msg: Record<string, unknown>) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(msg, '*');
|
||||
iframeRef.current?.contentWindow?.postMessage(msg, origin);
|
||||
},
|
||||
[],
|
||||
[origin],
|
||||
);
|
||||
|
||||
// Expose imperative handle.
|
||||
@@ -435,31 +435,14 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// Diagnose native state before loading.
|
||||
try {
|
||||
const diag = await SandboxPlugin.diagnose();
|
||||
console.log('[SandboxFrame] diagnose BEFORE src:', JSON.stringify(diag));
|
||||
} catch (err) {
|
||||
console.warn('[SandboxFrame] diagnose failed:', err);
|
||||
}
|
||||
|
||||
// Now set the iframe src to start loading content.
|
||||
// This triggers native fetch interception.
|
||||
// Set the iframe src to start loading content.
|
||||
// This triggers native fetch interception via the scheme handler.
|
||||
readyRef.current = true;
|
||||
const src = `${origin}/index.html`;
|
||||
console.log(`[SandboxFrame] setting iframe.src=${src}`);
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = src;
|
||||
}
|
||||
|
||||
// Diagnose again after a delay.
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
try {
|
||||
const diag = await SandboxPlugin.diagnose();
|
||||
console.log('[SandboxFrame] diagnose AFTER src (1.5s):', JSON.stringify(diag));
|
||||
} catch (err) {
|
||||
console.warn('[SandboxFrame] diagnose after failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNativeFetch(event: SandboxFetchEvent) {
|
||||
@@ -525,7 +508,7 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
|
||||
useEffect(() => {
|
||||
function onMessage(event: MessageEvent) {
|
||||
// On iOS the origin is "capacitor://<id>.sandbox.local",
|
||||
// On iOS the origin is "sbx://<id>",
|
||||
// on Android "https://<id>.sandbox.native".
|
||||
if (event.origin !== origin) return;
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
@@ -595,13 +578,15 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
|
||||
*
|
||||
* On native platforms (iOS/Android via Capacitor), this creates a regular
|
||||
* `<iframe>` element whose requests are intercepted by native code:
|
||||
* - iOS: Swizzled WebViewAssetHandler intercepting `*.sandbox.local`
|
||||
* hostnames on the `capacitor://` scheme (same scheme as parent)
|
||||
* - iOS: `WKURLSchemeHandler` for the `sbx://` custom scheme, registered
|
||||
* on the WKWebView configuration. Each sandbox loads from
|
||||
* `sbx://<sandbox-id>/path` with full origin isolation.
|
||||
* - Android: Custom BridgeWebViewClient intercepting `*.sandbox.native`
|
||||
*
|
||||
* Each sandbox gets a unique origin (via hostname), so localStorage/IndexedDB
|
||||
* are isolated per sandbox. Since the sandbox is a regular DOM element, web UI
|
||||
* (permission dialogs, popovers) naturally layers on top.
|
||||
* Each sandbox gets a unique origin (via hostname or scheme+host), so
|
||||
* localStorage/IndexedDB are isolated per sandbox. Since the sandbox is a
|
||||
* regular DOM element, web UI (permission dialogs, popovers) naturally
|
||||
* layers on top.
|
||||
*
|
||||
* All file serving is delegated to the `resolveFile` callback.
|
||||
* Custom RPC methods are delegated to the optional `onRpc` callback.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* SandboxPlugin — Capacitor plugin for native sandbox iframe support.
|
||||
*
|
||||
* On iOS, sandbox iframes use `capacitor://<sandbox-id>.sandbox.local/path`
|
||||
* — the same scheme as the parent app, so WKWebView routes iframe requests
|
||||
* through the existing `WebViewAssetHandler`. The plugin swizzles the
|
||||
* handler at runtime to intercept `*.sandbox.local` hostnames.
|
||||
* On iOS, sandbox iframes use the `sbx://` custom URL scheme, registered on
|
||||
* the WKWebView configuration via `setURLSchemeHandler(_:forURLScheme:)` in
|
||||
* `DittoBridgeViewController`. Each sandbox loads from `sbx://<sandbox-id>/path`,
|
||||
* giving every sandbox a unique web origin with full storage isolation.
|
||||
*
|
||||
* On Android, a custom BridgeWebViewClient subclass intercepts requests to
|
||||
* `https://<sandbox-id>.sandbox.native/path` from iframes in the main WebView.
|
||||
@@ -66,7 +66,6 @@ export interface SandboxDiagnostics {
|
||||
bridgeHasWebView: boolean;
|
||||
hasListenersFetch: boolean;
|
||||
pendingTaskCount: number;
|
||||
swizzleInstalled: boolean;
|
||||
}
|
||||
|
||||
export interface SandboxPluginInterface {
|
||||
@@ -90,6 +89,6 @@ export interface SandboxPluginInterface {
|
||||
/**
|
||||
* The SandboxPlugin Capacitor plugin.
|
||||
* Only usable on native platforms (iOS/Android). On web, SandboxFrame
|
||||
* uses the iframe.diy service worker sandbox.
|
||||
* uses the iframe.diy fetch proxy sandbox.
|
||||
*/
|
||||
export const SandboxPlugin = registerPlugin<SandboxPluginInterface>('SandboxPlugin');
|
||||
|
||||
Reference in New Issue
Block a user