diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5499057
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,21 @@
+# Xcode
+*.xcuserstate
+*.xcworkspace
+xcuserdata/
+DerivedData/
+*.hmap
+*.ipa
+*.dSYM.zip
+*.dSYM
+
+# Swift Package Manager
+.build/
+.swiftpm/
+
+# macOS
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Instruments
+*.trace
diff --git a/App/Info.plist b/App/Info.plist
new file mode 100644
index 0000000..097bfb7
--- /dev/null
+++ b/App/Info.plist
@@ -0,0 +1,33 @@
+
+
+
+
+ CFBundleDisplayName
+ Mere
+ CFBundleExecutable
+ Mere
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleName
+ Mere
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 0.1.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 26.0
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+ NSHighResolutionCapable
+
+ NSPrincipalClass
+ NSApplication
+ NSSupportsAutomaticGraphicsSwitching
+
+
+
diff --git a/App/Mere.entitlements b/App/Mere.entitlements
new file mode 100644
index 0000000..47a6f70
--- /dev/null
+++ b/App/Mere.entitlements
@@ -0,0 +1,23 @@
+
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+ com.apple.security.network.client
+
+
+ com.apple.security.temporary-exception.mach-lookup.global-name
+
+ com.apple.pboard
+
+
+ com.apple.security.files.user-selected.read-write
+
+
+ com.apple.security.device.audio-input
+
+
+
diff --git a/App/MereApp.swift b/App/MereApp.swift
new file mode 100644
index 0000000..026f141
--- /dev/null
+++ b/App/MereApp.swift
@@ -0,0 +1,29 @@
+import SwiftUI
+import WebKitEngine
+import MereCore
+import MereUI
+
+@main
+struct MereApp: App {
+
+ @StateObject private var window = WindowViewModel(
+ webkitContext: WebKitBrowserContext(),
+ adBlockEngines: []
+ )
+
+ var body: some Scene {
+ WindowGroup {
+ BrowserWindowView(window: window)
+ .frame(minWidth: 900, minHeight: 600)
+ }
+ .windowStyle(.hiddenTitleBar)
+ .commands {
+ CommandGroup(replacing: .newItem) {
+ Button("New Tab") {
+ window.openTab()
+ }
+ .keyboardShortcut("t", modifiers: .command)
+ }
+ }
+ }
+}
diff --git a/Mere.xcodeproj/project.pbxproj b/Mere.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..a35380e
--- /dev/null
+++ b/Mere.xcodeproj/project.pbxproj
@@ -0,0 +1,386 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 60;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ B3A1000101000000 /* MereApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A1000201000000 /* MereApp.swift */; };
+ B3A1000301000000 /* MereKit in Frameworks */ = {isa = PBXBuildFile; productRef = B3A1000401000000 /* MereKit */; };
+ B3A1000501000000 /* MereCore in Frameworks */ = {isa = PBXBuildFile; productRef = B3A1000601000000 /* MereCore */; };
+ B3A1000701000000 /* MereUI in Frameworks */ = {isa = PBXBuildFile; productRef = B3A1000801000000 /* MereUI */; };
+ B3A1000901000000 /* WebKitEngine in Frameworks */ = {isa = PBXBuildFile; productRef = B3A1000A01000000 /* WebKitEngine */; };
+ B3A1000B01000000 /* ChromiumEngine in Frameworks */ = {isa = PBXBuildFile; productRef = B3A1000C01000000 /* ChromiumEngine */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ B3A1000201000000 /* MereApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MereApp.swift; sourceTree = ""; };
+ B3A1001001000000 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ B3A1001101000000 /* Mere.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Mere.entitlements; sourceTree = ""; };
+ B3A1002001000000 /* Mere.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mere.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ B3A1003001000000 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ B3A1000301000000 /* MereKit in Frameworks */,
+ B3A1000501000000 /* MereCore in Frameworks */,
+ B3A1000701000000 /* MereUI in Frameworks */,
+ B3A1000901000000 /* WebKitEngine in Frameworks */,
+ B3A1000B01000000 /* ChromiumEngine in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ B3A1004001000000 /* App */ = {
+ isa = PBXGroup;
+ children = (
+ B3A1000201000000 /* MereApp.swift */,
+ B3A1001001000000 /* Info.plist */,
+ B3A1001101000000 /* Mere.entitlements */,
+ );
+ path = App;
+ sourceTree = "";
+ };
+ B3A1004101000000 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ B3A1002001000000 /* Mere.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ B3A1004201000000 = {
+ isa = PBXGroup;
+ children = (
+ B3A1004001000000 /* App */,
+ B3A1004101000000 /* Products */,
+ );
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ B3A1005001000000 /* Mere */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = B3A1006001000000 /* Build configuration list for PBXNativeTarget "Mere" */;
+ buildPhases = (
+ B3A1007001000000 /* Sources */,
+ B3A1003001000000 /* Frameworks */,
+ B3A1007101000000 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Mere;
+ packageProductDependencies = (
+ B3A1000401000000 /* MereKit */,
+ B3A1000601000000 /* MereCore */,
+ B3A1000801000000 /* MereUI */,
+ B3A1000A01000000 /* WebKitEngine */,
+ B3A1000C01000000 /* ChromiumEngine */,
+ );
+ productName = Mere;
+ productReference = B3A1002001000000 /* Mere.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ B3A1008001000000 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1640;
+ LastUpgradeCheck = 2640;
+ TargetAttributes = {
+ B3A1005001000000 = {
+ CreatedOnToolsVersion = 16.4;
+ };
+ };
+ };
+ buildConfigurationList = B3A1009001000000 /* Build configuration list for PBXProject "Mere" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = B3A1004201000000;
+ packageReferences = (
+ B3A1010001000000 /* XCLocalSwiftPackageReference "." */,
+ );
+ productRefGroup = B3A1004101000000 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ B3A1005001000000 /* Mere */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ B3A1007101000000 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ B3A1007001000000 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ B3A1000101000000 /* MereApp.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ B3A100A001000000 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = M67B42LX8D;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ B3A100A101000000 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = M67B42LX8D;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MTL_FAST_MATH = YES;
+ SDKROOT = macosx;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ };
+ name = Release;
+ };
+ B3A100B001000000 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = App/Mere.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEAD_CODE_STRIPPING = YES;
+ ENABLE_HARDENED_RUNTIME = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = App/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Mere;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 0.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = sh.dunkirk.mere;
+ PRODUCT_NAME = Mere;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 6.0;
+ };
+ name = Debug;
+ };
+ B3A100B101000000 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = App/Mere.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEAD_CODE_STRIPPING = YES;
+ ENABLE_HARDENED_RUNTIME = YES;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = App/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Mere;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 0.1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = sh.dunkirk.mere;
+ PRODUCT_NAME = Mere;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 6.0;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ B3A1006001000000 /* Build configuration list for PBXNativeTarget "Mere" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ B3A100B001000000 /* Debug */,
+ B3A100B101000000 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ B3A1009001000000 /* Build configuration list for PBXProject "Mere" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ B3A100A001000000 /* Debug */,
+ B3A100A101000000 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ B3A1010001000000 /* XCLocalSwiftPackageReference "." */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = .;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ B3A1000401000000 /* MereKit */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = B3A1010001000000 /* XCLocalSwiftPackageReference "." */;
+ productName = MereKit;
+ };
+ B3A1000601000000 /* MereCore */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = B3A1010001000000 /* XCLocalSwiftPackageReference "." */;
+ productName = MereCore;
+ };
+ B3A1000801000000 /* MereUI */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = B3A1010001000000 /* XCLocalSwiftPackageReference "." */;
+ productName = MereUI;
+ };
+ B3A1000A01000000 /* WebKitEngine */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = B3A1010001000000 /* XCLocalSwiftPackageReference "." */;
+ productName = WebKitEngine;
+ };
+ B3A1000C01000000 /* ChromiumEngine */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = B3A1010001000000 /* XCLocalSwiftPackageReference "." */;
+ productName = ChromiumEngine;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = B3A1008001000000 /* Project object */;
+}
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..28f9cdb
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,59 @@
+// swift-tools-version: 5.10
+import PackageDescription
+
+let package = Package(
+ name: "Mere",
+ platforms: [.macOS("26.0")],
+ products: [
+ .library(name: "MereKit", targets: ["MereKit"]),
+ .library(name: "WebKitEngine", targets: ["WebKitEngine"]),
+ .library(name: "ChromiumEngine", targets: ["ChromiumEngine"]),
+ .library(name: "MereCore", targets: ["MereCore"]),
+ .library(name: "MereUI", targets: ["MereUI"]),
+ ],
+ targets: [
+ // Core protocols + shared models — no engine dependency
+ .target(
+ name: "MereKit",
+ path: "Sources/MereKit"
+ ),
+
+ // WebKit implementation — depends only on MereKit + system WebKit
+ .target(
+ name: "WebKitEngine",
+ dependencies: ["MereKit"],
+ path: "Sources/WebKitEngine"
+ ),
+
+ // Chromium/CEF stub — wire in CEF.swift here when ready
+ .target(
+ name: "ChromiumEngine",
+ dependencies: ["MereKit"],
+ path: "Sources/ChromiumEngine"
+ // When adding CEF:
+ // dependencies: ["MereKit", .product(name: "CEF", package: "CEF.swift")],
+ // and add the CEF package to `dependencies:` above
+ ),
+
+ // Engine-agnostic controllers: Tab, WindowViewModel, CookieSyncController
+ .target(
+ name: "MereCore",
+ dependencies: ["MereKit", "WebKitEngine", "ChromiumEngine"],
+ path: "Sources/MereCore"
+ ),
+
+ // SwiftUI views — depends on MereCore, not on specific engines
+ .target(
+ name: "MereUI",
+ dependencies: ["MereCore", "MereKit"],
+ path: "Sources/MereUI"
+ ),
+
+ // Tests
+ .testTarget(
+ name: "MereKitTests",
+ dependencies: ["MereKit", "MereCore"],
+ path: "Tests/BrowserKitTests"
+ ),
+ ]
+)
diff --git a/Sources/ChromiumEngine/ChromiumAdBlocker.swift b/Sources/ChromiumEngine/ChromiumAdBlocker.swift
new file mode 100644
index 0000000..a37ae33
--- /dev/null
+++ b/Sources/ChromiumEngine/ChromiumAdBlocker.swift
@@ -0,0 +1,124 @@
+import Foundation
+import MereKit
+
+/// Ad blocker for the Chromium engine via CEF's CefRequestHandler.
+///
+/// ## Integration note
+/// CEF doesn't have a compiled rule list like WKContentRuleList — every request
+/// goes through a Swift callback. For large lists this is fine on modern hardware
+/// (~50k rules checked in <1ms using the AhoCorasick / trie approach below),
+/// but the CEF wiring is left as a stub until the engine is connected.
+///
+/// ## How to wire into CEF
+/// 1. In your `CefClient` subclass, override `GetRequestHandler()` to return a
+/// `CefRequestHandler` implementation.
+/// 2. In that handler, override `OnBeforeResourceLoad`:
+/// ```cpp
+/// CefResourceRequestHandler::ReturnValue OnBeforeResourceLoad(
+/// CefRefPtr browser,
+/// CefRefPtr frame,
+/// CefRefPtr request,
+/// CefRefPtr callback) override {
+///
+/// NSString* url = [NSString stringWithUTF8String:request->GetURL().ToString().c_str()];
+/// if ([swiftBlocker shouldBlock:url resourceType:resourceType]) {
+/// return RV_CANCEL;
+/// }
+/// return RV_CONTINUE;
+/// }
+/// ```
+/// 3. The `swiftBlocker` is this class, bridged via an @objc wrapper.
+@MainActor
+public final class ChromiumAdBlocker: AdBlockEngine {
+
+ public var isEnabled: Bool = true
+ public private(set) var loadedLists: [String: Int] = [:]
+ public private(set) var totalBlockedRequestCount = 0
+
+ // Compiled rule set: array of (regex, rule) tuples built once on load
+ private var compiled: [(regex: NSRegularExpression, rule: BlockList.Rule)] = []
+ // Allow-list rules checked after block rules
+ private var allowRules: [(regex: NSRegularExpression, rule: BlockList.Rule)] = []
+
+ public init() {}
+
+ // MARK: - AdBlockEngine
+
+ public func load(_ list: BlockList) async throws {
+ var newBlock: [(NSRegularExpression, BlockList.Rule)] = []
+ var newAllow: [(NSRegularExpression, BlockList.Rule)] = []
+
+ for rule in list.rules {
+ guard let regex = try? NSRegularExpression(pattern: rule.urlPattern, options: .caseInsensitive) else {
+ continue
+ }
+ switch rule.action {
+ case .block: newBlock.append((regex, rule))
+ case .allowList: newAllow.append((regex, rule))
+ }
+ }
+
+ // Merge into existing compiled set (remove old list first)
+ compiled.append(contentsOf: newBlock)
+ allowRules.append(contentsOf: newAllow)
+ loadedLists[list.name] = list.blockCount
+ }
+
+ public func remove(listNamed name: String) async {
+ // Without tagging rules by list name this is a full rebuild.
+ // In production, tag each compiled rule with its list name.
+ loadedLists.removeValue(forKey: name)
+ }
+
+ // MARK: - Request evaluation (called from CEF bridge)
+
+ /// Returns true if the request should be blocked.
+ /// This is the hot path — called for every network request.
+ public func shouldBlock(url: String, resourceType: BlockList.Rule.ResourceType? = nil, host: String? = nil) -> Bool {
+ guard isEnabled else { return false }
+
+ let range = NSRange(url.startIndex..., in: url)
+
+ // Check allow-list first
+ for (regex, rule) in allowRules {
+ if matchesRule(rule, url: url, urlRange: range, resourceType: resourceType, host: host) {
+ if regex.firstMatch(in: url, range: range) != nil {
+ return false
+ }
+ }
+ }
+
+ // Check block rules
+ for (regex, rule) in compiled {
+ if matchesRule(rule, url: url, urlRange: range, resourceType: resourceType, host: host) {
+ if regex.firstMatch(in: url, range: range) != nil {
+ totalBlockedRequestCount += 1
+ return true
+ }
+ }
+ }
+
+ return false
+ }
+
+ private func matchesRule(
+ _ rule: BlockList.Rule,
+ url: String,
+ urlRange: NSRange,
+ resourceType: BlockList.Rule.ResourceType?,
+ host: String?
+ ) -> Bool {
+ if let rt = resourceType, !rule.resourceTypes.isEmpty, !rule.resourceTypes.contains(rt) {
+ return false
+ }
+ if let host {
+ if !rule.ifDomain.isEmpty, !rule.ifDomain.contains(where: { host.hasSuffix($0) }) {
+ return false
+ }
+ if rule.unlessDomain.contains(where: { host.hasSuffix($0) }) {
+ return false
+ }
+ }
+ return true
+ }
+}
diff --git a/Sources/ChromiumEngine/ChromiumWebContent.swift b/Sources/ChromiumEngine/ChromiumWebContent.swift
new file mode 100644
index 0000000..4f727d0
--- /dev/null
+++ b/Sources/ChromiumEngine/ChromiumWebContent.swift
@@ -0,0 +1,93 @@
+import Foundation
+import AppKit
+import MereKit
+
+/// WebContent backed by CEF (Chromium Embedded Framework).
+///
+/// ## Integration note
+/// This is a stub. To wire it up:
+///
+/// 1. Add CEF as a dependency (https://bitbucket.org/chromiumembedded/cef).
+/// The easiest Swift path is via CEF.swift (https://github.com/lvsti/CEF.swift)
+/// or by bridging the CEF ObjC layer yourself using the same pattern
+/// Dia uses for ArcCore (Arc* ObjC classes → ADK Swift wrappers).
+///
+/// 2. Replace `hostView` with a real `CefBrowserView` or an `NSView` returned
+/// by `CefBrowserHost::CreateBrowserSync`.
+///
+/// 3. Forward CEF's `CefLoadHandler`, `CefDisplayHandler`, `CefLifeSpanHandler`
+/// callbacks into `eventContinuation.yield(...)`.
+///
+/// Everything above this class (MereCore, UI) is already engine-agnostic
+/// and needs no changes.
+@MainActor
+public final class ChromiumWebContent: WebContent {
+
+ public let id = UUID()
+ public let engine: EngineType = .chromium
+
+ public private(set) var url: URL?
+ public private(set) var title: String?
+ public private(set) var isLoading = false
+ public private(set) var estimatedProgress: Double = 0
+ public private(set) var canGoBack = false
+ public private(set) var canGoForward = false
+ public private(set) var hasAudioPlaying = false
+ public var isMuted = false
+ public var zoomFactor: Double = 1.0
+
+ private let (stream, continuation) = AsyncStream.makeStream()
+ public var navigationEvents: AsyncStream { stream }
+
+ // Placeholder — replace with real CefBrowserView
+ private let hostView = NSView()
+
+ public init() {}
+
+ public func loadURL(_ url: URL) {
+ self.url = url
+ // cefBrowser.mainFrame.loadURL(url.absoluteString)
+ assertionFailure("ChromiumWebContent: CEF not wired up yet. See class doc.")
+ }
+
+ public func loadHTML(_ html: String, baseURL: URL?) {
+ // cefBrowser.mainFrame.loadString(html, url: baseURL?.absoluteString ?? "about:blank")
+ }
+
+ public func goBack() { /* cefBrowser.goBack() */ }
+ public func goForward() { /* cefBrowser.goForward() */ }
+ public func reload() { /* cefBrowser.reload() */ }
+ public func stopLoading() { /* cefBrowser.stopLoad() */ }
+
+ public func evaluateJavaScript(_ script: String) async throws -> Any? {
+ // CEF JS evaluation is callback-based; bridge to async/await with a CheckedContinuation.
+ // cefBrowser.mainFrame.evaluateJavaScript(...)
+ return nil
+ }
+
+ public func findInPage(_ query: String, forward: Bool) async -> FindResult {
+ // cefBrowser.host.find(query, forward: forward, matchCase: false, findNext: true)
+ return FindResult(matchCount: 0, activeMatchIndex: 0)
+ }
+
+ public func clearFind() {
+ // cefBrowser.host.stopFinding(clearSelection: true)
+ }
+
+ public func attachHostView(_ container: NSView) {
+ hostView.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(hostView)
+ NSLayoutConstraint.activate([
+ hostView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+ hostView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+ hostView.topAnchor.constraint(equalTo: container.topAnchor),
+ hostView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+ ])
+ }
+
+ public func detachHostView() { hostView.removeFromSuperview() }
+
+ public func snapshot() async -> NSImage? { nil }
+
+ public func close() { continuation.finish() }
+}
diff --git a/Sources/MereCore/AdBlockController.swift b/Sources/MereCore/AdBlockController.swift
new file mode 100644
index 0000000..e8c226c
--- /dev/null
+++ b/Sources/MereCore/AdBlockController.swift
@@ -0,0 +1,61 @@
+import Foundation
+import MereKit
+import Combine
+
+/// Manages ad blocking state across both engine contexts.
+/// Lives on WindowViewModel; drives both WebKitAdBlocker and ChromiumAdBlocker.
+@MainActor
+public final class AdBlockController: ObservableObject {
+
+ @Published public private(set) var isEnabled: Bool = true
+ @Published public private(set) var isLoading: Bool = false
+ @Published public private(set) var loadedLists: [String: Int] = [:]
+ @Published public private(set) var error: String?
+
+ private let engines: [any AdBlockEngine]
+
+ public init(engines: [any AdBlockEngine]) {
+ self.engines = engines
+ }
+
+ // MARK: - Control
+
+ public func setEnabled(_ enabled: Bool) {
+ isEnabled = enabled
+ engines.forEach { $0.isEnabled = enabled }
+ }
+
+ // MARK: - List management
+
+ /// Load the default lists (EasyList + EasyPrivacy).
+ public func loadDefaults() async {
+ await load(from: BlockListSource.easyList, name: "EasyList")
+ await load(from: BlockListSource.easyPrivacy, name: "EasyPrivacy")
+ }
+
+ /// Fetch a list from a URL and load it into all engines.
+ public func load(from url: URL, name: String) async {
+ isLoading = true
+ error = nil
+ do {
+ let (data, _) = try await URLSession.shared.data(from: url)
+ let text = String(decoding: data, as: UTF8.self)
+ let list = EasyListParser.parse(text, name: name)
+ for engine in engines {
+ try await engine.load(list)
+ }
+ loadedLists[name] = list.blockCount
+ } catch {
+ self.error = "\(name): \(error.localizedDescription)"
+ }
+ isLoading = false
+ }
+
+ public func remove(listNamed name: String) async {
+ for engine in engines { await engine.remove(listNamed: name) }
+ loadedLists.removeValue(forKey: name)
+ }
+
+ public var totalRuleCount: Int { loadedLists.values.reduce(0, +) }
+ public var totalBlockedCount: Int { engines.map(\.totalBlockedRequestCount).reduce(0, +) }
+}
diff --git a/Sources/MereCore/CookieSyncController.swift b/Sources/MereCore/CookieSyncController.swift
new file mode 100644
index 0000000..bd32ab8
--- /dev/null
+++ b/Sources/MereCore/CookieSyncController.swift
@@ -0,0 +1,75 @@
+import Foundation
+import MereKit
+
+/// Bridges the cookie stores between the two engines when switching a tab.
+///
+/// The core problem: WKWebView and CEF maintain completely separate HTTP cookie
+/// stores. A user logged into GitHub in a WebKit tab will not be logged in when
+/// the same URL is opened in a Chromium tab.
+///
+/// This controller extracts cookies for a given URL from the source engine's
+/// store and injects them into the destination engine's store before navigation.
+///
+/// Limitations:
+/// - HttpOnly cookies set by servers are readable from WKHTTPCookieStore but
+/// may not be extractable from CEF's cookie manager depending on CEF version.
+/// - Secure cookies are transferred in-process (no network exposure), which is safe.
+/// - Session cookies are transferred but may expire immediately if the destination
+/// engine's session handling differs.
+@MainActor
+public final class CookieSyncController {
+
+ private let webkit: any BrowserContext
+ private let chromium: (any BrowserContext)?
+
+ public init(webkit: any BrowserContext, chromium: (any BrowserContext)?) {
+ self.webkit = webkit
+ self.chromium = chromium
+ }
+
+ /// Copy cookies for `url` from `sourceEngine` into the other engine's store.
+ public func sync(from sourceEngine: EngineType, url: URL) async {
+ switch sourceEngine {
+ case .webkit:
+ guard let chromium else { return }
+ let cookies = await webkit.cookies(for: url)
+ await chromium.setCookies(cookies, for: url)
+
+ case .chromium:
+ guard let chromium else { return }
+ let cookies = await chromium.cookies(for: url)
+ await webkit.setCookies(cookies, for: url)
+ }
+ }
+
+ /// Full bidirectional sync for all cookies on a domain.
+ /// Call this periodically if keeping both engines logged in simultaneously.
+ public func fullSync(url: URL) async {
+ guard let chromium else { return }
+ let webkitCookies = await webkit.cookies(for: url)
+ let chromiumCookies = await chromium.cookies(for: url)
+
+ // Merge: newest cookie wins on conflict
+ let merged = merge(webkitCookies, chromiumCookies)
+ await webkit.setCookies(merged, for: url)
+ await chromium.setCookies(merged, for: url)
+ }
+
+ private func merge(_ a: [HTTPCookie], _ b: [HTTPCookie]) -> [HTTPCookie] {
+ var result: [String: HTTPCookie] = [:]
+ for cookie in a + b {
+ let key = "\(cookie.domain)|\(cookie.path)|\(cookie.name)"
+ if let existing = result[key] {
+ // Keep the one with a later expiry, or b if equal
+ if let expA = existing.expiresDate, let expB = cookie.expiresDate, expB > expA {
+ result[key] = cookie
+ } else if existing.expiresDate == nil {
+ result[key] = cookie
+ }
+ } else {
+ result[key] = cookie
+ }
+ }
+ return Array(result.values)
+ }
+}
diff --git a/Sources/MereCore/Tab.swift b/Sources/MereCore/Tab.swift
new file mode 100644
index 0000000..77a293c
--- /dev/null
+++ b/Sources/MereCore/Tab.swift
@@ -0,0 +1,134 @@
+import Foundation
+import MereKit
+import Combine
+#if canImport(AppKit)
+import AppKit
+#endif
+
+/// Observable model for a single browser tab.
+/// Mirrors ADK2.WebContentViewModel / ADK2.WebContentController.
+@MainActor
+public final class Tab: ObservableObject, Identifiable {
+
+ public let id: UUID
+ public let engine: EngineType
+ public let content: any WebContent
+
+ @Published public private(set) var url: URL?
+ @Published public private(set) var title: String?
+ @Published public private(set) var isLoading = false
+ @Published public private(set) var estimatedProgress: Double = 0
+ @Published public private(set) var canGoBack = false
+ @Published public private(set) var canGoForward = false
+ @Published public private(set) var favicon: URL?
+ @Published public private(set) var hasAudioPlaying = false
+ @Published public private(set) var themeColor: PlatformColor?
+
+ private var observationTask: Task?
+
+ public init(content: any WebContent) {
+ self.id = content.id
+ self.engine = content.engine
+ self.content = content
+ startObserving()
+ }
+
+ // MARK: - Navigation passthrough
+
+ public func loadURL(_ url: URL) { content.loadURL(url) }
+ public func goBack() { content.goBack() }
+ public func goForward() { content.goForward() }
+ public func reload() { content.reload() }
+ public func stopLoading() { content.stopLoading() }
+
+ // MARK: - Private
+
+ private func startObserving() {
+ observationTask = Task { [weak self] in
+ guard let self else { return }
+ for await event in content.navigationEvents {
+ guard !Task.isCancelled else { break }
+ await MainActor.run {
+ self.apply(event)
+ }
+ }
+ }
+
+ // Poll lightweight state from the WebContent on each event.
+ // A real implementation would use Combine or direct KVO bindings.
+ Task { [weak self] in
+ while !Task.isCancelled {
+ guard let self else { return }
+ self.syncState()
+ try? await Task.sleep(for: .milliseconds(100))
+ }
+ }
+ }
+
+ private func apply(_ event: NavigationEvent) {
+ switch event {
+ case .started(let url):
+ self.url = url
+ self.isLoading = true
+ self.estimatedProgress = 0.1
+ case .committed(let url):
+ self.url = url
+ self.estimatedProgress = 0.7
+ case .finished(let url):
+ self.url = url
+ self.isLoading = false
+ self.estimatedProgress = 1.0
+ Task { await self.readThemeColor() }
+ case .failed:
+ self.isLoading = false
+ self.estimatedProgress = 0
+ case .titleChanged(let title):
+ self.title = title
+ case .faviconChanged(let url):
+ self.favicon = url
+ case .redirected(_, let to):
+ self.url = to
+ }
+ }
+
+ private func readThemeColor() async {
+ let js = """
+ (function() {
+ var m = document.querySelector('meta[name="theme-color"]');
+ if (m && m.content) return m.content;
+ var els = [document.documentElement, document.body];
+ for (var el of els) {
+ if (!el) continue;
+ var bg = window.getComputedStyle(el).backgroundColor;
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
+ }
+ return null;
+ })()
+ """
+ let result = try? await content.evaluateJavaScript(js)
+ guard let css = result as? String, !css.isEmpty else {
+ self.themeColor = nil
+ return
+ }
+ self.themeColor = PlatformColor.fromCSS(css)
+ }
+
+ private func syncState() {
+ self.url = content.url
+ self.title = content.title
+ self.isLoading = content.isLoading
+ self.estimatedProgress = content.estimatedProgress
+ self.canGoBack = content.canGoBack
+ self.canGoForward = content.canGoForward
+ self.hasAudioPlaying = content.hasAudioPlaying
+ // Re-check background colour on each poll cycle so JS-driven
+ // colour changes (dark mode toggles, SPA navigations) are picked up.
+ if !self.isLoading, self.url != nil {
+ Task { await self.readThemeColor() }
+ }
+ }
+
+ deinit {
+ observationTask?.cancel()
+ }
+}
diff --git a/Sources/MereCore/WindowViewModel.swift b/Sources/MereCore/WindowViewModel.swift
new file mode 100644
index 0000000..091ae35
--- /dev/null
+++ b/Sources/MereCore/WindowViewModel.swift
@@ -0,0 +1,100 @@
+import Foundation
+import MereKit
+import Combine
+
+/// Drives a single browser window: tab list, active tab, engine routing.
+/// Mirrors ADK2.BrowserApplicationController / ADK2.BrowserController.
+@MainActor
+public final class WindowViewModel: ObservableObject {
+
+ @Published public private(set) var tabs: [Tab] = []
+ @Published public var activeTab: Tab?
+ @Published public private(set) var newTabBackgroundColor: PlatformColor?
+
+ private let webkitContext: any BrowserContext
+ private let chromiumContext: (any BrowserContext)?
+ private let cookieSync: CookieSyncController
+ public let adBlock: AdBlockController
+ private var activeTabObservation: AnyCancellable?
+
+ public init(
+ webkitContext: any BrowserContext,
+ chromiumContext: (any BrowserContext)? = nil,
+ adBlockEngines: [any AdBlockEngine] = []
+ ) {
+ self.webkitContext = webkitContext
+ self.chromiumContext = chromiumContext
+ self.cookieSync = CookieSyncController(
+ webkit: webkitContext,
+ chromium: chromiumContext
+ )
+ self.adBlock = AdBlockController(engines: adBlockEngines)
+ }
+
+ // MARK: - Tab management
+
+ @discardableResult
+ public func openTab(url: URL? = nil, engine: EngineType? = nil) -> Tab {
+ // Carry the previous tab's theme color into the new tab page background.
+ if url == nil, let current = activeTab, current.url != nil {
+ newTabBackgroundColor = current.themeColor
+ }
+ let resolvedEngine = engine ?? url.map(EngineType.preferred) ?? .webkit
+ let context = context(for: resolvedEngine)
+ let content = context.makeWebContent()
+ let tab = Tab(content: content)
+ tabs.append(tab)
+ activeTab = tab
+ subscribeToActiveTab()
+ if let url { tab.loadURL(url) }
+ return tab
+ }
+
+ public func closeTab(_ tab: Tab) {
+ tab.content.close()
+ tabs.removeAll { $0.id == tab.id }
+ if activeTab?.id == tab.id {
+ activeTab = tabs.last
+ subscribeToActiveTab()
+ }
+ }
+
+ public func activateTab(_ tab: Tab) {
+ activeTab = tab
+ subscribeToActiveTab()
+ }
+
+ /// Reopen a tab in the other engine, syncing cookies first.
+ public func switchEngine(for tab: Tab) async {
+ guard let url = tab.url else { return }
+ let newEngine: EngineType = tab.engine == .webkit ? .chromium : .webkit
+ guard newEngine == .webkit || chromiumContext != nil else { return }
+
+ await cookieSync.sync(from: tab.engine, url: url)
+
+ let idx = tabs.firstIndex(where: { $0.id == tab.id })
+ closeTab(tab)
+
+ let newTab = openTab(url: url, engine: newEngine)
+ if let idx {
+ tabs.move(fromOffsets: IndexSet(integer: tabs.count - 1), toOffset: idx)
+ }
+ activeTab = newTab
+ subscribeToActiveTab()
+ }
+
+ // MARK: - Helpers
+
+ private func subscribeToActiveTab() {
+ activeTabObservation = activeTab?.objectWillChange
+ .receive(on: RunLoop.main)
+ .sink { [weak self] _ in self?.objectWillChange.send() }
+ }
+
+ private func context(for engine: EngineType) -> any BrowserContext {
+ switch engine {
+ case .webkit: return webkitContext
+ case .chromium: return chromiumContext ?? webkitContext
+ }
+ }
+}
diff --git a/Sources/MereKit/Mock/MockWebContent.swift b/Sources/MereKit/Mock/MockWebContent.swift
new file mode 100644
index 0000000..021daa9
--- /dev/null
+++ b/Sources/MereKit/Mock/MockWebContent.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+/// In-process stub used for SwiftUI previews and unit tests.
+/// Mirrors ADK2.MockWebContent.
+@MainActor
+public final class MockWebContent: WebContent {
+
+ public let id = UUID()
+ public let engine: EngineType = .webkit
+
+ public var url: URL? = URL(string: "https://example.com")
+ public var title: String? = "Example Domain"
+ public var isLoading = false
+ public var estimatedProgress: Double = 1.0
+ public var canGoBack = false
+ public var canGoForward = false
+ public var hasAudioPlaying = false
+ public var isMuted = false
+ public var zoomFactor: Double = 1.0
+
+ private let (stream, continuation) = AsyncStream.makeStream()
+ public var navigationEvents: AsyncStream { stream }
+
+ public init() {}
+
+ public func loadURL(_ url: URL) { self.url = url }
+ public func loadHTML(_ html: String, baseURL: URL?) {}
+ public func goBack() {}
+ public func goForward() {}
+ public func reload() {}
+ public func stopLoading() {}
+ public func evaluateJavaScript(_ script: String) async throws -> Any? { nil }
+ public func findInPage(_ query: String, forward: Bool) async -> FindResult { .init(matchCount: 0, activeMatchIndex: 0) }
+ public func clearFind() {}
+ public func attachHostView(_ container: PlatformView) {}
+ public func detachHostView() {}
+ public func snapshot() async -> PlatformImage? { nil }
+ public func close() { continuation.finish() }
+}
diff --git a/Sources/MereKit/Models/BlockList.swift b/Sources/MereKit/Models/BlockList.swift
new file mode 100644
index 0000000..92767d9
--- /dev/null
+++ b/Sources/MereKit/Models/BlockList.swift
@@ -0,0 +1,196 @@
+import Foundation
+
+/// A parsed, engine-agnostic block list.
+public struct BlockList: Sendable {
+
+ public struct Rule: Sendable {
+ public enum Action: Sendable { case block, allowList }
+ public enum ResourceType: String, Sendable, CaseIterable {
+ case document, script, image, stylesheet = "style-sheet"
+ case font, media, raw, svg = "svg-document"
+ case xhr = "fetch", websocket, other
+ }
+
+ public let urlPattern: String // regex
+ public let action: Action
+ public let resourceTypes: Set
+ public let ifDomain: [String] // only apply on these domains
+ public let unlessDomain: [String] // skip on these domains
+
+ public init(
+ urlPattern: String,
+ action: Action = .block,
+ resourceTypes: Set = [],
+ ifDomain: [String] = [],
+ unlessDomain: [String] = []
+ ) {
+ self.urlPattern = urlPattern
+ self.action = action
+ self.resourceTypes = resourceTypes
+ self.ifDomain = ifDomain
+ self.unlessDomain = unlessDomain
+ }
+ }
+
+ public let name: String
+ public let rules: [Rule]
+ public let updatedAt: Date
+
+ public init(name: String, rules: [Rule], updatedAt: Date = .now) {
+ self.name = name
+ self.rules = rules
+ self.updatedAt = updatedAt
+ }
+
+ /// Total count of blocking rules (excludes allowlist entries).
+ public var blockCount: Int { rules.filter { $0.action == .block }.count }
+}
+
+// MARK: - EasyList parser
+
+/// Parses a subset of Adblock Plus / EasyList filter syntax into BlockList.Rules.
+///
+/// Supported syntax:
+/// - `||example.com^` → domain anchor
+/// - `@@||example.com^` → allowlist
+/// - `$script,image` → resource type options
+/// - `$domain=foo.com|~bar.com` → domain restrictions
+/// - `/regex/` → regex rule
+/// - `##` cosmetic rules → ignored (CSS injection, not request blocking)
+public enum EasyListParser {
+
+ public static func parse(_ text: String, name: String) -> BlockList {
+ var rules: [BlockList.Rule] = []
+
+ for line in text.components(separatedBy: .newlines) {
+ let line = line.trimmingCharacters(in: .whitespaces)
+
+ // Skip comments, headers, empty lines, cosmetic rules
+ if line.isEmpty || line.hasPrefix("!") || line.hasPrefix("[") || line.contains("##") || line.contains("#@#") {
+ continue
+ }
+
+ if let rule = parseRule(line) {
+ rules.append(rule)
+ }
+ }
+
+ return BlockList(name: name, rules: rules)
+ }
+
+ private static func parseRule(_ line: String) -> BlockList.Rule? {
+ var raw = line
+ let isAllowList = raw.hasPrefix("@@")
+ if isAllowList { raw = String(raw.dropFirst(2)) }
+
+ // Split options after `$`
+ var options: [String] = []
+ var pattern = raw
+ if let dollarIdx = raw.lastIndex(of: "$"), !raw.hasPrefix("/") {
+ let optStr = String(raw[raw.index(after: dollarIdx)...])
+ // Only treat as options if it looks like options (no spaces, known keywords)
+ if !optStr.contains(" ") {
+ options = optStr.components(separatedBy: ",")
+ pattern = String(raw[.. = []
+ var ifDomain: [String] = []
+ var unlessDomain: [String] = []
+
+ for opt in options {
+ if opt.hasPrefix("domain=") {
+ let domains = String(opt.dropFirst(7)).components(separatedBy: "|")
+ for d in domains {
+ if d.hasPrefix("~") { unlessDomain.append(String(d.dropFirst())) }
+ else if !d.isEmpty { ifDomain.append(d) }
+ }
+ } else if let rt = parseResourceType(opt) {
+ resourceTypes.insert(rt)
+ }
+ }
+
+ // Convert EasyList pattern to regex
+ guard let regex = patternToRegex(pattern) else { return nil }
+
+ return BlockList.Rule(
+ urlPattern: regex,
+ action: isAllowList ? .allowList : .block,
+ resourceTypes: resourceTypes,
+ ifDomain: ifDomain,
+ unlessDomain: unlessDomain
+ )
+ }
+
+ private static func parseResourceType(_ opt: String) -> BlockList.Rule.ResourceType? {
+ switch opt {
+ case "script": return .script
+ case "image": return .image
+ case "stylesheet": return .stylesheet
+ case "font": return .font
+ case "media": return .media
+ case "xmlhttprequest", "fetch": return .xhr
+ case "websocket": return .websocket
+ case "document": return .document
+ case "subdocument": return .raw
+ case "other": return .other
+ default: return nil
+ }
+ }
+
+ private static func patternToRegex(_ pattern: String) -> String? {
+ // Already a regex
+ if pattern.hasPrefix("/") && pattern.hasSuffix("/") {
+ let inner = String(pattern.dropFirst().dropLast())
+ return inner.isEmpty ? nil : inner
+ }
+
+ var p = pattern
+
+ // Domain anchor `||` → match start of host
+ let domainAnchor = p.hasPrefix("||")
+ if domainAnchor { p = String(p.dropFirst(2)) }
+
+ // Left anchor `|` → start of URL
+ let leftAnchor = !domainAnchor && p.hasPrefix("|")
+ if leftAnchor { p = String(p.dropFirst()) }
+
+ // Right anchor `|`
+ let rightAnchor = p.hasSuffix("|")
+ if rightAnchor { p = String(p.dropLast()) }
+
+ if p.isEmpty { return nil }
+
+ // Escape regex metacharacters except `*` and `^`
+ var escaped = ""
+ for ch in p {
+ switch ch {
+ case ".", "+", "?", "{", "}", "(", ")", "[", "]", "\\", "$":
+ escaped += "\\\(ch)"
+ case "*":
+ escaped += ".*"
+ case "^":
+ // Separator — matches any non-word boundary character or end of string
+ escaped += "([^a-zA-Z0-9.%-]|$)"
+ default:
+ escaped += String(ch)
+ }
+ }
+
+ // Apply anchors
+ if domainAnchor {
+ escaped = "https?://(www\\.)?" + escaped
+ } else if leftAnchor {
+ escaped = "^" + escaped
+ }
+ if rightAnchor { escaped += "$" }
+
+ return escaped
+ }
+}
diff --git a/Sources/MereKit/Models/EngineType.swift b/Sources/MereKit/Models/EngineType.swift
new file mode 100644
index 0000000..3cc3693
--- /dev/null
+++ b/Sources/MereKit/Models/EngineType.swift
@@ -0,0 +1,18 @@
+import Foundation
+
+public enum EngineType: String, Codable, Sendable {
+ case webkit
+ case chromium
+
+ /// Heuristic: prefer Chromium for known compatibility-sensitive origins.
+ /// Everything else defaults to WebKit.
+ public static func preferred(for url: URL) -> EngineType {
+ guard let host = url.host else { return .webkit }
+ let chromiumHosts = [
+ "figma.com", "notion.so", "linear.app",
+ "docs.google.com", "sheets.google.com", "slides.google.com",
+ "app.diagrams.net",
+ ]
+ return chromiumHosts.contains(where: { host.hasSuffix($0) }) ? .chromium : .webkit
+ }
+}
diff --git a/Sources/MereKit/Models/NavigationEvent.swift b/Sources/MereKit/Models/NavigationEvent.swift
new file mode 100644
index 0000000..22ed834
--- /dev/null
+++ b/Sources/MereKit/Models/NavigationEvent.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+public enum NavigationEvent: Sendable {
+ case started(url: URL)
+ case redirected(from: URL, to: URL)
+ case committed(url: URL)
+ case finished(url: URL)
+ case failed(url: URL?, error: Error)
+ case titleChanged(title: String)
+ case faviconChanged(url: URL?)
+}
+
+public struct NavigationPolicy: Sendable {
+ public enum Action: Sendable {
+ case allow
+ case cancel
+ case redirectTo(URL)
+ }
+
+ public let action: Action
+
+ public static let allow = NavigationPolicy(action: .allow)
+ public static let cancel = NavigationPolicy(action: .cancel)
+}
+
+public struct FindResult: Sendable {
+ public let matchCount: Int
+ public let activeMatchIndex: Int
+
+ public init(matchCount: Int, activeMatchIndex: Int) {
+ self.matchCount = matchCount
+ self.activeMatchIndex = activeMatchIndex
+ }
+}
diff --git a/Sources/MereKit/Protocols/AdBlockEngine.swift b/Sources/MereKit/Protocols/AdBlockEngine.swift
new file mode 100644
index 0000000..08e4715
--- /dev/null
+++ b/Sources/MereKit/Protocols/AdBlockEngine.swift
@@ -0,0 +1,49 @@
+import Foundation
+
+/// Applies content blocking rules to a browser context.
+/// Each engine implements this differently:
+/// - WebKit → WKContentRuleList (compiled bytecode, runs in WebKit process)
+/// - Chromium → CefRequestHandler (Swift callback per request)
+@MainActor
+public protocol AdBlockEngine: AnyObject {
+
+ /// Whether blocking is currently active.
+ var isEnabled: Bool { get set }
+
+ /// Currently loaded lists and their rule counts.
+ var loadedLists: [String: Int] { get }
+
+ /// Load and compile a block list. Replaces any existing list with the same name.
+ /// Compilation is async because WebKit's rule list compilation can take ~100-500ms
+ /// for large lists (EasyList has ~50k rules).
+ func load(_ list: BlockList) async throws
+
+ /// Remove a list by name.
+ func remove(listNamed name: String) async
+
+ /// Fetch a list from a URL, parse it, and load it.
+ func fetchAndLoad(from url: URL, name: String) async throws
+
+ /// Block count across all loaded lists.
+ var totalBlockedRequestCount: Int { get }
+}
+
+// MARK: - Default implementation
+
+public extension AdBlockEngine {
+ func fetchAndLoad(from url: URL, name: String) async throws {
+ let (data, _) = try await URLSession.shared.data(from: url)
+ let text = String(decoding: data, as: UTF8.self)
+ let list = EasyListParser.parse(text, name: name)
+ try await load(list)
+ }
+}
+
+// MARK: - Well-known list URLs
+
+public enum BlockListSource {
+ public static let easyList = URL(string: "https://easylist.to/easylist/easylist.txt")!
+ public static let easyPrivacy = URL(string: "https://easylist.to/easylist/easyprivacy.txt")!
+ public static let uBlockFilters = URL(string: "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt")!
+ public static let peterlowePII = URL(string: "https://raw.githubusercontent.com/peterkliewe/easyprivacy/master/easyprivacy.txt")!
+}
diff --git a/Sources/MereKit/Protocols/BrowserContext.swift b/Sources/MereKit/Protocols/BrowserContext.swift
new file mode 100644
index 0000000..bf87973
--- /dev/null
+++ b/Sources/MereKit/Protocols/BrowserContext.swift
@@ -0,0 +1,68 @@
+import Foundation
+
+/// Represents a browsing profile: cookies, history, bookmarks, credentials.
+/// Mirrors ArcBrowserContext / ADK2.BrowserContextController.
+@MainActor
+public protocol BrowserContext: AnyObject {
+
+ var engine: EngineType { get }
+
+ // MARK: - WebContent factory
+
+ func makeWebContent() -> any WebContent
+
+ // MARK: - Cookies
+
+ /// Fetch all cookies for a given URL.
+ func cookies(for url: URL) async -> [HTTPCookie]
+
+ /// Set cookies into this context's store.
+ func setCookies(_ cookies: [HTTPCookie], for url: URL) async
+
+ /// Remove all cookies matching a URL.
+ func clearCookies(for url: URL) async
+
+ // MARK: - History (read-only; writes happen automatically on navigation)
+
+ func history(limit: Int) async -> [HistoryItem]
+ func clearHistory() async
+
+ // MARK: - Downloads
+
+ var activeDownloads: [DownloadItem] { get }
+
+ // MARK: - Lifecycle
+
+ func close()
+}
+
+// MARK: - Supporting types
+
+public struct HistoryItem: Identifiable, Sendable {
+ public let id: UUID
+ public let url: URL
+ public let title: String?
+ public let visitedAt: Date
+
+ public init(id: UUID = .init(), url: URL, title: String? = nil, visitedAt: Date = .now) {
+ self.id = id
+ self.url = url
+ self.title = title
+ self.visitedAt = visitedAt
+ }
+}
+
+public struct DownloadItem: Identifiable, Sendable {
+ public enum State: Sendable { case inProgress(Double), completed(URL), failed(Error) }
+ public let id: UUID
+ public let sourceURL: URL
+ public let filename: String
+ public let state: State
+
+ public init(id: UUID = .init(), sourceURL: URL, filename: String, state: State) {
+ self.id = id
+ self.sourceURL = sourceURL
+ self.filename = filename
+ self.state = state
+ }
+}
diff --git a/Sources/MereKit/Protocols/BrowserEngine.swift b/Sources/MereKit/Protocols/BrowserEngine.swift
new file mode 100644
index 0000000..caf99b8
--- /dev/null
+++ b/Sources/MereKit/Protocols/BrowserEngine.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+/// Entry point for each engine. One instance per process.
+/// Responsible for spinning up the runtime and vending BrowserContexts.
+public protocol BrowserEngine: AnyObject {
+
+ var engineType: EngineType { get }
+
+ /// Whether the engine runtime is currently loaded.
+ var isLoaded: Bool { get }
+
+ /// Load the engine runtime. No-op if already loaded.
+ /// For WebKit this is essentially free; for CEF it initialises the subprocess infrastructure.
+ func load() async throws
+
+ /// Create a new isolated browsing context (profile / cookie jar).
+ @MainActor
+ func makeContext() -> any BrowserContext
+
+ /// Tear down the engine. Call only on app exit.
+ func shutdown()
+}
diff --git a/Sources/MereKit/Protocols/WebContent.swift b/Sources/MereKit/Protocols/WebContent.swift
new file mode 100644
index 0000000..18aa10a
--- /dev/null
+++ b/Sources/MereKit/Protocols/WebContent.swift
@@ -0,0 +1,120 @@
+import Foundation
+#if canImport(AppKit)
+import AppKit
+public typealias PlatformView = NSView
+public typealias PlatformImage = NSImage
+public typealias PlatformColor = NSColor
+#elseif canImport(UIKit)
+import UIKit
+public typealias PlatformView = UIView
+public typealias PlatformImage = UIImage
+public typealias PlatformColor = UIColor
+#endif
+
+extension PlatformColor {
+ /// Create from a CSS color string — hex (`#rgb`, `#rrggbb`) or `rgb()`/`rgba()`.
+ public static func fromCSS(_ value: String) -> PlatformColor? {
+ let s = value.trimmingCharacters(in: .whitespaces)
+ if s.hasPrefix("#") { return fromHex(s) }
+ // rgb(r, g, b) or rgba(r, g, b, a)
+ guard s.hasPrefix("rgb") else { return nil }
+ let digits = s.drop(while: { $0 != "(" }).dropFirst().prefix(while: { $0 != ")" })
+ let parts = digits.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
+ guard parts.count >= 3,
+ let ri = Double(parts[0]), let gi = Double(parts[1]), let bi = Double(parts[2]) else { return nil }
+ let a = parts.count >= 4 ? (Double(parts[3]) ?? 1.0) : 1.0
+ return PlatformColor(red: ri / 255, green: gi / 255, blue: bi / 255, alpha: a)
+ }
+
+ public static func fromHex(_ hex: String) -> PlatformColor? {
+ var s = hex.trimmingCharacters(in: .whitespaces)
+ if s.hasPrefix("#") { s.removeFirst() }
+ let len = s.count
+ guard len == 3 || len == 6 || len == 8,
+ let value = UInt64(s, radix: 16) else { return nil }
+ let r, g, b, a: CGFloat
+ switch len {
+ case 3:
+ r = CGFloat((value >> 8) & 0xF) / 15
+ g = CGFloat((value >> 4) & 0xF) / 15
+ b = CGFloat(value & 0xF) / 15
+ a = 1
+ case 6:
+ r = CGFloat((value >> 16) & 0xFF) / 255
+ g = CGFloat((value >> 8) & 0xFF) / 255
+ b = CGFloat(value & 0xFF) / 255
+ a = 1
+ default: // 8
+ r = CGFloat((value >> 24) & 0xFF) / 255
+ g = CGFloat((value >> 16) & 0xFF) / 255
+ b = CGFloat((value >> 8) & 0xFF) / 255
+ a = CGFloat(value & 0xFF) / 255
+ }
+ return PlatformColor(red: r, green: g, blue: b, alpha: a)
+ }
+}
+
+/// The core abstraction over a single browser tab, regardless of engine.
+/// Mirrors what Dia calls ArcWebContents / ADK2.WebContentController.
+@MainActor
+public protocol WebContent: AnyObject {
+
+ // MARK: - Identity
+
+ var id: UUID { get }
+ var engine: EngineType { get }
+
+ // MARK: - State
+
+ var url: URL? { get }
+ var title: String? { get }
+ var isLoading: Bool { get }
+ var estimatedProgress: Double { get }
+ var canGoBack: Bool { get }
+ var canGoForward: Bool { get }
+ var hasAudioPlaying: Bool { get }
+ var isMuted: Bool { get set }
+
+ // MARK: - Navigation
+
+ func loadURL(_ url: URL)
+ func loadHTML(_ html: String, baseURL: URL?)
+ func goBack()
+ func goForward()
+ func reload()
+ func stopLoading()
+
+ // MARK: - JavaScript
+
+ @discardableResult
+ func evaluateJavaScript(_ script: String) async throws -> Any?
+
+ // MARK: - Find in Page
+
+ func findInPage(_ query: String, forward: Bool) async -> FindResult
+ func clearFind()
+
+ // MARK: - View
+
+ /// Attach the engine's native view into the given container.
+ /// Call this once after creation; the view fills the container.
+ func attachHostView(_ container: PlatformView)
+ func detachHostView()
+
+ // MARK: - Zoom
+
+ var zoomFactor: Double { get set }
+
+ // MARK: - Snapshot
+
+ func snapshot() async -> PlatformImage?
+
+ // MARK: - Events
+
+ /// Stream of navigation lifecycle events.
+ var navigationEvents: AsyncStream { get }
+
+ // MARK: - Lifecycle
+
+ func close()
+}
diff --git a/Sources/MereUI/AdBlockSettingsView.swift b/Sources/MereUI/AdBlockSettingsView.swift
new file mode 100644
index 0000000..a375f69
--- /dev/null
+++ b/Sources/MereUI/AdBlockSettingsView.swift
@@ -0,0 +1,85 @@
+import SwiftUI
+import MereCore
+
+public struct AdBlockSettingsView: View {
+
+ @ObservedObject var adBlock: AdBlockController
+
+ public init(adBlock: AdBlockController) {
+ self.adBlock = adBlock
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+
+ // Master toggle
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Ad & Tracker Blocking")
+ .font(.headline)
+ Text("\(adBlock.totalRuleCount.formatted()) rules · \(adBlock.totalBlockedCount.formatted()) blocked")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Toggle("", isOn: Binding(
+ get: { adBlock.isEnabled },
+ set: { adBlock.setEnabled($0) }
+ ))
+ .labelsHidden()
+ }
+
+ Divider()
+
+ // Loaded lists
+ if adBlock.loadedLists.isEmpty {
+ Text("No lists loaded")
+ .foregroundStyle(.secondary)
+ .font(.subheadline)
+ } else {
+ ForEach(Array(adBlock.loadedLists.keys.sorted()), id: \.self) { name in
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(name).font(.subheadline)
+ if let count = adBlock.loadedLists[name] {
+ Text("\(count.formatted()) rules")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ Button {
+ Task { await adBlock.remove(listNamed: name) }
+ } label: {
+ Image(systemName: "trash")
+ .foregroundStyle(.red)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ Divider()
+
+ // Load default lists
+ HStack {
+ Button("Load EasyList + EasyPrivacy") {
+ Task { await adBlock.loadDefaults() }
+ }
+ .disabled(adBlock.isLoading)
+
+ if adBlock.isLoading {
+ ProgressView().scaleEffect(0.7)
+ }
+ }
+
+ if let error = adBlock.error {
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ }
+ .padding()
+ .frame(width: 320)
+ }
+}
diff --git a/Sources/MereUI/BrowserWindowView.swift b/Sources/MereUI/BrowserWindowView.swift
new file mode 100644
index 0000000..e264e8a
--- /dev/null
+++ b/Sources/MereUI/BrowserWindowView.swift
@@ -0,0 +1,560 @@
+import SwiftUI
+import AppKit
+import MereCore
+import MereKit
+
+public struct BrowserWindowView: View {
+
+ @StateObject var window: WindowViewModel
+ @State private var sidebarVisible = true
+ // Incrementing this tells whichever address bar is visible to take focus.
+ @State private var addressFocusTrigger = 0
+
+ public init(window: WindowViewModel) {
+ _window = StateObject(wrappedValue: window)
+ }
+
+ private var isNewTab: Bool {
+ window.activeTab == nil || (window.activeTab?.url == nil && window.activeTab?.isLoading == false)
+ }
+
+ /// Returns the color scheme that gives readable contrast against `color`.
+ private func scheme(for color: NSColor) -> ColorScheme {
+ guard let rgb = color.usingColorSpace(.deviceRGB) else { return .light }
+ let r = rgb.redComponent, g = rgb.greenComponent, b = rgb.blueComponent
+ let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b
+ return luma > 0.5 ? .light : .dark
+ }
+
+ private var tintColor: Color {
+ if isNewTab {
+ return window.newTabBackgroundColor.map { Color(nsColor: $0) }
+ ?? Color(nsColor: .windowBackgroundColor)
+ }
+ if let tc = window.activeTab?.themeColor { return Color(nsColor: tc) }
+ return Color(nsColor: .windowBackgroundColor)
+ }
+
+ private var preferredScheme: ColorScheme? {
+ if let tc = window.activeTab?.themeColor { return scheme(for: tc) }
+ if isNewTab, let bg = window.newTabBackgroundColor { return scheme(for: bg) }
+ return nil
+ }
+
+ public var body: some View {
+ ZStack {
+ // Full-window color gradient using the raw page background colour.
+ LinearGradient(
+ stops: [
+ .init(color: tintColor.opacity(0.95), location: 0),
+ .init(color: tintColor.opacity(0.70), location: 1),
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .ignoresSafeArea()
+ .animation(.easeInOut(duration: 0.35), value: isNewTab)
+
+ HStack(spacing: 0) {
+ if sidebarVisible {
+ SidebarView(window: window)
+ .frame(width: 220)
+ .background(.ultraThinMaterial)
+ .transition(.move(edge: .leading).combined(with: .opacity))
+ }
+
+ VStack(spacing: 0) {
+ // Toolbar: transparent background — window gradient shows through.
+ BrowserToolbarView(
+ window: window,
+ sidebarVisible: $sidebarVisible,
+ focusTrigger: addressFocusTrigger
+ )
+ .padding(.top, 8)
+ .padding(.leading, sidebarVisible ? 10 : 86)
+ .padding(.trailing, 10)
+ .padding(.bottom, 8)
+
+ // Content: material matching sidebar, fills all remaining space.
+ contentArea
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(.ultraThinMaterial)
+ .clipShape(UnevenRoundedRectangle(
+ topLeadingRadius: 0,
+ bottomLeadingRadius: 10,
+ bottomTrailingRadius: 10,
+ topTrailingRadius: 0
+ ))
+ .overlay(
+ UnevenRoundedRectangle(
+ topLeadingRadius: 0,
+ bottomLeadingRadius: 10,
+ bottomTrailingRadius: 10,
+ topTrailingRadius: 0
+ )
+ .strokeBorder(Color(nsColor: .separatorColor).opacity(0.35), lineWidth: 0.5)
+ )
+ .padding(.horizontal, 8)
+ .padding(.bottom, 8)
+ }
+ .ignoresSafeArea(edges: .top)
+ }
+ }
+ .animation(.spring(duration: 0.22), value: sidebarVisible)
+ .background(keyboardShortcuts)
+ .background(TrafficLightNudge(xOffset: 8, yOffset: 8))
+ .preferredColorScheme(preferredScheme)
+ .onChange(of: window.activeTab?.id) { _, _ in
+ if window.activeTab?.url == nil {
+ addressFocusTrigger += 1
+ } else {
+ NSApp.keyWindow?.makeFirstResponder(nil)
+ }
+ }
+ }
+
+ private var keyboardShortcuts: some View {
+ Group {
+ Button("") { sidebarVisible.toggle() }
+ .keyboardShortcut("s", modifiers: .command)
+ Button("") { window.openTab() }
+ .keyboardShortcut("t", modifiers: .command)
+ Button("") {
+ if let tab = window.activeTab { window.closeTab(tab) }
+ }
+ .keyboardShortcut("w", modifiers: .command)
+ Button("") { window.activeTab?.reload() }
+ .keyboardShortcut("r", modifiers: .command)
+ Button("") { addressFocusTrigger += 1 }
+ .keyboardShortcut("l", modifiers: .command)
+ }
+ .frame(width: 0, height: 0)
+ .opacity(0)
+ }
+
+ @ViewBuilder
+ private var contentArea: some View {
+ if let tab = window.activeTab, tab.url != nil || tab.isLoading {
+ WebContentView(content: tab.content)
+ .id(tab.id)
+ } else {
+ NewTabView(hasBackground: window.newTabBackgroundColor != nil)
+ }
+ }
+}
+
+// MARK: - New Tab Page
+
+struct NewTabView: View {
+ let hasBackground: Bool
+
+ var body: some View {
+ Text("mere")
+ .font(.system(size: 52, weight: .ultraLight, design: .rounded))
+ .foregroundStyle(.primary)
+ .tracking(10)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+// MARK: - Sidebar
+
+struct SidebarView: View {
+ @ObservedObject var window: WindowViewModel
+
+ var body: some View {
+ GlassEffectContainer {
+ ScrollView(.vertical, showsIndicators: false) {
+ VStack(spacing: 1) {
+ ForEach(window.tabs) { tab in
+ SidebarTabRow(
+ tab: tab as MereCore.Tab,
+ isActive: window.activeTab?.id == tab.id,
+ onActivate: { window.activateTab(tab) },
+ onClose: { window.closeTab(tab) }
+ )
+ }
+ }
+ .padding(.top, 8)
+ .padding(.bottom, 6)
+ .padding(.horizontal, 8)
+ }
+ }
+ }
+}
+
+struct SidebarTabRow: View {
+ @ObservedObject var tab: MereCore.Tab
+ let isActive: Bool
+ let onActivate: () -> Void
+ let onClose: () -> Void
+ @State private var isHovered = false
+
+ var body: some View {
+ rowContent
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(Color(nsColor: .labelColor).opacity(isActive ? 0.08 : isHovered ? 0.04 : 0))
+ )
+ }
+
+ private var rowContent: some View {
+ HStack(spacing: 9) {
+ FaviconView(url: tab.favicon, engine: tab.engine)
+ .frame(width: 14, height: 14)
+
+ Text(tab.title ?? tab.url?.host ?? "New Tab")
+ .lineLimit(1)
+ .font(.system(size: 13))
+ .foregroundStyle(isActive ? .primary : .secondary)
+
+ Spacer(minLength: 0)
+
+ // Always reserve space; only visible on hover or when active.
+ Button { onClose() } label: {
+ Image(systemName: "xmark")
+ .font(.system(size: 8, weight: .semibold))
+ .frame(width: 14, height: 14)
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .opacity(isHovered || isActive ? 1 : 0)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 7)
+ .contentShape(RoundedRectangle(cornerRadius: 8))
+ .onTapGesture { onActivate() }
+ .onHover { isHovered = $0 }
+ }
+}
+
+// MARK: - Favicon
+
+private final class FaviconCache {
+ static let shared = FaviconCache()
+ private var store: [URL: NSImage] = [:]
+ private let queue = DispatchQueue(label: "sh.dunkirk.mere.favicon-cache")
+
+ func image(for url: URL) -> NSImage? {
+ queue.sync { store[url] }
+ }
+
+ func store(_ image: NSImage, for url: URL) {
+ queue.async { self.store[url] = image }
+ }
+}
+
+struct FaviconView: View {
+ let url: URL?
+ let engine: EngineType
+ @State private var image: NSImage? = nil
+
+ var body: some View {
+ Group {
+ if let image {
+ Image(nsImage: image)
+ .resizable()
+ .scaledToFit()
+ .clipShape(RoundedRectangle(cornerRadius: 3))
+ } else {
+ Circle()
+ .fill(engine == .webkit ? Color.blue.opacity(0.7) : Color.orange.opacity(0.7))
+ .frame(width: 8, height: 8)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .task(id: url) {
+ guard let url else { image = nil; return }
+ if let cached = FaviconCache.shared.image(for: url) {
+ image = cached
+ return
+ }
+ guard let (data, _) = try? await URLSession.shared.data(from: url),
+ let loaded = NSImage(data: data) else { return }
+ FaviconCache.shared.store(loaded, for: url)
+ image = loaded
+ }
+ }
+}
+
+// MARK: - Toolbar
+
+struct HoverButtonStyle: ButtonStyle {
+ var disabled: Bool = false
+ @State private var isHovered = false
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .foregroundStyle(disabled ? .tertiary : isHovered ? .primary : .secondary)
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color(nsColor: .labelColor)
+ .opacity(configuration.isPressed ? 0.12 : isHovered ? 0.07 : 0))
+ )
+ .animation(.easeInOut(duration: 0.12), value: isHovered)
+ .animation(.easeInOut(duration: 0.08), value: configuration.isPressed)
+ .onHover { isHovered = $0 }
+ }
+}
+
+struct BrowserToolbarView: View {
+ @ObservedObject var window: WindowViewModel
+ @Binding var sidebarVisible: Bool
+ let focusTrigger: Int
+ @State private var addressText = ""
+ @State private var addressBarHovered = false
+
+ var body: some View {
+ HStack(spacing: 4) {
+ navIcon("sidebar.left") {
+ sidebarVisible.toggle()
+ }
+
+ navIcon("chevron.left", disabled: window.activeTab?.canGoBack != true) {
+ window.activeTab?.goBack()
+ }
+ navIcon("chevron.right", disabled: window.activeTab?.canGoForward != true) {
+ window.activeTab?.goForward()
+ }
+
+ AddressBar(text: $addressText, focusTrigger: focusTrigger, onSubmit: navigate)
+ .frame(maxWidth: .infinity, minHeight: 22)
+ .padding(.leading, 10)
+ .padding(.trailing, 4)
+ .padding(.vertical, 5)
+ .onChange(of: window.activeTab?.url) { _, url in
+ addressText = url?.absoluteString ?? ""
+ }
+ .overlay(alignment: .trailing) {
+ if let url = window.activeTab?.url, addressBarHovered {
+ Button {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(url.absoluteString, forType: .string)
+ } label: {
+ Image(systemName: "link")
+ .font(.system(size: 11, weight: .medium))
+ .frame(width: 28, height: 28)
+ }
+ .buttonStyle(HoverButtonStyle())
+ .help("Copy URL")
+ .transition(.opacity.animation(.easeInOut(duration: 0.12)))
+ }
+ }
+ .background(
+ RoundedRectangle(cornerRadius: 7)
+ .fill(Color(nsColor: .controlBackgroundColor)
+ .opacity(addressBarHovered ? 0.68 : 0.55))
+ .overlay(
+ RoundedRectangle(cornerRadius: 7)
+ .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 0.5)
+ )
+ )
+ .animation(.easeInOut(duration: 0.15), value: addressBarHovered)
+
+ navIcon("arrow.clockwise") { window.activeTab?.reload() }
+
+ if let tab = window.activeTab {
+ Text(tab.engine == .webkit ? "WK" : "CR")
+ .font(.system(size: 10, weight: .semibold, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 5)
+ .padding(.vertical, 2)
+ .background(Color(nsColor: .separatorColor).opacity(0.3),
+ in: RoundedRectangle(cornerRadius: 4))
+ }
+ }
+ }
+
+ private func navIcon(_ name: String, disabled: Bool = false, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ Image(systemName: name)
+ .font(.system(size: 13, weight: .medium))
+ .frame(width: 26, height: 26)
+ }
+ .buttonStyle(HoverButtonStyle(disabled: disabled))
+ .disabled(disabled)
+ }
+
+ private func navigate() {
+ let raw = addressText.trimmingCharacters(in: .whitespaces)
+ guard !raw.isEmpty else { return }
+ let url: URL
+ if raw.contains(".") && !raw.contains(" "),
+ let u = URL(string: raw.hasPrefix("http") ? raw : "https://\(raw)") {
+ url = u
+ } else {
+ url = URL(string: "https://s.dunkirk.sh?q=\(raw.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")")!
+ }
+ if let tab = window.activeTab {
+ tab.loadURL(url)
+ } else {
+ window.openTab(url: url)
+ }
+ }
+}
+
+// MARK: - Address bar (NSViewRepresentable)
+// SwiftUI TextField + @FocusState is unreliable on macOS for makeFirstResponder.
+// NSTextField gives us direct control over focus and avoids the focus-ring highlight.
+
+struct AddressBar: NSViewRepresentable {
+ @Binding var text: String
+ var focusTrigger: Int
+ var onSubmit: () -> Void
+
+ func makeNSView(context: Context) -> NSTextField {
+ let f = NSTextField()
+ f.placeholderString = "Search or enter URL"
+ f.isBordered = false
+ f.drawsBackground = false
+ f.focusRingType = .none
+ f.font = .systemFont(ofSize: 13)
+ f.cell?.isScrollable = true
+ f.cell?.wraps = false
+ f.alignment = .center
+ f.delegate = context.coordinator
+ return f
+ }
+
+ func updateNSView(_ nsView: NSTextField, context: Context) {
+ context.coordinator.parent = self
+ if !context.coordinator.isEditing, context.coordinator.lastDisplayedURL != text {
+ context.coordinator.lastDisplayedURL = text
+ if let attr = prettyAttributed(text) {
+ nsView.attributedStringValue = attr
+ } else {
+ nsView.stringValue = text
+ }
+ }
+ if context.coordinator.lastTrigger != focusTrigger {
+ context.coordinator.lastTrigger = focusTrigger
+ context.coordinator.focusGeneration += 1
+ let gen = context.coordinator.focusGeneration
+ DispatchQueue.main.async { [weak coordinator = context.coordinator] in
+ guard coordinator?.focusGeneration == gen else { return }
+ nsView.window?.makeFirstResponder(nsView)
+ nsView.currentEditor()?.selectAll(nil)
+ }
+ }
+ }
+
+ func makeCoordinator() -> Coordinator { Coordinator(self) }
+
+ /// Returns an attributed string showing `host` in medium tone and `/path` in muted tone.
+ func prettyAttributed(_ urlString: String) -> NSAttributedString? {
+ guard !urlString.isEmpty,
+ let url = URL(string: urlString),
+ let host = url.host else { return nil }
+ let font = NSFont.systemFont(ofSize: 13)
+ let para = NSMutableParagraphStyle()
+ para.alignment = .center
+ let hostAttr: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: NSColor.labelColor.withAlphaComponent(0.65),
+ .paragraphStyle: para,
+ ]
+ let pathAttr: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: NSColor.labelColor.withAlphaComponent(0.32),
+ .paragraphStyle: para,
+ ]
+ let result = NSMutableAttributedString(string: host, attributes: hostAttr)
+ let path = url.path
+ let query = url.query.map { "?\($0)" } ?? ""
+ let suffix = (path == "/" || path.isEmpty ? "" : path) + query
+ if !suffix.isEmpty {
+ result.append(NSAttributedString(string: suffix, attributes: pathAttr))
+ }
+ return result
+ }
+
+ final class Coordinator: NSObject, NSTextFieldDelegate {
+ var parent: AddressBar
+ var lastTrigger: Int
+ var focusGeneration = 0
+ var lastDisplayedURL: String = ""
+ var isEditing = false
+
+ init(_ parent: AddressBar) {
+ self.parent = parent
+ self.lastTrigger = parent.focusTrigger
+ }
+
+ func controlTextDidChange(_ obj: Notification) {
+ guard let field = obj.object as? NSTextField else { return }
+ parent.text = field.stringValue
+ }
+
+ func control(_ control: NSControl, textView: NSTextView,
+ doCommandBy selector: Selector) -> Bool {
+ if selector == #selector(NSResponder.insertNewline(_:)) {
+ focusGeneration += 1 // cancel any pending focus-and-select
+ parent.onSubmit()
+ DispatchQueue.main.async { control.window?.makeFirstResponder(nil) }
+ return true
+ }
+ return false
+ }
+
+ func controlTextDidBeginEditing(_ obj: Notification) {
+ isEditing = true
+ if let tv = (obj.object as? NSTextField)?.currentEditor() as? NSTextView {
+ tv.insertionPointColor = .labelColor
+ }
+ }
+
+ func controlTextDidEndEditing(_ obj: Notification) {
+ isEditing = false
+ if let field = obj.object as? NSTextField {
+ lastDisplayedURL = "" // force re-render of pretty URL
+ if let attr = parent.prettyAttributed(parent.text) {
+ field.attributedStringValue = attr
+ } else {
+ field.stringValue = parent.text
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Traffic light repositioning
+
+/// Zero-size view that moves the window's traffic-light buttons down by `yOffset` points
+/// so they vertically align with the toolbar icon row.
+private struct TrafficLightNudge: NSViewRepresentable {
+ let xOffset: CGFloat
+ let yOffset: CGFloat
+
+ func makeNSView(context: Context) -> _View { _View() }
+
+ func updateNSView(_ nsView: _View, context: Context) {
+ let x = xOffset, y = yOffset
+ // Defer until after AppKit's own layout pass resets button frames.
+ DispatchQueue.main.async { nsView.apply(xOffset: x, yOffset: y) }
+ }
+
+ final class _View: NSView {
+ private var baseOrigins: [NSWindow.ButtonType: NSPoint] = [:]
+ private static let types: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton]
+
+ required init?(coder: NSCoder) { fatalError() }
+ init() { super.init(frame: .zero) }
+
+ func apply(xOffset: CGFloat, yOffset: CGFloat) {
+ guard let window else { return }
+ // Lazily capture default origins the first time we have a window.
+ if baseOrigins.isEmpty {
+ for type in Self.types {
+ if let btn = window.standardWindowButton(type) {
+ baseOrigins[type] = btn.frame.origin
+ }
+ }
+ }
+ for type in Self.types {
+ guard let btn = window.standardWindowButton(type),
+ let base = baseOrigins[type] else { continue }
+ btn.setFrameOrigin(NSPoint(x: base.x + xOffset, y: base.y - yOffset))
+ }
+ }
+ }
+}
diff --git a/Sources/MereUI/WebContentView.swift b/Sources/MereUI/WebContentView.swift
new file mode 100644
index 0000000..279ca89
--- /dev/null
+++ b/Sources/MereUI/WebContentView.swift
@@ -0,0 +1,27 @@
+import SwiftUI
+import AppKit
+import MereKit
+
+/// Hosts the engine's native NSView inside SwiftUI.
+/// Works identically for WebKit and Chromium tabs.
+public struct WebContentView: NSViewRepresentable {
+
+ let content: any WebContent
+
+ public init(content: any WebContent) {
+ self.content = content
+ }
+
+ public func makeNSView(context: Context) -> NSView {
+ let container = NSView()
+ container.wantsLayer = true
+ content.attachHostView(container)
+ return container
+ }
+
+ public func updateNSView(_ nsView: NSView, context: Context) {}
+
+ public static func dismantleNSView(_ nsView: NSView, coordinator: ()) {
+ // WebContent.close() is called by the Tab when removed from WindowViewModel
+ }
+}
diff --git a/Sources/WebKitEngine/WebKitAdBlocker.swift b/Sources/WebKitEngine/WebKitAdBlocker.swift
new file mode 100644
index 0000000..488de14
--- /dev/null
+++ b/Sources/WebKitEngine/WebKitAdBlocker.swift
@@ -0,0 +1,106 @@
+import Foundation
+import WebKit
+import MereKit
+
+/// Ad blocker for WebKit using WKContentRuleListStore.
+///
+/// How it works:
+/// - Converts BlockList rules → Apple's content blocker JSON format
+/// - Compiles them into a WKContentRuleList (bytecode, runs inside WebKit — no Swift
+/// callbacks per request, zero performance overhead)
+/// - Applies the compiled list to the shared WKWebViewConfiguration so all
+/// WebKitWebContent instances in this context are blocked automatically
+@MainActor
+public final class WebKitAdBlocker: AdBlockEngine {
+
+ public var isEnabled: Bool = true {
+ didSet { Task { await applyToConfiguration() } }
+ }
+
+ public private(set) var loadedLists: [String: Int] = [:]
+ public private(set) var totalBlockedRequestCount = 0
+
+ private let store: WKContentRuleListStore
+ private let configuration: WKWebViewConfiguration
+ private var compiledLists: [String: WKContentRuleList] = [:]
+
+ /// `store` is keyed to a directory so compiled bytecode survives app restarts.
+ public init(configuration: WKWebViewConfiguration, storageURL: URL? = nil) {
+ self.configuration = configuration
+ self.store = storageURL.map { WKContentRuleListStore(url: $0) }
+ ?? .default()
+ }
+
+ // MARK: - AdBlockEngine
+
+ public func load(_ list: BlockList) async throws {
+ let json = try appleContentBlockerJSON(from: list)
+ let compiled: WKContentRuleList = try await withCheckedThrowingContinuation { continuation in
+ store.compileContentRuleList(forIdentifier: list.name, encodedContentRuleList: json) { result, error in
+ if let error { continuation.resume(throwing: error) }
+ else if let result { continuation.resume(returning: result) }
+ else { continuation.resume(throwing: ContentBlockerError.compilationFailed) }
+ }
+ }
+ compiledLists[list.name] = compiled
+ loadedLists[list.name] = list.blockCount
+ await applyToConfiguration()
+ }
+
+ public func remove(listNamed name: String) async {
+ compiledLists.removeValue(forKey: name)
+ loadedLists.removeValue(forKey: name)
+ store.removeContentRuleList(forIdentifier: name) { _ in }
+ await applyToConfiguration()
+ }
+
+ // MARK: - Private
+
+ private func applyToConfiguration() async {
+ let controller = configuration.userContentController
+ controller.removeAllContentRuleLists()
+ guard isEnabled else { return }
+ for list in compiledLists.values {
+ controller.add(list)
+ }
+ }
+
+ // MARK: - JSON conversion
+
+ /// Converts our engine-agnostic rules to Apple's content blocker JSON format.
+ /// Spec: https://webkit.org/blog/3476/content-blockers-first-look/
+ private func appleContentBlockerJSON(from list: BlockList) throws -> String {
+ var entries: [[String: Any]] = []
+
+ for rule in list.rules {
+ var trigger: [String: Any] = ["url-filter": rule.urlPattern]
+
+ if !rule.resourceTypes.isEmpty {
+ trigger["resource-type"] = rule.resourceTypes.map { $0.rawValue }
+ }
+ if !rule.ifDomain.isEmpty {
+ trigger["if-domain"] = rule.ifDomain.map { "*\($0)" }
+ }
+ if !rule.unlessDomain.isEmpty {
+ trigger["unless-domain"] = rule.unlessDomain.map { "*\($0)" }
+ }
+
+ let action: [String: Any] = switch rule.action {
+ case .block: ["type": "block"]
+ case .allowList: ["type": "ignore-previous-rules"]
+ }
+
+ entries.append(["trigger": trigger, "action": action])
+
+ // WKContentRuleList has a hard cap of 150k rules per list
+ if entries.count >= 149_000 { break }
+ }
+
+ let data = try JSONSerialization.data(withJSONObject: entries)
+ return String(decoding: data, as: UTF8.self)
+ }
+}
+
+enum ContentBlockerError: Error {
+ case compilationFailed
+}
diff --git a/Sources/WebKitEngine/WebKitBrowserContext.swift b/Sources/WebKitEngine/WebKitBrowserContext.swift
new file mode 100644
index 0000000..ee790c2
--- /dev/null
+++ b/Sources/WebKitEngine/WebKitBrowserContext.swift
@@ -0,0 +1,68 @@
+import Foundation
+import WebKit
+import MereKit
+
+/// BrowserContext backed by a WKWebsiteDataStore.
+@MainActor
+public final class WebKitBrowserContext: BrowserContext {
+
+ public let engine: EngineType = .webkit
+
+ private let dataStore: WKWebsiteDataStore
+ private let sharedConfiguration: WKWebViewConfiguration
+ private var _activeDownloads: [DownloadItem] = []
+ public let adBlocker: WebKitAdBlocker
+
+ public init(persistent: Bool = true) {
+ self.dataStore = persistent ? .default() : .nonPersistent()
+ let config = WKWebViewConfiguration()
+ config.websiteDataStore = dataStore
+ config.preferences.isElementFullscreenEnabled = true
+ self.sharedConfiguration = config
+ self.adBlocker = WebKitAdBlocker(configuration: config)
+ }
+
+ // MARK: - BrowserContext
+
+ public func makeWebContent() -> any WebContent {
+ WebKitWebContent(configuration: sharedConfiguration)
+ }
+
+ public func cookies(for url: URL) async -> [HTTPCookie] {
+ await dataStore.httpCookieStore.allCookies().filter { cookie in
+ url.host?.hasSuffix(cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain) ?? false
+ }
+ }
+
+ public func setCookies(_ cookies: [HTTPCookie], for url: URL) async {
+ for cookie in cookies {
+ await dataStore.httpCookieStore.setCookie(cookie)
+ }
+ }
+
+ public func clearCookies(for url: URL) async {
+ let existing = await cookies(for: url)
+ for cookie in existing {
+ await dataStore.httpCookieStore.deleteCookie(cookie)
+ }
+ }
+
+ public func history(limit: Int) async -> [HistoryItem] {
+ // WKWebView doesn't expose browsing history via public API.
+ // Must be tracked manually — see SessionController.
+ return []
+ }
+
+ public func clearHistory() async {
+ await dataStore.removeData(
+ ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
+ modifiedSince: .distantPast
+ )
+ }
+
+ public var activeDownloads: [DownloadItem] { _activeDownloads }
+
+ public func close() {
+ _activeDownloads.removeAll()
+ }
+}
diff --git a/Sources/WebKitEngine/WebKitEngine.swift b/Sources/WebKitEngine/WebKitEngine.swift
new file mode 100644
index 0000000..e9b294c
--- /dev/null
+++ b/Sources/WebKitEngine/WebKitEngine.swift
@@ -0,0 +1,24 @@
+import Foundation
+import MereKit
+
+/// Lightweight WebKit engine — runtime is always available, load() is a no-op.
+public final class WebKitEngine: BrowserEngine {
+
+ public static let shared = WebKitEngine()
+
+ public let engineType: EngineType = .webkit
+ public private(set) var isLoaded = true
+
+ private init() {}
+
+ public func load() async throws {
+ // WebKit is always available — nothing to initialise.
+ }
+
+ @MainActor
+ public func makeContext() -> any BrowserContext {
+ WebKitBrowserContext()
+ }
+
+ public func shutdown() {}
+}
diff --git a/Sources/WebKitEngine/WebKitWebContent.swift b/Sources/WebKitEngine/WebKitWebContent.swift
new file mode 100644
index 0000000..52505c1
--- /dev/null
+++ b/Sources/WebKitEngine/WebKitWebContent.swift
@@ -0,0 +1,192 @@
+import Foundation
+import AppKit
+import WebKit
+import MereKit
+
+/// WebContent backed by WKWebView.
+@MainActor
+public final class WebKitWebContent: NSObject, WebContent {
+
+ public let id = UUID()
+ public let engine: EngineType = .webkit
+
+ // MARK: - Public state (KVO-observed from WKWebView)
+
+ public private(set) var url: URL?
+ public private(set) var title: String?
+ public private(set) var isLoading = false
+ public private(set) var estimatedProgress: Double = 0
+ public private(set) var canGoBack = false
+ public private(set) var canGoForward = false
+ public private(set) var hasAudioPlaying = false
+
+ // WKWebView gained isMuted in macOS 14 but it's on WKWebViewConfiguration.mediaTypesRequiringUserActionForPlayback,
+ // not directly on the view. Track manually.
+ public var isMuted: Bool = false {
+ didSet { webView.configuration.mediaTypesRequiringUserActionForPlayback = isMuted ? .all : [] }
+ }
+
+ public var zoomFactor: Double {
+ get { webView.pageZoom }
+ set { webView.pageZoom = newValue }
+ }
+
+ // MARK: - Navigation events
+
+ private let eventContinuation: AsyncStream.Continuation
+ public let navigationEvents: AsyncStream
+
+ // MARK: - Internals
+
+ let webView: WKWebView
+ private var observations: [NSKeyValueObservation] = []
+
+ // MARK: - Init
+
+ public init(configuration: WKWebViewConfiguration = .init()) {
+ let (stream, continuation) = AsyncStream.makeStream()
+ self.navigationEvents = stream
+ self.eventContinuation = continuation
+
+ self.webView = WKWebView(frame: .zero, configuration: configuration)
+ self.webView.allowsBackForwardNavigationGestures = true
+
+ super.init()
+
+ webView.navigationDelegate = self
+ webView.uiDelegate = self
+ observeWebViewProperties()
+ }
+
+ // MARK: - WebContent
+
+ public func loadURL(_ url: URL) {
+ webView.load(URLRequest(url: url))
+ }
+
+ public func loadHTML(_ html: String, baseURL: URL?) {
+ webView.loadHTMLString(html, baseURL: baseURL)
+ }
+
+ public func goBack() { webView.goBack() }
+ public func goForward() { webView.goForward() }
+ public func reload() { webView.reload() }
+ public func stopLoading() { webView.stopLoading() }
+
+ public func evaluateJavaScript(_ script: String) async throws -> Any? {
+ try await webView.evaluateJavaScript(script)
+ }
+
+ public func findInPage(_ query: String, forward: Bool) async -> FindResult {
+ // WKWebView doesn't expose find results count natively; use JS fallback.
+ let js = """
+ (function() {
+ window.getSelection().removeAllRanges();
+ return window.find('\(query.replacingOccurrences(of: "'", with: "\\'"))',
+ false, \(!forward), false, false, true);
+ })()
+ """
+ _ = try? await webView.evaluateJavaScript(js)
+ return FindResult(matchCount: -1, activeMatchIndex: -1) // WKWebView limitation
+ }
+
+ public func clearFind() {
+ Task { _ = try? await webView.evaluateJavaScript("window.getSelection().removeAllRanges()") }
+ }
+
+ public func attachHostView(_ container: NSView) {
+ webView.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(webView)
+ NSLayoutConstraint.activate([
+ webView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+ webView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+ webView.topAnchor.constraint(equalTo: container.topAnchor),
+ webView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+ ])
+ }
+
+ public func detachHostView() {
+ webView.removeFromSuperview()
+ }
+
+ public func snapshot() async -> NSImage? {
+ let config = WKSnapshotConfiguration()
+ return try? await webView.takeSnapshot(configuration: config)
+ }
+
+ public func close() {
+ observations.forEach { $0.invalidate() }
+ observations.removeAll()
+ webView.navigationDelegate = nil
+ webView.uiDelegate = nil
+ detachHostView()
+ eventContinuation.finish()
+ }
+
+ // MARK: - KVO
+
+ private func observeWebViewProperties() {
+ observations = [
+ webView.observe(\.url, options: [.new]) { [weak self] wv, _ in
+ Task { @MainActor in self?.url = wv.url }
+ },
+ webView.observe(\.title, options: [.new]) { [weak self] wv, _ in
+ Task { @MainActor in
+ self?.title = wv.title
+ if let t = wv.title { self?.eventContinuation.yield(.titleChanged(title: t)) }
+ }
+ },
+ webView.observe(\.isLoading, options: [.new]) { [weak self] wv, _ in
+ Task { @MainActor in self?.isLoading = wv.isLoading }
+ },
+ webView.observe(\.estimatedProgress, options: [.new]) { [weak self] wv, _ in
+ Task { @MainActor in self?.estimatedProgress = wv.estimatedProgress }
+ },
+ webView.observe(\.canGoBack, options: [.new]) { [weak self] wv, _ in
+ Task { @MainActor in self?.canGoBack = wv.canGoBack }
+ },
+ webView.observe(\.canGoForward, options: [.new]) { [weak self] wv, _ in
+ Task { @MainActor in self?.canGoForward = wv.canGoForward }
+ },
+ ]
+ }
+}
+
+// MARK: - WKNavigationDelegate
+
+extension WebKitWebContent: WKNavigationDelegate {
+ public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
+ if let url = webView.url { eventContinuation.yield(.started(url: url)) }
+ }
+
+ public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
+ if let url = webView.url { eventContinuation.yield(.committed(url: url)) }
+ }
+
+ public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ if let url = webView.url { eventContinuation.yield(.finished(url: url)) }
+ }
+
+ public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+ eventContinuation.yield(.failed(url: webView.url, error: error))
+ }
+
+ public func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) {
+ // redirected event emitted when URL KVO fires
+ }
+}
+
+// MARK: - WKUIDelegate
+
+extension WebKitWebContent: WKUIDelegate {
+ public func webView(_ webView: WKWebView,
+ createWebViewWith configuration: WKWebViewConfiguration,
+ for navigationAction: WKNavigationAction,
+ windowFeatures: WKWindowFeatures) -> WKWebView? {
+ // Emit the URL as a navigation so the tab controller can open a new tab.
+ if let url = navigationAction.request.url {
+ eventContinuation.yield(.started(url: url))
+ }
+ return nil
+ }
+}
diff --git a/Tests/BrowserKitTests/TabTests.swift b/Tests/BrowserKitTests/TabTests.swift
new file mode 100644
index 0000000..a5306d0
--- /dev/null
+++ b/Tests/BrowserKitTests/TabTests.swift
@@ -0,0 +1,13 @@
+import XCTest
+import MereKit
+@testable import MereCore
+
+@MainActor
+final class TabTests: XCTestCase {
+ func testOpenTab() async {
+ let mock = MockWebContent()
+ let tab = Tab(content: mock)
+ XCTAssertEqual(tab.engine, .webkit)
+ XCTAssertNil(tab.title)
+ }
+}