<3/products/browser/Sources/GhostBrowser/

Browser.swift

import AppKit
import SwiftUI
import WebKit

struct NavigationContext {
    var source: String?
    var sourceEvent: String?
    var kind: String
    var extra: [String: Any] = [:]
}

final class Browser: NSObject, ObservableObject {
    @Published var tabs: [BrowserTab] = []
    @Published var selected: UUID? {
        didSet {
            guard selected != oldValue else { return }
            addressEditing = false
            syncAddress()
        }
    }
    @Published var addressEditing = false
    @Published var addressFocusRequest = 0
    @Published var address = ""
    @Published var paused = false
    @Published var problem: String?
    let recorder: Recorder
    let websiteDataStore: WKWebsiteDataStore
    var failed: [CaptureEvent] = []
    var recentlyClosed: [URL] = []
    var active: BrowserTab? { tabs.first { $0.id == selected } }
    var isTesting: Bool

    init(root: URL, testing: Bool = false) {
        recorder = Recorder(root: root); isTesting = testing
        websiteDataStore = BrowserProfile.dataStore(testing: testing)
        super.init()
        do { try recorder.prepare() } catch { problem = error.localizedDescription }
    }

    @discardableResult func add(url: URL = URL(string: "about:blank")!, context: NavigationContext? = nil, configuration: WKWebViewConfiguration? = nil, load: Bool = true) -> BrowserTab {
        let tab = BrowserTab(browser: self, configuration: configuration)
        tabs.append(tab); selected = tab.id
        tab.pending = paused ? nil : context
        if load { tab.web.load(URLRequest(url: url)) }
        return tab
    }

    func newTab() {
        add()
        showAddress()
    }

    func close(_ id: UUID? = nil) {
        guard failed.isEmpty else { presentProblem(); return }
        guard let index = tabs.firstIndex(where: { $0.id == (id ?? selected) }) else { return }
        let tab = tabs.remove(at: index)
        if let url = tab.web.url { recentlyClosed.append(url); recentlyClosed = Array(recentlyClosed.suffix(20)) }
        tab.dispose()
        if selected == tab.id { selected = tabs.isEmpty ? nil : tabs[min(index, tabs.count - 1)].id }
        if tabs.isEmpty { newTab() }
    }

    func reopen() { if let url = recentlyClosed.popLast() { add(url: url, context: NavigationContext(kind: "reopened_tab")) } }
    func select(offset: Int) {
        guard let index = tabs.firstIndex(where: { $0.id == selected }), !tabs.isEmpty else { return }
        selected = tabs[(index + offset + tabs.count) % tabs.count].id
    }
    func syncAddress() {
        guard !addressEditing else { return }
        let url = active?.web.url
        address = ["http", "https"].contains(url?.scheme?.lowercased() ?? "") ? url!.absoluteString : ""
    }
    func showAddress() {
        addressEditing = false
        syncAddress()
        addressFocusRequest += 1
    }
    func cancelAddress() {
        addressEditing = false
        syncAddress()
        if let web = active?.web { web.window?.makeFirstResponder(web) }
    }
    func goBack() {
        guard let web = active?.web, web.canGoBack else { return }
        cancelAddress()
        web.goBack()
    }
    func goForward() {
        guard let web = active?.web, web.canGoForward else { return }
        cancelAddress()
        web.goForward()
    }
    func navigate() {
        var value = address.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !value.isEmpty else { return }
        if !value.contains("://") {
            if value.contains(" ") || !value.contains(".") {
                var search = URLComponents(string: "https://www.google.com/search")!
                search.queryItems = [URLQueryItem(name: "q", value: value)]
                value = search.string!
            }
            else { value = "https://" + value }
        }
        guard Recorder.webURL(value) != nil, let url = URL(string: value), let tab = active else { NSSound.beep(); return }
        tab.pending = paused ? nil : NavigationContext(source: tab.web.url?.absoluteString, sourceEvent: tab.lastEventID, kind: "address")
        addressEditing = false
        tab.web.load(URLRequest(url: url))
        tab.web.window?.makeFirstResponder(tab.web)
    }

    func emit(_ event: CaptureEvent) {
        guard !paused else { return }
        do { try recorder.write(event) }
        catch {
            failed.append(event)
            let wasHealthy = problem == nil
            problem = "Could not save a browsing event to Ghost. \(error.localizedDescription)"
            if wasHealthy { DispatchQueue.main.async { self.presentProblem() } }
        }
    }

    func captureProblem(_ message: String) {
        let wasHealthy = problem == nil
        problem = message
        if wasHealthy && !isTesting { DispatchQueue.main.async { self.presentProblem() } }
    }

    func presentProblem() {
        guard !isTesting else { return }
        let alert = NSAlert()
        alert.messageText = failed.isEmpty ? "Capture needs attention" : "An event has not reached Ghost"
        alert.informativeText = (problem ?? "") + (failed.isEmpty ? "" : "\n\nThe event is still in memory. Keep this app open and retry after fixing the folder or disk. There is no separate sync store.")
        alert.addButton(withTitle: failed.isEmpty ? "OK" : "Retry")
        if !failed.isEmpty { alert.addButton(withTitle: "Keep open") }
        let response = alert.runModal()
        if response == .alertFirstButtonReturn && !failed.isEmpty { retry() }
    }

    func retry() {
        var remaining: [CaptureEvent] = []
        for event in failed { do { try recorder.write(event) } catch { remaining.append(event); problem = error.localizedDescription } }
        failed = remaining
        if remaining.isEmpty { problem = nil }
    }

    func toggleCapture() {
        if problem != nil { presentProblem(); return }
        paused.toggle()
        for tab in tabs {
            tab.pending = nil; tab.linkIntent = nil; tab.lastEventID = nil
            tab.web.evaluateJavaScript("window.__ghostSetPaused?.(\(paused ? "true" : "false"))", completionHandler: nil)
        }
    }

    func restore() {
        let urls = UserDefaults.standard.stringArray(forKey: "openTabs") ?? []
        for value in urls.prefix(30) {
            if value == "about:blank" { add() }
            else if let safe = Recorder.webURL(value), let url = URL(string: safe) { add(url: url) }
        }
        if tabs.isEmpty { newTab() }
        let index = UserDefaults.standard.integer(forKey: "activeTab")
        selected = tabs[min(index, tabs.count - 1)].id
        if Recorder.webURL(active?.web.url?.absoluteString) == nil { showAddress() }
    }
    func saveTabs() {
        guard !isTesting else { return }
        UserDefaults.standard.set(tabs.map { Recorder.webURL($0.web.url?.absoluteString) ?? "about:blank" }, forKey: "openTabs")
        UserDefaults.standard.set(tabs.firstIndex { $0.id == selected } ?? 0, forKey: "activeTab")
    }
}

final class BrowserTab: NSObject, Identifiable, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler {
    let id = UUID()
    unowned let browser: Browser
    let web: WKWebView
    var title = "New tab"
    var pending: NavigationContext?
    var linkIntent: (url: String, context: NavigationContext, date: Date)?
    var lastEventID: String?
    private var observations: [NSKeyValueObservation] = []

    init(browser: Browser, configuration: WKWebViewConfiguration? = nil) {
        self.browser = browser
        let config = configuration ?? WKWebViewConfiguration()
        if configuration == nil { config.websiteDataStore = browser.websiteDataStore }
        config.preferences.javaScriptCanOpenWindowsAutomatically = true
        config.userContentController = WKUserContentController()
        web = BrowserWebView(frame: .zero, configuration: config)
        super.init()
        let controller = web.configuration.userContentController
        controller.add(self, name: "ghost")
        do {
            let scripts = try ["X", "Capture"].map { name -> String in
                let url = Bundle.main.resourceURL?.appendingPathComponent(name + ".js")
                let source = (url != nil && FileManager.default.fileExists(atPath: url!.path)) ? url : Bundle.module.url(forResource: name, withExtension: "js", subdirectory: "Resources")
                guard let source else { throw CaptureError.invalid("Missing capture script: \(name)") }
                return try String(contentsOf: source, encoding: .utf8)
            }.joined(separator: "\n")
            controller.addUserScript(WKUserScript(source: scripts, injectionTime: .atDocumentStart, forMainFrameOnly: true))
        } catch { browser.captureProblem(error.localizedDescription) }
        web.navigationDelegate = self; web.uiDelegate = self
        web.allowsBackForwardNavigationGestures = true
        web.allowsMagnification = true
        for keyPath in [\WKWebView.canGoBack, \WKWebView.canGoForward] {
            observations.append(web.observe(keyPath, options: [.new]) { [weak self] _, _ in
                self?.browser.objectWillChange.send()
            })
        }
        observations.append(web.observe(\.title, options: [.new]) { [weak self] view, _ in
            guard let self else { return }
            self.title = view.title?.isEmpty == false ? view.title! : (view.url?.host ?? "New tab")
            self.browser.objectWillChange.send()
        })
        observations.append(web.observe(\.url, options: [.new]) { [weak self] _, _ in
            guard let self else { return }
            if self.browser.selected == self.id { self.browser.syncAddress() }
            self.browser.saveTabs()
        })
    }

    func dispose() {
        web.stopLoading(); web.navigationDelegate = nil; web.uiDelegate = nil
        web.configuration.userContentController.removeScriptMessageHandler(forName: "ghost")
        observations.removeAll()
    }

    func context(for url: String, kind: String) -> NavigationContext {
        if let intent = linkIntent, Date().timeIntervalSince(intent.date) < 10, intent.url == url { return intent.context }
        return NavigationContext(source: web.url?.absoluteString, sourceEvent: lastEventID, kind: kind)
    }

    func arrival(_ url: String, context: NavigationContext) {
        guard !browser.paused, let safe = Recorder.webURL(url) else { return }
        var fields = context.extra
        fields["url"] = safe
        fields["source_url"] = Recorder.webURL(context.source)
        fields["source_event_id"] = context.sourceEvent
        fields["navigation_kind"] = context.kind
        fields["tab_id"] = id.uuidString.lowercased()
        // Native top-level navigation and injected SPA navigation use the same event shape.
        if Recorder.isX(URL(string: safe)) {
            let parts = URL(string: safe)!.pathComponents.filter { $0 != "/" }
            if parts.count == 2 && parts[1] == "following" { fields["context"] = "x.following_list"; fields["list_owner"] = parts[0] }
            if let source = context.source, Recorder.isX(URL(string: source)), let sourceURL = URL(string: source) {
                let from = sourceURL.pathComponents.filter { $0 != "/" }
                let reserved: Set<String> = ["home", "explore", "notifications", "messages", "i", "settings", "compose", "search", "login", "logout", "tos", "privacy"]
                if from.count == 2 && from[1] == "following" && parts.count == 1 && !reserved.contains(parts[0].lowercased()) {
                    fields["context"] = "x.profile_from_following_list"; fields["list_owner"] = from[0]; fields["account_handle"] = parts[0]
                }
            }
        }
        let event = CaptureEvent(type: "url.opened", fields: fields)
        browser.emit(event); lastEventID = event.id.uuidString.lowercased()
    }

    func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard let url = action.request.url else { decisionHandler(.cancel); return }
        if !["http", "https", "about"].contains(url.scheme?.lowercased() ?? "") { decisionHandler(.cancel); return }
        if action.targetFrame == nil { decisionHandler(.allow); return }
        guard action.targetFrame?.isMainFrame == true else { decisionHandler(.allow); return }
        if action.navigationType == .linkActivated && action.modifierFlags.contains(.command) {
            let previous = browser.selected
            browser.add(url: url, context: context(for: url.absoluteString, kind: "link"))
            if !action.modifierFlags.contains(.shift) { browser.selected = previous }
            decisionHandler(.cancel); return
        }
        // WebKit can report the child tab's initial load as linkActivated too.
        // Keep the opener's context instead of replacing it with the empty child page.
        if action.navigationType == .linkActivated { pending = browser.paused ? nil : (pending ?? context(for: url.absoluteString, kind: "link")) }
        if action.navigationType == .backForward { pending = browser.paused ? nil : context(for: url.absoluteString, kind: "back_forward") }
        if action.navigationType == .reload { pending = nil }
        decisionHandler(.allow)
    }
    func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
        if let pending, let url = webView.url?.absoluteString { arrival(url, context: pending) }
        pending = nil; linkIntent = nil
        if browser.paused { webView.evaluateJavaScript("window.__ghostSetPaused?.(true)", completionHandler: nil) }
    }
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        browser.saveTabs()
        guard Recorder.webURL(webView.url?.absoluteString) != nil else { return }
        webView.evaluateJavaScript("!!window.__ghostInstalled && !!window.GhostXAdapter") { [weak self] result, error in
            guard let self else { return }
            if error != nil || result as? Bool != true { self.browser.captureProblem("The capture observer did not start on this page. Reload the tab before relying on capture.") }
        }
    }
    func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { pending = nil }
    func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
        browser.captureProblem("The web page process stopped. Reload the tab to resume capture.")
    }
    func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
        guard let url = action.request.url, Recorder.webURL(url.absoluteString) != nil else { return nil }
        let original = browser.selected
        let child = browser.add(url: url, context: context(for: url.absoluteString, kind: "new_tab_link"), configuration: configuration, load: false)
        if action.modifierFlags.contains(.command) && !action.modifierFlags.contains(.shift) { browser.selected = original }
        return child.web
    }
    func webViewDidClose(_ webView: WKWebView) { browser.close(id) }
    func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
        let alert = NSAlert(); alert.messageText = frame.securityOrigin.host; alert.informativeText = String(message.prefix(2000)); alert.runModal(); completionHandler()
    }
    func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
        let alert = NSAlert(); alert.messageText = frame.securityOrigin.host; alert.informativeText = String(message.prefix(2000)); alert.addButton(withTitle: "OK"); alert.addButton(withTitle: "Cancel"); completionHandler(alert.runModal() == .alertFirstButtonReturn)
    }

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if browser.isTesting {
            let kind = (message.body as? [String: Any])?["kind"] ?? "unknown"
            let frame = message.frameInfo.request.url?.absoluteString ?? "nil"
            print("fixture message: \(kind) frame=\(frame) origin=\(message.frameInfo.securityOrigin.host)")
        }
        guard message.frameInfo.isMainFrame, let data = message.body as? [String: Any], let kind = data["kind"] as? String,
              let size = try? JSONSerialization.data(withJSONObject: data).count, size <= 262144 else { return }
        if kind == "ready" {
            if browser.paused { web.evaluateJavaScript("window.__ghostSetPaused?.(true)", completionHandler: nil) }
            return
        }
        guard !browser.paused else { return }
        let frameURL = message.frameInfo.request.url
        if kind == "capture_problem" {
            guard Recorder.isX(frameURL) else { return }
            browser.captureProblem(String((data["message"] as? String ?? "X capture needs attention.").prefix(250))); return
        }
        if kind == "link", let url = data["url"] as? String, Recorder.webURL(url) != nil {
            let context = NavigationContext(source: Recorder.webURL(data["source_url"] as? String), sourceEvent: lastEventID, kind: "link")
            linkIntent = (url, context, Date()); return
        }
        if kind == "navigation", let url = data["url"] as? String {
            guard let destination = URL(string: url), destination.host == frameURL?.host else { return }
            let context = NavigationContext(source: Recorder.webURL(data["source_url"] as? String), sourceEvent: lastEventID, kind: data["navigation_kind"] as? String ?? "site_navigation")
            arrival(url, context: context); pending = nil; linkIntent = nil; return
        }
        guard kind == "event", Recorder.isX(frameURL), let type = data["type"] as? String,
              ["post.liked", "account.followed", "post.published", "reply.published"].contains(type),
              var fields = data["fields"] as? [String: Any],
              fields["platform"] as? String == "x", fields["verification"] as? String == "successful_site_response",
              let url = Recorder.webURL(fields["url"] as? String), Recorder.isX(URL(string: url)) else { return }
        fields["url"] = url; fields["source_url"] = Recorder.webURL(fields["source_url"] as? String)
        fields["source_event_id"] = lastEventID; fields["tab_id"] = id.uuidString.lowercased()
        browser.emit(CaptureEvent(type: type, fields: fields, text: data["text"] as? String))
    }
}

struct WebSurface: NSViewRepresentable {
    let web: WKWebView
    func makeNSView(context: Context) -> WKWebView { web }
    func updateNSView(_ view: WKWebView, context: Context) {}
}

struct AddressField: NSViewRepresentable {
    @Binding var text: String
    @Binding var editing: Bool
    var focusRequest: Int
    var tabID: UUID?
    var submit: () -> Void
    var cancel: () -> Void
    final class Coordinator: NSObject, NSTextFieldDelegate {
        var parent: AddressField
        var lastFocusRequest = 0
        var lastTabID: UUID?
        init(_ parent: AddressField) { self.parent = parent }
        func controlTextDidBeginEditing(_ notification: Notification) { parent.editing = true }
        func controlTextDidEndEditing(_ notification: Notification) { parent.editing = false }
        func controlTextDidChange(_ notification: Notification) {
            if let field = notification.object as? NSTextField { parent.text = field.stringValue }
        }
        func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
            if commandSelector == #selector(NSResponder.insertNewline(_:)) { parent.submit(); return true }
            if commandSelector == #selector(NSResponder.cancelOperation(_:)) { parent.cancel(); return true }
            return false
        }
    }
    func makeCoordinator() -> Coordinator { Coordinator(self) }
    func makeNSView(context: Context) -> NSTextField {
        let field = NSTextField(string: text)
        field.delegate = context.coordinator; field.isBezeled = false; field.drawsBackground = false
        field.font = .systemFont(ofSize: 13); field.placeholderString = "Search or enter website address"
        field.setAccessibilityLabel("Website address")
        field.setAccessibilityIdentifier("address-field")
        field.lineBreakMode = .byTruncatingTail
        return field
    }
    func updateNSView(_ field: NSTextField, context: Context) {
        let coordinator = context.coordinator
        coordinator.parent = self
        if coordinator.lastTabID != tabID {
            coordinator.lastTabID = tabID
            if field.currentEditor() != nil { field.window?.makeFirstResponder(nil) }
        }
        if field.stringValue != text { field.stringValue = text }
        if coordinator.lastFocusRequest != focusRequest {
            coordinator.lastFocusRequest = focusRequest
            DispatchQueue.main.async {
                guard coordinator.lastFocusRequest == self.focusRequest else { return }
                field.window?.makeFirstResponder(field)
                field.selectText(nil)
            }
        }
    }
}

struct BrowserView: View {
    @ObservedObject var browser: Browser
    var body: some View {
        VStack(spacing: 0) {
            HStack(spacing: 4) {
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: 3) {
                        ForEach(browser.tabs) { tab in
                            ZStack(alignment: .trailing) {
                                Button {
                                    browser.selected = tab.id
                                } label: {
                                    Text(tab.title)
                                        .font(.system(size: 12, weight: browser.selected == tab.id ? .medium : .regular))
                                        .lineLimit(1)
                                        .frame(maxWidth: .infinity, alignment: .leading)
                                        .padding(.leading, 11).padding(.trailing, 34)
                                        .frame(width: 185, height: 32)
                                }
                                .buttonStyle(ChromeButtonStyle(selected: browser.selected == tab.id))
                                .help(tab.title)
                                .accessibilityLabel("Tab: " + tab.title)
                                .accessibilityAddTraits(browser.selected == tab.id ? .isSelected : [])

                                Button { browser.close(tab.id) } label: {
                                    Image(systemName: "xmark").font(.system(size: 9, weight: .medium))
                                        .frame(width: 24, height: 24)
                                }
                                .buttonStyle(ChromeButtonStyle(cornerRadius: 5))
                                .help("Close tab (⌘W)")
                                .accessibilityLabel("Close " + tab.title)
                                .padding(.trailing, 4)
                            }
                            .accessibilityElement(children: .contain).accessibilityLabel("Tab: " + tab.title)
                        }
                    }
                }
                Button { browser.newTab() } label: {
                    Image(systemName: "plus").font(.system(size: 13, weight: .medium))
                        .frame(width: 28, height: 28)
                }
                .buttonStyle(ChromeButtonStyle()).help("New tab (⌘T)").accessibilityLabel("New tab")
                Button { browser.toggleCapture() } label: {
                    Circle().fill(browser.problem != nil ? Color.orange : browser.paused ? Color.gray : Color.red)
                        .frame(width: 7, height: 7).frame(width: 28, height: 28)
                }.buttonStyle(ChromeButtonStyle()).help(browser.problem ?? (browser.paused ? "Capture paused — click to resume" : "Recording directly to Ghost — click to pause")).accessibilityLabel(browser.problem != nil ? "Capture problem" : browser.paused ? "Resume capture" : "Pause capture")
            }
            .padding(.horizontal, 7).frame(height: 42).background(Color(nsColor: .windowBackgroundColor))
            Divider()
            HStack(spacing: 12) {
                HStack(spacing: 4) {
                    Button { browser.goBack() } label: {
                        Image(systemName: "chevron.left")
                            .font(.system(size: 16, weight: .medium))
                            .frame(width: 30, height: 30)
                    }
                    .disabled(browser.active?.web.canGoBack != true)
                    .help("Back (⌘[)").accessibilityLabel("Back")
                    Button { browser.goForward() } label: {
                        Image(systemName: "chevron.right")
                            .font(.system(size: 16, weight: .medium))
                            .frame(width: 30, height: 30)
                    }
                    .disabled(browser.active?.web.canGoForward != true)
                    .help("Forward (⌘])").accessibilityLabel("Forward")
                }
                .buttonStyle(ChromeButtonStyle())
                Spacer(minLength: 0)
                AddressField(text: $browser.address, editing: $browser.addressEditing,
                             focusRequest: browser.addressFocusRequest, tabID: browser.selected,
                             submit: { browser.navigate() }, cancel: { browser.cancelAddress() })
                    .frame(height: 20)
                    .padding(.horizontal, 12).padding(.vertical, 6)
                    .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
                    .overlay(RoundedRectangle(cornerRadius: 8)
                        .strokeBorder(browser.addressEditing ? Color.accentColor : Color.primary.opacity(0.15),
                                      lineWidth: browser.addressEditing ? 2 : 1))
                    .frame(maxWidth: 820)
                Spacer(minLength: 0)
                Color.clear.frame(width: 64, height: 30).accessibilityHidden(true)
            }
            .frame(maxWidth: .infinity)
            .padding(.horizontal, 12).padding(.vertical, 8)
            .background(Color(nsColor: .windowBackgroundColor))
            Divider()
            if let tab = browser.active { WebSurface(web: tab.web).id(tab.id) }
        }
        .frame(minWidth: 600, minHeight: 400)
    }
}