import SwiftUI // JS bridge name used by DIALOG_OVERRIDE_JS to post messages to native. private let kBridgeName = "ReactNativeWebView" // Injected into every page to override browser dialogs and intercept downloads. private let dialogOverrideJs = #""" (function() { function post(obj) { window.webkit.messageHandlers.ReactNativeWebView.postMessage(JSON.stringify(obj)); } window.alert = function(msg) { post({ __xwv: 'alert', msg: String(msg) }); }; window.confirm = function(msg) { post({ __xwv: 'confirm', msg: String(msg) }); return true; }; window.prompt = function(msg, def) { post({ __xwv: 'prompt', msg: String(msg), def: def || '' }); return def || ''; }; window.open = function(url) { if (url) { window.location.href = url; } return null; }; URL.revokeObjectURL = function() {}; function readBlobAndPost(blobUrl, filename) { fetch(blobUrl) .then(function(r) { return r.blob(); }) .then(function(blob) { var reader = new FileReader(); reader.onloadend = function() { var b64 = reader.result.split(',')[1]; post({ __xwv: 'blobdownload', url: blobUrl, filename: filename, data: b64 }); }; reader.readAsDataURL(blob); }) .catch(function(err) { post({ __xwv: 'bloberror', msg: String(err) }); }); } var DL_RE = "\\.(exe|apk|ipa|zip|rar|tar|gz|dmg|pkg|deb|rpm|msi|pdf|doc|docx|xls|xlsx|ppt|pptx|mp4|mp3|mov|avi|mkv)(\\?|#|$)"; var dlRe = new RegExp(DL_RE, 'i'); function tryInterceptAnchor(el, e) { if (!el || el.tagName !== 'A') return false; var href = el.href || el.getAttribute('href') || ''; if (!href || href.indexOf('javascript') === 0) return false; var hasDownloadAttr = el.hasAttribute('download'); var dlName = el.getAttribute('download') || ''; var isDL = hasDownloadAttr || dlRe.test(href); if (isDL) { if (e) { e.preventDefault(); e.stopPropagation(); } if (href.startsWith('blob:')) { readBlobAndPost(href, dlName); } else { post({ __xwv: 'download', url: href, filename: dlName }); } return true; } return false; } document.addEventListener('click', function(e) { var el = e.target; while (el && el.tagName !== 'A') { el = el.parentElement; } tryInterceptAnchor(el, e); }, true); var _origClick = HTMLAnchorElement.prototype.click; HTMLAnchorElement.prototype.click = function() { if (!tryInterceptAnchor(this, null)) { _origClick.call(this); } }; })(); true; """# private func buildInjectedJs(_ config: XWebViewConfig) -> String { dialogOverrideJs + "\n" + (config.injectedJavaScript ?? "") } #if canImport(UIKit) import WebKit // Weak wrapper prevents retain cycle: WKUserContentController → handler → webView. private class WeakScriptHandler: NSObject, WKScriptMessageHandler { weak var target: (NSObject & WKScriptMessageHandler)? init(_ target: NSObject & WKScriptMessageHandler) { self.target = target } func userContentController(_ c: WKUserContentController, didReceive m: WKScriptMessage) { target?.userContentController(c, didReceive: m) } } @MainActor public struct XWebViewView: UIViewRepresentable { private let config: XWebViewConfig public init(config: XWebViewConfig? = nil) { self.config = config ?? XWebViewBridge.shared.currentConfig() } public func makeCoordinator() -> Coordinator { Coordinator(config: config) } public func makeUIView(context: Context) -> WKWebView { let userController = WKUserContentController() // Inject DIALOG_OVERRIDE_JS + user script at document end. let script = WKUserScript( source: buildInjectedJs(config), injectionTime: .atDocumentEnd, forMainFrameOnly: true ) userController.addUserScript(script) // Register JS → Native message channel via weak proxy to avoid retain cycle. userController.add(WeakScriptHandler(context.coordinator), name: kBridgeName) let configuration = WKWebViewConfiguration() configuration.userContentController = userController let webView = WKWebView(frame: .zero, configuration: configuration) webView.navigationDelegate = context.coordinator webView.uiDelegate = context.coordinator if let userAgent = config.userAgent { webView.customUserAgent = userAgent } context.coordinator.attach(webView) XWebViewBridge.shared.setController(context.coordinator) if !config.url.isEmpty, let url = URL(string: config.url) { webView.load(URLRequest(url: url)) } return webView } public func updateUIView(_ webView: WKWebView, context: Context) { context.coordinator.attach(webView) if webView.url == nil, !config.url.isEmpty, let url = URL(string: config.url) { webView.load(URLRequest(url: url)) } } public static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { uiView.configuration.userContentController.removeScriptMessageHandler(forName: kBridgeName) coordinator.detach() if XWebViewBridge.shared.currentController() === coordinator { XWebViewBridge.shared.setController(nil) } } @MainActor public final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, XWebViewController { private weak var webView: WKWebView? private let config: XWebViewConfig init(config: XWebViewConfig) { self.config = config } func attach(_ webView: WKWebView) { self.webView = webView } func detach() { webView = nil } public func canGoBack() -> Bool { webView?.canGoBack == true } public func canGoForward() -> Bool { webView?.canGoForward == true } public func currentUrl() -> String? { webView?.url?.absoluteString } public func goBack() { webView?.goBack() } public func goForward() { webView?.goForward() } public func reload() { webView?.reload() } public func load(url: String) { guard let url = URL(string: url) else { return } webView?.load(URLRequest(url: url)) } public func postMessageToWeb(js: String) { webView?.evaluateJavaScript(js, completionHandler: nil) } // WKScriptMessageHandler — routes window.webkit.messageHandlers.ReactNativeWebView.postMessage() public func userContentController( _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { guard let raw = message.body as? String else { return } // Filter internal __xwv messages; forward the rest to onMessage. if let data = raw.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let xwv = json["__xwv"] as? String { switch xwv { case "log": return // debug only, not forwarded case "alert", "confirm", "prompt", "download", "blobdownload", "bloberror": return default: break } } config.onMessage?(raw) } @available(iOS 15.0, *) public func webView( _ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void ) { decisionHandler(.grant) } } } #else public struct XWebViewView: View { private let config: XWebViewConfig public init(config: XWebViewConfig? = nil) { self.config = config ?? XWebViewBridge.shared.currentConfig() } public var body: some View { VStack(spacing: 12) { Text(config.title.isEmpty ? "WebView" : config.title) .font(.headline) Text("XWebView is available on iOS with UIKit-backed WebView.") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } #endif