fancy new browser
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 430 lines 18 kB view raw
1import Foundation 2import AppKit 3import WebKit 4import MereKit 5 6/// WebContent backed by WKWebView. 7@MainActor 8public final class WebKitWebContent: NSObject, WebContent { 9 10 public let id = UUID() 11 public let engine: EngineType = .webkit 12 13 // MARK: - Public state (KVO-observed from WKWebView) 14 15 public private(set) var url: URL? 16 public private(set) var title: String? 17 public private(set) var isLoading = false 18 public private(set) var estimatedProgress: Double = 0 19 public private(set) var canGoBack = false 20 public private(set) var canGoForward = false 21 public private(set) var hasAudioPlaying = false 22 23 // WKWebView gained isMuted in macOS 14 but it's on WKWebViewConfiguration.mediaTypesRequiringUserActionForPlayback, 24 // not directly on the view. Track manually. 25 public var isMuted: Bool = false { 26 didSet { webView.configuration.mediaTypesRequiringUserActionForPlayback = isMuted ? .all : [] } 27 } 28 29 public var zoomFactor: Double { 30 get { webView.pageZoom } 31 set { webView.pageZoom = newValue } 32 } 33 34 // MARK: - Navigation events 35 36 private let eventContinuation: AsyncStream<NavigationEvent>.Continuation 37 public let navigationEvents: AsyncStream<NavigationEvent> 38 39 // MARK: - Internals 40 41 let webView: WKWebView 42 private var observations: [NSKeyValueObservation] = [] 43 private var pendingReloadURL: URL? 44 private var reloadAttempt = 0 45 46 // MARK: - Init 47 48 public override convenience init() { 49 self.init(configuration: WKWebViewConfiguration()) 50 } 51 52 public init(configuration: WKWebViewConfiguration) { 53 let (stream, continuation) = AsyncStream<NavigationEvent>.makeStream() 54 self.navigationEvents = stream 55 self.eventContinuation = continuation 56 57 // Audio: forwards play/pause state via message handler. 58 configuration.userContentController.addUserScript(WKUserScript( 59 source: """ 60 (function() { 61 function notify() { 62 var playing = Array.from(document.querySelectorAll('video,audio')) 63 .some(function(m){ return !m.paused && !m.muted && m.volume > 0; }); 64 window.webkit.messageHandlers.mereAudio.postMessage(playing); 65 } 66 document.addEventListener('play', notify, true); 67 document.addEventListener('pause', notify, true); 68 document.addEventListener('volumechange', notify, true); 69 })(); 70 """, 71 injectionTime: .atDocumentEnd, 72 forMainFrameOnly: false 73 )) 74 75 // Console logging for debugging 76 configuration.userContentController.addUserScript(WKUserScript( 77 source: """ 78 (function() { 79 const originalLog = console.log; 80 const originalError = console.error; 81 const originalWarn = console.warn; 82 83 console.log = function() { 84 originalLog.apply(console, arguments); 85 const args = Array.from(arguments).map(String); 86 window.webkit.messageHandlers.mereConsole.postMessage('LOG: ' + args.join(' ')); 87 }; 88 89 console.error = function() { 90 originalError.apply(console, arguments); 91 const args = Array.from(arguments).map(String); 92 window.webkit.messageHandlers.mereConsole.postMessage('ERROR: ' + args.join(' ')); 93 }; 94 95 console.warn = function() { 96 originalWarn.apply(console, arguments); 97 const args = Array.from(arguments).map(String); 98 window.webkit.messageHandlers.mereConsole.postMessage('WARN: ' + args.join(' ')); 99 }; 100 })(); 101 """, 102 injectionTime: .atDocumentStart, 103 forMainFrameOnly: false 104 )) 105 106 // Theme colour detection. Priority order: 107 // 1. meta[name="theme-color"] explicit, always wins 108 // 2. elementFromPoint walk finds the actual rendered background 109 // regardless of which element holds it (wrapper divs, app roots, etc.) 110 // Triggers: documentEnd, window load, html/body attr mutations, 111 // and <head> childList (for SPAs that inject meta[name="theme-color"]). 112 configuration.userContentController.addUserScript(WKUserScript( 113 source: """ 114 (function() { 115 var _lastColor = null; 116 function elBg(el) { 117 var bg = window.getComputedStyle(el).backgroundColor; 118 if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg; 119 // Also check legacy bgcolor attribute (used by HN and old-school HTML) 120 var attr = el.getAttribute ? el.getAttribute('bgcolor') : null; 121 if (attr) return attr; 122 return null; 123 } 124 function readColor() { 125 var m = document.querySelector('meta[name="theme-color"]'); 126 if (m && m.content) return m.content; 127 // Walk up from the center of the page to find the background. 128 var el = document.elementFromPoint( 129 window.innerWidth / 2, window.innerHeight / 2); 130 while (el && el.nodeType === 1) { 131 var bg = elBg(el); 132 if (bg) return bg; 133 el = el.parentElement; 134 } 135 return null; 136 } 137 function report() { 138 var c = readColor(); 139 if (c && c !== _lastColor) { 140 _lastColor = c; 141 window.webkit.messageHandlers.mereTheme.postMessage(c); 142 } 143 } 144 report(); 145 window.addEventListener('load', report); 146 var attrObs = new MutationObserver(report); 147 var attrOpts = { attributes: true, attributeFilter: ['style', 'class'] }; 148 attrObs.observe(document.documentElement, attrOpts); 149 if (document.body) attrObs.observe(document.body, attrOpts); 150 if (document.head) { 151 var headObs = new MutationObserver(function(ms) { 152 for (var i = 0; i < ms.length; i++) { 153 for (var j = 0; j < ms[i].addedNodes.length; j++) { 154 if (ms[i].addedNodes[j].nodeName === 'META') { report(); return; } 155 } 156 } 157 }); 158 headObs.observe(document.head, { childList: true }); 159 } 160 })(); 161 console.log('📄 Page loaded, document.styleSheets.length:', document.styleSheets.length); 162 console.log('📄 StyleSheets:', Array.from(document.styleSheets).map(s => s.href)); 163 """, 164 injectionTime: .atDocumentEnd, 165 forMainFrameOnly: true 166 )) 167 168 self.webView = WKWebView(frame: .zero, configuration: configuration) 169 self.webView.allowsBackForwardNavigationGestures = true 170 171 #if DEBUG 172 // Enable Web Inspector for Safari 173 self.webView.isInspectable = true 174 #endif 175 176 super.init() 177 178 // Use a weak wrapper to avoid retain cycles through userContentController. 179 let weak = WeakScriptMessageHandler(self) 180 configuration.userContentController.add(weak, name: "mereAudio") 181 configuration.userContentController.add(weak, name: "mereTheme") 182 configuration.userContentController.add(weak, name: "mereConsole") 183 184 webView.navigationDelegate = self 185 webView.uiDelegate = self 186 observeWebViewProperties() 187 } 188 189 // MARK: - WebContent 190 191 public func loadURL(_ url: URL) { 192 print("🌐 WebKitWebContent.loadURL: \(url.absoluteString)") 193 webView.load(URLRequest(url: url)) 194 } 195 196 public func loadHTML(_ html: String, baseURL: URL?) { 197 webView.loadHTMLString(html, baseURL: baseURL) 198 } 199 200 public func goBack() { webView.goBack() } 201 public func goForward() { webView.goForward() } 202 public func reload() { webView.reload() } 203 public func stopLoading() { webView.stopLoading() } 204 205 public func evaluateJavaScript(_ script: String) async throws -> Any? { 206 try await webView.evaluateJavaScript(script) 207 } 208 209 public func findInPage(_ query: String, forward: Bool) async -> FindResult { 210 // WKWebView doesn't expose find results count natively; use JS fallback. 211 let js = """ 212 (function() { 213 window.getSelection().removeAllRanges(); 214 return window.find('\(query.replacingOccurrences(of: "'", with: "\\'"))', 215 false, \(!forward), false, false, true); 216 })() 217 """ 218 _ = try? await webView.evaluateJavaScript(js) 219 return FindResult(matchCount: -1, activeMatchIndex: -1) // WKWebView limitation 220 } 221 222 public func clearFind() { 223 Task { _ = try? await webView.evaluateJavaScript("window.getSelection().removeAllRanges()") } 224 } 225 226 public func attachHostView(_ container: NSView) { 227 webView.translatesAutoresizingMaskIntoConstraints = false 228 container.addSubview(webView) 229 NSLayoutConstraint.activate([ 230 webView.leadingAnchor.constraint(equalTo: container.leadingAnchor), 231 webView.trailingAnchor.constraint(equalTo: container.trailingAnchor), 232 webView.topAnchor.constraint(equalTo: container.topAnchor), 233 webView.bottomAnchor.constraint(equalTo: container.bottomAnchor), 234 ]) 235 } 236 237 public func detachHostView() { 238 webView.removeFromSuperview() 239 } 240 241 public func snapshot() async -> NSImage? { 242 let config = WKSnapshotConfiguration() 243 return try? await webView.takeSnapshot(configuration: config) 244 } 245 246 public func suspend() { 247 // Tell the page it is hidden well-behaved pages pause rAF, timers, etc. 248 Task { 249 _ = try? await webView.evaluateJavaScript(""" 250 (function() { 251 Object.defineProperty(document,'hidden',{value:true,configurable:true}); 252 Object.defineProperty(document,'visibilityState',{value:'hidden',configurable:true}); 253 document.dispatchEvent(new Event('visibilitychange')); 254 })() 255 """) 256 } 257 } 258 259 public func resume() { 260 Task { 261 _ = try? await webView.evaluateJavaScript(""" 262 (function() { 263 Object.defineProperty(document,'hidden',{value:false,configurable:true}); 264 Object.defineProperty(document,'visibilityState',{value:'visible',configurable:true}); 265 document.dispatchEvent(new Event('visibilitychange')); 266 })() 267 """) 268 } 269 } 270 271 public func close() { 272 observations.forEach { $0.invalidate() } 273 observations.removeAll() 274 webView.navigationDelegate = nil 275 webView.uiDelegate = nil 276 detachHostView() 277 eventContinuation.finish() 278 } 279 280 // MARK: - KVO 281 282 private func observeWebViewProperties() { 283 observations = [ 284 webView.observe(\.url, options: [.new]) { [weak self] wv, _ in 285 Task { @MainActor in self?.url = wv.url } 286 }, 287 webView.observe(\.title, options: [.new]) { [weak self] wv, _ in 288 Task { @MainActor in 289 self?.title = wv.title 290 if let t = wv.title { self?.eventContinuation.yield(.titleChanged(title: t)) } 291 } 292 }, 293 webView.observe(\.isLoading, options: [.new]) { [weak self] wv, _ in 294 Task { @MainActor in self?.isLoading = wv.isLoading } 295 }, 296 webView.observe(\.estimatedProgress, options: [.new]) { [weak self] wv, _ in 297 Task { @MainActor in self?.estimatedProgress = wv.estimatedProgress } 298 }, 299 webView.observe(\.canGoBack, options: [.new]) { [weak self] wv, _ in 300 Task { @MainActor in self?.canGoBack = wv.canGoBack } 301 }, 302 webView.observe(\.canGoForward, options: [.new]) { [weak self] wv, _ in 303 Task { @MainActor in self?.canGoForward = wv.canGoForward } 304 }, 305 ] 306 } 307} 308 309// MARK: - WKNavigationDelegate 310 311extension WebKitWebContent: WKNavigationDelegate { 312 public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { 313 if let url = webView.url { eventContinuation.yield(.started(url: url)) } 314 } 315 316 public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { 317 if let url = webView.url { eventContinuation.yield(.committed(url: url)) } 318 reloadAttempt = 0 319 pendingReloadURL = nil 320 } 321 322 public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 323 if let url = webView.url { eventContinuation.yield(.finished(url: url)) } 324 Task { await emitFavicon() } 325 } 326 327 private func emitFavicon() async { 328 let js = """ 329 (function() { 330 var sel = 'link[rel~="icon"], link[rel~="shortcut icon"], link[rel="apple-touch-icon"]'; 331 var links = Array.from(document.querySelectorAll(sel)); 332 links.sort(function(a, b) { 333 var sa = (a.sizes && a.sizes[0]) ? parseInt(a.sizes[0]) : 0; 334 var sb = (b.sizes && b.sizes[0]) ? parseInt(b.sizes[0]) : 0; 335 return sb - sa; 336 }); 337 if (links.length && links[0].href) return links[0].href; 338 return null; 339 })() 340 """ 341 let result = try? await webView.evaluateJavaScript(js) 342 let faviconURL: URL? 343 if let href = result as? String, let url = URL(string: href) { 344 faviconURL = url 345 } else if let host = webView.url.flatMap({ URL(string: "\($0.scheme ?? "https")://\($0.host ?? "")") } ) { 346 faviconURL = host.appendingPathComponent("favicon.ico") 347 } else { 348 faviconURL = nil 349 } 350 eventContinuation.yield(.faviconChanged(url: faviconURL)) 351 } 352 353 public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { 354 let nsError = error as NSError 355 let retryableCodes = [-1001, -1002, -1004, -1005] 356 357 print("❌ Provisional load failed: domain=\(nsError.domain) code=\(nsError.code) url=\(webView.url?.absoluteString ?? "nil")") 358 359 // Retry once for transient network errors 360 if retryableCodes.contains(nsError.code), reloadAttempt == 0, let url = webView.url { 361 reloadAttempt = 1 362 pendingReloadURL = url 363 print("🔄 Attempting retry for url: \(url.absoluteString)") 364 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in 365 self?.webView.reload() 366 } 367 } else { 368 reloadAttempt = 0 369 pendingReloadURL = nil 370 eventContinuation.yield(.failed(url: webView.url, error: error)) 371 } 372 } 373 374 public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { 375 eventContinuation.yield(.failed(url: webView.url, error: error)) 376 } 377 378 public func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) { 379 // redirected event emitted when URL KVO fires 380 } 381} 382 383// MARK: - WKUIDelegate 384 385extension WebKitWebContent: WKUIDelegate { 386 public func webView(_ webView: WKWebView, 387 createWebViewWith configuration: WKWebViewConfiguration, 388 for navigationAction: WKNavigationAction, 389 windowFeatures: WKWindowFeatures) -> WKWebView? { 390 // Emit the URL as a navigation so the tab controller can open a new tab. 391 if let url = navigationAction.request.url { 392 eventContinuation.yield(.started(url: url)) 393 } 394 return nil 395 } 396} 397 398// MARK: - Audio state message handler 399 400extension WebKitWebContent: WKScriptMessageHandler { 401 public func userContentController(_ userContentController: WKUserContentController, 402 didReceive message: WKScriptMessage) { 403 switch message.name { 404 case "mereAudio": 405 if let playing = message.body as? Bool { 406 Task { @MainActor in self.hasAudioPlaying = playing } 407 } 408 case "mereTheme": 409 if let css = message.body as? String { 410 eventContinuation.yield(.themeColorChanged(cssColor: css)) 411 } 412 case "mereConsole": 413 if let log = message.body as? String { 414 print("🖥️ JS Console: \(log)") 415 } 416 default: break 417 } 418 } 419} 420 421/// Breaks the WKUserContentController handler retain cycle. 422private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { 423 weak var target: WKScriptMessageHandler? 424 init(_ target: WKScriptMessageHandler) { self.target = target } 425 426 func userContentController(_ userContentController: WKUserContentController, 427 didReceive message: WKScriptMessage) { 428 target?.userContentController(userContentController, didReceive: message) 429 } 430}