Skip to content

Commit 5b383af

Browse files
committed
feat: add native mac dashboard window
1 parent 21244d9 commit 5b383af

52 files changed

Lines changed: 1851 additions & 163 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
99
- Security/audit: add `security.audit.suppressions` for intentionally accepted audit findings, keeping suppressed matches out of the active summary while preserving them in JSON output with an active suppression notice. (#76949) Thanks @100menotu001.
1010
- Agents/subagents: label delegated task and subagent completion handoffs as ready for parent review, and tell requester agents to review/verify results before calling them done. (#78985) Thanks @100menotu001.
1111
- Control UI: show provider quota usage in the Overview card and Chat header, and recover stale Chat in-progress state after missed terminal events. (#82647)
12+
- Mac app remote setup can now be preconfigured from `openclaw-mac configure-remote`, skips onboarding when config is already complete, supports direct LAN/Tailnet gateway URLs, allows private same-origin Control UI loads, and owns the SSH tunnel process when SSH is selected.
1213
- Providers/xAI: add xAI Grok OAuth login for SuperGrok subscribers, letting `xai/*` models and xAI media/tool providers authenticate without `XAI_API_KEY`.
1314
- CLI/cron: add `openclaw cron run --wait` with timeout and poll interval controls, plus exact `cron.runs --run-id` filtering so automation can block on one queued manual run. (#81929) Thanks @ificator.
1415
- Maintainer tooling: route Crabbox skill defaults through the repo brokered AWS config, leaving Blacksmith Testbox as an explicit opt-in instead of the broad-proof default.
@@ -87,6 +88,7 @@ Docs: https://docs.openclaw.ai
8788
- GitHub Copilot: route device-login requests through the plugin SSRF guard with a GitHub-only policy.
8889
- Group/channel replies: keep message-tool-preferred final replies private when the agent misses the message tool, and log suppressed payload metadata in the gateway debug log for quieter diagnosis.
8990
- Gateway/WebChat: route image attachments through a configured vision-capable `imageModel` plan before inlining images, and carry that image-model fallback chain through runtime retries. (#82524) Thanks @frankekn.
91+
- macOS app: open the Dashboard in a native WebKit window with standard macOS traffic-light controls, keep the Dock icon visible by default, and reuse the app's connected gateway auth for automatic Control UI login.
9092
- WebChat: show progress while manual `/compact` is running by streaming a session operation event to subscribed Control UI clients. Fixes #82407. Thanks @Conan-Scott.
9193
- Codex app-server: limit canonical OpenAI Codex app-server attribution rewrites to local transcript and trajectory records, leaving runtime/tool routing on the selected OpenAI model metadata so OpenAI API-key backup profiles keep their billing path.
9294
- Codex app-server: hide native tool-search control tools from dynamic tool exposure while preserving the message tool.
16.6 KB
Loading

apps/macos/Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ let package = Package(
8181
dependencies: [
8282
"OpenClawIPC",
8383
"OpenClaw",
84+
"OpenClawMacCLI",
8485
"OpenClawDiscovery",
8586
.product(name: "OpenClawProtocol", package: "OpenClawKit"),
8687
.product(name: "SwabbleKit", package: "swabble"),

apps/macos/Sources/OpenClaw/AppState.swift

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,12 @@ final class AppState {
318318
self.iconAnimationsEnabled = true
319319
UserDefaults.standard.set(true, forKey: iconAnimationsEnabledKey)
320320
}
321-
self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey)
321+
if let storedShowDockIcon = UserDefaults.standard.object(forKey: showDockIconKey) as? Bool {
322+
self.showDockIcon = storedShowDockIcon
323+
} else {
324+
self.showDockIcon = true
325+
UserDefaults.standard.set(true, forKey: showDockIconKey)
326+
}
322327
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
323328
self.voiceWakeMicName = UserDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? ""
324329
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
@@ -365,8 +370,16 @@ final class AppState {
365370
self.remoteTransport = configRemoteTransport
366371
self.connectionMode = resolvedConnectionMode
367372

373+
let configRemote = (configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]
374+
let configRemoteTarget = (configRemote?["sshTarget"] as? String)?
375+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
368376
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
369377
if resolvedConnectionMode == .remote,
378+
!configRemoteTarget.isEmpty,
379+
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
380+
{
381+
self.remoteTarget = configRemoteTarget
382+
} else if resolvedConnectionMode == .remote,
370383
configRemoteTransport != .direct,
371384
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
372385
let host = AppState.remoteHost(from: configRemoteUrl),
@@ -380,9 +393,11 @@ final class AppState {
380393
self.remoteToken = configRemoteToken.textFieldValue
381394
self.remoteTokenDirty = false
382395
self.remoteTokenUnsupported = configRemoteToken.isUnsupportedNonString
383-
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
384-
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
385-
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
396+
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey)?.nonEmpty
397+
?? configRemote?["sshIdentity"] as? String
398+
?? ""
399+
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey)?.nonEmpty ?? ""
400+
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey)?.nonEmpty ?? ""
386401
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
387402
let execDefaults = ExecApprovalsStore.resolveDefaults()
388403
self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask)

apps/macos/Sources/OpenClaw/CommandResolver.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -426,10 +426,15 @@ enum CommandResolver {
426426
{
427427
let root = configRoot ?? OpenClawConfigFile.loadDict()
428428
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
429-
let target = defaults.string(forKey: remoteTargetKey) ?? ""
430-
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
431-
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
432-
let cliPath = defaults.string(forKey: remoteCliPathKey) ?? ""
429+
let remote = (root["gateway"] as? [String: Any])?["remote"] as? [String: Any]
430+
let target = defaults.string(forKey: remoteTargetKey)?.nonEmpty
431+
?? remote?["sshTarget"] as? String
432+
?? ""
433+
let identity = defaults.string(forKey: remoteIdentityKey)?.nonEmpty
434+
?? remote?["sshIdentity"] as? String
435+
?? ""
436+
let projectRoot = defaults.string(forKey: remoteProjectRootKey)?.nonEmpty ?? ""
437+
let cliPath = defaults.string(forKey: remoteCliPathKey)?.nonEmpty ?? ""
433438
return RemoteSettings(
434439
mode: mode,
435440
target: self.sanitizedTarget(target),

apps/macos/Sources/OpenClaw/ControlChannel.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,12 @@ final class ControlChannel {
240240
case .timedOut:
241241
return "Gateway request timed out; check gateway on localhost:\(port)."
242242
case .notConnectedToInternet:
243+
if Self.isLikelyLocalNetworkPermissionBlock() {
244+
return """
245+
macOS is blocking OpenClaw Local Network access.
246+
Allow OpenClaw in System Settings → Privacy & Security → Local Network, then relaunch the app.
247+
"""
248+
}
243249
return "No network connectivity; cannot reach gateway."
244250
default:
245251
break
@@ -257,6 +263,21 @@ final class ControlChannel {
257263
return "Gateway error: \(trimmed)"
258264
}
259265

266+
private static func isLikelyLocalNetworkPermissionBlock() -> Bool {
267+
let root = OpenClawConfigFile.loadDict()
268+
guard ConnectionModeResolver.resolve(root: root).mode == .remote,
269+
GatewayRemoteConfig.resolveTransport(root: root) == .direct,
270+
let url = GatewayRemoteConfig.resolveGatewayUrl(root: root),
271+
url.scheme?.lowercased() == "ws",
272+
let host = url.host,
273+
GatewayRemoteConfig.isTrustedPlaintextRemoteHost(host),
274+
!LoopbackHost.isLoopbackHost(host)
275+
else {
276+
return false
277+
}
278+
return true
279+
}
280+
260281
private func scheduleRecovery(reason: String) {
261282
let now = Date()
262283
if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return }
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import AppKit
2+
import Foundation
3+
import OpenClawKit
4+
import OSLog
5+
6+
private let dashboardManagerLogger = Logger(subsystem: "ai.openclaw", category: "DashboardManager")
7+
8+
@MainActor
9+
final class DashboardManager {
10+
static let shared = DashboardManager()
11+
12+
private var controller: DashboardWindowController?
13+
14+
private init() {}
15+
16+
@discardableResult
17+
func showConfiguredWindowIfPossible() -> Bool {
18+
let mode = AppStateStore.shared.connectionMode
19+
guard let config = self.immediateDashboardConfig(mode: mode),
20+
let url = try? GatewayEndpointStore.dashboardURL(
21+
for: config,
22+
mode: mode,
23+
authToken: config.token)
24+
else {
25+
return false
26+
}
27+
let auth = DashboardWindowAuth(
28+
gatewayUrl: Self.websocketURLString(for: url),
29+
token: config.token,
30+
password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
31+
guard auth.hasCredential else {
32+
return false
33+
}
34+
if let controller {
35+
controller.show(url: url, auth: auth)
36+
} else {
37+
let controller = DashboardWindowController(url: url, auth: auth)
38+
self.controller = controller
39+
controller.show(url: url, auth: auth)
40+
}
41+
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
42+
return true
43+
}
44+
45+
func show() async throws {
46+
let mode = AppStateStore.shared.connectionMode
47+
dashboardManagerLogger.info("dashboard show requested mode=\(String(describing: mode), privacy: .public)")
48+
let config = try await self.dashboardConfig(mode: mode)
49+
dashboardManagerLogger.info("dashboard config url=\(config.url.absoluteString, privacy: .public)")
50+
let token = await GatewayConnection.shared.controlUiAutoAuthToken(config: config)
51+
let url = try GatewayEndpointStore.dashboardURL(for: config, mode: mode, authToken: token)
52+
let auth = DashboardWindowAuth(
53+
gatewayUrl: Self.websocketURLString(for: url),
54+
token: token,
55+
password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
56+
57+
if let controller {
58+
dashboardManagerLogger.info("dashboard reuse window url=\(url.absoluteString, privacy: .public)")
59+
controller.show(url: url, auth: auth)
60+
return
61+
}
62+
63+
dashboardManagerLogger.info("dashboard create window url=\(url.absoluteString, privacy: .public)")
64+
let controller = DashboardWindowController(url: url, auth: auth)
65+
self.controller = controller
66+
controller.show(url: url, auth: auth)
67+
68+
// Refresh the cached hello payload without blocking window creation.
69+
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
70+
}
71+
72+
func close() {
73+
self.controller?.closeDashboard()
74+
}
75+
76+
private static func websocketURLString(for dashboardURL: URL) -> String {
77+
guard var components = URLComponents(url: dashboardURL, resolvingAgainstBaseURL: false) else {
78+
return dashboardURL.absoluteString
79+
}
80+
switch components.scheme?.lowercased() {
81+
case "https":
82+
components.scheme = "wss"
83+
default:
84+
components.scheme = "ws"
85+
}
86+
components.queryItems = nil
87+
components.fragment = nil
88+
return components.url?.absoluteString ?? dashboardURL.absoluteString
89+
}
90+
91+
private func dashboardConfig(mode: AppState.ConnectionMode) async throws -> GatewayConnection.Config {
92+
if let config = self.immediateDashboardConfig(mode: mode) {
93+
return config
94+
}
95+
96+
return try await Task.detached(priority: .userInitiated) {
97+
await GatewayEndpointStore.shared.refresh()
98+
return try await GatewayEndpointStore.shared.requireConfig()
99+
}.value
100+
}
101+
102+
private func immediateDashboardConfig(mode: AppState.ConnectionMode) -> GatewayConnection.Config? {
103+
let root = OpenClawConfigFile.loadDict()
104+
if mode == .remote,
105+
GatewayRemoteConfig.resolveTransport(root: root) == .direct,
106+
let url = GatewayRemoteConfig.resolveGatewayUrl(root: root)
107+
{
108+
return (
109+
url,
110+
GatewayRemoteConfig.resolveTokenString(root: root),
111+
GatewayRemoteConfig.resolvePasswordString(root: root))
112+
}
113+
114+
if mode == .local {
115+
return GatewayEndpointStore.localConfig()
116+
}
117+
118+
return nil
119+
}
120+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import AppKit
2+
import Foundation
3+
import OSLog
4+
5+
let dashboardWindowLogger = Logger(subsystem: "ai.openclaw", category: "DashboardWindow")
6+
7+
enum DashboardWindowLayout {
8+
static let windowSize = NSSize(width: 1240, height: 860)
9+
static let windowMinSize = NSSize(width: 900, height: 620)
10+
}
11+
12+
struct DashboardWindowAuth: Equatable {
13+
var gatewayUrl: String?
14+
var token: String?
15+
var password: String?
16+
17+
var hasCredential: Bool {
18+
self.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ||
19+
self.password?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
20+
}
21+
}

0 commit comments

Comments
 (0)