fancy new browser
1
fork

Configure Feed

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

feat: add a bunch of things

+649 -120
+1 -17
App/Mere.entitlements
··· 2 2 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 3 <plist version="1.0"> 4 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/> 5 + <!-- Sandbox disabled for development - WebKit WebContent process needs too many exceptions --> 22 6 </dict> 23 7 </plist>
+37
App/MereApp.swift
··· 15 15 WindowGroup { 16 16 BrowserWindowView(window: window) 17 17 .frame(minWidth: 900, minHeight: 600) 18 + .onAppear { 19 + if window.tabs.isEmpty { 20 + window.openTab() 21 + } 22 + } 18 23 } 19 24 .windowStyle(.hiddenTitleBar) 20 25 .commands { ··· 23 28 window.openTab() 24 29 } 25 30 .keyboardShortcut("t", modifiers: .command) 31 + 32 + Button("Close Tab") { 33 + if let tab = window.activeTab { window.closeTab(tab) } 34 + } 35 + .keyboardShortcut("w", modifiers: .command) 36 + } 37 + 38 + CommandGroup(replacing: .sidebar) { 39 + Button("Toggle Sidebar") { 40 + window.sidebarVisible.toggle() 41 + } 42 + .keyboardShortcut("s", modifiers: .command) 43 + } 44 + 45 + CommandGroup(after: .toolbar) { 46 + Button("Focus Address Bar") { 47 + window.addressFocusTrigger += 1 48 + } 49 + .keyboardShortcut("l", modifiers: .command) 50 + 51 + Button("Reload Page") { 52 + window.activeTab?.reload() 53 + } 54 + .keyboardShortcut("r", modifiers: .command) 55 + 56 + Button("Copy URL") { 57 + if let url = window.activeTab?.url { 58 + NSPasteboard.general.clearContents() 59 + NSPasteboard.general.setString(url.absoluteString, forType: .string) 60 + } 61 + } 62 + .keyboardShortcut("c", modifiers: [.command, .shift]) 26 63 } 27 64 } 28 65 }
+79
Mere.xcodeproj/xcshareddata/xcschemes/Mere.xcscheme
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <Scheme 3 + LastUpgradeVersion = "2640" 4 + version = "1.7"> 5 + <BuildAction 6 + parallelizeBuildables = "YES" 7 + buildImplicitDependencies = "YES" 8 + buildArchitectures = "Automatic"> 9 + <BuildActionEntries> 10 + <BuildActionEntry 11 + buildForTesting = "YES" 12 + buildForRunning = "YES" 13 + buildForProfiling = "YES" 14 + buildForArchiving = "YES" 15 + buildForAnalyzing = "YES"> 16 + <BuildableReference 17 + BuildableIdentifier = "primary" 18 + BlueprintIdentifier = "B3A1005001000000" 19 + BuildableName = "Mere.app" 20 + BlueprintName = "Mere" 21 + ReferencedContainer = "container:Mere.xcodeproj"> 22 + </BuildableReference> 23 + </BuildActionEntry> 24 + </BuildActionEntries> 25 + </BuildAction> 26 + <TestAction 27 + buildConfiguration = "Debug" 28 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" 29 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" 30 + shouldUseLaunchSchemeArgsEnv = "YES" 31 + shouldAutocreateTestPlan = "YES"> 32 + </TestAction> 33 + <LaunchAction 34 + buildConfiguration = "Debug" 35 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" 36 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" 37 + launchStyle = "0" 38 + useCustomWorkingDirectory = "NO" 39 + ignoresPersistentStateOnLaunch = "NO" 40 + debugDocumentVersioning = "YES" 41 + debugServiceExtension = "internal" 42 + allowLocationSimulation = "YES" 43 + queueDebuggingEnableBacktraceRecording = "Yes"> 44 + <BuildableProductRunnable 45 + runnableDebuggingMode = "0"> 46 + <BuildableReference 47 + BuildableIdentifier = "primary" 48 + BlueprintIdentifier = "B3A1005001000000" 49 + BuildableName = "Mere.app" 50 + BlueprintName = "Mere" 51 + ReferencedContainer = "container:Mere.xcodeproj"> 52 + </BuildableReference> 53 + </BuildableProductRunnable> 54 + </LaunchAction> 55 + <ProfileAction 56 + buildConfiguration = "Release" 57 + shouldUseLaunchSchemeArgsEnv = "YES" 58 + savedToolIdentifier = "" 59 + useCustomWorkingDirectory = "NO" 60 + debugDocumentVersioning = "YES"> 61 + <BuildableProductRunnable 62 + runnableDebuggingMode = "0"> 63 + <BuildableReference 64 + BuildableIdentifier = "primary" 65 + BlueprintIdentifier = "B3A1005001000000" 66 + BuildableName = "Mere.app" 67 + BlueprintName = "Mere" 68 + ReferencedContainer = "container:Mere.xcodeproj"> 69 + </BuildableReference> 70 + </BuildableProductRunnable> 71 + </ProfileAction> 72 + <AnalyzeAction 73 + buildConfiguration = "Debug"> 74 + </AnalyzeAction> 75 + <ArchiveAction 76 + buildConfiguration = "Release" 77 + revealArchiveInOrganizer = "YES"> 78 + </ArchiveAction> 79 + </Scheme>
+2
Sources/ChromiumEngine/ChromiumWebContent.swift
··· 89 89 90 90 public func snapshot() async -> NSImage? { nil } 91 91 92 + public func suspend() {} 93 + public func resume() {} 92 94 public func close() { continuation.finish() } 93 95 }
+87 -23
Sources/MereCore/Tab.swift
··· 23 23 @Published public private(set) var favicon: URL? 24 24 @Published public private(set) var hasAudioPlaying = false 25 25 @Published public private(set) var themeColor: PlatformColor? 26 + @Published public private(set) var navigationError: Error? 26 27 27 28 private var observationTask: Task<Void, Never>? 29 + private var pollTask: Task<Void, Never>? 30 + private var themeColorTask: Task<Void, Never>? 31 + 32 + /// Whether this tab is currently visible. Background tabs poll at a much 33 + /// lower rate and skip theme-colour reads to save CPU and memory. 34 + public private(set) var isActive = false 28 35 29 36 public init(content: any WebContent) { 30 37 self.id = content.id ··· 41 48 public func reload() { content.reload() } 42 49 public func stopLoading() { content.stopLoading() } 43 50 51 + // MARK: - State control 52 + 53 + public func resetToNewTab() { 54 + url = nil 55 + title = nil 56 + isLoading = false 57 + estimatedProgress = 0 58 + canGoBack = false 59 + canGoForward = false 60 + themeColor = nil 61 + navigationError = nil 62 + content.loadHTML("", baseURL: nil) 63 + } 64 + 65 + // MARK: - Active state 66 + 67 + public func activate() { 68 + guard !isActive else { return } 69 + isActive = true 70 + content.resume() 71 + // Immediately sync so the UI reflects current state. 72 + syncState() 73 + scheduleThemeColorRead() 74 + startPoll() 75 + } 76 + 77 + public func deactivate() { 78 + guard isActive else { return } 79 + isActive = false 80 + content.suspend() 81 + pollTask?.cancel() 82 + pollTask = nil 83 + themeColorTask?.cancel() 84 + themeColorTask = nil 85 + } 86 + 44 87 // MARK: - Private 45 88 46 89 private func startObserving() { ··· 48 91 guard let self else { return } 49 92 for await event in content.navigationEvents { 50 93 guard !Task.isCancelled else { break } 51 - await MainActor.run { 52 - self.apply(event) 53 - } 94 + await MainActor.run { self.apply(event) } 54 95 } 55 96 } 97 + // Start polling immediately; WindowViewModel will call activate/deactivate. 98 + startPoll() 99 + } 56 100 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 101 + private func startPoll() { 102 + pollTask?.cancel() 103 + // Active tabs poll at 250 ms; background tabs poll at 2 s (title/loading only). 104 + let interval: Duration = isActive ? .milliseconds(250) : .seconds(2) 105 + pollTask = Task { [weak self] in 60 106 while !Task.isCancelled { 61 107 guard let self else { return } 62 108 self.syncState() 63 - try? await Task.sleep(for: .milliseconds(100)) 109 + try? await Task.sleep(for: interval) 64 110 } 65 111 } 66 112 } ··· 71 117 self.url = url 72 118 self.isLoading = true 73 119 self.estimatedProgress = 0.1 120 + self.navigationError = nil // clear any previous error 121 + self.themeColor = nil // clear for new navigation; readThemeColor will repopulate 74 122 case .committed(let url): 75 123 self.url = url 76 124 self.estimatedProgress = 0.7 ··· 78 126 self.url = url 79 127 self.isLoading = false 80 128 self.estimatedProgress = 1.0 81 - Task { await self.readThemeColor() } 82 - case .failed: 129 + self.navigationError = nil 130 + scheduleThemeColorRead() 131 + case .failed(_, let error): 83 132 self.isLoading = false 84 133 self.estimatedProgress = 0 134 + self.navigationError = error 85 135 case .titleChanged(let title): 86 136 self.title = title 87 137 case .faviconChanged(let url): 88 138 self.favicon = url 139 + case .themeColorChanged(let css): 140 + self.themeColor = PlatformColor.fromCSS(css) 89 141 case .redirected(_, let to): 90 142 self.url = to 91 143 } 92 144 } 93 145 146 + private func scheduleThemeColorRead() { 147 + guard isActive, themeColorTask == nil else { return } 148 + themeColorTask = Task { [weak self] in 149 + await self?.readThemeColor() 150 + self?.themeColorTask = nil 151 + } 152 + } 153 + 94 154 private func readThemeColor() async { 95 155 let js = """ 96 156 (function() { 157 + function elBg(el) { 158 + var bg = window.getComputedStyle(el).backgroundColor; 159 + if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg; 160 + var attr = el.getAttribute ? el.getAttribute('bgcolor') : null; 161 + if (attr) return attr; 162 + return null; 163 + } 97 164 var m = document.querySelector('meta[name="theme-color"]'); 98 165 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; 166 + var el = document.elementFromPoint( 167 + window.innerWidth / 2, window.innerHeight / 2); 168 + while (el && el.nodeType === 1) { 169 + var bg = elBg(el); 170 + if (bg) return bg; 171 + el = el.parentElement; 104 172 } 105 173 return null; 106 174 })() 107 175 """ 108 176 let result = try? await content.evaluateJavaScript(js) 109 - guard let css = result as? String, !css.isEmpty else { 110 - self.themeColor = nil 111 - return 112 - } 177 + // Only update when we get a valid colour; don't reset to nil on a 178 + // failed/null read — the colour was already cleared on .started. 179 + guard let css = result as? String, !css.isEmpty else { return } 113 180 self.themeColor = PlatformColor.fromCSS(css) 114 181 } 115 182 ··· 121 188 self.canGoBack = content.canGoBack 122 189 self.canGoForward = content.canGoForward 123 190 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 191 } 130 192 131 193 deinit { 132 194 observationTask?.cancel() 195 + pollTask?.cancel() 196 + themeColorTask?.cancel() 133 197 } 134 198 }
+23
Sources/MereCore/WindowViewModel.swift
··· 11 11 @Published public var activeTab: Tab? 12 12 @Published public private(set) var newTabBackgroundColor: PlatformColor? 13 13 14 + /// Sidebar visibility — owned here so keyboard shortcuts in commands can toggle it. 15 + @Published public var sidebarVisible = true 16 + /// Incrementing this triggers the address bar to take focus and select-all. 17 + @Published public var addressFocusTrigger = 0 18 + 14 19 private let webkitContext: any BrowserContext 15 20 private let chromiumContext: (any BrowserContext)? 16 21 private let cookieSync: CookieSyncController 17 22 public let adBlock: AdBlockController 18 23 private var activeTabObservation: AnyCancellable? 19 24 25 + private let tabsFileURL: URL 26 + 20 27 public init( 21 28 webkitContext: any BrowserContext, 22 29 chromiumContext: (any BrowserContext)? = nil, ··· 29 36 chromium: chromiumContext 30 37 ) 31 38 self.adBlock = AdBlockController(engines: adBlockEngines) 39 + 40 + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! 41 + let appDir = appSupport.appendingPathComponent("Mere", isDirectory: true) 42 + try? FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true) 43 + self.tabsFileURL = appDir.appendingPathComponent("saved_tabs.json") 32 44 } 33 45 34 46 // MARK: - Tab management ··· 39 51 if url == nil, let current = activeTab, current.url != nil { 40 52 newTabBackgroundColor = current.themeColor 41 53 } 54 + activeTab?.deactivate() 42 55 let resolvedEngine = engine ?? url.map(EngineType.preferred) ?? .webkit 43 56 let context = context(for: resolvedEngine) 44 57 let content = context.makeWebContent() 45 58 let tab = Tab(content: content) 46 59 tabs.append(tab) 47 60 activeTab = tab 61 + tab.activate() 48 62 subscribeToActiveTab() 49 63 if let url { tab.loadURL(url) } 50 64 return tab 51 65 } 52 66 53 67 public func closeTab(_ tab: Tab) { 68 + guard tabs.count > 1 else { 69 + tab.resetToNewTab() 70 + return 71 + } 72 + tab.deactivate() 54 73 tab.content.close() 55 74 tabs.removeAll { $0.id == tab.id } 56 75 if activeTab?.id == tab.id { 57 76 activeTab = tabs.last 77 + activeTab?.activate() 58 78 subscribeToActiveTab() 59 79 } 60 80 } 61 81 62 82 public func activateTab(_ tab: Tab) { 83 + activeTab?.deactivate() 63 84 activeTab = tab 85 + tab.activate() 64 86 subscribeToActiveTab() 65 87 } 66 88 ··· 80 102 tabs.move(fromOffsets: IndexSet(integer: tabs.count - 1), toOffset: idx) 81 103 } 82 104 activeTab = newTab 105 + newTab.activate() 83 106 subscribeToActiveTab() 84 107 } 85 108
+2
Sources/MereKit/Mock/MockWebContent.swift
··· 35 35 public func attachHostView(_ container: PlatformView) {} 36 36 public func detachHostView() {} 37 37 public func snapshot() async -> PlatformImage? { nil } 38 + public func suspend() {} 39 + public func resume() {} 38 40 public func close() { continuation.finish() } 39 41 }
+2
Sources/MereKit/Models/NavigationEvent.swift
··· 8 8 case failed(url: URL?, error: Error) 9 9 case titleChanged(title: String) 10 10 case faviconChanged(url: URL?) 11 + /// Native theme-color from `<meta name="theme-color">` — carries the raw CSS string. 12 + case themeColorChanged(cssColor: String) 11 13 } 12 14 13 15 public struct NavigationPolicy: Sendable {
+20
Sources/MereKit/Protocols/WebContent.swift
··· 12 12 #endif 13 13 14 14 extension PlatformColor { 15 + /// Serialize to an `rgb()` CSS string. 16 + public var cssString: String? { 17 + #if canImport(AppKit) 18 + guard let c = usingColorSpace(.deviceRGB) else { return nil } 19 + let r = Int(c.redComponent * 255), g = Int(c.greenComponent * 255), b = Int(c.blueComponent * 255) 20 + #else 21 + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 22 + getRed(&r, green: &g, blue: &b, alpha: &a) 23 + let r = Int(r * 255), g = Int(g * 255), b = Int(b * 255) 24 + #endif 25 + return "rgb(\(r),\(g),\(b))" 26 + } 27 + 15 28 /// Create from a CSS color string — hex (`#rgb`, `#rrggbb`) or `rgb()`/`rgba()`. 16 29 public static func fromCSS(_ value: String) -> PlatformColor? { 17 30 let s = value.trimmingCharacters(in: .whitespaces) ··· 115 128 var navigationEvents: AsyncStream<NavigationEvent> { get } 116 129 117 130 // MARK: - Lifecycle 131 + 132 + /// Signal that the tab moved to the background. Implementations should 133 + /// fire the Page Visibility API and pause media so the page reduces activity. 134 + func suspend() 135 + 136 + /// Signal that the tab became active again. 137 + func resume() 118 138 119 139 func close() 120 140 }
+238 -78
Sources/MereUI/BrowserWindowView.swift
··· 6 6 public struct BrowserWindowView: View { 7 7 8 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 9 13 10 public init(window: WindowViewModel) { 14 11 _window = StateObject(wrappedValue: window) ··· 41 38 return nil 42 39 } 43 40 41 + private var isLocalhost: Bool { 42 + guard let url = window.activeTab?.url else { return false } 43 + let host = url.host?.lowercased() ?? "" 44 + return host == "localhost" || 45 + host == "127.0.0.1" || 46 + host == "::1" || 47 + host.hasPrefix("127.") || 48 + host == "[::1]" 49 + } 50 + 44 51 public var body: some View { 45 52 ZStack { 46 53 // Full-window color gradient using the raw page background colour. ··· 56 63 .animation(.easeInOut(duration: 0.35), value: isNewTab) 57 64 58 65 HStack(spacing: 0) { 59 - if sidebarVisible { 66 + if window.sidebarVisible { 60 67 SidebarView(window: window) 61 68 .frame(width: 220) 62 69 .background(.ultraThinMaterial) ··· 67 74 // Toolbar: transparent background — window gradient shows through. 68 75 BrowserToolbarView( 69 76 window: window, 70 - sidebarVisible: $sidebarVisible, 71 - focusTrigger: addressFocusTrigger 77 + sidebarVisible: $window.sidebarVisible, 78 + focusTrigger: window.addressFocusTrigger 72 79 ) 73 80 .padding(.top, 8) 74 - .padding(.leading, sidebarVisible ? 10 : 86) 81 + .padding(.leading, window.sidebarVisible ? 10 : 86) 75 82 .padding(.trailing, 10) 76 83 .padding(.bottom, 8) 77 84 78 85 // Content: material matching sidebar, fills all remaining space. 79 86 contentArea 80 87 .frame(maxWidth: .infinity, maxHeight: .infinity) 81 - .background(.ultraThinMaterial) 82 88 .clipShape(UnevenRoundedRectangle( 83 89 topLeadingRadius: 0, 84 90 bottomLeadingRadius: 10, ··· 97 103 .padding(.horizontal, 8) 98 104 .padding(.bottom, 8) 99 105 } 106 + .padding(4) 107 + .overlay( 108 + Group { 109 + if isLocalhost { 110 + UnevenRoundedRectangle( 111 + topLeadingRadius: 0, 112 + bottomLeadingRadius: 14, 113 + bottomTrailingRadius: 14, 114 + topTrailingRadius: 0 115 + ) 116 + .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [6, 4])) 117 + .foregroundStyle(.yellow) 118 + } 119 + } 120 + ) 100 121 .ignoresSafeArea(edges: .top) 101 122 } 102 123 } 103 - .animation(.spring(duration: 0.22), value: sidebarVisible) 104 - .background(keyboardShortcuts) 124 + .animation(.spring(duration: 0.22), value: window.sidebarVisible) 105 125 .background(TrafficLightNudge(xOffset: 8, yOffset: 8)) 106 126 .preferredColorScheme(preferredScheme) 107 127 .onChange(of: window.activeTab?.id) { _, _ in 108 - if window.activeTab?.url == nil { 109 - addressFocusTrigger += 1 110 - } else { 111 - NSApp.keyWindow?.makeFirstResponder(nil) 112 - } 128 + NSApp.keyWindow?.makeFirstResponder(nil) 113 129 } 114 130 } 115 131 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 132 @ViewBuilder 136 133 private var contentArea: some View { 137 134 if let tab = window.activeTab, tab.url != nil || tab.isLoading { 138 - WebContentView(content: tab.content) 139 - .id(tab.id) 135 + ZStack { 136 + WebContentView(content: tab.content) 137 + .id(tab.id) 138 + 139 + if let error = tab.navigationError { 140 + NavigationErrorView(error: error) 141 + .padding() 142 + } 143 + } 140 144 } else { 141 145 NewTabView(hasBackground: window.newTabBackgroundColor != nil) 142 146 } ··· 157 161 } 158 162 } 159 163 164 + // MARK: - Navigation Error 165 + 166 + struct NavigationErrorView: View { 167 + let error: Error 168 + 169 + private var errorMessage: String { 170 + let nsError = error as NSError 171 + switch (nsError.domain, nsError.code) { 172 + case ("NSURLErrorDomain", -1004): 173 + return "Can't connect to server" 174 + case ("NSURLErrorDomain", -1001): 175 + return "Connection timed out" 176 + case ("NSURLErrorDomain", -1003): 177 + return "Server not found" 178 + default: 179 + return nsError.localizedDescription 180 + } 181 + } 182 + 183 + var body: some View { 184 + VStack(spacing: 12) { 185 + Image(systemName: "exclamationmark.triangle.fill") 186 + .font(.system(size: 48)) 187 + .foregroundStyle(.orange) 188 + 189 + Text("Unable to load page") 190 + .font(.system(size: 20, weight: .semibold)) 191 + 192 + Text(errorMessage) 193 + .font(.system(size: 14)) 194 + .foregroundStyle(.secondary) 195 + .multilineTextAlignment(.center) 196 + } 197 + .frame(maxWidth: 300) 198 + .padding(20) 199 + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) 200 + .shadow(radius: 20) 201 + } 202 + } 203 + 160 204 // MARK: - Sidebar 161 205 162 206 struct SidebarView: View { ··· 210 254 211 255 Spacer(minLength: 0) 212 256 257 + if tab.hasAudioPlaying { 258 + Button { 259 + tab.content.isMuted.toggle() 260 + } label: { 261 + Image(systemName: tab.content.isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") 262 + .font(.system(size: 9, weight: .medium)) 263 + .frame(width: 18, height: 18) 264 + } 265 + .buttonStyle(HoverButtonStyle()) 266 + .transition(.opacity.animation(.easeInOut(duration: 0.15))) 267 + } 268 + 213 269 // Always reserve space; only visible on hover or when active. 214 270 Button { onClose() } label: { 215 271 Image(systemName: "xmark") 216 272 .font(.system(size: 8, weight: .semibold)) 217 273 .frame(width: 14, height: 14) 218 - .foregroundStyle(.secondary) 219 274 } 220 - .buttonStyle(.plain) 275 + .buttonStyle(HoverButtonStyle()) 221 276 .opacity(isHovered || isActive ? 1 : 0) 222 277 } 223 278 .padding(.horizontal, 10) ··· 232 287 233 288 private final class FaviconCache { 234 289 static let shared = FaviconCache() 235 - private var store: [URL: NSImage] = [:] 236 - private let queue = DispatchQueue(label: "sh.dunkirk.mere.favicon-cache") 290 + private let cache: NSCache<NSURL, NSImage> = { 291 + let c = NSCache<NSURL, NSImage>() 292 + c.countLimit = 200 293 + return c 294 + }() 237 295 238 296 func image(for url: URL) -> NSImage? { 239 - queue.sync { store[url] } 297 + cache.object(forKey: url as NSURL) 240 298 } 241 299 242 300 func store(_ image: NSImage, for url: URL) { 243 - queue.async { self.store[url] = image } 301 + cache.setObject(image, forKey: url as NSURL) 244 302 } 245 303 } 246 304 ··· 302 360 @Binding var sidebarVisible: Bool 303 361 let focusTrigger: Int 304 362 @State private var addressText = "" 305 - @State private var addressBarHovered = false 363 + 364 + private var securityState: SecurityState { 365 + guard let url = window.activeTab?.url else { return .none } 366 + if isLocalhost(url) { return .localhost } 367 + return url.scheme == "https" ? .secure : .insecure 368 + } 306 369 307 370 var body: some View { 308 371 HStack(spacing: 4) { ··· 317 380 window.activeTab?.goForward() 318 381 } 319 382 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 { 383 + HStack(spacing: 6) { 384 + securityIcon 385 + 386 + HStack(spacing: 6) { 387 + AddressBar(text: $addressText, focusTrigger: focusTrigger, onSubmit: navigate) 388 + .frame(maxWidth: .infinity, minHeight: 22) 389 + 390 + if let url = window.activeTab?.url { 330 391 Button { 331 392 NSPasteboard.general.clearContents() 332 393 NSPasteboard.general.setString(url.absoluteString, forType: .string) 333 394 } label: { 334 395 Image(systemName: "link") 335 - .font(.system(size: 11, weight: .medium)) 336 - .frame(width: 28, height: 28) 396 + .font(.system(size: 10, weight: .medium)) 337 397 } 338 398 .buttonStyle(HoverButtonStyle()) 339 - .help("Copy URL") 340 - .transition(.opacity.animation(.easeInOut(duration: 0.12))) 341 399 } 342 400 } 401 + .padding(.horizontal, 10) 402 + .padding(.vertical, 3) 403 + .onChange(of: window.activeTab?.url) { _, url in 404 + addressText = url?.absoluteString ?? "" 405 + } 343 406 .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) 407 + RoundedRectangle(cornerRadius: 7) 408 + .fill(.regularMaterial) 409 + .overlay( 410 + RoundedRectangle(cornerRadius: 7) 411 + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.3), lineWidth: 0.5) 412 + ) 413 + ) 414 + } 353 415 354 416 navIcon("arrow.clockwise") { window.activeTab?.reload() } 355 417 ··· 375 437 .disabled(disabled) 376 438 } 377 439 440 + @ViewBuilder 441 + private var securityIcon: some View { 442 + Button { 443 + showSecurityInfo() 444 + } label: { 445 + let iconName: String = { 446 + switch securityState { 447 + case .secure, .localhost: 448 + return "lock.fill" 449 + case .insecure: 450 + return "lock.open.fill" 451 + case .none: 452 + return "lock.fill" 453 + } 454 + }() 455 + 456 + Image(systemName: iconName) 457 + .font(.system(size: 11, weight: .medium)) 458 + .foregroundStyle(.primary) 459 + .frame(width: 20, height: 26) 460 + } 461 + .buttonStyle(HoverButtonStyle()) 462 + } 463 + 464 + private func showSecurityInfo() { 465 + guard let url = window.activeTab?.url else { return } 466 + let message: String 467 + switch securityState { 468 + case .secure: 469 + message = "Connection is secure (HTTPS)\n\n\(url.absoluteString)" 470 + case .insecure: 471 + message = "Connection is not secure (HTTP)\n\n\(url.absoluteString)" 472 + case .localhost: 473 + message = "Localhost connection\n\n\(url.absoluteString)" 474 + case .none: 475 + message = "No connection" 476 + } 477 + let alert = NSAlert() 478 + alert.messageText = "Security Information" 479 + alert.informativeText = message 480 + alert.alertStyle = .informational 481 + alert.addButton(withTitle: "OK") 482 + alert.runModal() 483 + } 484 + 378 485 private func navigate() { 379 486 let raw = addressText.trimmingCharacters(in: .whitespaces) 380 487 guard !raw.isEmpty else { return } 488 + 381 489 let url: URL 382 - if raw.contains(".") && !raw.contains(" "), 383 - let u = URL(string: raw.hasPrefix("http") ? raw : "https://\(raw)") { 384 - url = u 490 + 491 + func isURL(_ input: String) -> Bool { 492 + // Already has a scheme 493 + if input.hasPrefix("http://") || input.hasPrefix("https://") { 494 + return true 495 + } 496 + 497 + // localhost (with optional port) 498 + if input.lowercased().hasPrefix("localhost") { 499 + return true 500 + } 501 + 502 + // IPv4 address pattern 503 + let ipv4Pattern = #"^(\d{1,3}\.){3}\d{1,3}(:\d+)?$"# 504 + if let regex = try? NSRegularExpression(pattern: ipv4Pattern), 505 + regex.firstMatch(in: input, options: [], range: NSRange(input.startIndex..., in: input)) != nil { 506 + return true 507 + } 508 + 509 + // Domain-like (contains dot, no spaces) 510 + if input.contains(".") && !input.contains(" ") { 511 + return true 512 + } 513 + 514 + return false 515 + } 516 + 517 + if isURL(raw) { 518 + let urlString = raw.hasPrefix("http") ? raw : "http://\(raw)" 519 + if let u = URL(string: urlString) { 520 + url = u 521 + } else { 522 + // Fallback to search if URL construction fails 523 + url = URL(string: "https://s.dunkirk.sh?q=\(raw.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")")! 524 + } 385 525 } else { 386 526 url = URL(string: "https://s.dunkirk.sh?q=\(raw.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")")! 387 527 } 528 + 388 529 if let tab = window.activeTab { 389 530 tab.loadURL(url) 390 531 } else { ··· 411 552 f.font = .systemFont(ofSize: 13) 412 553 f.cell?.isScrollable = true 413 554 f.cell?.wraps = false 414 - f.alignment = .center 555 + f.alignment = .left 415 556 f.delegate = context.coordinator 416 557 return f 417 558 } ··· 430 571 context.coordinator.lastTrigger = focusTrigger 431 572 context.coordinator.focusGeneration += 1 432 573 let gen = context.coordinator.focusGeneration 433 - DispatchQueue.main.async { [weak coordinator = context.coordinator] in 574 + Task { @MainActor [weak coordinator = context.coordinator] in 434 575 guard coordinator?.focusGeneration == gen else { return } 435 576 nsView.window?.makeFirstResponder(nsView) 436 577 nsView.currentEditor()?.selectAll(nil) ··· 440 581 441 582 func makeCoordinator() -> Coordinator { Coordinator(self) } 442 583 443 - /// Returns an attributed string showing `host` in medium tone and `/path` in muted tone. 584 + /// Returns an attributed string showing `host` in label color and `/path` in secondary label color. 444 585 func prettyAttributed(_ urlString: String) -> NSAttributedString? { 445 586 guard !urlString.isEmpty, 446 587 let url = URL(string: urlString), 447 588 let host = url.host else { return nil } 448 589 let font = NSFont.systemFont(ofSize: 13) 449 590 let para = NSMutableParagraphStyle() 450 - para.alignment = .center 591 + para.alignment = .left 451 592 let hostAttr: [NSAttributedString.Key: Any] = [ 452 593 .font: font, 453 - .foregroundColor: NSColor.labelColor.withAlphaComponent(0.65), 594 + .foregroundColor: NSColor.labelColor, 454 595 .paragraphStyle: para, 455 596 ] 456 597 let pathAttr: [NSAttributedString.Key: Any] = [ 457 598 .font: font, 458 - .foregroundColor: NSColor.labelColor.withAlphaComponent(0.32), 599 + .foregroundColor: NSColor.secondaryLabelColor, 459 600 .paragraphStyle: para, 460 601 ] 461 - let result = NSMutableAttributedString(string: host, attributes: hostAttr) 602 + let displayHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host 603 + let result = NSMutableAttributedString(string: displayHost, attributes: hostAttr) 462 604 let path = url.path 463 605 let query = url.query.map { "?\($0)" } ?? "" 464 606 let suffix = (path == "/" || path.isEmpty ? "" : path) + query ··· 490 632 if selector == #selector(NSResponder.insertNewline(_:)) { 491 633 focusGeneration += 1 // cancel any pending focus-and-select 492 634 parent.onSubmit() 493 - DispatchQueue.main.async { control.window?.makeFirstResponder(nil) } 635 + Task { @MainActor in control.window?.makeFirstResponder(nil) } 494 636 return true 495 637 } 496 638 return false ··· 517 659 } 518 660 } 519 661 662 + // MARK: - Security 663 + 664 + enum SecurityState { 665 + case none 666 + case secure 667 + case insecure 668 + case localhost 669 + } 670 + 671 + private func isLocalhost(_ url: URL) -> Bool { 672 + guard let host = url.host?.lowercased() else { return false } 673 + return host == "localhost" || 674 + host == "127.0.0.1" || 675 + host == "::1" || 676 + host.hasPrefix("127.") || 677 + host == "[::1]" 678 + } 679 + 520 680 // MARK: - Traffic light repositioning 521 681 522 682 /// Zero-size view that moves the window's traffic-light buttons down by `yOffset` points ··· 530 690 func updateNSView(_ nsView: _View, context: Context) { 531 691 let x = xOffset, y = yOffset 532 692 // Defer until after AppKit's own layout pass resets button frames. 533 - DispatchQueue.main.async { nsView.apply(xOffset: x, yOffset: y) } 693 + Task { @MainActor in nsView.apply(xOffset: x, yOffset: y) } 534 694 } 535 695 536 696 final class _View: NSView {
+7
Sources/WebKitEngine/WebKitAdBlocker.swift
··· 54 54 await applyToConfiguration() 55 55 } 56 56 57 + /// Apply the current enabled rule lists to a freshly created content controller. 58 + /// Called by WebKitBrowserContext when making a new tab. 59 + public func applyCurrentRules(to controller: WKUserContentController) { 60 + guard isEnabled else { return } 61 + for list in compiledLists.values { controller.add(list) } 62 + } 63 + 57 64 // MARK: - Private 58 65 59 66 private func applyToConfiguration() async {
+8 -1
Sources/WebKitEngine/WebKitBrowserContext.swift
··· 25 25 // MARK: - BrowserContext 26 26 27 27 public func makeWebContent() -> any WebContent { 28 - WebKitWebContent(configuration: sharedConfiguration) 28 + // Each tab needs its own WKWebViewConfiguration so that script message 29 + // handler names (mereAudio, mereTheme) don't collide. We share only the 30 + // websiteDataStore so cookies and storage are common across tabs. 31 + let config = WKWebViewConfiguration() 32 + config.websiteDataStore = dataStore 33 + config.preferences.isElementFullscreenEnabled = true 34 + adBlocker.applyCurrentRules(to: config.userContentController) 35 + return WebKitWebContent(configuration: config) 29 36 } 30 37 31 38 public func cookies(for url: URL) async -> [HTTPCookie] {
+143 -1
Sources/WebKitEngine/WebKitWebContent.swift
··· 43 43 44 44 // MARK: - Init 45 45 46 - public init(configuration: WKWebViewConfiguration = .init()) { 46 + public override convenience init() { 47 + self.init(configuration: WKWebViewConfiguration()) 48 + } 49 + 50 + public init(configuration: WKWebViewConfiguration) { 47 51 let (stream, continuation) = AsyncStream<NavigationEvent>.makeStream() 48 52 self.navigationEvents = stream 49 53 self.eventContinuation = continuation 50 54 55 + // Audio: forwards play/pause state via message handler. 56 + configuration.userContentController.addUserScript(WKUserScript( 57 + source: """ 58 + (function() { 59 + function notify() { 60 + var playing = Array.from(document.querySelectorAll('video,audio')) 61 + .some(function(m){ return !m.paused && !m.muted && m.volume > 0; }); 62 + window.webkit.messageHandlers.mereAudio.postMessage(playing); 63 + } 64 + document.addEventListener('play', notify, true); 65 + document.addEventListener('pause', notify, true); 66 + document.addEventListener('volumechange', notify, true); 67 + })(); 68 + """, 69 + injectionTime: .atDocumentEnd, 70 + forMainFrameOnly: false 71 + )) 72 + 73 + // Theme colour detection. Priority order: 74 + // 1. meta[name="theme-color"] — explicit, always wins 75 + // 2. elementFromPoint walk — finds the actual rendered background 76 + // regardless of which element holds it (wrapper divs, app roots, etc.) 77 + // Triggers: documentEnd, window load, html/body attr mutations, 78 + // and <head> childList (for SPAs that inject meta[name="theme-color"]). 79 + configuration.userContentController.addUserScript(WKUserScript( 80 + source: """ 81 + (function() { 82 + var _lastColor = null; 83 + function elBg(el) { 84 + var bg = window.getComputedStyle(el).backgroundColor; 85 + if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg; 86 + // Also check legacy bgcolor attribute (used by HN and old-school HTML) 87 + var attr = el.getAttribute ? el.getAttribute('bgcolor') : null; 88 + if (attr) return attr; 89 + return null; 90 + } 91 + function readColor() { 92 + var m = document.querySelector('meta[name="theme-color"]'); 93 + if (m && m.content) return m.content; 94 + // Walk up from the center of the page to find the background. 95 + var el = document.elementFromPoint( 96 + window.innerWidth / 2, window.innerHeight / 2); 97 + while (el && el.nodeType === 1) { 98 + var bg = elBg(el); 99 + if (bg) return bg; 100 + el = el.parentElement; 101 + } 102 + return null; 103 + } 104 + function report() { 105 + var c = readColor(); 106 + if (c && c !== _lastColor) { 107 + _lastColor = c; 108 + window.webkit.messageHandlers.mereTheme.postMessage(c); 109 + } 110 + } 111 + report(); 112 + window.addEventListener('load', report); 113 + var attrObs = new MutationObserver(report); 114 + var attrOpts = { attributes: true, attributeFilter: ['style', 'class'] }; 115 + attrObs.observe(document.documentElement, attrOpts); 116 + if (document.body) attrObs.observe(document.body, attrOpts); 117 + if (document.head) { 118 + var headObs = new MutationObserver(function(ms) { 119 + for (var i = 0; i < ms.length; i++) { 120 + for (var j = 0; j < ms[i].addedNodes.length; j++) { 121 + if (ms[i].addedNodes[j].nodeName === 'META') { report(); return; } 122 + } 123 + } 124 + }); 125 + headObs.observe(document.head, { childList: true }); 126 + } 127 + })(); 128 + """, 129 + injectionTime: .atDocumentEnd, 130 + forMainFrameOnly: true 131 + )) 132 + 51 133 self.webView = WKWebView(frame: .zero, configuration: configuration) 52 134 self.webView.allowsBackForwardNavigationGestures = true 53 135 54 136 super.init() 137 + 138 + // Use a weak wrapper to avoid retain cycles through userContentController. 139 + let weak = WeakScriptMessageHandler(self) 140 + configuration.userContentController.add(weak, name: "mereAudio") 141 + configuration.userContentController.add(weak, name: "mereTheme") 55 142 56 143 webView.navigationDelegate = self 57 144 webView.uiDelegate = self ··· 114 201 return try? await webView.takeSnapshot(configuration: config) 115 202 } 116 203 204 + public func suspend() { 205 + // Tell the page it is hidden — well-behaved pages pause rAF, timers, etc. 206 + Task { 207 + _ = try? await webView.evaluateJavaScript(""" 208 + (function() { 209 + Object.defineProperty(document,'hidden',{value:true,configurable:true}); 210 + Object.defineProperty(document,'visibilityState',{value:'hidden',configurable:true}); 211 + document.dispatchEvent(new Event('visibilitychange')); 212 + })() 213 + """) 214 + } 215 + } 216 + 217 + public func resume() { 218 + Task { 219 + _ = try? await webView.evaluateJavaScript(""" 220 + (function() { 221 + Object.defineProperty(document,'hidden',{value:false,configurable:true}); 222 + Object.defineProperty(document,'visibilityState',{value:'visible',configurable:true}); 223 + document.dispatchEvent(new Event('visibilitychange')); 224 + })() 225 + """) 226 + } 227 + } 228 + 117 229 public func close() { 118 230 observations.forEach { $0.invalidate() } 119 231 observations.removeAll() ··· 217 329 return nil 218 330 } 219 331 } 332 + 333 + // MARK: - Audio state message handler 334 + 335 + extension WebKitWebContent: WKScriptMessageHandler { 336 + public func userContentController(_ userContentController: WKUserContentController, 337 + didReceive message: WKScriptMessage) { 338 + switch message.name { 339 + case "mereAudio": 340 + if let playing = message.body as? Bool { 341 + Task { @MainActor in self.hasAudioPlaying = playing } 342 + } 343 + case "mereTheme": 344 + if let css = message.body as? String { 345 + eventContinuation.yield(.themeColorChanged(cssColor: css)) 346 + } 347 + default: break 348 + } 349 + } 350 + } 351 + 352 + /// Breaks the WKUserContentController → handler retain cycle. 353 + private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { 354 + weak var target: WKScriptMessageHandler? 355 + init(_ target: WKScriptMessageHandler) { self.target = target } 356 + 357 + func userContentController(_ userContentController: WKUserContentController, 358 + didReceive message: WKScriptMessage) { 359 + target?.userContentController(userContentController, didReceive: message) 360 + } 361 + }