diff --git a/apps/macos/Sources/OpenClaw/DashboardLinkBrowserView.swift b/apps/macos/Sources/OpenClaw/DashboardLinkBrowserView.swift new file mode 100644 index 00000000000..7904e7e6888 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/DashboardLinkBrowserView.swift @@ -0,0 +1,262 @@ +import AppKit +import Foundation +import WebKit + +@MainActor +final class DashboardLinkBrowserView: NSView { + private(set) var webView: WKWebView + var onClose: (() -> Void)? + var onOpenExternal: ((URL) -> Void)? + + private let websiteDataStore: WKWebsiteDataStore + private let toolbar = NSVisualEffectView() + 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 var navigationObservations: [NSKeyValueObservation] = [] + private var webViewConstraints: [NSLayoutConstraint] = [] + private var representedURL: URL? + 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 + }() + + init(websiteDataStore: WKWebsiteDataStore) { + self.websiteDataStore = websiteDataStore + self.webView = Self.makeWebView(websiteDataStore: websiteDataStore) + super.init(frame: .zero) + + self.configureActions() + self.buildView() + self.observeNavigationState() + self.updateChrome() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + func open(_ url: URL) { + self.navigationWillStart(url) + self.webView.load(URLRequest(url: url)) + } + + func closeBrowser() { + self.representedURL = nil + self.replaceWebView() + self.updateChrome() + } + + func updateChrome() { + let url = self.representedURL + self.addressLabel.stringValue = url?.host(percentEncoded: false) ?? url?.absoluteString ?? "" + self.addressLabel.toolTip = url?.absoluteString + self.backButton.isEnabled = self.webView.canGoBack + self.forwardButton.isEnabled = self.webView.canGoForward + self.reloadButton.isEnabled = url != nil + self.externalButton.isEnabled = url.flatMap(Self.httpURL) != nil + } + + func navigationWillStart(_ url: URL) { + self.representedURL = url + self.updateChrome() + } + + func navigationDidFinish() { + self.representedURL = self.webView.url + self.updateChrome() + } + + private static func makeWebView(websiteDataStore: WKWebsiteDataStore) -> WKWebView { + // External pages share persisted browser sessions, but never inherit the + // dashboard's auth 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 replaceWebView() { + let previousWebView = self.webView + let navigationDelegate = previousWebView.navigationDelegate + let uiDelegate = previousWebView.uiDelegate + let replacement = Self.makeWebView(websiteDataStore: self.websiteDataStore) + + self.navigationObservations.forEach { $0.invalidate() } + self.navigationObservations.removeAll() + NSLayoutConstraint.deactivate(self.webViewConstraints) + previousWebView.navigationDelegate = nil + previousWebView.uiDelegate = nil + previousWebView.stopLoading() + previousWebView.removeFromSuperview() + + self.webView = replacement + self.installWebView() + replacement.navigationDelegate = navigationDelegate + replacement.uiDelegate = uiDelegate + self.observeNavigationState() + } + + 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) + } + + private func observeNavigationState() { + // WebKit updates these properties after some navigation delegate callbacks. + // KVO also catches same-document SPA URL changes that skip didFinish. + self.navigationObservations = [ + self.webView.observe(\.canGoBack, options: [.new]) { [weak self] _, _ in + Task { @MainActor in + self?.updateChrome() + } + }, + self.webView.observe(\.canGoForward, options: [.new]) { [weak self] _, _ in + Task { @MainActor in + self?.updateChrome() + } + }, + self.webView.observe(\.url, options: [.new]) { [weak self] _, _ in + Task { @MainActor in + self?.navigationDidFinish() + } + }, + ] + } + + 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) + + let separator = NSBox() + separator.boxType = .separator + separator.translatesAutoresizingMaskIntoConstraints = false + self.toolbar.addSubview(separator) + self.installWebView() + + NSLayoutConstraint.activate([ + self.toolbar.leadingAnchor.constraint(equalTo: leadingAnchor), + self.toolbar.trailingAnchor.constraint(equalTo: trailingAnchor), + self.toolbar.topAnchor.constraint(equalTo: topAnchor), + // The top 32 points stay clear of the dashboard window's drag overlay. + self.toolbar.heightAnchor.constraint(equalToConstant: 68), + + 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: -8), + 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() { + self.webView.translatesAutoresizingMaskIntoConstraints = false + addSubview(self.webView) + self.webViewConstraints = [ + self.webView.leadingAnchor.constraint(equalTo: leadingAnchor), + self.webView.trailingAnchor.constraint(equalTo: trailingAnchor), + self.webView.topAnchor.constraint(equalTo: self.toolbar.bottomAnchor), + self.webView.bottomAnchor.constraint(equalTo: bottomAnchor), + ] + NSLayoutConstraint.activate(self.webViewConstraints) + } + + 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.webView.goBack() + } + + @objc private func goForward() { + self.webView.goForward() + } + + @objc private func reload() { + self.webView.reload() + } + + @objc private func openExternal() { + guard let url = representedURL.flatMap(Self.httpURL) else { return } + self.onOpenExternal?(url) + } + + @objc private func close() { + self.onClose?() + } +} + +#if DEBUG +extension DashboardLinkBrowserView { + var _testRepresentedURL: URL? { + self.representedURL + } + + var _testNavigationObservationCount: Int { + self.navigationObservations.count + } +} +#endif diff --git a/apps/macos/Sources/OpenClaw/DashboardWindow.swift b/apps/macos/Sources/OpenClaw/DashboardWindow.swift index 9f573cc6fe5..e1002236a57 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindow.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindow.swift @@ -7,6 +7,27 @@ let dashboardWindowLogger = Logger(subsystem: "ai.openclaw", category: "Dashboar enum DashboardWindowLayout { static let windowSize = NSSize(width: 1240, height: 860) static let windowMinSize = NSSize(width: 900, height: 620) + static let mainBrowserMinWidth: CGFloat = 520 + static let linkBrowserMinWidth: CGFloat = 320 + static let linkBrowserMaxWidth: CGFloat = 760 + static let linkBrowserPreferredFraction: CGFloat = 0.4 + static let linkBrowserSplitAutosaveName = "OpenClawDashboardLinkBrowserSplit" +} + +enum DashboardLinkTarget: String, Equatable { + case inline + case external +} + +enum DashboardTargetlessNavigationAction: Equatable { + case allow + case openExternal + case cancel +} + +struct DashboardLinkRequest: Equatable { + let url: URL + let target: DashboardLinkTarget } struct DashboardWindowAuth: Equatable { diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift index e3309a7245f..f4b8818c9c1 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -14,13 +14,27 @@ private final class DashboardWindowDragRegionView: NSView { } override func mouseDown(with event: NSEvent) { - self.window?.performDrag(with: event) + window?.performDrag(with: event) + } +} + +@MainActor +private final class DashboardLinkMessageHandler: NSObject, WKScriptMessageHandler { + weak var owner: DashboardWindowController? + + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + self.owner?.receiveLinkMessage(message) } } @MainActor final class DashboardWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate { + private static let linkMessageHandlerName = "openclawLink" + private let webView: WKWebView + private let linkBrowser: DashboardLinkBrowserView + private let linkBrowserItem: NSSplitViewItem + private let splitViewController: NSSplitViewController private(set) var currentURL: URL private var auth: DashboardWindowAuth private var backButton: NSButton? @@ -32,10 +46,15 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, self.currentURL = url self.auth = auth + let dataStore = WKWebsiteDataStore.default() let config = WKWebViewConfiguration() + config.websiteDataStore = dataStore config.preferences.isElementFullscreenEnabled = true + config.preferences.javaScriptCanOpenWindowsAutomatically = false config.preferences.setValue(true, forKey: "developerExtrasEnabled") config.userContentController = WKUserContentController() + let linkMessageHandler = DashboardLinkMessageHandler() + config.userContentController.add(linkMessageHandler, name: Self.linkMessageHandlerName) Self.installNativeChromeScript(into: config.userContentController) Self.installNativeAuthScript(into: config.userContentController, url: url, auth: auth) @@ -48,11 +67,49 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, // below) the dashboard window has no way back. self.webView.allowsBackForwardNavigationGestures = true - let window = Self.makeWindow(contentView: self.webView) + let linkBrowser = DashboardLinkBrowserView(websiteDataStore: dataStore) + let splitViewController = NSSplitViewController() + splitViewController.splitView.isVertical = true + splitViewController.splitView.dividerStyle = .thin + splitViewController.splitView.autosaveName = DashboardWindowLayout.linkBrowserSplitAutosaveName + + let dashboardViewController = NSViewController() + dashboardViewController.view = self.webView + let dashboardItem = NSSplitViewItem(viewController: dashboardViewController) + dashboardItem.minimumThickness = DashboardWindowLayout.mainBrowserMinWidth + + let linkBrowserViewController = NSViewController() + linkBrowserViewController.view = linkBrowser + let linkBrowserItem = NSSplitViewItem(viewController: linkBrowserViewController) + linkBrowserItem.minimumThickness = DashboardWindowLayout.linkBrowserMinWidth + linkBrowserItem.maximumThickness = DashboardWindowLayout.linkBrowserMaxWidth + linkBrowserItem.preferredThicknessFraction = DashboardWindowLayout.linkBrowserPreferredFraction + // Keep the sidebar width stable while staying below AppKit's divider-drag + // priority; the dashboard absorbs window resizing first. + linkBrowserItem.holdingPriority = NSLayoutConstraint.Priority(rawValue: 251) + linkBrowserItem.canCollapse = true + linkBrowserItem.isCollapsed = true + + splitViewController.addSplitViewItem(dashboardItem) + splitViewController.addSplitViewItem(linkBrowserItem) + + self.linkBrowser = linkBrowser + self.linkBrowserItem = linkBrowserItem + self.splitViewController = splitViewController + + let window = Self.makeWindow(contentView: splitViewController.view) super.init(window: window) + // Width is autosaved, while each new dashboard window starts with the + // optional browser collapsed until a link explicitly opens it. + self.linkBrowserItem.isCollapsed = true + linkMessageHandler.owner = self self.webView.navigationDelegate = self self.webView.uiDelegate = self + self.linkBrowser.webView.navigationDelegate = self + self.linkBrowser.webView.uiDelegate = self + self.linkBrowser.onClose = { [weak self] in self?.closeLinkBrowser() } + self.linkBrowser.onOpenExternal = { [weak self] url in self?.openExternal(url) } self.window?.delegate = self self.installNavigationControls() } @@ -65,15 +122,19 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, func webView( _ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, - initiatedByFrame frame: WKFrameInfo, + initiatedByFrame _: WKFrameInfo, completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void) { + guard webView === self.webView || webView === self.linkBrowser.webView else { + completionHandler(nil) + return + } let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = parameters.allowsDirectories panel.allowsMultipleSelection = parameters.allowsMultipleSelection panel.resolvesAliases = true - if let window = self.window { + if let window { panel.beginSheetModal(for: window) { response in completionHandler(response == .OK ? panel.urls : nil) } @@ -84,8 +145,27 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, } } + func webView( + _ webView: WKWebView, + createWebViewWith _: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures _: WKWindowFeatures) -> WKWebView? + { + // WebKit reaches this callback only for user-allowed new-window requests; + // both configurations disable automatic JavaScript windows. + guard webView === self.webView || webView === self.linkBrowser.webView, + navigationAction.targetFrame == nil, + let url = navigationAction.request.url, + Self.isHTTPURL(url) + else { + return nil + } + self.openExternal(url) + return nil + } + @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) is not supported") } @@ -122,15 +202,15 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, window.setFrame(WindowPlacement.centeredFrame(size: DashboardWindowLayout.windowSize), display: false) } } - self.showWindow(nil) - self.window?.makeKeyAndOrderFront(nil) - self.window?.makeFirstResponder(self.webView) - self.window?.orderFrontRegardless() + showWindow(nil) + window?.makeKeyAndOrderFront(nil) + window?.makeFirstResponder(self.webView) + window?.orderFrontRegardless() NSApp.activate(ignoringOtherApps: true) } func closeDashboard() { - self.window?.performClose(nil) + window?.performClose(nil) } func showFailure(title: String, message: String, detail: String? = nil) { @@ -149,6 +229,112 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, self.webView.load(URLRequest(url: url)) } + private func openLinkBrowser(_ url: URL) { + self.linkBrowserItem.isCollapsed = false + self.linkBrowser.open(url) + window?.makeFirstResponder(self.linkBrowser.webView) + } + + private func closeLinkBrowser(focusDashboard: Bool = true) { + self.linkBrowser.closeBrowser() + self.linkBrowserItem.isCollapsed = true + if focusDashboard { + window?.makeFirstResponder(self.webView) + } + } + + private func openExternal(_ url: URL) { + guard Self.isExternalURL(url) || Self.isEditorURL(url) else { return } + NSWorkspace.shared.open(url) + } + + fileprivate func receiveLinkMessage(_ message: WKScriptMessage) { + // The page-world handler is privileged. Accept only the main frame of + // the current Control UI path; the sibling browser never receives it. + guard message.name == Self.linkMessageHandlerName, + message.webView === self.webView, + message.frameInfo.isMainFrame, + Self.isTrustedLinkSource(message.frameInfo.request.url, dashboardURL: self.currentURL), + let request = Self.linkRequest(from: message.body) + else { + return + } + + switch request.target { + case .inline: + self.openLinkBrowser(request.url) + case .external: + self.openExternal(request.url) + } + } + + static func linkRequest(from body: Any) -> DashboardLinkRequest? { + guard let payload = body as? [String: Any], + payload["type"] as? String == "open-link", + let rawURL = payload["url"] as? String, + let url = URL(string: rawURL), + let rawTarget = payload["target"] as? String, + let target = DashboardLinkTarget(rawValue: rawTarget) + else { + return nil + } + switch target { + case .inline: + guard self.isHTTPURL(url) else { return nil } + case .external: + guard self.isExternalURL(url) else { return nil } + } + return DashboardLinkRequest(url: url, target: target) + } + + static func isTrustedLinkSource(_ sourceURL: URL?, dashboardURL: URL) -> Bool { + guard let sourceURL, sameOrigin(sourceURL, dashboardURL) else { return false } + let allowedPath = Self.allowedPath(for: dashboardURL) + return allowedPath == "/" || sourceURL.path.hasPrefix(allowedPath) + } + + static func shouldAllowEditorURLLaunch( + from sourceURL: URL?, + isMainFrame: Bool, + dashboardURL: URL) -> Bool + { + isMainFrame && self.isTrustedLinkSource(sourceURL, dashboardURL: dashboardURL) + } + + private static func isHTTPURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + url.host?.isEmpty == false + else { + return false + } + return true + } + + private static func isExternalURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { return false } + if scheme == "http" || scheme == "https" { + return self.isHTTPURL(url) + } + return scheme == "mailto" || scheme == "tel" + } + + private static func isEditorURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), + url.host?.lowercased() == "file", + !url.path.isEmpty + else { + return false + } + return scheme == "cursor" || scheme == "vscode" || scheme == "windsurf" || scheme == "zed" + } + + private static func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.scheme?.lowercased() == rhs.scheme?.lowercased() && + lhs.host?.lowercased() == rhs.host?.lowercased() && + lhs.port == rhs.port + } + private func refreshNativeAuthScript(url: URL, auth: DashboardWindowAuth) { let controller = self.webView.configuration.userContentController controller.removeAllUserScripts() @@ -305,6 +491,19 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, padding-top: max(14px, var(--openclaw-native-titlebar-height)) !important; } } + @media (max-width: 1100px) { + /* The responsive topbar replaces the sidebar below this breakpoint. + Move its controls below AppKit's traffic lights and drag overlay. */ + html.openclaw-native-macos .shell { + --shell-topbar-height: calc(58px + var(--openclaw-native-titlebar-height)); + } + html.openclaw-native-macos .topbar { + padding: var(--openclaw-native-titlebar-height) 12px 0 !important; + } + html.openclaw-native-macos .topnav-shell { + min-height: 58px; + } + } """ let script = """ (() => { @@ -368,7 +567,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, return out } - private static func allowedPath(for url: URL) -> String { + static func allowedPath(for url: URL) -> String { let path = url.path.trimmingCharacters(in: .whitespacesAndNewlines) guard !path.isEmpty else { return "/" } return path.hasSuffix("/") ? path : path + "/" @@ -386,12 +585,67 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, } func webView( - _: WKWebView, + _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) { + let isDashboardWebView = webView === self.webView + let isLinkBrowserWebView = webView === self.linkBrowser.webView + guard isDashboardWebView || isLinkBrowserWebView else { + decisionHandler(.cancel) + return + } guard let url = navigationAction.request.url else { - decisionHandler(.allow) + decisionHandler(isDashboardWebView ? .allow : .cancel) + return + } + if isLinkBrowserWebView { + // The lightweight sidebar has no download destination UI. Preserve + // direct pointer-activated downloads by handing them to the default browser. + if navigationAction.shouldPerformDownload { + if Self.shouldOpenExternalDashboardNavigation( + url, + navigationType: navigationAction.navigationType, + buttonNumber: navigationAction.buttonNumber) + { + self.openExternal(url) + } + decisionHandler(.cancel) + return + } + if navigationAction.targetFrame == nil { + self.decideTargetlessNavigation( + url, + navigationType: navigationAction.navigationType, + buttonNumber: navigationAction.buttonNumber, + allowEditorURLs: false, + decisionHandler: decisionHandler) + return + } + let isMainFrame = navigationAction.targetFrame?.isMainFrame == true + if Self.shouldAllowBrowserNavigation(to: url, isMainFrame: isMainFrame) { + if isMainFrame { + self.linkBrowser.navigationWillStart(url) + } + decisionHandler(.allow) + return + } + // The sidebar is an HTTP(S) reading surface. Only the trusted + // dashboard bridge may ask macOS to launch mail or phone URLs. + decisionHandler(.cancel) + return + } + if navigationAction.targetFrame == nil { + let allowEditorURLs = Self.shouldAllowEditorURLLaunch( + from: navigationAction.sourceFrame.request.url, + isMainFrame: navigationAction.sourceFrame.isMainFrame, + dashboardURL: self.currentURL) + self.decideTargetlessNavigation( + url, + navigationType: navigationAction.navigationType, + buttonNumber: navigationAction.buttonNumber, + allowEditorURLs: allowEditorURLs, + decisionHandler: decisionHandler) return } if Self.shouldAllowNavigation(to: url, dashboardURL: self.currentURL) { @@ -405,15 +659,47 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, decisionHandler(.cancel) return } - NSWorkspace.shared.open(url) + if Self.shouldOpenExternalDashboardNavigation( + url, + navigationType: navigationAction.navigationType, + buttonNumber: navigationAction.buttonNumber) + { + self.openExternal(url) + } decisionHandler(.cancel) } - func webView(_: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) { + if webView === self.linkBrowser.webView { + self.linkBrowser.updateChrome() + } + } + + func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { + if webView === self.linkBrowser.webView { + self.linkBrowser.navigationDidFinish() + } + } + + func webView(_ webView: WKWebView, didFail _: WKNavigation!, withError error: Error) { + if webView === self.linkBrowser.webView { + self.linkBrowser.updateChrome() + return + } + guard webView === self.webView else { return } self.showLoadFailure(error) } - func webView(_: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation _: WKNavigation!, + withError error: Error) + { + if webView === self.linkBrowser.webView { + self.linkBrowser.updateChrome() + return + } + guard webView === self.webView else { return } self.showLoadFailure(error) } @@ -428,8 +714,74 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, url.port == dashboardURL.port } + static func shouldAllowBrowserNavigation(to url: URL, isMainFrame: Bool) -> Bool { + if isMainFrame { + return self.isHTTPURL(url) + } + guard let scheme = url.scheme?.lowercased() else { return false } + return scheme == "about" || scheme == "blob" || scheme == "data" || self.isHTTPURL(url) + } + + static func shouldOpenExternalDashboardNavigation( + _ url: URL, + navigationType: WKNavigationType, + buttonNumber: Int) -> Bool + { + // WebKit also labels synthetic anchor.click() as linkActivated. Its + // action reports button 0; a physical primary click reports 1 here. + navigationType == .linkActivated && buttonNumber > 0 && self.isExternalURL(url) + } + + static func targetlessNavigationAction( + for url: URL, + navigationType: WKNavigationType, + buttonNumber: Int, + allowEditorURLs: Bool) -> DashboardTargetlessNavigationAction + { + if self.isHTTPURL(url) { + return .allow + } + // The trusted Control UI's file sidebar opens these explicit editor URLs + // with window.open(); never grant the same synthetic-launch path to web content. + if allowEditorURLs, self.isEditorURL(url) { + return .openExternal + } + if self.shouldOpenExternalDashboardNavigation( + url, + navigationType: navigationType, + buttonNumber: buttonNumber) + { + return .openExternal + } + return .cancel + } + + private func decideTargetlessNavigation( + _ url: URL, + navigationType: WKNavigationType, + buttonNumber: Int, + allowEditorURLs: Bool, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) + { + switch Self.targetlessNavigationAction( + for: url, + navigationType: navigationType, + buttonNumber: buttonNumber, + allowEditorURLs: allowEditorURLs) + { + case .allow: + decisionHandler(.allow) + case .openExternal: + self.openExternal(url) + decisionHandler(.cancel) + case .cancel: + decisionHandler(.cancel) + } + } + func windowWillClose(_: Notification) { self.webView.stopLoading() + self.closeLinkBrowser(focusDashboard: false) } private func showLoadFailure(_ error: Error) { @@ -562,6 +914,64 @@ extension DashboardWindowController { self.webView.configuration.userContentController.userScripts } + var _testLinkBrowserIsCollapsed: Bool { + self.linkBrowserItem.isCollapsed + } + + var _testLinkBrowserDataStore: WKWebsiteDataStore { + self.linkBrowser.webView.configuration.websiteDataStore + } + + var _testLinkBrowserRepresentedURL: URL? { + self.linkBrowser._testRepresentedURL + } + + var _testLinkBrowserNavigationObservationCount: Int { + self.linkBrowser._testNavigationObservationCount + } + + var _testLinkBrowserWebViewIdentity: ObjectIdentifier { + ObjectIdentifier(self.linkBrowser.webView) + } + + var _testLinkBrowserWebViewURL: URL? { + self.linkBrowser.webView.url + } + + var _testLinkBrowserHistoryIsEmpty: Bool { + let history = self.linkBrowser.webView.backForwardList + return history.currentItem == nil && history.backItem == nil && history.forwardItem == nil + } + + var _testLinkBrowserDelegatesAreInstalled: Bool { + self.linkBrowser.webView.navigationDelegate === self && self.linkBrowser.webView.uiDelegate === self + } + + var _testLinkBrowserWebViewIsInstalled: Bool { + self.linkBrowser.webView.superview === self.linkBrowser + } + + var _testDashboardDataStore: WKWebsiteDataStore { + self.webView.configuration.websiteDataStore + } + + var _testCanOpenWindowsAutomatically: Bool { + self.webView.configuration.preferences.javaScriptCanOpenWindowsAutomatically || + self.linkBrowser.webView.configuration.preferences.javaScriptCanOpenWindowsAutomatically + } + + var _testSplitAutosaveName: String? { + self.splitViewController.splitView.autosaveName + } + + func _testOpenLinkBrowser(_ url: URL) { + self.openLinkBrowser(url) + } + + func _testCloseLinkBrowser() { + self.closeLinkBrowser() + } + var _testAllowsBackForwardGestures: Bool { self.webView.allowsBackForwardNavigationGestures } diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift index 96bee5168db..41838c61622 100644 --- a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift @@ -1,7 +1,7 @@ import AppKit import Foundation -import Testing @testable import OpenClaw +import Testing @Suite(.serialized) @MainActor @@ -13,7 +13,9 @@ struct DashboardWindowSmokeTests { auth: DashboardWindowAuth( gatewayUrl: "ws://127.0.0.1:18789/control/", token: "device-token", - password: nil)) + password: nil + ) + ) controller.show() #expect(controller.window?.styleMask.contains(.titled) == true) #expect(controller.window?.styleMask.contains(.closable) == true) @@ -26,12 +28,196 @@ struct DashboardWindowSmokeTests { @Test func `dashboard navigation stays on same endpoint`() throws { let dashboard = try #require(URL(string: "http://127.0.0.1:18789/control/")) - #expect(DashboardWindowController.shouldAllowNavigation( - to: try #require(URL(string: "http://127.0.0.1:18789/control/chat")), - dashboardURL: dashboard)) + let staleEndpoint = try #require(URL(string: "http://127.0.0.1:18790/control/chat")) + #expect(try DashboardWindowController.shouldAllowNavigation( + to: #require(URL(string: "http://127.0.0.1:18789/control/chat")), + dashboardURL: dashboard + )) + #expect(try !DashboardWindowController.shouldAllowNavigation( + to: #require(URL(string: "https://docs.openclaw.ai/")), + dashboardURL: dashboard + )) #expect(!DashboardWindowController.shouldAllowNavigation( - to: try #require(URL(string: "https://docs.openclaw.ai/")), - dashboardURL: dashboard)) + to: staleEndpoint, + dashboardURL: dashboard + )) + #expect(!DashboardWindowController.shouldOpenExternalDashboardNavigation( + staleEndpoint, + navigationType: .backForward, + buttonNumber: 1 + )) + } + + @Test func `dashboard parses only bounded native link requests`() throws { + let request = DashboardWindowController.linkRequest(from: [ + "type": "open-link", + "url": "https://docs.openclaw.ai/platforms/macos", + "target": "inline", + ]) + #expect(try request == DashboardLinkRequest( + url: #require(URL(string: "https://docs.openclaw.ai/platforms/macos")), + target: .inline + )) + + #expect(DashboardWindowController.linkRequest(from: [ + "type": "open-link", + "url": "file:///tmp/private", + "target": "inline", + ]) == nil) + #expect(DashboardWindowController.linkRequest(from: [ + "type": "open-link", + "url": "https://docs.openclaw.ai/", + "target": "unknown", + ]) == nil) + #expect(DashboardWindowController.linkRequest(from: [ + "type": "other", + "url": "https://docs.openclaw.ai/", + "target": "external", + ]) == nil) + #expect(try DashboardWindowController.linkRequest(from: [ + "type": "open-link", + "url": "mailto:hello@example.com", + "target": "external", + ]) == DashboardLinkRequest( + url: #require(URL(string: "mailto:hello@example.com")), + target: .external + )) + #expect(DashboardWindowController.linkRequest(from: [ + "type": "open-link", + "url": "mailto:hello@example.com", + "target": "inline", + ]) == nil) + #expect(DashboardWindowController.linkRequest(from: [ + "type": "open-link", + "url": "https:hostless", + "target": "external", + ]) == nil) + } + + @Test func `dashboard trusts only its main control path for link messages`() throws { + let dashboard = try #require(URL(string: "http://127.0.0.1:18789/control/")) + let trusted = try #require(URL(string: "http://127.0.0.1:18789/control/chat")) + let wrongPath = try #require(URL(string: "http://127.0.0.1:18789/control-room")) + let wrongPort = try #require(URL(string: "http://127.0.0.1:18790/control/")) + #expect(DashboardWindowController.isTrustedLinkSource(trusted, dashboardURL: dashboard)) + #expect(!DashboardWindowController.isTrustedLinkSource(wrongPath, dashboardURL: dashboard)) + #expect(!DashboardWindowController.isTrustedLinkSource(wrongPort, dashboardURL: dashboard)) + #expect(!DashboardWindowController.isTrustedLinkSource(nil, dashboardURL: dashboard)) + #expect(DashboardWindowController.shouldAllowEditorURLLaunch( + from: trusted, + isMainFrame: true, + dashboardURL: dashboard + )) + #expect(!DashboardWindowController.shouldAllowEditorURLLaunch( + from: wrongPath, + isMainFrame: true, + dashboardURL: dashboard + )) + #expect(!DashboardWindowController.shouldAllowEditorURLLaunch( + from: trusted, + isMainFrame: false, + dashboardURL: dashboard + )) + } + + @Test func `dashboard link browser is isolated collapsed and width persistent`() throws { + let dashboard = try #require(URL(string: "http://127.0.0.1:18789/control/")) + let controller = DashboardWindowController( + url: dashboard, + auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil) + ) + #expect(controller._testLinkBrowserIsCollapsed) + #expect(controller._testLinkBrowserDataStore === controller._testDashboardDataStore) + #expect(!controller._testCanOpenWindowsAutomatically) + #expect(controller._testLinkBrowserNavigationObservationCount == 3) + #expect(controller._testSplitAutosaveName == DashboardWindowLayout.linkBrowserSplitAutosaveName) + let initialBrowserIdentity = controller._testLinkBrowserWebViewIdentity + + let report = try #require(URL(string: "http://127.0.0.1:1/report")) + controller._testOpenLinkBrowser(report) + #expect(!controller._testLinkBrowserIsCollapsed) + #expect(controller._testLinkBrowserRepresentedURL == report) + controller._testCloseLinkBrowser() + #expect(controller._testLinkBrowserIsCollapsed) + #expect(controller._testLinkBrowserRepresentedURL == nil) + #expect(controller._testLinkBrowserWebViewIdentity != initialBrowserIdentity) + #expect(controller._testLinkBrowserNavigationObservationCount == 3) + #expect(controller._testLinkBrowserWebViewURL == nil) + #expect(controller._testLinkBrowserHistoryIsEmpty) + #expect(controller._testLinkBrowserDelegatesAreInstalled) + #expect(controller._testLinkBrowserWebViewIsInstalled) + #expect(controller._testLinkBrowserDataStore === controller._testDashboardDataStore) + } + + @Test func `sidebar browser reserves auxiliary schemes for subframes`() throws { + let webURL = try #require(URL(string: "https://github.com/openclaw/openclaw")) + let blankURL = try #require(URL(string: "about:blank")) + let fileURL = try #require(URL(string: "file:///tmp/private")) + let mailURL = try #require(URL(string: "mailto:hello@example.com")) + #expect(DashboardWindowController.shouldAllowBrowserNavigation(to: webURL, isMainFrame: true)) + #expect(DashboardWindowController.shouldAllowBrowserNavigation(to: webURL, isMainFrame: false)) + #expect(!DashboardWindowController.shouldAllowBrowserNavigation(to: blankURL, isMainFrame: true)) + #expect(DashboardWindowController.shouldAllowBrowserNavigation(to: blankURL, isMainFrame: false)) + #expect(!DashboardWindowController.shouldAllowBrowserNavigation(to: fileURL, isMainFrame: false)) + #expect(!DashboardWindowController.shouldAllowBrowserNavigation(to: mailURL, isMainFrame: false)) + } + + @Test func `external pointer fallback rejects synthetic link activation`() throws { + let webURL = try #require(URL(string: "https://docs.openclaw.ai/")) + let mailURL = try #require(URL(string: "mailto:hello@example.com")) + #expect(DashboardWindowController.shouldOpenExternalDashboardNavigation( + webURL, + navigationType: .linkActivated, + buttonNumber: 1 + )) + #expect(DashboardWindowController.shouldOpenExternalDashboardNavigation( + mailURL, + navigationType: .linkActivated, + buttonNumber: 1 + )) + #expect(!DashboardWindowController.shouldOpenExternalDashboardNavigation( + webURL, + navigationType: .linkActivated, + buttonNumber: 0 + )) + #expect(!DashboardWindowController.shouldOpenExternalDashboardNavigation( + mailURL, + navigationType: .other, + buttonNumber: 1 + )) + + #expect(DashboardWindowController.targetlessNavigationAction( + for: webURL, + navigationType: .linkActivated, + buttonNumber: 1, + allowEditorURLs: false + ) == .allow) + #expect(DashboardWindowController.targetlessNavigationAction( + for: mailURL, + navigationType: .linkActivated, + buttonNumber: 1, + allowEditorURLs: false + ) == .openExternal) + #expect(DashboardWindowController.targetlessNavigationAction( + for: mailURL, + navigationType: .linkActivated, + buttonNumber: 0, + allowEditorURLs: false + ) == .cancel) + + let editorURL = try #require(URL(string: "vscode://file/workspace/src/foo.ts")) + #expect(DashboardWindowController.targetlessNavigationAction( + for: editorURL, + navigationType: .other, + buttonNumber: 0, + allowEditorURLs: true + ) == .openExternal) + #expect(DashboardWindowController.targetlessNavigationAction( + for: editorURL, + navigationType: .other, + buttonNumber: 0, + allowEditorURLs: false + ) == .cancel) } @Test func `dashboard origin brackets ipv6 literals`() throws { @@ -48,13 +234,16 @@ struct DashboardWindowSmokeTests { let url = try #require(URL(string: "http://127.0.0.1:18789/control/")) let controller = DashboardWindowController( url: url, - auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil)) + auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil) + ) let chromeScript = try #require(controller._testUserScripts.first { $0.source.contains("openclaw-native-macos-chrome") }) #expect(chromeScript.source.contains(".sidebar-shell")) #expect(chromeScript.source.contains(".settings-sidebar__header")) + #expect(chromeScript.source.contains(".topbar")) + #expect(chromeScript.source.contains("max-width: 1100px")) #expect(chromeScript.source.contains("--openclaw-native-titlebar-height")) } @@ -80,11 +269,13 @@ struct DashboardWindowSmokeTests { let url = try #require(URL(string: "http://127.0.0.1:18789/control/")) let controller = DashboardWindowController( url: url, - auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil)) + auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil) + ) controller.showFailure( title: "Dashboard unavailable", message: "Remote control tunnel failed", - detail: "Reset the remote tunnel and try again.") + detail: "Reset the remote tunnel and try again." + ) #expect(controller.window?.isVisible == true) #expect(controller.window?.styleMask.contains(.closable) == true) controller.closeDashboard() @@ -97,22 +288,25 @@ struct DashboardWindowSmokeTests { auth: DashboardWindowAuth( gatewayUrl: "ws://127.0.0.1:60001/", token: "device-token", - password: nil)) + password: nil + ) + ) controller.show() return controller } @Test func `dashboard follows ready endpoint to a new tunnel port`() async throws { - let controller = try self.makeShownController() + let controller = try makeShownController() defer { controller.closeDashboard() } let manager = DashboardManager._testMake() manager._testSetController(controller) - await manager.handleEndpointState(.ready( + try await manager.handleEndpointState(.ready( mode: .remote, - url: try #require(URL(string: "ws://127.0.0.1:60002")), + url: #require(URL(string: "ws://127.0.0.1:60002")), token: "device-token", - password: nil)) + password: nil + )) #expect(controller.currentURL.absoluteString == "http://127.0.0.1:60002/#token=device-token") let authScripts = controller._testUserScripts @@ -124,17 +318,18 @@ struct DashboardWindowSmokeTests { } @Test func `dashboard keeps endpoint when ready state matches current URL`() async throws { - let controller = try self.makeShownController() + let controller = try makeShownController() defer { controller.closeDashboard() } let manager = DashboardManager._testMake() manager._testSetController(controller) let scriptsBefore = controller._testUserScripts - await manager.handleEndpointState(.ready( + try await manager.handleEndpointState(.ready( mode: .remote, - url: try #require(URL(string: "ws://127.0.0.1:60001")), + url: #require(URL(string: "ws://127.0.0.1:60001")), token: "device-token", - password: nil)) + password: nil + )) await manager.handleEndpointState(.connecting(mode: .remote, detail: "Connecting…")) await manager.handleEndpointState(.unavailable(mode: .remote, reason: "tunnel down")) @@ -150,15 +345,18 @@ struct DashboardWindowSmokeTests { auth: DashboardWindowAuth( gatewayUrl: "ws://127.0.0.1:60001/", token: "device-token", - password: nil)) + password: nil + ) + ) let manager = DashboardManager._testMake() manager._testSetController(controller) - await manager.handleEndpointState(.ready( + try await manager.handleEndpointState(.ready( mode: .remote, - url: try #require(URL(string: "ws://127.0.0.1:60002")), + url: #require(URL(string: "ws://127.0.0.1:60002")), token: "device-token", - password: nil)) + password: nil + )) #expect(controller.currentURL == url) } diff --git a/docs/docs_map.md b/docs/docs_map.md index 8aa6972564e..77e329461c1 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -5186,6 +5186,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H2: Download - H2: First run + - H2: Open dashboard links - H2: Choose a Gateway mode - H2: What the app owns - H2: macOS detail pages diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index fa49b8474c0..28935287f6a 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -41,6 +41,12 @@ available for recovery. For the CLI/Gateway setup path, use [Getting started](/start/getting-started). For permission recovery, use [macOS permissions](/platforms/mac/permissions). +## Open dashboard links + +In the macOS app's embedded dashboard, clicking an external web link opens it in a resizable browser sidebar. The window's titlebar back/forward controls and trackpad swipes navigate dashboard history; the sidebar's own back/forward controls navigate external page history. The sidebar also has reload, open-in-default-browser, and close controls, and it remembers its width. + +Right-click an external link to choose **Open in Sidebar**, **Open in Default Browser**, or **Copy Link**. Modified clicks and user-activated new-window links continue to open in the default browser. Regular browser-hosted Control UI pages keep the browser's normal link and context-menu behavior. + ## Choose a Gateway mode | Mode | Use it when | Detail page | diff --git a/ui/src/app/native-link-routing.ts b/ui/src/app/native-link-routing.ts index 47dacc84a69..66cb367e7a5 100644 --- a/ui/src/app/native-link-routing.ts +++ b/ui/src/app/native-link-routing.ts @@ -53,6 +53,22 @@ function externalHttpUrl(event: Event): { anchor: HTMLAnchorElement; url: URL } return { anchor, url }; } +function trustedExternalAppUrl(event: MouseEvent): { anchor: HTMLAnchorElement; url: URL } | null { + if (!event.isTrusted) { + return null; + } + const anchor = anchorFromEvent(event); + if (!anchor || anchor.hasAttribute("download") || anchor.hasAttribute("data-file-path")) { + return null; + } + try { + const url = new URL(anchor.href, window.location.href); + return url.protocol === "mailto:" || url.protocol === "tel:" ? { anchor, url } : null; + } catch { + return null; + } +} + function menuContainer(event: Event): HTMLElement { const path = event.composedPath(); const modalHost = path.find( @@ -142,8 +158,11 @@ export function startNativeLinkRouting(): NativeLinkRouting { ) { return; } - const link = externalHttpUrl(event); - if (!link || !postNativeLink(postMessage, link.url, "inline")) { + const appLink = trustedExternalAppUrl(event); + const webLink = appLink ? null : externalHttpUrl(event); + const link = appLink ?? webLink; + const target = appLink ? "external" : "inline"; + if (!link || !postNativeLink(postMessage, link.url, target)) { return; } closeMenu(); diff --git a/ui/src/e2e/native-link-routing.e2e.test.ts b/ui/src/e2e/native-link-routing.e2e.test.ts index d969426daf6..64b1ee7d917 100644 --- a/ui/src/e2e/native-link-routing.e2e.test.ts +++ b/ui/src/e2e/native-link-routing.e2e.test.ts @@ -105,6 +105,49 @@ describeControlUiE2e("native link routing", () => { ) .toEqual([{ type: "open-link", url: "https://example.com/report", target: "inline" }]); + await page.evaluate(() => { + const anchor = document.createElement("a"); + anchor.href = "mailto:hello@example.com"; + anchor.textContent = "email support"; + anchor.addEventListener("click", (event) => { + if (!event.isTrusted) { + event.preventDefault(); + } + }); + document.body.append(anchor); + }); + const emailLink = page.getByRole("link", { name: "email support" }); + await emailLink.focus(); + await page.keyboard.press("Enter"); + await expect + .poll(() => + page.evaluate( + () => + (window as Window & { openclawNativeLinkMessages?: unknown[] }) + .openclawNativeLinkMessages, + ), + ) + .toContainEqual({ + type: "open-link", + url: "mailto:hello@example.com", + target: "external", + }); + const messageCount = await page.evaluate( + () => + (window as Window & { openclawNativeLinkMessages?: unknown[] }).openclawNativeLinkMessages + ?.length ?? 0, + ); + await emailLink.evaluate((anchor) => (anchor as HTMLAnchorElement).click()); + await expect + .poll(() => + page.evaluate( + () => + (window as Window & { openclawNativeLinkMessages?: unknown[] }) + .openclawNativeLinkMessages?.length ?? 0, + ), + ) + .toBe(messageCount); + const bubble = page.locator(".chat-bubble"); const bubbleBox = await bubble.boundingBox(); expect(bubbleBox).not.toBeNull(); @@ -139,6 +182,7 @@ describeControlUiE2e("native link routing", () => { ) .toEqual([ { type: "open-link", url: "https://example.com/report", target: "inline" }, + { type: "open-link", url: "mailto:hello@example.com", target: "external" }, { type: "open-link", url: "https://example.com/report", target: "external" }, ]);