import AppKit import Foundation import WebKit private final class DashboardWindowContentView: NSView { override var mouseDownCanMoveWindow: Bool { true } } private final class DashboardWindowDragRegionView: NSView { override var mouseDownCanMoveWindow: Bool { true } override func mouseDown(with event: NSEvent) { 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 private final class DashboardWindowDragMessageHandler: NSObject, WKScriptMessageHandler { weak var owner: DashboardWindowController? func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { self.owner?.receiveWindowDragMessage(message) } } @MainActor private final class DashboardNavMessageHandler: NSObject, WKScriptMessageHandler { weak var owner: DashboardWindowController? func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { self.owner?.receiveNavMessage(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 navMessageHandlerName = "openclawNav" private static let updateMessageHandlerName = "openclawUpdate" private let webView: WKWebView private let linkBrowser: DashboardLinkBrowserView private let linkBrowserItem: NSSplitViewItem 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 var backButton: NSButton? private var forwardButton: NSButton? private var navAccessory: DashboardNavAccessoryView? private var canGoBackObservation: NSKeyValueObservation? private var canGoForwardObservation: NSKeyValueObservation? init( url: URL, auth: DashboardWindowAuth, updater: UpdaterProviding? = nil, updateBridgeEnabled: Bool = true) { let shouldEnableUpdateBridge = updater?.isAvailable == true && updateBridgeEnabled self.currentURL = url self.auth = auth self.updater = updater self.updateBridgeEnabled = shouldEnableUpdateBridge 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 navMessageHandler = DashboardNavMessageHandler() config.userContentController.add(navMessageHandler, name: Self.navMessageHandlerName) 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 = WKWebView( frame: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize), configuration: config) self.webView.setValue(true, forKey: "drawsBackground") // The Control UI routes via pushState, so WKWebView's back-forward list // carries in-app navigation; without this (and the titlebar buttons // below) the dashboard window has no way back. self.webView.allowsBackForwardNavigationGestures = true 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 = 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.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 windowDragMessageHandler.owner = self navMessageHandler.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.window?.delegate = self self.installNavigationControls() } 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) } } // MARK: - WKUIDelegate /// Bridges `` 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 } @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() } /// 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) { self.currentURL = url self.auth = auth self.refreshNativeAuthScript(url: url, auth: auth) if let updateBridgeEnabled { self.setUpdateBridgeEnabled(updateBridgeEnabled) } self.load(url) } /// 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 } 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 showFailure(title: String, message: String, detail: String? = nil) { // Failure pages and older gateway bundles do not report nav state; keep // the shipped toggle/back/forward layout until a trusted report arrives. self.applyNavAccessoryState(.legacy) 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 may load an older web bundle, so each main-frame load // starts from the shipped layout rather than retaining stale web state. self.applyNavAccessoryState(.legacy) dashboardWindowLogger.debug("dashboard load \(dashboardLogString(for: url), privacy: .public)") self.webView.load(URLRequest(url: url)) } private func openLinkBrowser(_ url: URL) { self.linkBrowserItem.isCollapsed = false self.linkBrowser.open(url) window?.makeFirstResponder(self.linkBrowser.activeWebView) } 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 receiveNavMessage(_ message: WKScriptMessage) { guard message.name == Self.navMessageHandlerName, message.webView === self.webView, message.frameInfo.isMainFrame, Self.isTrustedLinkSource(message.frameInfo.request.url, dashboardURL: self.currentURL), let request = DashboardNavStateMessage.parse(message.body) else { return } self.applyNavAccessoryState( request.collapsed ? .collapsed : .expanded(sidebarWidth: request.width)) } 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) } /// Sidebar toggle plus back/forward buttons next to the traffic lights /// (Safari's ordering). The window has no native toolbar (full-size content /// view with the web UI's own chrome), so a leading titlebar accessory is /// the only native slot for them. private func installNavigationControls() { guard let window = self.window else { return } let sidebar = Self.makeNavigationButton( symbolName: "sidebar.leading", label: "Toggle Sidebar", action: #selector(self.toggleNavigationSidebar(_:)), target: self) // Unlike back/forward there is no readiness state to observe; the web // UI ignores the toggle event on surfaces without a collapsible nav. sidebar.isEnabled = true let search = Self.makeNavigationButton( symbolName: "magnifyingglass", label: "Search", action: #selector(self.openNativeSearch(_:)), target: self) search.isEnabled = true let newSession = Self.makeNavigationButton( symbolName: "plus", label: "New Session", action: #selector(self.openNativeNewSession(_:)), target: self) newSession.isEnabled = true let back = Self.makeNavigationButton( symbolName: "chevron.left", label: "Back", action: #selector(self.navigateBack(_:)), target: self) let forward = Self.makeNavigationButton( symbolName: "chevron.right", label: "Forward", action: #selector(self.navigateForward(_:)), target: self) self.backButton = back self.forwardButton = forward // Compact frame: 12 leading inset + three ~27pt buttons + two 12pt // gaps. Expanded state stretches this frame to the web sidebar edge. let container = DashboardNavAccessoryView( toggleButton: sidebar, searchButton: search, newSessionButton: newSession, backButton: back, forwardButton: forward) self.navAccessory = container let accessory = NSTitlebarAccessoryViewController() accessory.view = container accessory.layoutAttribute = .leading window.addTitlebarAccessoryViewController(accessory) self.canGoBackObservation = self.webView.observe(\.canGoBack, options: [ .initial, .new, ]) { [weak self] _, change in guard let canGoBack = change.newValue else { return } Task { @MainActor in self?.backButton?.isEnabled = canGoBack } } self.canGoForwardObservation = self.webView.observe(\.canGoForward, options: [ .initial, .new, ]) { [weak self] _, change in guard let canGoForward = change.newValue else { return } Task { @MainActor in self?.forwardButton?.isEnabled = canGoForward } } } private static func makeNavigationButton( symbolName: String, label: String, action: Selector, target: AnyObject) -> NSButton { let button = NSButton() button.bezelStyle = .accessoryBarAction button.isBordered = false button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: label)? .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)) button.imagePosition = .imageOnly button.contentTintColor = .secondaryLabelColor button.target = target button.action = action button.toolTip = label button.setAccessibilityLabel(label) button.isEnabled = false return button } func navigateBack() { self.activeNavigationWebView.goBack() } func navigateForward() { self.activeNavigationWebView.goForward() } @objc private func navigateBack(_: Any?) { self.webView.goBack() } @objc private func navigateForward(_: Any?) { self.webView.goForward() } /// Named to avoid AppKit's standard `toggleSidebar(_:)` responder action, /// which would otherwise reach the split view controller and collapse the /// native link-browser pane instead of the web UI's navigation sidebar. @objc private func toggleNavigationSidebar(_: Any?) { self.webView.evaluateJavaScript( "window.dispatchEvent(new CustomEvent('openclaw:native-toggle-sidebar'))") } @objc private func openNativeSearch(_: Any?) { self.webView.evaluateJavaScript( "window.dispatchEvent(new CustomEvent('openclaw:native-open-search'))") } @objc private func openNativeNewSession(_: Any?) { self.webView.evaluateJavaScript( "window.dispatchEvent(new CustomEvent('openclaw:native-new-session'))") } private func applyNavAccessoryState(_ state: DashboardNavAccessoryState) { self.navAccessory?.apply(state, windowWidth: self.window?.frame.width) } 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 = NSWindow( 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) let sidebarDragRegion = DashboardWindowDragRegionView() sidebarDragRegion.translatesAutoresizingMaskIntoConstraints = false container.addSubview(sidebarDragRegion) 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 sidebar region // below stays the primary drag surface — it floats over the 50px // strip the native chrome CSS reserves in the web sidebar. At // narrow widths the compact drawer topbar keeps x 78-254 passive // (its brand strip), so the region stays click-safe there too. 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), sidebarDragRegion.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 78), sidebarDragRegion.topAnchor.constraint(equalTo: container.topAnchor), sidebarDragRegion.widthAnchor.constraint(equalToConstant: 176), sidebarDragRegion.heightAnchor.constraint(equalToConstant: 46), ]) window.title = "OpenClaw" window.titleVisibility = .hidden window.titlebarAppearsTransparent = true 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 WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize) return window } private static func installNativeChromeScript(into userContentController: WKUserContentController) { // 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 { --openclaw-native-titlebar-height: 50px; } @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)); // openclaw-native-nav advertises the titlebar sidebar toggle so a // matching Control UI hides its in-page expand/collapse buttons; // older web bundles ignore the class and keep their own controls. document.documentElement.classList.add("openclaw-native-macos", "openclaw-native-nav"); 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) -> 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 } return url.scheme?.lowercased() == dashboardURL.scheme?.lowercased() && url.host?.lowercased() == dashboardURL.host?.lowercased() && 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 } 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) } func windowDidResize(_: Notification) { guard let navAccessory else { return } navAccessory.apply(navAccessory.state, windowWidth: self.window?.frame.width) } private func showLoadFailure(_ error: Error) { let nsError = error as NSError if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return } dashboardWindowLogger.error( """ dashboard load failed url=\(dashboardLogString(for: self.currentURL), privacy: .public) \ error=\(error.localizedDescription, privacy: .public) """) self.applyNavAccessoryState(.legacy) 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) } } /// 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) { 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() } } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { if self.linkBrowser.owns(webView) { self.linkBrowser.navigationDidFinish(navigation, for: webView) } } 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 _testSplitAutosaveName: String? { self.splitViewController.splitView.autosaveName } func _testOpenLinkBrowser(_ url: URL) { self.openLinkBrowser(url) } 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 _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 } } #endif