SPB Git

spb/zyquo-atlas Public License

The AI-native macOS web browser — every surface, intelligent.

Swift 75.2% JavaScript 22% Shell 2% Makefile 0.9%
10.2 KB · 268 lines swift
Raw Blame History
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