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) + } +}