diff --git a/Sources/XuqmWebViewSDK/XWebViewBridge.swift b/Sources/XuqmWebViewSDK/XWebViewBridge.swift index 4bba0e0..f6e6e83 100644 --- a/Sources/XuqmWebViewSDK/XWebViewBridge.swift +++ b/Sources/XuqmWebViewSDK/XWebViewBridge.swift @@ -24,6 +24,10 @@ public final class XWebViewBridge { public func currentController() -> (any XWebViewController)? { controller as? any XWebViewController } + + public func postMessageToWeb(_ js: String) { + currentController()?.postMessageToWeb(js: js) + } } @MainActor diff --git a/Sources/XuqmWebViewSDK/XWebViewTypes.swift b/Sources/XuqmWebViewSDK/XWebViewTypes.swift index 06caca1..4efe8ce 100644 --- a/Sources/XuqmWebViewSDK/XWebViewTypes.swift +++ b/Sources/XuqmWebViewSDK/XWebViewTypes.swift @@ -6,19 +6,25 @@ public struct XWebViewConfig: Sendable { public var hideToolbar: Bool public var hideStatusBar: Bool public var userAgent: String? + public var injectedJavaScript: String? + public var onMessage: (@Sendable (String) -> Void)? public init( url: String = "", title: String = "", hideToolbar: Bool = false, hideStatusBar: Bool = false, - userAgent: String? = nil + userAgent: String? = nil, + injectedJavaScript: String? = nil, + onMessage: (@Sendable (String) -> Void)? = nil ) { self.url = url self.title = title self.hideToolbar = hideToolbar self.hideStatusBar = hideStatusBar self.userAgent = userAgent + self.injectedJavaScript = injectedJavaScript + self.onMessage = onMessage } } @@ -30,4 +36,5 @@ public protocol XWebViewController: AnyObject { func goForward() func reload() func load(url: String) + func postMessageToWeb(js: String) } diff --git a/Sources/XuqmWebViewSDK/XWebViewView.swift b/Sources/XuqmWebViewSDK/XWebViewView.swift index d293597..33f4eaa 100644 --- a/Sources/XuqmWebViewSDK/XWebViewView.swift +++ b/Sources/XuqmWebViewSDK/XWebViewView.swift @@ -1,8 +1,96 @@ 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 @@ -12,11 +100,27 @@ public struct XWebViewView: UIViewRepresentable { } public func makeCoordinator() -> Coordinator { - Coordinator() + Coordinator(config: config) } public func makeUIView(context: Context) -> WKWebView { - let webView = WKWebView(frame: .zero) + 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 { @@ -38,6 +142,7 @@ public struct XWebViewView: UIViewRepresentable { } 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) @@ -45,45 +150,60 @@ public struct XWebViewView: UIViewRepresentable { } @MainActor - public final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, XWebViewController { + public final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, XWebViewController { private weak var webView: WKWebView? + private let config: XWebViewConfig - func attach(_ webView: WKWebView) { - self.webView = webView + init(config: XWebViewConfig) { + self.config = config } - 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() - } + 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) + } } }