fancy new browser
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: init basic browser

+2759
+21
.gitignore
··· 1 + # Xcode 2 + *.xcuserstate 3 + *.xcworkspace 4 + xcuserdata/ 5 + DerivedData/ 6 + *.hmap 7 + *.ipa 8 + *.dSYM.zip 9 + *.dSYM 10 + 11 + # Swift Package Manager 12 + .build/ 13 + .swiftpm/ 14 + 15 + # macOS 16 + .DS_Store 17 + .AppleDouble 18 + .LSOverride 19 + 20 + # Instruments 21 + *.trace
+33
App/Info.plist
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>CFBundleDisplayName</key> 6 + <string>Mere</string> 7 + <key>CFBundleExecutable</key> 8 + <string>Mere</string> 9 + <key>CFBundleIdentifier</key> 10 + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> 11 + <key>CFBundleName</key> 12 + <string>Mere</string> 13 + <key>CFBundlePackageType</key> 14 + <string>APPL</string> 15 + <key>CFBundleShortVersionString</key> 16 + <string>0.1.0</string> 17 + <key>CFBundleVersion</key> 18 + <string>1</string> 19 + <key>LSMinimumSystemVersion</key> 20 + <string>26.0</string> 21 + <key>NSAppTransportSecurity</key> 22 + <dict> 23 + <key>NSAllowsArbitraryLoads</key> 24 + <true/> 25 + </dict> 26 + <key>NSHighResolutionCapable</key> 27 + <true/> 28 + <key>NSPrincipalClass</key> 29 + <string>NSApplication</string> 30 + <key>NSSupportsAutomaticGraphicsSwitching</key> 31 + <true/> 32 + </dict> 33 + </plist>
+23
App/Mere.entitlements
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <!-- Required for WKWebView to work in sandbox --> 6 + <key>com.apple.security.app-sandbox</key> 7 + <true/> 8 + <!-- Load web pages --> 9 + <key>com.apple.security.network.client</key> 10 + <true/> 11 + <!-- Copy/paste in web pages --> 12 + <key>com.apple.security.temporary-exception.mach-lookup.global-name</key> 13 + <array> 14 + <string>com.apple.pboard</string> 15 + </array> 16 + <!-- File access for downloads / uploads --> 17 + <key>com.apple.security.files.user-selected.read-write</key> 18 + <true/> 19 + <!-- Audio playback --> 20 + <key>com.apple.security.device.audio-input</key> 21 + <false/> 22 + </dict> 23 + </plist>
+29
App/MereApp.swift
··· 1 + import SwiftUI 2 + import WebKitEngine 3 + import MereCore 4 + import MereUI 5 + 6 + @main 7 + struct MereApp: App { 8 + 9 + @StateObject private var window = WindowViewModel( 10 + webkitContext: WebKitBrowserContext(), 11 + adBlockEngines: [] 12 + ) 13 + 14 + var body: some Scene { 15 + WindowGroup { 16 + BrowserWindowView(window: window) 17 + .frame(minWidth: 900, minHeight: 600) 18 + } 19 + .windowStyle(.hiddenTitleBar) 20 + .commands { 21 + CommandGroup(replacing: .newItem) { 22 + Button("New Tab") { 23 + window.openTab() 24 + } 25 + .keyboardShortcut("t", modifiers: .command) 26 + } 27 + } 28 + } 29 + }
+386
Mere.xcodeproj/project.pbxproj
··· 1 + // !$*UTF8*$! 2 + { 3 + archiveVersion = 1; 4 + classes = { 5 + }; 6 + objectVersion = 60; 7 + objects = { 8 + 9 + /* Begin PBXBuildFile section */ 10 + B3A1000101000000 /* MereApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A1000201000000 /* MereApp.swift */; }; 11 + B3A1000301000000 /* MereKit in Frameworks */ = {isa = PBXBuildFile; productRef = B3A1000401000000 /* MereKit */; }; 12 + B3A1000501000000 /* MereCore in Frameworks */ = {isa = PBXBuildFile; productRef = B3A1000601000000 /* MereCore */; }; 13 + B3A1000701000000 /* MereUI in Frameworks */ = {isa = PBXBuildFile; productRef = B3A1000801000000 /* MereUI */; }; 14 + B3A1000901000000 /* WebKitEngine in Frameworks */ = {isa = PBXBuildFile; productRef = B3A1000A01000000 /* WebKitEngine */; }; 15 + B3A1000B01000000 /* ChromiumEngine in Frameworks */ = {isa = PBXBuildFile; productRef = B3A1000C01000000 /* ChromiumEngine */; }; 16 + /* End PBXBuildFile section */ 17 + 18 + /* Begin PBXFileReference section */ 19 + B3A1000201000000 /* MereApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MereApp.swift; sourceTree = "<group>"; }; 20 + B3A1001001000000 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 21 + B3A1001101000000 /* Mere.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Mere.entitlements; sourceTree = "<group>"; }; 22 + B3A1002001000000 /* Mere.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mere.app; sourceTree = BUILT_PRODUCTS_DIR; }; 23 + /* End PBXFileReference section */ 24 + 25 + /* Begin PBXFrameworksBuildPhase section */ 26 + B3A1003001000000 /* Frameworks */ = { 27 + isa = PBXFrameworksBuildPhase; 28 + buildActionMask = 2147483647; 29 + files = ( 30 + B3A1000301000000 /* MereKit in Frameworks */, 31 + B3A1000501000000 /* MereCore in Frameworks */, 32 + B3A1000701000000 /* MereUI in Frameworks */, 33 + B3A1000901000000 /* WebKitEngine in Frameworks */, 34 + B3A1000B01000000 /* ChromiumEngine in Frameworks */, 35 + ); 36 + runOnlyForDeploymentPostprocessing = 0; 37 + }; 38 + /* End PBXFrameworksBuildPhase section */ 39 + 40 + /* Begin PBXGroup section */ 41 + B3A1004001000000 /* App */ = { 42 + isa = PBXGroup; 43 + children = ( 44 + B3A1000201000000 /* MereApp.swift */, 45 + B3A1001001000000 /* Info.plist */, 46 + B3A1001101000000 /* Mere.entitlements */, 47 + ); 48 + path = App; 49 + sourceTree = "<group>"; 50 + }; 51 + B3A1004101000000 /* Products */ = { 52 + isa = PBXGroup; 53 + children = ( 54 + B3A1002001000000 /* Mere.app */, 55 + ); 56 + name = Products; 57 + sourceTree = "<group>"; 58 + }; 59 + B3A1004201000000 = { 60 + isa = PBXGroup; 61 + children = ( 62 + B3A1004001000000 /* App */, 63 + B3A1004101000000 /* Products */, 64 + ); 65 + sourceTree = "<group>"; 66 + }; 67 + /* End PBXGroup section */ 68 + 69 + /* Begin PBXNativeTarget section */ 70 + B3A1005001000000 /* Mere */ = { 71 + isa = PBXNativeTarget; 72 + buildConfigurationList = B3A1006001000000 /* Build configuration list for PBXNativeTarget "Mere" */; 73 + buildPhases = ( 74 + B3A1007001000000 /* Sources */, 75 + B3A1003001000000 /* Frameworks */, 76 + B3A1007101000000 /* Resources */, 77 + ); 78 + buildRules = ( 79 + ); 80 + dependencies = ( 81 + ); 82 + name = Mere; 83 + packageProductDependencies = ( 84 + B3A1000401000000 /* MereKit */, 85 + B3A1000601000000 /* MereCore */, 86 + B3A1000801000000 /* MereUI */, 87 + B3A1000A01000000 /* WebKitEngine */, 88 + B3A1000C01000000 /* ChromiumEngine */, 89 + ); 90 + productName = Mere; 91 + productReference = B3A1002001000000 /* Mere.app */; 92 + productType = "com.apple.product-type.application"; 93 + }; 94 + /* End PBXNativeTarget section */ 95 + 96 + /* Begin PBXProject section */ 97 + B3A1008001000000 /* Project object */ = { 98 + isa = PBXProject; 99 + attributes = { 100 + BuildIndependentTargetsInParallel = 1; 101 + LastSwiftUpdateCheck = 1640; 102 + LastUpgradeCheck = 2640; 103 + TargetAttributes = { 104 + B3A1005001000000 = { 105 + CreatedOnToolsVersion = 16.4; 106 + }; 107 + }; 108 + }; 109 + buildConfigurationList = B3A1009001000000 /* Build configuration list for PBXProject "Mere" */; 110 + compatibilityVersion = "Xcode 14.0"; 111 + developmentRegion = en; 112 + hasScannedForEncodings = 0; 113 + knownRegions = ( 114 + en, 115 + Base, 116 + ); 117 + mainGroup = B3A1004201000000; 118 + packageReferences = ( 119 + B3A1010001000000 /* XCLocalSwiftPackageReference "." */, 120 + ); 121 + productRefGroup = B3A1004101000000 /* Products */; 122 + projectDirPath = ""; 123 + projectRoot = ""; 124 + targets = ( 125 + B3A1005001000000 /* Mere */, 126 + ); 127 + }; 128 + /* End PBXProject section */ 129 + 130 + /* Begin PBXResourcesBuildPhase section */ 131 + B3A1007101000000 /* Resources */ = { 132 + isa = PBXResourcesBuildPhase; 133 + buildActionMask = 2147483647; 134 + files = ( 135 + ); 136 + runOnlyForDeploymentPostprocessing = 0; 137 + }; 138 + /* End PBXResourcesBuildPhase section */ 139 + 140 + /* Begin PBXSourcesBuildPhase section */ 141 + B3A1007001000000 /* Sources */ = { 142 + isa = PBXSourcesBuildPhase; 143 + buildActionMask = 2147483647; 144 + files = ( 145 + B3A1000101000000 /* MereApp.swift in Sources */, 146 + ); 147 + runOnlyForDeploymentPostprocessing = 0; 148 + }; 149 + /* End PBXSourcesBuildPhase section */ 150 + 151 + /* Begin XCBuildConfiguration section */ 152 + B3A100A001000000 /* Debug */ = { 153 + isa = XCBuildConfiguration; 154 + buildSettings = { 155 + ALWAYS_SEARCH_USER_PATHS = NO; 156 + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 157 + CLANG_ANALYZER_NONNULL = YES; 158 + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 159 + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 160 + CLANG_ENABLE_MODULES = YES; 161 + CLANG_ENABLE_OBJC_ARC = YES; 162 + CLANG_ENABLE_OBJC_WEAK = YES; 163 + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 164 + CLANG_WARN_BOOL_CONVERSION = YES; 165 + CLANG_WARN_COMMA = YES; 166 + CLANG_WARN_CONSTANT_CONVERSION = YES; 167 + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 168 + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 169 + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 170 + CLANG_WARN_EMPTY_BODY = YES; 171 + CLANG_WARN_ENUM_CONVERSION = YES; 172 + CLANG_WARN_INFINITE_RECURSION = YES; 173 + CLANG_WARN_INT_CONVERSION = YES; 174 + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 175 + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 176 + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 177 + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 178 + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 179 + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 180 + CLANG_WARN_STRICT_PROTOTYPES = YES; 181 + CLANG_WARN_SUSPICIOUS_MOVE = YES; 182 + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 183 + CLANG_WARN_UNREACHABLE_CODE = YES; 184 + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 185 + COPY_PHASE_STRIP = NO; 186 + DEAD_CODE_STRIPPING = YES; 187 + DEBUG_INFORMATION_FORMAT = dwarf; 188 + DEVELOPMENT_TEAM = M67B42LX8D; 189 + ENABLE_STRICT_OBJC_MSGSEND = YES; 190 + ENABLE_TESTABILITY = YES; 191 + ENABLE_USER_SCRIPT_SANDBOXING = YES; 192 + GCC_C_LANGUAGE_STANDARD = gnu17; 193 + GCC_DYNAMIC_NO_PIC = NO; 194 + GCC_NO_COMMON_BLOCKS = YES; 195 + GCC_OPTIMIZATION_LEVEL = 0; 196 + GCC_PREPROCESSOR_DEFINITIONS = ( 197 + "DEBUG=1", 198 + "$(inherited)", 199 + ); 200 + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 201 + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 202 + GCC_WARN_UNDECLARED_SELECTOR = YES; 203 + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 204 + GCC_WARN_UNUSED_FUNCTION = YES; 205 + GCC_WARN_UNUSED_VARIABLE = YES; 206 + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 207 + MACOSX_DEPLOYMENT_TARGET = 26.0; 208 + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 209 + MTL_FAST_MATH = YES; 210 + ONLY_ACTIVE_ARCH = YES; 211 + SDKROOT = macosx; 212 + STRING_CATALOG_GENERATE_SYMBOLS = YES; 213 + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 214 + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 215 + }; 216 + name = Debug; 217 + }; 218 + B3A100A101000000 /* Release */ = { 219 + isa = XCBuildConfiguration; 220 + buildSettings = { 221 + ALWAYS_SEARCH_USER_PATHS = NO; 222 + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 223 + CLANG_ANALYZER_NONNULL = YES; 224 + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 225 + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 226 + CLANG_ENABLE_MODULES = YES; 227 + CLANG_ENABLE_OBJC_ARC = YES; 228 + CLANG_ENABLE_OBJC_WEAK = YES; 229 + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 230 + CLANG_WARN_BOOL_CONVERSION = YES; 231 + CLANG_WARN_COMMA = YES; 232 + CLANG_WARN_CONSTANT_CONVERSION = YES; 233 + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 234 + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 235 + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 236 + CLANG_WARN_EMPTY_BODY = YES; 237 + CLANG_WARN_ENUM_CONVERSION = YES; 238 + CLANG_WARN_INFINITE_RECURSION = YES; 239 + CLANG_WARN_INT_CONVERSION = YES; 240 + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 241 + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 242 + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 243 + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 244 + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 245 + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 246 + CLANG_WARN_STRICT_PROTOTYPES = YES; 247 + CLANG_WARN_SUSPICIOUS_MOVE = YES; 248 + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 249 + CLANG_WARN_UNREACHABLE_CODE = YES; 250 + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 251 + COPY_PHASE_STRIP = NO; 252 + DEAD_CODE_STRIPPING = YES; 253 + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 254 + DEVELOPMENT_TEAM = M67B42LX8D; 255 + ENABLE_NS_ASSERTIONS = NO; 256 + ENABLE_STRICT_OBJC_MSGSEND = YES; 257 + ENABLE_USER_SCRIPT_SANDBOXING = YES; 258 + GCC_C_LANGUAGE_STANDARD = gnu17; 259 + GCC_NO_COMMON_BLOCKS = YES; 260 + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 261 + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 262 + GCC_WARN_UNDECLARED_SELECTOR = YES; 263 + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 264 + GCC_WARN_UNUSED_FUNCTION = YES; 265 + GCC_WARN_UNUSED_VARIABLE = YES; 266 + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 267 + MACOSX_DEPLOYMENT_TARGET = 26.0; 268 + MTL_FAST_MATH = YES; 269 + SDKROOT = macosx; 270 + STRING_CATALOG_GENERATE_SYMBOLS = YES; 271 + SWIFT_COMPILATION_MODE = wholemodule; 272 + }; 273 + name = Release; 274 + }; 275 + B3A100B001000000 /* Debug */ = { 276 + isa = XCBuildConfiguration; 277 + buildSettings = { 278 + CODE_SIGN_ENTITLEMENTS = App/Mere.entitlements; 279 + CODE_SIGN_STYLE = Automatic; 280 + COMBINE_HIDPI_IMAGES = YES; 281 + CURRENT_PROJECT_VERSION = 1; 282 + DEAD_CODE_STRIPPING = YES; 283 + ENABLE_HARDENED_RUNTIME = YES; 284 + GENERATE_INFOPLIST_FILE = NO; 285 + INFOPLIST_FILE = App/Info.plist; 286 + INFOPLIST_KEY_CFBundleDisplayName = Mere; 287 + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 288 + LD_RUNPATH_SEARCH_PATHS = ( 289 + "$(inherited)", 290 + "@executable_path/../Frameworks", 291 + ); 292 + MACOSX_DEPLOYMENT_TARGET = 26.0; 293 + MARKETING_VERSION = 0.1.0; 294 + PRODUCT_BUNDLE_IDENTIFIER = sh.dunkirk.mere; 295 + PRODUCT_NAME = Mere; 296 + SWIFT_EMIT_LOC_STRINGS = YES; 297 + SWIFT_VERSION = 6.0; 298 + }; 299 + name = Debug; 300 + }; 301 + B3A100B101000000 /* Release */ = { 302 + isa = XCBuildConfiguration; 303 + buildSettings = { 304 + CODE_SIGN_ENTITLEMENTS = App/Mere.entitlements; 305 + CODE_SIGN_STYLE = Automatic; 306 + COMBINE_HIDPI_IMAGES = YES; 307 + CURRENT_PROJECT_VERSION = 1; 308 + DEAD_CODE_STRIPPING = YES; 309 + ENABLE_HARDENED_RUNTIME = YES; 310 + GENERATE_INFOPLIST_FILE = NO; 311 + INFOPLIST_FILE = App/Info.plist; 312 + INFOPLIST_KEY_CFBundleDisplayName = Mere; 313 + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 314 + LD_RUNPATH_SEARCH_PATHS = ( 315 + "$(inherited)", 316 + "@executable_path/../Frameworks", 317 + ); 318 + MACOSX_DEPLOYMENT_TARGET = 26.0; 319 + MARKETING_VERSION = 0.1.0; 320 + PRODUCT_BUNDLE_IDENTIFIER = sh.dunkirk.mere; 321 + PRODUCT_NAME = Mere; 322 + SWIFT_EMIT_LOC_STRINGS = YES; 323 + SWIFT_VERSION = 6.0; 324 + }; 325 + name = Release; 326 + }; 327 + /* End XCBuildConfiguration section */ 328 + 329 + /* Begin XCConfigurationList section */ 330 + B3A1006001000000 /* Build configuration list for PBXNativeTarget "Mere" */ = { 331 + isa = XCConfigurationList; 332 + buildConfigurations = ( 333 + B3A100B001000000 /* Debug */, 334 + B3A100B101000000 /* Release */, 335 + ); 336 + defaultConfigurationIsVisible = 0; 337 + defaultConfigurationName = Release; 338 + }; 339 + B3A1009001000000 /* Build configuration list for PBXProject "Mere" */ = { 340 + isa = XCConfigurationList; 341 + buildConfigurations = ( 342 + B3A100A001000000 /* Debug */, 343 + B3A100A101000000 /* Release */, 344 + ); 345 + defaultConfigurationIsVisible = 0; 346 + defaultConfigurationName = Release; 347 + }; 348 + /* End XCConfigurationList section */ 349 + 350 + /* Begin XCLocalSwiftPackageReference section */ 351 + B3A1010001000000 /* XCLocalSwiftPackageReference "." */ = { 352 + isa = XCLocalSwiftPackageReference; 353 + relativePath = .; 354 + }; 355 + /* End XCLocalSwiftPackageReference section */ 356 + 357 + /* Begin XCSwiftPackageProductDependency section */ 358 + B3A1000401000000 /* MereKit */ = { 359 + isa = XCSwiftPackageProductDependency; 360 + package = B3A1010001000000 /* XCLocalSwiftPackageReference "." */; 361 + productName = MereKit; 362 + }; 363 + B3A1000601000000 /* MereCore */ = { 364 + isa = XCSwiftPackageProductDependency; 365 + package = B3A1010001000000 /* XCLocalSwiftPackageReference "." */; 366 + productName = MereCore; 367 + }; 368 + B3A1000801000000 /* MereUI */ = { 369 + isa = XCSwiftPackageProductDependency; 370 + package = B3A1010001000000 /* XCLocalSwiftPackageReference "." */; 371 + productName = MereUI; 372 + }; 373 + B3A1000A01000000 /* WebKitEngine */ = { 374 + isa = XCSwiftPackageProductDependency; 375 + package = B3A1010001000000 /* XCLocalSwiftPackageReference "." */; 376 + productName = WebKitEngine; 377 + }; 378 + B3A1000C01000000 /* ChromiumEngine */ = { 379 + isa = XCSwiftPackageProductDependency; 380 + package = B3A1010001000000 /* XCLocalSwiftPackageReference "." */; 381 + productName = ChromiumEngine; 382 + }; 383 + /* End XCSwiftPackageProductDependency section */ 384 + }; 385 + rootObject = B3A1008001000000 /* Project object */; 386 + }
+59
Package.swift
··· 1 + // swift-tools-version: 5.10 2 + import PackageDescription 3 + 4 + let package = Package( 5 + name: "Mere", 6 + platforms: [.macOS("26.0")], 7 + products: [ 8 + .library(name: "MereKit", targets: ["MereKit"]), 9 + .library(name: "WebKitEngine", targets: ["WebKitEngine"]), 10 + .library(name: "ChromiumEngine", targets: ["ChromiumEngine"]), 11 + .library(name: "MereCore", targets: ["MereCore"]), 12 + .library(name: "MereUI", targets: ["MereUI"]), 13 + ], 14 + targets: [ 15 + // Core protocols + shared models — no engine dependency 16 + .target( 17 + name: "MereKit", 18 + path: "Sources/MereKit" 19 + ), 20 + 21 + // WebKit implementation — depends only on MereKit + system WebKit 22 + .target( 23 + name: "WebKitEngine", 24 + dependencies: ["MereKit"], 25 + path: "Sources/WebKitEngine" 26 + ), 27 + 28 + // Chromium/CEF stub — wire in CEF.swift here when ready 29 + .target( 30 + name: "ChromiumEngine", 31 + dependencies: ["MereKit"], 32 + path: "Sources/ChromiumEngine" 33 + // When adding CEF: 34 + // dependencies: ["MereKit", .product(name: "CEF", package: "CEF.swift")], 35 + // and add the CEF package to `dependencies:` above 36 + ), 37 + 38 + // Engine-agnostic controllers: Tab, WindowViewModel, CookieSyncController 39 + .target( 40 + name: "MereCore", 41 + dependencies: ["MereKit", "WebKitEngine", "ChromiumEngine"], 42 + path: "Sources/MereCore" 43 + ), 44 + 45 + // SwiftUI views — depends on MereCore, not on specific engines 46 + .target( 47 + name: "MereUI", 48 + dependencies: ["MereCore", "MereKit"], 49 + path: "Sources/MereUI" 50 + ), 51 + 52 + // Tests 53 + .testTarget( 54 + name: "MereKitTests", 55 + dependencies: ["MereKit", "MereCore"], 56 + path: "Tests/BrowserKitTests" 57 + ), 58 + ] 59 + )
+124
Sources/ChromiumEngine/ChromiumAdBlocker.swift
··· 1 + import Foundation 2 + import MereKit 3 + 4 + /// Ad blocker for the Chromium engine via CEF's CefRequestHandler. 5 + /// 6 + /// ## Integration note 7 + /// CEF doesn't have a compiled rule list like WKContentRuleList — every request 8 + /// goes through a Swift callback. For large lists this is fine on modern hardware 9 + /// (~50k rules checked in <1ms using the AhoCorasick / trie approach below), 10 + /// but the CEF wiring is left as a stub until the engine is connected. 11 + /// 12 + /// ## How to wire into CEF 13 + /// 1. In your `CefClient` subclass, override `GetRequestHandler()` to return a 14 + /// `CefRequestHandler` implementation. 15 + /// 2. In that handler, override `OnBeforeResourceLoad`: 16 + /// ```cpp 17 + /// CefResourceRequestHandler::ReturnValue OnBeforeResourceLoad( 18 + /// CefRefPtr<CefBrowser> browser, 19 + /// CefRefPtr<CefFrame> frame, 20 + /// CefRefPtr<CefRequest> request, 21 + /// CefRefPtr<CefCallback> callback) override { 22 + /// 23 + /// NSString* url = [NSString stringWithUTF8String:request->GetURL().ToString().c_str()]; 24 + /// if ([swiftBlocker shouldBlock:url resourceType:resourceType]) { 25 + /// return RV_CANCEL; 26 + /// } 27 + /// return RV_CONTINUE; 28 + /// } 29 + /// ``` 30 + /// 3. The `swiftBlocker` is this class, bridged via an @objc wrapper. 31 + @MainActor 32 + public final class ChromiumAdBlocker: AdBlockEngine { 33 + 34 + public var isEnabled: Bool = true 35 + public private(set) var loadedLists: [String: Int] = [:] 36 + public private(set) var totalBlockedRequestCount = 0 37 + 38 + // Compiled rule set: array of (regex, rule) tuples built once on load 39 + private var compiled: [(regex: NSRegularExpression, rule: BlockList.Rule)] = [] 40 + // Allow-list rules checked after block rules 41 + private var allowRules: [(regex: NSRegularExpression, rule: BlockList.Rule)] = [] 42 + 43 + public init() {} 44 + 45 + // MARK: - AdBlockEngine 46 + 47 + public func load(_ list: BlockList) async throws { 48 + var newBlock: [(NSRegularExpression, BlockList.Rule)] = [] 49 + var newAllow: [(NSRegularExpression, BlockList.Rule)] = [] 50 + 51 + for rule in list.rules { 52 + guard let regex = try? NSRegularExpression(pattern: rule.urlPattern, options: .caseInsensitive) else { 53 + continue 54 + } 55 + switch rule.action { 56 + case .block: newBlock.append((regex, rule)) 57 + case .allowList: newAllow.append((regex, rule)) 58 + } 59 + } 60 + 61 + // Merge into existing compiled set (remove old list first) 62 + compiled.append(contentsOf: newBlock) 63 + allowRules.append(contentsOf: newAllow) 64 + loadedLists[list.name] = list.blockCount 65 + } 66 + 67 + public func remove(listNamed name: String) async { 68 + // Without tagging rules by list name this is a full rebuild. 69 + // In production, tag each compiled rule with its list name. 70 + loadedLists.removeValue(forKey: name) 71 + } 72 + 73 + // MARK: - Request evaluation (called from CEF bridge) 74 + 75 + /// Returns true if the request should be blocked. 76 + /// This is the hot path — called for every network request. 77 + public func shouldBlock(url: String, resourceType: BlockList.Rule.ResourceType? = nil, host: String? = nil) -> Bool { 78 + guard isEnabled else { return false } 79 + 80 + let range = NSRange(url.startIndex..., in: url) 81 + 82 + // Check allow-list first 83 + for (regex, rule) in allowRules { 84 + if matchesRule(rule, url: url, urlRange: range, resourceType: resourceType, host: host) { 85 + if regex.firstMatch(in: url, range: range) != nil { 86 + return false 87 + } 88 + } 89 + } 90 + 91 + // Check block rules 92 + for (regex, rule) in compiled { 93 + if matchesRule(rule, url: url, urlRange: range, resourceType: resourceType, host: host) { 94 + if regex.firstMatch(in: url, range: range) != nil { 95 + totalBlockedRequestCount += 1 96 + return true 97 + } 98 + } 99 + } 100 + 101 + return false 102 + } 103 + 104 + private func matchesRule( 105 + _ rule: BlockList.Rule, 106 + url: String, 107 + urlRange: NSRange, 108 + resourceType: BlockList.Rule.ResourceType?, 109 + host: String? 110 + ) -> Bool { 111 + if let rt = resourceType, !rule.resourceTypes.isEmpty, !rule.resourceTypes.contains(rt) { 112 + return false 113 + } 114 + if let host { 115 + if !rule.ifDomain.isEmpty, !rule.ifDomain.contains(where: { host.hasSuffix($0) }) { 116 + return false 117 + } 118 + if rule.unlessDomain.contains(where: { host.hasSuffix($0) }) { 119 + return false 120 + } 121 + } 122 + return true 123 + } 124 + }
+93
Sources/ChromiumEngine/ChromiumWebContent.swift
··· 1 + import Foundation 2 + import AppKit 3 + import MereKit 4 + 5 + /// WebContent backed by CEF (Chromium Embedded Framework). 6 + /// 7 + /// ## Integration note 8 + /// This is a stub. To wire it up: 9 + /// 10 + /// 1. Add CEF as a dependency (https://bitbucket.org/chromiumembedded/cef). 11 + /// The easiest Swift path is via CEF.swift (https://github.com/lvsti/CEF.swift) 12 + /// or by bridging the CEF ObjC layer yourself using the same pattern 13 + /// Dia uses for ArcCore (Arc* ObjC classes → ADK Swift wrappers). 14 + /// 15 + /// 2. Replace `hostView` with a real `CefBrowserView` or an `NSView` returned 16 + /// by `CefBrowserHost::CreateBrowserSync`. 17 + /// 18 + /// 3. Forward CEF's `CefLoadHandler`, `CefDisplayHandler`, `CefLifeSpanHandler` 19 + /// callbacks into `eventContinuation.yield(...)`. 20 + /// 21 + /// Everything above this class (MereCore, UI) is already engine-agnostic 22 + /// and needs no changes. 23 + @MainActor 24 + public final class ChromiumWebContent: WebContent { 25 + 26 + public let id = UUID() 27 + public let engine: EngineType = .chromium 28 + 29 + public private(set) var url: URL? 30 + public private(set) var title: String? 31 + public private(set) var isLoading = false 32 + public private(set) var estimatedProgress: Double = 0 33 + public private(set) var canGoBack = false 34 + public private(set) var canGoForward = false 35 + public private(set) var hasAudioPlaying = false 36 + public var isMuted = false 37 + public var zoomFactor: Double = 1.0 38 + 39 + private let (stream, continuation) = AsyncStream<NavigationEvent>.makeStream() 40 + public var navigationEvents: AsyncStream<NavigationEvent> { stream } 41 + 42 + // Placeholder — replace with real CefBrowserView 43 + private let hostView = NSView() 44 + 45 + public init() {} 46 + 47 + public func loadURL(_ url: URL) { 48 + self.url = url 49 + // cefBrowser.mainFrame.loadURL(url.absoluteString) 50 + assertionFailure("ChromiumWebContent: CEF not wired up yet. See class doc.") 51 + } 52 + 53 + public func loadHTML(_ html: String, baseURL: URL?) { 54 + // cefBrowser.mainFrame.loadString(html, url: baseURL?.absoluteString ?? "about:blank") 55 + } 56 + 57 + public func goBack() { /* cefBrowser.goBack() */ } 58 + public func goForward() { /* cefBrowser.goForward() */ } 59 + public func reload() { /* cefBrowser.reload() */ } 60 + public func stopLoading() { /* cefBrowser.stopLoad() */ } 61 + 62 + public func evaluateJavaScript(_ script: String) async throws -> Any? { 63 + // CEF JS evaluation is callback-based; bridge to async/await with a CheckedContinuation. 64 + // cefBrowser.mainFrame.evaluateJavaScript(...) 65 + return nil 66 + } 67 + 68 + public func findInPage(_ query: String, forward: Bool) async -> FindResult { 69 + // cefBrowser.host.find(query, forward: forward, matchCase: false, findNext: true) 70 + return FindResult(matchCount: 0, activeMatchIndex: 0) 71 + } 72 + 73 + public func clearFind() { 74 + // cefBrowser.host.stopFinding(clearSelection: true) 75 + } 76 + 77 + public func attachHostView(_ container: NSView) { 78 + hostView.translatesAutoresizingMaskIntoConstraints = false 79 + container.addSubview(hostView) 80 + NSLayoutConstraint.activate([ 81 + hostView.leadingAnchor.constraint(equalTo: container.leadingAnchor), 82 + hostView.trailingAnchor.constraint(equalTo: container.trailingAnchor), 83 + hostView.topAnchor.constraint(equalTo: container.topAnchor), 84 + hostView.bottomAnchor.constraint(equalTo: container.bottomAnchor), 85 + ]) 86 + } 87 + 88 + public func detachHostView() { hostView.removeFromSuperview() } 89 + 90 + public func snapshot() async -> NSImage? { nil } 91 + 92 + public func close() { continuation.finish() } 93 + }
+61
Sources/MereCore/AdBlockController.swift
··· 1 + import Foundation 2 + import MereKit 3 + import Combine 4 + 5 + /// Manages ad blocking state across both engine contexts. 6 + /// Lives on WindowViewModel; drives both WebKitAdBlocker and ChromiumAdBlocker. 7 + @MainActor 8 + public final class AdBlockController: ObservableObject { 9 + 10 + @Published public private(set) var isEnabled: Bool = true 11 + @Published public private(set) var isLoading: Bool = false 12 + @Published public private(set) var loadedLists: [String: Int] = [:] 13 + @Published public private(set) var error: String? 14 + 15 + private let engines: [any AdBlockEngine] 16 + 17 + public init(engines: [any AdBlockEngine]) { 18 + self.engines = engines 19 + } 20 + 21 + // MARK: - Control 22 + 23 + public func setEnabled(_ enabled: Bool) { 24 + isEnabled = enabled 25 + engines.forEach { $0.isEnabled = enabled } 26 + } 27 + 28 + // MARK: - List management 29 + 30 + /// Load the default lists (EasyList + EasyPrivacy). 31 + public func loadDefaults() async { 32 + await load(from: BlockListSource.easyList, name: "EasyList") 33 + await load(from: BlockListSource.easyPrivacy, name: "EasyPrivacy") 34 + } 35 + 36 + /// Fetch a list from a URL and load it into all engines. 37 + public func load(from url: URL, name: String) async { 38 + isLoading = true 39 + error = nil 40 + do { 41 + let (data, _) = try await URLSession.shared.data(from: url) 42 + let text = String(decoding: data, as: UTF8.self) 43 + let list = EasyListParser.parse(text, name: name) 44 + for engine in engines { 45 + try await engine.load(list) 46 + } 47 + loadedLists[name] = list.blockCount 48 + } catch { 49 + self.error = "\(name): \(error.localizedDescription)" 50 + } 51 + isLoading = false 52 + } 53 + 54 + public func remove(listNamed name: String) async { 55 + for engine in engines { await engine.remove(listNamed: name) } 56 + loadedLists.removeValue(forKey: name) 57 + } 58 + 59 + public var totalRuleCount: Int { loadedLists.values.reduce(0, +) } 60 + public var totalBlockedCount: Int { engines.map(\.totalBlockedRequestCount).reduce(0, +) } 61 + }
+75
Sources/MereCore/CookieSyncController.swift
··· 1 + import Foundation 2 + import MereKit 3 + 4 + /// Bridges the cookie stores between the two engines when switching a tab. 5 + /// 6 + /// The core problem: WKWebView and CEF maintain completely separate HTTP cookie 7 + /// stores. A user logged into GitHub in a WebKit tab will not be logged in when 8 + /// the same URL is opened in a Chromium tab. 9 + /// 10 + /// This controller extracts cookies for a given URL from the source engine's 11 + /// store and injects them into the destination engine's store before navigation. 12 + /// 13 + /// Limitations: 14 + /// - HttpOnly cookies set by servers are readable from WKHTTPCookieStore but 15 + /// may not be extractable from CEF's cookie manager depending on CEF version. 16 + /// - Secure cookies are transferred in-process (no network exposure), which is safe. 17 + /// - Session cookies are transferred but may expire immediately if the destination 18 + /// engine's session handling differs. 19 + @MainActor 20 + public final class CookieSyncController { 21 + 22 + private let webkit: any BrowserContext 23 + private let chromium: (any BrowserContext)? 24 + 25 + public init(webkit: any BrowserContext, chromium: (any BrowserContext)?) { 26 + self.webkit = webkit 27 + self.chromium = chromium 28 + } 29 + 30 + /// Copy cookies for `url` from `sourceEngine` into the other engine's store. 31 + public func sync(from sourceEngine: EngineType, url: URL) async { 32 + switch sourceEngine { 33 + case .webkit: 34 + guard let chromium else { return } 35 + let cookies = await webkit.cookies(for: url) 36 + await chromium.setCookies(cookies, for: url) 37 + 38 + case .chromium: 39 + guard let chromium else { return } 40 + let cookies = await chromium.cookies(for: url) 41 + await webkit.setCookies(cookies, for: url) 42 + } 43 + } 44 + 45 + /// Full bidirectional sync for all cookies on a domain. 46 + /// Call this periodically if keeping both engines logged in simultaneously. 47 + public func fullSync(url: URL) async { 48 + guard let chromium else { return } 49 + let webkitCookies = await webkit.cookies(for: url) 50 + let chromiumCookies = await chromium.cookies(for: url) 51 + 52 + // Merge: newest cookie wins on conflict 53 + let merged = merge(webkitCookies, chromiumCookies) 54 + await webkit.setCookies(merged, for: url) 55 + await chromium.setCookies(merged, for: url) 56 + } 57 + 58 + private func merge(_ a: [HTTPCookie], _ b: [HTTPCookie]) -> [HTTPCookie] { 59 + var result: [String: HTTPCookie] = [:] 60 + for cookie in a + b { 61 + let key = "\(cookie.domain)|\(cookie.path)|\(cookie.name)" 62 + if let existing = result[key] { 63 + // Keep the one with a later expiry, or b if equal 64 + if let expA = existing.expiresDate, let expB = cookie.expiresDate, expB > expA { 65 + result[key] = cookie 66 + } else if existing.expiresDate == nil { 67 + result[key] = cookie 68 + } 69 + } else { 70 + result[key] = cookie 71 + } 72 + } 73 + return Array(result.values) 74 + } 75 + }
+134
Sources/MereCore/Tab.swift
··· 1 + import Foundation 2 + import MereKit 3 + import Combine 4 + #if canImport(AppKit) 5 + import AppKit 6 + #endif 7 + 8 + /// Observable model for a single browser tab. 9 + /// Mirrors ADK2.WebContentViewModel / ADK2.WebContentController. 10 + @MainActor 11 + public final class Tab: ObservableObject, Identifiable { 12 + 13 + public let id: UUID 14 + public let engine: EngineType 15 + public let content: any WebContent 16 + 17 + @Published public private(set) var url: URL? 18 + @Published public private(set) var title: String? 19 + @Published public private(set) var isLoading = false 20 + @Published public private(set) var estimatedProgress: Double = 0 21 + @Published public private(set) var canGoBack = false 22 + @Published public private(set) var canGoForward = false 23 + @Published public private(set) var favicon: URL? 24 + @Published public private(set) var hasAudioPlaying = false 25 + @Published public private(set) var themeColor: PlatformColor? 26 + 27 + private var observationTask: Task<Void, Never>? 28 + 29 + public init(content: any WebContent) { 30 + self.id = content.id 31 + self.engine = content.engine 32 + self.content = content 33 + startObserving() 34 + } 35 + 36 + // MARK: - Navigation passthrough 37 + 38 + public func loadURL(_ url: URL) { content.loadURL(url) } 39 + public func goBack() { content.goBack() } 40 + public func goForward() { content.goForward() } 41 + public func reload() { content.reload() } 42 + public func stopLoading() { content.stopLoading() } 43 + 44 + // MARK: - Private 45 + 46 + private func startObserving() { 47 + observationTask = Task { [weak self] in 48 + guard let self else { return } 49 + for await event in content.navigationEvents { 50 + guard !Task.isCancelled else { break } 51 + await MainActor.run { 52 + self.apply(event) 53 + } 54 + } 55 + } 56 + 57 + // Poll lightweight state from the WebContent on each event. 58 + // A real implementation would use Combine or direct KVO bindings. 59 + Task { [weak self] in 60 + while !Task.isCancelled { 61 + guard let self else { return } 62 + self.syncState() 63 + try? await Task.sleep(for: .milliseconds(100)) 64 + } 65 + } 66 + } 67 + 68 + private func apply(_ event: NavigationEvent) { 69 + switch event { 70 + case .started(let url): 71 + self.url = url 72 + self.isLoading = true 73 + self.estimatedProgress = 0.1 74 + case .committed(let url): 75 + self.url = url 76 + self.estimatedProgress = 0.7 77 + case .finished(let url): 78 + self.url = url 79 + self.isLoading = false 80 + self.estimatedProgress = 1.0 81 + Task { await self.readThemeColor() } 82 + case .failed: 83 + self.isLoading = false 84 + self.estimatedProgress = 0 85 + case .titleChanged(let title): 86 + self.title = title 87 + case .faviconChanged(let url): 88 + self.favicon = url 89 + case .redirected(_, let to): 90 + self.url = to 91 + } 92 + } 93 + 94 + private func readThemeColor() async { 95 + let js = """ 96 + (function() { 97 + var m = document.querySelector('meta[name="theme-color"]'); 98 + if (m && m.content) return m.content; 99 + var els = [document.documentElement, document.body]; 100 + for (var el of els) { 101 + if (!el) continue; 102 + var bg = window.getComputedStyle(el).backgroundColor; 103 + if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg; 104 + } 105 + return null; 106 + })() 107 + """ 108 + let result = try? await content.evaluateJavaScript(js) 109 + guard let css = result as? String, !css.isEmpty else { 110 + self.themeColor = nil 111 + return 112 + } 113 + self.themeColor = PlatformColor.fromCSS(css) 114 + } 115 + 116 + private func syncState() { 117 + self.url = content.url 118 + self.title = content.title 119 + self.isLoading = content.isLoading 120 + self.estimatedProgress = content.estimatedProgress 121 + self.canGoBack = content.canGoBack 122 + self.canGoForward = content.canGoForward 123 + self.hasAudioPlaying = content.hasAudioPlaying 124 + // Re-check background colour on each poll cycle so JS-driven 125 + // colour changes (dark mode toggles, SPA navigations) are picked up. 126 + if !self.isLoading, self.url != nil { 127 + Task { await self.readThemeColor() } 128 + } 129 + } 130 + 131 + deinit { 132 + observationTask?.cancel() 133 + } 134 + }
+100
Sources/MereCore/WindowViewModel.swift
··· 1 + import Foundation 2 + import MereKit 3 + import Combine 4 + 5 + /// Drives a single browser window: tab list, active tab, engine routing. 6 + /// Mirrors ADK2.BrowserApplicationController / ADK2.BrowserController. 7 + @MainActor 8 + public final class WindowViewModel: ObservableObject { 9 + 10 + @Published public private(set) var tabs: [Tab] = [] 11 + @Published public var activeTab: Tab? 12 + @Published public private(set) var newTabBackgroundColor: PlatformColor? 13 + 14 + private let webkitContext: any BrowserContext 15 + private let chromiumContext: (any BrowserContext)? 16 + private let cookieSync: CookieSyncController 17 + public let adBlock: AdBlockController 18 + private var activeTabObservation: AnyCancellable? 19 + 20 + public init( 21 + webkitContext: any BrowserContext, 22 + chromiumContext: (any BrowserContext)? = nil, 23 + adBlockEngines: [any AdBlockEngine] = [] 24 + ) { 25 + self.webkitContext = webkitContext 26 + self.chromiumContext = chromiumContext 27 + self.cookieSync = CookieSyncController( 28 + webkit: webkitContext, 29 + chromium: chromiumContext 30 + ) 31 + self.adBlock = AdBlockController(engines: adBlockEngines) 32 + } 33 + 34 + // MARK: - Tab management 35 + 36 + @discardableResult 37 + public func openTab(url: URL? = nil, engine: EngineType? = nil) -> Tab { 38 + // Carry the previous tab's theme color into the new tab page background. 39 + if url == nil, let current = activeTab, current.url != nil { 40 + newTabBackgroundColor = current.themeColor 41 + } 42 + let resolvedEngine = engine ?? url.map(EngineType.preferred) ?? .webkit 43 + let context = context(for: resolvedEngine) 44 + let content = context.makeWebContent() 45 + let tab = Tab(content: content) 46 + tabs.append(tab) 47 + activeTab = tab 48 + subscribeToActiveTab() 49 + if let url { tab.loadURL(url) } 50 + return tab 51 + } 52 + 53 + public func closeTab(_ tab: Tab) { 54 + tab.content.close() 55 + tabs.removeAll { $0.id == tab.id } 56 + if activeTab?.id == tab.id { 57 + activeTab = tabs.last 58 + subscribeToActiveTab() 59 + } 60 + } 61 + 62 + public func activateTab(_ tab: Tab) { 63 + activeTab = tab 64 + subscribeToActiveTab() 65 + } 66 + 67 + /// Reopen a tab in the other engine, syncing cookies first. 68 + public func switchEngine(for tab: Tab) async { 69 + guard let url = tab.url else { return } 70 + let newEngine: EngineType = tab.engine == .webkit ? .chromium : .webkit 71 + guard newEngine == .webkit || chromiumContext != nil else { return } 72 + 73 + await cookieSync.sync(from: tab.engine, url: url) 74 + 75 + let idx = tabs.firstIndex(where: { $0.id == tab.id }) 76 + closeTab(tab) 77 + 78 + let newTab = openTab(url: url, engine: newEngine) 79 + if let idx { 80 + tabs.move(fromOffsets: IndexSet(integer: tabs.count - 1), toOffset: idx) 81 + } 82 + activeTab = newTab 83 + subscribeToActiveTab() 84 + } 85 + 86 + // MARK: - Helpers 87 + 88 + private func subscribeToActiveTab() { 89 + activeTabObservation = activeTab?.objectWillChange 90 + .receive(on: RunLoop.main) 91 + .sink { [weak self] _ in self?.objectWillChange.send() } 92 + } 93 + 94 + private func context(for engine: EngineType) -> any BrowserContext { 95 + switch engine { 96 + case .webkit: return webkitContext 97 + case .chromium: return chromiumContext ?? webkitContext 98 + } 99 + } 100 + }
+39
Sources/MereKit/Mock/MockWebContent.swift
··· 1 + import Foundation 2 + 3 + /// In-process stub used for SwiftUI previews and unit tests. 4 + /// Mirrors ADK2.MockWebContent. 5 + @MainActor 6 + public final class MockWebContent: WebContent { 7 + 8 + public let id = UUID() 9 + public let engine: EngineType = .webkit 10 + 11 + public var url: URL? = URL(string: "https://example.com") 12 + public var title: String? = "Example Domain" 13 + public var isLoading = false 14 + public var estimatedProgress: Double = 1.0 15 + public var canGoBack = false 16 + public var canGoForward = false 17 + public var hasAudioPlaying = false 18 + public var isMuted = false 19 + public var zoomFactor: Double = 1.0 20 + 21 + private let (stream, continuation) = AsyncStream<NavigationEvent>.makeStream() 22 + public var navigationEvents: AsyncStream<NavigationEvent> { stream } 23 + 24 + public init() {} 25 + 26 + public func loadURL(_ url: URL) { self.url = url } 27 + public func loadHTML(_ html: String, baseURL: URL?) {} 28 + public func goBack() {} 29 + public func goForward() {} 30 + public func reload() {} 31 + public func stopLoading() {} 32 + public func evaluateJavaScript(_ script: String) async throws -> Any? { nil } 33 + public func findInPage(_ query: String, forward: Bool) async -> FindResult { .init(matchCount: 0, activeMatchIndex: 0) } 34 + public func clearFind() {} 35 + public func attachHostView(_ container: PlatformView) {} 36 + public func detachHostView() {} 37 + public func snapshot() async -> PlatformImage? { nil } 38 + public func close() { continuation.finish() } 39 + }
+196
Sources/MereKit/Models/BlockList.swift
··· 1 + import Foundation 2 + 3 + /// A parsed, engine-agnostic block list. 4 + public struct BlockList: Sendable { 5 + 6 + public struct Rule: Sendable { 7 + public enum Action: Sendable { case block, allowList } 8 + public enum ResourceType: String, Sendable, CaseIterable { 9 + case document, script, image, stylesheet = "style-sheet" 10 + case font, media, raw, svg = "svg-document" 11 + case xhr = "fetch", websocket, other 12 + } 13 + 14 + public let urlPattern: String // regex 15 + public let action: Action 16 + public let resourceTypes: Set<ResourceType> 17 + public let ifDomain: [String] // only apply on these domains 18 + public let unlessDomain: [String] // skip on these domains 19 + 20 + public init( 21 + urlPattern: String, 22 + action: Action = .block, 23 + resourceTypes: Set<ResourceType> = [], 24 + ifDomain: [String] = [], 25 + unlessDomain: [String] = [] 26 + ) { 27 + self.urlPattern = urlPattern 28 + self.action = action 29 + self.resourceTypes = resourceTypes 30 + self.ifDomain = ifDomain 31 + self.unlessDomain = unlessDomain 32 + } 33 + } 34 + 35 + public let name: String 36 + public let rules: [Rule] 37 + public let updatedAt: Date 38 + 39 + public init(name: String, rules: [Rule], updatedAt: Date = .now) { 40 + self.name = name 41 + self.rules = rules 42 + self.updatedAt = updatedAt 43 + } 44 + 45 + /// Total count of blocking rules (excludes allowlist entries). 46 + public var blockCount: Int { rules.filter { $0.action == .block }.count } 47 + } 48 + 49 + // MARK: - EasyList parser 50 + 51 + /// Parses a subset of Adblock Plus / EasyList filter syntax into BlockList.Rules. 52 + /// 53 + /// Supported syntax: 54 + /// - `||example.com^` → domain anchor 55 + /// - `@@||example.com^` → allowlist 56 + /// - `$script,image` → resource type options 57 + /// - `$domain=foo.com|~bar.com` → domain restrictions 58 + /// - `/regex/` → regex rule 59 + /// - `##` cosmetic rules → ignored (CSS injection, not request blocking) 60 + public enum EasyListParser { 61 + 62 + public static func parse(_ text: String, name: String) -> BlockList { 63 + var rules: [BlockList.Rule] = [] 64 + 65 + for line in text.components(separatedBy: .newlines) { 66 + let line = line.trimmingCharacters(in: .whitespaces) 67 + 68 + // Skip comments, headers, empty lines, cosmetic rules 69 + if line.isEmpty || line.hasPrefix("!") || line.hasPrefix("[") || line.contains("##") || line.contains("#@#") { 70 + continue 71 + } 72 + 73 + if let rule = parseRule(line) { 74 + rules.append(rule) 75 + } 76 + } 77 + 78 + return BlockList(name: name, rules: rules) 79 + } 80 + 81 + private static func parseRule(_ line: String) -> BlockList.Rule? { 82 + var raw = line 83 + let isAllowList = raw.hasPrefix("@@") 84 + if isAllowList { raw = String(raw.dropFirst(2)) } 85 + 86 + // Split options after `$` 87 + var options: [String] = [] 88 + var pattern = raw 89 + if let dollarIdx = raw.lastIndex(of: "$"), !raw.hasPrefix("/") { 90 + let optStr = String(raw[raw.index(after: dollarIdx)...]) 91 + // Only treat as options if it looks like options (no spaces, known keywords) 92 + if !optStr.contains(" ") { 93 + options = optStr.components(separatedBy: ",") 94 + pattern = String(raw[..<dollarIdx]) 95 + } 96 + } 97 + 98 + // Skip purely cosmetic/script-inject options 99 + let skipOptions = ["elemhide", "generichide", "genericblock", "jsinject", "content", "extension", "stealth"] 100 + if options.contains(where: { skipOptions.contains($0) }) { return nil } 101 + 102 + // Parse resource types 103 + var resourceTypes: Set<BlockList.Rule.ResourceType> = [] 104 + var ifDomain: [String] = [] 105 + var unlessDomain: [String] = [] 106 + 107 + for opt in options { 108 + if opt.hasPrefix("domain=") { 109 + let domains = String(opt.dropFirst(7)).components(separatedBy: "|") 110 + for d in domains { 111 + if d.hasPrefix("~") { unlessDomain.append(String(d.dropFirst())) } 112 + else if !d.isEmpty { ifDomain.append(d) } 113 + } 114 + } else if let rt = parseResourceType(opt) { 115 + resourceTypes.insert(rt) 116 + } 117 + } 118 + 119 + // Convert EasyList pattern to regex 120 + guard let regex = patternToRegex(pattern) else { return nil } 121 + 122 + return BlockList.Rule( 123 + urlPattern: regex, 124 + action: isAllowList ? .allowList : .block, 125 + resourceTypes: resourceTypes, 126 + ifDomain: ifDomain, 127 + unlessDomain: unlessDomain 128 + ) 129 + } 130 + 131 + private static func parseResourceType(_ opt: String) -> BlockList.Rule.ResourceType? { 132 + switch opt { 133 + case "script": return .script 134 + case "image": return .image 135 + case "stylesheet": return .stylesheet 136 + case "font": return .font 137 + case "media": return .media 138 + case "xmlhttprequest", "fetch": return .xhr 139 + case "websocket": return .websocket 140 + case "document": return .document 141 + case "subdocument": return .raw 142 + case "other": return .other 143 + default: return nil 144 + } 145 + } 146 + 147 + private static func patternToRegex(_ pattern: String) -> String? { 148 + // Already a regex 149 + if pattern.hasPrefix("/") && pattern.hasSuffix("/") { 150 + let inner = String(pattern.dropFirst().dropLast()) 151 + return inner.isEmpty ? nil : inner 152 + } 153 + 154 + var p = pattern 155 + 156 + // Domain anchor `||` → match start of host 157 + let domainAnchor = p.hasPrefix("||") 158 + if domainAnchor { p = String(p.dropFirst(2)) } 159 + 160 + // Left anchor `|` → start of URL 161 + let leftAnchor = !domainAnchor && p.hasPrefix("|") 162 + if leftAnchor { p = String(p.dropFirst()) } 163 + 164 + // Right anchor `|` 165 + let rightAnchor = p.hasSuffix("|") 166 + if rightAnchor { p = String(p.dropLast()) } 167 + 168 + if p.isEmpty { return nil } 169 + 170 + // Escape regex metacharacters except `*` and `^` 171 + var escaped = "" 172 + for ch in p { 173 + switch ch { 174 + case ".", "+", "?", "{", "}", "(", ")", "[", "]", "\\", "$": 175 + escaped += "\\\(ch)" 176 + case "*": 177 + escaped += ".*" 178 + case "^": 179 + // Separator — matches any non-word boundary character or end of string 180 + escaped += "([^a-zA-Z0-9.%-]|$)" 181 + default: 182 + escaped += String(ch) 183 + } 184 + } 185 + 186 + // Apply anchors 187 + if domainAnchor { 188 + escaped = "https?://(www\\.)?" + escaped 189 + } else if leftAnchor { 190 + escaped = "^" + escaped 191 + } 192 + if rightAnchor { escaped += "$" } 193 + 194 + return escaped 195 + } 196 + }
+18
Sources/MereKit/Models/EngineType.swift
··· 1 + import Foundation 2 + 3 + public enum EngineType: String, Codable, Sendable { 4 + case webkit 5 + case chromium 6 + 7 + /// Heuristic: prefer Chromium for known compatibility-sensitive origins. 8 + /// Everything else defaults to WebKit. 9 + public static func preferred(for url: URL) -> EngineType { 10 + guard let host = url.host else { return .webkit } 11 + let chromiumHosts = [ 12 + "figma.com", "notion.so", "linear.app", 13 + "docs.google.com", "sheets.google.com", "slides.google.com", 14 + "app.diagrams.net", 15 + ] 16 + return chromiumHosts.contains(where: { host.hasSuffix($0) }) ? .chromium : .webkit 17 + } 18 + }
+34
Sources/MereKit/Models/NavigationEvent.swift
··· 1 + import Foundation 2 + 3 + public enum NavigationEvent: Sendable { 4 + case started(url: URL) 5 + case redirected(from: URL, to: URL) 6 + case committed(url: URL) 7 + case finished(url: URL) 8 + case failed(url: URL?, error: Error) 9 + case titleChanged(title: String) 10 + case faviconChanged(url: URL?) 11 + } 12 + 13 + public struct NavigationPolicy: Sendable { 14 + public enum Action: Sendable { 15 + case allow 16 + case cancel 17 + case redirectTo(URL) 18 + } 19 + 20 + public let action: Action 21 + 22 + public static let allow = NavigationPolicy(action: .allow) 23 + public static let cancel = NavigationPolicy(action: .cancel) 24 + } 25 + 26 + public struct FindResult: Sendable { 27 + public let matchCount: Int 28 + public let activeMatchIndex: Int 29 + 30 + public init(matchCount: Int, activeMatchIndex: Int) { 31 + self.matchCount = matchCount 32 + self.activeMatchIndex = activeMatchIndex 33 + } 34 + }
+49
Sources/MereKit/Protocols/AdBlockEngine.swift
··· 1 + import Foundation 2 + 3 + /// Applies content blocking rules to a browser context. 4 + /// Each engine implements this differently: 5 + /// - WebKit → WKContentRuleList (compiled bytecode, runs in WebKit process) 6 + /// - Chromium → CefRequestHandler (Swift callback per request) 7 + @MainActor 8 + public protocol AdBlockEngine: AnyObject { 9 + 10 + /// Whether blocking is currently active. 11 + var isEnabled: Bool { get set } 12 + 13 + /// Currently loaded lists and their rule counts. 14 + var loadedLists: [String: Int] { get } 15 + 16 + /// Load and compile a block list. Replaces any existing list with the same name. 17 + /// Compilation is async because WebKit's rule list compilation can take ~100-500ms 18 + /// for large lists (EasyList has ~50k rules). 19 + func load(_ list: BlockList) async throws 20 + 21 + /// Remove a list by name. 22 + func remove(listNamed name: String) async 23 + 24 + /// Fetch a list from a URL, parse it, and load it. 25 + func fetchAndLoad(from url: URL, name: String) async throws 26 + 27 + /// Block count across all loaded lists. 28 + var totalBlockedRequestCount: Int { get } 29 + } 30 + 31 + // MARK: - Default implementation 32 + 33 + public extension AdBlockEngine { 34 + func fetchAndLoad(from url: URL, name: String) async throws { 35 + let (data, _) = try await URLSession.shared.data(from: url) 36 + let text = String(decoding: data, as: UTF8.self) 37 + let list = EasyListParser.parse(text, name: name) 38 + try await load(list) 39 + } 40 + } 41 + 42 + // MARK: - Well-known list URLs 43 + 44 + public enum BlockListSource { 45 + public static let easyList = URL(string: "https://easylist.to/easylist/easylist.txt")! 46 + public static let easyPrivacy = URL(string: "https://easylist.to/easylist/easyprivacy.txt")! 47 + public static let uBlockFilters = URL(string: "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt")! 48 + public static let peterlowePII = URL(string: "https://raw.githubusercontent.com/peterkliewe/easyprivacy/master/easyprivacy.txt")! 49 + }
+68
Sources/MereKit/Protocols/BrowserContext.swift
··· 1 + import Foundation 2 + 3 + /// Represents a browsing profile: cookies, history, bookmarks, credentials. 4 + /// Mirrors ArcBrowserContext / ADK2.BrowserContextController. 5 + @MainActor 6 + public protocol BrowserContext: AnyObject { 7 + 8 + var engine: EngineType { get } 9 + 10 + // MARK: - WebContent factory 11 + 12 + func makeWebContent() -> any WebContent 13 + 14 + // MARK: - Cookies 15 + 16 + /// Fetch all cookies for a given URL. 17 + func cookies(for url: URL) async -> [HTTPCookie] 18 + 19 + /// Set cookies into this context's store. 20 + func setCookies(_ cookies: [HTTPCookie], for url: URL) async 21 + 22 + /// Remove all cookies matching a URL. 23 + func clearCookies(for url: URL) async 24 + 25 + // MARK: - History (read-only; writes happen automatically on navigation) 26 + 27 + func history(limit: Int) async -> [HistoryItem] 28 + func clearHistory() async 29 + 30 + // MARK: - Downloads 31 + 32 + var activeDownloads: [DownloadItem] { get } 33 + 34 + // MARK: - Lifecycle 35 + 36 + func close() 37 + } 38 + 39 + // MARK: - Supporting types 40 + 41 + public struct HistoryItem: Identifiable, Sendable { 42 + public let id: UUID 43 + public let url: URL 44 + public let title: String? 45 + public let visitedAt: Date 46 + 47 + public init(id: UUID = .init(), url: URL, title: String? = nil, visitedAt: Date = .now) { 48 + self.id = id 49 + self.url = url 50 + self.title = title 51 + self.visitedAt = visitedAt 52 + } 53 + } 54 + 55 + public struct DownloadItem: Identifiable, Sendable { 56 + public enum State: Sendable { case inProgress(Double), completed(URL), failed(Error) } 57 + public let id: UUID 58 + public let sourceURL: URL 59 + public let filename: String 60 + public let state: State 61 + 62 + public init(id: UUID = .init(), sourceURL: URL, filename: String, state: State) { 63 + self.id = id 64 + self.sourceURL = sourceURL 65 + self.filename = filename 66 + self.state = state 67 + } 68 + }
+22
Sources/MereKit/Protocols/BrowserEngine.swift
··· 1 + import Foundation 2 + 3 + /// Entry point for each engine. One instance per process. 4 + /// Responsible for spinning up the runtime and vending BrowserContexts. 5 + public protocol BrowserEngine: AnyObject { 6 + 7 + var engineType: EngineType { get } 8 + 9 + /// Whether the engine runtime is currently loaded. 10 + var isLoaded: Bool { get } 11 + 12 + /// Load the engine runtime. No-op if already loaded. 13 + /// For WebKit this is essentially free; for CEF it initialises the subprocess infrastructure. 14 + func load() async throws 15 + 16 + /// Create a new isolated browsing context (profile / cookie jar). 17 + @MainActor 18 + func makeContext() -> any BrowserContext 19 + 20 + /// Tear down the engine. Call only on app exit. 21 + func shutdown() 22 + }
+120
Sources/MereKit/Protocols/WebContent.swift
··· 1 + import Foundation 2 + #if canImport(AppKit) 3 + import AppKit 4 + public typealias PlatformView = NSView 5 + public typealias PlatformImage = NSImage 6 + public typealias PlatformColor = NSColor 7 + #elseif canImport(UIKit) 8 + import UIKit 9 + public typealias PlatformView = UIView 10 + public typealias PlatformImage = UIImage 11 + public typealias PlatformColor = UIColor 12 + #endif 13 + 14 + extension PlatformColor { 15 + /// Create from a CSS color string — hex (`#rgb`, `#rrggbb`) or `rgb()`/`rgba()`. 16 + public static func fromCSS(_ value: String) -> PlatformColor? { 17 + let s = value.trimmingCharacters(in: .whitespaces) 18 + if s.hasPrefix("#") { return fromHex(s) } 19 + // rgb(r, g, b) or rgba(r, g, b, a) 20 + guard s.hasPrefix("rgb") else { return nil } 21 + let digits = s.drop(while: { $0 != "(" }).dropFirst().prefix(while: { $0 != ")" }) 22 + let parts = digits.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } 23 + guard parts.count >= 3, 24 + let ri = Double(parts[0]), let gi = Double(parts[1]), let bi = Double(parts[2]) else { return nil } 25 + let a = parts.count >= 4 ? (Double(parts[3]) ?? 1.0) : 1.0 26 + return PlatformColor(red: ri / 255, green: gi / 255, blue: bi / 255, alpha: a) 27 + } 28 + 29 + public static func fromHex(_ hex: String) -> PlatformColor? { 30 + var s = hex.trimmingCharacters(in: .whitespaces) 31 + if s.hasPrefix("#") { s.removeFirst() } 32 + let len = s.count 33 + guard len == 3 || len == 6 || len == 8, 34 + let value = UInt64(s, radix: 16) else { return nil } 35 + let r, g, b, a: CGFloat 36 + switch len { 37 + case 3: 38 + r = CGFloat((value >> 8) & 0xF) / 15 39 + g = CGFloat((value >> 4) & 0xF) / 15 40 + b = CGFloat(value & 0xF) / 15 41 + a = 1 42 + case 6: 43 + r = CGFloat((value >> 16) & 0xFF) / 255 44 + g = CGFloat((value >> 8) & 0xFF) / 255 45 + b = CGFloat(value & 0xFF) / 255 46 + a = 1 47 + default: // 8 48 + r = CGFloat((value >> 24) & 0xFF) / 255 49 + g = CGFloat((value >> 16) & 0xFF) / 255 50 + b = CGFloat((value >> 8) & 0xFF) / 255 51 + a = CGFloat(value & 0xFF) / 255 52 + } 53 + return PlatformColor(red: r, green: g, blue: b, alpha: a) 54 + } 55 + } 56 + 57 + /// The core abstraction over a single browser tab, regardless of engine. 58 + /// Mirrors what Dia calls ArcWebContents / ADK2.WebContentController. 59 + @MainActor 60 + public protocol WebContent: AnyObject { 61 + 62 + // MARK: - Identity 63 + 64 + var id: UUID { get } 65 + var engine: EngineType { get } 66 + 67 + // MARK: - State 68 + 69 + var url: URL? { get } 70 + var title: String? { get } 71 + var isLoading: Bool { get } 72 + var estimatedProgress: Double { get } 73 + var canGoBack: Bool { get } 74 + var canGoForward: Bool { get } 75 + var hasAudioPlaying: Bool { get } 76 + var isMuted: Bool { get set } 77 + 78 + // MARK: - Navigation 79 + 80 + func loadURL(_ url: URL) 81 + func loadHTML(_ html: String, baseURL: URL?) 82 + func goBack() 83 + func goForward() 84 + func reload() 85 + func stopLoading() 86 + 87 + // MARK: - JavaScript 88 + 89 + @discardableResult 90 + func evaluateJavaScript(_ script: String) async throws -> Any? 91 + 92 + // MARK: - Find in Page 93 + 94 + func findInPage(_ query: String, forward: Bool) async -> FindResult 95 + func clearFind() 96 + 97 + // MARK: - View 98 + 99 + /// Attach the engine's native view into the given container. 100 + /// Call this once after creation; the view fills the container. 101 + func attachHostView(_ container: PlatformView) 102 + func detachHostView() 103 + 104 + // MARK: - Zoom 105 + 106 + var zoomFactor: Double { get set } 107 + 108 + // MARK: - Snapshot 109 + 110 + func snapshot() async -> PlatformImage? 111 + 112 + // MARK: - Events 113 + 114 + /// Stream of navigation lifecycle events. 115 + var navigationEvents: AsyncStream<NavigationEvent> { get } 116 + 117 + // MARK: - Lifecycle 118 + 119 + func close() 120 + }
+85
Sources/MereUI/AdBlockSettingsView.swift
··· 1 + import SwiftUI 2 + import MereCore 3 + 4 + public struct AdBlockSettingsView: View { 5 + 6 + @ObservedObject var adBlock: AdBlockController 7 + 8 + public init(adBlock: AdBlockController) { 9 + self.adBlock = adBlock 10 + } 11 + 12 + public var body: some View { 13 + VStack(alignment: .leading, spacing: 16) { 14 + 15 + // Master toggle 16 + HStack { 17 + VStack(alignment: .leading, spacing: 2) { 18 + Text("Ad & Tracker Blocking") 19 + .font(.headline) 20 + Text("\(adBlock.totalRuleCount.formatted()) rules · \(adBlock.totalBlockedCount.formatted()) blocked") 21 + .font(.caption) 22 + .foregroundStyle(.secondary) 23 + } 24 + Spacer() 25 + Toggle("", isOn: Binding( 26 + get: { adBlock.isEnabled }, 27 + set: { adBlock.setEnabled($0) } 28 + )) 29 + .labelsHidden() 30 + } 31 + 32 + Divider() 33 + 34 + // Loaded lists 35 + if adBlock.loadedLists.isEmpty { 36 + Text("No lists loaded") 37 + .foregroundStyle(.secondary) 38 + .font(.subheadline) 39 + } else { 40 + ForEach(Array(adBlock.loadedLists.keys.sorted()), id: \.self) { name in 41 + HStack { 42 + VStack(alignment: .leading, spacing: 2) { 43 + Text(name).font(.subheadline) 44 + if let count = adBlock.loadedLists[name] { 45 + Text("\(count.formatted()) rules") 46 + .font(.caption) 47 + .foregroundStyle(.secondary) 48 + } 49 + } 50 + Spacer() 51 + Button { 52 + Task { await adBlock.remove(listNamed: name) } 53 + } label: { 54 + Image(systemName: "trash") 55 + .foregroundStyle(.red) 56 + } 57 + .buttonStyle(.plain) 58 + } 59 + } 60 + } 61 + 62 + Divider() 63 + 64 + // Load default lists 65 + HStack { 66 + Button("Load EasyList + EasyPrivacy") { 67 + Task { await adBlock.loadDefaults() } 68 + } 69 + .disabled(adBlock.isLoading) 70 + 71 + if adBlock.isLoading { 72 + ProgressView().scaleEffect(0.7) 73 + } 74 + } 75 + 76 + if let error = adBlock.error { 77 + Text(error) 78 + .font(.caption) 79 + .foregroundStyle(.red) 80 + } 81 + } 82 + .padding() 83 + .frame(width: 320) 84 + } 85 + }
+560
Sources/MereUI/BrowserWindowView.swift
··· 1 + import SwiftUI 2 + import AppKit 3 + import MereCore 4 + import MereKit 5 + 6 + public struct BrowserWindowView: View { 7 + 8 + @StateObject var window: WindowViewModel 9 + @State private var sidebarVisible = true 10 + // Incrementing this tells whichever address bar is visible to take focus. 11 + @State private var addressFocusTrigger = 0 12 + 13 + public init(window: WindowViewModel) { 14 + _window = StateObject(wrappedValue: window) 15 + } 16 + 17 + private var isNewTab: Bool { 18 + window.activeTab == nil || (window.activeTab?.url == nil && window.activeTab?.isLoading == false) 19 + } 20 + 21 + /// Returns the color scheme that gives readable contrast against `color`. 22 + private func scheme(for color: NSColor) -> ColorScheme { 23 + guard let rgb = color.usingColorSpace(.deviceRGB) else { return .light } 24 + let r = rgb.redComponent, g = rgb.greenComponent, b = rgb.blueComponent 25 + let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b 26 + return luma > 0.5 ? .light : .dark 27 + } 28 + 29 + private var tintColor: Color { 30 + if isNewTab { 31 + return window.newTabBackgroundColor.map { Color(nsColor: $0) } 32 + ?? Color(nsColor: .windowBackgroundColor) 33 + } 34 + if let tc = window.activeTab?.themeColor { return Color(nsColor: tc) } 35 + return Color(nsColor: .windowBackgroundColor) 36 + } 37 + 38 + private var preferredScheme: ColorScheme? { 39 + if let tc = window.activeTab?.themeColor { return scheme(for: tc) } 40 + if isNewTab, let bg = window.newTabBackgroundColor { return scheme(for: bg) } 41 + return nil 42 + } 43 + 44 + public var body: some View { 45 + ZStack { 46 + // Full-window color gradient using the raw page background colour. 47 + LinearGradient( 48 + stops: [ 49 + .init(color: tintColor.opacity(0.95), location: 0), 50 + .init(color: tintColor.opacity(0.70), location: 1), 51 + ], 52 + startPoint: .top, 53 + endPoint: .bottom 54 + ) 55 + .ignoresSafeArea() 56 + .animation(.easeInOut(duration: 0.35), value: isNewTab) 57 + 58 + HStack(spacing: 0) { 59 + if sidebarVisible { 60 + SidebarView(window: window) 61 + .frame(width: 220) 62 + .background(.ultraThinMaterial) 63 + .transition(.move(edge: .leading).combined(with: .opacity)) 64 + } 65 + 66 + VStack(spacing: 0) { 67 + // Toolbar: transparent background — window gradient shows through. 68 + BrowserToolbarView( 69 + window: window, 70 + sidebarVisible: $sidebarVisible, 71 + focusTrigger: addressFocusTrigger 72 + ) 73 + .padding(.top, 8) 74 + .padding(.leading, sidebarVisible ? 10 : 86) 75 + .padding(.trailing, 10) 76 + .padding(.bottom, 8) 77 + 78 + // Content: material matching sidebar, fills all remaining space. 79 + contentArea 80 + .frame(maxWidth: .infinity, maxHeight: .infinity) 81 + .background(.ultraThinMaterial) 82 + .clipShape(UnevenRoundedRectangle( 83 + topLeadingRadius: 0, 84 + bottomLeadingRadius: 10, 85 + bottomTrailingRadius: 10, 86 + topTrailingRadius: 0 87 + )) 88 + .overlay( 89 + UnevenRoundedRectangle( 90 + topLeadingRadius: 0, 91 + bottomLeadingRadius: 10, 92 + bottomTrailingRadius: 10, 93 + topTrailingRadius: 0 94 + ) 95 + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.35), lineWidth: 0.5) 96 + ) 97 + .padding(.horizontal, 8) 98 + .padding(.bottom, 8) 99 + } 100 + .ignoresSafeArea(edges: .top) 101 + } 102 + } 103 + .animation(.spring(duration: 0.22), value: sidebarVisible) 104 + .background(keyboardShortcuts) 105 + .background(TrafficLightNudge(xOffset: 8, yOffset: 8)) 106 + .preferredColorScheme(preferredScheme) 107 + .onChange(of: window.activeTab?.id) { _, _ in 108 + if window.activeTab?.url == nil { 109 + addressFocusTrigger += 1 110 + } else { 111 + NSApp.keyWindow?.makeFirstResponder(nil) 112 + } 113 + } 114 + } 115 + 116 + private var keyboardShortcuts: some View { 117 + Group { 118 + Button("") { sidebarVisible.toggle() } 119 + .keyboardShortcut("s", modifiers: .command) 120 + Button("") { window.openTab() } 121 + .keyboardShortcut("t", modifiers: .command) 122 + Button("") { 123 + if let tab = window.activeTab { window.closeTab(tab) } 124 + } 125 + .keyboardShortcut("w", modifiers: .command) 126 + Button("") { window.activeTab?.reload() } 127 + .keyboardShortcut("r", modifiers: .command) 128 + Button("") { addressFocusTrigger += 1 } 129 + .keyboardShortcut("l", modifiers: .command) 130 + } 131 + .frame(width: 0, height: 0) 132 + .opacity(0) 133 + } 134 + 135 + @ViewBuilder 136 + private var contentArea: some View { 137 + if let tab = window.activeTab, tab.url != nil || tab.isLoading { 138 + WebContentView(content: tab.content) 139 + .id(tab.id) 140 + } else { 141 + NewTabView(hasBackground: window.newTabBackgroundColor != nil) 142 + } 143 + } 144 + } 145 + 146 + // MARK: - New Tab Page 147 + 148 + struct NewTabView: View { 149 + let hasBackground: Bool 150 + 151 + var body: some View { 152 + Text("mere") 153 + .font(.system(size: 52, weight: .ultraLight, design: .rounded)) 154 + .foregroundStyle(.primary) 155 + .tracking(10) 156 + .frame(maxWidth: .infinity, maxHeight: .infinity) 157 + } 158 + } 159 + 160 + // MARK: - Sidebar 161 + 162 + struct SidebarView: View { 163 + @ObservedObject var window: WindowViewModel 164 + 165 + var body: some View { 166 + GlassEffectContainer { 167 + ScrollView(.vertical, showsIndicators: false) { 168 + VStack(spacing: 1) { 169 + ForEach(window.tabs) { tab in 170 + SidebarTabRow( 171 + tab: tab as MereCore.Tab, 172 + isActive: window.activeTab?.id == tab.id, 173 + onActivate: { window.activateTab(tab) }, 174 + onClose: { window.closeTab(tab) } 175 + ) 176 + } 177 + } 178 + .padding(.top, 8) 179 + .padding(.bottom, 6) 180 + .padding(.horizontal, 8) 181 + } 182 + } 183 + } 184 + } 185 + 186 + struct SidebarTabRow: View { 187 + @ObservedObject var tab: MereCore.Tab 188 + let isActive: Bool 189 + let onActivate: () -> Void 190 + let onClose: () -> Void 191 + @State private var isHovered = false 192 + 193 + var body: some View { 194 + rowContent 195 + .background( 196 + RoundedRectangle(cornerRadius: 8) 197 + .fill(Color(nsColor: .labelColor).opacity(isActive ? 0.08 : isHovered ? 0.04 : 0)) 198 + ) 199 + } 200 + 201 + private var rowContent: some View { 202 + HStack(spacing: 9) { 203 + FaviconView(url: tab.favicon, engine: tab.engine) 204 + .frame(width: 14, height: 14) 205 + 206 + Text(tab.title ?? tab.url?.host ?? "New Tab") 207 + .lineLimit(1) 208 + .font(.system(size: 13)) 209 + .foregroundStyle(isActive ? .primary : .secondary) 210 + 211 + Spacer(minLength: 0) 212 + 213 + // Always reserve space; only visible on hover or when active. 214 + Button { onClose() } label: { 215 + Image(systemName: "xmark") 216 + .font(.system(size: 8, weight: .semibold)) 217 + .frame(width: 14, height: 14) 218 + .foregroundStyle(.secondary) 219 + } 220 + .buttonStyle(.plain) 221 + .opacity(isHovered || isActive ? 1 : 0) 222 + } 223 + .padding(.horizontal, 10) 224 + .padding(.vertical, 7) 225 + .contentShape(RoundedRectangle(cornerRadius: 8)) 226 + .onTapGesture { onActivate() } 227 + .onHover { isHovered = $0 } 228 + } 229 + } 230 + 231 + // MARK: - Favicon 232 + 233 + private final class FaviconCache { 234 + static let shared = FaviconCache() 235 + private var store: [URL: NSImage] = [:] 236 + private let queue = DispatchQueue(label: "sh.dunkirk.mere.favicon-cache") 237 + 238 + func image(for url: URL) -> NSImage? { 239 + queue.sync { store[url] } 240 + } 241 + 242 + func store(_ image: NSImage, for url: URL) { 243 + queue.async { self.store[url] = image } 244 + } 245 + } 246 + 247 + struct FaviconView: View { 248 + let url: URL? 249 + let engine: EngineType 250 + @State private var image: NSImage? = nil 251 + 252 + var body: some View { 253 + Group { 254 + if let image { 255 + Image(nsImage: image) 256 + .resizable() 257 + .scaledToFit() 258 + .clipShape(RoundedRectangle(cornerRadius: 3)) 259 + } else { 260 + Circle() 261 + .fill(engine == .webkit ? Color.blue.opacity(0.7) : Color.orange.opacity(0.7)) 262 + .frame(width: 8, height: 8) 263 + .frame(maxWidth: .infinity, maxHeight: .infinity) 264 + } 265 + } 266 + .task(id: url) { 267 + guard let url else { image = nil; return } 268 + if let cached = FaviconCache.shared.image(for: url) { 269 + image = cached 270 + return 271 + } 272 + guard let (data, _) = try? await URLSession.shared.data(from: url), 273 + let loaded = NSImage(data: data) else { return } 274 + FaviconCache.shared.store(loaded, for: url) 275 + image = loaded 276 + } 277 + } 278 + } 279 + 280 + // MARK: - Toolbar 281 + 282 + struct HoverButtonStyle: ButtonStyle { 283 + var disabled: Bool = false 284 + @State private var isHovered = false 285 + 286 + func makeBody(configuration: Configuration) -> some View { 287 + configuration.label 288 + .foregroundStyle(disabled ? .tertiary : isHovered ? .primary : .secondary) 289 + .background( 290 + RoundedRectangle(cornerRadius: 6) 291 + .fill(Color(nsColor: .labelColor) 292 + .opacity(configuration.isPressed ? 0.12 : isHovered ? 0.07 : 0)) 293 + ) 294 + .animation(.easeInOut(duration: 0.12), value: isHovered) 295 + .animation(.easeInOut(duration: 0.08), value: configuration.isPressed) 296 + .onHover { isHovered = $0 } 297 + } 298 + } 299 + 300 + struct BrowserToolbarView: View { 301 + @ObservedObject var window: WindowViewModel 302 + @Binding var sidebarVisible: Bool 303 + let focusTrigger: Int 304 + @State private var addressText = "" 305 + @State private var addressBarHovered = false 306 + 307 + var body: some View { 308 + HStack(spacing: 4) { 309 + navIcon("sidebar.left") { 310 + sidebarVisible.toggle() 311 + } 312 + 313 + navIcon("chevron.left", disabled: window.activeTab?.canGoBack != true) { 314 + window.activeTab?.goBack() 315 + } 316 + navIcon("chevron.right", disabled: window.activeTab?.canGoForward != true) { 317 + window.activeTab?.goForward() 318 + } 319 + 320 + AddressBar(text: $addressText, focusTrigger: focusTrigger, onSubmit: navigate) 321 + .frame(maxWidth: .infinity, minHeight: 22) 322 + .padding(.leading, 10) 323 + .padding(.trailing, 4) 324 + .padding(.vertical, 5) 325 + .onChange(of: window.activeTab?.url) { _, url in 326 + addressText = url?.absoluteString ?? "" 327 + } 328 + .overlay(alignment: .trailing) { 329 + if let url = window.activeTab?.url, addressBarHovered { 330 + Button { 331 + NSPasteboard.general.clearContents() 332 + NSPasteboard.general.setString(url.absoluteString, forType: .string) 333 + } label: { 334 + Image(systemName: "link") 335 + .font(.system(size: 11, weight: .medium)) 336 + .frame(width: 28, height: 28) 337 + } 338 + .buttonStyle(HoverButtonStyle()) 339 + .help("Copy URL") 340 + .transition(.opacity.animation(.easeInOut(duration: 0.12))) 341 + } 342 + } 343 + .background( 344 + RoundedRectangle(cornerRadius: 7) 345 + .fill(Color(nsColor: .controlBackgroundColor) 346 + .opacity(addressBarHovered ? 0.68 : 0.55)) 347 + .overlay( 348 + RoundedRectangle(cornerRadius: 7) 349 + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 0.5) 350 + ) 351 + ) 352 + .animation(.easeInOut(duration: 0.15), value: addressBarHovered) 353 + 354 + navIcon("arrow.clockwise") { window.activeTab?.reload() } 355 + 356 + if let tab = window.activeTab { 357 + Text(tab.engine == .webkit ? "WK" : "CR") 358 + .font(.system(size: 10, weight: .semibold, design: .monospaced)) 359 + .foregroundStyle(.secondary) 360 + .padding(.horizontal, 5) 361 + .padding(.vertical, 2) 362 + .background(Color(nsColor: .separatorColor).opacity(0.3), 363 + in: RoundedRectangle(cornerRadius: 4)) 364 + } 365 + } 366 + } 367 + 368 + private func navIcon(_ name: String, disabled: Bool = false, action: @escaping () -> Void) -> some View { 369 + Button(action: action) { 370 + Image(systemName: name) 371 + .font(.system(size: 13, weight: .medium)) 372 + .frame(width: 26, height: 26) 373 + } 374 + .buttonStyle(HoverButtonStyle(disabled: disabled)) 375 + .disabled(disabled) 376 + } 377 + 378 + private func navigate() { 379 + let raw = addressText.trimmingCharacters(in: .whitespaces) 380 + guard !raw.isEmpty else { return } 381 + let url: URL 382 + if raw.contains(".") && !raw.contains(" "), 383 + let u = URL(string: raw.hasPrefix("http") ? raw : "https://\(raw)") { 384 + url = u 385 + } else { 386 + url = URL(string: "https://s.dunkirk.sh?q=\(raw.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")")! 387 + } 388 + if let tab = window.activeTab { 389 + tab.loadURL(url) 390 + } else { 391 + window.openTab(url: url) 392 + } 393 + } 394 + } 395 + 396 + // MARK: - Address bar (NSViewRepresentable) 397 + // SwiftUI TextField + @FocusState is unreliable on macOS for makeFirstResponder. 398 + // NSTextField gives us direct control over focus and avoids the focus-ring highlight. 399 + 400 + struct AddressBar: NSViewRepresentable { 401 + @Binding var text: String 402 + var focusTrigger: Int 403 + var onSubmit: () -> Void 404 + 405 + func makeNSView(context: Context) -> NSTextField { 406 + let f = NSTextField() 407 + f.placeholderString = "Search or enter URL" 408 + f.isBordered = false 409 + f.drawsBackground = false 410 + f.focusRingType = .none 411 + f.font = .systemFont(ofSize: 13) 412 + f.cell?.isScrollable = true 413 + f.cell?.wraps = false 414 + f.alignment = .center 415 + f.delegate = context.coordinator 416 + return f 417 + } 418 + 419 + func updateNSView(_ nsView: NSTextField, context: Context) { 420 + context.coordinator.parent = self 421 + if !context.coordinator.isEditing, context.coordinator.lastDisplayedURL != text { 422 + context.coordinator.lastDisplayedURL = text 423 + if let attr = prettyAttributed(text) { 424 + nsView.attributedStringValue = attr 425 + } else { 426 + nsView.stringValue = text 427 + } 428 + } 429 + if context.coordinator.lastTrigger != focusTrigger { 430 + context.coordinator.lastTrigger = focusTrigger 431 + context.coordinator.focusGeneration += 1 432 + let gen = context.coordinator.focusGeneration 433 + DispatchQueue.main.async { [weak coordinator = context.coordinator] in 434 + guard coordinator?.focusGeneration == gen else { return } 435 + nsView.window?.makeFirstResponder(nsView) 436 + nsView.currentEditor()?.selectAll(nil) 437 + } 438 + } 439 + } 440 + 441 + func makeCoordinator() -> Coordinator { Coordinator(self) } 442 + 443 + /// Returns an attributed string showing `host` in medium tone and `/path` in muted tone. 444 + func prettyAttributed(_ urlString: String) -> NSAttributedString? { 445 + guard !urlString.isEmpty, 446 + let url = URL(string: urlString), 447 + let host = url.host else { return nil } 448 + let font = NSFont.systemFont(ofSize: 13) 449 + let para = NSMutableParagraphStyle() 450 + para.alignment = .center 451 + let hostAttr: [NSAttributedString.Key: Any] = [ 452 + .font: font, 453 + .foregroundColor: NSColor.labelColor.withAlphaComponent(0.65), 454 + .paragraphStyle: para, 455 + ] 456 + let pathAttr: [NSAttributedString.Key: Any] = [ 457 + .font: font, 458 + .foregroundColor: NSColor.labelColor.withAlphaComponent(0.32), 459 + .paragraphStyle: para, 460 + ] 461 + let result = NSMutableAttributedString(string: host, attributes: hostAttr) 462 + let path = url.path 463 + let query = url.query.map { "?\($0)" } ?? "" 464 + let suffix = (path == "/" || path.isEmpty ? "" : path) + query 465 + if !suffix.isEmpty { 466 + result.append(NSAttributedString(string: suffix, attributes: pathAttr)) 467 + } 468 + return result 469 + } 470 + 471 + final class Coordinator: NSObject, NSTextFieldDelegate { 472 + var parent: AddressBar 473 + var lastTrigger: Int 474 + var focusGeneration = 0 475 + var lastDisplayedURL: String = "" 476 + var isEditing = false 477 + 478 + init(_ parent: AddressBar) { 479 + self.parent = parent 480 + self.lastTrigger = parent.focusTrigger 481 + } 482 + 483 + func controlTextDidChange(_ obj: Notification) { 484 + guard let field = obj.object as? NSTextField else { return } 485 + parent.text = field.stringValue 486 + } 487 + 488 + func control(_ control: NSControl, textView: NSTextView, 489 + doCommandBy selector: Selector) -> Bool { 490 + if selector == #selector(NSResponder.insertNewline(_:)) { 491 + focusGeneration += 1 // cancel any pending focus-and-select 492 + parent.onSubmit() 493 + DispatchQueue.main.async { control.window?.makeFirstResponder(nil) } 494 + return true 495 + } 496 + return false 497 + } 498 + 499 + func controlTextDidBeginEditing(_ obj: Notification) { 500 + isEditing = true 501 + if let tv = (obj.object as? NSTextField)?.currentEditor() as? NSTextView { 502 + tv.insertionPointColor = .labelColor 503 + } 504 + } 505 + 506 + func controlTextDidEndEditing(_ obj: Notification) { 507 + isEditing = false 508 + if let field = obj.object as? NSTextField { 509 + lastDisplayedURL = "" // force re-render of pretty URL 510 + if let attr = parent.prettyAttributed(parent.text) { 511 + field.attributedStringValue = attr 512 + } else { 513 + field.stringValue = parent.text 514 + } 515 + } 516 + } 517 + } 518 + } 519 + 520 + // MARK: - Traffic light repositioning 521 + 522 + /// Zero-size view that moves the window's traffic-light buttons down by `yOffset` points 523 + /// so they vertically align with the toolbar icon row. 524 + private struct TrafficLightNudge: NSViewRepresentable { 525 + let xOffset: CGFloat 526 + let yOffset: CGFloat 527 + 528 + func makeNSView(context: Context) -> _View { _View() } 529 + 530 + func updateNSView(_ nsView: _View, context: Context) { 531 + let x = xOffset, y = yOffset 532 + // Defer until after AppKit's own layout pass resets button frames. 533 + DispatchQueue.main.async { nsView.apply(xOffset: x, yOffset: y) } 534 + } 535 + 536 + final class _View: NSView { 537 + private var baseOrigins: [NSWindow.ButtonType: NSPoint] = [:] 538 + private static let types: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton] 539 + 540 + required init?(coder: NSCoder) { fatalError() } 541 + init() { super.init(frame: .zero) } 542 + 543 + func apply(xOffset: CGFloat, yOffset: CGFloat) { 544 + guard let window else { return } 545 + // Lazily capture default origins the first time we have a window. 546 + if baseOrigins.isEmpty { 547 + for type in Self.types { 548 + if let btn = window.standardWindowButton(type) { 549 + baseOrigins[type] = btn.frame.origin 550 + } 551 + } 552 + } 553 + for type in Self.types { 554 + guard let btn = window.standardWindowButton(type), 555 + let base = baseOrigins[type] else { continue } 556 + btn.setFrameOrigin(NSPoint(x: base.x + xOffset, y: base.y - yOffset)) 557 + } 558 + } 559 + } 560 + }
+27
Sources/MereUI/WebContentView.swift
··· 1 + import SwiftUI 2 + import AppKit 3 + import MereKit 4 + 5 + /// Hosts the engine's native NSView inside SwiftUI. 6 + /// Works identically for WebKit and Chromium tabs. 7 + public struct WebContentView: NSViewRepresentable { 8 + 9 + let content: any WebContent 10 + 11 + public init(content: any WebContent) { 12 + self.content = content 13 + } 14 + 15 + public func makeNSView(context: Context) -> NSView { 16 + let container = NSView() 17 + container.wantsLayer = true 18 + content.attachHostView(container) 19 + return container 20 + } 21 + 22 + public func updateNSView(_ nsView: NSView, context: Context) {} 23 + 24 + public static func dismantleNSView(_ nsView: NSView, coordinator: ()) { 25 + // WebContent.close() is called by the Tab when removed from WindowViewModel 26 + } 27 + }
+106
Sources/WebKitEngine/WebKitAdBlocker.swift
··· 1 + import Foundation 2 + import WebKit 3 + import MereKit 4 + 5 + /// Ad blocker for WebKit using WKContentRuleListStore. 6 + /// 7 + /// How it works: 8 + /// - Converts BlockList rules → Apple's content blocker JSON format 9 + /// - Compiles them into a WKContentRuleList (bytecode, runs inside WebKit — no Swift 10 + /// callbacks per request, zero performance overhead) 11 + /// - Applies the compiled list to the shared WKWebViewConfiguration so all 12 + /// WebKitWebContent instances in this context are blocked automatically 13 + @MainActor 14 + public final class WebKitAdBlocker: AdBlockEngine { 15 + 16 + public var isEnabled: Bool = true { 17 + didSet { Task { await applyToConfiguration() } } 18 + } 19 + 20 + public private(set) var loadedLists: [String: Int] = [:] 21 + public private(set) var totalBlockedRequestCount = 0 22 + 23 + private let store: WKContentRuleListStore 24 + private let configuration: WKWebViewConfiguration 25 + private var compiledLists: [String: WKContentRuleList] = [:] 26 + 27 + /// `store` is keyed to a directory so compiled bytecode survives app restarts. 28 + public init(configuration: WKWebViewConfiguration, storageURL: URL? = nil) { 29 + self.configuration = configuration 30 + self.store = storageURL.map { WKContentRuleListStore(url: $0) } 31 + ?? .default() 32 + } 33 + 34 + // MARK: - AdBlockEngine 35 + 36 + public func load(_ list: BlockList) async throws { 37 + let json = try appleContentBlockerJSON(from: list) 38 + let compiled: WKContentRuleList = try await withCheckedThrowingContinuation { continuation in 39 + store.compileContentRuleList(forIdentifier: list.name, encodedContentRuleList: json) { result, error in 40 + if let error { continuation.resume(throwing: error) } 41 + else if let result { continuation.resume(returning: result) } 42 + else { continuation.resume(throwing: ContentBlockerError.compilationFailed) } 43 + } 44 + } 45 + compiledLists[list.name] = compiled 46 + loadedLists[list.name] = list.blockCount 47 + await applyToConfiguration() 48 + } 49 + 50 + public func remove(listNamed name: String) async { 51 + compiledLists.removeValue(forKey: name) 52 + loadedLists.removeValue(forKey: name) 53 + store.removeContentRuleList(forIdentifier: name) { _ in } 54 + await applyToConfiguration() 55 + } 56 + 57 + // MARK: - Private 58 + 59 + private func applyToConfiguration() async { 60 + let controller = configuration.userContentController 61 + controller.removeAllContentRuleLists() 62 + guard isEnabled else { return } 63 + for list in compiledLists.values { 64 + controller.add(list) 65 + } 66 + } 67 + 68 + // MARK: - JSON conversion 69 + 70 + /// Converts our engine-agnostic rules to Apple's content blocker JSON format. 71 + /// Spec: https://webkit.org/blog/3476/content-blockers-first-look/ 72 + private func appleContentBlockerJSON(from list: BlockList) throws -> String { 73 + var entries: [[String: Any]] = [] 74 + 75 + for rule in list.rules { 76 + var trigger: [String: Any] = ["url-filter": rule.urlPattern] 77 + 78 + if !rule.resourceTypes.isEmpty { 79 + trigger["resource-type"] = rule.resourceTypes.map { $0.rawValue } 80 + } 81 + if !rule.ifDomain.isEmpty { 82 + trigger["if-domain"] = rule.ifDomain.map { "*\($0)" } 83 + } 84 + if !rule.unlessDomain.isEmpty { 85 + trigger["unless-domain"] = rule.unlessDomain.map { "*\($0)" } 86 + } 87 + 88 + let action: [String: Any] = switch rule.action { 89 + case .block: ["type": "block"] 90 + case .allowList: ["type": "ignore-previous-rules"] 91 + } 92 + 93 + entries.append(["trigger": trigger, "action": action]) 94 + 95 + // WKContentRuleList has a hard cap of 150k rules per list 96 + if entries.count >= 149_000 { break } 97 + } 98 + 99 + let data = try JSONSerialization.data(withJSONObject: entries) 100 + return String(decoding: data, as: UTF8.self) 101 + } 102 + } 103 + 104 + enum ContentBlockerError: Error { 105 + case compilationFailed 106 + }
+68
Sources/WebKitEngine/WebKitBrowserContext.swift
··· 1 + import Foundation 2 + import WebKit 3 + import MereKit 4 + 5 + /// BrowserContext backed by a WKWebsiteDataStore. 6 + @MainActor 7 + public final class WebKitBrowserContext: BrowserContext { 8 + 9 + public let engine: EngineType = .webkit 10 + 11 + private let dataStore: WKWebsiteDataStore 12 + private let sharedConfiguration: WKWebViewConfiguration 13 + private var _activeDownloads: [DownloadItem] = [] 14 + public let adBlocker: WebKitAdBlocker 15 + 16 + public init(persistent: Bool = true) { 17 + self.dataStore = persistent ? .default() : .nonPersistent() 18 + let config = WKWebViewConfiguration() 19 + config.websiteDataStore = dataStore 20 + config.preferences.isElementFullscreenEnabled = true 21 + self.sharedConfiguration = config 22 + self.adBlocker = WebKitAdBlocker(configuration: config) 23 + } 24 + 25 + // MARK: - BrowserContext 26 + 27 + public func makeWebContent() -> any WebContent { 28 + WebKitWebContent(configuration: sharedConfiguration) 29 + } 30 + 31 + public func cookies(for url: URL) async -> [HTTPCookie] { 32 + await dataStore.httpCookieStore.allCookies().filter { cookie in 33 + url.host?.hasSuffix(cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain) ?? false 34 + } 35 + } 36 + 37 + public func setCookies(_ cookies: [HTTPCookie], for url: URL) async { 38 + for cookie in cookies { 39 + await dataStore.httpCookieStore.setCookie(cookie) 40 + } 41 + } 42 + 43 + public func clearCookies(for url: URL) async { 44 + let existing = await cookies(for: url) 45 + for cookie in existing { 46 + await dataStore.httpCookieStore.deleteCookie(cookie) 47 + } 48 + } 49 + 50 + public func history(limit: Int) async -> [HistoryItem] { 51 + // WKWebView doesn't expose browsing history via public API. 52 + // Must be tracked manually — see SessionController. 53 + return [] 54 + } 55 + 56 + public func clearHistory() async { 57 + await dataStore.removeData( 58 + ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), 59 + modifiedSince: .distantPast 60 + ) 61 + } 62 + 63 + public var activeDownloads: [DownloadItem] { _activeDownloads } 64 + 65 + public func close() { 66 + _activeDownloads.removeAll() 67 + } 68 + }
+24
Sources/WebKitEngine/WebKitEngine.swift
··· 1 + import Foundation 2 + import MereKit 3 + 4 + /// Lightweight WebKit engine — runtime is always available, load() is a no-op. 5 + public final class WebKitEngine: BrowserEngine { 6 + 7 + public static let shared = WebKitEngine() 8 + 9 + public let engineType: EngineType = .webkit 10 + public private(set) var isLoaded = true 11 + 12 + private init() {} 13 + 14 + public func load() async throws { 15 + // WebKit is always available — nothing to initialise. 16 + } 17 + 18 + @MainActor 19 + public func makeContext() -> any BrowserContext { 20 + WebKitBrowserContext() 21 + } 22 + 23 + public func shutdown() {} 24 + }
+192
Sources/WebKitEngine/WebKitWebContent.swift
··· 1 + import Foundation 2 + import AppKit 3 + import WebKit 4 + import MereKit 5 + 6 + /// WebContent backed by WKWebView. 7 + @MainActor 8 + public final class WebKitWebContent: NSObject, WebContent { 9 + 10 + public let id = UUID() 11 + public let engine: EngineType = .webkit 12 + 13 + // MARK: - Public state (KVO-observed from WKWebView) 14 + 15 + public private(set) var url: URL? 16 + public private(set) var title: String? 17 + public private(set) var isLoading = false 18 + public private(set) var estimatedProgress: Double = 0 19 + public private(set) var canGoBack = false 20 + public private(set) var canGoForward = false 21 + public private(set) var hasAudioPlaying = false 22 + 23 + // WKWebView gained isMuted in macOS 14 but it's on WKWebViewConfiguration.mediaTypesRequiringUserActionForPlayback, 24 + // not directly on the view. Track manually. 25 + public var isMuted: Bool = false { 26 + didSet { webView.configuration.mediaTypesRequiringUserActionForPlayback = isMuted ? .all : [] } 27 + } 28 + 29 + public var zoomFactor: Double { 30 + get { webView.pageZoom } 31 + set { webView.pageZoom = newValue } 32 + } 33 + 34 + // MARK: - Navigation events 35 + 36 + private let eventContinuation: AsyncStream<NavigationEvent>.Continuation 37 + public let navigationEvents: AsyncStream<NavigationEvent> 38 + 39 + // MARK: - Internals 40 + 41 + let webView: WKWebView 42 + private var observations: [NSKeyValueObservation] = [] 43 + 44 + // MARK: - Init 45 + 46 + public init(configuration: WKWebViewConfiguration = .init()) { 47 + let (stream, continuation) = AsyncStream<NavigationEvent>.makeStream() 48 + self.navigationEvents = stream 49 + self.eventContinuation = continuation 50 + 51 + self.webView = WKWebView(frame: .zero, configuration: configuration) 52 + self.webView.allowsBackForwardNavigationGestures = true 53 + 54 + super.init() 55 + 56 + webView.navigationDelegate = self 57 + webView.uiDelegate = self 58 + observeWebViewProperties() 59 + } 60 + 61 + // MARK: - WebContent 62 + 63 + public func loadURL(_ url: URL) { 64 + webView.load(URLRequest(url: url)) 65 + } 66 + 67 + public func loadHTML(_ html: String, baseURL: URL?) { 68 + webView.loadHTMLString(html, baseURL: baseURL) 69 + } 70 + 71 + public func goBack() { webView.goBack() } 72 + public func goForward() { webView.goForward() } 73 + public func reload() { webView.reload() } 74 + public func stopLoading() { webView.stopLoading() } 75 + 76 + public func evaluateJavaScript(_ script: String) async throws -> Any? { 77 + try await webView.evaluateJavaScript(script) 78 + } 79 + 80 + public func findInPage(_ query: String, forward: Bool) async -> FindResult { 81 + // WKWebView doesn't expose find results count natively; use JS fallback. 82 + let js = """ 83 + (function() { 84 + window.getSelection().removeAllRanges(); 85 + return window.find('\(query.replacingOccurrences(of: "'", with: "\\'"))', 86 + false, \(!forward), false, false, true); 87 + })() 88 + """ 89 + _ = try? await webView.evaluateJavaScript(js) 90 + return FindResult(matchCount: -1, activeMatchIndex: -1) // WKWebView limitation 91 + } 92 + 93 + public func clearFind() { 94 + Task { _ = try? await webView.evaluateJavaScript("window.getSelection().removeAllRanges()") } 95 + } 96 + 97 + public func attachHostView(_ container: NSView) { 98 + webView.translatesAutoresizingMaskIntoConstraints = false 99 + container.addSubview(webView) 100 + NSLayoutConstraint.activate([ 101 + webView.leadingAnchor.constraint(equalTo: container.leadingAnchor), 102 + webView.trailingAnchor.constraint(equalTo: container.trailingAnchor), 103 + webView.topAnchor.constraint(equalTo: container.topAnchor), 104 + webView.bottomAnchor.constraint(equalTo: container.bottomAnchor), 105 + ]) 106 + } 107 + 108 + public func detachHostView() { 109 + webView.removeFromSuperview() 110 + } 111 + 112 + public func snapshot() async -> NSImage? { 113 + let config = WKSnapshotConfiguration() 114 + return try? await webView.takeSnapshot(configuration: config) 115 + } 116 + 117 + public func close() { 118 + observations.forEach { $0.invalidate() } 119 + observations.removeAll() 120 + webView.navigationDelegate = nil 121 + webView.uiDelegate = nil 122 + detachHostView() 123 + eventContinuation.finish() 124 + } 125 + 126 + // MARK: - KVO 127 + 128 + private func observeWebViewProperties() { 129 + observations = [ 130 + webView.observe(\.url, options: [.new]) { [weak self] wv, _ in 131 + Task { @MainActor in self?.url = wv.url } 132 + }, 133 + webView.observe(\.title, options: [.new]) { [weak self] wv, _ in 134 + Task { @MainActor in 135 + self?.title = wv.title 136 + if let t = wv.title { self?.eventContinuation.yield(.titleChanged(title: t)) } 137 + } 138 + }, 139 + webView.observe(\.isLoading, options: [.new]) { [weak self] wv, _ in 140 + Task { @MainActor in self?.isLoading = wv.isLoading } 141 + }, 142 + webView.observe(\.estimatedProgress, options: [.new]) { [weak self] wv, _ in 143 + Task { @MainActor in self?.estimatedProgress = wv.estimatedProgress } 144 + }, 145 + webView.observe(\.canGoBack, options: [.new]) { [weak self] wv, _ in 146 + Task { @MainActor in self?.canGoBack = wv.canGoBack } 147 + }, 148 + webView.observe(\.canGoForward, options: [.new]) { [weak self] wv, _ in 149 + Task { @MainActor in self?.canGoForward = wv.canGoForward } 150 + }, 151 + ] 152 + } 153 + } 154 + 155 + // MARK: - WKNavigationDelegate 156 + 157 + extension WebKitWebContent: WKNavigationDelegate { 158 + public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { 159 + if let url = webView.url { eventContinuation.yield(.started(url: url)) } 160 + } 161 + 162 + public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { 163 + if let url = webView.url { eventContinuation.yield(.committed(url: url)) } 164 + } 165 + 166 + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 167 + if let url = webView.url { eventContinuation.yield(.finished(url: url)) } 168 + } 169 + 170 + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { 171 + eventContinuation.yield(.failed(url: webView.url, error: error)) 172 + } 173 + 174 + public func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) { 175 + // redirected event emitted when URL KVO fires 176 + } 177 + } 178 + 179 + // MARK: - WKUIDelegate 180 + 181 + extension WebKitWebContent: WKUIDelegate { 182 + public func webView(_ webView: WKWebView, 183 + createWebViewWith configuration: WKWebViewConfiguration, 184 + for navigationAction: WKNavigationAction, 185 + windowFeatures: WKWindowFeatures) -> WKWebView? { 186 + // Emit the URL as a navigation so the tab controller can open a new tab. 187 + if let url = navigationAction.request.url { 188 + eventContinuation.yield(.started(url: url)) 189 + } 190 + return nil 191 + } 192 + }
+13
Tests/BrowserKitTests/TabTests.swift
··· 1 + import XCTest 2 + import MereKit 3 + @testable import MereCore 4 + 5 + @MainActor 6 + final class TabTests: XCTestCase { 7 + func testOpenTab() async { 8 + let mock = MockWebContent() 9 + let tab = Tab(content: mock) 10 + XCTAssertEqual(tab.engine, .webkit) 11 + XCTAssertNil(tab.title) 12 + } 13 + }