spb/zyquo-atlas Public License
The AI-native macOS web browser — every surface, intelligent.
Swift 75.2%
JavaScript 22%
Shell 2%
Makefile 0.9%
1//2// Tab.swift3// Zyquo Atlas4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// A browser tab. Owns one WKWebView and publishes its navigation state (URL,9// title, progress, back/forward, loading, favicon) via KVO so SwiftUI chrome10// updates live. Acts as the web view's navigation + UI delegate: link opens11// that request a new web view (target=_blank, ⌘-click) are routed back to the12// TabManager. Each tab owns its own future AI context (Phase 3); background13// tabs can be suspended to reclaim memory.14//1516import Foundation17import WebKit18import Combine1920@MainActor21final class Tab: NSObject, ObservableObject, Identifiable {22 let id = UUID()23 let profile: Profile2425 // Published navigation state (chrome binds to these).26 @Published private(set) var url: URL?27 @Published var title: String = "New Tab"28 @Published private(set) var estimatedProgress: Double = 029 @Published private(set) var isLoading: Bool = false30 @Published private(set) var canGoBack: Bool = false31 @Published private(set) var canGoForward: Bool = false32 @Published private(set) var hasSecureConnection: Bool = false33 @Published private(set) var isSuspended: Bool = false34 /// True while a determinate load is in flight (drives the progress bar).35 @Published private(set) var showsProgress: Bool = false3637 /// Per-tab AI context (chat-with-page, summarize). Cancelled on navigation.38 let ai = AIService()3940 /// Called when the page requests a new web view (new tab / ⌘-click).41 /// Returns the web view the new tab will drive, or nil to block.42 var onCreateTab: ((WKWebViewConfiguration, URLRequest?) -> WKWebView?)?43 /// Called when the tab wants to be closed (window.close()).44 var onClose: (() -> Void)?45 /// Called after a top-level navigation finishes (url, title) — used to46 /// record history (skipped for private profiles by the wiring).47 var onDidFinishNavigation: ((String, String) -> Void)?48 /// Receives file downloads triggered by this tab.49 weak var downloadManager: DownloadManager?5051 /// Pinned tabs survive session restore and sort first.52 @Published var isPinned: Bool = false5354 /// Current in-page text selection (for the floating selection toolbar).55 @Published var selectionText: String = ""56 /// Selection rect in web-view coordinates (top-left origin, points).57 @Published var selectionRect: CGRect = .zero5859 private(set) var webView: WKWebView!60 private var observations: [NSKeyValueObservation] = []61 /// Restored on un-suspend.62 private var suspendedURL: URL?6364 init(profile: Profile = .defaultProfile,65 configuration: WKWebViewConfiguration? = nil) {66 self.profile = profile67 super.init()68 let config = configuration ?? ProfileStore.shared.makeConfiguration(for: profile)69 let webView = WKWebView(frame: .zero, configuration: config)70 webView.navigationDelegate = self71 webView.uiDelegate = self72 webView.allowsBackForwardNavigationGestures = true73 webView.allowsMagnification = true74 webView.customUserAgent = nil // use WebKit's default (Safari-compatible)75 self.webView = webView76 installObservers()77 // Receive in-page selection changes from the isolated content world.78 config.userContentController.add(self, contentWorld: ContentExtractor.world,79 name: "atlasSelection")80 }8182 // MARK: - Navigation commands8384 func load(_ url: URL) {85 unsuspendIfNeeded()86 webView.load(URLRequest(url: url))87 }8889 func loadOmnibox(_ text: String) {90 if let url = OmniIntent.resolve(text).url { load(url) }91 }9293 func goBack() { webView.goBack() }94 func goForward() { webView.goForward() }95 func reload() { webView.reload() }96 func stop() { webView.stopLoading() }9798 // MARK: - Find in page99100 /// Highlights and scrolls to the next match. Returns match presence via the101 /// completion. Uses WKWebView's native find (macOS 11+).102 func find(_ query: String, forward: Bool = true, completion: ((Bool) -> Void)? = nil) {103 guard !query.isEmpty else { clearFind(); completion?(false); return }104 let cfg = WKFindConfiguration()105 cfg.backwards = !forward106 cfg.caseSensitive = false107 cfg.wraps = true108 webView.find(query, configuration: cfg) { result in109 completion?(result.matchFound)110 }111 }112113 func clearFind() {114 // Re-running an empty selection clears the highlight overlay.115 webView.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges(); null",116 in: nil, in: .page) { _ in }117 }118119 // MARK: - Zoom (per-tab)120121 func setZoom(_ factor: CGFloat) { webView.pageZoom = factor }122 var zoom: CGFloat { webView.pageZoom }123124 // MARK: - Suspension (memory saving for background tabs)125126 /// Tears down the web content while remembering the current URL, freeing127 /// the content process. Restored on next activation.128 func suspend() {129 guard !isSuspended, let current = url else { return }130 suspendedURL = current131 webView.loadHTMLString("", baseURL: nil)132 isSuspended = true133 }134135 private func unsuspendIfNeeded() {136 guard isSuspended, let saved = suspendedURL else { return }137 isSuspended = false138 suspendedURL = nil139 webView.load(URLRequest(url: saved))140 }141142 func activate() { unsuspendIfNeeded() }143144 // MARK: - KVO wiring145146 private func installObservers() {147 observations = [148 webView.observe(\.estimatedProgress, options: [.new]) { [weak self] wv, _ in149 Task { @MainActor in self?.estimatedProgress = wv.estimatedProgress }150 },151 webView.observe(\.isLoading, options: [.new]) { [weak self] wv, _ in152 Task { @MainActor in153 self?.isLoading = wv.isLoading154 self?.showsProgress = wv.isLoading && wv.estimatedProgress < 1155 }156 },157 webView.observe(\.url, options: [.new]) { [weak self] wv, _ in158 Task { @MainActor in159 self?.url = wv.url160 self?.hasSecureConnection = wv.url?.scheme?.lowercased() == "https"161 }162 },163 webView.observe(\.title, options: [.new]) { [weak self] wv, _ in164 Task { @MainActor in165 let t = wv.title ?? ""166 self?.title = t.isEmpty ? (wv.url?.host ?? "New Tab") : t167 }168 },169 webView.observe(\.canGoBack, options: [.new]) { [weak self] wv, _ in170 Task { @MainActor in self?.canGoBack = wv.canGoBack }171 },172 webView.observe(\.canGoForward, options: [.new]) { [weak self] wv, _ in173 Task { @MainActor in self?.canGoForward = wv.canGoForward }174 },175 ]176 }177178 deinit { observations.forEach { $0.invalidate() } }179}180181// MARK: - WKNavigationDelegate182183extension Tab: WKNavigationDelegate {184 func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {185 showsProgress = true186 // Cancel any in-flight AI request bound to the previous page.187 ai.cancel()188 }189190 /// Extracts a normalized PageContext from this tab's live page.191 func extractPageContext() async throws -> PageContext {192 try await ContentExtractor.extract(from: webView)193 }194195 func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {196 showsProgress = false197 estimatedProgress = 1198 if let url = webView.url?.absoluteString {199 let name = (webView.title?.isEmpty == false) ? webView.title! : (webView.url?.host ?? url)200 onDidFinishNavigation?(url, name)201 }202 }203204 // Route non-displayable responses (and explicit downloads) to WKDownload.205 func webView(_ webView: WKWebView,206 decidePolicyFor navigationResponse: WKNavigationResponse,207 decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {208 decisionHandler(navigationResponse.canShowMIMEType ? .allow : .download)209 }210211 func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse,212 didBecome download: WKDownload) {213 downloadManager?.attach(download)214 }215216 func webView(_ webView: WKWebView, navigationAction: WKNavigationAction,217 didBecome download: WKDownload) {218 downloadManager?.attach(download)219 }220221 func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {222 showsProgress = false223 }224225 func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {226 showsProgress = false227 }228}229230// MARK: - WKUIDelegate (new tabs / window.close)231232// MARK: - WKScriptMessageHandler (in-page selection)233234extension Tab: WKScriptMessageHandler {235 func userContentController(_ userContentController: WKUserContentController,236 didReceive message: WKScriptMessage) {237 guard message.name == "atlasSelection",238 let body = message.body as? [String: Any] else { return }239 let text = (body["text"] as? String) ?? ""240 selectionText = text241 if !text.isEmpty,242 let x = body["x"] as? Double, let y = body["y"] as? Double,243 let w = body["w"] as? Double, let h = body["h"] as? Double {244 let z = Double(webView.pageZoom)245 selectionRect = CGRect(x: x * z, y: y * z, width: w * z, height: h * z)246 } else {247 selectionRect = .zero248 }249 }250}251252// MARK: - WKUIDelegate (new tabs / window.close)253254extension Tab: WKUIDelegate {255 func webView(_ webView: WKWebView,256 createWebViewWith configuration: WKWebViewConfiguration,257 for navigationAction: WKNavigationAction,258 windowFeatures: WKWindowFeatures) -> WKWebView? {259 // target=_blank, window.open, ⌘-click → open in a new tab and hand the260 // page the new tab's web view so it drives the load.261 onCreateTab?(configuration, navigationAction.request)262 }263264 func webViewDidClose(_ webView: WKWebView) {265 onClose?()266 }267}268