···89899090 public func snapshot() async -> NSImage? { nil }
91919292+ public func suspend() {}
9393+ public func resume() {}
9294 public func close() { continuation.finish() }
9395}
+87-23
Sources/MereCore/Tab.swift
···2323 @Published public private(set) var favicon: URL?
2424 @Published public private(set) var hasAudioPlaying = false
2525 @Published public private(set) var themeColor: PlatformColor?
2626+ @Published public private(set) var navigationError: Error?
26272728 private var observationTask: Task<Void, Never>?
2929+ private var pollTask: Task<Void, Never>?
3030+ private var themeColorTask: Task<Void, Never>?
3131+3232+ /// Whether this tab is currently visible. Background tabs poll at a much
3333+ /// lower rate and skip theme-colour reads to save CPU and memory.
3434+ public private(set) var isActive = false
28352936 public init(content: any WebContent) {
3037 self.id = content.id
···4148 public func reload() { content.reload() }
4249 public func stopLoading() { content.stopLoading() }
43505151+ // MARK: - State control
5252+5353+ public func resetToNewTab() {
5454+ url = nil
5555+ title = nil
5656+ isLoading = false
5757+ estimatedProgress = 0
5858+ canGoBack = false
5959+ canGoForward = false
6060+ themeColor = nil
6161+ navigationError = nil
6262+ content.loadHTML("", baseURL: nil)
6363+ }
6464+6565+ // MARK: - Active state
6666+6767+ public func activate() {
6868+ guard !isActive else { return }
6969+ isActive = true
7070+ content.resume()
7171+ // Immediately sync so the UI reflects current state.
7272+ syncState()
7373+ scheduleThemeColorRead()
7474+ startPoll()
7575+ }
7676+7777+ public func deactivate() {
7878+ guard isActive else { return }
7979+ isActive = false
8080+ content.suspend()
8181+ pollTask?.cancel()
8282+ pollTask = nil
8383+ themeColorTask?.cancel()
8484+ themeColorTask = nil
8585+ }
8686+4487 // MARK: - Private
45884689 private func startObserving() {
···4891 guard let self else { return }
4992 for await event in content.navigationEvents {
5093 guard !Task.isCancelled else { break }
5151- await MainActor.run {
5252- self.apply(event)
5353- }
9494+ await MainActor.run { self.apply(event) }
5495 }
5596 }
9797+ // Start polling immediately; WindowViewModel will call activate/deactivate.
9898+ startPoll()
9999+ }
561005757- // Poll lightweight state from the WebContent on each event.
5858- // A real implementation would use Combine or direct KVO bindings.
5959- Task { [weak self] in
101101+ private func startPoll() {
102102+ pollTask?.cancel()
103103+ // Active tabs poll at 250 ms; background tabs poll at 2 s (title/loading only).
104104+ let interval: Duration = isActive ? .milliseconds(250) : .seconds(2)
105105+ pollTask = Task { [weak self] in
60106 while !Task.isCancelled {
61107 guard let self else { return }
62108 self.syncState()
6363- try? await Task.sleep(for: .milliseconds(100))
109109+ try? await Task.sleep(for: interval)
64110 }
65111 }
66112 }
···71117 self.url = url
72118 self.isLoading = true
73119 self.estimatedProgress = 0.1
120120+ self.navigationError = nil // clear any previous error
121121+ self.themeColor = nil // clear for new navigation; readThemeColor will repopulate
74122 case .committed(let url):
75123 self.url = url
76124 self.estimatedProgress = 0.7
···78126 self.url = url
79127 self.isLoading = false
80128 self.estimatedProgress = 1.0
8181- Task { await self.readThemeColor() }
8282- case .failed:
129129+ self.navigationError = nil
130130+ scheduleThemeColorRead()
131131+ case .failed(_, let error):
83132 self.isLoading = false
84133 self.estimatedProgress = 0
134134+ self.navigationError = error
85135 case .titleChanged(let title):
86136 self.title = title
87137 case .faviconChanged(let url):
88138 self.favicon = url
139139+ case .themeColorChanged(let css):
140140+ self.themeColor = PlatformColor.fromCSS(css)
89141 case .redirected(_, let to):
90142 self.url = to
91143 }
92144 }
93145146146+ private func scheduleThemeColorRead() {
147147+ guard isActive, themeColorTask == nil else { return }
148148+ themeColorTask = Task { [weak self] in
149149+ await self?.readThemeColor()
150150+ self?.themeColorTask = nil
151151+ }
152152+ }
153153+94154 private func readThemeColor() async {
95155 let js = """
96156 (function() {
157157+ function elBg(el) {
158158+ var bg = window.getComputedStyle(el).backgroundColor;
159159+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
160160+ var attr = el.getAttribute ? el.getAttribute('bgcolor') : null;
161161+ if (attr) return attr;
162162+ return null;
163163+ }
97164 var m = document.querySelector('meta[name="theme-color"]');
98165 if (m && m.content) return m.content;
9999- var els = [document.documentElement, document.body];
100100- for (var el of els) {
101101- if (!el) continue;
102102- var bg = window.getComputedStyle(el).backgroundColor;
103103- if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
166166+ var el = document.elementFromPoint(
167167+ window.innerWidth / 2, window.innerHeight / 2);
168168+ while (el && el.nodeType === 1) {
169169+ var bg = elBg(el);
170170+ if (bg) return bg;
171171+ el = el.parentElement;
104172 }
105173 return null;
106174 })()
107175 """
108176 let result = try? await content.evaluateJavaScript(js)
109109- guard let css = result as? String, !css.isEmpty else {
110110- self.themeColor = nil
111111- return
112112- }
177177+ // Only update when we get a valid colour; don't reset to nil on a
178178+ // failed/null read — the colour was already cleared on .started.
179179+ guard let css = result as? String, !css.isEmpty else { return }
113180 self.themeColor = PlatformColor.fromCSS(css)
114181 }
115182···121188 self.canGoBack = content.canGoBack
122189 self.canGoForward = content.canGoForward
123190 self.hasAudioPlaying = content.hasAudioPlaying
124124- // Re-check background colour on each poll cycle so JS-driven
125125- // colour changes (dark mode toggles, SPA navigations) are picked up.
126126- if !self.isLoading, self.url != nil {
127127- Task { await self.readThemeColor() }
128128- }
129191 }
130192131193 deinit {
132194 observationTask?.cancel()
195195+ pollTask?.cancel()
196196+ themeColorTask?.cancel()
133197 }
134198}
+23
Sources/MereCore/WindowViewModel.swift
···1111 @Published public var activeTab: Tab?
1212 @Published public private(set) var newTabBackgroundColor: PlatformColor?
13131414+ /// Sidebar visibility — owned here so keyboard shortcuts in commands can toggle it.
1515+ @Published public var sidebarVisible = true
1616+ /// Incrementing this triggers the address bar to take focus and select-all.
1717+ @Published public var addressFocusTrigger = 0
1818+1419 private let webkitContext: any BrowserContext
1520 private let chromiumContext: (any BrowserContext)?
1621 private let cookieSync: CookieSyncController
1722 public let adBlock: AdBlockController
1823 private var activeTabObservation: AnyCancellable?
19242525+ private let tabsFileURL: URL
2626+2027 public init(
2128 webkitContext: any BrowserContext,
2229 chromiumContext: (any BrowserContext)? = nil,
···2936 chromium: chromiumContext
3037 )
3138 self.adBlock = AdBlockController(engines: adBlockEngines)
3939+4040+ let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
4141+ let appDir = appSupport.appendingPathComponent("Mere", isDirectory: true)
4242+ try? FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true)
4343+ self.tabsFileURL = appDir.appendingPathComponent("saved_tabs.json")
3244 }
33453446 // MARK: - Tab management
···3951 if url == nil, let current = activeTab, current.url != nil {
4052 newTabBackgroundColor = current.themeColor
4153 }
5454+ activeTab?.deactivate()
4255 let resolvedEngine = engine ?? url.map(EngineType.preferred) ?? .webkit
4356 let context = context(for: resolvedEngine)
4457 let content = context.makeWebContent()
4558 let tab = Tab(content: content)
4659 tabs.append(tab)
4760 activeTab = tab
6161+ tab.activate()
4862 subscribeToActiveTab()
4963 if let url { tab.loadURL(url) }
5064 return tab
5165 }
52665367 public func closeTab(_ tab: Tab) {
6868+ guard tabs.count > 1 else {
6969+ tab.resetToNewTab()
7070+ return
7171+ }
7272+ tab.deactivate()
5473 tab.content.close()
5574 tabs.removeAll { $0.id == tab.id }
5675 if activeTab?.id == tab.id {
5776 activeTab = tabs.last
7777+ activeTab?.activate()
5878 subscribeToActiveTab()
5979 }
6080 }
61816282 public func activateTab(_ tab: Tab) {
8383+ activeTab?.deactivate()
6384 activeTab = tab
8585+ tab.activate()
6486 subscribeToActiveTab()
6587 }
6688···80102 tabs.move(fromOffsets: IndexSet(integer: tabs.count - 1), toOffset: idx)
81103 }
82104 activeTab = newTab
105105+ newTab.activate()
83106 subscribeToActiveTab()
84107 }
85108
+2
Sources/MereKit/Mock/MockWebContent.swift
···3535 public func attachHostView(_ container: PlatformView) {}
3636 public func detachHostView() {}
3737 public func snapshot() async -> PlatformImage? { nil }
3838+ public func suspend() {}
3939+ public func resume() {}
3840 public func close() { continuation.finish() }
3941}
+2
Sources/MereKit/Models/NavigationEvent.swift
···88 case failed(url: URL?, error: Error)
99 case titleChanged(title: String)
1010 case faviconChanged(url: URL?)
1111+ /// Native theme-color from `<meta name="theme-color">` — carries the raw CSS string.
1212+ case themeColorChanged(cssColor: String)
1113}
12141315public struct NavigationPolicy: Sendable {
+20
Sources/MereKit/Protocols/WebContent.swift
···1212#endif
13131414extension PlatformColor {
1515+ /// Serialize to an `rgb()` CSS string.
1616+ public var cssString: String? {
1717+ #if canImport(AppKit)
1818+ guard let c = usingColorSpace(.deviceRGB) else { return nil }
1919+ let r = Int(c.redComponent * 255), g = Int(c.greenComponent * 255), b = Int(c.blueComponent * 255)
2020+ #else
2121+ var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
2222+ getRed(&r, green: &g, blue: &b, alpha: &a)
2323+ let r = Int(r * 255), g = Int(g * 255), b = Int(b * 255)
2424+ #endif
2525+ return "rgb(\(r),\(g),\(b))"
2626+ }
2727+1528 /// Create from a CSS color string — hex (`#rgb`, `#rrggbb`) or `rgb()`/`rgba()`.
1629 public static func fromCSS(_ value: String) -> PlatformColor? {
1730 let s = value.trimmingCharacters(in: .whitespaces)
···115128 var navigationEvents: AsyncStream<NavigationEvent> { get }
116129117130 // MARK: - Lifecycle
131131+132132+ /// Signal that the tab moved to the background. Implementations should
133133+ /// fire the Page Visibility API and pause media so the page reduces activity.
134134+ func suspend()
135135+136136+ /// Signal that the tab became active again.
137137+ func resume()
118138119139 func close()
120140}
···5454 await applyToConfiguration()
5555 }
56565757+ /// Apply the current enabled rule lists to a freshly created content controller.
5858+ /// Called by WebKitBrowserContext when making a new tab.
5959+ public func applyCurrentRules(to controller: WKUserContentController) {
6060+ guard isEnabled else { return }
6161+ for list in compiledLists.values { controller.add(list) }
6262+ }
6363+5764 // MARK: - Private
58655966 private func applyToConfiguration() async {
+8-1
Sources/WebKitEngine/WebKitBrowserContext.swift
···2525 // MARK: - BrowserContext
26262727 public func makeWebContent() -> any WebContent {
2828- WebKitWebContent(configuration: sharedConfiguration)
2828+ // Each tab needs its own WKWebViewConfiguration so that script message
2929+ // handler names (mereAudio, mereTheme) don't collide. We share only the
3030+ // websiteDataStore so cookies and storage are common across tabs.
3131+ let config = WKWebViewConfiguration()
3232+ config.websiteDataStore = dataStore
3333+ config.preferences.isElementFullscreenEnabled = true
3434+ adBlocker.applyCurrentRules(to: config.userContentController)
3535+ return WebKitWebContent(configuration: config)
2936 }
30373138 public func cookies(for url: URL) async -> [HTTPCookie] {
+143-1
Sources/WebKitEngine/WebKitWebContent.swift
···43434444 // MARK: - Init
45454646- public init(configuration: WKWebViewConfiguration = .init()) {
4646+ public override convenience init() {
4747+ self.init(configuration: WKWebViewConfiguration())
4848+ }
4949+5050+ public init(configuration: WKWebViewConfiguration) {
4751 let (stream, continuation) = AsyncStream<NavigationEvent>.makeStream()
4852 self.navigationEvents = stream
4953 self.eventContinuation = continuation
50545555+ // Audio: forwards play/pause state via message handler.
5656+ configuration.userContentController.addUserScript(WKUserScript(
5757+ source: """
5858+ (function() {
5959+ function notify() {
6060+ var playing = Array.from(document.querySelectorAll('video,audio'))
6161+ .some(function(m){ return !m.paused && !m.muted && m.volume > 0; });
6262+ window.webkit.messageHandlers.mereAudio.postMessage(playing);
6363+ }
6464+ document.addEventListener('play', notify, true);
6565+ document.addEventListener('pause', notify, true);
6666+ document.addEventListener('volumechange', notify, true);
6767+ })();
6868+ """,
6969+ injectionTime: .atDocumentEnd,
7070+ forMainFrameOnly: false
7171+ ))
7272+7373+ // Theme colour detection. Priority order:
7474+ // 1. meta[name="theme-color"] — explicit, always wins
7575+ // 2. elementFromPoint walk — finds the actual rendered background
7676+ // regardless of which element holds it (wrapper divs, app roots, etc.)
7777+ // Triggers: documentEnd, window load, html/body attr mutations,
7878+ // and <head> childList (for SPAs that inject meta[name="theme-color"]).
7979+ configuration.userContentController.addUserScript(WKUserScript(
8080+ source: """
8181+ (function() {
8282+ var _lastColor = null;
8383+ function elBg(el) {
8484+ var bg = window.getComputedStyle(el).backgroundColor;
8585+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
8686+ // Also check legacy bgcolor attribute (used by HN and old-school HTML)
8787+ var attr = el.getAttribute ? el.getAttribute('bgcolor') : null;
8888+ if (attr) return attr;
8989+ return null;
9090+ }
9191+ function readColor() {
9292+ var m = document.querySelector('meta[name="theme-color"]');
9393+ if (m && m.content) return m.content;
9494+ // Walk up from the center of the page to find the background.
9595+ var el = document.elementFromPoint(
9696+ window.innerWidth / 2, window.innerHeight / 2);
9797+ while (el && el.nodeType === 1) {
9898+ var bg = elBg(el);
9999+ if (bg) return bg;
100100+ el = el.parentElement;
101101+ }
102102+ return null;
103103+ }
104104+ function report() {
105105+ var c = readColor();
106106+ if (c && c !== _lastColor) {
107107+ _lastColor = c;
108108+ window.webkit.messageHandlers.mereTheme.postMessage(c);
109109+ }
110110+ }
111111+ report();
112112+ window.addEventListener('load', report);
113113+ var attrObs = new MutationObserver(report);
114114+ var attrOpts = { attributes: true, attributeFilter: ['style', 'class'] };
115115+ attrObs.observe(document.documentElement, attrOpts);
116116+ if (document.body) attrObs.observe(document.body, attrOpts);
117117+ if (document.head) {
118118+ var headObs = new MutationObserver(function(ms) {
119119+ for (var i = 0; i < ms.length; i++) {
120120+ for (var j = 0; j < ms[i].addedNodes.length; j++) {
121121+ if (ms[i].addedNodes[j].nodeName === 'META') { report(); return; }
122122+ }
123123+ }
124124+ });
125125+ headObs.observe(document.head, { childList: true });
126126+ }
127127+ })();
128128+ """,
129129+ injectionTime: .atDocumentEnd,
130130+ forMainFrameOnly: true
131131+ ))
132132+51133 self.webView = WKWebView(frame: .zero, configuration: configuration)
52134 self.webView.allowsBackForwardNavigationGestures = true
5313554136 super.init()
137137+138138+ // Use a weak wrapper to avoid retain cycles through userContentController.
139139+ let weak = WeakScriptMessageHandler(self)
140140+ configuration.userContentController.add(weak, name: "mereAudio")
141141+ configuration.userContentController.add(weak, name: "mereTheme")
5514256143 webView.navigationDelegate = self
57144 webView.uiDelegate = self
···114201 return try? await webView.takeSnapshot(configuration: config)
115202 }
116203204204+ public func suspend() {
205205+ // Tell the page it is hidden — well-behaved pages pause rAF, timers, etc.
206206+ Task {
207207+ _ = try? await webView.evaluateJavaScript("""
208208+ (function() {
209209+ Object.defineProperty(document,'hidden',{value:true,configurable:true});
210210+ Object.defineProperty(document,'visibilityState',{value:'hidden',configurable:true});
211211+ document.dispatchEvent(new Event('visibilitychange'));
212212+ })()
213213+ """)
214214+ }
215215+ }
216216+217217+ public func resume() {
218218+ Task {
219219+ _ = try? await webView.evaluateJavaScript("""
220220+ (function() {
221221+ Object.defineProperty(document,'hidden',{value:false,configurable:true});
222222+ Object.defineProperty(document,'visibilityState',{value:'visible',configurable:true});
223223+ document.dispatchEvent(new Event('visibilitychange'));
224224+ })()
225225+ """)
226226+ }
227227+ }
228228+117229 public func close() {
118230 observations.forEach { $0.invalidate() }
119231 observations.removeAll()
···217329 return nil
218330 }
219331}
332332+333333+// MARK: - Audio state message handler
334334+335335+extension WebKitWebContent: WKScriptMessageHandler {
336336+ public func userContentController(_ userContentController: WKUserContentController,
337337+ didReceive message: WKScriptMessage) {
338338+ switch message.name {
339339+ case "mereAudio":
340340+ if let playing = message.body as? Bool {
341341+ Task { @MainActor in self.hasAudioPlaying = playing }
342342+ }
343343+ case "mereTheme":
344344+ if let css = message.body as? String {
345345+ eventContinuation.yield(.themeColorChanged(cssColor: css))
346346+ }
347347+ default: break
348348+ }
349349+ }
350350+}
351351+352352+/// Breaks the WKUserContentController → handler retain cycle.
353353+private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
354354+ weak var target: WKScriptMessageHandler?
355355+ init(_ target: WKScriptMessageHandler) { self.target = target }
356356+357357+ func userContentController(_ userContentController: WKUserContentController,
358358+ didReceive message: WKScriptMessage) {
359359+ target?.userContentController(userContentController, didReceive: message)
360360+ }
361361+}