Files
openclaw/apps/macos/Sources/OpenClaw/DashboardWindowController.swift
T
Peter SteinbergerandGitHub 38df399ed8 fix(macos): show local ClickClack session discussions (#111883)
* fix(macos): show local ClickClack session discussions

* fix(macos): support loopback aliases in discussion embeds

* fix(macos): allow trusted hosted ClickClack embeds

* fix(macos): trust only dashboard-initiated discussion frames
2026-07-20 09:33:27 -07:00

1416 lines
58 KiB
Swift

import AppKit
import Foundation
import WebKit
private final class DashboardWindowContentView: NSView {
override var mouseDownCanMoveWindow: Bool {
true
}
}
/// The dashboard's empty unified toolbar exists only to grow the titlebar to
/// 52pt so the traffic lights align with the hosted web chrome. `View > Hide
/// Toolbar` (and ⌥⌘T) would collapse the titlebar while the web inset stays
/// pinned at `--openclaw-native-titlebar-height`, resurrecting the traffic-light
/// misalignment. Refusing the toggle keeps the two heights in lockstep.
private final class DashboardWindow: NSWindow {
override func toggleToolbarShown(_: Any?) {}
override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
if item.action == #selector(NSWindow.toggleToolbarShown(_:)) { return false }
return super.validateUserInterfaceItem(item)
}
}
private final class DashboardWindowDragRegionView: NSView {
override var mouseDownCanMoveWindow: Bool {
true
}
override func mouseDown(with event: NSEvent) {
window?.performDrag(with: event)
}
}
private final class DashboardLinkSplitView: NSSplitView {
var onDividerDragEnded: (() -> Void)?
override func mouseDown(with event: NSEvent) {
let originalPosition = self.subviews.first?.frame.maxX
// AppKit tracks a divider drag inside this call. Persist after it ends;
// resize notifications also cover automatic layout and would lose the preference.
super.mouseDown(with: event)
guard DashboardWindowLayout.dividerMoved(
from: originalPosition,
to: self.subviews.first?.frame.maxX)
else { return }
self.onDividerDragEnded?()
}
#if DEBUG
func _testCompleteDividerDrag() {
self.onDividerDragEnded?()
}
#endif
}
@MainActor
private final class DashboardLinkMessageHandler: NSObject, WKScriptMessageHandler {
weak var owner: DashboardWindowController?
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
self.owner?.receiveLinkMessage(message)
}
}
@MainActor
private final class DashboardWindowDragMessageHandler: NSObject, WKScriptMessageHandler {
weak var owner: DashboardWindowController?
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
self.owner?.receiveWindowDragMessage(message)
}
}
@MainActor
private final class DashboardUpdateMessageHandler: NSObject, WKScriptMessageHandler {
weak var owner: DashboardWindowController?
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
self.owner?.receiveUpdateMessage(message)
}
}
@MainActor
final class DashboardWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
private static let linkMessageHandlerName = "openclawLink"
private static let windowDragMessageHandlerName = "openclawWindowDrag"
private static let updateMessageHandlerName = "openclawUpdate"
let webView: DashboardWebView
private let linkBrowser: DashboardLinkBrowserView
private let linkBrowserItem: NSSplitViewItem
private let linkBrowserSplitView: DashboardLinkSplitView
private let splitViewController: NSSplitViewController
private let updateMessageHandler: DashboardUpdateMessageHandler
private(set) var currentURL: URL
private var auth: DashboardWindowAuth
private let updater: UpdaterProviding?
private var updateBridgeEnabled: Bool
private let requestBrowserProfileImportOffer:
@MainActor (@escaping @MainActor () -> Bool) async -> Bool
private var canGoBackObservation: NSKeyValueObservation?
private var canGoForwardObservation: NSKeyValueObservation?
private var didRequestBrowserProfileImportOffer = false
private var browserProfileImportOfferIsArmed = false
private var browserProfileImportOfferRequestIsInFlight = false
private var browserProfileImportOfferRetryPending = false
private var hasLiveContent = false
private var isShowingFailurePage = false
private var pendingNativeCommands: [DashboardNativeCommand] = []
init(
url: URL,
auth: DashboardWindowAuth,
updater: UpdaterProviding? = nil,
updateBridgeEnabled: Bool = true,
requestBrowserProfileImportOffer:
@escaping @MainActor (@escaping @MainActor () -> Bool) async -> Bool = { shouldApply in
await BrowserProfileImportModel.shared.requestAutomaticOfferIfEligible(while: shouldApply)
})
{
let shouldEnableUpdateBridge = updater?.isAvailable == true && updateBridgeEnabled
self.currentURL = url
self.auth = auth
self.updater = updater
self.updateBridgeEnabled = shouldEnableUpdateBridge
self.requestBrowserProfileImportOffer = requestBrowserProfileImportOffer
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)
let windowDragMessageHandler = DashboardWindowDragMessageHandler()
config.userContentController.add(windowDragMessageHandler, name: Self.windowDragMessageHandlerName)
let notificationsMessageHandler = DashboardNotificationsMessageHandler()
config.userContentController.add(notificationsMessageHandler, name: Self.notificationsMessageHandlerName)
let updateMessageHandler = DashboardUpdateMessageHandler()
self.updateMessageHandler = updateMessageHandler
if shouldEnableUpdateBridge {
// Handler presence is the Control UI feature probe; unsigned builds
// and remote dashboards must not advertise a local app update.
config.userContentController.add(updateMessageHandler, name: Self.updateMessageHandlerName)
}
Self.installNativeChromeScript(into: config.userContentController)
Self.installNativeAuthScript(into: config.userContentController, url: url, auth: auth)
self.webView = DashboardWebView(
frame: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize),
configuration: config)
self.webView.setValue(true, forKey: "drawsBackground")
self.webView.underPageBackgroundColor = .windowBackgroundColor
// The Control UI routes via pushState, so WKWebView's back-forward list
// carries in-app navigation; the web titlebar buttons use this list.
self.webView.allowsBackForwardNavigationGestures = true
let linkBrowser = DashboardLinkBrowserView(websiteDataStore: dataStore)
let linkBrowserSplitView = DashboardLinkSplitView()
let splitViewController = NSSplitViewController()
splitViewController.splitView = linkBrowserSplitView
splitViewController.splitView.isVertical = true
splitViewController.splitView.dividerStyle = .thin
let dashboardViewController = NSViewController()
dashboardViewController.view = BrowserProfileImportBannerView.makeDashboardPane(webView: 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.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.linkBrowserSplitView = linkBrowserSplitView
self.splitViewController = splitViewController
let window = Self.makeWindow(contentView: splitViewController.view)
super.init(window: window)
// NSWindowController adopts its own frame state during initialization;
// keep it aligned with the autosave name installed by makeWindow, then
// re-correct placement in case the assignment re-applied a stale frame.
self.windowFrameAutosaveName = DashboardWindowLayout.windowFrameAutosaveName
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
// 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
windowDragMessageHandler.owner = self
notificationsMessageHandler.owner = self
updateMessageHandler.owner = self
self.webView.navigationDelegate = self
self.webView.uiDelegate = self
self.linkBrowser.webViewNavigationDelegate = self
self.linkBrowser.webViewUIDelegate = self
self.linkBrowser.onClose = { [weak self] in self?.closeLinkBrowser() }
self.linkBrowser.onOpenExternal = { [weak self] url in self?.openExternal(url) }
self.linkBrowserSplitView.onDividerDragEnded = { [weak self] in
self?.persistLinkBrowserWidth()
}
self.window?.delegate = self
self.installHistoryStateBridge()
}
func setUpdateBridgeEnabled(_ enabled: Bool) {
let nextEnabled = self.updater?.isAvailable == true && enabled
guard nextEnabled != self.updateBridgeEnabled else { return }
self.updateBridgeEnabled = nextEnabled
let controller = self.webView.configuration.userContentController
controller.removeScriptMessageHandler(forName: Self.updateMessageHandlerName)
if nextEnabled {
controller.add(self.updateMessageHandler, name: Self.updateMessageHandlerName)
}
// The handler is the dashboard's ownership probe. Notify the live page so
// its update target stays correct when connection mode or ownership changes.
self.webView.evaluateJavaScript(
"window.dispatchEvent(new CustomEvent('openclaw:native-update-availability-changed'))")
}
// MARK: - WKUIDelegate
/// Bridges JavaScript `window.confirm` calls in the embedded Control UI to a
/// native confirmation sheet; without this callback, WebKit treats every
/// confirm as Cancel and destructive dashboard actions silently stop.
func webView(
_ webView: WKWebView,
runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping @MainActor @Sendable (Bool) -> Void)
{
guard webView === self.webView || self.linkBrowser.owns(webView) else {
completionHandler(false)
return
}
let alert = Self.makeJavaScriptConfirmAlert(
message: message,
host: frame.request.url?.host)
if let window {
alert.beginSheetModal(for: window) { response in
completionHandler(Self.javaScriptConfirmResult(for: response))
}
return
}
completionHandler(Self.javaScriptConfirmResult(for: alert.runModal()))
}
/// Bridges `<input type="file">` clicks in the embedded Control UI to a native
/// `NSOpenPanel`; without a `WKUIDelegate`, WebKit silently drops the request
/// and "Choose image" / file-picker buttons do nothing.
func webView(
_ webView: WKWebView,
runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame _: WKFrameInfo,
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void)
{
guard webView === self.webView || self.linkBrowser.owns(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 {
panel.beginSheetModal(for: window) { response in
completionHandler(response == .OK ? panel.urls : nil)
}
return
}
panel.begin { response in
completionHandler(response == .OK ? panel.urls : nil)
}
}
func webView(
_ webView: WKWebView,
createWebViewWith _: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures _: WKWindowFeatures) -> WKWebView?
{
// WebKit reaches this callback only for user-allowed new-window requests;
// every configuration disables automatic JavaScript windows.
guard navigationAction.targetFrame == nil,
webView === self.webView || self.linkBrowser.owns(webView)
else {
return nil
}
// Sidebar target=_blank links become tabs; dashboard requests preserve
// the existing handoff to the default browser.
switch Self.newWindowAction(
for: navigationAction.request.url,
sourceIsLinkBrowser: self.linkBrowser.owns(webView))
{
case let .openTab(url):
self.linkBrowser.openInNewTab(url)
case let .openExternal(url):
self.openExternal(url)
case .ignore:
break
}
return nil
}
private static func makeJavaScriptConfirmAlert(message: String, host: String?) -> NSAlert {
let alert = NSAlert()
alert.messageText = "OpenClaw Dashboard"
if let host, !host.isEmpty {
alert.informativeText = "\(host) is asking:\n\n\(message)"
} else {
alert.informativeText = message
}
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
return alert
}
private static func javaScriptConfirmResult(
for response: NSApplication.ModalResponse)
-> Bool
{
response == .alertFirstButtonReturn
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) is not supported")
}
func show(url: URL, auth: DashboardWindowAuth, updateBridgeEnabled: Bool? = nil) {
self.update(url: url, auth: auth, updateBridgeEnabled: updateBridgeEnabled)
self.show()
}
func loadInBackground(url: URL, auth: DashboardWindowAuth) {
self.update(url: url, auth: auth)
}
/// Swap the dashboard to a new gateway endpoint without reordering the window:
/// re-injects the native auth script for the new origin and reloads. Used when
/// the remote tunnel is recreated on a new local port while the window stays
/// open; ordering the window front here would steal focus on background
/// tunnel recreation.
func update(url: URL, auth: DashboardWindowAuth, updateBridgeEnabled: Bool? = nil) {
let shouldReload = Self.shouldReloadDashboard(
currentURL: self.currentURL,
newURL: url,
currentAuth: self.auth,
newAuth: auth,
hasUsableDocument: self.hasLiveContent || self.webView.isLoading,
isShowingFailurePage: self.isShowingFailurePage)
self.currentURL = url
self.auth = auth
if let updateBridgeEnabled {
self.setUpdateBridgeEnabled(updateBridgeEnabled)
}
if shouldReload {
self.refreshNativeAuthScript(url: url, auth: auth)
self.load(url)
}
self.requestBrowserProfileImportOfferIfNeeded()
}
/// Miniaturized windows report `isVisible == false` but must still follow
/// endpoint changes so deminiaturizing does not land on a dead port.
var isWindowOpen: Bool {
guard let window else { return false }
return window.isVisible || window.isMiniaturized
}
/// Commands are deliverable when a document is live or a load is in flight
/// (the queue flushes at `didFinish`). A failure page, or a terminally
/// cancelled load with no successor, needs a reload before dispatch —
/// otherwise queued ⌘N/⌘K would wait on a `didFinish` that never comes.
var canDeliverNativeCommands: Bool {
!self.isShowingFailurePage && (self.hasLiveContent || self.webView.isLoading)
}
func show() {
if let window {
let frame = window.frame
if frame.width < DashboardWindowLayout.windowMinSize.width ||
frame.height < DashboardWindowLayout.windowMinSize.height
{
window.setFrame(WindowPlacement.centeredFrame(size: DashboardWindowLayout.windowSize), display: false)
}
}
showWindow(nil)
window?.makeKeyAndOrderFront(nil)
window?.makeFirstResponder(self.webView)
window?.orderFrontRegardless()
NSApp.activate(ignoringOtherApps: true)
}
func closeDashboard() {
window?.performClose(nil)
}
func releaseFrameAutosaveForReplacement() {
// AppKit rejects duplicate autosave owners. Release only when the manager
// replaces this controller so the successor can restore the saved frame.
self.window?.saveFrame(usingName: DashboardWindowLayout.windowFrameAutosaveName)
self.windowFrameAutosaveName = ""
}
func showFailure(title: String, message: String, detail: String? = nil) {
self.hasLiveContent = false
self.isShowingFailurePage = true
// Queued commands are moment-bound user intent; replaying them after a
// later recovery reload would toggle or navigate unexpectedly.
self.pendingNativeCommands = []
self.currentURL = URL(string: "about:blank")!
self.auth = DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil)
self.setUpdateBridgeEnabled(false)
self.refreshNativeAuthScript(url: self.currentURL, auth: self.auth)
self.webView.stopLoading()
self.webView.loadHTMLString(
DashboardFailurePage.html(title: title, message: message, detail: detail, url: nil),
baseURL: nil)
self.show()
}
private func load(_ url: URL) {
// Endpoint swaps must queue commands for the replacement document.
self.hasLiveContent = false
self.isShowingFailurePage = false
dashboardWindowLogger.debug("dashboard load \(dashboardLogString(for: url), privacy: .public)")
self.webView.load(URLRequest(url: url))
}
private func openLinkBrowser(_ url: URL, requestBrowserProfileImportOffer: Bool = true) {
let wasCollapsed = self.linkBrowserItem.isCollapsed
let persistedWidth = self.persistedLinkBrowserWidth()
self.linkBrowserItem.isCollapsed = false
if wasCollapsed {
self.restoreLinkBrowserWidth(persistedWidth)
}
self.linkBrowser.open(url)
window?.makeFirstResponder(self.linkBrowser.activeWebView)
if requestBrowserProfileImportOffer {
self.browserProfileImportOfferIsArmed = true
self.requestBrowserProfileImportOfferIfNeeded()
}
}
private func persistedLinkBrowserWidth() -> CGFloat? {
guard let number = UserDefaults.standard.object(
forKey: DashboardWindowLayout.linkBrowserWidthDefaultsKey) as? NSNumber
else { return nil }
let width = CGFloat(number.doubleValue)
return width.isFinite && width > 0 ? width : nil
}
private func restoreLinkBrowserWidth(_ persistedWidth: CGFloat?) {
let splitView = self.splitViewController.splitView
splitView.layoutSubtreeIfNeeded()
// AppKit requires adjusted, current pane frames before divider positions
// are meaningful. Uncollapsing invalidates those frames.
splitView.adjustSubviews()
let width = DashboardWindowLayout.linkBrowserWidth(
splitWidth: splitView.bounds.width,
dividerThickness: splitView.dividerThickness,
persistedWidth: persistedWidth)
splitView.setPosition(
splitView.bounds.width - splitView.dividerThickness - width,
ofDividerAt: 0)
splitView.layoutSubtreeIfNeeded()
}
private func persistLinkBrowserWidth() {
guard !self.linkBrowserItem.isCollapsed else { return }
let width = self.linkBrowser.frame.width
guard width.isFinite, width >= DashboardWindowLayout.linkBrowserMinWidth else { return }
UserDefaults.standard.set(Double(width), forKey: DashboardWindowLayout.linkBrowserWidthDefaultsKey)
}
private func requestBrowserProfileImportOfferIfNeeded() {
guard self.browserProfileImportOfferIsArmed,
!self.linkBrowserItem.isCollapsed,
!self.didRequestBrowserProfileImportOffer
else { return }
if self.browserProfileImportOfferRequestIsInFlight {
// Gateway readiness can arrive while the status poll awaits transport.
// Latch one retry so in-flight dedupe does not discard that reconnect signal.
self.browserProfileImportOfferRetryPending = true
return
}
self.browserProfileImportOfferRequestIsInFlight = true
Task { [weak self] in
guard let self else { return }
let didApply = await self.requestBrowserProfileImportOffer { [weak self] in
guard let self else { return false }
return self.browserProfileImportOfferIsArmed &&
!self.linkBrowserItem.isCollapsed &&
!self.didRequestBrowserProfileImportOffer
}
self.browserProfileImportOfferRequestIsInFlight = false
let shouldRetry = self.browserProfileImportOfferRetryPending && !didApply
self.browserProfileImportOfferRetryPending = false
if didApply {
self.didRequestBrowserProfileImportOffer = true
} else if shouldRetry {
self.requestBrowserProfileImportOfferIfNeeded()
}
}
}
func handleOnboardingCompletion() {
// A pre-onboarding inline browser leaves the one-shot armed. Retry at
// the eligibility transition so it does not depend on later navigation.
self.requestBrowserProfileImportOfferIfNeeded()
}
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)
}
}
/// The Control UI posts this from mousedown on passive pane-header chrome
/// (split-view session titles). WKWebView swallows titlebar-style drags, so
/// the web side asks the window to take over the in-flight mouse gesture.
fileprivate func receiveWindowDragMessage(_ message: WKScriptMessage) {
guard message.name == Self.windowDragMessageHandlerName,
message.webView === self.webView,
message.frameInfo.isMainFrame,
Self.isTrustedLinkSource(message.frameInfo.request.url, dashboardURL: self.currentURL),
Self.isWindowDragRequest(message.body),
let window
else {
return
}
// The script message arrives async; during a press the app's current
// event is still the initiating left-mouse-down (or a later drag). A
// finished click leaves left-mouse-up here and starts no drag.
guard let event = NSApp.currentEvent,
event.type == .leftMouseDown || event.type == .leftMouseDragged,
event.window === window
else {
return
}
window.performDrag(with: event)
}
static func isWindowDragRequest(_ body: Any) -> Bool {
guard let payload = body as? [String: Any] else { return false }
return payload["type"] as? String == "window-drag"
}
fileprivate func receiveUpdateMessage(_ message: WKScriptMessage) {
guard message.name == Self.updateMessageHandlerName,
message.webView === self.webView,
message.frameInfo.isMainFrame,
Self.isTrustedLinkSource(message.frameInfo.request.url, dashboardURL: self.currentURL),
Self.isStartUpdateRequest(message.body),
let updater = self.updater
else {
return
}
// Eligibility is cached at window setup, but update.channel or launchd
// ownership can change while the dashboard stays open. Revalidate here.
guard DashboardManager.updateBridgeEnabled(mode: AppStateStore.shared.connectionMode) else {
self.setUpdateBridgeEnabled(false)
// JS treated its posted message as handled; return this click to
// the gateway updater after withdrawing the native bridge.
self.webView.evaluateJavaScript(
"window.dispatchEvent(new CustomEvent('openclaw:native-update-declined'))")
return
}
updater.checkForUpdates(nil)
}
static func isStartUpdateRequest(_ body: Any) -> Bool {
guard let payload = body as? [String: Any] else { return false }
return payload["type"] as? String == "start-update"
}
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()
Self.installNativeChromeScript(into: controller)
Self.installNativeAuthScript(into: controller, url: url, auth: auth)
}
private func installHistoryStateBridge() {
self.canGoBackObservation = self.webView.observe(\.canGoBack, options: [
.initial,
.new,
]) { [weak self] _, _ in
Task { @MainActor in
self?.publishNativeHistoryState()
}
}
self.canGoForwardObservation = self.webView.observe(\.canGoForward, options: [
.initial,
.new,
]) { [weak self] _, _ in
Task { @MainActor in
self?.publishNativeHistoryState()
}
}
}
private func publishNativeHistoryState() {
let canGoBack = self.webView.canGoBack ? "true" : "false"
let canGoForward = self.webView.canGoForward ? "true" : "false"
self.webView.evaluateJavaScript(
"""
window.__OPENCLAW_NATIVE_HISTORY__ = {canGoBack:\(canGoBack),canGoForward:\(canGoForward)};
window.dispatchEvent(new CustomEvent('openclaw:native-history-state', \
{detail:window.__OPENCLAW_NATIVE_HISTORY__}));
""")
}
func navigateBack() {
self.activeNavigationWebView.goBack()
}
func navigateForward() {
self.activeNavigationWebView.goForward()
}
private var activeNavigationWebView: WKWebView {
guard let linkWebView = self.linkBrowser.activeWebView,
let firstResponder = self.window?.firstResponder as? NSView,
firstResponder === linkWebView || firstResponder.isDescendant(of: linkWebView)
else {
return self.webView
}
return linkWebView
}
private static func makeWindow(contentView: NSView) -> NSWindow {
let window = DashboardWindow(
contentRect: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false)
let container = DashboardWindowContentView(frame: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize))
contentView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(contentView)
let topDragRegion = DashboardWindowDragRegionView()
topDragRegion.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(topDragRegion)
let topRightDragRegion = DashboardWindowDragRegionView()
topRightDragRegion.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(topRightDragRegion)
NSLayoutConstraint.activate([
contentView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
contentView.topAnchor.constraint(equalTo: container.topAnchor),
contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
topDragRegion.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 78),
topDragRegion.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -380),
topDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
// Thin edge strip only: the web UI has no desktop topbar row, so a
// taller region would swallow clicks meant for the top of the
// content column (chat thread, page headers). The web titlebar
// toolbar owns the larger drag surface beside the traffic lights.
topDragRegion.heightAnchor.constraint(equalToConstant: 12),
topRightDragRegion.leadingAnchor.constraint(equalTo: topDragRegion.trailingAnchor),
topRightDragRegion.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8),
topRightDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
topRightDragRegion.heightAnchor.constraint(equalToConstant: 6),
])
window.title = "OpenClaw"
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
// An empty unified toolbar grows the transparent titlebar to 52pt so the
// traffic lights sit vertically centered against the web titlebar row
// (--openclaw-native-titlebar-height); without it they hug the top edge.
window.toolbar = NSToolbar(identifier: "DashboardWindowTitlebar")
window.toolbarStyle = .unified
window.titlebarSeparatorStyle = .none
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.hasShadow = true
window.backgroundColor = .windowBackgroundColor
window.isOpaque = true
let viewController = NSViewController()
viewController.view = container
window.contentViewController = viewController
window.center()
window.minSize = DashboardWindowLayout.windowMinSize
// Autosave restore first, placement correction last: a frame saved on
// a since-disconnected monitor must not leave the window off-screen.
window.setFrameAutosaveName(DashboardWindowLayout.windowFrameAutosaveName)
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
return window
}
private static func installNativeChromeScript(into userContentController: WKUserContentController) {
// Deliberately no native fallback for pages that ignore this flag
// (older gateway bundles, failure pages): they keep their own in-page
// toggles plus back/forward gestures and the Cmd-[/] menu items.
let capabilityScript = "window.__OPENCLAW_NATIVE_WEB_CHROME__ = true;"
userContentController.addUserScript(
WKUserScript(source: capabilityScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
// Narrow widths need no rules here: the Control UI's own
// `html.openclaw-native-macos` styles fold the titlebar clearance into
// the drawer topbar row (layout.mobile.css); their body-qualified
// !important selectors also outrank the rules older app builds inject.
let css = """
html.openclaw-native-macos {
/* Matches the 52pt unified-toolbar titlebar so the web buttons and the
traffic lights share one vertical center. */
--openclaw-native-titlebar-height: 52px;
}
@media (min-width: 700px) {
/* Both desktop navigation surfaces must clear AppKit's window controls
and drag regions or their first interactive row becomes unreachable. */
html.openclaw-native-macos .sidebar-shell,
html.openclaw-native-macos .settings-sidebar__header {
padding-top: max(14px, var(--openclaw-native-titlebar-height)) !important;
}
}
"""
let script = """
(() => {
try {
if (document.getElementById("openclaw-native-macos-chrome")) return;
const style = document.createElement("style");
style.id = "openclaw-native-macos-chrome";
style.textContent = \(Self.jsStringLiteral(css));
document.documentElement.classList.add("openclaw-native-macos", "openclaw-native-web-chrome");
document.head.appendChild(style);
} catch {}
})();
"""
userContentController.addUserScript(
WKUserScript(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: true))
}
private static func installNativeAuthScript(
into userContentController: WKUserContentController,
url: URL,
auth: DashboardWindowAuth)
{
guard auth.hasCredential else { return }
let allowedOrigin = self.originString(for: url)
let allowedPath = self.allowedPath(for: url)
let payload: [String: Any?] = [
"gatewayUrl": auth.gatewayUrl,
"token": auth.token,
"password": auth.password,
]
guard let data = try? JSONSerialization.data(withJSONObject: payload.compactMapValues { $0 }),
let json = String(data: data, encoding: .utf8)
else {
return
}
let script = """
(() => {
try {
const allowedOrigin = \(Self.jsStringLiteral(allowedOrigin));
const allowedPath = \(Self.jsStringLiteral(allowedPath));
if (location.origin !== allowedOrigin) return;
if (allowedPath !== "/" && !location.pathname.startsWith(allowedPath)) return;
Object.defineProperty(window, "__OPENCLAW_NATIVE_CONTROL_AUTH__", {
value: \(json),
configurable: true,
});
} catch {}
})();
"""
userContentController.addUserScript(
WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
}
static func originString(for url: URL) -> String {
guard let scheme = url.scheme, let host = url.host else { return "" }
let hostPart = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host
var out = "\(scheme)://\(hostPart)"
if let port = url.port {
out += ":\(port)"
}
return out
}
static func allowedPath(for url: URL) -> String {
let path = url.path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !path.isEmpty else { return "/" }
return path.hasSuffix("/") ? path : path + "/"
}
private static func jsStringLiteral(_ value: String) -> String {
guard let data = try? JSONSerialization.data(withJSONObject: [value]),
let raw = String(data: data, encoding: .utf8),
raw.hasPrefix("["),
raw.hasSuffix("]")
else {
return "\"\""
}
return String(raw.dropFirst().dropLast())
}
static func shouldAllowNavigation(
to url: URL,
dashboardURL: URL,
isMainFrame: Bool,
isTrustedDashboardSource: Bool = false) -> Bool
{
guard let scheme = url.scheme?.lowercased() else { return true }
if scheme == "about" || scheme == "blob" || scheme == "data" {
return true
}
guard scheme == "http" || scheme == "https" else { return false }
let dashboardScheme = dashboardURL.scheme?.lowercased()
let dashboardHost = dashboardURL.host?.lowercased()
let host = url.host?.lowercased()
if scheme == dashboardScheme, host == dashboardHost, url.port == dashboardURL.port {
return true
}
guard !isMainFrame,
isTrustedDashboardSource,
host?.isEmpty == false,
url.user == nil,
url.password == nil
else {
return false
}
let components = url.path.split(separator: "/", omittingEmptySubsequences: true)
return components.count == 4 &&
components[0] == "embed" &&
(components[1] == "channel" || components[1] == "thread")
}
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
}
static func newWindowAction(for url: URL?, sourceIsLinkBrowser: Bool) -> DashboardNewWindowAction {
guard let url, self.isHTTPURL(url) else { return .ignore }
return sourceIsLinkBrowser ? .openTab(url) : .openExternal(url)
}
func windowWillClose(_: Notification) {
self.webView.stopLoading()
self.closeLinkBrowser(focusDashboard: false)
}
private func showLoadFailure(_ error: Error) {
let nsError = error as NSError
// A cancelled provisional navigation never commits, so the prior
// document survives and stays command-capable; clearing live state
// here would queue native commands forever with no reload to flush.
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled {
return
}
self.hasLiveContent = false
self.isShowingFailurePage = true
// Same moment-bound rule as showFailure: a terminal load failure
// invalidates commands queued for the document that never arrived.
self.pendingNativeCommands = []
dashboardWindowLogger.error(
"""
dashboard load failed url=\(dashboardLogString(for: self.currentURL), privacy: .public) \
error=\(error.localizedDescription, privacy: .public)
""")
let html = DashboardFailurePage.html(
title: "Dashboard unavailable",
message: error.localizedDescription,
detail: "The dashboard window is open, but the web UI could not load from this endpoint.",
url: self.currentURL)
self.webView.loadHTMLString(html, baseURL: nil)
}
}
extension DashboardWindowController {
static func shouldReloadDashboard(
currentURL: URL,
newURL: URL,
currentAuth: DashboardWindowAuth,
newAuth: DashboardWindowAuth,
hasUsableDocument: Bool,
isShowingFailurePage: Bool) -> Bool
{
// Token changes surface in the URL fragment, but password-only auth keeps
// the URL identical; comparing auth prevents serving stale credentials.
// An in-flight load counts as usable so opening mid-preload does not
// cancel and restart it — unless the in-flight document is the failure
// page, which must always be replaced.
currentURL != newURL || currentAuth != newAuth || isShowingFailurePage || !hasUsableDocument
}
func dispatchNativeCommand(_ command: DashboardNativeCommand) {
guard self.hasLiveContent else {
// Ordered queue, duplicates included: two ⌘K presses while loading
// must toggle twice, and ⌘N followed by ⌘K must deliver both.
self.pendingNativeCommands.append(command)
return
}
self.evaluateNativeCommand(command)
}
private func evaluateNativeCommand(_ command: DashboardNativeCommand) {
guard let fallback = command.legacyFallbackEventName else {
self.webView.evaluateJavaScript(
"window.dispatchEvent(new CustomEvent(\(Self.jsStringLiteral(command.rawValue))))")
return
}
// Older gateway-served bundles predate the toggle event but handled ⌘K
// via page keydown, which the menu item now intercepts. A handler that
// knows the new event calls preventDefault; otherwise fall back to the
// legacy open-only event so ⌘K keeps working against old bundles.
self.webView.evaluateJavaScript(
"""
(() => {
const handled = !window.dispatchEvent(
new CustomEvent(\(Self.jsStringLiteral(command.rawValue)), {cancelable: true}));
if (!handled) {
window.dispatchEvent(new CustomEvent(\(Self.jsStringLiteral(fallback))));
}
})();
""")
}
private func flushPendingNativeCommands() {
let commands = self.pendingNativeCommands
self.pendingNativeCommands = []
for command in commands {
self.evaluateNativeCommand(command)
}
}
}
/// WKNavigationDelegate policy lives in an extension to keep the class
/// body inside the swiftlint type_body_length budget.
extension DashboardWindowController {
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
{
let isDashboardWebView = webView === self.webView
let isLinkBrowserWebView = self.linkBrowser.owns(webView)
guard isDashboardWebView || isLinkBrowserWebView else {
decisionHandler(.cancel)
return
}
guard let url = navigationAction.request.url else {
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, in: webView)
}
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,
isMainFrame: navigationAction.targetFrame?.isMainFrame == true,
isTrustedDashboardSource: navigationAction.sourceFrame.isMainFrame &&
Self.isTrustedLinkSource(
navigationAction.sourceFrame.request.url,
dashboardURL: self.currentURL))
{
decisionHandler(.allow)
return
}
// Back/forward can reach entries from a previous gateway endpoint after
// a tunnel/port swap; opening those externally would launch a dead URL
// in the browser, so swallow the traversal instead.
if navigationAction.navigationType == .backForward {
decisionHandler(.cancel)
return
}
if Self.shouldOpenExternalDashboardNavigation(
url,
navigationType: navigationAction.navigationType,
buttonNumber: navigationAction.buttonNumber)
{
self.openExternal(url)
}
decisionHandler(.cancel)
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
if self.linkBrowser.owns(webView) {
self.linkBrowser.navigationDidStart(navigation, in: webView)
self.linkBrowser.updateChrome()
}
}
/// The displayed document is replaced at commit, not at provisional start.
/// Clearing here covers page/WebKit-initiated main-frame navigations that
/// never pass through `load(_:)`, so commands queue for the new document.
func webView(_ webView: WKWebView, didCommit _: WKNavigation!) {
guard webView === self.webView else { return }
self.hasLiveContent = false
// Swipe-back/⌘[ can leave the failure page through WKWebView history
// without a `load(_:)`; a committed http(s) document is a real
// dashboard again (the failure page itself commits as about:blank).
if webView.url?.scheme?.lowercased().hasPrefix("http") == true {
self.isShowingFailurePage = false
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
if self.linkBrowser.owns(webView) {
self.linkBrowser.navigationDidFinish(navigation, for: webView)
} else if webView === self.webView {
guard !self.isShowingFailurePage else { return }
self.hasLiveContent = true
self.publishNativeHistoryState()
// History state must reach the shell before a queued command can navigate it.
self.flushPendingNativeCommands()
}
}
func webView(_ webView: WKWebView, didFail _: WKNavigation!, withError error: Error) {
if self.linkBrowser.owns(webView) {
self.linkBrowser.navigationDidFail(for: webView)
return
}
guard webView === self.webView else { return }
self.showLoadFailure(error)
}
func webView(
_ webView: WKWebView,
didFailProvisionalNavigation _: WKNavigation!,
withError error: Error)
{
if self.linkBrowser.owns(webView) {
self.linkBrowser.navigationDidFail(for: webView)
return
}
guard webView === self.webView else { return }
self.showLoadFailure(error)
}
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)
}
}
}
#if DEBUG
extension DashboardWindowController {
var _testUserScripts: [WKUserScript] {
self.webView.configuration.userContentController.userScripts
}
var _testUpdateBridgeAvailable: Bool {
self.updateBridgeEnabled
}
var _testLinkBrowserIsCollapsed: Bool {
self.linkBrowserItem.isCollapsed
}
var _testLinkBrowserDataStore: WKWebsiteDataStore {
// Prefer the active tab's configured store so tests catch a tab that
// was built with the wrong (non-shared) data store.
self.linkBrowser.activeWebView?.configuration.websiteDataStore
?? self.linkBrowser._testWebsiteDataStore
}
var _testLinkBrowserRepresentedURL: URL? {
self.linkBrowser._testRepresentedURL
}
var _testLinkBrowserNavigationObservationCount: Int {
self.linkBrowser._testNavigationObservationCount
}
var _testLinkBrowserWebViewIdentity: ObjectIdentifier? {
self.linkBrowser.activeWebView.map(ObjectIdentifier.init)
}
var _testLinkBrowserWebViewURL: URL? {
self.linkBrowser.activeWebView?.url
}
var _testLinkBrowserHistoryIsEmpty: Bool {
guard let history = self.linkBrowser.activeWebView?.backForwardList else { return true }
return history.currentItem == nil && history.backItem == nil && history.forwardItem == nil
}
var _testLinkBrowserDelegatesAreInstalled: Bool {
guard let webView = self.linkBrowser.activeWebView else { return false }
return webView.navigationDelegate === self && webView.uiDelegate === self
}
var _testLinkBrowserWebViewIsInstalled: Bool {
self.linkBrowser.activeWebView?.superview === self.linkBrowser
}
var _testDashboardDataStore: WKWebsiteDataStore {
self.webView.configuration.websiteDataStore
}
var _testCanOpenWindowsAutomatically: Bool {
self.webView.configuration.preferences.javaScriptCanOpenWindowsAutomatically ||
self.linkBrowser._testAllWebViews.contains {
$0.configuration.preferences.javaScriptCanOpenWindowsAutomatically
}
}
var _testLinkBrowserWidth: CGFloat {
self.linkBrowser.frame.width
}
var _testLinkBrowserSplitWidth: CGFloat {
let splitView = self.splitViewController.splitView
// Hosted runners can constrain the visible window while content and split
// bounds still reflect the requested size. The window frame is authoritative.
return self.window?.frame.width ?? splitView.bounds.width
}
var _testLinkBrowserDividerThickness: CGFloat {
self.splitViewController.splitView.dividerThickness
}
var _testLinkBrowserMaximumThickness: CGFloat {
self.linkBrowserItem.maximumThickness
}
var _testLinkBrowserTabBarIsHidden: Bool {
self.linkBrowser._testTabBarIsHidden
}
var _testLinkBrowserToolbarHeight: CGFloat {
self.linkBrowser._testToolbarHeight
}
var _testLinkBrowserTabBarHeight: CGFloat {
self.linkBrowser._testTabBarHeight
}
func _testCompleteLinkBrowserDividerDrag() {
self.linkBrowserSplitView._testCompleteDividerDrag()
}
func _testOpenLinkBrowser(_ url: URL, requestBrowserProfileImportOffer: Bool = false) {
self.openLinkBrowser(url, requestBrowserProfileImportOffer: requestBrowserProfileImportOffer)
}
func _testCloseLinkBrowser() {
self.closeLinkBrowser()
}
var _testLinkBrowserTabCount: Int {
self.linkBrowser._testTabCount
}
var _testLinkBrowserTabURLs: [URL?] {
self.linkBrowser._testTabURLs
}
var _testLinkBrowserActiveTabIndex: Int? {
self.linkBrowser._testActiveTabIndex
}
func _testLinkBrowserOpenInNewTab(_ url: URL) {
self.linkBrowser.openInNewTab(url)
}
func _testLinkBrowserCloseTab(at index: Int) {
self.linkBrowser._testCloseTab(at: index)
}
func _testLinkBrowserMoveTab(from fromIndex: Int, to toIndex: Int) {
self.linkBrowser._testMoveTab(from: fromIndex, to: toIndex)
}
func _testLinkBrowserSelectTab(at index: Int) {
self.linkBrowser._testSelectTab(at: index)
}
func _testLinkBrowserContextMenu(forTabAt index: Int) -> NSMenu? {
self.linkBrowser._testContextMenu(forTabAt: index)
}
var _testAllowsBackForwardGestures: Bool {
self.webView.allowsBackForwardNavigationGestures
}
var _testPendingNativeCommands: [DashboardNativeCommand] {
self.pendingNativeCommands
}
var _testNavigationWebViewIdentity: ObjectIdentifier {
ObjectIdentifier(self.activeNavigationWebView)
}
var _testDashboardWebViewIdentity: ObjectIdentifier {
ObjectIdentifier(self.webView)
}
func _testFocusLinkBrowser() -> Bool {
guard let webView = self.linkBrowser.activeWebView else { return false }
return self.window?.makeFirstResponder(webView) == true
}
static func _testJavaScriptConfirmAlert(message: String, host: String?) -> NSAlert {
self.makeJavaScriptConfirmAlert(message: message, host: host)
}
static func _testJavaScriptConfirmResult(for response: NSApplication.ModalResponse) -> Bool {
self.javaScriptConfirmResult(for: response)
}
}
#endif