import SwiftUI import XuqmFileSDK // 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 } guard let data = raw.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { config.onMessage?(raw) return } guard let xwv = json["__xwv"] as? String else { config.onMessage?(raw) return } switch xwv { case "log", "alert", "confirm", "prompt", "bloberror": return case "download": let url = json["url"] as? String ?? "" let filename = (json["filename"] as? String)?.nilIfEmpty Task { await handleDownload(url: url, filename: filename) } case "blobdownload": let url = json["url"] as? String ?? "" let filename = (json["filename"] as? String)?.nilIfEmpty ?? "download.bin" let b64 = json["data"] as? String ?? "" Task { await handleBlobDownload(url: url, filename: filename, base64Data: b64) } default: break } } private func dispatchDownloadEvent(_ name: String, url: String, extra: String = "") { let escaped = url.jsEscaped let js = "window.dispatchEvent(new CustomEvent('\(name)',{detail:{url:'\(escaped)'\(extra)}}));" webView?.evaluateJavaScript(js, completionHandler: nil) } private func handleDownload(url: String, filename: String?) async { do { let localURL = try await FileSDK.shared.download( url: url, fileName: filename, destination: config.downloadDestination ) { [weak self] progress in Task { @MainActor [weak self] in self?.dispatchDownloadEvent("__xwvDownloadProgress", url: url, extra: ",progress:\(progress)") } } dispatchDownloadEvent("__xwvDownloadDone", url: url, extra: ",success:true") openOrSave(localURL: localURL, destination: config.downloadDestination) } catch { let msg = error.localizedDescription.jsEscaped dispatchDownloadEvent("__xwvDownloadDone", url: url, extra: ",success:false,error:'\(msg)'") } } private func handleBlobDownload(url: String, filename: String, base64Data: String) async { do { let localURL = try FileSDK.shared.saveBlobDownload( base64Data: base64Data, fileName: filename, destination: config.downloadDestination ) dispatchDownloadEvent("__xwvDownloadDone", url: url, extra: ",success:true") openOrSave(localURL: localURL, destination: config.downloadDestination) } catch { let msg = error.localizedDescription.jsEscaped dispatchDownloadEvent("__xwvDownloadDone", url: url, extra: ",success:false,error:'\(msg)'") } } private func openOrSave(localURL: URL, destination: FileDownloadDestination) { guard let wv = webView else { return } #if canImport(UIKit) switch destination { case .sandbox: let vc = UIActivityViewController(activityItems: [localURL], applicationActivities: nil) topViewController(from: wv)?.present(vc, animated: true) case .userPick: let picker = UIDocumentPickerViewController(forExporting: [localURL], asCopy: true) topViewController(from: wv)?.present(picker, animated: true) } #endif } #if canImport(UIKit) private func topViewController(from view: UIView) -> UIViewController? { var responder: UIResponder? = view while let r = responder { if let vc = r as? UIViewController { return vc } responder = r.next } return nil } #endif @available(iOS 15.0, *) public func webView( _ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void ) { decisionHandler(.grant) } } } private extension String { var jsEscaped: String { replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "'", with: "\\'") .replacingOccurrences(of: "\n", with: "\\n") .replacingOccurrences(of: "\r", with: "\\r") } var nilIfEmpty: String? { isEmpty ? nil : self } } #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