mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
* 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
1416 lines
58 KiB
Swift
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
|