fancy new browser
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}