fancy new browser
1
fork

Configure Feed

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

at main 201 lines 6.7 kB view raw
1import Foundation 2import MereKit 3import Combine 4#if canImport(AppKit) 5import AppKit 6#endif 7 8/// Observable model for a single browser tab. 9/// Mirrors ADK2.WebContentViewModel / ADK2.WebContentController. 10@MainActor 11public 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 @Published public private(set) var navigationError: Error? 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 35 36 public init(content: any WebContent) { 37 self.id = content.id 38 self.engine = content.engine 39 self.content = content 40 startObserving() 41 } 42 43 // MARK: - Navigation passthrough 44 45 public func loadURL(_ url: URL) { 46 print("🔍 Tab.loadURL: \(url.absoluteString)") 47 content.loadURL(url) 48 } 49 public func goBack() { content.goBack() } 50 public func goForward() { content.goForward() } 51 public func reload() { content.reload() } 52 public func stopLoading() { content.stopLoading() } 53 54 // MARK: - State control 55 56 public func resetToNewTab() { 57 url = nil 58 title = nil 59 isLoading = false 60 estimatedProgress = 0 61 canGoBack = false 62 canGoForward = false 63 themeColor = nil 64 navigationError = nil 65 content.loadHTML("", baseURL: nil) 66 } 67 68 // MARK: - Active state 69 70 public func activate() { 71 guard !isActive else { return } 72 isActive = true 73 content.resume() 74 // Immediately sync so the UI reflects current state. 75 syncState() 76 scheduleThemeColorRead() 77 startPoll() 78 } 79 80 public func deactivate() { 81 guard isActive else { return } 82 isActive = false 83 content.suspend() 84 pollTask?.cancel() 85 pollTask = nil 86 themeColorTask?.cancel() 87 themeColorTask = nil 88 } 89 90 // MARK: - Private 91 92 private func startObserving() { 93 observationTask = Task { [weak self] in 94 guard let self else { return } 95 for await event in content.navigationEvents { 96 guard !Task.isCancelled else { break } 97 await MainActor.run { self.apply(event) } 98 } 99 } 100 // Start polling immediately; WindowViewModel will call activate/deactivate. 101 startPoll() 102 } 103 104 private func startPoll() { 105 pollTask?.cancel() 106 // Active tabs poll at 250 ms; background tabs poll at 2 s (title/loading only). 107 let interval: Duration = isActive ? .milliseconds(250) : .seconds(2) 108 pollTask = Task { [weak self] in 109 while !Task.isCancelled { 110 guard let self else { return } 111 self.syncState() 112 try? await Task.sleep(for: interval) 113 } 114 } 115 } 116 117 private func apply(_ event: NavigationEvent) { 118 switch event { 119 case .started(let url): 120 self.url = url 121 self.isLoading = true 122 self.estimatedProgress = 0.1 123 self.navigationError = nil // clear any previous error 124 self.themeColor = nil // clear for new navigation; readThemeColor will repopulate 125 case .committed(let url): 126 self.url = url 127 self.estimatedProgress = 0.7 128 case .finished(let url): 129 self.url = url 130 self.isLoading = false 131 self.estimatedProgress = 1.0 132 self.navigationError = nil 133 scheduleThemeColorRead() 134 case .failed(_, let error): 135 self.isLoading = false 136 self.estimatedProgress = 0 137 self.navigationError = error 138 case .titleChanged(let title): 139 self.title = title 140 case .faviconChanged(let url): 141 self.favicon = url 142 case .themeColorChanged(let css): 143 self.themeColor = PlatformColor.fromCSS(css) 144 case .redirected(_, let to): 145 self.url = to 146 } 147 } 148 149 private func scheduleThemeColorRead() { 150 guard isActive, themeColorTask == nil else { return } 151 themeColorTask = Task { [weak self] in 152 await self?.readThemeColor() 153 self?.themeColorTask = nil 154 } 155 } 156 157 private func readThemeColor() async { 158 let js = """ 159 (function() { 160 function elBg(el) { 161 var bg = window.getComputedStyle(el).backgroundColor; 162 if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg; 163 var attr = el.getAttribute ? el.getAttribute('bgcolor') : null; 164 if (attr) return attr; 165 return null; 166 } 167 var m = document.querySelector('meta[name="theme-color"]'); 168 if (m && m.content) return m.content; 169 var el = document.elementFromPoint( 170 window.innerWidth / 2, window.innerHeight / 2); 171 while (el && el.nodeType === 1) { 172 var bg = elBg(el); 173 if (bg) return bg; 174 el = el.parentElement; 175 } 176 return null; 177 })() 178 """ 179 let result = try? await content.evaluateJavaScript(js) 180 // Only update when we get a valid colour; don't reset to nil on a 181 // failed/null read the colour was already cleared on .started. 182 guard let css = result as? String, !css.isEmpty else { return } 183 self.themeColor = PlatformColor.fromCSS(css) 184 } 185 186 private func syncState() { 187 self.url = content.url 188 self.title = content.title 189 self.isLoading = content.isLoading 190 self.estimatedProgress = content.estimatedProgress 191 self.canGoBack = content.canGoBack 192 self.canGoForward = content.canGoForward 193 self.hasAudioPlaying = content.hasAudioPlaying 194 } 195 196 deinit { 197 observationTask?.cancel() 198 pollTask?.cancel() 199 themeColorTask?.cancel() 200 } 201}