import SwiftUI import XuqmCoreSDK import XuqmFileSDK // Internal bridge name for SDK-owned messages (__xwv, xuqm.*) private let kInternalBridgeName = "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" + XWebViewStandardHandlers.injectedJavaScript + "\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() let script = WKUserScript( source: buildInjectedJs(config), injectionTime: .atDocumentEnd, forMainFrameOnly: true ) userController.addUserScript(script) // Internal SDK bridge (dialog overrides, download, xuqm.*) userController.add(WeakScriptHandler(context.coordinator), name: kInternalBridgeName) // Custom app bridge (routes to onMessage callback) let appBridgeName = config.jsBridgeName if !appBridgeName.isEmpty && appBridgeName != kInternalBridgeName { userController.add(WeakScriptHandler(context.coordinator), name: appBridgeName) } 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) { let uc = uiView.configuration.userContentController uc.removeScriptMessageHandler(forName: kInternalBridgeName) let appBridgeName = coordinator.appBridgeName if !appBridgeName.isEmpty && appBridgeName != kInternalBridgeName { uc.removeScriptMessageHandler(forName: appBridgeName) } 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 private var progressObservation: NSKeyValueObservation? let appBridgeName: String init(config: XWebViewConfig) { self.config = config self.appBridgeName = config.jsBridgeName } func attach(_ webView: WKWebView) { guard self.webView !== webView else { return } progressObservation?.invalidate() self.webView = webView progressObservation = webView.observe(\.estimatedProgress, options: .new) { [weak self] wv, _ in let pct = Int(wv.estimatedProgress * 100) Task { @MainActor [weak self] in self?.config.onProgressChanged?(pct) } } } func detach() { progressObservation?.invalidate() progressObservation = nil 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) } // MARK: - WKNavigationDelegate public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { config.onProgressChanged?(0) notifyNavigationChanged(webView) } public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { config.onProgressChanged?(100) notifyNavigationChanged(webView) } public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { let nsError = error as NSError if nsError.code != NSURLErrorCancelled { config.onPageError?(error.localizedDescription) } notifyNavigationChanged(webView) } public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { let nsError = error as NSError if nsError.code != NSURLErrorCancelled { config.onPageError?(error.localizedDescription) } notifyNavigationChanged(webView) } private func notifyNavigationChanged(_ webView: WKWebView) { config.onNavigationChanged?(webView.canGoBack, webView.canGoForward) } @available(iOS 15.0, *) public func webView( _ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, decisionHandler: @escaping (WKPermissionDecision) -> Void ) { decisionHandler(.grant) } // MARK: - WKScriptMessageHandler public func userContentController( _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { // Route custom app messages to onMessage if message.name == appBridgeName { if let body = message.body as? String { config.onMessage?(body) } return } // Internal bridge (ReactNativeWebView) 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 } // StandardHandlers (xuqm.* actions) if let action = json["action"] as? String, action.hasPrefix("xuqm.") { handleStandardAction(action, message: json) 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 } } // MARK: - Standard action handlers private func handleStandardAction(_ action: String, message: [String: Any]) { let callbackId = message["callbackId"] as? String switch action { case "xuqm.getDeviceInfo": handleGetDeviceInfo(callbackId: callbackId) case "xuqm.getToken": handleGetToken(callbackId: callbackId) case "xuqm.getUserInfo": handleGetUserInfo(callbackId: callbackId) case "xuqm.closeWebView": config.onClose?() case "xuqm.showToast": break default: break } } private func handleGetDeviceInfo(callbackId: String?) { guard let callbackId else { return } let device = UIDevice.current let data: [String: Any] = [ "model": device.model, "osVersion": "iOS \(device.systemVersion)", "platform": "IOS", "deviceId": device.identifierForVendor?.uuidString ?? "", ] callCallback(callbackId, data: data) } private func handleGetToken(callbackId: String?) { guard let callbackId else { return } callCallback(callbackId, data: ["token": XuqmSDK.shared.token ?? ""]) } private func handleGetUserInfo(callbackId: String?) { guard let callbackId else { return } callCallback(callbackId, data: ["userId": XuqmSDK.shared.userId ?? ""]) } private func callCallback(_ callbackId: String, data: [String: Any]) { guard let jsonData = try? JSONSerialization.data(withJSONObject: data), let jsonString = String(data: jsonData, encoding: .utf8) else { return } let js = "window[\"\(callbackId)\"](\(jsonString))" webView?.evaluateJavaScript(js, completionHandler: nil) } // MARK: - Download handling 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 } 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) } } 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 } } } 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