mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(macos): keep dashboard failures in window
This commit is contained in:
@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected, and keep the Settings sidebar toggle in the leading titlebar area.
|
||||
- Mac app: align the Sessions settings pane with the standard Settings page gutter and row spacing.
|
||||
- Codex app-server: preserve streamed native command output in mirrored transcripts and trajectory exports when final snapshots omit aggregated output. (#83200) Thanks @rozmiarD.
|
||||
- Codex app-server: fail closed when chat or sender policy denies tools, disabling native code, app, environment, and user MCP surfaces for restricted turns. (#82374) Thanks @VACInc.
|
||||
|
||||
@@ -10,6 +10,7 @@ final class DashboardManager {
|
||||
static let shared = DashboardManager()
|
||||
|
||||
private var controller: DashboardWindowController?
|
||||
private static let failureURL = URL(string: "about:blank")!
|
||||
|
||||
private init() {}
|
||||
|
||||
@@ -69,6 +70,19 @@ final class DashboardManager {
|
||||
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
|
||||
}
|
||||
|
||||
func showFailure(_ error: Error) {
|
||||
let message = (error as NSError).localizedDescription
|
||||
dashboardManagerLogger.error("dashboard setup failed error=\(message, privacy: .public)")
|
||||
let controller = self.controller ?? DashboardWindowController(
|
||||
url: Self.failureURL,
|
||||
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
|
||||
self.controller = controller
|
||||
controller.showFailure(
|
||||
title: "Dashboard unavailable",
|
||||
message: message,
|
||||
detail: "Check Settings → Connection or use Debug → Reset Remote Tunnel, then try again.")
|
||||
}
|
||||
|
||||
func close() {
|
||||
self.controller?.closeDashboard()
|
||||
}
|
||||
|
||||
@@ -80,6 +80,17 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
self.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.refreshNativeAuthScript(url: self.currentURL, auth: self.auth)
|
||||
self.webView.stopLoading()
|
||||
self.webView.loadHTMLString(
|
||||
Self.failureHTML(title: title, message: message, detail: detail, url: nil),
|
||||
baseURL: nil)
|
||||
self.show()
|
||||
}
|
||||
|
||||
private func load(_ url: URL) {
|
||||
dashboardWindowLogger.debug("dashboard load \(url.absoluteString, privacy: .public)")
|
||||
self.webView.load(URLRequest(url: url))
|
||||
@@ -282,54 +293,107 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return }
|
||||
dashboardWindowLogger.error(
|
||||
"dashboard load failed url=\(self.currentURL.absoluteString, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
let html = Self.failureHTML(url: self.currentURL, message: error.localizedDescription)
|
||||
let html = Self.failureHTML(
|
||||
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)
|
||||
}
|
||||
|
||||
private static func failureHTML(url: URL, message: String) -> String {
|
||||
"""
|
||||
private static func failureHTML(title: String, message: String, detail: String?, url: URL?) -> String {
|
||||
let detailHTML = detail.map { "<p class=\"detail\">\(self.htmlEscape($0))</p>" } ?? ""
|
||||
let urlHTML = url.map { "<code>\(self.htmlEscape($0.absoluteString))</code>" } ?? ""
|
||||
return """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: Canvas;
|
||||
color: CanvasText;
|
||||
font: -apple-system-body;
|
||||
background: #101114;
|
||||
color: rgba(255,255,255,.92);
|
||||
font: 15px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
||||
}
|
||||
main {
|
||||
width: min(520px, calc(100vw - 64px));
|
||||
line-height: 1.4;
|
||||
width: min(540px, calc(100vw - 72px));
|
||||
padding: 34px;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
border-radius: 22px;
|
||||
background: rgba(255,255,255,.035);
|
||||
box-shadow: 0 28px 90px rgba(0,0,0,.36);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.badge {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,.07);
|
||||
color: #ff746b;
|
||||
font-size: 24px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
font: -apple-system-title2;
|
||||
font-weight: 650;
|
||||
margin: 0 0 12px;
|
||||
font-size: 24px;
|
||||
line-height: 1.16;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: rgba(255,255,255,.76);
|
||||
font-size: 16px;
|
||||
}
|
||||
.detail {
|
||||
margin-top: 14px;
|
||||
color: rgba(255,255,255,.56);
|
||||
font-size: 13px;
|
||||
}
|
||||
p { margin: 8px 0; color: color-mix(in srgb, CanvasText 72%, transparent); }
|
||||
code {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
margin-top: 18px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, CanvasText 8%, transparent);
|
||||
color: CanvasText;
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 10px;
|
||||
background: rgba(0,0,0,.26);
|
||||
color: rgba(255,255,255,.76);
|
||||
overflow-wrap: anywhere;
|
||||
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
body { background: #f5f6f8; color: rgba(0,0,0,.86); }
|
||||
main {
|
||||
background: rgba(255,255,255,.84);
|
||||
border-color: rgba(0,0,0,.1);
|
||||
box-shadow: 0 28px 90px rgba(0,0,0,.12);
|
||||
}
|
||||
.badge { background: rgba(0,0,0,.06); }
|
||||
p { color: rgba(0,0,0,.68); }
|
||||
.detail { color: rgba(0,0,0,.54); }
|
||||
code {
|
||||
background: rgba(0,0,0,.05);
|
||||
border-color: rgba(0,0,0,.08);
|
||||
color: rgba(0,0,0,.68);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Dashboard unavailable</h1>
|
||||
<div class="badge">!</div>
|
||||
<h1>\(self.htmlEscape(title))</h1>
|
||||
<p>\(self.htmlEscape(message))</p>
|
||||
<code>\(self.htmlEscape(url.absoluteString))</code>
|
||||
\(detailHTML)
|
||||
\(urlHTML)
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -184,7 +184,7 @@ final class DeepLinkHandler {
|
||||
do {
|
||||
try await DashboardManager.shared.show()
|
||||
} catch {
|
||||
self.presentAlert(title: "Dashboard unavailable", message: error.localizedDescription)
|
||||
DashboardManager.shared.showFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,8 +91,11 @@ struct OpenClawApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) {
|
||||
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
||||
private func applyStatusItemAppearance(paused _: Bool, sleeping _: Bool) {
|
||||
// Keep the status item actionable even when the Gateway is paused or disconnected.
|
||||
// The SwiftUI label already renders those states; AppKit's disabled appearance can
|
||||
// leak into menu item validation and grey out app-level commands like Settings.
|
||||
self.statusItem?.button?.appearsDisabled = false
|
||||
}
|
||||
|
||||
private static func applyAttachOnlyOverrideIfNeeded() {
|
||||
@@ -180,10 +183,7 @@ struct OpenClawApp: App {
|
||||
do {
|
||||
try await DashboardManager.shared.show()
|
||||
} catch {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Dashboard unavailable"
|
||||
alert.informativeText = error.localizedDescription
|
||||
alert.runModal()
|
||||
DashboardManager.shared.showFailure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,10 +302,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
do {
|
||||
try await DashboardManager.shared.show()
|
||||
} catch {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Dashboard unavailable"
|
||||
alert.informativeText = error.localizedDescription
|
||||
alert.runModal()
|
||||
DashboardManager.shared.showFailure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,17 @@ struct SettingsRootView: View {
|
||||
}
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.toolbar(removing: .sidebarToggle)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigation) {
|
||||
Button {
|
||||
NSApp.sendAction(#selector(NSSplitViewController.toggleSidebar(_:)), to: nil, from: nil)
|
||||
} label: {
|
||||
Image(systemName: "sidebar.left")
|
||||
}
|
||||
.help("Show or hide sidebar")
|
||||
}
|
||||
}
|
||||
.background(SettingsWindowChromeConfigurator())
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openclawSelectSettingsTab)) { note in
|
||||
if let tab = note.object as? SettingsTab {
|
||||
|
||||
@@ -37,4 +37,18 @@ struct DashboardWindowSmokeTests {
|
||||
let url = try #require(URL(string: "http://[fd12:3456:789a::1]:18789/control/"))
|
||||
#expect(DashboardWindowController.originString(for: url) == "http://[fd12:3456:789a::1]:18789")
|
||||
}
|
||||
|
||||
@Test func `dashboard failure state opens in dashboard window`() throws {
|
||||
let url = try #require(URL(string: "http://127.0.0.1:18789/control/"))
|
||||
let controller = DashboardWindowController(
|
||||
url: url,
|
||||
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
|
||||
controller.showFailure(
|
||||
title: "Dashboard unavailable",
|
||||
message: "Remote control tunnel failed",
|
||||
detail: "Reset the remote tunnel and try again.")
|
||||
#expect(controller.window?.isVisible == true)
|
||||
#expect(controller.window?.styleMask.contains(.closable) == true)
|
||||
controller.closeDashboard()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user