import CryptoKit import Foundation import WebKit #if DEBUG import Bonsplit #endif // MARK: - Settings enum ReactGrabSettings { static let versionKey = "reactGrabVersion" static let defaultVersion = "0.1.29" /// Known versions and their SHA-256 integrity hashes. /// Add new entries when bumping the default or to allow user-selected versions. static let knownHashes: [String: String] = [ "0.1.29": "4a1e71090e8ad8bb6049de80ccccdc0f5bb147b9f8fb88886d871612ac7ca04b", ] static func scriptURL(for version: String) -> URL { URL(string: "https://unpkg.com/react-grab@\(version)/dist/index.global.js")! } static var configuredVersion: String { let stored = UserDefaults.standard.string(forKey: versionKey)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return stored.isEmpty ? defaultVersion : stored } } // MARK: - Script Loader /// Fetches, integrity-checks, and caches the react-grab script. /// Shared across all BrowserPanel instances. enum ReactGrabScriptLoader { private static var cachedScript: String? private static var cachedVersion: String? private static var prefetchTask: Task? static func prefetch() { let version = ReactGrabSettings.configuredVersion // Invalidate cache if version changed. if cachedVersion != version { cachedScript = nil cachedVersion = nil } guard cachedScript == nil else { return } guard prefetchTask == nil else { return } prefetchTask = Task.detached(priority: .low) { let result = await doFetch(version: version) await MainActor.run { prefetchTask = nil } return result } } static func fetch() async -> String? { let version = ReactGrabSettings.configuredVersion if cachedVersion == version, let cached = cachedScript { return cached } prefetch() return await prefetchTask?.value } private static func doFetch(version: String) async -> String? { let url = ReactGrabSettings.scriptURL(for: version) do { let (data, _) = try await URLSession.shared.data(from: url) if let expectedHash = ReactGrabSettings.knownHashes[version] { let hash = SHA256.hash(data: data) let hex = hash.compactMap { String(format: "%02x", $0) }.joined() guard hex == expectedHash else { NSLog("ReactGrab: integrity mismatch for v%@ (got %@)", version, hex) return nil } } guard let script = String(data: data, encoding: .utf8) else { return nil } await MainActor.run { cachedScript = script cachedVersion = version } return script } catch { NSLog("ReactGrab: fetch failed for v%@: %@", version, error.localizedDescription) return nil } } } // MARK: - WKScriptMessageHandler private let reactGrabMessageHandlerName = "cmuxReactGrab" class ReactGrabMessageHandler: NSObject, WKScriptMessageHandler { private let onStateChange: @MainActor (Bool) -> Void init(onStateChange: @escaping @MainActor (Bool) -> Void) { self.onStateChange = onStateChange } func userContentController( _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { guard let body = message.body as? [String: Any], let isActive = body["isActive"] as? Bool else { return } #if DEBUG dlog("reactGrab.messageHandler isActive=\(isActive)") #endif Task { @MainActor in #if DEBUG dlog("reactGrab.messageHandler.mainActor isActive=\(isActive)") #endif onStateChange(isActive) } } } // MARK: - BrowserPanel extension extension BrowserPanel { func setupReactGrabMessageHandler(for webView: WKWebView) { let handler = ReactGrabMessageHandler { [weak self] isActive in self?.isReactGrabActive = isActive } reactGrabMessageHandler = handler webView.configuration.userContentController.add(handler, name: reactGrabMessageHandlerName) } func injectReactGrab() async { #if DEBUG dlog("reactGrab.inject.start") #endif guard let scriptSource = await ReactGrabScriptLoader.fetch() else { #if DEBUG dlog("reactGrab.inject.fetchFailed") #endif return } #if DEBUG dlog("reactGrab.inject.fetched len=\(scriptSource.count)") #endif let handlerName = reactGrabMessageHandlerName let combined = """ (function() { if (window.__REACT_GRAB__) { window.__REACT_GRAB__.activate(); return; } window.addEventListener('react-grab:init', function(e) { var api = e.detail; if (!api) return; api.activate(); var lastActive; api.registerPlugin({ name: 'cmux-bridge', hooks: { onStateChange: function(state) { if (state.isActive === lastActive) return; lastActive = state.isActive; var h = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.\(handlerName); if (h) h.postMessage({ isActive: state.isActive }); } } }); }, { once: true }); })(); \(scriptSource) """ #if DEBUG dlog("reactGrab.inject.evalJS len=\(combined.count)") #endif webView.evaluateJavaScript(combined) { [weak self] _, error in #if DEBUG dlog("reactGrab.inject.evalJS.done error=\(error?.localizedDescription ?? "none")") #endif if let error { NSLog("ReactGrab: injection failed: %@", error.localizedDescription) Task { @MainActor in self?.isReactGrabActive = false } } } #if DEBUG dlog("reactGrab.inject.end") #endif } func toggleReactGrab() { #if DEBUG dlog("reactGrab.toggle.start") #endif let script = "window.__REACT_GRAB__?.toggle()" webView.evaluateJavaScript(script, completionHandler: nil) #if DEBUG dlog("reactGrab.toggle.end") #endif } func toggleOrInjectReactGrab() async { if isReactGrabActive { toggleReactGrab() } else { await injectReactGrab() } } func resetReactGrabState() { isReactGrabActive = false } }