feat(macos): open dashboard links in a sidebar browser (#102899)

* feat(macos): add dashboard link browser

* fix(macos): preserve browser navigation state

* fix(macos): preserve accessible external link activation

* fix(macos): preserve trusted editor handoff

* docs: refresh dashboard link map

* chore: keep dashboard release note in PR

* fix(macos): harden sidebar browser lifecycle
This commit is contained in:
Peter Steinberger 2026-07-09 16:40:50 +01:00 committed by GitHub
parent 2e160718d4
commit d8d369b065
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1002 additions and 41 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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
}

View file

@ -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)
}