mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 04:56:03 +00:00
* fix(macos): center dashboard traffic lights with the hosted titlebar buttons Give the dashboard window an empty unified toolbar so its titlebar grows to 52pt, vertically centering the macOS traffic lights against the row of web-hosted titlebar buttons (sidebar toggle, back/forward) instead of hugging the top edge. Bump the injected --openclaw-native-titlebar-height to match, and nudge the hosted controls' left offset from 78px to 92px so the traffic-light cluster and the first web button read as separate groups. The toolbar exists only to size the titlebar, so View > Hide Toolbar (and the corresponding shortcut) are refused; hiding it would collapse the titlebar while the web inset stays pinned at 52pt, resurrecting the misalignment. Smoke tests cover toolbar presence/style, hide refusal, and the exact injected height. * chore(i18n): resync native inventory line metadata
1045 lines
42 KiB
Swift
1045 lines
42 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)
|
|
}
|
|
}
|
|
|
|
@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"
|
|
|
|
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 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 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; the web titlebar buttons use this list.
|
|
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
|
|
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.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)
|
|
}
|
|
}
|
|
|
|
// MARK: - WKUIDelegate
|
|
|
|
/// 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
|
|
}
|
|
|
|
@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) {
|
|
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) {
|
|
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 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
|
|
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) -> 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)
|
|
}
|
|
|
|
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)
|
|
""")
|
|
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)
|
|
} else if webView === self.webView {
|
|
self.publishNativeHistoryState()
|
|
}
|
|
}
|
|
|
|
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
|