Files
openclaw/apps/macos/Sources/OpenClaw/DashboardLinkBrowserView.swift
2026-07-12 02:41:17 -07:00

666 lines
24 KiB
Swift

import AppKit
import Foundation
import WebKit
@MainActor
private final class DashboardLinkBrowserTab {
/// WebKit preserves one WKNavigation identity across a redirect chain.
/// A different identity means the opened-link alias is no longer current.
private enum RequestAliasPhase {
case awaitingNavigation
case loading(AnyObject)
case retained
case retired
}
let id = UUID()
let webView: WKWebView
let requestedURL: URL
var representedURL: URL?
var title: String?
var observations: [NSKeyValueObservation] = []
private var requestAliasPhase: RequestAliasPhase = .awaitingNavigation
init(webView: WKWebView, requestedURL: URL) {
self.webView = webView
self.requestedURL = requestedURL
self.representedURL = requestedURL
}
var requestedURLAlias: URL? {
if case .retired = self.requestAliasPhase {
nil
} else {
self.requestedURL
}
}
func startNavigation(_ navigation: AnyObject) {
switch self.requestAliasPhase {
case .awaitingNavigation:
self.requestAliasPhase = .loading(navigation)
case let .loading(initial) where initial !== navigation:
self.requestAliasPhase = .retired
case .retained:
self.requestAliasPhase = .retired
case .loading, .retired:
break
}
}
func updateRepresentedURL(_ url: URL?) {
// Initial redirects keep the opened link reusable. Once that chain
// finishes, a distinct navigation retires the now-stale alias.
if case .retained = self.requestAliasPhase, let url, url != self.representedURL {
self.requestAliasPhase = .retired
}
self.representedURL = url
}
func finishNavigation(_ navigation: AnyObject?, at url: URL?, title: String?) {
self.updateRepresentedURL(url)
self.title = title
guard let navigation else { return }
switch self.requestAliasPhase {
case .awaitingNavigation:
self.requestAliasPhase = .retained
case let .loading(initial) where initial === navigation:
self.requestAliasPhase = .retained
case .loading:
self.requestAliasPhase = .retired
case .retained, .retired:
break
}
}
func failNavigation() {
// A failed initial chain has no reusable page. Retire its alias so
// opening the original link again starts a fresh load.
self.requestAliasPhase = .retired
}
}
@MainActor
final class DashboardLinkBrowserView: NSView {
var activeWebView: WKWebView? {
self.activeTab?.webView
}
weak var webViewNavigationDelegate: WKNavigationDelegate? {
didSet {
self.tabs.forEach { $0.webView.navigationDelegate = self.webViewNavigationDelegate }
}
}
weak var webViewUIDelegate: WKUIDelegate? {
didSet {
self.tabs.forEach { $0.webView.uiDelegate = self.webViewUIDelegate }
}
}
var onClose: (() -> Void)?
var onOpenExternal: ((URL) -> Void)?
private let websiteDataStore: WKWebsiteDataStore
private var tabs: [DashboardLinkBrowserTab] = []
private var activeTabID: UUID?
private let toolbar = NSVisualEffectView()
private let tabBar = DashboardLinkBrowserTabBar()
private let backButton = DashboardLinkBrowserView.makeButton(symbol: "chevron.left", label: "Back")
private let forwardButton = DashboardLinkBrowserView.makeButton(symbol: "chevron.right", label: "Forward")
private let reloadButton = DashboardLinkBrowserView.makeButton(symbol: "arrow.clockwise", label: "Reload")
private let externalButton = DashboardLinkBrowserView.makeButton(
symbol: "arrow.up.right.square",
label: "Open in Default Browser")
private let closeButton = DashboardLinkBrowserView.makeButton(symbol: "xmark", label: "Close Sidebar")
private let addressLabel: NSTextField = {
let label = NSTextField(labelWithString: "")
label.font = .systemFont(ofSize: 12, weight: .medium)
label.textColor = .secondaryLabelColor
label.lineBreakMode = .byTruncatingMiddle
label.setContentHuggingPriority(.defaultLow, for: .horizontal)
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return label
}()
private var activeTab: DashboardLinkBrowserTab? {
guard let activeTabID else { return nil }
return self.tabs.first { $0.id == activeTabID }
}
init(websiteDataStore: WKWebsiteDataStore) {
self.websiteDataStore = websiteDataStore
super.init(frame: .zero)
self.configureActions()
self.buildView()
self.updateChrome()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) is not supported")
}
func owns(_ webView: WKWebView) -> Bool {
self.tabs.contains { $0.webView === webView }
}
func open(_ url: URL) {
// Keep the original request as a stable dedupe key across redirects while
// also reusing a tab that has since navigated to the requested target.
let tab = self.tabs.first(where: { $0.representedURL == url }) ??
self.tabs.first(where: { $0.requestedURLAlias == url })
if let tab {
self.activateTab(id: tab.id)
return
}
self.openInNewTab(url)
}
func openInNewTab(_ url: URL) {
let tab = self.makeTab(requestedURL: url)
self.tabs.append(tab)
self.tabBar.appendTab(id: tab.id, title: self.displayTitle(for: tab), toolTip: url.absoluteString)
self.activateTab(id: tab.id)
let webView = tab.webView
// Let the installed tab become observable before navigation starts.
// An immediate close then cancels the pending load without retaining it.
Task { @MainActor [weak self, weak webView] in
guard let self, let webView, self.owns(webView) else { return }
webView.load(URLRequest(url: url))
}
}
func closeTab(id: UUID) {
guard let index = self.tabs.firstIndex(where: { $0.id == id }) else { return }
let wasActive = self.activeTabID == id
let tab = self.tabs.remove(at: index)
self.dispose(tab)
self.tabBar.removeTab(id: id)
guard !self.tabs.isEmpty else {
self.activeTabID = nil
self.tabBar.setActiveTab(id: nil)
self.updateChrome()
self.onClose?()
return
}
if wasActive {
let nextIndex = min(index, self.tabs.count - 1)
self.activateTab(id: self.tabs[nextIndex].id)
}
}
func closeOtherTabs(keeping id: UUID) {
guard let keptTab = self.tabs.first(where: { $0.id == id }) else { return }
let removedTabs = self.tabs.filter { $0.id != id }
for tab in removedTabs {
self.dispose(tab)
self.tabBar.removeTab(id: tab.id)
}
self.tabs = [keptTab]
self.activateTab(id: id)
}
func moveTab(fromIndex: Int, toIndex: Int) {
guard self.tabs.indices.contains(fromIndex), toIndex >= 0, toIndex < self.tabs.count else { return }
let tab = self.tabs.remove(at: fromIndex)
self.tabs.insert(tab, at: toIndex)
self.tabBar.moveTab(id: tab.id, toIndex: toIndex)
}
func closeBrowser() {
let tabs = self.tabs
self.tabs.removeAll()
self.activeTabID = nil
for tab in tabs {
self.dispose(tab)
self.tabBar.removeTab(id: tab.id)
}
self.tabBar.setActiveTab(id: nil)
self.updateChrome()
}
func updateChrome() {
let tab = self.activeTab
let url = tab?.representedURL
self.addressLabel.stringValue = url?.host(percentEncoded: false) ?? url?.absoluteString ?? ""
self.addressLabel.toolTip = url?.absoluteString
self.backButton.isEnabled = tab?.webView.canGoBack == true
self.forwardButton.isEnabled = tab?.webView.canGoForward == true
self.reloadButton.isEnabled = url != nil
self.externalButton.isEnabled = url.flatMap(Self.httpURL) != nil
}
func navigationWillStart(_ url: URL, in webView: WKWebView) {
guard let tab = self.tab(owning: webView) else { return }
tab.updateRepresentedURL(url)
self.refreshTab(tab)
}
func navigationDidStart(_ navigation: WKNavigation?, in webView: WKWebView) {
guard let navigation, let tab = self.tab(owning: webView) else { return }
tab.startNavigation(navigation)
}
func navigationDidFinish(_ navigation: WKNavigation?, for webView: WKWebView) {
self.finishNavigation(navigation, at: webView.url, title: webView.title, in: webView)
}
func navigationDidFail(for webView: WKWebView) {
guard let tab = self.tab(owning: webView) else { return }
tab.failNavigation()
self.updateChrome()
}
func navigationURLDidChange(for webView: WKWebView) {
guard let tab = self.tab(owning: webView) else { return }
tab.updateRepresentedURL(webView.url)
self.refreshTab(tab)
}
private func finishNavigation(
_ navigation: AnyObject?,
at url: URL?,
title: String?,
in webView: WKWebView)
{
guard let tab = self.tab(owning: webView) else { return }
tab.finishNavigation(navigation, at: url, title: title)
self.refreshTab(tab)
}
func contextMenu(forTabAt index: Int) -> NSMenu? {
guard self.tabs.indices.contains(index) else { return nil }
return self.contextMenu(for: self.tabs[index])
}
private static func makeWebView(websiteDataStore: WKWebsiteDataStore) -> WKWebView {
// Every tab shares persisted browser sessions, but never inherits the
// dashboard's auth scripts, user scripts, or privileged message handler.
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = websiteDataStore
configuration.preferences.isElementFullscreenEnabled = true
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.setValue(true, forKey: "drawsBackground")
return webView
}
private func makeTab(requestedURL: URL) -> DashboardLinkBrowserTab {
let webView = Self.makeWebView(websiteDataStore: self.websiteDataStore)
webView.navigationDelegate = self.webViewNavigationDelegate
webView.uiDelegate = self.webViewUIDelegate
let tab = DashboardLinkBrowserTab(webView: webView, requestedURL: requestedURL)
self.installWebView(webView)
self.observeNavigationState(for: tab)
return tab
}
private func dispose(_ tab: DashboardLinkBrowserTab) {
tab.observations.forEach { $0.invalidate() }
tab.observations.removeAll()
tab.webView.navigationDelegate = nil
tab.webView.uiDelegate = nil
tab.webView.stopLoading()
tab.webView.removeFromSuperview()
}
private func tab(owning webView: WKWebView) -> DashboardLinkBrowserTab? {
self.tabs.first { $0.webView === webView }
}
private func activateTab(id: UUID) {
guard self.tabs.contains(where: { $0.id == id }) else { return }
self.activeTabID = id
for tab in self.tabs {
tab.webView.isHidden = tab.id != id
}
self.tabBar.setActiveTab(id: id)
self.updateChrome()
if self.window != nil {
self.window?.makeFirstResponder(self.activeWebView)
}
}
private func refreshTab(_ tab: DashboardLinkBrowserTab) {
self.tabBar.updateTab(
id: tab.id,
title: self.displayTitle(for: tab),
toolTip: tab.representedURL?.absoluteString)
if tab.id == self.activeTabID {
self.updateChrome()
}
}
private func displayTitle(for tab: DashboardLinkBrowserTab) -> String {
if let title = tab.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
return title
}
return tab.representedURL?.host(percentEncoded: false) ?? "New Tab"
}
private func configureActions() {
self.backButton.target = self
self.backButton.action = #selector(self.goBack)
self.forwardButton.target = self
self.forwardButton.action = #selector(self.goForward)
self.reloadButton.target = self
self.reloadButton.action = #selector(self.reload)
self.externalButton.target = self
self.externalButton.action = #selector(self.openExternal)
self.closeButton.target = self
self.closeButton.action = #selector(self.close)
self.tabBar.delegate = self
}
private func observeNavigationState(for tab: DashboardLinkBrowserTab) {
let webView = tab.webView
// WebKit updates these properties after some navigation delegate callbacks.
// KVO also catches same-document SPA URL changes that skip didFinish.
tab.observations = [
webView.observe(\.canGoBack, options: [.new]) { [weak self, weak tab] _, _ in
Task { @MainActor in
guard let self, let tab else { return }
self.refreshTab(tab)
}
},
webView.observe(\.canGoForward, options: [.new]) { [weak self, weak tab] _, _ in
Task { @MainActor in
guard let self, let tab else { return }
self.refreshTab(tab)
}
},
webView.observe(\.url, options: [.new]) { [weak self, weak webView] _, _ in
Task { @MainActor in
guard let self, let webView else { return }
self.navigationURLDidChange(for: webView)
}
},
webView.observe(\.title, options: [.new]) { [weak self, weak tab, weak webView] _, _ in
Task { @MainActor in
guard let self, let tab, let webView else { return }
tab.title = webView.title
self.refreshTab(tab)
}
},
]
}
private func buildView() {
self.toolbar.material = .headerView
self.toolbar.blendingMode = .withinWindow
self.toolbar.state = .active
self.toolbar.translatesAutoresizingMaskIntoConstraints = false
addSubview(self.toolbar)
let controls = NSStackView(views: [
backButton,
forwardButton,
reloadButton,
addressLabel,
externalButton,
closeButton,
])
controls.orientation = .horizontal
controls.alignment = .centerY
controls.distribution = .fill
controls.spacing = 4
controls.setCustomSpacing(10, after: self.reloadButton)
controls.setCustomSpacing(10, after: self.addressLabel)
controls.translatesAutoresizingMaskIntoConstraints = false
self.toolbar.addSubview(controls)
self.tabBar.translatesAutoresizingMaskIntoConstraints = false
self.toolbar.addSubview(self.tabBar)
let separator = NSBox()
separator.boxType = .separator
separator.translatesAutoresizingMaskIntoConstraints = false
self.toolbar.addSubview(separator)
NSLayoutConstraint.activate([
self.toolbar.leadingAnchor.constraint(equalTo: leadingAnchor),
self.toolbar.trailingAnchor.constraint(equalTo: trailingAnchor),
self.toolbar.topAnchor.constraint(equalTo: topAnchor),
self.toolbar.heightAnchor.constraint(equalToConstant: 78),
// Browser-style header: tabs on top, navigation controls below.
// 12pt top inset clears the window's 12pt drag overlay (it reaches
// into wide sidebars), keeping every tab pixel clickable.
self.tabBar.leadingAnchor.constraint(equalTo: self.toolbar.leadingAnchor),
self.tabBar.trailingAnchor.constraint(equalTo: self.toolbar.trailingAnchor),
self.tabBar.topAnchor.constraint(equalTo: self.toolbar.topAnchor, constant: 12),
self.tabBar.heightAnchor.constraint(equalToConstant: DashboardWindowLayout.linkBrowserTabBarHeight),
controls.leadingAnchor.constraint(equalTo: self.toolbar.leadingAnchor, constant: 10),
controls.trailingAnchor.constraint(equalTo: self.toolbar.trailingAnchor, constant: -10),
controls.bottomAnchor.constraint(equalTo: self.toolbar.bottomAnchor, constant: -4),
controls.heightAnchor.constraint(equalToConstant: 28),
separator.leadingAnchor.constraint(equalTo: self.toolbar.leadingAnchor),
separator.trailingAnchor.constraint(equalTo: self.toolbar.trailingAnchor),
separator.bottomAnchor.constraint(equalTo: self.toolbar.bottomAnchor),
])
}
private func installWebView(_ webView: WKWebView) {
webView.translatesAutoresizingMaskIntoConstraints = false
webView.isHidden = true
addSubview(webView)
NSLayoutConstraint.activate([
webView.leadingAnchor.constraint(equalTo: leadingAnchor),
webView.trailingAnchor.constraint(equalTo: trailingAnchor),
webView.topAnchor.constraint(equalTo: self.toolbar.bottomAnchor),
webView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
private func contextMenu(for tab: DashboardLinkBrowserTab) -> NSMenu {
let menu = NSMenu()
menu.autoenablesItems = false
let externalURL = tab.representedURL.flatMap(Self.httpURL)
menu.addItem(self.menuItem(
title: "Open in Default Browser",
action: #selector(self.openTabExternal(_:)),
tabID: tab.id,
isEnabled: externalURL != nil))
menu.addItem(self.menuItem(
title: "Copy Link",
action: #selector(self.copyTabLink(_:)),
tabID: tab.id,
isEnabled: tab.representedURL != nil))
menu.addItem(self.menuItem(
title: "Reload",
action: #selector(self.reloadTab(_:)),
tabID: tab.id,
isEnabled: tab.representedURL != nil))
menu.addItem(.separator())
menu.addItem(self.menuItem(
title: "Close Tab",
action: #selector(self.closeTabFromMenu(_:)),
tabID: tab.id,
isEnabled: true))
menu.addItem(self.menuItem(
title: "Close Other Tabs",
action: #selector(self.closeOtherTabsFromMenu(_:)),
tabID: tab.id,
isEnabled: self.tabs.count > 1))
return menu
}
private func menuItem(
title: String,
action: Selector,
tabID: UUID,
isEnabled: Bool) -> NSMenuItem
{
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.target = self
item.representedObject = tabID as NSUUID
item.isEnabled = isEnabled
return item
}
private func tab(for menuItem: NSMenuItem) -> DashboardLinkBrowserTab? {
guard let id = menuItem.representedObject as? UUID else { return nil }
return self.tabs.first { $0.id == id }
}
private static func makeButton(symbol: String, label: String) -> NSButton {
let configuration = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium)
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: label)?
.withSymbolConfiguration(configuration) ?? NSImage(size: NSSize(width: 16, height: 16))
let button = NSButton(image: image, target: nil, action: nil)
button.isBordered = false
button.bezelStyle = .regularSquare
button.imageScaling = .scaleProportionallyDown
button.toolTip = label
button.setAccessibilityLabel(label)
button.widthAnchor.constraint(equalToConstant: 26).isActive = true
button.heightAnchor.constraint(equalToConstant: 26).isActive = true
return button
}
private static func httpURL(_ url: URL) -> URL? {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
return nil
}
return url
}
@objc private func goBack() {
self.activeWebView?.goBack()
}
@objc private func goForward() {
self.activeWebView?.goForward()
}
@objc private func reload() {
self.activeWebView?.reload()
}
@objc private func openExternal() {
guard let url = self.activeTab?.representedURL.flatMap(Self.httpURL) else { return }
self.onOpenExternal?(url)
}
@objc private func close() {
self.onClose?()
}
@objc private func openTabExternal(_ sender: NSMenuItem) {
guard let url = self.tab(for: sender)?.representedURL.flatMap(Self.httpURL) else { return }
self.onOpenExternal?(url)
}
@objc private func copyTabLink(_ sender: NSMenuItem) {
guard let url = self.tab(for: sender)?.representedURL else { return }
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.writeObjects([url as NSURL])
pasteboard.setString(url.absoluteString, forType: .string)
}
@objc private func reloadTab(_ sender: NSMenuItem) {
self.tab(for: sender)?.webView.reload()
}
@objc private func closeTabFromMenu(_ sender: NSMenuItem) {
guard let id = (sender.representedObject as? UUID) else { return }
self.closeTab(id: id)
}
@objc private func closeOtherTabsFromMenu(_ sender: NSMenuItem) {
guard let id = (sender.representedObject as? UUID) else { return }
self.closeOtherTabs(keeping: id)
}
}
extension DashboardLinkBrowserView: DashboardLinkBrowserTabBarDelegate {
func tabBar(_: DashboardLinkBrowserTabBar, didSelectTab id: UUID) {
self.activateTab(id: id)
}
func tabBar(_: DashboardLinkBrowserTabBar, didCloseTab id: UUID) {
self.closeTab(id: id)
}
func tabBar(_: DashboardLinkBrowserTabBar, didMoveTab id: UUID, toIndex: Int) {
guard let fromIndex = self.tabs.firstIndex(where: { $0.id == id }) else { return }
self.moveTab(fromIndex: fromIndex, toIndex: toIndex)
}
func tabBar(_: DashboardLinkBrowserTabBar, contextMenuForTab id: UUID) -> NSMenu? {
guard let tab = self.tabs.first(where: { $0.id == id }) else { return nil }
return self.contextMenu(for: tab)
}
}
#if DEBUG
extension DashboardLinkBrowserView {
var _testTabCount: Int {
self.tabs.count
}
var _testTabURLs: [URL?] {
self.tabs.map(\.representedURL)
}
var _testActiveTabIndex: Int? {
guard let activeTabID else { return nil }
return self.tabs.firstIndex { $0.id == activeTabID }
}
var _testActiveWebView: WKWebView? {
self.activeWebView
}
var _testRepresentedURL: URL? {
self.activeTab?.representedURL
}
var _testNavigationObservationCount: Int {
self.activeTab?.observations.count ?? 0
}
var _testWebsiteDataStore: WKWebsiteDataStore {
self.websiteDataStore
}
var _testAllWebViews: [WKWebView] {
self.tabs.map(\.webView)
}
func _testSelectTab(at index: Int) {
guard self.tabs.indices.contains(index) else { return }
self.activateTab(id: self.tabs[index].id)
}
func _testCloseTab(at index: Int) {
guard self.tabs.indices.contains(index) else { return }
self.closeTab(id: self.tabs[index].id)
}
func _testMoveTab(from fromIndex: Int, to toIndex: Int) {
self.moveTab(fromIndex: fromIndex, toIndex: toIndex)
}
func _testOpenInNewTab(_ url: URL) {
self.openInNewTab(url)
}
func _testStartNavigation(_ navigation: AnyObject, in webView: WKWebView) {
guard let tab = self.tab(owning: webView) else { return }
tab.startNavigation(navigation)
}
func _testFinishNavigation(_ navigation: AnyObject, at url: URL?, in webView: WKWebView) {
self.finishNavigation(navigation, at: url, title: webView.title, in: webView)
}
func _testContextMenu(forTabAt index: Int) -> NSMenu? {
self.contextMenu(forTabAt: index)
}
}
#endif