// // Tab.swift // Zyquo Atlas // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // A browser tab. Owns one WKWebView and publishes its navigation state (URL, // title, progress, back/forward, loading, favicon) via KVO so SwiftUI chrome // updates live. Acts as the web view's navigation + UI delegate: link opens // that request a new web view (target=_blank, ⌘-click) are routed back to the // TabManager. Each tab owns its own future AI context (Phase 3); background // tabs can be suspended to reclaim memory. // import Foundation import WebKit import Combine @MainActor final class Tab: NSObject, ObservableObject, Identifiable { let id = UUID() let profile: Profile // Published navigation state (chrome binds to these). @Published private(set) var url: URL? @Published var title: String = "New Tab" @Published private(set) var estimatedProgress: Double = 0 @Published private(set) var isLoading: Bool = false @Published private(set) var canGoBack: Bool = false @Published private(set) var canGoForward: Bool = false @Published private(set) var hasSecureConnection: Bool = false @Published private(set) var isSuspended: Bool = false /// True while a determinate load is in flight (drives the progress bar). @Published private(set) var showsProgress: Bool = false /// Per-tab AI context (chat-with-page, summarize). Cancelled on navigation. let ai = AIService() /// Called when the page requests a new web view (new tab / ⌘-click). /// Returns the web view the new tab will drive, or nil to block. var onCreateTab: ((WKWebViewConfiguration, URLRequest?) -> WKWebView?)? /// Called when the tab wants to be closed (window.close()). var onClose: (() -> Void)? /// Called after a top-level navigation finishes (url, title) — used to /// record history (skipped for private profiles by the wiring). var onDidFinishNavigation: ((String, String) -> Void)? /// Receives file downloads triggered by this tab. weak var downloadManager: DownloadManager? /// Pinned tabs survive session restore and sort first. @Published var isPinned: Bool = false /// Current in-page text selection (for the floating selection toolbar). @Published var selectionText: String = "" /// Selection rect in web-view coordinates (top-left origin, points). @Published var selectionRect: CGRect = .zero private(set) var webView: WKWebView! private var observations: [NSKeyValueObservation] = [] /// Restored on un-suspend. private var suspendedURL: URL? init(profile: Profile = .defaultProfile, configuration: WKWebViewConfiguration? = nil) { self.profile = profile super.init() let config = configuration ?? ProfileStore.shared.makeConfiguration(for: profile) let webView = WKWebView(frame: .zero, configuration: config) webView.navigationDelegate = self webView.uiDelegate = self webView.allowsBackForwardNavigationGestures = true webView.allowsMagnification = true webView.customUserAgent = nil // use WebKit's default (Safari-compatible) self.webView = webView installObservers() // Receive in-page selection changes from the isolated content world. config.userContentController.add(self, contentWorld: ContentExtractor.world, name: "atlasSelection") } // MARK: - Navigation commands func load(_ url: URL) { unsuspendIfNeeded() webView.load(URLRequest(url: url)) } func loadOmnibox(_ text: String) { if let url = OmniIntent.resolve(text).url { load(url) } } func goBack() { webView.goBack() } func goForward() { webView.goForward() } func reload() { webView.reload() } func stop() { webView.stopLoading() } // MARK: - Find in page /// Highlights and scrolls to the next match. Returns match presence via the /// completion. Uses WKWebView's native find (macOS 11+). func find(_ query: String, forward: Bool = true, completion: ((Bool) -> Void)? = nil) { guard !query.isEmpty else { clearFind(); completion?(false); return } let cfg = WKFindConfiguration() cfg.backwards = !forward cfg.caseSensitive = false cfg.wraps = true webView.find(query, configuration: cfg) { result in completion?(result.matchFound) } } func clearFind() { // Re-running an empty selection clears the highlight overlay. webView.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges(); null", in: nil, in: .page) { _ in } } // MARK: - Zoom (per-tab) func setZoom(_ factor: CGFloat) { webView.pageZoom = factor } var zoom: CGFloat { webView.pageZoom } // MARK: - Suspension (memory saving for background tabs) /// Tears down the web content while remembering the current URL, freeing /// the content process. Restored on next activation. func suspend() { guard !isSuspended, let current = url else { return } suspendedURL = current webView.loadHTMLString("", baseURL: nil) isSuspended = true } private func unsuspendIfNeeded() { guard isSuspended, let saved = suspendedURL else { return } isSuspended = false suspendedURL = nil webView.load(URLRequest(url: saved)) } func activate() { unsuspendIfNeeded() } // MARK: - KVO wiring private func installObservers() { observations = [ webView.observe(\.estimatedProgress, options: [.new]) { [weak self] wv, _ in Task { @MainActor in self?.estimatedProgress = wv.estimatedProgress } }, webView.observe(\.isLoading, options: [.new]) { [weak self] wv, _ in Task { @MainActor in self?.isLoading = wv.isLoading self?.showsProgress = wv.isLoading && wv.estimatedProgress < 1 } }, webView.observe(\.url, options: [.new]) { [weak self] wv, _ in Task { @MainActor in self?.url = wv.url self?.hasSecureConnection = wv.url?.scheme?.lowercased() == "https" } }, webView.observe(\.title, options: [.new]) { [weak self] wv, _ in Task { @MainActor in let t = wv.title ?? "" self?.title = t.isEmpty ? (wv.url?.host ?? "New Tab") : t } }, webView.observe(\.canGoBack, options: [.new]) { [weak self] wv, _ in Task { @MainActor in self?.canGoBack = wv.canGoBack } }, webView.observe(\.canGoForward, options: [.new]) { [weak self] wv, _ in Task { @MainActor in self?.canGoForward = wv.canGoForward } }, ] } deinit { observations.forEach { $0.invalidate() } } } // MARK: - WKNavigationDelegate extension Tab: WKNavigationDelegate { func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { showsProgress = true // Cancel any in-flight AI request bound to the previous page. ai.cancel() } /// Extracts a normalized PageContext from this tab's live page. func extractPageContext() async throws -> PageContext { try await ContentExtractor.extract(from: webView) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { showsProgress = false estimatedProgress = 1 if let url = webView.url?.absoluteString { let name = (webView.title?.isEmpty == false) ? webView.title! : (webView.url?.host ?? url) onDidFinishNavigation?(url, name) } } // Route non-displayable responses (and explicit downloads) to WKDownload. func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { decisionHandler(navigationResponse.canShowMIMEType ? .allow : .download) } func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { downloadManager?.attach(download) } func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { downloadManager?.attach(download) } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { showsProgress = false } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { showsProgress = false } } // MARK: - WKUIDelegate (new tabs / window.close) // MARK: - WKScriptMessageHandler (in-page selection) extension Tab: WKScriptMessageHandler { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "atlasSelection", let body = message.body as? [String: Any] else { return } let text = (body["text"] as? String) ?? "" selectionText = text if !text.isEmpty, let x = body["x"] as? Double, let y = body["y"] as? Double, let w = body["w"] as? Double, let h = body["h"] as? Double { let z = Double(webView.pageZoom) selectionRect = CGRect(x: x * z, y: y * z, width: w * z, height: h * z) } else { selectionRect = .zero } } } // MARK: - WKUIDelegate (new tabs / window.close) extension Tab: WKUIDelegate { func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { // target=_blank, window.open, ⌘-click → open in a new tab and hand the // page the new tab's web view so it drives the load. onCreateTab?(configuration, navigationAction.request) } func webViewDidClose(_ webView: WKWebView) { onClose?() } }