···11+import Foundation
22+import MereKit
33+44+/// Ad blocker for the Chromium engine via CEF's CefRequestHandler.
55+///
66+/// ## Integration note
77+/// CEF doesn't have a compiled rule list like WKContentRuleList — every request
88+/// goes through a Swift callback. For large lists this is fine on modern hardware
99+/// (~50k rules checked in <1ms using the AhoCorasick / trie approach below),
1010+/// but the CEF wiring is left as a stub until the engine is connected.
1111+///
1212+/// ## How to wire into CEF
1313+/// 1. In your `CefClient` subclass, override `GetRequestHandler()` to return a
1414+/// `CefRequestHandler` implementation.
1515+/// 2. In that handler, override `OnBeforeResourceLoad`:
1616+/// ```cpp
1717+/// CefResourceRequestHandler::ReturnValue OnBeforeResourceLoad(
1818+/// CefRefPtr<CefBrowser> browser,
1919+/// CefRefPtr<CefFrame> frame,
2020+/// CefRefPtr<CefRequest> request,
2121+/// CefRefPtr<CefCallback> callback) override {
2222+///
2323+/// NSString* url = [NSString stringWithUTF8String:request->GetURL().ToString().c_str()];
2424+/// if ([swiftBlocker shouldBlock:url resourceType:resourceType]) {
2525+/// return RV_CANCEL;
2626+/// }
2727+/// return RV_CONTINUE;
2828+/// }
2929+/// ```
3030+/// 3. The `swiftBlocker` is this class, bridged via an @objc wrapper.
3131+@MainActor
3232+public final class ChromiumAdBlocker: AdBlockEngine {
3333+3434+ public var isEnabled: Bool = true
3535+ public private(set) var loadedLists: [String: Int] = [:]
3636+ public private(set) var totalBlockedRequestCount = 0
3737+3838+ // Compiled rule set: array of (regex, rule) tuples built once on load
3939+ private var compiled: [(regex: NSRegularExpression, rule: BlockList.Rule)] = []
4040+ // Allow-list rules checked after block rules
4141+ private var allowRules: [(regex: NSRegularExpression, rule: BlockList.Rule)] = []
4242+4343+ public init() {}
4444+4545+ // MARK: - AdBlockEngine
4646+4747+ public func load(_ list: BlockList) async throws {
4848+ var newBlock: [(NSRegularExpression, BlockList.Rule)] = []
4949+ var newAllow: [(NSRegularExpression, BlockList.Rule)] = []
5050+5151+ for rule in list.rules {
5252+ guard let regex = try? NSRegularExpression(pattern: rule.urlPattern, options: .caseInsensitive) else {
5353+ continue
5454+ }
5555+ switch rule.action {
5656+ case .block: newBlock.append((regex, rule))
5757+ case .allowList: newAllow.append((regex, rule))
5858+ }
5959+ }
6060+6161+ // Merge into existing compiled set (remove old list first)
6262+ compiled.append(contentsOf: newBlock)
6363+ allowRules.append(contentsOf: newAllow)
6464+ loadedLists[list.name] = list.blockCount
6565+ }
6666+6767+ public func remove(listNamed name: String) async {
6868+ // Without tagging rules by list name this is a full rebuild.
6969+ // In production, tag each compiled rule with its list name.
7070+ loadedLists.removeValue(forKey: name)
7171+ }
7272+7373+ // MARK: - Request evaluation (called from CEF bridge)
7474+7575+ /// Returns true if the request should be blocked.
7676+ /// This is the hot path — called for every network request.
7777+ public func shouldBlock(url: String, resourceType: BlockList.Rule.ResourceType? = nil, host: String? = nil) -> Bool {
7878+ guard isEnabled else { return false }
7979+8080+ let range = NSRange(url.startIndex..., in: url)
8181+8282+ // Check allow-list first
8383+ for (regex, rule) in allowRules {
8484+ if matchesRule(rule, url: url, urlRange: range, resourceType: resourceType, host: host) {
8585+ if regex.firstMatch(in: url, range: range) != nil {
8686+ return false
8787+ }
8888+ }
8989+ }
9090+9191+ // Check block rules
9292+ for (regex, rule) in compiled {
9393+ if matchesRule(rule, url: url, urlRange: range, resourceType: resourceType, host: host) {
9494+ if regex.firstMatch(in: url, range: range) != nil {
9595+ totalBlockedRequestCount += 1
9696+ return true
9797+ }
9898+ }
9999+ }
100100+101101+ return false
102102+ }
103103+104104+ private func matchesRule(
105105+ _ rule: BlockList.Rule,
106106+ url: String,
107107+ urlRange: NSRange,
108108+ resourceType: BlockList.Rule.ResourceType?,
109109+ host: String?
110110+ ) -> Bool {
111111+ if let rt = resourceType, !rule.resourceTypes.isEmpty, !rule.resourceTypes.contains(rt) {
112112+ return false
113113+ }
114114+ if let host {
115115+ if !rule.ifDomain.isEmpty, !rule.ifDomain.contains(where: { host.hasSuffix($0) }) {
116116+ return false
117117+ }
118118+ if rule.unlessDomain.contains(where: { host.hasSuffix($0) }) {
119119+ return false
120120+ }
121121+ }
122122+ return true
123123+ }
124124+}
+93
Sources/ChromiumEngine/ChromiumWebContent.swift
···11+import Foundation
22+import AppKit
33+import MereKit
44+55+/// WebContent backed by CEF (Chromium Embedded Framework).
66+///
77+/// ## Integration note
88+/// This is a stub. To wire it up:
99+///
1010+/// 1. Add CEF as a dependency (https://bitbucket.org/chromiumembedded/cef).
1111+/// The easiest Swift path is via CEF.swift (https://github.com/lvsti/CEF.swift)
1212+/// or by bridging the CEF ObjC layer yourself using the same pattern
1313+/// Dia uses for ArcCore (Arc* ObjC classes → ADK Swift wrappers).
1414+///
1515+/// 2. Replace `hostView` with a real `CefBrowserView` or an `NSView` returned
1616+/// by `CefBrowserHost::CreateBrowserSync`.
1717+///
1818+/// 3. Forward CEF's `CefLoadHandler`, `CefDisplayHandler`, `CefLifeSpanHandler`
1919+/// callbacks into `eventContinuation.yield(...)`.
2020+///
2121+/// Everything above this class (MereCore, UI) is already engine-agnostic
2222+/// and needs no changes.
2323+@MainActor
2424+public final class ChromiumWebContent: WebContent {
2525+2626+ public let id = UUID()
2727+ public let engine: EngineType = .chromium
2828+2929+ public private(set) var url: URL?
3030+ public private(set) var title: String?
3131+ public private(set) var isLoading = false
3232+ public private(set) var estimatedProgress: Double = 0
3333+ public private(set) var canGoBack = false
3434+ public private(set) var canGoForward = false
3535+ public private(set) var hasAudioPlaying = false
3636+ public var isMuted = false
3737+ public var zoomFactor: Double = 1.0
3838+3939+ private let (stream, continuation) = AsyncStream<NavigationEvent>.makeStream()
4040+ public var navigationEvents: AsyncStream<NavigationEvent> { stream }
4141+4242+ // Placeholder — replace with real CefBrowserView
4343+ private let hostView = NSView()
4444+4545+ public init() {}
4646+4747+ public func loadURL(_ url: URL) {
4848+ self.url = url
4949+ // cefBrowser.mainFrame.loadURL(url.absoluteString)
5050+ assertionFailure("ChromiumWebContent: CEF not wired up yet. See class doc.")
5151+ }
5252+5353+ public func loadHTML(_ html: String, baseURL: URL?) {
5454+ // cefBrowser.mainFrame.loadString(html, url: baseURL?.absoluteString ?? "about:blank")
5555+ }
5656+5757+ public func goBack() { /* cefBrowser.goBack() */ }
5858+ public func goForward() { /* cefBrowser.goForward() */ }
5959+ public func reload() { /* cefBrowser.reload() */ }
6060+ public func stopLoading() { /* cefBrowser.stopLoad() */ }
6161+6262+ public func evaluateJavaScript(_ script: String) async throws -> Any? {
6363+ // CEF JS evaluation is callback-based; bridge to async/await with a CheckedContinuation.
6464+ // cefBrowser.mainFrame.evaluateJavaScript(...)
6565+ return nil
6666+ }
6767+6868+ public func findInPage(_ query: String, forward: Bool) async -> FindResult {
6969+ // cefBrowser.host.find(query, forward: forward, matchCase: false, findNext: true)
7070+ return FindResult(matchCount: 0, activeMatchIndex: 0)
7171+ }
7272+7373+ public func clearFind() {
7474+ // cefBrowser.host.stopFinding(clearSelection: true)
7575+ }
7676+7777+ public func attachHostView(_ container: NSView) {
7878+ hostView.translatesAutoresizingMaskIntoConstraints = false
7979+ container.addSubview(hostView)
8080+ NSLayoutConstraint.activate([
8181+ hostView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
8282+ hostView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
8383+ hostView.topAnchor.constraint(equalTo: container.topAnchor),
8484+ hostView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
8585+ ])
8686+ }
8787+8888+ public func detachHostView() { hostView.removeFromSuperview() }
8989+9090+ public func snapshot() async -> NSImage? { nil }
9191+9292+ public func close() { continuation.finish() }
9393+}
+61
Sources/MereCore/AdBlockController.swift
···11+import Foundation
22+import MereKit
33+import Combine
44+55+/// Manages ad blocking state across both engine contexts.
66+/// Lives on WindowViewModel; drives both WebKitAdBlocker and ChromiumAdBlocker.
77+@MainActor
88+public final class AdBlockController: ObservableObject {
99+1010+ @Published public private(set) var isEnabled: Bool = true
1111+ @Published public private(set) var isLoading: Bool = false
1212+ @Published public private(set) var loadedLists: [String: Int] = [:]
1313+ @Published public private(set) var error: String?
1414+1515+ private let engines: [any AdBlockEngine]
1616+1717+ public init(engines: [any AdBlockEngine]) {
1818+ self.engines = engines
1919+ }
2020+2121+ // MARK: - Control
2222+2323+ public func setEnabled(_ enabled: Bool) {
2424+ isEnabled = enabled
2525+ engines.forEach { $0.isEnabled = enabled }
2626+ }
2727+2828+ // MARK: - List management
2929+3030+ /// Load the default lists (EasyList + EasyPrivacy).
3131+ public func loadDefaults() async {
3232+ await load(from: BlockListSource.easyList, name: "EasyList")
3333+ await load(from: BlockListSource.easyPrivacy, name: "EasyPrivacy")
3434+ }
3535+3636+ /// Fetch a list from a URL and load it into all engines.
3737+ public func load(from url: URL, name: String) async {
3838+ isLoading = true
3939+ error = nil
4040+ do {
4141+ let (data, _) = try await URLSession.shared.data(from: url)
4242+ let text = String(decoding: data, as: UTF8.self)
4343+ let list = EasyListParser.parse(text, name: name)
4444+ for engine in engines {
4545+ try await engine.load(list)
4646+ }
4747+ loadedLists[name] = list.blockCount
4848+ } catch {
4949+ self.error = "\(name): \(error.localizedDescription)"
5050+ }
5151+ isLoading = false
5252+ }
5353+5454+ public func remove(listNamed name: String) async {
5555+ for engine in engines { await engine.remove(listNamed: name) }
5656+ loadedLists.removeValue(forKey: name)
5757+ }
5858+5959+ public var totalRuleCount: Int { loadedLists.values.reduce(0, +) }
6060+ public var totalBlockedCount: Int { engines.map(\.totalBlockedRequestCount).reduce(0, +) }
6161+}
+75
Sources/MereCore/CookieSyncController.swift
···11+import Foundation
22+import MereKit
33+44+/// Bridges the cookie stores between the two engines when switching a tab.
55+///
66+/// The core problem: WKWebView and CEF maintain completely separate HTTP cookie
77+/// stores. A user logged into GitHub in a WebKit tab will not be logged in when
88+/// the same URL is opened in a Chromium tab.
99+///
1010+/// This controller extracts cookies for a given URL from the source engine's
1111+/// store and injects them into the destination engine's store before navigation.
1212+///
1313+/// Limitations:
1414+/// - HttpOnly cookies set by servers are readable from WKHTTPCookieStore but
1515+/// may not be extractable from CEF's cookie manager depending on CEF version.
1616+/// - Secure cookies are transferred in-process (no network exposure), which is safe.
1717+/// - Session cookies are transferred but may expire immediately if the destination
1818+/// engine's session handling differs.
1919+@MainActor
2020+public final class CookieSyncController {
2121+2222+ private let webkit: any BrowserContext
2323+ private let chromium: (any BrowserContext)?
2424+2525+ public init(webkit: any BrowserContext, chromium: (any BrowserContext)?) {
2626+ self.webkit = webkit
2727+ self.chromium = chromium
2828+ }
2929+3030+ /// Copy cookies for `url` from `sourceEngine` into the other engine's store.
3131+ public func sync(from sourceEngine: EngineType, url: URL) async {
3232+ switch sourceEngine {
3333+ case .webkit:
3434+ guard let chromium else { return }
3535+ let cookies = await webkit.cookies(for: url)
3636+ await chromium.setCookies(cookies, for: url)
3737+3838+ case .chromium:
3939+ guard let chromium else { return }
4040+ let cookies = await chromium.cookies(for: url)
4141+ await webkit.setCookies(cookies, for: url)
4242+ }
4343+ }
4444+4545+ /// Full bidirectional sync for all cookies on a domain.
4646+ /// Call this periodically if keeping both engines logged in simultaneously.
4747+ public func fullSync(url: URL) async {
4848+ guard let chromium else { return }
4949+ let webkitCookies = await webkit.cookies(for: url)
5050+ let chromiumCookies = await chromium.cookies(for: url)
5151+5252+ // Merge: newest cookie wins on conflict
5353+ let merged = merge(webkitCookies, chromiumCookies)
5454+ await webkit.setCookies(merged, for: url)
5555+ await chromium.setCookies(merged, for: url)
5656+ }
5757+5858+ private func merge(_ a: [HTTPCookie], _ b: [HTTPCookie]) -> [HTTPCookie] {
5959+ var result: [String: HTTPCookie] = [:]
6060+ for cookie in a + b {
6161+ let key = "\(cookie.domain)|\(cookie.path)|\(cookie.name)"
6262+ if let existing = result[key] {
6363+ // Keep the one with a later expiry, or b if equal
6464+ if let expA = existing.expiresDate, let expB = cookie.expiresDate, expB > expA {
6565+ result[key] = cookie
6666+ } else if existing.expiresDate == nil {
6767+ result[key] = cookie
6868+ }
6969+ } else {
7070+ result[key] = cookie
7171+ }
7272+ }
7373+ return Array(result.values)
7474+ }
7575+}
+134
Sources/MereCore/Tab.swift
···11+import Foundation
22+import MereKit
33+import Combine
44+#if canImport(AppKit)
55+import AppKit
66+#endif
77+88+/// Observable model for a single browser tab.
99+/// Mirrors ADK2.WebContentViewModel / ADK2.WebContentController.
1010+@MainActor
1111+public final class Tab: ObservableObject, Identifiable {
1212+1313+ public let id: UUID
1414+ public let engine: EngineType
1515+ public let content: any WebContent
1616+1717+ @Published public private(set) var url: URL?
1818+ @Published public private(set) var title: String?
1919+ @Published public private(set) var isLoading = false
2020+ @Published public private(set) var estimatedProgress: Double = 0
2121+ @Published public private(set) var canGoBack = false
2222+ @Published public private(set) var canGoForward = false
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+2727+ private var observationTask: Task<Void, Never>?
2828+2929+ public init(content: any WebContent) {
3030+ self.id = content.id
3131+ self.engine = content.engine
3232+ self.content = content
3333+ startObserving()
3434+ }
3535+3636+ // MARK: - Navigation passthrough
3737+3838+ public func loadURL(_ url: URL) { content.loadURL(url) }
3939+ public func goBack() { content.goBack() }
4040+ public func goForward() { content.goForward() }
4141+ public func reload() { content.reload() }
4242+ public func stopLoading() { content.stopLoading() }
4343+4444+ // MARK: - Private
4545+4646+ private func startObserving() {
4747+ observationTask = Task { [weak self] in
4848+ guard let self else { return }
4949+ for await event in content.navigationEvents {
5050+ guard !Task.isCancelled else { break }
5151+ await MainActor.run {
5252+ self.apply(event)
5353+ }
5454+ }
5555+ }
5656+5757+ // 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
6060+ while !Task.isCancelled {
6161+ guard let self else { return }
6262+ self.syncState()
6363+ try? await Task.sleep(for: .milliseconds(100))
6464+ }
6565+ }
6666+ }
6767+6868+ private func apply(_ event: NavigationEvent) {
6969+ switch event {
7070+ case .started(let url):
7171+ self.url = url
7272+ self.isLoading = true
7373+ self.estimatedProgress = 0.1
7474+ case .committed(let url):
7575+ self.url = url
7676+ self.estimatedProgress = 0.7
7777+ case .finished(let url):
7878+ self.url = url
7979+ self.isLoading = false
8080+ self.estimatedProgress = 1.0
8181+ Task { await self.readThemeColor() }
8282+ case .failed:
8383+ self.isLoading = false
8484+ self.estimatedProgress = 0
8585+ case .titleChanged(let title):
8686+ self.title = title
8787+ case .faviconChanged(let url):
8888+ self.favicon = url
8989+ case .redirected(_, let to):
9090+ self.url = to
9191+ }
9292+ }
9393+9494+ private func readThemeColor() async {
9595+ let js = """
9696+ (function() {
9797+ var m = document.querySelector('meta[name="theme-color"]');
9898+ 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;
104104+ }
105105+ return null;
106106+ })()
107107+ """
108108+ let result = try? await content.evaluateJavaScript(js)
109109+ guard let css = result as? String, !css.isEmpty else {
110110+ self.themeColor = nil
111111+ return
112112+ }
113113+ self.themeColor = PlatformColor.fromCSS(css)
114114+ }
115115+116116+ private func syncState() {
117117+ self.url = content.url
118118+ self.title = content.title
119119+ self.isLoading = content.isLoading
120120+ self.estimatedProgress = content.estimatedProgress
121121+ self.canGoBack = content.canGoBack
122122+ self.canGoForward = content.canGoForward
123123+ 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+ }
129129+ }
130130+131131+ deinit {
132132+ observationTask?.cancel()
133133+ }
134134+}
+100
Sources/MereCore/WindowViewModel.swift
···11+import Foundation
22+import MereKit
33+import Combine
44+55+/// Drives a single browser window: tab list, active tab, engine routing.
66+/// Mirrors ADK2.BrowserApplicationController / ADK2.BrowserController.
77+@MainActor
88+public final class WindowViewModel: ObservableObject {
99+1010+ @Published public private(set) var tabs: [Tab] = []
1111+ @Published public var activeTab: Tab?
1212+ @Published public private(set) var newTabBackgroundColor: PlatformColor?
1313+1414+ private let webkitContext: any BrowserContext
1515+ private let chromiumContext: (any BrowserContext)?
1616+ private let cookieSync: CookieSyncController
1717+ public let adBlock: AdBlockController
1818+ private var activeTabObservation: AnyCancellable?
1919+2020+ public init(
2121+ webkitContext: any BrowserContext,
2222+ chromiumContext: (any BrowserContext)? = nil,
2323+ adBlockEngines: [any AdBlockEngine] = []
2424+ ) {
2525+ self.webkitContext = webkitContext
2626+ self.chromiumContext = chromiumContext
2727+ self.cookieSync = CookieSyncController(
2828+ webkit: webkitContext,
2929+ chromium: chromiumContext
3030+ )
3131+ self.adBlock = AdBlockController(engines: adBlockEngines)
3232+ }
3333+3434+ // MARK: - Tab management
3535+3636+ @discardableResult
3737+ public func openTab(url: URL? = nil, engine: EngineType? = nil) -> Tab {
3838+ // Carry the previous tab's theme color into the new tab page background.
3939+ if url == nil, let current = activeTab, current.url != nil {
4040+ newTabBackgroundColor = current.themeColor
4141+ }
4242+ let resolvedEngine = engine ?? url.map(EngineType.preferred) ?? .webkit
4343+ let context = context(for: resolvedEngine)
4444+ let content = context.makeWebContent()
4545+ let tab = Tab(content: content)
4646+ tabs.append(tab)
4747+ activeTab = tab
4848+ subscribeToActiveTab()
4949+ if let url { tab.loadURL(url) }
5050+ return tab
5151+ }
5252+5353+ public func closeTab(_ tab: Tab) {
5454+ tab.content.close()
5555+ tabs.removeAll { $0.id == tab.id }
5656+ if activeTab?.id == tab.id {
5757+ activeTab = tabs.last
5858+ subscribeToActiveTab()
5959+ }
6060+ }
6161+6262+ public func activateTab(_ tab: Tab) {
6363+ activeTab = tab
6464+ subscribeToActiveTab()
6565+ }
6666+6767+ /// Reopen a tab in the other engine, syncing cookies first.
6868+ public func switchEngine(for tab: Tab) async {
6969+ guard let url = tab.url else { return }
7070+ let newEngine: EngineType = tab.engine == .webkit ? .chromium : .webkit
7171+ guard newEngine == .webkit || chromiumContext != nil else { return }
7272+7373+ await cookieSync.sync(from: tab.engine, url: url)
7474+7575+ let idx = tabs.firstIndex(where: { $0.id == tab.id })
7676+ closeTab(tab)
7777+7878+ let newTab = openTab(url: url, engine: newEngine)
7979+ if let idx {
8080+ tabs.move(fromOffsets: IndexSet(integer: tabs.count - 1), toOffset: idx)
8181+ }
8282+ activeTab = newTab
8383+ subscribeToActiveTab()
8484+ }
8585+8686+ // MARK: - Helpers
8787+8888+ private func subscribeToActiveTab() {
8989+ activeTabObservation = activeTab?.objectWillChange
9090+ .receive(on: RunLoop.main)
9191+ .sink { [weak self] _ in self?.objectWillChange.send() }
9292+ }
9393+9494+ private func context(for engine: EngineType) -> any BrowserContext {
9595+ switch engine {
9696+ case .webkit: return webkitContext
9797+ case .chromium: return chromiumContext ?? webkitContext
9898+ }
9999+ }
100100+}
+39
Sources/MereKit/Mock/MockWebContent.swift
···11+import Foundation
22+33+/// In-process stub used for SwiftUI previews and unit tests.
44+/// Mirrors ADK2.MockWebContent.
55+@MainActor
66+public final class MockWebContent: WebContent {
77+88+ public let id = UUID()
99+ public let engine: EngineType = .webkit
1010+1111+ public var url: URL? = URL(string: "https://example.com")
1212+ public var title: String? = "Example Domain"
1313+ public var isLoading = false
1414+ public var estimatedProgress: Double = 1.0
1515+ public var canGoBack = false
1616+ public var canGoForward = false
1717+ public var hasAudioPlaying = false
1818+ public var isMuted = false
1919+ public var zoomFactor: Double = 1.0
2020+2121+ private let (stream, continuation) = AsyncStream<NavigationEvent>.makeStream()
2222+ public var navigationEvents: AsyncStream<NavigationEvent> { stream }
2323+2424+ public init() {}
2525+2626+ public func loadURL(_ url: URL) { self.url = url }
2727+ public func loadHTML(_ html: String, baseURL: URL?) {}
2828+ public func goBack() {}
2929+ public func goForward() {}
3030+ public func reload() {}
3131+ public func stopLoading() {}
3232+ public func evaluateJavaScript(_ script: String) async throws -> Any? { nil }
3333+ public func findInPage(_ query: String, forward: Bool) async -> FindResult { .init(matchCount: 0, activeMatchIndex: 0) }
3434+ public func clearFind() {}
3535+ public func attachHostView(_ container: PlatformView) {}
3636+ public func detachHostView() {}
3737+ public func snapshot() async -> PlatformImage? { nil }
3838+ public func close() { continuation.finish() }
3939+}
+196
Sources/MereKit/Models/BlockList.swift
···11+import Foundation
22+33+/// A parsed, engine-agnostic block list.
44+public struct BlockList: Sendable {
55+66+ public struct Rule: Sendable {
77+ public enum Action: Sendable { case block, allowList }
88+ public enum ResourceType: String, Sendable, CaseIterable {
99+ case document, script, image, stylesheet = "style-sheet"
1010+ case font, media, raw, svg = "svg-document"
1111+ case xhr = "fetch", websocket, other
1212+ }
1313+1414+ public let urlPattern: String // regex
1515+ public let action: Action
1616+ public let resourceTypes: Set<ResourceType>
1717+ public let ifDomain: [String] // only apply on these domains
1818+ public let unlessDomain: [String] // skip on these domains
1919+2020+ public init(
2121+ urlPattern: String,
2222+ action: Action = .block,
2323+ resourceTypes: Set<ResourceType> = [],
2424+ ifDomain: [String] = [],
2525+ unlessDomain: [String] = []
2626+ ) {
2727+ self.urlPattern = urlPattern
2828+ self.action = action
2929+ self.resourceTypes = resourceTypes
3030+ self.ifDomain = ifDomain
3131+ self.unlessDomain = unlessDomain
3232+ }
3333+ }
3434+3535+ public let name: String
3636+ public let rules: [Rule]
3737+ public let updatedAt: Date
3838+3939+ public init(name: String, rules: [Rule], updatedAt: Date = .now) {
4040+ self.name = name
4141+ self.rules = rules
4242+ self.updatedAt = updatedAt
4343+ }
4444+4545+ /// Total count of blocking rules (excludes allowlist entries).
4646+ public var blockCount: Int { rules.filter { $0.action == .block }.count }
4747+}
4848+4949+// MARK: - EasyList parser
5050+5151+/// Parses a subset of Adblock Plus / EasyList filter syntax into BlockList.Rules.
5252+///
5353+/// Supported syntax:
5454+/// - `||example.com^` → domain anchor
5555+/// - `@@||example.com^` → allowlist
5656+/// - `$script,image` → resource type options
5757+/// - `$domain=foo.com|~bar.com` → domain restrictions
5858+/// - `/regex/` → regex rule
5959+/// - `##` cosmetic rules → ignored (CSS injection, not request blocking)
6060+public enum EasyListParser {
6161+6262+ public static func parse(_ text: String, name: String) -> BlockList {
6363+ var rules: [BlockList.Rule] = []
6464+6565+ for line in text.components(separatedBy: .newlines) {
6666+ let line = line.trimmingCharacters(in: .whitespaces)
6767+6868+ // Skip comments, headers, empty lines, cosmetic rules
6969+ if line.isEmpty || line.hasPrefix("!") || line.hasPrefix("[") || line.contains("##") || line.contains("#@#") {
7070+ continue
7171+ }
7272+7373+ if let rule = parseRule(line) {
7474+ rules.append(rule)
7575+ }
7676+ }
7777+7878+ return BlockList(name: name, rules: rules)
7979+ }
8080+8181+ private static func parseRule(_ line: String) -> BlockList.Rule? {
8282+ var raw = line
8383+ let isAllowList = raw.hasPrefix("@@")
8484+ if isAllowList { raw = String(raw.dropFirst(2)) }
8585+8686+ // Split options after `$`
8787+ var options: [String] = []
8888+ var pattern = raw
8989+ if let dollarIdx = raw.lastIndex(of: "$"), !raw.hasPrefix("/") {
9090+ let optStr = String(raw[raw.index(after: dollarIdx)...])
9191+ // Only treat as options if it looks like options (no spaces, known keywords)
9292+ if !optStr.contains(" ") {
9393+ options = optStr.components(separatedBy: ",")
9494+ pattern = String(raw[..<dollarIdx])
9595+ }
9696+ }
9797+9898+ // Skip purely cosmetic/script-inject options
9999+ let skipOptions = ["elemhide", "generichide", "genericblock", "jsinject", "content", "extension", "stealth"]
100100+ if options.contains(where: { skipOptions.contains($0) }) { return nil }
101101+102102+ // Parse resource types
103103+ var resourceTypes: Set<BlockList.Rule.ResourceType> = []
104104+ var ifDomain: [String] = []
105105+ var unlessDomain: [String] = []
106106+107107+ for opt in options {
108108+ if opt.hasPrefix("domain=") {
109109+ let domains = String(opt.dropFirst(7)).components(separatedBy: "|")
110110+ for d in domains {
111111+ if d.hasPrefix("~") { unlessDomain.append(String(d.dropFirst())) }
112112+ else if !d.isEmpty { ifDomain.append(d) }
113113+ }
114114+ } else if let rt = parseResourceType(opt) {
115115+ resourceTypes.insert(rt)
116116+ }
117117+ }
118118+119119+ // Convert EasyList pattern to regex
120120+ guard let regex = patternToRegex(pattern) else { return nil }
121121+122122+ return BlockList.Rule(
123123+ urlPattern: regex,
124124+ action: isAllowList ? .allowList : .block,
125125+ resourceTypes: resourceTypes,
126126+ ifDomain: ifDomain,
127127+ unlessDomain: unlessDomain
128128+ )
129129+ }
130130+131131+ private static func parseResourceType(_ opt: String) -> BlockList.Rule.ResourceType? {
132132+ switch opt {
133133+ case "script": return .script
134134+ case "image": return .image
135135+ case "stylesheet": return .stylesheet
136136+ case "font": return .font
137137+ case "media": return .media
138138+ case "xmlhttprequest", "fetch": return .xhr
139139+ case "websocket": return .websocket
140140+ case "document": return .document
141141+ case "subdocument": return .raw
142142+ case "other": return .other
143143+ default: return nil
144144+ }
145145+ }
146146+147147+ private static func patternToRegex(_ pattern: String) -> String? {
148148+ // Already a regex
149149+ if pattern.hasPrefix("/") && pattern.hasSuffix("/") {
150150+ let inner = String(pattern.dropFirst().dropLast())
151151+ return inner.isEmpty ? nil : inner
152152+ }
153153+154154+ var p = pattern
155155+156156+ // Domain anchor `||` → match start of host
157157+ let domainAnchor = p.hasPrefix("||")
158158+ if domainAnchor { p = String(p.dropFirst(2)) }
159159+160160+ // Left anchor `|` → start of URL
161161+ let leftAnchor = !domainAnchor && p.hasPrefix("|")
162162+ if leftAnchor { p = String(p.dropFirst()) }
163163+164164+ // Right anchor `|`
165165+ let rightAnchor = p.hasSuffix("|")
166166+ if rightAnchor { p = String(p.dropLast()) }
167167+168168+ if p.isEmpty { return nil }
169169+170170+ // Escape regex metacharacters except `*` and `^`
171171+ var escaped = ""
172172+ for ch in p {
173173+ switch ch {
174174+ case ".", "+", "?", "{", "}", "(", ")", "[", "]", "\\", "$":
175175+ escaped += "\\\(ch)"
176176+ case "*":
177177+ escaped += ".*"
178178+ case "^":
179179+ // Separator — matches any non-word boundary character or end of string
180180+ escaped += "([^a-zA-Z0-9.%-]|$)"
181181+ default:
182182+ escaped += String(ch)
183183+ }
184184+ }
185185+186186+ // Apply anchors
187187+ if domainAnchor {
188188+ escaped = "https?://(www\\.)?" + escaped
189189+ } else if leftAnchor {
190190+ escaped = "^" + escaped
191191+ }
192192+ if rightAnchor { escaped += "$" }
193193+194194+ return escaped
195195+ }
196196+}
+18
Sources/MereKit/Models/EngineType.swift
···11+import Foundation
22+33+public enum EngineType: String, Codable, Sendable {
44+ case webkit
55+ case chromium
66+77+ /// Heuristic: prefer Chromium for known compatibility-sensitive origins.
88+ /// Everything else defaults to WebKit.
99+ public static func preferred(for url: URL) -> EngineType {
1010+ guard let host = url.host else { return .webkit }
1111+ let chromiumHosts = [
1212+ "figma.com", "notion.so", "linear.app",
1313+ "docs.google.com", "sheets.google.com", "slides.google.com",
1414+ "app.diagrams.net",
1515+ ]
1616+ return chromiumHosts.contains(where: { host.hasSuffix($0) }) ? .chromium : .webkit
1717+ }
1818+}
+34
Sources/MereKit/Models/NavigationEvent.swift
···11+import Foundation
22+33+public enum NavigationEvent: Sendable {
44+ case started(url: URL)
55+ case redirected(from: URL, to: URL)
66+ case committed(url: URL)
77+ case finished(url: URL)
88+ case failed(url: URL?, error: Error)
99+ case titleChanged(title: String)
1010+ case faviconChanged(url: URL?)
1111+}
1212+1313+public struct NavigationPolicy: Sendable {
1414+ public enum Action: Sendable {
1515+ case allow
1616+ case cancel
1717+ case redirectTo(URL)
1818+ }
1919+2020+ public let action: Action
2121+2222+ public static let allow = NavigationPolicy(action: .allow)
2323+ public static let cancel = NavigationPolicy(action: .cancel)
2424+}
2525+2626+public struct FindResult: Sendable {
2727+ public let matchCount: Int
2828+ public let activeMatchIndex: Int
2929+3030+ public init(matchCount: Int, activeMatchIndex: Int) {
3131+ self.matchCount = matchCount
3232+ self.activeMatchIndex = activeMatchIndex
3333+ }
3434+}
+49
Sources/MereKit/Protocols/AdBlockEngine.swift
···11+import Foundation
22+33+/// Applies content blocking rules to a browser context.
44+/// Each engine implements this differently:
55+/// - WebKit → WKContentRuleList (compiled bytecode, runs in WebKit process)
66+/// - Chromium → CefRequestHandler (Swift callback per request)
77+@MainActor
88+public protocol AdBlockEngine: AnyObject {
99+1010+ /// Whether blocking is currently active.
1111+ var isEnabled: Bool { get set }
1212+1313+ /// Currently loaded lists and their rule counts.
1414+ var loadedLists: [String: Int] { get }
1515+1616+ /// Load and compile a block list. Replaces any existing list with the same name.
1717+ /// Compilation is async because WebKit's rule list compilation can take ~100-500ms
1818+ /// for large lists (EasyList has ~50k rules).
1919+ func load(_ list: BlockList) async throws
2020+2121+ /// Remove a list by name.
2222+ func remove(listNamed name: String) async
2323+2424+ /// Fetch a list from a URL, parse it, and load it.
2525+ func fetchAndLoad(from url: URL, name: String) async throws
2626+2727+ /// Block count across all loaded lists.
2828+ var totalBlockedRequestCount: Int { get }
2929+}
3030+3131+// MARK: - Default implementation
3232+3333+public extension AdBlockEngine {
3434+ func fetchAndLoad(from url: URL, name: String) async throws {
3535+ let (data, _) = try await URLSession.shared.data(from: url)
3636+ let text = String(decoding: data, as: UTF8.self)
3737+ let list = EasyListParser.parse(text, name: name)
3838+ try await load(list)
3939+ }
4040+}
4141+4242+// MARK: - Well-known list URLs
4343+4444+public enum BlockListSource {
4545+ public static let easyList = URL(string: "https://easylist.to/easylist/easylist.txt")!
4646+ public static let easyPrivacy = URL(string: "https://easylist.to/easylist/easyprivacy.txt")!
4747+ public static let uBlockFilters = URL(string: "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt")!
4848+ public static let peterlowePII = URL(string: "https://raw.githubusercontent.com/peterkliewe/easyprivacy/master/easyprivacy.txt")!
4949+}
+68
Sources/MereKit/Protocols/BrowserContext.swift
···11+import Foundation
22+33+/// Represents a browsing profile: cookies, history, bookmarks, credentials.
44+/// Mirrors ArcBrowserContext / ADK2.BrowserContextController.
55+@MainActor
66+public protocol BrowserContext: AnyObject {
77+88+ var engine: EngineType { get }
99+1010+ // MARK: - WebContent factory
1111+1212+ func makeWebContent() -> any WebContent
1313+1414+ // MARK: - Cookies
1515+1616+ /// Fetch all cookies for a given URL.
1717+ func cookies(for url: URL) async -> [HTTPCookie]
1818+1919+ /// Set cookies into this context's store.
2020+ func setCookies(_ cookies: [HTTPCookie], for url: URL) async
2121+2222+ /// Remove all cookies matching a URL.
2323+ func clearCookies(for url: URL) async
2424+2525+ // MARK: - History (read-only; writes happen automatically on navigation)
2626+2727+ func history(limit: Int) async -> [HistoryItem]
2828+ func clearHistory() async
2929+3030+ // MARK: - Downloads
3131+3232+ var activeDownloads: [DownloadItem] { get }
3333+3434+ // MARK: - Lifecycle
3535+3636+ func close()
3737+}
3838+3939+// MARK: - Supporting types
4040+4141+public struct HistoryItem: Identifiable, Sendable {
4242+ public let id: UUID
4343+ public let url: URL
4444+ public let title: String?
4545+ public let visitedAt: Date
4646+4747+ public init(id: UUID = .init(), url: URL, title: String? = nil, visitedAt: Date = .now) {
4848+ self.id = id
4949+ self.url = url
5050+ self.title = title
5151+ self.visitedAt = visitedAt
5252+ }
5353+}
5454+5555+public struct DownloadItem: Identifiable, Sendable {
5656+ public enum State: Sendable { case inProgress(Double), completed(URL), failed(Error) }
5757+ public let id: UUID
5858+ public let sourceURL: URL
5959+ public let filename: String
6060+ public let state: State
6161+6262+ public init(id: UUID = .init(), sourceURL: URL, filename: String, state: State) {
6363+ self.id = id
6464+ self.sourceURL = sourceURL
6565+ self.filename = filename
6666+ self.state = state
6767+ }
6868+}
+22
Sources/MereKit/Protocols/BrowserEngine.swift
···11+import Foundation
22+33+/// Entry point for each engine. One instance per process.
44+/// Responsible for spinning up the runtime and vending BrowserContexts.
55+public protocol BrowserEngine: AnyObject {
66+77+ var engineType: EngineType { get }
88+99+ /// Whether the engine runtime is currently loaded.
1010+ var isLoaded: Bool { get }
1111+1212+ /// Load the engine runtime. No-op if already loaded.
1313+ /// For WebKit this is essentially free; for CEF it initialises the subprocess infrastructure.
1414+ func load() async throws
1515+1616+ /// Create a new isolated browsing context (profile / cookie jar).
1717+ @MainActor
1818+ func makeContext() -> any BrowserContext
1919+2020+ /// Tear down the engine. Call only on app exit.
2121+ func shutdown()
2222+}
+120
Sources/MereKit/Protocols/WebContent.swift
···11+import Foundation
22+#if canImport(AppKit)
33+import AppKit
44+public typealias PlatformView = NSView
55+public typealias PlatformImage = NSImage
66+public typealias PlatformColor = NSColor
77+#elseif canImport(UIKit)
88+import UIKit
99+public typealias PlatformView = UIView
1010+public typealias PlatformImage = UIImage
1111+public typealias PlatformColor = UIColor
1212+#endif
1313+1414+extension PlatformColor {
1515+ /// Create from a CSS color string — hex (`#rgb`, `#rrggbb`) or `rgb()`/`rgba()`.
1616+ public static func fromCSS(_ value: String) -> PlatformColor? {
1717+ let s = value.trimmingCharacters(in: .whitespaces)
1818+ if s.hasPrefix("#") { return fromHex(s) }
1919+ // rgb(r, g, b) or rgba(r, g, b, a)
2020+ guard s.hasPrefix("rgb") else { return nil }
2121+ let digits = s.drop(while: { $0 != "(" }).dropFirst().prefix(while: { $0 != ")" })
2222+ let parts = digits.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
2323+ guard parts.count >= 3,
2424+ let ri = Double(parts[0]), let gi = Double(parts[1]), let bi = Double(parts[2]) else { return nil }
2525+ let a = parts.count >= 4 ? (Double(parts[3]) ?? 1.0) : 1.0
2626+ return PlatformColor(red: ri / 255, green: gi / 255, blue: bi / 255, alpha: a)
2727+ }
2828+2929+ public static func fromHex(_ hex: String) -> PlatformColor? {
3030+ var s = hex.trimmingCharacters(in: .whitespaces)
3131+ if s.hasPrefix("#") { s.removeFirst() }
3232+ let len = s.count
3333+ guard len == 3 || len == 6 || len == 8,
3434+ let value = UInt64(s, radix: 16) else { return nil }
3535+ let r, g, b, a: CGFloat
3636+ switch len {
3737+ case 3:
3838+ r = CGFloat((value >> 8) & 0xF) / 15
3939+ g = CGFloat((value >> 4) & 0xF) / 15
4040+ b = CGFloat(value & 0xF) / 15
4141+ a = 1
4242+ case 6:
4343+ r = CGFloat((value >> 16) & 0xFF) / 255
4444+ g = CGFloat((value >> 8) & 0xFF) / 255
4545+ b = CGFloat(value & 0xFF) / 255
4646+ a = 1
4747+ default: // 8
4848+ r = CGFloat((value >> 24) & 0xFF) / 255
4949+ g = CGFloat((value >> 16) & 0xFF) / 255
5050+ b = CGFloat((value >> 8) & 0xFF) / 255
5151+ a = CGFloat(value & 0xFF) / 255
5252+ }
5353+ return PlatformColor(red: r, green: g, blue: b, alpha: a)
5454+ }
5555+}
5656+5757+/// The core abstraction over a single browser tab, regardless of engine.
5858+/// Mirrors what Dia calls ArcWebContents / ADK2.WebContentController.
5959+@MainActor
6060+public protocol WebContent: AnyObject {
6161+6262+ // MARK: - Identity
6363+6464+ var id: UUID { get }
6565+ var engine: EngineType { get }
6666+6767+ // MARK: - State
6868+6969+ var url: URL? { get }
7070+ var title: String? { get }
7171+ var isLoading: Bool { get }
7272+ var estimatedProgress: Double { get }
7373+ var canGoBack: Bool { get }
7474+ var canGoForward: Bool { get }
7575+ var hasAudioPlaying: Bool { get }
7676+ var isMuted: Bool { get set }
7777+7878+ // MARK: - Navigation
7979+8080+ func loadURL(_ url: URL)
8181+ func loadHTML(_ html: String, baseURL: URL?)
8282+ func goBack()
8383+ func goForward()
8484+ func reload()
8585+ func stopLoading()
8686+8787+ // MARK: - JavaScript
8888+8989+ @discardableResult
9090+ func evaluateJavaScript(_ script: String) async throws -> Any?
9191+9292+ // MARK: - Find in Page
9393+9494+ func findInPage(_ query: String, forward: Bool) async -> FindResult
9595+ func clearFind()
9696+9797+ // MARK: - View
9898+9999+ /// Attach the engine's native view into the given container.
100100+ /// Call this once after creation; the view fills the container.
101101+ func attachHostView(_ container: PlatformView)
102102+ func detachHostView()
103103+104104+ // MARK: - Zoom
105105+106106+ var zoomFactor: Double { get set }
107107+108108+ // MARK: - Snapshot
109109+110110+ func snapshot() async -> PlatformImage?
111111+112112+ // MARK: - Events
113113+114114+ /// Stream of navigation lifecycle events.
115115+ var navigationEvents: AsyncStream<NavigationEvent> { get }
116116+117117+ // MARK: - Lifecycle
118118+119119+ func close()
120120+}
···11+import SwiftUI
22+import AppKit
33+import MereCore
44+import MereKit
55+66+public struct BrowserWindowView: View {
77+88+ @StateObject var window: WindowViewModel
99+ @State private var sidebarVisible = true
1010+ // Incrementing this tells whichever address bar is visible to take focus.
1111+ @State private var addressFocusTrigger = 0
1212+1313+ public init(window: WindowViewModel) {
1414+ _window = StateObject(wrappedValue: window)
1515+ }
1616+1717+ private var isNewTab: Bool {
1818+ window.activeTab == nil || (window.activeTab?.url == nil && window.activeTab?.isLoading == false)
1919+ }
2020+2121+ /// Returns the color scheme that gives readable contrast against `color`.
2222+ private func scheme(for color: NSColor) -> ColorScheme {
2323+ guard let rgb = color.usingColorSpace(.deviceRGB) else { return .light }
2424+ let r = rgb.redComponent, g = rgb.greenComponent, b = rgb.blueComponent
2525+ let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b
2626+ return luma > 0.5 ? .light : .dark
2727+ }
2828+2929+ private var tintColor: Color {
3030+ if isNewTab {
3131+ return window.newTabBackgroundColor.map { Color(nsColor: $0) }
3232+ ?? Color(nsColor: .windowBackgroundColor)
3333+ }
3434+ if let tc = window.activeTab?.themeColor { return Color(nsColor: tc) }
3535+ return Color(nsColor: .windowBackgroundColor)
3636+ }
3737+3838+ private var preferredScheme: ColorScheme? {
3939+ if let tc = window.activeTab?.themeColor { return scheme(for: tc) }
4040+ if isNewTab, let bg = window.newTabBackgroundColor { return scheme(for: bg) }
4141+ return nil
4242+ }
4343+4444+ public var body: some View {
4545+ ZStack {
4646+ // Full-window color gradient using the raw page background colour.
4747+ LinearGradient(
4848+ stops: [
4949+ .init(color: tintColor.opacity(0.95), location: 0),
5050+ .init(color: tintColor.opacity(0.70), location: 1),
5151+ ],
5252+ startPoint: .top,
5353+ endPoint: .bottom
5454+ )
5555+ .ignoresSafeArea()
5656+ .animation(.easeInOut(duration: 0.35), value: isNewTab)
5757+5858+ HStack(spacing: 0) {
5959+ if sidebarVisible {
6060+ SidebarView(window: window)
6161+ .frame(width: 220)
6262+ .background(.ultraThinMaterial)
6363+ .transition(.move(edge: .leading).combined(with: .opacity))
6464+ }
6565+6666+ VStack(spacing: 0) {
6767+ // Toolbar: transparent background — window gradient shows through.
6868+ BrowserToolbarView(
6969+ window: window,
7070+ sidebarVisible: $sidebarVisible,
7171+ focusTrigger: addressFocusTrigger
7272+ )
7373+ .padding(.top, 8)
7474+ .padding(.leading, sidebarVisible ? 10 : 86)
7575+ .padding(.trailing, 10)
7676+ .padding(.bottom, 8)
7777+7878+ // Content: material matching sidebar, fills all remaining space.
7979+ contentArea
8080+ .frame(maxWidth: .infinity, maxHeight: .infinity)
8181+ .background(.ultraThinMaterial)
8282+ .clipShape(UnevenRoundedRectangle(
8383+ topLeadingRadius: 0,
8484+ bottomLeadingRadius: 10,
8585+ bottomTrailingRadius: 10,
8686+ topTrailingRadius: 0
8787+ ))
8888+ .overlay(
8989+ UnevenRoundedRectangle(
9090+ topLeadingRadius: 0,
9191+ bottomLeadingRadius: 10,
9292+ bottomTrailingRadius: 10,
9393+ topTrailingRadius: 0
9494+ )
9595+ .strokeBorder(Color(nsColor: .separatorColor).opacity(0.35), lineWidth: 0.5)
9696+ )
9797+ .padding(.horizontal, 8)
9898+ .padding(.bottom, 8)
9999+ }
100100+ .ignoresSafeArea(edges: .top)
101101+ }
102102+ }
103103+ .animation(.spring(duration: 0.22), value: sidebarVisible)
104104+ .background(keyboardShortcuts)
105105+ .background(TrafficLightNudge(xOffset: 8, yOffset: 8))
106106+ .preferredColorScheme(preferredScheme)
107107+ .onChange(of: window.activeTab?.id) { _, _ in
108108+ if window.activeTab?.url == nil {
109109+ addressFocusTrigger += 1
110110+ } else {
111111+ NSApp.keyWindow?.makeFirstResponder(nil)
112112+ }
113113+ }
114114+ }
115115+116116+ private var keyboardShortcuts: some View {
117117+ Group {
118118+ Button("") { sidebarVisible.toggle() }
119119+ .keyboardShortcut("s", modifiers: .command)
120120+ Button("") { window.openTab() }
121121+ .keyboardShortcut("t", modifiers: .command)
122122+ Button("") {
123123+ if let tab = window.activeTab { window.closeTab(tab) }
124124+ }
125125+ .keyboardShortcut("w", modifiers: .command)
126126+ Button("") { window.activeTab?.reload() }
127127+ .keyboardShortcut("r", modifiers: .command)
128128+ Button("") { addressFocusTrigger += 1 }
129129+ .keyboardShortcut("l", modifiers: .command)
130130+ }
131131+ .frame(width: 0, height: 0)
132132+ .opacity(0)
133133+ }
134134+135135+ @ViewBuilder
136136+ private var contentArea: some View {
137137+ if let tab = window.activeTab, tab.url != nil || tab.isLoading {
138138+ WebContentView(content: tab.content)
139139+ .id(tab.id)
140140+ } else {
141141+ NewTabView(hasBackground: window.newTabBackgroundColor != nil)
142142+ }
143143+ }
144144+}
145145+146146+// MARK: - New Tab Page
147147+148148+struct NewTabView: View {
149149+ let hasBackground: Bool
150150+151151+ var body: some View {
152152+ Text("mere")
153153+ .font(.system(size: 52, weight: .ultraLight, design: .rounded))
154154+ .foregroundStyle(.primary)
155155+ .tracking(10)
156156+ .frame(maxWidth: .infinity, maxHeight: .infinity)
157157+ }
158158+}
159159+160160+// MARK: - Sidebar
161161+162162+struct SidebarView: View {
163163+ @ObservedObject var window: WindowViewModel
164164+165165+ var body: some View {
166166+ GlassEffectContainer {
167167+ ScrollView(.vertical, showsIndicators: false) {
168168+ VStack(spacing: 1) {
169169+ ForEach(window.tabs) { tab in
170170+ SidebarTabRow(
171171+ tab: tab as MereCore.Tab,
172172+ isActive: window.activeTab?.id == tab.id,
173173+ onActivate: { window.activateTab(tab) },
174174+ onClose: { window.closeTab(tab) }
175175+ )
176176+ }
177177+ }
178178+ .padding(.top, 8)
179179+ .padding(.bottom, 6)
180180+ .padding(.horizontal, 8)
181181+ }
182182+ }
183183+ }
184184+}
185185+186186+struct SidebarTabRow: View {
187187+ @ObservedObject var tab: MereCore.Tab
188188+ let isActive: Bool
189189+ let onActivate: () -> Void
190190+ let onClose: () -> Void
191191+ @State private var isHovered = false
192192+193193+ var body: some View {
194194+ rowContent
195195+ .background(
196196+ RoundedRectangle(cornerRadius: 8)
197197+ .fill(Color(nsColor: .labelColor).opacity(isActive ? 0.08 : isHovered ? 0.04 : 0))
198198+ )
199199+ }
200200+201201+ private var rowContent: some View {
202202+ HStack(spacing: 9) {
203203+ FaviconView(url: tab.favicon, engine: tab.engine)
204204+ .frame(width: 14, height: 14)
205205+206206+ Text(tab.title ?? tab.url?.host ?? "New Tab")
207207+ .lineLimit(1)
208208+ .font(.system(size: 13))
209209+ .foregroundStyle(isActive ? .primary : .secondary)
210210+211211+ Spacer(minLength: 0)
212212+213213+ // Always reserve space; only visible on hover or when active.
214214+ Button { onClose() } label: {
215215+ Image(systemName: "xmark")
216216+ .font(.system(size: 8, weight: .semibold))
217217+ .frame(width: 14, height: 14)
218218+ .foregroundStyle(.secondary)
219219+ }
220220+ .buttonStyle(.plain)
221221+ .opacity(isHovered || isActive ? 1 : 0)
222222+ }
223223+ .padding(.horizontal, 10)
224224+ .padding(.vertical, 7)
225225+ .contentShape(RoundedRectangle(cornerRadius: 8))
226226+ .onTapGesture { onActivate() }
227227+ .onHover { isHovered = $0 }
228228+ }
229229+}
230230+231231+// MARK: - Favicon
232232+233233+private final class FaviconCache {
234234+ static let shared = FaviconCache()
235235+ private var store: [URL: NSImage] = [:]
236236+ private let queue = DispatchQueue(label: "sh.dunkirk.mere.favicon-cache")
237237+238238+ func image(for url: URL) -> NSImage? {
239239+ queue.sync { store[url] }
240240+ }
241241+242242+ func store(_ image: NSImage, for url: URL) {
243243+ queue.async { self.store[url] = image }
244244+ }
245245+}
246246+247247+struct FaviconView: View {
248248+ let url: URL?
249249+ let engine: EngineType
250250+ @State private var image: NSImage? = nil
251251+252252+ var body: some View {
253253+ Group {
254254+ if let image {
255255+ Image(nsImage: image)
256256+ .resizable()
257257+ .scaledToFit()
258258+ .clipShape(RoundedRectangle(cornerRadius: 3))
259259+ } else {
260260+ Circle()
261261+ .fill(engine == .webkit ? Color.blue.opacity(0.7) : Color.orange.opacity(0.7))
262262+ .frame(width: 8, height: 8)
263263+ .frame(maxWidth: .infinity, maxHeight: .infinity)
264264+ }
265265+ }
266266+ .task(id: url) {
267267+ guard let url else { image = nil; return }
268268+ if let cached = FaviconCache.shared.image(for: url) {
269269+ image = cached
270270+ return
271271+ }
272272+ guard let (data, _) = try? await URLSession.shared.data(from: url),
273273+ let loaded = NSImage(data: data) else { return }
274274+ FaviconCache.shared.store(loaded, for: url)
275275+ image = loaded
276276+ }
277277+ }
278278+}
279279+280280+// MARK: - Toolbar
281281+282282+struct HoverButtonStyle: ButtonStyle {
283283+ var disabled: Bool = false
284284+ @State private var isHovered = false
285285+286286+ func makeBody(configuration: Configuration) -> some View {
287287+ configuration.label
288288+ .foregroundStyle(disabled ? .tertiary : isHovered ? .primary : .secondary)
289289+ .background(
290290+ RoundedRectangle(cornerRadius: 6)
291291+ .fill(Color(nsColor: .labelColor)
292292+ .opacity(configuration.isPressed ? 0.12 : isHovered ? 0.07 : 0))
293293+ )
294294+ .animation(.easeInOut(duration: 0.12), value: isHovered)
295295+ .animation(.easeInOut(duration: 0.08), value: configuration.isPressed)
296296+ .onHover { isHovered = $0 }
297297+ }
298298+}
299299+300300+struct BrowserToolbarView: View {
301301+ @ObservedObject var window: WindowViewModel
302302+ @Binding var sidebarVisible: Bool
303303+ let focusTrigger: Int
304304+ @State private var addressText = ""
305305+ @State private var addressBarHovered = false
306306+307307+ var body: some View {
308308+ HStack(spacing: 4) {
309309+ navIcon("sidebar.left") {
310310+ sidebarVisible.toggle()
311311+ }
312312+313313+ navIcon("chevron.left", disabled: window.activeTab?.canGoBack != true) {
314314+ window.activeTab?.goBack()
315315+ }
316316+ navIcon("chevron.right", disabled: window.activeTab?.canGoForward != true) {
317317+ window.activeTab?.goForward()
318318+ }
319319+320320+ AddressBar(text: $addressText, focusTrigger: focusTrigger, onSubmit: navigate)
321321+ .frame(maxWidth: .infinity, minHeight: 22)
322322+ .padding(.leading, 10)
323323+ .padding(.trailing, 4)
324324+ .padding(.vertical, 5)
325325+ .onChange(of: window.activeTab?.url) { _, url in
326326+ addressText = url?.absoluteString ?? ""
327327+ }
328328+ .overlay(alignment: .trailing) {
329329+ if let url = window.activeTab?.url, addressBarHovered {
330330+ Button {
331331+ NSPasteboard.general.clearContents()
332332+ NSPasteboard.general.setString(url.absoluteString, forType: .string)
333333+ } label: {
334334+ Image(systemName: "link")
335335+ .font(.system(size: 11, weight: .medium))
336336+ .frame(width: 28, height: 28)
337337+ }
338338+ .buttonStyle(HoverButtonStyle())
339339+ .help("Copy URL")
340340+ .transition(.opacity.animation(.easeInOut(duration: 0.12)))
341341+ }
342342+ }
343343+ .background(
344344+ RoundedRectangle(cornerRadius: 7)
345345+ .fill(Color(nsColor: .controlBackgroundColor)
346346+ .opacity(addressBarHovered ? 0.68 : 0.55))
347347+ .overlay(
348348+ RoundedRectangle(cornerRadius: 7)
349349+ .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 0.5)
350350+ )
351351+ )
352352+ .animation(.easeInOut(duration: 0.15), value: addressBarHovered)
353353+354354+ navIcon("arrow.clockwise") { window.activeTab?.reload() }
355355+356356+ if let tab = window.activeTab {
357357+ Text(tab.engine == .webkit ? "WK" : "CR")
358358+ .font(.system(size: 10, weight: .semibold, design: .monospaced))
359359+ .foregroundStyle(.secondary)
360360+ .padding(.horizontal, 5)
361361+ .padding(.vertical, 2)
362362+ .background(Color(nsColor: .separatorColor).opacity(0.3),
363363+ in: RoundedRectangle(cornerRadius: 4))
364364+ }
365365+ }
366366+ }
367367+368368+ private func navIcon(_ name: String, disabled: Bool = false, action: @escaping () -> Void) -> some View {
369369+ Button(action: action) {
370370+ Image(systemName: name)
371371+ .font(.system(size: 13, weight: .medium))
372372+ .frame(width: 26, height: 26)
373373+ }
374374+ .buttonStyle(HoverButtonStyle(disabled: disabled))
375375+ .disabled(disabled)
376376+ }
377377+378378+ private func navigate() {
379379+ let raw = addressText.trimmingCharacters(in: .whitespaces)
380380+ guard !raw.isEmpty else { return }
381381+ let url: URL
382382+ if raw.contains(".") && !raw.contains(" "),
383383+ let u = URL(string: raw.hasPrefix("http") ? raw : "https://\(raw)") {
384384+ url = u
385385+ } else {
386386+ url = URL(string: "https://s.dunkirk.sh?q=\(raw.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")")!
387387+ }
388388+ if let tab = window.activeTab {
389389+ tab.loadURL(url)
390390+ } else {
391391+ window.openTab(url: url)
392392+ }
393393+ }
394394+}
395395+396396+// MARK: - Address bar (NSViewRepresentable)
397397+// SwiftUI TextField + @FocusState is unreliable on macOS for makeFirstResponder.
398398+// NSTextField gives us direct control over focus and avoids the focus-ring highlight.
399399+400400+struct AddressBar: NSViewRepresentable {
401401+ @Binding var text: String
402402+ var focusTrigger: Int
403403+ var onSubmit: () -> Void
404404+405405+ func makeNSView(context: Context) -> NSTextField {
406406+ let f = NSTextField()
407407+ f.placeholderString = "Search or enter URL"
408408+ f.isBordered = false
409409+ f.drawsBackground = false
410410+ f.focusRingType = .none
411411+ f.font = .systemFont(ofSize: 13)
412412+ f.cell?.isScrollable = true
413413+ f.cell?.wraps = false
414414+ f.alignment = .center
415415+ f.delegate = context.coordinator
416416+ return f
417417+ }
418418+419419+ func updateNSView(_ nsView: NSTextField, context: Context) {
420420+ context.coordinator.parent = self
421421+ if !context.coordinator.isEditing, context.coordinator.lastDisplayedURL != text {
422422+ context.coordinator.lastDisplayedURL = text
423423+ if let attr = prettyAttributed(text) {
424424+ nsView.attributedStringValue = attr
425425+ } else {
426426+ nsView.stringValue = text
427427+ }
428428+ }
429429+ if context.coordinator.lastTrigger != focusTrigger {
430430+ context.coordinator.lastTrigger = focusTrigger
431431+ context.coordinator.focusGeneration += 1
432432+ let gen = context.coordinator.focusGeneration
433433+ DispatchQueue.main.async { [weak coordinator = context.coordinator] in
434434+ guard coordinator?.focusGeneration == gen else { return }
435435+ nsView.window?.makeFirstResponder(nsView)
436436+ nsView.currentEditor()?.selectAll(nil)
437437+ }
438438+ }
439439+ }
440440+441441+ func makeCoordinator() -> Coordinator { Coordinator(self) }
442442+443443+ /// Returns an attributed string showing `host` in medium tone and `/path` in muted tone.
444444+ func prettyAttributed(_ urlString: String) -> NSAttributedString? {
445445+ guard !urlString.isEmpty,
446446+ let url = URL(string: urlString),
447447+ let host = url.host else { return nil }
448448+ let font = NSFont.systemFont(ofSize: 13)
449449+ let para = NSMutableParagraphStyle()
450450+ para.alignment = .center
451451+ let hostAttr: [NSAttributedString.Key: Any] = [
452452+ .font: font,
453453+ .foregroundColor: NSColor.labelColor.withAlphaComponent(0.65),
454454+ .paragraphStyle: para,
455455+ ]
456456+ let pathAttr: [NSAttributedString.Key: Any] = [
457457+ .font: font,
458458+ .foregroundColor: NSColor.labelColor.withAlphaComponent(0.32),
459459+ .paragraphStyle: para,
460460+ ]
461461+ let result = NSMutableAttributedString(string: host, attributes: hostAttr)
462462+ let path = url.path
463463+ let query = url.query.map { "?\($0)" } ?? ""
464464+ let suffix = (path == "/" || path.isEmpty ? "" : path) + query
465465+ if !suffix.isEmpty {
466466+ result.append(NSAttributedString(string: suffix, attributes: pathAttr))
467467+ }
468468+ return result
469469+ }
470470+471471+ final class Coordinator: NSObject, NSTextFieldDelegate {
472472+ var parent: AddressBar
473473+ var lastTrigger: Int
474474+ var focusGeneration = 0
475475+ var lastDisplayedURL: String = ""
476476+ var isEditing = false
477477+478478+ init(_ parent: AddressBar) {
479479+ self.parent = parent
480480+ self.lastTrigger = parent.focusTrigger
481481+ }
482482+483483+ func controlTextDidChange(_ obj: Notification) {
484484+ guard let field = obj.object as? NSTextField else { return }
485485+ parent.text = field.stringValue
486486+ }
487487+488488+ func control(_ control: NSControl, textView: NSTextView,
489489+ doCommandBy selector: Selector) -> Bool {
490490+ if selector == #selector(NSResponder.insertNewline(_:)) {
491491+ focusGeneration += 1 // cancel any pending focus-and-select
492492+ parent.onSubmit()
493493+ DispatchQueue.main.async { control.window?.makeFirstResponder(nil) }
494494+ return true
495495+ }
496496+ return false
497497+ }
498498+499499+ func controlTextDidBeginEditing(_ obj: Notification) {
500500+ isEditing = true
501501+ if let tv = (obj.object as? NSTextField)?.currentEditor() as? NSTextView {
502502+ tv.insertionPointColor = .labelColor
503503+ }
504504+ }
505505+506506+ func controlTextDidEndEditing(_ obj: Notification) {
507507+ isEditing = false
508508+ if let field = obj.object as? NSTextField {
509509+ lastDisplayedURL = "" // force re-render of pretty URL
510510+ if let attr = parent.prettyAttributed(parent.text) {
511511+ field.attributedStringValue = attr
512512+ } else {
513513+ field.stringValue = parent.text
514514+ }
515515+ }
516516+ }
517517+ }
518518+}
519519+520520+// MARK: - Traffic light repositioning
521521+522522+/// Zero-size view that moves the window's traffic-light buttons down by `yOffset` points
523523+/// so they vertically align with the toolbar icon row.
524524+private struct TrafficLightNudge: NSViewRepresentable {
525525+ let xOffset: CGFloat
526526+ let yOffset: CGFloat
527527+528528+ func makeNSView(context: Context) -> _View { _View() }
529529+530530+ func updateNSView(_ nsView: _View, context: Context) {
531531+ let x = xOffset, y = yOffset
532532+ // Defer until after AppKit's own layout pass resets button frames.
533533+ DispatchQueue.main.async { nsView.apply(xOffset: x, yOffset: y) }
534534+ }
535535+536536+ final class _View: NSView {
537537+ private var baseOrigins: [NSWindow.ButtonType: NSPoint] = [:]
538538+ private static let types: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton]
539539+540540+ required init?(coder: NSCoder) { fatalError() }
541541+ init() { super.init(frame: .zero) }
542542+543543+ func apply(xOffset: CGFloat, yOffset: CGFloat) {
544544+ guard let window else { return }
545545+ // Lazily capture default origins the first time we have a window.
546546+ if baseOrigins.isEmpty {
547547+ for type in Self.types {
548548+ if let btn = window.standardWindowButton(type) {
549549+ baseOrigins[type] = btn.frame.origin
550550+ }
551551+ }
552552+ }
553553+ for type in Self.types {
554554+ guard let btn = window.standardWindowButton(type),
555555+ let base = baseOrigins[type] else { continue }
556556+ btn.setFrameOrigin(NSPoint(x: base.x + xOffset, y: base.y - yOffset))
557557+ }
558558+ }
559559+ }
560560+}
+27
Sources/MereUI/WebContentView.swift
···11+import SwiftUI
22+import AppKit
33+import MereKit
44+55+/// Hosts the engine's native NSView inside SwiftUI.
66+/// Works identically for WebKit and Chromium tabs.
77+public struct WebContentView: NSViewRepresentable {
88+99+ let content: any WebContent
1010+1111+ public init(content: any WebContent) {
1212+ self.content = content
1313+ }
1414+1515+ public func makeNSView(context: Context) -> NSView {
1616+ let container = NSView()
1717+ container.wantsLayer = true
1818+ content.attachHostView(container)
1919+ return container
2020+ }
2121+2222+ public func updateNSView(_ nsView: NSView, context: Context) {}
2323+2424+ public static func dismantleNSView(_ nsView: NSView, coordinator: ()) {
2525+ // WebContent.close() is called by the Tab when removed from WindowViewModel
2626+ }
2727+}
+106
Sources/WebKitEngine/WebKitAdBlocker.swift
···11+import Foundation
22+import WebKit
33+import MereKit
44+55+/// Ad blocker for WebKit using WKContentRuleListStore.
66+///
77+/// How it works:
88+/// - Converts BlockList rules → Apple's content blocker JSON format
99+/// - Compiles them into a WKContentRuleList (bytecode, runs inside WebKit — no Swift
1010+/// callbacks per request, zero performance overhead)
1111+/// - Applies the compiled list to the shared WKWebViewConfiguration so all
1212+/// WebKitWebContent instances in this context are blocked automatically
1313+@MainActor
1414+public final class WebKitAdBlocker: AdBlockEngine {
1515+1616+ public var isEnabled: Bool = true {
1717+ didSet { Task { await applyToConfiguration() } }
1818+ }
1919+2020+ public private(set) var loadedLists: [String: Int] = [:]
2121+ public private(set) var totalBlockedRequestCount = 0
2222+2323+ private let store: WKContentRuleListStore
2424+ private let configuration: WKWebViewConfiguration
2525+ private var compiledLists: [String: WKContentRuleList] = [:]
2626+2727+ /// `store` is keyed to a directory so compiled bytecode survives app restarts.
2828+ public init(configuration: WKWebViewConfiguration, storageURL: URL? = nil) {
2929+ self.configuration = configuration
3030+ self.store = storageURL.map { WKContentRuleListStore(url: $0) }
3131+ ?? .default()
3232+ }
3333+3434+ // MARK: - AdBlockEngine
3535+3636+ public func load(_ list: BlockList) async throws {
3737+ let json = try appleContentBlockerJSON(from: list)
3838+ let compiled: WKContentRuleList = try await withCheckedThrowingContinuation { continuation in
3939+ store.compileContentRuleList(forIdentifier: list.name, encodedContentRuleList: json) { result, error in
4040+ if let error { continuation.resume(throwing: error) }
4141+ else if let result { continuation.resume(returning: result) }
4242+ else { continuation.resume(throwing: ContentBlockerError.compilationFailed) }
4343+ }
4444+ }
4545+ compiledLists[list.name] = compiled
4646+ loadedLists[list.name] = list.blockCount
4747+ await applyToConfiguration()
4848+ }
4949+5050+ public func remove(listNamed name: String) async {
5151+ compiledLists.removeValue(forKey: name)
5252+ loadedLists.removeValue(forKey: name)
5353+ store.removeContentRuleList(forIdentifier: name) { _ in }
5454+ await applyToConfiguration()
5555+ }
5656+5757+ // MARK: - Private
5858+5959+ private func applyToConfiguration() async {
6060+ let controller = configuration.userContentController
6161+ controller.removeAllContentRuleLists()
6262+ guard isEnabled else { return }
6363+ for list in compiledLists.values {
6464+ controller.add(list)
6565+ }
6666+ }
6767+6868+ // MARK: - JSON conversion
6969+7070+ /// Converts our engine-agnostic rules to Apple's content blocker JSON format.
7171+ /// Spec: https://webkit.org/blog/3476/content-blockers-first-look/
7272+ private func appleContentBlockerJSON(from list: BlockList) throws -> String {
7373+ var entries: [[String: Any]] = []
7474+7575+ for rule in list.rules {
7676+ var trigger: [String: Any] = ["url-filter": rule.urlPattern]
7777+7878+ if !rule.resourceTypes.isEmpty {
7979+ trigger["resource-type"] = rule.resourceTypes.map { $0.rawValue }
8080+ }
8181+ if !rule.ifDomain.isEmpty {
8282+ trigger["if-domain"] = rule.ifDomain.map { "*\($0)" }
8383+ }
8484+ if !rule.unlessDomain.isEmpty {
8585+ trigger["unless-domain"] = rule.unlessDomain.map { "*\($0)" }
8686+ }
8787+8888+ let action: [String: Any] = switch rule.action {
8989+ case .block: ["type": "block"]
9090+ case .allowList: ["type": "ignore-previous-rules"]
9191+ }
9292+9393+ entries.append(["trigger": trigger, "action": action])
9494+9595+ // WKContentRuleList has a hard cap of 150k rules per list
9696+ if entries.count >= 149_000 { break }
9797+ }
9898+9999+ let data = try JSONSerialization.data(withJSONObject: entries)
100100+ return String(decoding: data, as: UTF8.self)
101101+ }
102102+}
103103+104104+enum ContentBlockerError: Error {
105105+ case compilationFailed
106106+}
+68
Sources/WebKitEngine/WebKitBrowserContext.swift
···11+import Foundation
22+import WebKit
33+import MereKit
44+55+/// BrowserContext backed by a WKWebsiteDataStore.
66+@MainActor
77+public final class WebKitBrowserContext: BrowserContext {
88+99+ public let engine: EngineType = .webkit
1010+1111+ private let dataStore: WKWebsiteDataStore
1212+ private let sharedConfiguration: WKWebViewConfiguration
1313+ private var _activeDownloads: [DownloadItem] = []
1414+ public let adBlocker: WebKitAdBlocker
1515+1616+ public init(persistent: Bool = true) {
1717+ self.dataStore = persistent ? .default() : .nonPersistent()
1818+ let config = WKWebViewConfiguration()
1919+ config.websiteDataStore = dataStore
2020+ config.preferences.isElementFullscreenEnabled = true
2121+ self.sharedConfiguration = config
2222+ self.adBlocker = WebKitAdBlocker(configuration: config)
2323+ }
2424+2525+ // MARK: - BrowserContext
2626+2727+ public func makeWebContent() -> any WebContent {
2828+ WebKitWebContent(configuration: sharedConfiguration)
2929+ }
3030+3131+ public func cookies(for url: URL) async -> [HTTPCookie] {
3232+ await dataStore.httpCookieStore.allCookies().filter { cookie in
3333+ url.host?.hasSuffix(cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain) ?? false
3434+ }
3535+ }
3636+3737+ public func setCookies(_ cookies: [HTTPCookie], for url: URL) async {
3838+ for cookie in cookies {
3939+ await dataStore.httpCookieStore.setCookie(cookie)
4040+ }
4141+ }
4242+4343+ public func clearCookies(for url: URL) async {
4444+ let existing = await cookies(for: url)
4545+ for cookie in existing {
4646+ await dataStore.httpCookieStore.deleteCookie(cookie)
4747+ }
4848+ }
4949+5050+ public func history(limit: Int) async -> [HistoryItem] {
5151+ // WKWebView doesn't expose browsing history via public API.
5252+ // Must be tracked manually — see SessionController.
5353+ return []
5454+ }
5555+5656+ public func clearHistory() async {
5757+ await dataStore.removeData(
5858+ ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
5959+ modifiedSince: .distantPast
6060+ )
6161+ }
6262+6363+ public var activeDownloads: [DownloadItem] { _activeDownloads }
6464+6565+ public func close() {
6666+ _activeDownloads.removeAll()
6767+ }
6868+}
+24
Sources/WebKitEngine/WebKitEngine.swift
···11+import Foundation
22+import MereKit
33+44+/// Lightweight WebKit engine — runtime is always available, load() is a no-op.
55+public final class WebKitEngine: BrowserEngine {
66+77+ public static let shared = WebKitEngine()
88+99+ public let engineType: EngineType = .webkit
1010+ public private(set) var isLoaded = true
1111+1212+ private init() {}
1313+1414+ public func load() async throws {
1515+ // WebKit is always available — nothing to initialise.
1616+ }
1717+1818+ @MainActor
1919+ public func makeContext() -> any BrowserContext {
2020+ WebKitBrowserContext()
2121+ }
2222+2323+ public func shutdown() {}
2424+}
+192
Sources/WebKitEngine/WebKitWebContent.swift
···11+import Foundation
22+import AppKit
33+import WebKit
44+import MereKit
55+66+/// WebContent backed by WKWebView.
77+@MainActor
88+public final class WebKitWebContent: NSObject, WebContent {
99+1010+ public let id = UUID()
1111+ public let engine: EngineType = .webkit
1212+1313+ // MARK: - Public state (KVO-observed from WKWebView)
1414+1515+ public private(set) var url: URL?
1616+ public private(set) var title: String?
1717+ public private(set) var isLoading = false
1818+ public private(set) var estimatedProgress: Double = 0
1919+ public private(set) var canGoBack = false
2020+ public private(set) var canGoForward = false
2121+ public private(set) var hasAudioPlaying = false
2222+2323+ // WKWebView gained isMuted in macOS 14 but it's on WKWebViewConfiguration.mediaTypesRequiringUserActionForPlayback,
2424+ // not directly on the view. Track manually.
2525+ public var isMuted: Bool = false {
2626+ didSet { webView.configuration.mediaTypesRequiringUserActionForPlayback = isMuted ? .all : [] }
2727+ }
2828+2929+ public var zoomFactor: Double {
3030+ get { webView.pageZoom }
3131+ set { webView.pageZoom = newValue }
3232+ }
3333+3434+ // MARK: - Navigation events
3535+3636+ private let eventContinuation: AsyncStream<NavigationEvent>.Continuation
3737+ public let navigationEvents: AsyncStream<NavigationEvent>
3838+3939+ // MARK: - Internals
4040+4141+ let webView: WKWebView
4242+ private var observations: [NSKeyValueObservation] = []
4343+4444+ // MARK: - Init
4545+4646+ public init(configuration: WKWebViewConfiguration = .init()) {
4747+ let (stream, continuation) = AsyncStream<NavigationEvent>.makeStream()
4848+ self.navigationEvents = stream
4949+ self.eventContinuation = continuation
5050+5151+ self.webView = WKWebView(frame: .zero, configuration: configuration)
5252+ self.webView.allowsBackForwardNavigationGestures = true
5353+5454+ super.init()
5555+5656+ webView.navigationDelegate = self
5757+ webView.uiDelegate = self
5858+ observeWebViewProperties()
5959+ }
6060+6161+ // MARK: - WebContent
6262+6363+ public func loadURL(_ url: URL) {
6464+ webView.load(URLRequest(url: url))
6565+ }
6666+6767+ public func loadHTML(_ html: String, baseURL: URL?) {
6868+ webView.loadHTMLString(html, baseURL: baseURL)
6969+ }
7070+7171+ public func goBack() { webView.goBack() }
7272+ public func goForward() { webView.goForward() }
7373+ public func reload() { webView.reload() }
7474+ public func stopLoading() { webView.stopLoading() }
7575+7676+ public func evaluateJavaScript(_ script: String) async throws -> Any? {
7777+ try await webView.evaluateJavaScript(script)
7878+ }
7979+8080+ public func findInPage(_ query: String, forward: Bool) async -> FindResult {
8181+ // WKWebView doesn't expose find results count natively; use JS fallback.
8282+ let js = """
8383+ (function() {
8484+ window.getSelection().removeAllRanges();
8585+ return window.find('\(query.replacingOccurrences(of: "'", with: "\\'"))',
8686+ false, \(!forward), false, false, true);
8787+ })()
8888+ """
8989+ _ = try? await webView.evaluateJavaScript(js)
9090+ return FindResult(matchCount: -1, activeMatchIndex: -1) // WKWebView limitation
9191+ }
9292+9393+ public func clearFind() {
9494+ Task { _ = try? await webView.evaluateJavaScript("window.getSelection().removeAllRanges()") }
9595+ }
9696+9797+ public func attachHostView(_ container: NSView) {
9898+ webView.translatesAutoresizingMaskIntoConstraints = false
9999+ container.addSubview(webView)
100100+ NSLayoutConstraint.activate([
101101+ webView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
102102+ webView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
103103+ webView.topAnchor.constraint(equalTo: container.topAnchor),
104104+ webView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
105105+ ])
106106+ }
107107+108108+ public func detachHostView() {
109109+ webView.removeFromSuperview()
110110+ }
111111+112112+ public func snapshot() async -> NSImage? {
113113+ let config = WKSnapshotConfiguration()
114114+ return try? await webView.takeSnapshot(configuration: config)
115115+ }
116116+117117+ public func close() {
118118+ observations.forEach { $0.invalidate() }
119119+ observations.removeAll()
120120+ webView.navigationDelegate = nil
121121+ webView.uiDelegate = nil
122122+ detachHostView()
123123+ eventContinuation.finish()
124124+ }
125125+126126+ // MARK: - KVO
127127+128128+ private func observeWebViewProperties() {
129129+ observations = [
130130+ webView.observe(\.url, options: [.new]) { [weak self] wv, _ in
131131+ Task { @MainActor in self?.url = wv.url }
132132+ },
133133+ webView.observe(\.title, options: [.new]) { [weak self] wv, _ in
134134+ Task { @MainActor in
135135+ self?.title = wv.title
136136+ if let t = wv.title { self?.eventContinuation.yield(.titleChanged(title: t)) }
137137+ }
138138+ },
139139+ webView.observe(\.isLoading, options: [.new]) { [weak self] wv, _ in
140140+ Task { @MainActor in self?.isLoading = wv.isLoading }
141141+ },
142142+ webView.observe(\.estimatedProgress, options: [.new]) { [weak self] wv, _ in
143143+ Task { @MainActor in self?.estimatedProgress = wv.estimatedProgress }
144144+ },
145145+ webView.observe(\.canGoBack, options: [.new]) { [weak self] wv, _ in
146146+ Task { @MainActor in self?.canGoBack = wv.canGoBack }
147147+ },
148148+ webView.observe(\.canGoForward, options: [.new]) { [weak self] wv, _ in
149149+ Task { @MainActor in self?.canGoForward = wv.canGoForward }
150150+ },
151151+ ]
152152+ }
153153+}
154154+155155+// MARK: - WKNavigationDelegate
156156+157157+extension WebKitWebContent: WKNavigationDelegate {
158158+ public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
159159+ if let url = webView.url { eventContinuation.yield(.started(url: url)) }
160160+ }
161161+162162+ public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
163163+ if let url = webView.url { eventContinuation.yield(.committed(url: url)) }
164164+ }
165165+166166+ public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
167167+ if let url = webView.url { eventContinuation.yield(.finished(url: url)) }
168168+ }
169169+170170+ public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
171171+ eventContinuation.yield(.failed(url: webView.url, error: error))
172172+ }
173173+174174+ public func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) {
175175+ // redirected event emitted when URL KVO fires
176176+ }
177177+}
178178+179179+// MARK: - WKUIDelegate
180180+181181+extension WebKitWebContent: WKUIDelegate {
182182+ public func webView(_ webView: WKWebView,
183183+ createWebViewWith configuration: WKWebViewConfiguration,
184184+ for navigationAction: WKNavigationAction,
185185+ windowFeatures: WKWindowFeatures) -> WKWebView? {
186186+ // Emit the URL as a navigation so the tab controller can open a new tab.
187187+ if let url = navigationAction.request.url {
188188+ eventContinuation.yield(.started(url: url))
189189+ }
190190+ return nil
191191+ }
192192+}