mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
2e160718d4
commit
d8d369b065
8 changed files with 1002 additions and 41 deletions
262
apps/macos/Sources/OpenClaw/DashboardLinkBrowserView.swift
Normal file
262
apps/macos/Sources/OpenClaw/DashboardLinkBrowserView.swift
Normal 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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue