fancy new browser
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}