Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,36 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "openclaw-mac.png",
"name" : "openclaw-mac",
"position" : {
"scale" : 1.07,
"translation-in-points" : [
-2,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

114
apps/macos/Package.resolved Normal file
View File

@@ -0,0 +1,114 @@
{
"originHash" : "4f7b315ce0e0a16d150d8d74dce445628c03d8926485ad2f5595e091b4d33440",
"pins" : [
{
"identity" : "axorcist",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/AXorcist.git",
"state" : {
"revision" : "289d403c53ce26cdd2fcb2bfb8672cd43adc2b43",
"version" : "0.1.2"
}
},
{
"identity" : "commander",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git",
"state" : {
"revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b",
"version" : "0.2.2"
}
},
{
"identity" : "elevenlabskit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/ElevenLabsKit",
"state" : {
"revision" : "0f1e4c039bd0e22b03c0cb7f43c00c1865858f0b",
"version" : "0.1.1"
}
},
{
"identity" : "menubarextraaccess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/orchetect/MenuBarExtraAccess",
"state" : {
"revision" : "33bb0e4b1e407feac791e047dcaaf9c69b25fd26",
"version" : "1.3.0"
}
},
{
"identity" : "peekaboo",
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Peekaboo.git",
"state" : {
"revision" : "1fa8eead7eeac3ff618a3111fc333ae78db043d2",
"version" : "3.5.2"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
"version" : "2.9.3"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms",
"state" : {
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
"version" : "1.2.1"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a",
"version" : "1.13.2"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics.git",
"state" : {
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
"version" : "1.1.1"
}
},
{
"identity" : "swift-subprocess",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-subprocess.git",
"state" : {
"revision" : "13d087685b95d64d6aac9b94500d347bbe84c39b",
"version" : "0.4.0"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system",
"state" : {
"revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
"version" : "1.6.4"
}
}
],
"version" : 3
}

96
apps/macos/Package.swift Normal file
View File

@@ -0,0 +1,96 @@
// swift-tools-version: 6.2
// Package manifest for the OpenClaw macOS companion (menu bar app + IPC library).
import PackageDescription
let package = Package(
name: "OpenClaw",
platforms: [
.macOS(.v15),
],
products: [
.library(name: "OpenClawIPC", targets: ["OpenClawIPC"]),
.library(name: "OpenClawDiscovery", targets: ["OpenClawDiscovery"]),
.executable(name: "OpenClaw", targets: ["OpenClaw"]),
.executable(name: "openclaw-mac", targets: ["OpenClawMacCLI"]),
],
dependencies: [
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.3.0"),
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.5.2"),
.package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.3.1"),
.package(path: "../shared/OpenClawKit"),
.package(path: "../swabble"),
],
targets: [
.target(
name: "OpenClawIPC",
dependencies: [],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.target(
name: "OpenClawDiscovery",
dependencies: [
.product(name: "OpenClawKit", package: "OpenClawKit"),
],
path: "Sources/OpenClawDiscovery",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "OpenClaw",
dependencies: [
"OpenClawIPC",
"OpenClawDiscovery",
.product(name: "OpenClawKit", package: "OpenClawKit"),
.product(name: "OpenClawChatUI", package: "OpenClawKit"),
.product(name: "OpenClawProtocol", package: "OpenClawKit"),
.product(name: "SwabbleKit", package: "swabble"),
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
.product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Logging", package: "swift-log"),
.product(name: "Sparkle", package: "Sparkle"),
.product(name: "PeekabooBridge", package: "Peekaboo"),
.product(name: "PeekabooAutomationKit", package: "Peekaboo"),
.product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"),
],
exclude: [
"Resources/Info.plist",
"Resources/Localizable.xcstrings",
],
resources: [
.copy("Resources/OpenClaw.icns"),
.copy("Resources/DeviceModels"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.executableTarget(
name: "OpenClawMacCLI",
dependencies: [
"OpenClawDiscovery",
.product(name: "OpenClawKit", package: "OpenClawKit"),
.product(name: "OpenClawProtocol", package: "OpenClawKit"),
],
path: "Sources/OpenClawMacCLI",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.testTarget(
name: "OpenClawIPCTests",
dependencies: [
"OpenClawIPC",
"OpenClaw",
"OpenClawMacCLI",
"OpenClawDiscovery",
.product(name: "OpenClawProtocol", package: "OpenClawKit"),
.product(name: "SwabbleKit", package: "swabble"),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),
]),
])

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

64
apps/macos/README.md Normal file
View File

@@ -0,0 +1,64 @@
# OpenClaw macOS app (dev + signing)
## Quick dev run
```bash
# from repo root
scripts/restart-mac.sh
```
Options:
```bash
scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick)
scripts/restart-mac.sh --sign # force code signing (requires cert)
```
## Packaging flow
```bash
scripts/package-mac-app.sh
```
Creates `dist/OpenClaw.app` and signs it via `scripts/codesign-mac-app.sh`.
## Signing behavior
Auto-selects identity (first match):
1) Developer ID Application
2) Apple Distribution
3) Apple Development
4) first available identity
If none found:
- errors by default
- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign
## Team ID audit (Sparkle mismatch guard)
After signing, we read the app bundle Team ID and compare every Mach-O inside the app.
If any embedded binary has a different Team ID, signing fails.
Skip the audit:
```bash
SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh
```
## Library validation workaround (dev only)
If Sparkle Team ID mismatch blocks loading (common with Apple Development certs), opt in:
```bash
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh
```
This adds `com.apple.security.cs.disable-library-validation` to app entitlements.
Use for local dev only; keep off for release builds.
## Useful env flags
- `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"`
- `ALLOW_ADHOC_SIGNING=1` (ad-hoc, TCC permissions do not persist)
- `CODESIGN_TIMESTAMP=off` (offline debug)
- `DISABLE_LIBRARY_VALIDATION=1` (dev-only Sparkle workaround)
- `SKIP_TEAM_ID_CHECK=1` (bypass audit)

View File

@@ -0,0 +1,203 @@
import OpenClawChatUI
import SwiftUI
struct AboutSettings: View {
weak var updater: UpdaterProviding?
@Environment(\.colorScheme) private var colorScheme
@State private var iconHover = false
@AppStorage("autoUpdateEnabled") private var autoCheckEnabled = true
@State private var didLoadUpdaterState = false
var body: some View {
VStack(spacing: 8) {
Button {
if let url = URL(string: "https://github.com/openclaw/openclaw") {
NSWorkspace.shared.open(url)
}
} label: {
// Hero treatment from openclaw.ai: coral silhouette glow at 10% of
// size, teal glow at 15% plus scale 1.1 on hover.
OpenClawMascotView()
.frame(width: 160, height: 160)
.shadow(
color: OpenClawMascotView.heroGlowColor(
for: self.colorScheme,
hovering: self.iconHover),
radius: self.iconHover ? 24 : 16)
.scaleEffect(self.iconHover ? 1.1 : 1.0)
}
.buttonStyle(.plain)
.accessibilityLabel("OpenClaw on GitHub")
.focusable(false)
.pointingHandCursor()
.onHover { hover in
withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover }
}
VStack(spacing: 3) {
Text("OpenClaw")
.font(.title3.bold())
Text("Version \(self.versionString)")
.foregroundStyle(.secondary)
if let buildTimestamp {
Text("Built \(buildTimestamp)\(self.buildSuffix)")
.font(.footnote)
.foregroundStyle(.secondary)
}
Text("Menu bar companion for notifications, screenshots, and privileged agent actions.")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 18)
}
VStack(alignment: .center, spacing: 6) {
AboutLinkRow(
icon: "chevron.left.slash.chevron.right",
title: "GitHub",
url: "https://github.com/openclaw/openclaw")
AboutLinkRow(icon: "globe", title: "Website", url: "https://openclaw.ai")
AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete")
AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me")
}
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.padding(.vertical, 10)
if let updater {
Divider()
.padding(.vertical, 8)
if updater.isAvailable {
VStack(spacing: 10) {
Toggle("Check for updates automatically", isOn: self.$autoCheckEnabled)
.toggleStyle(.checkbox)
.frame(maxWidth: .infinity, alignment: .center)
Button("Check for Updates…") { updater.checkForUpdates(nil) }
}
} else {
Text("Updates unavailable in this build.")
.foregroundStyle(.secondary)
.padding(.top, 4)
}
}
Text("© 2026 OpenClaw Foundation — MIT License.")
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.top, 4)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.settingsDetailContent()
.onAppear {
guard let updater, !self.didLoadUpdaterState else { return }
// Keep Sparkles auto-check setting in sync with the persisted toggle.
updater.automaticallyChecksForUpdates = self.autoCheckEnabled
updater.automaticallyDownloadsUpdates = self.autoCheckEnabled
self.didLoadUpdaterState = true
}
.onChange(of: self.autoCheckEnabled) { _, newValue in
self.updater?.automaticallyChecksForUpdates = newValue
self.updater?.automaticallyDownloadsUpdates = newValue
}
}
private var versionString: String {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev"
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
return build.map { "\(version) (\($0))" } ?? version
}
private var buildTimestamp: String? {
guard
let raw =
(Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) ??
(Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String)
else { return nil }
let parser = ISO8601DateFormatter()
parser.formatOptions = [.withInternetDateTime]
guard let date = parser.date(from: raw) else { return raw }
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
formatter.locale = .current
return formatter.string(from: date)
}
private var gitCommit: String {
(Bundle.main.object(forInfoDictionaryKey: "OpenClawGitCommit") as? String) ??
(Bundle.main.object(forInfoDictionaryKey: "OpenClawGitCommit") as? String) ??
"unknown"
}
private var bundleID: String {
Bundle.main.bundleIdentifier ?? "unknown"
}
private var buildSuffix: String {
let git = self.gitCommit
guard !git.isEmpty, git != "unknown" else { return "" }
var suffix = " (\(git)"
#if DEBUG
suffix += " DEBUG"
#endif
suffix += ")"
return suffix
}
}
@MainActor
private struct AboutLinkRow: View {
let icon: String
let title: String
let url: String
@State private var hovering = false
var body: some View {
Button {
if let url = URL(string: url) { NSWorkspace.shared.open(url) }
} label: {
HStack(spacing: 6) {
Image(systemName: self.icon)
Text(self.title)
.underline(self.hovering, color: .accentColor)
}
.foregroundColor(.accentColor)
}
.buttonStyle(.plain)
.onHover { self.hovering = $0 }
.pointingHandCursor()
}
}
private struct AboutMetaRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(self.label)
.foregroundStyle(.secondary)
Spacer()
Text(self.value)
.font(.caption.monospaced())
.foregroundStyle(.primary)
}
}
}
#if DEBUG
struct AboutSettings_Previews: PreviewProvider {
private static let updater = DisabledUpdaterController()
static var previews: some View {
AboutSettings(updater: updater)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif

View File

@@ -0,0 +1,17 @@
import Foundation
/// Human-friendly age string (e.g., "2m ago").
func age(from date: Date, now: Date = .init()) -> String {
let seconds = max(0, Int(now.timeIntervalSince(date)))
let minutes = seconds / 60
let hours = minutes / 60
let days = hours / 24
if seconds < 60 { return "just now" }
if minutes == 1 { return "1 minute ago" }
if minutes < 60 { return "\(minutes)m ago" }
if hours == 1 { return "1 hour ago" }
if hours < 24 { return "\(hours)h ago" }
if days == 1 { return "yesterday" }
return "\(days)d ago"
}

View File

@@ -0,0 +1,22 @@
import Foundation
import Observation
@MainActor
@Observable
final class AgentEventStore {
static let shared = AgentEventStore()
private(set) var events: [ControlAgentEvent] = []
private let maxEvents = 400
func append(_ event: ControlAgentEvent) {
self.events.append(event)
if self.events.count > self.maxEvents {
self.events.removeFirst(self.events.count - self.maxEvents)
}
}
func clear() {
self.events.removeAll()
}
}

View File

@@ -0,0 +1,109 @@
import OpenClawProtocol
import SwiftUI
@MainActor
struct AgentEventsWindow: View {
private let store = AgentEventStore.shared
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Agent Events")
.font(.title3.weight(.semibold))
Spacer()
Button("Clear") { self.store.clear() }
.buttonStyle(.bordered)
}
.padding(.bottom, 4)
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(self.store.events.reversed(), id: \.seq) { evt in
EventRow(event: evt)
}
}
}
}
.padding(12)
.frame(minWidth: 520, minHeight: 360)
}
}
private struct EventRow: View {
let event: ControlAgentEvent
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(self.event.stream.uppercased())
.font(.caption2.weight(.bold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(self.tint)
.foregroundStyle(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
Text("run " + self.event.runId)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
Spacer()
Text(self.formattedTs)
.font(.caption2)
.foregroundStyle(.secondary)
}
if let json = self.prettyJSON(event.data) {
Text(json)
.font(.caption.monospaced())
.foregroundStyle(.primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.primary.opacity(0.04)))
}
private var tint: Color {
switch self.event.stream {
case "job": .blue
case "tool": .orange
case "assistant": .green
default: .gray
}
}
private var formattedTs: String {
let date = Date(timeIntervalSince1970: event.ts / 1000)
let f = DateFormatter()
f.dateFormat = "HH:mm:ss.SSS"
return f.string(from: date)
}
private func prettyJSON(_ dict: [String: OpenClawProtocol.AnyCodable]) -> String? {
let normalized = dict.mapValues { $0.value }
guard JSONSerialization.isValidJSONObject(normalized),
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
let str = String(data: data, encoding: .utf8)
else { return nil }
return str
}
}
struct AgentEventsWindow_Previews: PreviewProvider {
static var previews: some View {
let sample = ControlAgentEvent(
runId: "abc",
seq: 1,
stream: "tool",
ts: Date().timeIntervalSince1970 * 1000,
data: [
"phase": OpenClawProtocol.AnyCodable("start"),
"name": OpenClawProtocol.AnyCodable("bash"),
],
summary: nil)
AgentEventStore.shared.append(sample)
return AgentEventsWindow()
}
}

View File

@@ -0,0 +1,343 @@
import Foundation
import OSLog
enum AgentWorkspace {
private static let logger = Logger(subsystem: "ai.openclaw", category: "workspace")
static let agentsFilename = "AGENTS.md"
static let soulFilename = "SOUL.md"
static let identityFilename = "IDENTITY.md"
static let userFilename = "USER.md"
static let bootstrapFilename = "BOOTSTRAP.md"
private static let templateDirname = "templates"
private static let ignoredEntries: Set<String> = [".DS_Store", ".git", ".gitignore"]
private static let templateEntries: Set<String> = [
AgentWorkspace.agentsFilename,
AgentWorkspace.soulFilename,
AgentWorkspace.identityFilename,
AgentWorkspace.userFilename,
AgentWorkspace.bootstrapFilename,
]
struct BootstrapSafety: Equatable {
let unsafeReason: String?
static let safe = Self(unsafeReason: nil)
static func blocked(_ reason: String) -> Self {
Self(unsafeReason: reason)
}
}
static func displayPath(for url: URL) -> String {
let home = FileManager().homeDirectoryForCurrentUser.path
let path = url.path
if path == home { return "~" }
if path.hasPrefix(home + "/") {
return "~/" + String(path.dropFirst(home.count + 1))
}
return path
}
static func resolveWorkspaceURL(from userInput: String?) -> URL {
let trimmed = userInput?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty { return OpenClawConfigFile.defaultWorkspaceURL() }
let expanded = (trimmed as NSString).expandingTildeInPath
return URL(fileURLWithPath: expanded, isDirectory: true)
}
static func agentsURL(workspaceURL: URL) -> URL {
workspaceURL.appendingPathComponent(self.agentsFilename)
}
static func workspaceEntries(workspaceURL: URL) throws -> [String] {
let contents = try FileManager().contentsOfDirectory(atPath: workspaceURL.path)
return contents.filter { !self.ignoredEntries.contains($0) }
}
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
let fm = FileManager()
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true
}
guard isDir.boolValue else { return false }
guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false }
return entries.isEmpty
}
static func isTemplateOnlyWorkspace(workspaceURL: URL) -> Bool {
guard let entries = try? self.workspaceEntries(workspaceURL: workspaceURL) else { return false }
guard !entries.isEmpty else { return true }
return Set(entries).isSubset(of: self.templateEntries)
}
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
let fm = FileManager()
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return .safe
}
if !isDir.boolValue { return .blocked("Workspace path points to a file.") }
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
if fm.fileExists(atPath: agentsURL.path) {
return .safe
}
do {
let entries = try self.workspaceEntries(workspaceURL: workspaceURL)
return entries.isEmpty
? .safe
: .blocked("Folder isn't empty. Choose a new folder or add AGENTS.md first.")
} catch {
return .blocked("Couldn't inspect the workspace folder.")
}
}
static func bootstrap(workspaceURL: URL) throws -> URL {
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
try FileManager().createDirectory(at: workspaceURL, withIntermediateDirectories: true)
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
if !FileManager().fileExists(atPath: agentsURL.path) {
try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8)
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
}
let soulURL = workspaceURL.appendingPathComponent(self.soulFilename)
if !FileManager().fileExists(atPath: soulURL.path) {
try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8)
self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)")
}
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
if !FileManager().fileExists(atPath: identityURL.path) {
try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8)
self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)")
}
let userURL = workspaceURL.appendingPathComponent(self.userFilename)
if !FileManager().fileExists(atPath: userURL.path) {
try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8)
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
}
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
if shouldSeedBootstrap, !FileManager().fileExists(atPath: bootstrapURL.path) {
try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8)
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
}
return agentsURL
}
static func needsBootstrap(workspaceURL: URL) -> Bool {
let fm = FileManager()
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true
}
guard isDir.boolValue else { return true }
if self.hasIdentity(workspaceURL: workspaceURL) {
return false
}
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
guard fm.fileExists(atPath: bootstrapURL.path) else { return false }
return self.isTemplateOnlyWorkspace(workspaceURL: workspaceURL)
}
static func hasIdentity(workspaceURL: URL) -> Bool {
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
guard let contents = try? String(contentsOf: identityURL, encoding: .utf8) else { return false }
return self.identityLinesHaveValues(contents)
}
private static func identityLinesHaveValues(_ content: String) -> Bool {
for line in content.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("-"), let colon = trimmed.firstIndex(of: ":") else { continue }
let value = trimmed[trimmed.index(after: colon)...].trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return true
}
}
return false
}
static func defaultTemplate() -> String {
let fallback = """
# AGENTS.md - OpenClaw Workspace
This folder is the assistant's working directory.
## First run (one-time)
- If BOOTSTRAP.md exists, follow its ritual and delete it once complete.
- Your agent identity lives in IDENTITY.md.
- Your profile lives in USER.md.
## Backup tip (recommended)
If you treat this workspace as the agent's "memory", make it a git repo (ideally private) so identity
and notes are backed up.
```bash
git init
git add AGENTS.md
git commit -m "Add agent workspace"
```
## Safety defaults
- Don't exfiltrate secrets or private data.
- Don't run destructive commands unless explicitly asked.
- Be concise in chat; write longer output to files in this workspace.
## Daily memory (recommended)
- Keep a short daily log at memory/YYYY-MM-DD.md (create memory/ if needed).
- On session start, read today + yesterday if present.
- Capture durable facts, preferences, and decisions; avoid secrets.
## Customize
- Add your preferred style, rules, and "memory" here.
"""
return self.loadTemplate(named: self.agentsFilename, fallback: fallback)
}
static func defaultSoulTemplate() -> String {
let fallback = """
# SOUL.md - Persona & Boundaries
Describe who the assistant is, tone, and boundaries.
- Keep replies concise and direct.
- Ask clarifying questions when needed.
- Never send streaming/partial replies to external messaging surfaces.
"""
return self.loadTemplate(named: self.soulFilename, fallback: fallback)
}
static func defaultIdentityTemplate() -> String {
let fallback = """
# IDENTITY.md - Agent Identity
- Name:
- Creature:
- Vibe:
- Emoji:
"""
return self.loadTemplate(named: self.identityFilename, fallback: fallback)
}
static func defaultUserTemplate() -> String {
let fallback = """
# USER.md - User Profile
- Name:
- Preferred address:
- Pronouns (optional):
- Timezone (optional):
- Notes:
"""
return self.loadTemplate(named: self.userFilename, fallback: fallback)
}
static func defaultBootstrapTemplate() -> String {
let fallback = """
# BOOTSTRAP.md - First Run Ritual (delete after)
Hello. I was just born.
## Your mission
Start a short, playful conversation and learn:
- Who am I?
- What am I?
- Who are you?
- How should I call you?
## How to ask (cute + helpful)
Say:
"Hello! I was just born. Who am I? What am I? Who are you? How should I call you?"
Then offer suggestions:
- 3-5 name ideas.
- 3-5 creature/vibe combos.
- 5 emoji ideas.
## Write these files
After the user chooses, update:
1) IDENTITY.md
- Name
- Creature
- Vibe
- Emoji
2) USER.md
- Name
- Preferred address
- Pronouns (optional)
- Timezone (optional)
- Notes
3) ~/.openclaw/openclaw.json
Set identity.name, identity.theme, identity.emoji to match IDENTITY.md.
## Cleanup
Delete BOOTSTRAP.md once this is complete.
"""
return self.loadTemplate(named: self.bootstrapFilename, fallback: fallback)
}
private static func loadTemplate(named: String, fallback: String) -> String {
for url in self.templateURLs(named: named) {
if let content = try? String(contentsOf: url, encoding: .utf8) {
let stripped = self.stripFrontMatter(content)
if !stripped.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return stripped
}
}
}
return fallback
}
private static func templateURLs(named: String) -> [URL] {
var urls: [URL] = []
if let resource = Bundle.main.url(
forResource: named.replacingOccurrences(of: ".md", with: ""),
withExtension: "md",
subdirectory: self.templateDirname)
{
urls.append(resource)
}
if let resource = Bundle.main.url(
forResource: named,
withExtension: nil,
subdirectory: self.templateDirname)
{
urls.append(resource)
}
if let dev = self.devTemplateURL(named: named) {
urls.append(dev)
}
let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath)
urls.append(cwd.appendingPathComponent("docs")
.appendingPathComponent(self.templateDirname)
.appendingPathComponent(named))
return urls
}
private static func devTemplateURL(named: String) -> URL? {
let sourceURL = URL(fileURLWithPath: #filePath)
let repoRoot = sourceURL
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
return repoRoot.appendingPathComponent("docs")
.appendingPathComponent(self.templateDirname)
.appendingPathComponent(named)
}
private static func stripFrontMatter(_ content: String) -> String {
guard content.hasPrefix("---") else { return content }
let start = content.index(content.startIndex, offsetBy: 3)
guard let range = content.range(of: "\n---", range: start..<content.endIndex) else {
return content
}
let remainder = content[range.upperBound...]
let trimmed = remainder.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed + "\n"
}
// Identity is written by the agent during the bootstrap ritual.
}

View File

@@ -0,0 +1,30 @@
import Foundation
enum AgentWorkspaceConfig {
static func workspace(from root: [String: Any]) -> String? {
let agents = root["agents"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any]
return defaults?["workspace"] as? String
}
static func setWorkspace(in root: inout [String: Any], workspace: String?) {
var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
defaults.removeValue(forKey: "workspace")
} else {
defaults["workspace"] = trimmed
}
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
}
}
}

View File

@@ -0,0 +1,6 @@
import Foundation
import OpenClawKit
// Prefer the OpenClawKit wrapper to keep gateway request payloads consistent.
typealias AnyCodable = OpenClawKit.AnyCodable
typealias InstanceIdentity = OpenClawKit.InstanceIdentity

View File

@@ -0,0 +1,49 @@
import AppKit
@MainActor
enum AppNavigationActions {
static func openDashboard() {
NSApp.activate(ignoringOtherApps: true)
if DashboardManager.shared.showConfiguredWindowIfPossible() {
return
}
Task { @MainActor in
if DashboardManager.shared.showConfiguredWindowIfPossible() {
return
}
do {
try await DashboardManager.shared.show()
} catch {
DashboardManager.shared.showFailure(error)
}
}
}
static func openChat() {
NSApp.activate(ignoringOtherApps: true)
Task { @MainActor in
let sessionKey = await WebChatManager.shared.preferredSessionKey()
WebChatManager.shared.show(sessionKey: sessionKey)
}
}
static func toggleCanvas() {
NSApp.activate(ignoringOtherApps: true)
Task { @MainActor in
if AppStateStore.shared.canvasPanelVisible {
CanvasManager.shared.hideAll()
} else {
let sessionKey = await GatewayConnection.shared.mainSessionKey()
_ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil)
}
}
}
static func openSettings(tab: SettingsTab = .general) {
SettingsTabRouter.request(tab)
SettingsWindowOpener.shared.open()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .openclawSelectSettingsTab, object: tab)
}
}
}

View File

@@ -0,0 +1,933 @@
import AppKit
import Foundation
import Observation
import OpenClawKit
import ServiceManagement
import SwiftUI
@MainActor
@Observable
final class AppState {
private static let logger = Logger(subsystem: "ai.openclaw", category: "app-state")
private let isPreview: Bool
private var isInitializing = true
private var isApplyingRemoteTokenConfig = false
private var configWatcher: ConfigFileWatcher?
private var suppressVoiceWakeGlobalSync = false
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
private func ifNotPreview(_ action: () -> Void) {
guard !self.isPreview else { return }
action()
}
enum ConnectionMode: String {
case unconfigured
case local
case remote
}
enum RemoteTransport: String {
case ssh
case direct
}
struct RemoteGatewayConfigDraft {
var transport: RemoteTransport
var remoteUrl: String
var remoteHost: String?
var remoteTarget: String
var remoteIdentity: String
var remoteToken: String
var remoteTokenDirty: Bool
}
struct GatewayConfigSyncDraft {
var connectionMode: ConnectionMode
var remoteTransport: RemoteTransport
var remoteTarget: String
var remoteIdentity: String
var remoteUrl: String
var remoteToken: String
var remoteTokenDirty: Bool
}
var isPaused: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
}
var launchAtLogin: Bool {
didSet {
guard !self.isInitializing else { return }
self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } }
}
}
var onboardingSeen: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: onboardingSeenKey) }
}
}
var debugPaneEnabled: Bool {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: debugPaneEnabledKey) }
CanvasManager.shared.refreshDebugStatus()
}
}
var swabbleEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey)
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
}
var swabbleTriggerWords: [String] {
didSet {
// Preserve the raw editing state; sanitization happens when we actually use the triggers.
self.ifNotPreview {
UserDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
self.scheduleVoiceWakeGlobalSyncIfNeeded()
}
}
}
var voiceWakeTriggerChime: VoiceWakeChime {
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeTriggerChime, key: voiceWakeTriggerChimeKey) } }
}
var voiceWakeSendChime: VoiceWakeChime {
didSet { self.ifNotPreview { self.storeChime(self.voiceWakeSendChime, key: voiceWakeSendChimeKey) } }
}
var iconAnimationsEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.iconAnimationsEnabled,
forKey: iconAnimationsEnabledKey) } }
}
var showDockIcon: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
AppActivationPolicy.apply(showDockIcon: self.showDockIcon)
}
}
}
var voiceWakeMicID: String {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
}
}
var voiceWakeMicName: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.voiceWakeMicName, forKey: voiceWakeMicNameKey) } }
}
var voiceWakeLocaleID: String {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
}
}
var voiceWakeAdditionalLocaleIDs: [String] {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.voiceWakeAdditionalLocaleIDs,
forKey: voiceWakeAdditionalLocalesKey) } }
}
var voicePushToTalkEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.voicePushToTalkEnabled,
forKey: voicePushToTalkEnabledKey) } }
}
var voiceWakeTriggersTalkMode: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeTriggersTalkMode, forKey: voiceWakeTriggersTalkModeKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
}
}
var voiceWakeMeterActive = false
var talkEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.talkEnabled, forKey: talkEnabledKey)
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
}
}
}
var talkPhaseSoundsEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.talkPhaseSoundsEnabled, forKey: talkPhaseSoundsEnabledKey)
}
}
}
var talkShiftToStopEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.talkShiftToStopEnabled, forKey: talkShiftToStopEnabledKey)
Task { TalkSpeechInterruptMonitor.shared.setEnabled(self.talkShiftToStopEnabled && self.talkEnabled) }
}
}
}
/// Gateway-provided UI accent color (hex). Optional; clients provide a default.
var seamColorHex: String?
var iconOverride: IconOverrideSelection {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
}
var isWorking: Bool = false
var earBoostActive: Bool = false
var blinkTick: Int = 0
var sendCelebrationTick: Int = 0
var heartbeatsEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
Task { _ = await GatewayConnection.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) }
}
}
}
var connectionMode: ConnectionMode {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
self.syncGatewayConfigIfNeeded()
}
}
var remoteTransport: RemoteTransport {
didSet { self.syncGatewayConfigIfNeeded() }
}
var canvasEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
var execApprovalMode: ExecApprovalQuickMode {
didSet {
self.ifNotPreview {
ExecApprovalsStore.updateDefaults { defaults in
defaults.security = self.execApprovalMode.security
defaults.ask = self.execApprovalMode.ask
}
}
}
}
/// Tracks whether the Canvas panel is currently visible (not persisted).
var canvasPanelVisible: Bool = false
var peekabooBridgeEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(self.peekabooBridgeEnabled) }
}
}
}
var remoteTarget: String {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
self.syncGatewayConfigIfNeeded()
}
}
var remoteUrl: String {
didSet { self.syncGatewayConfigIfNeeded() }
}
var remoteToken: String {
didSet {
guard !self.isApplyingRemoteTokenConfig else { return }
self.remoteTokenDirty = true
self.remoteTokenUnsupported = false
self.syncGatewayConfigIfNeeded()
}
}
private(set) var remoteTokenDirty = false
private(set) var remoteTokenUnsupported = false
var remoteIdentity: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
}
var remoteProjectRoot: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
}
var remoteCliPath: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } }
}
private var earBoostTask: Task<Void, Never>?
init(preview: Bool = false) {
let isPreview = preview || ProcessInfo.processInfo.isRunningTests
self.isPreview = isPreview
if !isPreview {
migrateLegacyDefaults()
}
let onboardingSeen = UserDefaults.standard.bool(forKey: onboardingSeenKey)
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
self.launchAtLogin = false
self.onboardingSeen = onboardingSeen
self.debugPaneEnabled = UserDefaults.standard.bool(forKey: debugPaneEnabledKey)
let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey)
self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false
self.swabbleTriggerWords = UserDefaults.standard
.stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers
self.voiceWakeTriggerChime = Self.loadChime(
key: voiceWakeTriggerChimeKey,
fallback: .system(name: "Glass"))
self.voiceWakeSendChime = Self.loadChime(
key: voiceWakeSendChimeKey,
fallback: .system(name: "Glass"))
if let storedIconAnimations = UserDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool {
self.iconAnimationsEnabled = storedIconAnimations
} else {
self.iconAnimationsEnabled = true
UserDefaults.standard.set(true, forKey: iconAnimationsEnabledKey)
}
if let storedShowDockIcon = UserDefaults.standard.object(forKey: showDockIconKey) as? Bool {
self.showDockIcon = storedShowDockIcon
} else {
self.showDockIcon = true
UserDefaults.standard.set(true, forKey: showDockIconKey)
}
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
self.voiceWakeMicName = UserDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? ""
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
self.voicePushToTalkEnabled = UserDefaults.standard
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
self.voiceWakeTriggersTalkMode = UserDefaults.standard
.object(forKey: voiceWakeTriggersTalkModeKey) as? Bool ?? false
self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey)
if let storedPhaseSounds = UserDefaults.standard.object(forKey: talkPhaseSoundsEnabledKey) as? Bool {
self.talkPhaseSoundsEnabled = storedPhaseSounds
} else {
self.talkPhaseSoundsEnabled = true
UserDefaults.standard.set(true, forKey: talkPhaseSoundsEnabledKey)
}
if let storedShiftToStop = UserDefaults.standard.object(forKey: talkShiftToStopEnabledKey) as? Bool {
self.talkShiftToStopEnabled = storedShiftToStop
} else {
self.talkShiftToStopEnabled = true
UserDefaults.standard.set(true, forKey: talkShiftToStopEnabledKey)
}
self.seamColorHex = nil
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
self.heartbeatsEnabled = storedHeartbeats
} else {
self.heartbeatsEnabled = true
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
}
if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey),
let selection = IconOverrideSelection(rawValue: storedOverride)
{
self.iconOverride = selection
} else {
self.iconOverride = .system
UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
}
let configRoot = OpenClawConfigFile.loadDict()
let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot)
let configRemoteResolution = GatewayRemoteConfig.resolveTransportResolution(root: configRoot)
let configRemoteTransport = configRemoteResolution.transport
let configRemoteUrl = configRemoteResolution.directURL?.absoluteString
?? GatewayRemoteConfig.resolveUrlString(root: configRoot)
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
self.remoteTransport = configRemoteTransport
self.connectionMode = resolvedConnectionMode
let configRemote = (configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]
let configRemoteTarget = (configRemote?["sshTarget"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
if resolvedConnectionMode == .remote,
!configRemoteTarget.isEmpty,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
self.remoteTarget = configRemoteTarget
} else if resolvedConnectionMode == .remote,
configRemoteTransport != .direct,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl),
!LoopbackHost.isLoopbackHost(host)
{
self.remoteTarget = "\(NSUserName())@\(host)"
} else {
self.remoteTarget = storedRemoteTarget
}
self.remoteUrl = configRemoteUrl ?? ""
self.remoteToken = configRemoteToken.textFieldValue
self.remoteTokenDirty = false
self.remoteTokenUnsupported = configRemoteToken.isUnsupportedNonString
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey)?.nonEmpty
?? configRemote?["sshIdentity"] as? String
?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey)?.nonEmpty ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey)?.nonEmpty ?? ""
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
let execDefaults = ExecApprovalsStore.resolveDefaults()
self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask)
self.peekabooBridgeEnabled = UserDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
if !self.isPreview {
Task.detached(priority: .utility) { [weak self] in
let current = await LaunchAgentManager.status()
await MainActor.run { [weak self] in self?.launchAtLogin = current }
}
}
if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
self.swabbleEnabled = false
}
if self.talkEnabled, !PermissionManager.voiceWakePermissionsGranted() {
self.talkEnabled = false
}
if !self.isPreview {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
}
self.isInitializing = false
if !self.isPreview {
self.startConfigWatcher()
}
}
@MainActor
deinit {
self.configWatcher?.stop()
}
private static func remoteHost(from urlString: String?) -> String? {
guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty,
let url = URL(string: raw),
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!host.isEmpty
else {
return nil
}
return host
}
private static func sanitizeSSHTarget(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("ssh ") {
return trimmed.replacingOccurrences(of: "ssh ", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return trimmed
}
private static func sshTunnelGatewayUrl(existingUrl: String?, expectedRemoteHost: String?) -> String {
let fallback = "ws://127.0.0.1:18789"
let trimmed = existingUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty,
let url = URL(string: trimmed),
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
!host.isEmpty
else {
return fallback
}
let preservePort: Bool = if LoopbackHost.isLoopbackHost(host) {
true
} else if let expectedRemoteHost {
OpenClawConfigFile.canonicalHostForComparison(host) ==
OpenClawConfigFile.canonicalHostForComparison(expectedRemoteHost)
} else {
false
}
guard preservePort else { return fallback }
return "ws://127.0.0.1:\(url.port ?? 18789)"
}
private static func updateGatewayString(
_ dictionary: inout [String: Any],
key: String,
value: String?) -> Bool
{
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
guard dictionary[key] != nil else { return false }
dictionary.removeValue(forKey: key)
return true
}
if (dictionary[key] as? String) != trimmed {
dictionary[key] = trimmed
return true
}
return false
}
private func applyRemoteTokenState(_ tokenValue: GatewayRemoteConfig.TokenValue) {
let nextToken = tokenValue.textFieldValue
let unsupported = tokenValue.isUnsupportedNonString
guard self.remoteToken != nextToken || self.remoteTokenDirty || self.remoteTokenUnsupported != unsupported
else {
return
}
self.isApplyingRemoteTokenConfig = true
self.remoteToken = nextToken
self.isApplyingRemoteTokenConfig = false
self.remoteTokenDirty = false
self.remoteTokenUnsupported = unsupported
}
private static func updatedRemoteGatewayConfig(
current: [String: Any],
draft: RemoteGatewayConfigDraft) -> (remote: [String: Any], changed: Bool)
{
var remote = current
var changed = false
switch draft.transport {
case .direct:
changed = Self.updateGatewayString(
&remote,
key: "transport",
value: RemoteTransport.direct.rawValue) || changed
let trimmedUrl = draft.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedUrl.isEmpty {
changed = Self.updateGatewayString(&remote, key: "url", value: nil) || changed
} else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) {
changed = Self.updateGatewayString(&remote, key: "url", value: normalizedUrl) || changed
}
case .ssh:
changed = Self.updateGatewayString(
&remote,
key: "transport",
value: RemoteTransport.ssh.rawValue) || changed
let existingTarget = Self.sanitizeSSHTarget(remote["sshTarget"] as? String ?? "")
let sanitizedTarget = Self.sanitizeSSHTarget(draft.remoteTarget)
let expectedRemoteHost = CommandResolver.parseSSHTarget(sanitizedTarget)?.host ?? draft.remoteHost
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let desiredUrl = Self.sshTunnelGatewayUrl(
existingUrl: existingUrl,
expectedRemoteHost: expectedRemoteHost)
changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed
changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed
changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: draft.remoteIdentity) || changed
if existingTarget != sanitizedTarget {
changed = Self.updateGatewayString(
&remote,
key: "sshHostKeyPolicy",
value: "strict") || changed
}
}
if draft.remoteTokenDirty {
changed = Self.updateGatewayString(&remote, key: "token", value: draft.remoteToken) || changed
}
return (remote, changed)
}
private func startConfigWatcher() {
let configUrl = OpenClawConfigFile.url()
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
Task { @MainActor in
self?.applyConfigFromDisk()
}
}
self.configWatcher?.start()
}
private func applyConfigFromDisk() {
let root = OpenClawConfigFile.loadDict()
self.applyConfigOverrides(root)
}
private func applyConfigOverrides(_ root: [String: Any]) {
let gateway = root["gateway"] as? [String: Any]
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
let remoteToken = GatewayRemoteConfig.resolveTokenValue(root: root)
let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let remoteResolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
let remoteTransport = remoteResolution.transport
let desiredMode: ConnectionMode? = switch modeRaw {
case "local":
.local
case "remote":
.remote
case "unconfigured":
.unconfigured
default:
nil
}
if let desiredMode {
if desiredMode != self.connectionMode {
self.connectionMode = desiredMode
}
} else if hasRemoteUrl, self.connectionMode != .remote {
self.connectionMode = .remote
}
if remoteTransport != self.remoteTransport {
self.remoteTransport = remoteTransport
}
let remoteUrlText = remoteResolution.directURL?.absoluteString ?? remoteUrl ?? ""
if remoteUrlText != self.remoteUrl {
self.remoteUrl = remoteUrlText
}
self.applyRemoteTokenState(remoteToken)
let targetMode = desiredMode ?? self.connectionMode
if targetMode == .remote,
remoteTransport != .direct,
let host = AppState.remoteHost(from: remoteUrl),
!LoopbackHost.isLoopbackHost(host)
{
self.updateRemoteTarget(host: host)
}
}
private func updateRemoteTarget(host: String) {
let trimmed = self.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
guard let parsed = CommandResolver.parseSSHTarget(trimmed) else { return }
let trimmedUser = parsed.user?.trimmingCharacters(in: .whitespacesAndNewlines)
let user = (trimmedUser?.isEmpty ?? true) ? nil : trimmedUser
let port = parsed.port
let assembled: String = if let user {
port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
} else {
port == 22 ? host : "\(host):\(port)"
}
if assembled != self.remoteTarget {
self.remoteTarget = assembled
}
}
private static func syncedGatewayRoot(
currentRoot: [String: Any],
draft: GatewayConfigSyncDraft) -> (root: [String: Any], changed: Bool)
{
var root = currentRoot
var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false
let desiredMode: String? = switch draft.connectionMode {
case .local:
"local"
case .remote:
"remote"
case .unconfigured:
nil
}
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let desiredMode {
if currentMode != desiredMode {
gateway["mode"] = desiredMode
changed = true
}
} else if currentMode != nil {
gateway.removeValue(forKey: "mode")
changed = true
}
if draft.connectionMode == .remote {
let remoteHost = CommandResolver.parseSSHTarget(draft.remoteTarget)?.host
let currentRemote = gateway["remote"] as? [String: Any] ?? [:]
let updated = Self.updatedRemoteGatewayConfig(
current: currentRemote,
draft: .init(
transport: draft.remoteTransport,
remoteUrl: draft.remoteUrl,
remoteHost: remoteHost,
remoteTarget: draft.remoteTarget,
remoteIdentity: draft.remoteIdentity,
remoteToken: draft.remoteToken,
remoteTokenDirty: draft.remoteTokenDirty))
if updated.changed {
gateway["remote"] = updated.remote
changed = true
}
}
guard changed else { return (currentRoot, false) }
if gateway.isEmpty {
root.removeValue(forKey: "gateway")
} else {
root["gateway"] = gateway
}
return (root, true)
}
private func syncGatewayConfigIfNeeded() {
guard !self.isPreview, !self.isInitializing else { return }
Task { @MainActor in
self.syncGatewayConfigNow()
}
}
@MainActor
func syncGatewayConfigNow() {
guard !self.isPreview, !self.isInitializing else { return }
// Keep app-only connection settings local to avoid overwriting remote gateway config.
let synced = Self.syncedGatewayRoot(
currentRoot: OpenClawConfigFile.loadDict(),
draft: .init(
connectionMode: self.connectionMode,
remoteTransport: self.remoteTransport,
remoteTarget: self.remoteTarget,
remoteIdentity: self.remoteIdentity,
remoteUrl: self.remoteUrl,
remoteToken: self.remoteToken,
remoteTokenDirty: self.remoteTokenDirty))
guard synced.changed else { return }
guard OpenClawConfigFile.saveDict(synced.root) else {
Self.logger.warning("gateway config sync rejected to protect persisted gateway auth/mode")
return
}
}
func triggerVoiceEars(ttl: TimeInterval? = 5) {
self.earBoostTask?.cancel()
self.earBoostActive = true
guard let ttl else { return }
self.earBoostTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(ttl * 1_000_000_000))
await MainActor.run { [weak self] in self?.earBoostActive = false }
}
}
func stopVoiceEars() {
self.earBoostTask?.cancel()
self.earBoostTask = nil
self.earBoostActive = false
}
func blinkOnce() {
self.blinkTick &+= 1
}
func celebrateSend() {
self.sendCelebrationTick &+= 1
}
func setVoiceWakeEnabled(_ enabled: Bool) async {
guard voiceWakeSupported else {
self.swabbleEnabled = false
return
}
self.swabbleEnabled = enabled
guard !self.isPreview else { return }
if !enabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
return
}
if PermissionManager.voiceWakePermissionsGranted() {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
return
}
let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true)
self.swabbleEnabled = granted
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
func setTalkEnabled(_ enabled: Bool) async {
guard voiceWakeSupported else {
self.talkEnabled = false
await GatewayConnection.shared.talkMode(enabled: false, phase: "disabled")
return
}
self.talkEnabled = enabled
guard !self.isPreview else { return }
if !enabled {
await GatewayConnection.shared.talkMode(enabled: false, phase: "disabled")
return
}
if PermissionManager.voiceWakePermissionsGranted() {
await GatewayConnection.shared.talkMode(enabled: true, phase: "enabled")
return
}
let granted = await PermissionManager.ensureVoiceWakePermissions(interactive: true)
self.talkEnabled = granted
await GatewayConnection.shared.talkMode(enabled: granted, phase: granted ? "enabled" : "denied")
}
// MARK: - Global wake words sync (Gateway-owned)
func applyGlobalVoiceWakeTriggers(_ triggers: [String]) {
self.suppressVoiceWakeGlobalSync = true
self.swabbleTriggerWords = triggers
self.suppressVoiceWakeGlobalSync = false
}
private func scheduleVoiceWakeGlobalSyncIfNeeded() {
guard !self.suppressVoiceWakeGlobalSync else { return }
let sanitized = sanitizeVoiceWakeTriggers(self.swabbleTriggerWords)
self.voiceWakeGlobalSyncTask?.cancel()
self.voiceWakeGlobalSyncTask = Task { [sanitized] in
try? await Task.sleep(nanoseconds: 650_000_000)
await GatewayConnection.shared.voiceWakeSetTriggers(sanitized)
}
}
func setWorking(_ working: Bool) {
self.isWorking = working
}
// MARK: - Chime persistence
private static func loadChime(key: String, fallback: VoiceWakeChime) -> VoiceWakeChime {
guard let data = UserDefaults.standard.data(forKey: key) else { return fallback }
if let decoded = try? JSONDecoder().decode(VoiceWakeChime.self, from: data) {
return decoded
}
return fallback
}
private func storeChime(_ chime: VoiceWakeChime, key: String) {
guard let data = try? JSONEncoder().encode(chime) else { return }
UserDefaults.standard.set(data, forKey: key)
}
}
extension AppState {
static var preview: AppState {
let state = AppState(preview: true)
state.isPaused = false
state.launchAtLogin = true
state.onboardingSeen = true
state.debugPaneEnabled = true
state.swabbleEnabled = true
state.swabbleTriggerWords = ["Claude", "Computer", "Jarvis"]
state.voiceWakeTriggerChime = .system(name: "Glass")
state.voiceWakeSendChime = .system(name: "Ping")
state.iconAnimationsEnabled = true
state.showDockIcon = true
state.voiceWakeMicID = "BuiltInMic"
state.voiceWakeMicName = "Built-in Microphone"
state.voiceWakeLocaleID = Locale.current.identifier
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
state.voicePushToTalkEnabled = false
state.talkEnabled = false
state.talkPhaseSoundsEnabled = true
state.talkShiftToStopEnabled = true
state.iconOverride = .system
state.heartbeatsEnabled = true
state.connectionMode = .local
state.remoteTransport = .ssh
state.canvasEnabled = true
state.remoteTarget = "user@example.com"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteToken = "example-token"
state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/openclaw"
state.remoteCliPath = ""
return state
}
}
#if DEBUG
@MainActor
extension AppState {
static func _testUpdatedRemoteGatewayConfig(
current: [String: Any],
draft: RemoteGatewayConfigDraft) -> [String: Any]
{
self.updatedRemoteGatewayConfig(
current: current,
draft: draft).remote
}
static func _testSyncedGatewayRoot(
currentRoot: [String: Any],
draft: GatewayConfigSyncDraft) -> [String: Any]
{
self.syncedGatewayRoot(
currentRoot: currentRoot,
draft: draft).root
}
}
#endif
@MainActor
enum AppStateStore {
static let shared = AppState()
static var isPausedFlag: Bool {
UserDefaults.standard.bool(forKey: pauseDefaultsKey)
}
static func updateLaunchAtLogin(enabled: Bool) {
Task.detached(priority: .utility) {
await LaunchAgentManager.set(enabled: enabled, bundlePath: Bundle.main.bundlePath)
}
}
static var canvasEnabled: Bool {
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
}
}
@MainActor
enum AppActivationPolicy {
static func apply(showDockIcon: Bool) {
_ = showDockIcon
DockIconManager.shared.updateDockVisibility()
}
}

View File

@@ -0,0 +1,216 @@
import CoreAudio
import Foundation
import OSLog
final class AudioInputDeviceObserver {
private let logger = Logger(subsystem: "ai.openclaw", category: "audio.devices")
private var isActive = false
private var devicesListener: AudioObjectPropertyListenerBlock?
private var defaultInputListener: AudioObjectPropertyListenerBlock?
static func defaultInputDeviceUID() -> String? {
guard let deviceID = self.defaultInputDeviceID() else { return nil }
return self.deviceUID(for: deviceID)
}
static func aliveInputDeviceUIDs() -> Set<String> {
let systemObject = AudioObjectID(kAudioObjectSystemObject)
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(systemObject, &address, 0, nil, &size)
guard status == noErr, size > 0 else { return [] }
let count = Int(size) / MemoryLayout<AudioObjectID>.size
var deviceIDs = [AudioObjectID](repeating: 0, count: count)
status = AudioObjectGetPropertyData(systemObject, &address, 0, nil, &size, &deviceIDs)
guard status == noErr else { return [] }
var output = Set<String>()
for deviceID in deviceIDs {
guard self.deviceIsAlive(deviceID) else { continue }
guard self.deviceHasInput(deviceID) else { continue }
if let uid = self.deviceUID(for: deviceID) {
output.insert(uid)
}
}
return output
}
/// Returns true when the system default input device exists and is alive with input channels.
/// Use this preflight before accessing `AVAudioEngine.inputNode` to avoid SIGABRT on Macs
/// without a built-in microphone (Mac mini, Mac Pro, Mac Studio) or when an external mic
/// is disconnected.
static func hasUsableDefaultInputDevice() -> Bool {
guard let uid = self.defaultInputDeviceUID() else { return false }
return self.aliveInputDeviceUIDs().contains(uid)
}
static func defaultInputDeviceSummary() -> String {
guard let deviceID = self.defaultInputDeviceID() else {
return "defaultInput=unknown"
}
let uid = self.deviceUID(for: deviceID) ?? "unknown"
let name = self.deviceName(for: deviceID) ?? "unknown"
return "defaultInput=\(name) (\(uid))"
}
private static func defaultInputDeviceID() -> AudioObjectID? {
let systemObject = AudioObjectID(kAudioObjectSystemObject)
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var deviceID = AudioObjectID(0)
var size = UInt32(MemoryLayout<AudioObjectID>.size)
let status = AudioObjectGetPropertyData(
systemObject,
&address,
0,
nil,
&size,
&deviceID)
guard status == noErr, deviceID != 0 else { return nil }
return deviceID
}
func start(onChange: @escaping @Sendable () -> Void) {
guard !self.isActive else { return }
self.isActive = true
let systemObject = AudioObjectID(kAudioObjectSystemObject)
let queue = DispatchQueue.main
var devicesAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
let devicesListener: AudioObjectPropertyListenerBlock = { _, _ in
self.logDefaultInputChange(reason: "devices")
onChange()
}
let devicesStatus = AudioObjectAddPropertyListenerBlock(
systemObject,
&devicesAddress,
queue,
devicesListener)
var defaultInputAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
let defaultInputListener: AudioObjectPropertyListenerBlock = { _, _ in
self.logDefaultInputChange(reason: "default")
onChange()
}
let defaultStatus = AudioObjectAddPropertyListenerBlock(
systemObject,
&defaultInputAddress,
queue,
defaultInputListener)
if devicesStatus != noErr || defaultStatus != noErr {
self.logger.error("audio device observer install failed devices=\(devicesStatus) default=\(defaultStatus)")
}
self.logger.info("audio device observer started (\(Self.defaultInputDeviceSummary(), privacy: .public))")
self.devicesListener = devicesListener
self.defaultInputListener = defaultInputListener
}
func stop() {
guard self.isActive else { return }
self.isActive = false
let systemObject = AudioObjectID(kAudioObjectSystemObject)
if let devicesListener {
var devicesAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
_ = AudioObjectRemovePropertyListenerBlock(
systemObject,
&devicesAddress,
DispatchQueue.main,
devicesListener)
}
if let defaultInputListener {
var defaultInputAddress = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
_ = AudioObjectRemovePropertyListenerBlock(
systemObject,
&defaultInputAddress,
DispatchQueue.main,
defaultInputListener)
}
self.devicesListener = nil
self.defaultInputListener = nil
}
private static func deviceUID(for deviceID: AudioObjectID) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceUID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var uid: Unmanaged<CFString>?
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &uid)
guard status == noErr, let uid else { return nil }
return uid.takeUnretainedValue() as String
}
private static func deviceName(for deviceID: AudioObjectID) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioObjectPropertyName,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var name: Unmanaged<CFString>?
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &name)
guard status == noErr, let name else { return nil }
return name.takeUnretainedValue() as String
}
private static func deviceIsAlive(_ deviceID: AudioObjectID) -> Bool {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceIsAlive,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var alive: UInt32 = 0
var size = UInt32(MemoryLayout<UInt32>.size)
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &alive)
return status == noErr && alive != 0
}
private static func deviceHasInput(_ deviceID: AudioObjectID) -> Bool {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioDevicePropertyScopeInput,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
var status = AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &size)
guard status == noErr, size > 0 else { return false }
let raw = UnsafeMutableRawPointer.allocate(
byteCount: Int(size),
alignment: MemoryLayout<AudioBufferList>.alignment)
defer { raw.deallocate() }
let bufferList = raw.bindMemory(to: AudioBufferList.self, capacity: 1)
status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, bufferList)
guard status == noErr else { return false }
let buffers = UnsafeMutableAudioBufferListPointer(bufferList)
return buffers.contains(where: { $0.mNumberChannels > 0 })
}
private func logDefaultInputChange(reason: StaticString) {
self.logger.info("audio input changed (\(reason)) (\(Self.defaultInputDeviceSummary(), privacy: .public))")
}
}

View File

@@ -0,0 +1,101 @@
import AppKit
import Foundation
import OSLog
@MainActor
final class CLIInstallPrompter {
static let shared = CLIInstallPrompter()
private let logger = Logger(subsystem: "ai.openclaw", category: "cli.prompt")
private var isPrompting = false
func checkAndPromptIfNeeded(reason: String) {
guard !self.isPrompting else { return }
self.isPrompting = true
Task { @MainActor in
await self.checkAndPromptIfNeededAsync(reason: reason)
self.isPrompting = false
}
}
private func checkAndPromptIfNeededAsync(reason: String) async {
guard AppStateStore.shared.onboardingSeen else { return }
guard AppStateStore.shared.connectionMode == .local else { return }
guard let version = Self.appVersion() else { return }
let status = await CLIInstaller.status()
guard AppStateStore.shared.onboardingSeen else { return }
guard AppStateStore.shared.connectionMode == .local else { return }
guard !status.isReady else { return }
let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
guard lastPrompt != version else { return }
UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
let alert = NSAlert()
alert.messageText = "Install OpenClaw CLI?"
alert.informativeText = "Local mode needs the CLI so launchd can run the gateway."
alert.addButton(withTitle: "Install CLI")
alert.addButton(withTitle: "Not now")
alert.addButton(withTitle: "Open Settings")
let response = alert.runModal()
switch response {
case .alertFirstButtonReturn:
Task { await self.installCLI() }
case .alertThirdButtonReturn:
self.openSettings(tab: .connection)
default:
break
}
self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)")
}
private func installCLI() async {
let status = StatusBox()
let installed = await CLIInstaller.install { message in
await status.set(message)
}
if installed {
await status.set("Starting OpenClaw Gateway…")
let activation = await CLIInstaller.activateLocalGateway()
let message = switch activation {
case .ready:
"OpenClaw Gateway is ready."
case .deferred:
"OpenClaw is installed. The Gateway will start when This Mac is active and resumed."
case .failed:
"OpenClaw was installed, but the Gateway did not start. Open Settings to retry."
}
await status.set(message)
}
if let message = await status.get() {
let alert = NSAlert()
alert.messageText = "CLI install finished"
alert.informativeText = message
alert.runModal()
}
}
private func openSettings(tab: SettingsTab) {
SettingsTabRouter.request(tab)
SettingsWindowOpener.shared.open()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .openclawSelectSettingsTab, object: tab)
}
}
private static func appVersion() -> String? {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
}
}
private actor StatusBox {
private var value: String?
func set(_ value: String) {
self.value = value
}
func get() -> String? {
self.value
}
}

View File

@@ -0,0 +1,296 @@
import Foundation
@MainActor
enum CLIInstaller {
enum LocalGatewayActivation: Equatable {
case ready
case deferred
case failed
}
enum Status: Equatable {
case ready(location: String, version: String)
case missing(location: String)
case unusable(location: String)
case incompatible(location: String, found: String, required: String)
var isReady: Bool {
if case .ready = self { return true }
return false
}
var location: String {
switch self {
case let .ready(location, _),
let .missing(location),
let .unusable(location),
let .incompatible(location, _, _):
location
}
}
var message: String {
switch self {
case let .ready(_, version):
"OpenClaw Gateway \(version) is ready."
case .missing:
"OpenClaw Gateway is not installed yet."
case .unusable:
"The OpenClaw Gateway could not be verified. Setup will repair it."
case let .incompatible(_, found, required):
"Gateway \(found) does not match app \(required). Setup will update it."
}
}
}
static func installedLocation() -> String? {
self.installedLocations(
searchPaths: CommandResolver.preferredPaths(),
fileManager: .default).first
}
static func installedLocation(
searchPaths: [String],
fileManager: FileManager) -> String?
{
self.installedLocations(searchPaths: searchPaths, fileManager: fileManager).first
}
static func installedLocations(
searchPaths: [String],
fileManager: FileManager) -> [String]
{
var locations: [String] = []
for basePath in searchPaths {
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("openclaw").path
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
!isDirectory.boolValue
else {
continue
}
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
locations.append(candidate)
}
return locations
}
static func isInstalled() -> Bool {
self.installedLocation() != nil
}
static func managedExecutableLocation() -> String {
URL(fileURLWithPath: self.installPrefix())
.appendingPathComponent("bin/openclaw")
.path
}
static func status() async -> Status {
let locations = self.installedLocations(
searchPaths: CommandResolver.preferredPaths(),
fileManager: .default)
guard !locations.isEmpty else {
return .missing(location: self.managedExecutableLocation())
}
var fallbackStatus: Status?
for location in locations {
let status = await self.status(location: location)
if status.isReady {
self.rememberValidated(status)
return status
}
fallbackStatus = fallbackStatus ?? status
}
return fallbackStatus ?? .missing(location: self.managedExecutableLocation())
}
static func managedStatus() async -> Status {
let location = self.managedExecutableLocation()
guard FileManager.default.isExecutableFile(atPath: location) else {
return .missing(location: location)
}
let status = await self.status(location: location)
if status.isReady {
self.rememberValidated(status)
}
return status
}
static func status(location: String) async -> Status {
let environment = self.probeEnvironment(location: location)
let response = await ShellExecutor.runDetailed(
command: [location, "--version"],
cwd: nil,
env: environment,
timeout: 5)
guard response.success else {
return .unusable(location: location)
}
let versionStatus = self.classifyVersion(
location: location,
output: response.stdout,
expectedVersion: GatewayEnvironment.expectedGatewayVersionString())
guard versionStatus.isReady else { return versionStatus }
guard await self.runtimeIsCompatible(environment: environment) else {
return .unusable(location: location)
}
return versionStatus
}
private static func runtimeIsCompatible(environment: [String: String]) async -> Bool {
let paths = environment["PATH"]?.split(separator: ":").map(String.init) ?? []
return await Task.detached(priority: .utility) {
if case .success = RuntimeLocator.resolve(searchPaths: paths) {
return true
}
return false
}.value
}
static func classifyVersion(
location: String,
output: String?,
expectedVersion: String?) -> Status
{
let normalized = GatewayEnvironment.normalizeGatewayVersionOutput(output)
guard let normalized, let installed = Semver.parse(normalized) else {
return .unusable(location: location)
}
guard let required = Semver.parse(expectedVersion) else {
return .ready(location: location, version: normalized)
}
guard installed.compatible(with: required) else {
return .incompatible(
location: location,
found: normalized,
required: expectedVersion ?? required.description)
}
return .ready(location: location, version: normalized)
}
static func probeEnvironment(
location: String,
processEnvironment: [String: String] = ProcessInfo.processInfo.environment,
preferredPaths: [String] = CommandResolver.preferredPaths(),
managedExecutable: String? = nil,
managedRuntimeDirectory: String? = nil) -> [String: String]
{
var environment = processEnvironment
let executableDirectory = URL(fileURLWithPath: location).deletingLastPathComponent().path
let effectiveManagedExecutable = managedExecutable ?? self.managedExecutableLocation()
let effectiveManagedRuntimeDirectory = managedRuntimeDirectory ?? URL(fileURLWithPath: self.installPrefix())
.appendingPathComponent("tools/node/bin")
.path
let initialPaths = location == effectiveManagedExecutable
? [executableDirectory, effectiveManagedRuntimeDirectory]
: [executableDirectory]
var seen = Set<String>()
let paths = (initialPaths + preferredPaths).filter { seen.insert($0).inserted }
environment["PATH"] = paths.joined(separator: ":")
return environment
}
private static func rememberValidated(_ status: Status) {
guard case let .ready(location, version) = status else { return }
UserDefaults.standard.set(location, forKey: cliValidatedExecutableKey)
UserDefaults.standard.set(version, forKey: cliValidatedVersionKey)
}
@discardableResult
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async -> Bool {
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
let prefix = Self.installPrefix()
await statusHandler("Installing openclaw CLI…")
guard let installerURL = Bundle.main.url(forResource: "install-cli", withExtension: "sh") else {
await statusHandler("Install failed: installer resource is missing. Reinstall OpenClaw.")
return false
}
let cmd = self.installScriptCommand(
version: expected,
prefix: prefix,
scriptPath: installerURL.path)
let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: nil, timeout: 900)
if response.success {
let managedStatus = await self.managedStatus()
guard managedStatus.isReady else {
await statusHandler("Install failed: \(managedStatus.message)")
return false
}
let parsed = self.parseInstallEvents(response.stdout)
let installedVersion = parsed.last { $0.event == "done" }?.version
let summary = installedVersion.map { "Installed openclaw \($0)." } ?? "Installed openclaw."
await statusHandler(summary)
return true
}
let parsed = self.parseInstallEvents(response.stdout)
if let error = parsed.last(where: { $0.event == "error" })?.message {
await statusHandler("Install failed: \(error)")
return false
}
let detail = response.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
let fallback = response.errorMessage ?? "install failed"
await statusHandler("Install failed: \(detail.isEmpty ? fallback : detail)")
return false
}
private static func installPrefix() -> String {
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw")
.path
}
static func installScriptCommand(version: String, prefix: String, scriptPath: String) -> [String] {
[
"/bin/bash",
scriptPath,
"--json",
"--no-onboard",
"--prefix",
prefix,
"--version",
version,
]
}
static func activateLocalGateway(
mode: AppState.ConnectionMode = AppStateStore.shared.connectionMode,
paused: Bool = AppStateStore.shared.isPaused,
start: @MainActor () -> Void = { GatewayProcessManager.shared.setActive(true) },
waitUntilReady: @MainActor () async -> Bool = {
await GatewayProcessManager.shared.waitForGatewayReady(timeout: 12)
}) async -> LocalGatewayActivation
{
guard mode == .local, !paused else { return .deferred }
start()
return await waitUntilReady() ? .ready : .failed
}
private static func parseInstallEvents(_ output: String) -> [InstallEvent] {
let decoder = JSONDecoder()
let lines = output
.split(whereSeparator: \.isNewline)
.map { String($0) }
var events: [InstallEvent] = []
for line in lines {
guard let data = line.data(using: .utf8) else { continue }
if let event = try? decoder.decode(InstallEvent.self, from: data) {
events.append(event)
}
}
return events
}
}
private struct InstallEvent: Decodable {
let event: String
let version: String?
let message: String?
}

View File

@@ -0,0 +1,376 @@
import AVFoundation
import CoreGraphics
import Foundation
import OpenClawIPC
import OpenClawKit
import OSLog
actor CameraCaptureService {
struct CameraDeviceInfo: Encodable {
let id: String
let name: String
let position: String
let deviceType: String
}
enum CameraError: LocalizedError {
case cameraUnavailable
case microphoneUnavailable
case permissionDenied(kind: String)
case captureFailed(String)
case exportFailed(String)
var errorDescription: String? {
switch self {
case .cameraUnavailable:
"Camera unavailable"
case .microphoneUnavailable:
"Microphone unavailable"
case let .permissionDenied(kind):
"\(kind) permission denied"
case let .captureFailed(msg):
msg
case let .exportFailed(msg):
msg
}
}
}
private let logger = Logger(subsystem: "ai.openclaw", category: "camera")
func listDevices() -> [CameraDeviceInfo] {
Self.availableCameras().map { device in
CameraDeviceInfo(
id: device.uniqueID,
name: device.localizedName,
position: Self.positionLabel(device.position),
deviceType: device.deviceType.rawValue)
}
}
func snap(
facing: CameraFacing?,
maxWidth: Int?,
quality: Double?,
deviceId: String?,
delayMs: Int) async throws -> (data: Data, size: CGSize)
{
let facing = facing ?? .front
let normalized = Self.normalizeSnap(maxWidth: maxWidth, quality: quality)
let maxWidth = normalized.maxWidth
let quality = normalized.quality
let delayMs = max(0, delayMs)
let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines)
try await self.ensureAccess(for: .video)
let prepared = try CameraCapturePipelineSupport.preparePhotoSession(
preferFrontCamera: facing == .front,
deviceId: deviceId,
pickCamera: { preferFrontCamera, deviceId in
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
},
cameraUnavailableError: CameraError.cameraUnavailable,
mapSetupError: { setupError in
CameraError.captureFailed(setupError.localizedDescription)
})
let session = prepared.session
let device = prepared.device
let output = prepared.output
session.startRunning()
defer { session.stopRunning() }
await CameraCapturePipelineSupport.warmUpCaptureSession()
await self.waitForExposureAndWhiteBalance(device: device)
await self.sleepDelayMs(delayMs)
var delegate: PhotoCaptureDelegate?
let rawData: Data = try await withCheckedThrowingContinuation { continuation in
let captureDelegate = PhotoCaptureDelegate(continuation)
delegate = captureDelegate
output.capturePhoto(
with: CameraCapturePipelineSupport.makePhotoSettings(output: output),
delegate: captureDelegate)
}
withExtendedLifetime(delegate) {}
let res: (data: Data, widthPx: Int, heightPx: Int)
do {
res = try PhotoCapture.transcodeJPEGForGateway(
rawData: rawData,
maxWidthPx: maxWidth,
quality: quality)
} catch {
throw CameraError.captureFailed(error.localizedDescription)
}
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
}
func clip(
facing: CameraFacing?,
durationMs: Int?,
includeAudio: Bool,
deviceId: String?,
outPath: String?) async throws -> (path: String, durationMs: Int, hasAudio: Bool)
{
let facing = facing ?? .front
let durationMs = Self.clampDurationMs(durationMs)
let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines)
try await self.ensureAccess(for: .video)
if includeAudio {
try await self.ensureAccess(for: .audio)
}
let prepared = try await CameraCapturePipelineSupport.prepareWarmMovieSession(
preferFrontCamera: facing == .front,
deviceId: deviceId,
includeAudio: includeAudio,
durationMs: durationMs,
pickCamera: { preferFrontCamera, deviceId in
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
},
cameraUnavailableError: CameraError.cameraUnavailable,
mapSetupError: Self.mapMovieSetupError)
let session = prepared.session
let output = prepared.output
defer { session.stopRunning() }
let tmpMovURL = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
defer { try? FileManager().removeItem(at: tmpMovURL) }
let outputURL: URL = {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath)
}
return FileManager().temporaryDirectory
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
}()
// Ensure we don't fail exporting due to an existing file.
try? FileManager().removeItem(at: outputURL)
let logger = self.logger
var delegate: MovieFileDelegate?
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
let d = MovieFileDelegate(cont, logger: logger)
delegate = d
output.startRecording(to: tmpMovURL, recordingDelegate: d)
}
withExtendedLifetime(delegate) {}
try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL)
return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio)
}
private func ensureAccess(for mediaType: AVMediaType) async throws {
if await !(CameraAuthorization.isAuthorized(for: mediaType)) {
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
}
}
private nonisolated static func availableCameras() -> [AVCaptureDevice] {
var types: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
.continuityCamera,
]
if let external = externalDeviceType() {
types.append(external)
}
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: types,
mediaType: .video,
position: .unspecified)
return session.devices
}
private nonisolated static func externalDeviceType() -> AVCaptureDevice.DeviceType? {
if #available(macOS 14.0, *) {
return .external
}
// Use raw value to avoid deprecated symbol in the SDK.
return AVCaptureDevice.DeviceType(rawValue: "AVCaptureDeviceTypeExternalUnknown")
}
private nonisolated static func pickCamera(
facing: CameraFacing,
deviceId: String?) -> AVCaptureDevice?
{
if let deviceId, !deviceId.isEmpty {
if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) {
return match
}
}
let position: AVCaptureDevice.Position = (facing == .front) ? .front : .back
if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) {
return device
}
// Many macOS cameras report `unspecified` position; fall back to any default.
return AVCaptureDevice.default(for: .video)
}
private nonisolated static func clampQuality(_ quality: Double?) -> Double {
let q = quality ?? 0.9
return min(1.0, max(0.05, q))
}
nonisolated static func normalizeSnap(maxWidth: Int?, quality: Double?) -> (maxWidth: Int, quality: Double) {
// Default to a reasonable max width to keep downstream payload sizes manageable.
// If you need full-res, explicitly request a larger maxWidth.
let maxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
let quality = Self.clampQuality(quality)
return (maxWidth: maxWidth, quality: quality)
}
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
let v = ms ?? 3000
return min(60000, max(250, v))
}
private nonisolated static func mapMovieSetupError(_ setupError: CameraSessionConfigurationError) -> CameraError {
CameraCapturePipelineSupport.mapMovieSetupError(
setupError,
microphoneUnavailableError: .microphoneUnavailable,
captureFailed: { .captureFailed($0) })
}
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
let asset = AVURLAsset(url: inputURL)
guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
throw CameraError.exportFailed("Failed to create export session")
}
export.shouldOptimizeForNetworkUse = true
if #available(macOS 15.0, *) {
do {
try await export.export(to: outputURL, as: .mp4)
return
} catch {
throw CameraError.exportFailed(error.localizedDescription)
}
} else {
export.outputURL = outputURL
export.outputFileType = .mp4
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
export.exportAsynchronously {
cont.resume(returning: ())
}
}
switch export.status {
case .completed:
return
case .failed:
throw CameraError.exportFailed(export.error?.localizedDescription ?? "export failed")
case .cancelled:
throw CameraError.exportFailed("export cancelled")
default:
throw CameraError.exportFailed("export did not complete (\(export.status.rawValue))")
}
}
}
private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async {
let stepNs: UInt64 = 50_000_000
let maxSteps = 30 // ~1.5s
for _ in 0..<maxSteps {
if !(device.isAdjustingExposure || device.isAdjustingWhiteBalance) {
return
}
try? await Task.sleep(nanoseconds: stepNs)
}
}
private func sleepDelayMs(_ delayMs: Int) async {
guard delayMs > 0 else { return }
let ns = UInt64(min(delayMs, 10000)) * 1_000_000
try? await Task.sleep(nanoseconds: ns)
}
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
CameraCapturePipelineSupport.positionLabel(position)
}
}
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
private var cont: CheckedContinuation<Data, Error>?
private var didResume = false
init(_ cont: CheckedContinuation<Data, Error>) {
self.cont = cont
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?)
{
guard !self.didResume, let cont else { return }
self.didResume = true
self.cont = nil
if let error {
cont.resume(throwing: error)
return
}
guard let data = photo.fileDataRepresentation() else {
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("No photo data"))
return
}
if data.isEmpty {
cont.resume(throwing: CameraCaptureService.CameraError.captureFailed("Photo data empty"))
return
}
cont.resume(returning: data)
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
error: Error?)
{
guard let error else { return }
guard !self.didResume, let cont else { return }
self.didResume = true
self.cont = nil
cont.resume(throwing: error)
}
}
private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
private var cont: CheckedContinuation<URL, Error>?
private let logger: Logger
init(_ cont: CheckedContinuation<URL, Error>, logger: Logger) {
self.cont = cont
self.logger = logger
}
func fileOutput(
_ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL,
from connections: [AVCaptureConnection],
error: Error?)
{
guard let cont else { return }
self.cont = nil
if let error {
let ns = error as NSError
if ns.domain == AVFoundationErrorDomain,
ns.code == AVError.maximumDurationReached.rawValue
{
cont.resume(returning: outputFileURL)
return
}
self.logger.error("camera record failed: \(error.localizedDescription, privacy: .public)")
cont.resume(throwing: error)
return
}
cont.resume(returning: outputFileURL)
}
}

View File

@@ -0,0 +1,145 @@
import AppKit
import Foundation
import OpenClawIPC
import OpenClawKit
import WebKit
final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
static let messageName = "openclawCanvasA2UIAction"
static let allMessageNames = [messageName]
/// Compatibility helper for debug/test shims. Runtime dispatch remains
/// limited to in-app canvas schemes in `didReceive`.
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
return false
}
guard let host = url.host?.lowercased(), !host.isEmpty else {
return false
}
if host == "localhost" {
return true
}
guard let ip = Self.parseIPv4(host) else {
return false
}
return Self.isLocalNetworkIPv4(ip)
}
private let sessionKey: String
init(sessionKey: String) {
self.sessionKey = sessionKey
super.init()
}
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
guard Self.allMessageNames.contains(message.name) else { return }
// Only accept actions from the in-app canvas scheme. Local-network HTTP
// pages are regular web content and must not get direct agent dispatch.
guard let webView = message.webView, let url = webView.url else { return }
guard let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) else {
return
}
let body: [String: Any] = {
if let dict = message.body as? [String: Any] { return dict }
if let dict = message.body as? [AnyHashable: Any] {
return dict.reduce(into: [String: Any]()) { acc, pair in
guard let key = pair.key as? String else { return }
acc[key] = pair.value
}
}
return [:]
}()
guard !body.isEmpty else { return }
let userActionAny = body["userAction"] ?? body
let userAction: [String: Any] = {
if let dict = userActionAny as? [String: Any] { return dict }
if let dict = userActionAny as? [AnyHashable: Any] {
return dict.reduce(into: [String: Any]()) { acc, pair in
guard let key = pair.key as? String else { return }
acc[key] = pair.value
}
}
return [:]
}()
guard !userAction.isEmpty else { return }
guard let name = OpenClawCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId =
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? UUID().uuidString
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
let surfaceId = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty ?? "main"
let sourceComponentId = (userAction["sourceComponentId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "-"
let instanceId = InstanceIdentity.instanceId.lowercased()
let contextJSON = OpenClawCanvasA2UIAction.compactJSON(userAction["context"])
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext(
actionName: name,
session: .init(key: self.sessionKey, surfaceId: surfaceId),
component: .init(id: sourceComponentId, host: InstanceIdentity.displayName, instanceId: instanceId),
contextJSON: contextJSON)
let text = OpenClawCanvasA2UIAction.formatAgentMessage(messageContext)
Task { [weak webView] in
if AppStateStore.shared.connectionMode == .local {
GatewayProcessManager.shared.setActive(true)
}
let result = await GatewayConnection.shared.sendAgent(
GatewayAgentInvocation(
message: text,
sessionKey: self.sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: .last,
idempotencyKey: actionId))
await MainActor.run {
guard let webView else { return }
let js = OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(
actionId: actionId,
ok: result.ok,
error: result.error)
webView.evaluateJavaScript(js) { _, _ in }
}
if !result.ok {
canvasWindowLogger.error(
"""
A2UI action send failed name=\(name, privacy: .public) \
error=\(result.error ?? "unknown", privacy: .public)
""")
}
}
}
private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 4 else { return nil }
let bytes = parts.compactMap { UInt8($0) }
guard bytes.count == 4 else { return nil }
return (bytes[0], bytes[1], bytes[2], bytes[3])
}
private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
let (a, b, _, _) = ip
if a == 10 { return true }
if a == 172, (16...31).contains(Int(b)) { return true }
if a == 192, b == 168 { return true }
if a == 127 { return true }
if a == 169, b == 254 { return true }
if a == 100, (64...127).contains(Int(b)) { return true }
return false
}
// Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`).
}

View File

@@ -0,0 +1,235 @@
import AppKit
import QuartzCore
final class HoverChromeContainerView: NSView {
private let content: NSView
private let chrome: CanvasChromeOverlayView
private var tracking: NSTrackingArea?
var onClose: (() -> Void)?
init(containing content: NSView) {
self.content = content
self.chrome = CanvasChromeOverlayView(frame: .zero)
super.init(frame: .zero)
self.wantsLayer = true
self.layer?.cornerRadius = 12
self.layer?.masksToBounds = true
self.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
self.content.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.content)
self.chrome.translatesAutoresizingMaskIntoConstraints = false
self.chrome.alphaValue = 0
self.chrome.onClose = { [weak self] in self?.onClose?() }
self.addSubview(self.chrome)
NSLayoutConstraint.activate([
self.content.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.content.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.content.topAnchor.constraint(equalTo: self.topAnchor),
self.content.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.chrome.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.chrome.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.chrome.topAnchor.constraint(equalTo: self.topAnchor),
self.chrome.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let tracking {
self.removeTrackingArea(tracking)
}
let area = NSTrackingArea(
rect: self.bounds,
options: [.activeAlways, .mouseEnteredAndExited, .inVisibleRect],
owner: self,
userInfo: nil)
self.addTrackingArea(area)
self.tracking = area
}
private final class CanvasDragHandleView: NSView {
override func mouseDown(with event: NSEvent) {
self.window?.performDrag(with: event)
}
override func acceptsFirstMouse(for _: NSEvent?) -> Bool {
true
}
}
private final class CanvasResizeHandleView: NSView {
private var startPoint: NSPoint = .zero
private var startFrame: NSRect = .zero
override func acceptsFirstMouse(for _: NSEvent?) -> Bool {
true
}
override func mouseDown(with event: NSEvent) {
guard let window else { return }
_ = window.makeFirstResponder(self)
self.startPoint = NSEvent.mouseLocation
self.startFrame = window.frame
super.mouseDown(with: event)
}
override func mouseDragged(with _: NSEvent) {
guard let window else { return }
let current = NSEvent.mouseLocation
let dx = current.x - self.startPoint.x
let dy = current.y - self.startPoint.y
var frame = self.startFrame
frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx)
frame.origin.y += dy
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
if let screen = window.screen {
frame = CanvasWindowController.constrainFrame(frame, toVisibleFrame: screen.visibleFrame)
}
window.setFrame(frame, display: true)
}
}
private final class CanvasChromeOverlayView: NSView {
var onClose: (() -> Void)?
private let dragHandle = CanvasDragHandleView(frame: .zero)
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
private final class PassthroughVisualEffectView: NSVisualEffectView {
override func hitTest(_: NSPoint) -> NSView? {
nil
}
}
private let closeBackground: NSVisualEffectView = {
let v = PassthroughVisualEffectView(frame: .zero)
v.material = .hudWindow
v.blendingMode = .withinWindow
v.state = .active
v.appearance = NSAppearance(named: .vibrantDark)
v.wantsLayer = true
v.layer?.cornerRadius = 10
v.layer?.masksToBounds = true
v.layer?.borderWidth = 1
v.layer?.borderColor = NSColor.white.withAlphaComponent(0.22).cgColor
v.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor
v.layer?.shadowColor = NSColor.black.withAlphaComponent(0.35).cgColor
v.layer?.shadowOpacity = 0.35
v.layer?.shadowRadius = 8
v.layer?.shadowOffset = .zero
return v
}()
private let closeButton: NSButton = {
let cfg = NSImage.SymbolConfiguration(pointSize: 8, weight: .semibold)
let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close")?
.withSymbolConfiguration(cfg)
?? NSImage(size: NSSize(width: 18, height: 18))
let btn = NSButton(image: img, target: nil, action: nil)
btn.isBordered = false
btn.bezelStyle = .regularSquare
btn.imageScaling = .scaleProportionallyDown
btn.contentTintColor = NSColor.white.withAlphaComponent(0.92)
btn.toolTip = "Close"
return btn
}()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
self.wantsLayer = true
self.layer?.cornerRadius = 12
self.layer?.masksToBounds = true
self.layer?.borderWidth = 1
self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor
self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor
self.dragHandle.translatesAutoresizingMaskIntoConstraints = false
self.dragHandle.wantsLayer = true
self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.dragHandle)
self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false
self.resizeHandle.wantsLayer = true
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.resizeHandle)
self.closeBackground.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.closeBackground)
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
self.closeButton.target = self
self.closeButton.action = #selector(self.handleClose)
self.addSubview(self.closeButton)
NSLayoutConstraint.activate([
self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor),
self.dragHandle.heightAnchor.constraint(equalToConstant: 30),
self.closeBackground.centerXAnchor.constraint(equalTo: self.closeButton.centerXAnchor),
self.closeBackground.centerYAnchor.constraint(equalTo: self.closeButton.centerYAnchor),
self.closeBackground.widthAnchor.constraint(equalToConstant: 20),
self.closeBackground.heightAnchor.constraint(equalToConstant: 20),
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
self.closeButton.widthAnchor.constraint(equalToConstant: 16),
self.closeButton.heightAnchor.constraint(equalToConstant: 16),
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.resizeHandle.widthAnchor.constraint(equalToConstant: 18),
self.resizeHandle.heightAnchor.constraint(equalToConstant: 18),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func hitTest(_ point: NSPoint) -> NSView? {
// When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them).
guard self.alphaValue > 0.02 else { return nil }
if self.closeButton.frame.contains(point) { return self.closeButton }
if self.dragHandle.frame.contains(point) { return self.dragHandle }
if self.resizeHandle.frame.contains(point) { return self.resizeHandle }
return nil
}
@objc private func handleClose() {
self.onClose?()
}
}
override func mouseEntered(with _: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.12
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
self.chrome.animator().alphaValue = 1
}
}
override func mouseExited(with _: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.16
ctx.timingFunction = CAMediaTimingFunction(name: .easeOut)
self.chrome.animator().alphaValue = 0
}
}
}

View File

@@ -0,0 +1,102 @@
import Foundation
final class CanvasFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
let watcher: SimpleFileWatcher
private let pollingWatcher: PollingDirectoryWatcher
init(url: URL, onChange: @escaping () -> Void) {
self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher(
paths: [url.path],
queueLabel: "ai.openclaw.canvaswatcher",
onChange: onChange))
self.pollingWatcher = PollingDirectoryWatcher(
url: url,
queueLabel: "ai.openclaw.canvaswatcher.poll",
onChange: onChange)
}
func start() {
self.watcher.start()
self.pollingWatcher.start()
}
func stop() {
self.watcher.stop()
self.pollingWatcher.stop()
}
}
private final class PollingDirectoryWatcher: @unchecked Sendable {
private struct FileSignature: Equatable {
let modifiedAt: TimeInterval
let size: Int
}
private let url: URL
private let queue: DispatchQueue
private let onChange: () -> Void
private var timer: DispatchSourceTimer?
private var lastSnapshot: [String: FileSignature] = [:]
init(url: URL, queueLabel: String, onChange: @escaping () -> Void) {
self.url = url
self.queue = DispatchQueue(label: queueLabel)
self.onChange = onChange
}
deinit {
self.stop()
}
func start() {
self.queue.sync {
guard self.timer == nil else { return }
self.lastSnapshot = self.snapshot()
let timer = DispatchSource.makeTimerSource(queue: self.queue)
timer.schedule(deadline: .now() + 0.15, repeating: 0.25)
timer.setEventHandler { [weak self] in
self?.poll()
}
self.timer = timer
timer.resume()
}
}
func stop() {
self.queue.sync {
self.timer?.cancel()
self.timer = nil
self.lastSnapshot = [:]
}
}
private func poll() {
let next = self.snapshot()
guard next != self.lastSnapshot else { return }
self.lastSnapshot = next
self.onChange()
}
private func snapshot() -> [String: FileSignature] {
let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey, .isRegularFileKey]
guard let enumerator = FileManager.default.enumerator(
at: self.url,
includingPropertiesForKeys: keys,
options: [.skipsPackageDescendants])
else { return [:] }
var result: [String: FileSignature] = [:]
for case let fileURL as URL in enumerator {
guard let values = try? fileURL.resourceValues(forKeys: Set(keys)),
values.isRegularFile == true
else { continue }
let relativePath = String(fileURL.path.dropFirst(self.url.path.count + 1))
result[relativePath] = FileSignature(
modifiedAt: values.contentModificationDate?.timeIntervalSinceReferenceDate ?? 0,
size: values.fileSize ?? 0)
}
return result
}
}

View File

@@ -0,0 +1,346 @@
import AppKit
import Foundation
import OpenClawIPC
import OpenClawKit
import OSLog
@MainActor
final class CanvasManager {
static let shared = CanvasManager()
private static let logger = Logger(subsystem: "ai.openclaw", category: "CanvasManager")
private var panelController: CanvasWindowController?
private var panelSessionKey: String?
private var lastAutoA2UIUrl: String?
private var gatewayWatchTask: Task<Void, Never>?
private init() {
self.startGatewayObserver()
}
var onPanelVisibilityChanged: ((Bool) -> Void)?
/// Optional anchor provider (e.g. menu bar status item). If nil, Canvas anchors to the mouse cursor.
var defaultAnchorProvider: (() -> NSRect?)?
private nonisolated static let canvasRoot: URL = {
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("OpenClaw/canvas", isDirectory: true)
}()
func show(sessionKey: String, path: String? = nil, placement: CanvasPlacement? = nil) throws -> String {
try self.showDetailed(sessionKey: sessionKey, target: path, placement: placement).directory
}
func showDetailed(
sessionKey: String,
target: String? = nil,
placement: CanvasPlacement? = nil) throws -> CanvasShowResult
{
Self.logger.debug(
"""
showDetailed start session=\(sessionKey, privacy: .public) \
target=\(target ?? "", privacy: .public) \
placement=\(placement != nil)
""")
let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedTarget = target?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
if let controller = self.panelController, self.panelSessionKey == session {
Self.logger.debug("showDetailed reuse existing session=\(session, privacy: .public)")
controller.onVisibilityChanged = { [weak self] visible in
self?.onPanelVisibilityChanged?(visible)
}
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
controller.applyPreferredPlacement(placement)
self.refreshDebugStatus()
// Existing session: only navigate when an explicit target was provided.
if let normalizedTarget {
controller.load(target: normalizedTarget)
return self.makeShowResult(
directory: controller.directoryPath,
target: target,
effectiveTarget: normalizedTarget)
}
self.maybeAutoNavigateToA2UIAsync(controller: controller)
return CanvasShowResult(
directory: controller.directoryPath,
target: target,
effectiveTarget: nil,
status: .shown,
url: nil)
}
Self.logger.debug("showDetailed creating new session=\(session, privacy: .public)")
self.panelController?.close()
self.panelController = nil
self.panelSessionKey = nil
Self.logger.debug("showDetailed ensure canvas root dir")
try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
Self.logger.debug("showDetailed init CanvasWindowController")
let controller = try CanvasWindowController(
sessionKey: session,
root: Self.canvasRoot,
presentation: .panel(anchorProvider: anchorProvider))
Self.logger.debug("showDetailed CanvasWindowController init done")
controller.onVisibilityChanged = { [weak self] visible in
self?.onPanelVisibilityChanged?(visible)
}
self.panelController = controller
self.panelSessionKey = session
controller.applyPreferredPlacement(placement)
// New session: default to "/" so the user sees either the welcome page or `index.html`.
let effectiveTarget = normalizedTarget ?? "/"
Self.logger.debug("showDetailed showCanvas effectiveTarget=\(effectiveTarget, privacy: .public)")
controller.showCanvas(path: effectiveTarget)
Self.logger.debug("showDetailed showCanvas done")
if normalizedTarget == nil {
self.maybeAutoNavigateToA2UIAsync(controller: controller)
}
self.refreshDebugStatus()
return self.makeShowResult(
directory: controller.directoryPath,
target: target,
effectiveTarget: effectiveTarget)
}
func hide(sessionKey: String) {
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.panelSessionKey == session else { return }
self.panelController?.hideCanvas()
}
func hideAll() {
self.panelController?.hideCanvas()
}
func eval(sessionKey: String, javaScript: String) async throws -> String {
_ = try self.show(sessionKey: sessionKey, path: nil)
guard let controller = self.panelController else { return "" }
return try await controller.eval(javaScript: javaScript)
}
func snapshot(sessionKey: String, outPath: String?) async throws -> String {
_ = try self.show(sessionKey: sessionKey, path: nil)
guard let controller = self.panelController else {
throw NSError(domain: "Canvas", code: 21, userInfo: [NSLocalizedDescriptionKey: "canvas not available"])
}
return try await controller.snapshot(to: outPath)
}
// MARK: - Gateway A2UI auto-nav
private func startGatewayObserver() {
self.gatewayWatchTask?.cancel()
self.gatewayWatchTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 1)
for await push in stream {
self.handleGatewayPush(push)
}
}
}
private func handleGatewayPush(_ push: GatewayPush) {
guard case let .snapshot(snapshot) = push else { return }
let raw =
(snapshot.pluginsurfaceurls?["canvas"]?.value as? String)?
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
if raw.isEmpty {
Self.logger.debug("canvas plugin surface URL missing in gateway snapshot")
} else {
Self.logger.debug("canvas plugin surface URL snapshot=\(raw, privacy: .public)")
}
let a2uiUrl = Self.resolveA2UIHostUrl(from: raw)
if a2uiUrl == nil, !raw.isEmpty {
Self.logger.debug("canvas plugin surface URL invalid; cannot resolve A2UI")
}
guard let controller = self.panelController else {
if a2uiUrl != nil {
Self.logger.debug("canvas panel not visible; skipping auto-nav")
}
return
}
self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
}
private func maybeAutoNavigateToA2UIAsync(controller: CanvasWindowController) {
Task { [weak self] in
guard let self else { return }
let a2uiUrl = await self.resolveA2UIHostUrl()
await MainActor.run {
guard self.panelController === controller else { return }
self.maybeAutoNavigateToA2UI(controller: controller, a2uiUrl: a2uiUrl)
}
}
}
private func maybeAutoNavigateToA2UI(controller: CanvasWindowController, a2uiUrl: String?) {
guard let a2uiUrl else { return }
let shouldNavigate = controller.shouldAutoNavigateToA2UI(
lastAutoTarget: self.lastAutoA2UIUrl,
candidateTarget: a2uiUrl)
guard shouldNavigate else {
Self.logger.debug("canvas auto-nav skipped; target unchanged")
return
}
Self.logger.debug("canvas auto-nav -> \(a2uiUrl, privacy: .public)")
controller.load(target: a2uiUrl)
self.lastAutoA2UIUrl = a2uiUrl
}
private func resolveA2UIHostUrl() async -> String? {
let raw = await GatewayConnection.shared.canvasPluginSurfaceUrl()
return Self.resolveA2UIHostUrl(from: raw)
}
func refreshDebugStatus() {
guard let controller = self.panelController else { return }
let enabled = AppStateStore.shared.debugPaneEnabled
let mode = AppStateStore.shared.connectionMode
let title: String?
let subtitle: String?
switch mode {
case .remote:
title = "Remote control"
switch ControlChannel.shared.state {
case .connected:
subtitle = "Connected"
case .connecting:
subtitle = "Connecting…"
case .disconnected:
subtitle = "Disconnected"
case let .degraded(message):
subtitle = message.isEmpty ? "Degraded" : message
}
case .local:
title = GatewayProcessManager.shared.status.label
subtitle = mode.rawValue
case .unconfigured:
title = "Unconfigured"
subtitle = mode.rawValue
}
controller.updateDebugStatus(enabled: enabled, title: title, subtitle: subtitle)
}
private static func resolveA2UIHostUrl(from raw: String?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=macos"
}
// MARK: - Anchoring
private static func mouseAnchorProvider() -> NSRect? {
let pt = NSEvent.mouseLocation
return NSRect(x: pt.x, y: pt.y, width: 1, height: 1)
}
// placement interpretation is handled by the window controller.
// MARK: - Helpers
private static func directURL(for target: String?) -> URL? {
guard let target else { return nil }
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
if scheme == "https" || scheme == "http" || scheme == "file" { return url }
}
// Convenience: existing absolute *file* paths resolve as local files.
// (Avoid treating Canvas routes like "/" as filesystem paths.)
if trimmed.hasPrefix("/") {
var isDir: ObjCBool = false
if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
return URL(fileURLWithPath: trimmed)
}
}
return nil
}
private func makeShowResult(
directory: String,
target: String?,
effectiveTarget: String) -> CanvasShowResult
{
if let url = Self.directURL(for: effectiveTarget) {
return CanvasShowResult(
directory: directory,
target: target,
effectiveTarget: effectiveTarget,
status: .web,
url: url.absoluteString)
}
let sessionDir = URL(fileURLWithPath: directory)
let status = Self.localStatus(sessionDir: sessionDir, target: effectiveTarget)
let host = sessionDir.lastPathComponent
let canvasURL = CanvasScheme.makeURL(session: host, path: effectiveTarget)?.absoluteString
return CanvasShowResult(
directory: directory,
target: target,
effectiveTarget: effectiveTarget,
status: status,
url: canvasURL)
}
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
let fm = FileManager()
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
.map(String.init) ?? trimmed
var path = withoutQuery
if path.hasPrefix("/") { path.removeFirst() }
path = path.removingPercentEncoding ?? path
// Root special-case: built-in scaffold page when no index exists.
if path.isEmpty {
let a = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok }
return .welcome
}
// Direct file or directory.
var candidate = sessionDir.appendingPathComponent(path, isDirectory: false)
var isDir: ObjCBool = false
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) {
if isDir.boolValue {
return Self.indexExists(in: candidate) ? .ok : .notFound
}
return .ok
}
// Directory index behavior ("/yolo" -> "yolo/index.html") if directory exists.
if !path.isEmpty, !path.hasSuffix("/") {
candidate = sessionDir.appendingPathComponent(path, isDirectory: true)
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue {
return Self.indexExists(in: candidate) ? .ok : .notFound
}
}
return .notFound
}
private static func indexExists(in dir: URL) -> Bool {
let fm = FileManager()
let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) { return true }
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
return fm.fileExists(atPath: b.path)
}
// no bundled A2UI shell; scaffold fallback is purely visual
}

View File

@@ -0,0 +1,42 @@
import Foundation
enum CanvasScheme {
static let scheme = "openclaw-canvas"
static let allSchemes = [scheme]
static func makeURL(session: String, path: String? = nil) -> URL? {
var comps = URLComponents()
comps.scheme = Self.scheme
comps.host = session
let p = (path ?? "/").trimmingCharacters(in: .whitespacesAndNewlines)
if p.isEmpty || p == "/" {
comps.path = "/"
} else if p.hasPrefix("/") {
comps.path = p
} else {
comps.path = "/" + p
}
return comps.url
}
static func mimeType(forExtension ext: String) -> String {
switch ext.lowercased() {
// Note: WKURLSchemeHandler uses URLResponse(mimeType:), which expects a bare MIME type
// (no `; charset=...`). Encoding is provided via URLResponse(textEncodingName:).
case "html", "htm": "text/html"
case "js", "mjs": "application/javascript"
case "css": "text/css"
case "json", "map": "application/json"
case "svg": "image/svg+xml"
case "png": "image/png"
case "jpg", "jpeg": "image/jpeg"
case "gif": "image/gif"
case "ico": "image/x-icon"
case "woff2": "font/woff2"
case "woff": "font/woff"
case "ttf": "font/ttf"
case "wasm": "application/wasm"
default: "application/octet-stream"
}
}
}

View File

@@ -0,0 +1,265 @@
import Foundation
import OpenClawKit
import OSLog
import WebKit
private let canvasLogger = Logger(subsystem: "ai.openclaw", category: "Canvas")
final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
private let root: URL
init(root: URL) {
self.root = root
}
func webView(_: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
guard let url = urlSchemeTask.request.url else {
urlSchemeTask.didFailWithError(NSError(domain: "Canvas", code: 1, userInfo: [
NSLocalizedDescriptionKey: "missing url",
]))
return
}
let response = self.response(for: url)
let mime = response.mime
let data = response.data
let encoding = self.textEncodingName(forMimeType: mime)
let urlResponse = URLResponse(
url: url,
mimeType: mime,
expectedContentLength: data.count,
textEncodingName: encoding)
urlSchemeTask.didReceive(urlResponse)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
func webView(_: WKWebView, stop _: WKURLSchemeTask) {
// no-op
}
private struct CanvasResponse {
let mime: String
let data: Data
}
private func response(for url: URL) -> CanvasResponse {
guard let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) else {
return self.html("Invalid scheme.")
}
guard let session = url.host, !session.isEmpty else {
return self.html("Missing session.")
}
// Keep session component safe; don't allow slashes or traversal.
if session.contains("/") || session.contains("..") {
return self.html("Invalid session.")
}
let sessionRoot = self.root.appendingPathComponent(session, isDirectory: true)
// Path mapping: request path maps directly into the session dir.
var path = url.path
if let qIdx = path.firstIndex(of: "?") { path = String(path[..<qIdx]) }
if path.hasPrefix("/") { path.removeFirst() }
path = path.removingPercentEncoding ?? path
// Special-case: welcome page when root index is missing.
if path.isEmpty {
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager().fileExists(atPath: indexA.path),
!FileManager().fileExists(atPath: indexB.path)
{
return self.scaffoldPage(sessionRoot: sessionRoot)
}
}
let resolved = self.resolveFileURL(sessionRoot: sessionRoot, requestPath: path)
guard let fileURL = resolved else {
return self.html("Not Found", title: "Canvas: 404")
}
// Resolve symlinks before enforcing the session-root boundary so links inside
// the canvas tree cannot escape to arbitrary host files.
let resolvedRoot = sessionRoot.resolvingSymlinksInPath().standardizedFileURL
let resolvedFile = fileURL.resolvingSymlinksInPath().standardizedFileURL
guard self.isFileURL(resolvedFile, withinDirectory: resolvedRoot) else {
return self.html("Forbidden", title: "Canvas: 403")
}
do {
let data = try Data(contentsOf: resolvedFile)
let mime = CanvasScheme.mimeType(forExtension: resolvedFile.pathExtension)
let servedPath = resolvedFile.path
canvasLogger.debug(
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)")
return CanvasResponse(mime: mime, data: data)
} catch {
let failedPath = resolvedFile.path
let errorText = error.localizedDescription
canvasLogger
.error(
"failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)")
return self.html("Failed to read file.", title: "Canvas error")
}
}
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
let fm = FileManager()
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
var isDir: ObjCBool = false
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) {
if isDir.boolValue {
if let idx = self.resolveIndex(in: candidate) { return idx }
return nil
}
return candidate
}
// Directory index behavior:
// - "/yolo" serves "<yolo>/index.html" if that directory exists.
if !requestPath.isEmpty, !requestPath.hasSuffix("/") {
candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: true)
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue {
if let idx = self.resolveIndex(in: candidate) { return idx }
}
}
// Root fallback:
// - "/" serves "<sessionRoot>/index.html" if present.
if requestPath.isEmpty {
return self.resolveIndex(in: sessionRoot)
}
return nil
}
private func resolveIndex(in dir: URL) -> URL? {
let fm = FileManager()
let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) { return a }
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
if fm.fileExists(atPath: b.path) { return b }
return nil
}
private func isFileURL(_ fileURL: URL, withinDirectory rootURL: URL) -> Bool {
let rootPath = rootURL.path.hasSuffix("/") ? rootURL.path : rootURL.path + "/"
return fileURL.path == rootURL.path || fileURL.path.hasPrefix(rootPath)
}
private func html(_ body: String, title: String = "Canvas") -> CanvasResponse {
let html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>\(title)</title>
<style>
:root { color-scheme: light; }
html,body { height:100%; margin:0; }
body {
font: 13px -apple-system, system-ui;
display:flex;
align-items:center;
justify-content:center;
background: #fff;
color:#111827;
}
.card {
max-width: 520px;
padding: 18px 18px;
border-radius: 12px;
border: 1px solid rgba(0,0,0,.08);
box-shadow: 0 10px 30px rgba(0,0,0,.08);
}
.muted { color:#6b7280; margin-top:8px; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
</style>
</head>
<body>
<div class="card">
<div>\(body)</div>
</div>
</body>
</html>
"""
return CanvasResponse(mime: "text/html", data: Data(html.utf8))
}
private func welcomePage(sessionRoot: URL) -> CanvasResponse {
let escaped = sessionRoot.path
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
let body = """
<div style="font-weight:600; font-size:14px;">Canvas is ready.</div>
<div class="muted">Create <code>index.html</code> in:</div>
<div style="margin-top:10px;"><code>\(escaped)</code></div>
"""
return self.html(body, title: "Canvas")
}
private func scaffoldPage(sessionRoot: URL) -> CanvasResponse {
// Default Canvas UX: when no index exists, show the built-in scaffold page.
if let data = self.loadBundledResourceData(relativePath: "CanvasScaffold/scaffold.html") {
return CanvasResponse(mime: "text/html", data: data)
}
// Fallback for dev misconfiguration: show the classic welcome page.
return self.welcomePage(sessionRoot: sessionRoot)
}
private func loadBundledResourceData(relativePath: String) -> Data? {
let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.contains("..") || trimmed.contains("\\") { return nil }
let parts = trimmed.split(separator: "/")
guard let filename = parts.last else { return nil }
let subdirectory =
parts.count > 1 ? parts.dropLast().joined(separator: "/") : nil
let fileURL = URL(fileURLWithPath: String(filename))
let ext = fileURL.pathExtension
let name = fileURL.deletingPathExtension().lastPathComponent
guard !name.isEmpty, !ext.isEmpty else { return nil }
let bundle = OpenClawKitResources.bundle
let resourceURL =
bundle.url(forResource: name, withExtension: ext, subdirectory: subdirectory)
?? bundle.url(forResource: name, withExtension: ext)
guard let resourceURL else { return nil }
return try? Data(contentsOf: resourceURL)
}
private func textEncodingName(forMimeType mimeType: String) -> String? {
if mimeType.hasPrefix("text/") { return "utf-8" }
switch mimeType {
case "application/javascript", "application/json", "image/svg+xml":
return "utf-8"
default:
return nil
}
}
}
#if DEBUG
extension CanvasSchemeHandler {
func _testResponse(for url: URL) -> (mime: String, data: Data) {
let response = self.response(for: url)
return (response.mime, response.data)
}
func _testResolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
self.resolveFileURL(sessionRoot: sessionRoot, requestPath: requestPath)
}
func _testTextEncodingName(for mimeType: String) -> String? {
self.textEncodingName(forMimeType: mimeType)
}
}
#endif

View File

@@ -0,0 +1,31 @@
import AppKit
let canvasWindowLogger = Logger(subsystem: "ai.openclaw", category: "Canvas")
enum CanvasLayout {
static let panelSize = NSSize(width: 520, height: 680)
static let windowSize = NSSize(width: 1120, height: 840)
static let anchorPadding: CGFloat = 8
static let defaultPadding: CGFloat = 10
static let minPanelSize = NSSize(width: 360, height: 360)
}
final class CanvasPanel: NSPanel {
override var canBecomeKey: Bool {
true
}
override var canBecomeMain: Bool {
true
}
}
enum CanvasPresentation {
case window
case panel(anchorProvider: () -> NSRect?)
var isPanel: Bool {
if case .panel = self { return true }
return false
}
}

View File

@@ -0,0 +1,43 @@
import AppKit
import Foundation
extension CanvasWindowController {
// MARK: - Helpers
static func sanitizeSessionKey(_ key: String) -> String {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return "main" }
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+")
let scalars = trimmed.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
return String(scalars)
}
static func jsStringLiteral(_ value: String) -> String {
let data = try? JSONEncoder().encode(value)
return data.flatMap { String(data: $0, encoding: .utf8) } ?? "\"\""
}
static func jsOptionalStringLiteral(_ value: String?) -> String {
guard let value else { return "null" }
return Self.jsStringLiteral(value)
}
static func storedFrameDefaultsKey(sessionKey: String) -> String {
"openclaw.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
}
static func loadRestoredFrame(sessionKey: String) -> NSRect? {
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil }
let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3])
if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil }
return rect
}
static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) {
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
UserDefaults.standard.set(
[Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)],
forKey: key)
}
}

View File

@@ -0,0 +1,64 @@
import AppKit
import WebKit
extension CanvasWindowController {
// MARK: - WKNavigationDelegate
@MainActor
func webView(
_: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
{
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
let scheme = url.scheme?.lowercased()
// Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace.
if scheme == "openclaw" {
if let currentScheme = self.webView.url?.scheme,
CanvasScheme.allSchemes.contains(currentScheme)
{
Task { await DeepLinkHandler.shared.handle(url: url) }
} else {
canvasWindowLogger
.debug("ignoring deep link from non-canvas page \(url.absoluteString, privacy: .public)")
}
decisionHandler(.cancel)
return
}
// Keep web content inside the panel when reasonable.
// `about:blank` and friends are common internal navigations for WKWebView; never send them to NSWorkspace.
if CanvasScheme.allSchemes.contains(scheme ?? "")
|| scheme == "https"
|| scheme == "http"
|| scheme == "about"
|| scheme == "blob"
|| scheme == "data"
|| scheme == "javascript"
{
decisionHandler(.allow)
return
}
// Only open external URLs when there is a registered handler, otherwise macOS will show a confusing
// "There is no application set to open the URL ..." alert (e.g. for about:blank).
if let appURL = NSWorkspace.shared.urlForApplication(toOpen: url) {
NSWorkspace.shared.open(
[url],
withApplicationAt: appURL,
configuration: NSWorkspace.OpenConfiguration(),
completionHandler: nil)
} else {
canvasWindowLogger.debug("no application to open url \(url.absoluteString, privacy: .public)")
}
decisionHandler(.cancel)
}
func webView(_: WKWebView, didFinish _: WKNavigation?) {
self.applyDebugStatusIfNeeded()
}
}

View File

@@ -0,0 +1,50 @@
#if DEBUG
import AppKit
import Foundation
extension CanvasWindowController {
static func _testSanitizeSessionKey(_ key: String) -> String {
self.sanitizeSessionKey(key)
}
static func _testJSStringLiteral(_ value: String) -> String {
self.jsStringLiteral(value)
}
static func _testJSOptionalStringLiteral(_ value: String?) -> String {
self.jsOptionalStringLiteral(value)
}
static func _testStoredFrameKey(sessionKey: String) -> String {
self.storedFrameDefaultsKey(sessionKey: sessionKey)
}
static func _testStoreAndLoadFrame(sessionKey: String, frame: NSRect) -> NSRect? {
self.storeRestoredFrame(frame, sessionKey: sessionKey)
return self.loadRestoredFrame(sessionKey: sessionKey)
}
static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 4 else { return nil }
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
guard bytes.count == 4 else { return nil }
return (bytes[0], bytes[1], bytes[2], bytes[3])
}
static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
let (a, b, _, _) = ip
if a == 10 { return true }
if a == 172, (16...31).contains(Int(b)) { return true }
if a == 192, b == 168 { return true }
if a == 127 { return true }
if a == 169, b == 254 { return true }
if a == 100, (64...127).contains(Int(b)) { return true }
return false
}
static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool {
CanvasA2UIActionMessageHandler.isLocalNetworkCanvasURL(url)
}
}
#endif

View File

@@ -0,0 +1,32 @@
import AppKit
import WebKit
extension CanvasWindowController {
// MARK: - WKUIDelegate
/// Bridges `<input type="file">` clicks in canvas HTML to a native `NSOpenPanel`.
/// Without a `WKUIDelegate`, WebKit silently drops the request and file-picker
/// buttons in canvas pages do nothing.
@MainActor
func webView(
_ webView: WKWebView,
runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void)
{
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = parameters.allowsDirectories
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
panel.resolvesAliases = true
if let window = self.window {
panel.beginSheetModal(for: window) { response in
completionHandler(response == .OK ? panel.urls : nil)
}
return
}
panel.begin { response in
completionHandler(response == .OK ? panel.urls : nil)
}
}
}

View File

@@ -0,0 +1,166 @@
import AppKit
import OpenClawIPC
extension CanvasWindowController {
// MARK: - Window
static func makeWindow(for presentation: CanvasPresentation, contentView: NSView) -> NSWindow {
switch presentation {
case .window:
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: CanvasLayout.windowSize),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false)
window.title = "OpenClaw Canvas"
window.isReleasedWhenClosed = false
window.contentView = contentView
window.center()
window.minSize = NSSize(width: 880, height: 680)
return window
case .panel:
let panel = CanvasPanel(
contentRect: NSRect(origin: .zero, size: CanvasLayout.panelSize),
styleMask: [.borderless, .resizable],
backing: .buffered,
defer: false)
// Keep Canvas below the Voice Wake overlay panel.
panel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue - 1)
panel.hasShadow = true
panel.isMovable = false
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
panel.backgroundColor = .clear
panel.isOpaque = false
panel.contentView = contentView
panel.becomesKeyOnlyIfNeeded = true
panel.hidesOnDeactivate = false
panel.minSize = CanvasLayout.minPanelSize
return panel
}
}
func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) {
guard case .panel = self.presentation, let window else { return }
self.repositionPanel(using: anchorProvider)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
window.makeFirstResponder(self.webView)
VoiceWakeOverlayController.shared.bringToFrontIfVisible()
self.onVisibilityChanged?(true)
}
func repositionPanel(using anchorProvider: () -> NSRect?) {
guard let panel = self.window else { return }
let anchor = anchorProvider()
let targetScreen = Self.screen(forAnchor: anchor)
?? Self.screenContainingMouseCursor()
?? panel.screen
?? NSScreen.main
?? NSScreen.screens.first
let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey)
let restoredIsValid = if let restored, let targetScreen {
Self.isFrameMeaningfullyVisible(restored, on: targetScreen)
} else {
restored != nil
}
var frame = if let restored, restoredIsValid {
restored
} else {
Self.defaultTopRightFrame(panel: panel, screen: targetScreen)
}
// Apply agent placement as partial overrides:
// - If agent provides x/y, override origin.
// - If agent provides width/height, override size.
// - If agent provides only size, keep the remembered origin.
if let placement = self.preferredPlacement {
if let x = placement.x { frame.origin.x = x }
if let y = placement.y { frame.origin.y = y }
if let w = placement.width { frame.size.width = max(CanvasLayout.minPanelSize.width, CGFloat(w)) }
if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) }
}
self.setPanelFrame(frame, on: targetScreen)
}
static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect {
let w = max(CanvasLayout.minPanelSize.width, panel.frame.width)
let h = max(CanvasLayout.minPanelSize.height, panel.frame.height)
return WindowPlacement.topRightFrame(
size: NSSize(width: w, height: h),
padding: CanvasLayout.defaultPadding,
on: screen)
}
func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
guard let panel = self.window else { return }
guard let s = screen ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first else {
panel.setFrame(frame, display: false)
self.persistFrameIfPanel()
return
}
let constrained = Self.constrainFrame(frame, toVisibleFrame: s.visibleFrame)
panel.setFrame(constrained, display: false)
self.persistFrameIfPanel()
}
static func screen(forAnchor anchor: NSRect?) -> NSScreen? {
guard let anchor else { return nil }
let center = NSPoint(x: anchor.midX, y: anchor.midY)
return NSScreen.screens.first { screen in
screen.frame.contains(anchor.origin) || screen.frame.contains(center)
}
}
static func screenContainingMouseCursor() -> NSScreen? {
let point = NSEvent.mouseLocation
return NSScreen.screens.first { $0.frame.contains(point) }
}
static func isFrameMeaningfullyVisible(_ frame: NSRect, on screen: NSScreen) -> Bool {
frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12))
}
static func constrainFrame(_ frame: NSRect, toVisibleFrame bounds: NSRect) -> NSRect {
if bounds == .zero { return frame }
var next = frame
next.size.width = min(max(CanvasLayout.minPanelSize.width, next.size.width), bounds.width)
next.size.height = min(max(CanvasLayout.minPanelSize.height, next.size.height), bounds.height)
let maxX = bounds.maxX - next.size.width
let maxY = bounds.maxY - next.size.height
next.origin.x = maxX >= bounds.minX ? min(max(next.origin.x, bounds.minX), maxX) : bounds.minX
next.origin.y = maxY >= bounds.minY ? min(max(next.origin.y, bounds.minY), maxY) : bounds.minY
next.origin.x = round(next.origin.x)
next.origin.y = round(next.origin.y)
return next
}
// MARK: - NSWindowDelegate
func windowWillClose(_: Notification) {
self.onVisibilityChanged?(false)
}
func windowDidMove(_: Notification) {
self.persistFrameIfPanel()
}
func windowDidEndLiveResize(_: Notification) {
self.persistFrameIfPanel()
}
func persistFrameIfPanel() {
guard case .panel = self.presentation, let window else { return }
Self.storeRestoredFrame(window.frame, sessionKey: self.sessionKey)
}
}

View File

@@ -0,0 +1,336 @@
import AppKit
import Foundation
import OpenClawIPC
import OpenClawKit
import WebKit
@MainActor
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
let sessionKey: String
private let root: URL
private let sessionDir: URL
private let schemeHandler: CanvasSchemeHandler
let webView: WKWebView
private var a2uiActionMessageHandler: CanvasA2UIActionMessageHandler?
private let watcher: CanvasFileWatcher
private let container: HoverChromeContainerView
let presentation: CanvasPresentation
var preferredPlacement: CanvasPlacement?
private(set) var currentTarget: String?
private var debugStatusEnabled = false
private var debugStatusTitle: String?
private var debugStatusSubtitle: String?
var onVisibilityChanged: ((Bool) -> Void)?
init(sessionKey: String, root: URL, presentation: CanvasPresentation) throws {
self.sessionKey = sessionKey
self.root = root
self.presentation = presentation
canvasWindowLogger.debug("CanvasWindowController init start session=\(sessionKey, privacy: .public)")
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
try FileManager().createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
self.schemeHandler = CanvasSchemeHandler(root: root)
canvasWindowLogger.debug("CanvasWindowController init scheme handler ready")
let config = WKWebViewConfiguration()
config.userContentController = WKUserContentController()
config.preferences.isElementFullscreenEnabled = true
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
canvasWindowLogger.debug("CanvasWindowController init config ready")
for scheme in CanvasScheme.allSchemes {
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: scheme)
}
canvasWindowLogger.debug("CanvasWindowController init scheme handler installed")
// Bridge A2UI "a2uiaction" DOM events back into the native agent loop.
//
// Keep the bridge on the trusted in-app canvas scheme only, and do not
// expose unattended deep-link credentials to page JavaScript.
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
let allowedSchemesJSON = (
try? String(
data: JSONSerialization.data(withJSONObject: CanvasScheme.allSchemes),
encoding: .utf8)) ?? "[]"
let bridgeScript = """
(() => {
try {
const allowedSchemes = \(allowedSchemesJSON);
const protocol = location.protocol.replace(':', '');
if (!allowedSchemes.includes(protocol)) return;
if (globalThis.__openclawA2UIBridgeInstalled) return;
globalThis.__openclawA2UIBridgeInstalled = true;
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName));
const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId));
globalThis.addEventListener('a2uiaction', (evt) => {
try {
const payload = evt?.detail ?? evt?.payload ?? null;
if (!payload || payload.eventType !== 'a2ui.action') return;
const action = payload.action ?? null;
const name = action?.name ?? '';
if (!name) return;
const context = Array.isArray(action?.context) ? action.context : [];
const userAction = {
id: (globalThis.crypto?.randomUUID?.() ?? String(Date.now())),
name,
surfaceId: payload.surfaceId ?? 'main',
sourceComponentId: payload.sourceComponentId ?? '',
dataContextPath: payload.dataContextPath ?? '',
timestamp: new Date().toISOString(),
...(context.length ? { context } : {}),
};
const handler = globalThis.webkit?.messageHandlers?.openclawCanvasA2UIAction;
// If the bundled A2UI shell is present, let it forward actions so we keep its richer
// context resolution (data model path lookups, surface detection, etc.).
const hasBundledA2UIHost =
!!globalThis.openclawA2UI ||
!!document.querySelector('openclaw-a2ui-host');
if (hasBundledA2UIHost && handler?.postMessage) return;
// Otherwise, forward directly when possible.
if (!hasBundledA2UIHost && handler?.postMessage) {
handler.postMessage({ userAction });
return;
}
// Without the native handler, fail closed instead of exposing an
// unattended deep-link credential to page JavaScript.
} catch {}
}, true);
} catch {}
})();
"""
config.userContentController.addUserScript(
WKUserScript(source: bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
canvasWindowLogger.debug("CanvasWindowController init A2UI bridge installed")
canvasWindowLogger.debug("CanvasWindowController init creating WKWebView")
self.webView = WKWebView(frame: .zero, configuration: config)
// Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays.
self.webView.setValue(true, forKey: "drawsBackground")
let sessionDir = self.sessionDir
let webView = self.webView
self.watcher = CanvasFileWatcher(url: sessionDir) { [weak webView] in
Task { @MainActor in
guard let webView else { return }
// Only auto-reload when we are showing local canvas content.
guard let scheme = webView.url?.scheme,
CanvasScheme.allSchemes.contains(scheme) else { return }
let path = webView.url?.path ?? ""
if path == "/" || path.isEmpty {
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager().fileExists(atPath: indexA.path),
!FileManager().fileExists(atPath: indexB.path)
{
return
}
}
webView.reload()
}
}
self.container = HoverChromeContainerView(containing: self.webView)
let window = Self.makeWindow(for: presentation, contentView: self.container)
canvasWindowLogger.debug("CanvasWindowController init makeWindow done")
super.init(window: window)
let handler = CanvasA2UIActionMessageHandler(sessionKey: sessionKey)
self.a2uiActionMessageHandler = handler
for name in CanvasA2UIActionMessageHandler.allMessageNames {
self.webView.configuration.userContentController.add(handler, name: name)
}
self.webView.navigationDelegate = self
self.webView.uiDelegate = self
self.window?.delegate = self
self.container.onClose = { [weak self] in
self?.hideCanvas()
}
self.watcher.start()
canvasWindowLogger.debug("CanvasWindowController init done")
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
@MainActor deinit {
for name in CanvasA2UIActionMessageHandler.allMessageNames {
self.webView.configuration.userContentController.removeScriptMessageHandler(forName: name)
}
self.watcher.stop()
}
func applyPreferredPlacement(_ placement: CanvasPlacement?) {
self.preferredPlacement = placement
}
func showCanvas(path: String? = nil) {
if case let .panel(anchorProvider) = self.presentation {
self.presentAnchoredPanel(anchorProvider: anchorProvider)
if let path {
self.load(target: path)
}
return
}
self.showWindow(nil)
self.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
if let path {
self.load(target: path)
}
self.onVisibilityChanged?(true)
}
func hideCanvas() {
if case .panel = self.presentation {
self.persistFrameIfPanel()
}
self.window?.orderOut(nil)
self.onVisibilityChanged?(false)
}
func load(target: String) {
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
self.currentTarget = trimmed
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() {
if scheme == "https" || scheme == "http" {
canvasWindowLogger.debug("canvas load url \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
return
}
if scheme == "file" {
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url)
return
}
}
// Convenience: absolute file paths resolve as local files when they exist.
// (Avoid treating Canvas routes like "/" as filesystem paths.)
if trimmed.hasPrefix("/") {
var isDir: ObjCBool = false
if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
let url = URL(fileURLWithPath: trimmed)
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url)
return
}
}
guard let url = CanvasScheme.makeURL(
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
path: trimmed)
else {
canvasWindowLogger
.error(
"invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
return
}
canvasWindowLogger.debug("canvas load canvas \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
}
func updateDebugStatus(enabled: Bool, title: String?, subtitle: String?) {
self.debugStatusEnabled = enabled
self.debugStatusTitle = title
self.debugStatusSubtitle = subtitle
self.applyDebugStatusIfNeeded()
}
func applyDebugStatusIfNeeded() {
WebViewJavaScriptSupport.applyDebugStatus(
webView: self.webView,
enabled: self.debugStatusEnabled,
title: self.debugStatusTitle,
subtitle: self.debugStatusSubtitle)
}
private func loadFile(_ url: URL) {
let fileURL = url.isFileURL ? url : URL(fileURLWithPath: url.path)
let accessDir = fileURL.deletingLastPathComponent()
self.webView.loadFileURL(fileURL, allowingReadAccessTo: accessDir)
}
func eval(javaScript: String) async throws -> String {
try await WebViewJavaScriptSupport.evaluateToString(webView: self.webView, javaScript: javaScript)
}
func snapshot(to outPath: String?) async throws -> String {
let image: NSImage = try await withCheckedThrowingContinuation { cont in
self.webView.takeSnapshot(with: nil) { image, error in
if let error {
cont.resume(throwing: error)
return
}
guard let image else {
cont.resume(throwing: NSError(domain: "Canvas", code: 11, userInfo: [
NSLocalizedDescriptionKey: "snapshot returned nil image",
]))
return
}
cont.resume(returning: image)
}
}
guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let png = rep.representation(using: .png, properties: [:])
else {
throw NSError(domain: "Canvas", code: 12, userInfo: [
NSLocalizedDescriptionKey: "failed to encode png",
])
}
let path: String
if let outPath, !outPath.isEmpty {
path = outPath
} else {
let ts = Int(Date().timeIntervalSince1970)
path = "/tmp/openclaw-canvas-\(CanvasWindowController.sanitizeSessionKey(self.sessionKey))-\(ts).png"
}
try png.write(to: URL(fileURLWithPath: path), options: [.atomic])
return path
}
var directoryPath: String {
self.sessionDir.path
}
func shouldAutoNavigateToA2UI(lastAutoTarget: String?, candidateTarget: String) -> Bool {
let current = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let candidate = candidateTarget.trimmingCharacters(in: .whitespacesAndNewlines)
if current.isEmpty || current == "/" { return true }
if !candidate.isEmpty, current == candidate { return false }
if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines),
!lastAuto.isEmpty,
current == lastAuto
{
return true
}
return false
}
}

View File

@@ -0,0 +1,639 @@
import SwiftUI
enum ConfigSchemaFormMode {
case full
case channelQuick
}
struct ConfigSchemaForm: View {
@Bindable var store: ChannelsStore
let schema: ConfigSchemaNode
let path: ConfigPath
let mode: ConfigSchemaFormMode
init(
store: ChannelsStore,
schema: ConfigSchemaNode,
path: ConfigPath,
mode: ConfigSchemaFormMode = .full)
{
self.store = store
self.schema = schema
self.path = path
self.mode = mode
}
var body: some View {
self.renderNode(self.schema, path: self.path)
}
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
let storedValue = self.store.configValue(at: path)
let value = storedValue ?? schema.explicitDefault
let label = self.fieldLabel(for: schema, path: path)
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf
if !variants.isEmpty {
let nonNull = variants.filter { !$0.isNullSchema }
if nonNull.count == 1, let only = nonNull.first {
return self.renderNode(only, path: path)
}
let literals = nonNull.compactMap(\.literalValue)
if !literals.isEmpty, literals.count == nonNull.count {
return AnyView(
self.renderEnumField(
path: path,
options: literals,
defaultValue: schema.explicitDefault,
label: label,
help: help))
}
}
if let options = schema.enumValues, !options.isEmpty {
return AnyView(
self.renderEnumField(
path: path,
options: options,
defaultValue: schema.explicitDefault,
label: label,
help: help))
}
switch schema.schemaType {
case "object":
if self.mode == .channelQuick, self.isChannelRoot(path) {
return AnyView(self.renderChannelQuickObject(schema, path: path, value: value))
}
let showHeader = !self.isNestedChannelQuickConfigurationObject(path: path, label: label)
return AnyView(
VStack(alignment: .leading, spacing: 12) {
if showHeader, let label {
Text(label)
.font(.callout.weight(.semibold))
}
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
let properties = schema.properties
let sortedKeys = self.visibleObjectKeys(properties: properties, path: path)
ForEach(sortedKeys, id: \.self) { key in
if let child = properties[key] {
self.renderNode(child, path: path + [.key(key)])
}
}
if sortedKeys.isEmpty, self.mode == .channelQuick, self.isChannelRoot(path) {
self.renderChannelQuickEmptyState()
}
if self.shouldRenderAdditionalProperties(schema, path: path, value: value) {
self.renderAdditionalProperties(schema, path: path, value: value)
}
})
case "array":
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
case "boolean":
if self.isChannelQuickLeaf(path) {
return AnyView(
SettingsCardToggleRow(
title: label ?? "Enabled",
subtitle: help,
binding: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)))
}
return AnyView(
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
if let label { Text(label) } else { Text("Enabled") }
}
.help(help ?? ""))
case "number", "integer":
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
case "string":
return AnyView(self.renderStringField(schema, path: path, label: label, help: help))
default:
if schema.literalValue != nil {
return AnyView(EmptyView())
}
return AnyView(
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
Text("Unsupported field type.")
.font(.caption)
.foregroundStyle(.secondary)
})
}
}
private func fieldLabel(for schema: ConfigSchemaNode, path: ConfigPath) -> String? {
let label = hintForPath(path, hints: self.store.configUiHints)?.label
?? schema.title
?? labelForConfigPath(path)
return self.cleanedChannelQuickLabel(label, path: path)
}
private func cleanedChannelQuickLabel(_ label: String?, path: ConfigPath) -> String? {
guard self.mode == .channelQuick, path.count >= 3 else { return label }
guard case let .key(channelId) = path[1] else { return label }
guard let label else { return nil }
let prefix = humanizeConfigKey(channelId) + " "
guard label.hasPrefix(prefix) else { return label }
return String(label.dropFirst(prefix.count))
}
private func visibleObjectKeys(
properties: [String: ConfigSchemaNode],
path: ConfigPath) -> [String]
{
let sortedKeys = properties.keys.sorted { lhs, rhs in
let orderA = hintForPath(path + [.key(lhs)], hints: store.configUiHints)?.order ?? 0
let orderB = hintForPath(path + [.key(rhs)], hints: store.configUiHints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
}
guard self.mode == .channelQuick, self.isChannelRoot(path) else {
return sortedKeys
}
return sortedKeys.filter { key in
guard let child = properties[key] else { return false }
return self.shouldRenderChannelQuickField(key: key, schema: child, path: path + [.key(key)])
}
}
private func shouldRenderChannelQuickField(
key: String,
schema: ConfigSchemaNode,
path: ConfigPath) -> Bool
{
if hintForPath(path, hints: self.store.configUiHints)?.advanced == true {
return false
}
if Self.channelQuickKeys.contains(key) {
return self.isSimpleField(schema)
}
return self.store.configValue(at: path) != nil && self.isSimpleField(schema)
}
private func isSimpleField(_ schema: ConfigSchemaNode) -> Bool {
let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf
let nonNullVariants = variants.filter { !$0.isNullSchema }
if !nonNullVariants.isEmpty {
if nonNullVariants.count == 1, let only = nonNullVariants.first {
return self.isSimpleField(only)
}
return nonNullVariants.allSatisfy { $0.literalValue != nil }
}
if let enumValues = schema.enumValues {
return !enumValues.isEmpty
}
switch schema.schemaType {
case "boolean", "integer", "number", "string":
return true
default:
return false
}
}
private func shouldRenderAdditionalProperties(
_ schema: ConfigSchemaNode,
path: ConfigPath,
value: Any?) -> Bool
{
guard schema.allowsAdditionalProperties else { return false }
if self.mode != .channelQuick { return true }
guard let dict = value as? [String: Any] else { return false }
let reserved = Set(schema.properties.keys)
return dict.keys.contains { !reserved.contains($0) }
}
private func isChannelQuickLeaf(_ path: ConfigPath) -> Bool {
guard self.mode == .channelQuick, path.count == 3 else { return false }
guard case .key("channels") = path[0] else { return false }
guard case .key = path[1], case .key = path[2] else { return false }
return true
}
private func isChannelRoot(_ path: ConfigPath) -> Bool {
guard path.count == 2 else { return false }
guard case .key("channels") = path[0] else { return false }
guard case .key = path[1] else { return false }
return true
}
private func isNestedChannelQuickConfigurationObject(path: ConfigPath, label: String?) -> Bool {
guard self.mode == .channelQuick, path.count == 3 else { return false }
guard case .key("channels") = path[0] else { return false }
guard case .key = path[1] else { return false }
guard label == "Configuration" else { return false }
return true
}
@ViewBuilder
private func renderChannelQuickObject(
_ schema: ConfigSchemaNode,
path: ConfigPath,
value: Any?) -> some View
{
let properties = schema.properties
let sortedKeys = self.visibleObjectKeys(properties: properties, path: path)
VStack(alignment: .leading, spacing: 16) {
if sortedKeys.isEmpty {
self.renderChannelQuickEmptyState()
} else {
SettingsCardGroup("Configuration") {
ForEach(sortedKeys, id: \.self) { key in
if let child = properties[key] {
self.renderNode(child, path: path + [.key(key)])
}
}
}
}
if self.shouldRenderAdditionalProperties(schema, path: path, value: value) {
self.renderAdditionalProperties(schema, path: path, value: value)
}
}
}
private func renderChannelQuickEmptyState() -> some View {
VStack(alignment: .leading, spacing: 4) {
Text("No quick settings for this channel.")
.font(.callout.weight(.semibold))
Text("Use Config for account, guild, action, and policy details.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private static let channelQuickKeys: Set<String> = [
"apiHash",
"apiId",
"appToken",
"baseUrl",
"botToken",
"configWrites",
"deviceName",
"dmPolicy",
"enabled",
"groupPolicy",
"historyLimit",
"mode",
"nativeCommands",
"nativeSkillCommands",
"phoneNumber",
"signingSecret",
"token",
"url",
"username",
"webhookUrl",
]
private func renderChannelQuickField(
title: String?,
subtitle: String?,
@ViewBuilder control: () -> some View) -> some View
{
SettingsCardRow(title: title ?? "Value", subtitle: subtitle) {
control()
}
}
@ViewBuilder
private func renderEnumField(
path: ConfigPath,
options: [Any],
defaultValue: Any?,
label: String?,
help: String?) -> some View
{
let picker = Picker(
"",
selection: self.enumBinding(
path,
options: options,
defaultValue: defaultValue))
{
Text("Select…").tag(-1)
ForEach(options.indices, id: \.self) { index in
Text(String(describing: options[index])).tag(index)
}
}
.pickerStyle(.menu)
if self.isChannelQuickLeaf(path) {
self.renderChannelQuickField(title: label, subtitle: help) {
picker
.labelsHidden()
.frame(width: 180)
}
} else {
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
picker
}
}
}
@ViewBuilder
private func renderStringField(
_ schema: ConfigSchemaNode,
path: ConfigPath,
label: String?,
help: String?) -> some View
{
let hint = hintForPath(path, hints: store.configUiHints)
let placeholder = hint?.placeholder ?? ""
let sensitive = hint?.sensitive ?? isSensitivePath(path)
let defaultValue = schema.explicitDefault as? String
if self.isChannelQuickLeaf(path) {
self.renderChannelQuickField(title: label, subtitle: help) {
if sensitive {
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
.frame(width: 260)
} else {
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
.frame(width: 260)
}
}
} else {
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
if sensitive {
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
} else {
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
}
}
}
@ViewBuilder
private func renderNumberField(
_ schema: ConfigSchemaNode,
path: ConfigPath,
label: String?,
help: String?) -> some View
{
let defaultValue = (schema.explicitDefault as? Double)
?? (schema.explicitDefault as? Int).map(Double.init)
if self.isChannelQuickLeaf(path) {
self.renderChannelQuickField(title: label, subtitle: help) {
TextField(
"",
text: self.numberBinding(
path,
isInteger: schema.schemaType == "integer",
defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
.frame(width: 120)
}
} else {
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
TextField(
"",
text: self.numberBinding(
path,
isInteger: schema.schemaType == "integer",
defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
}
}
@ViewBuilder
private func renderArray(
_ schema: ConfigSchemaNode,
path: ConfigPath,
value: Any?,
label: String?,
help: String?) -> some View
{
let items = value as? [Any] ?? []
let itemSchema = schema.items
VStack(alignment: .leading, spacing: 10) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(items.indices, id: \.self) { index in
HStack(alignment: .top, spacing: 8) {
if let itemSchema {
self.renderNode(itemSchema, path: path + [.index(index)])
} else {
Text(String(describing: items[index]))
}
Button("Remove") {
var next = items
next.remove(at: index)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Button("Add") {
var next = items
if let itemSchema {
next.append(itemSchema.defaultValue)
} else {
next.append("")
}
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
@ViewBuilder
private func renderAdditionalProperties(
_ schema: ConfigSchemaNode,
path: ConfigPath,
value: Any?) -> some View
{
if let additionalSchema = schema.additionalProperties {
let dict = value as? [String: Any] ?? [:]
let reserved = Set(schema.properties.keys)
let extras = dict.keys.filter { !reserved.contains($0) }.sorted()
VStack(alignment: .leading, spacing: 8) {
Text("Extra entries")
.font(.callout.weight(.semibold))
if extras.isEmpty {
Text("No extra entries yet.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(extras, id: \.self) { key in
let itemPath: ConfigPath = path + [.key(key)]
HStack(alignment: .top, spacing: 8) {
TextField("Key", text: self.mapKeyBinding(path: path, key: key))
.textFieldStyle(.roundedBorder)
.frame(width: 160)
self.renderNode(additionalSchema, path: itemPath)
Button("Remove") {
var next = dict
next.removeValue(forKey: key)
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
Button("Add") {
var next = dict
var index = 1
var key = "new-\(index)"
while next[key] != nil {
index += 1
key = "new-\(index)"
}
next[key] = additionalSchema.defaultValue
self.store.updateConfigValue(path: path, value: next)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
private func stringBinding(_ path: ConfigPath, defaultValue: String?) -> Binding<String> {
Binding(
get: {
if let value = store.configValue(at: path) as? String { return value }
return defaultValue ?? ""
},
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
})
}
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
Binding(
get: {
if let value = store.configValue(at: path) as? Bool { return value }
return defaultValue ?? false
},
set: { newValue in
self.store.updateConfigValue(path: path, value: newValue)
})
}
private func numberBinding(
_ path: ConfigPath,
isInteger: Bool,
defaultValue: Double?) -> Binding<String>
{
Binding(
get: {
if let value = store.configValue(at: path) { return String(describing: value) }
guard let defaultValue else { return "" }
return isInteger ? String(Int(defaultValue)) : String(defaultValue)
},
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
self.store.updateConfigValue(path: path, value: nil)
} else if let value = Double(trimmed) {
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
}
})
}
private func enumBinding(
_ path: ConfigPath,
options: [Any],
defaultValue: Any?) -> Binding<Int>
{
Binding(
get: {
let value = self.store.configValue(at: path) ?? defaultValue
guard let value else { return -1 }
return options.firstIndex { option in
String(describing: option) == String(describing: value)
} ?? -1
},
set: { index in
guard index >= 0, index < options.count else {
self.store.updateConfigValue(path: path, value: nil)
return
}
self.store.updateConfigValue(path: path, value: options[index])
})
}
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
Binding(
get: { key },
set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard trimmed != key else { return }
let current = self.store.configValue(at: path) as? [String: Any] ?? [:]
guard current[trimmed] == nil else { return }
var next = current
next[trimmed] = current[key]
next.removeValue(forKey: key)
self.store.updateConfigValue(path: path, value: next)
})
}
}
struct ChannelConfigForm: View {
@Bindable var store: ChannelsStore
let channelId: String
var body: some View {
if self.store.configSchemaLoading {
SettingsCardGroup("Configuration") {
SettingsCardRow(title: "Loading channel settings", subtitle: nil, showsDivider: false) {
ProgressView()
.controlSize(.small)
}
}
} else if let schema = store.channelConfigSchema(for: channelId) {
ConfigSchemaForm(
store: self.store,
schema: schema,
path: [.key("channels"), .key(self.channelId)],
mode: .channelQuick)
} else {
SettingsCardGroup("Configuration") {
SettingsCardRow(
title: "Schema unavailable",
subtitle: "OpenClaw could not load editable settings for this channel.",
showsDivider: false)
{
EmptyView()
}
}
}
}
}

View File

@@ -0,0 +1,135 @@
import SwiftUI
extension ChannelsSettings {
func formSection(_ title: String, @ViewBuilder content: () -> some View) -> some View {
GroupBox(title) {
VStack(alignment: .leading, spacing: 10) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
func channelHeaderActions(_ channel: ChannelItem) -> some View {
HStack(spacing: 8) {
if channel.id == "whatsapp" {
Button("Logout") {
Task { await self.store.logoutWhatsApp() }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
if channel.id == "telegram" {
Button("Logout") {
Task { await self.store.logoutTelegram() }
}
.buttonStyle(.bordered)
.disabled(self.store.telegramBusy)
}
Button {
Task { await self.store.refresh(probe: true) }
} label: {
if self.store.isRefreshing {
ProgressView().controlSize(.small)
} else {
Text("Refresh")
}
}
.buttonStyle(.bordered)
.disabled(self.store.isRefreshing)
}
.controlSize(.small)
}
var whatsAppSection: some View {
VStack(alignment: .leading, spacing: 16) {
self.formSection("Linking") {
if let message = self.store.whatsappLoginMessage {
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let qr = self.store.whatsappLoginQrDataUrl, let image = self.qrImage(from: qr) {
Image(nsImage: image)
.resizable()
.interpolation(.none)
.frame(width: 180, height: 180)
.cornerRadius(8)
}
HStack(spacing: 12) {
Button {
Task { await self.store.startWhatsAppLogin(force: false) }
} label: {
if self.store.whatsappBusy {
ProgressView().controlSize(.small)
} else {
Text("Show QR")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.whatsappBusy)
Button("Relink") {
Task { await self.store.startWhatsAppLogin(force: true) }
}
.buttonStyle(.bordered)
.disabled(self.store.whatsappBusy)
}
.font(.caption)
}
self.configEditorSection(channelId: "whatsapp")
}
}
func genericChannelSection(_ channel: ChannelItem) -> some View {
VStack(alignment: .leading, spacing: 16) {
self.configEditorSection(channelId: channel.id)
}
}
@ViewBuilder
private func configEditorSection(channelId: String) -> some View {
ChannelConfigForm(store: self.store, channelId: channelId)
self.configStatusMessage
HStack(spacing: 12) {
Button {
Task { await self.store.saveConfigDraft() }
} label: {
if self.store.isSavingConfig {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.store.isSavingConfig || !self.store.configDirty)
Button("Reload") {
Task { await self.store.reloadConfigDraft() }
}
.buttonStyle(.bordered)
.disabled(self.store.isSavingConfig)
Spacer()
}
.font(.caption)
}
@ViewBuilder
var configStatusMessage: some View {
if let status = self.store.configStatus {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}

View File

@@ -0,0 +1,550 @@
import OpenClawProtocol
import SwiftUI
extension ChannelsSettings {
private func channelStatus<T: Decodable>(
_ id: String,
as type: T.Type) -> T?
{
self.store.decodedChannel(id, as: type)
}
private func configuredChannelTint(configured: Bool, running: Bool, hasError: Bool, probeOk: Bool?) -> Color {
if !configured { return .secondary }
if hasError { return .orange }
if probeOk == false { return .orange }
if running { return .green }
return .orange
}
private func configuredChannelSummary(configured: Bool, running: Bool) -> String {
if !configured { return "Not configured" }
if running { return "Running" }
return "Configured"
}
private func appendProbeDetails(
lines: inout [String],
probeOk: Bool?,
probeStatus: Int?,
probeElapsedMs: Double?,
probeVersion: String? = nil,
probeError: String? = nil,
lastProbeAtMs: Double?,
lastError: String?)
{
if let probeOk {
if probeOk {
if let version = probeVersion, !version.isEmpty {
lines.append("Version \(version)")
}
if let elapsed = probeElapsedMs {
lines.append("Probe \(Int(elapsed))ms")
}
} else if let probeError, !probeError.isEmpty {
lines.append("Probe error: \(probeError)")
} else {
let code = probeStatus.map { String($0) } ?? "unknown"
lines.append("Probe failed (\(code))")
}
}
if let last = self.date(fromMs: lastProbeAtMs) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let lastError, !lastError.isEmpty {
lines.append("Error: \(lastError)")
}
}
private func finishDetails(
lines: inout [String],
probeOk: Bool?,
probeStatus: Int?,
probeElapsedMs: Double?,
probeVersion: String? = nil,
probeError: String? = nil,
lastProbeAtMs: Double?,
lastError: String?) -> String?
{
self.appendProbeDetails(
lines: &lines,
probeOk: probeOk,
probeStatus: probeStatus,
probeElapsedMs: probeElapsedMs,
probeVersion: probeVersion,
probeError: probeError,
lastProbeAtMs: lastProbeAtMs,
lastError: lastError)
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
private func finishProbeDetails(
lines: inout [String],
probe: (ok: Bool?, status: Int?, elapsedMs: Double?),
lastProbeAtMs: Double?,
lastError: String?) -> String?
{
self.finishDetails(
lines: &lines,
probeOk: probe.ok,
probeStatus: probe.status,
probeElapsedMs: probe.elapsedMs,
lastProbeAtMs: lastProbeAtMs,
lastError: lastError)
}
var whatsAppTint: Color {
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if !status.linked { return .red }
if status.lastError != nil { return .orange }
if status.connected { return .green }
if status.running { return .orange }
return .orange
}
var telegramTint: Color {
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return .secondary }
return self.configuredChannelTint(
configured: status.configured,
running: status.running,
hasError: status.lastError != nil,
probeOk: status.probe?.ok)
}
var discordTint: Color {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return .secondary }
return self.configuredChannelTint(
configured: status.configured,
running: status.running,
hasError: status.lastError != nil,
probeOk: status.probe?.ok)
}
var googlechatTint: Color {
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return .secondary }
return self.configuredChannelTint(
configured: status.configured,
running: status.running,
hasError: status.lastError != nil,
probeOk: status.probe?.ok)
}
var signalTint: Color {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return .secondary }
return self.configuredChannelTint(
configured: status.configured,
running: status.running,
hasError: status.lastError != nil,
probeOk: status.probe?.ok)
}
var imessageTint: Color {
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return .secondary }
return self.configuredChannelTint(
configured: status.configured,
running: status.running,
hasError: status.lastError != nil,
probeOk: status.probe?.ok)
}
var whatsAppSummary: String {
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return "Checking…" }
if !status.linked { return "Not linked" }
if status.connected { return "Connected" }
if status.running { return "Running" }
return "Linked"
}
var telegramSummary: String {
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return "Checking…" }
return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var discordSummary: String {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return "Checking…" }
return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var googlechatSummary: String {
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return "Checking…" }
return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var signalSummary: String {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return "Checking…" }
return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var imessageSummary: String {
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return "Checking…" }
return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var whatsAppDetails: String? {
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return nil }
var lines: [String] = []
if let e164 = status.`self`?.e164 ?? status.`self`?.jid {
lines.append("Linked as \(e164)")
}
if let age = status.authAgeMs {
lines.append("Auth age \(msToAge(age))")
}
if let last = self.date(fromMs: status.lastConnectedAt) {
lines.append("Last connect \(relativeAge(from: last))")
}
if let disconnect = status.lastDisconnect {
let when = self.date(fromMs: disconnect.at).map { relativeAge(from: $0) } ?? "unknown"
let code = disconnect.status.map { "status \($0)" } ?? "status unknown"
let err = disconnect.error ?? "disconnect"
lines.append("Last disconnect \(code) · \(err) · \(when)")
}
if status.reconnectAttempts > 0 {
lines.append("Reconnect attempts \(status.reconnectAttempts)")
}
if let msgAt = self.date(fromMs: status.lastMessageAt) {
lines.append("Last message \(relativeAge(from: msgAt))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
var telegramDetails: String? {
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return nil }
var lines: [String] = []
if let source = status.tokenSource {
lines.append("Token source: \(source)")
}
if let mode = status.mode {
lines.append("Mode: \(mode)")
}
if let probe = status.probe {
if probe.ok {
if let name = probe.bot?.username {
lines.append("Bot: @\(name)")
}
if let url = probe.webhook?.url, !url.isEmpty {
lines.append("Webhook: \(url)")
}
}
}
return self.finishDetails(
lines: &lines,
probeOk: status.probe?.ok,
probeStatus: status.probe?.status,
probeElapsedMs: nil,
lastProbeAtMs: status.lastProbeAt,
lastError: status.lastError)
}
var discordDetails: String? {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return nil }
var lines: [String] = []
if let source = status.tokenSource {
lines.append("Token source: \(source)")
}
if let name = status.probe?.bot?.username, !name.isEmpty {
lines.append("Bot: @\(name)")
}
return self.finishProbeDetails(
lines: &lines,
probe: (
ok: status.probe?.ok,
status: status.probe?.status,
elapsedMs: status.probe?.elapsedMs),
lastProbeAtMs: status.lastProbeAt,
lastError: status.lastError)
}
var googlechatDetails: String? {
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return nil }
var lines: [String] = []
if let source = status.credentialSource {
lines.append("Credential: \(source)")
}
if let audienceType = status.audienceType {
let audience = status.audience ?? ""
let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)"
lines.append("Audience: \(label)")
}
return self.finishProbeDetails(
lines: &lines,
probe: (
ok: status.probe?.ok,
status: status.probe?.status,
elapsedMs: status.probe?.elapsedMs),
lastProbeAtMs: status.lastProbeAt,
lastError: status.lastError)
}
var signalDetails: String? {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return nil }
var lines: [String] = []
lines.append("Base URL: \(status.baseUrl)")
return self.finishDetails(
lines: &lines,
probeOk: status.probe?.ok,
probeStatus: status.probe?.status,
probeElapsedMs: status.probe?.elapsedMs,
probeVersion: status.probe?.version,
lastProbeAtMs: status.lastProbeAt,
lastError: status.lastError)
}
var imessageDetails: String? {
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return nil }
var lines: [String] = []
if let cliPath = status.cliPath, !cliPath.isEmpty {
lines.append("CLI: \(cliPath)")
}
if let dbPath = status.dbPath, !dbPath.isEmpty {
lines.append("DB: \(dbPath)")
}
return self.finishDetails(
lines: &lines,
probeOk: status.probe?.ok,
probeStatus: nil,
probeElapsedMs: nil,
probeError: status.probe?.error,
lastProbeAtMs: status.lastProbeAt,
lastError: status.lastError)
}
var orderedChannels: [ChannelItem] {
let fallback = ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage"]
let order = self.store.snapshot?.channelOrder ?? fallback
let channels = order.enumerated().map { index, id in
ChannelItem(
id: id,
title: self.resolveChannelTitle(id),
detailTitle: self.resolveChannelDetailTitle(id),
systemImage: self.resolveChannelSystemImage(id),
sortOrder: index)
}
return channels.sorted { lhs, rhs in
let lhsEnabled = self.channelEnabled(lhs)
let rhsEnabled = self.channelEnabled(rhs)
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
return lhs.sortOrder < rhs.sortOrder
}
}
var enabledChannels: [ChannelItem] {
self.orderedChannels.filter { self.channelEnabled($0) }
}
var availableChannels: [ChannelItem] {
self.orderedChannels.filter { !self.channelEnabled($0) }
}
func ensureSelection() {
self.ensureSelection(in: self.orderedChannels)
}
func ensureSelection(in orderedChannels: [ChannelItem]) {
guard let selected = self.selectedChannel else {
self.selectedChannel = orderedChannels.first
return
}
if !orderedChannels.contains(selected) {
self.selectedChannel = orderedChannels.first
}
}
func channelEnabled(_ channel: ChannelItem) -> Bool {
let status = self.channelStatusDictionary(channel.id)
let configured = status?["configured"]?.boolValue ?? false
let running = status?["running"]?.boolValue ?? false
let connected = status?["connected"]?.boolValue ?? false
let accountActive = self.store.snapshot?.channelAccounts[channel.id]?.contains(
where: { $0.configured == true || $0.running == true || $0.connected == true }) ?? false
return configured || running || connected || accountActive
}
@ViewBuilder
func channelSection(_ channel: ChannelItem) -> some View {
if channel.id == "whatsapp" {
self.whatsAppSection
} else {
self.genericChannelSection(channel)
}
}
func channelTint(_ channel: ChannelItem) -> Color {
switch channel.id {
case "whatsapp":
return self.whatsAppTint
case "telegram":
return self.telegramTint
case "discord":
return self.discordTint
case "googlechat":
return self.googlechatTint
case "signal":
return self.signalTint
case "imessage":
return self.imessageTint
default:
if self.channelHasError(channel) { return .orange }
if self.channelEnabled(channel) { return .green }
return .secondary
}
}
func channelSummary(_ channel: ChannelItem) -> String {
switch channel.id {
case "whatsapp":
return self.whatsAppSummary
case "telegram":
return self.telegramSummary
case "discord":
return self.discordSummary
case "googlechat":
return self.googlechatSummary
case "signal":
return self.signalSummary
case "imessage":
return self.imessageSummary
default:
if self.channelHasError(channel) { return "Error" }
if self.channelEnabled(channel) { return "Active" }
return "Not configured"
}
}
func channelDetails(_ channel: ChannelItem) -> String? {
switch channel.id {
case "whatsapp":
return self.whatsAppDetails
case "telegram":
return self.telegramDetails
case "discord":
return self.discordDetails
case "googlechat":
return self.googlechatDetails
case "signal":
return self.signalDetails
case "imessage":
return self.imessageDetails
default:
let status = self.channelStatusDictionary(channel.id)
if let err = status?["lastError"]?.stringValue, !err.isEmpty {
return "Error: \(err)"
}
return nil
}
}
func channelLastCheckText(_ channel: ChannelItem) -> String {
guard let date = self.channelLastCheck(channel) else { return "never" }
return relativeAge(from: date)
}
func channelLastCheck(_ channel: ChannelItem) -> Date? {
switch channel.id {
case "whatsapp":
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return nil }
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
case "telegram":
return self
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
.lastProbeAt)
case "discord":
return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt)
case "googlechat":
return self
.date(fromMs: self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)?
.lastProbeAt)
case "signal":
return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
case "imessage":
return self
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
.lastProbeAt)
default:
let status = self.channelStatusDictionary(channel.id)
if let probeAt = status?["lastProbeAt"]?.doubleValue {
return self.date(fromMs: probeAt)
}
if let accounts = self.store.snapshot?.channelAccounts[channel.id] {
let last = accounts.compactMap { $0.lastInboundAt ?? $0.lastOutboundAt }.max()
return self.date(fromMs: last)
}
return nil
}
}
func channelHasError(_ channel: ChannelItem) -> Bool {
switch channel.id {
case "whatsapp":
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
case "telegram":
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case "discord":
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case "googlechat":
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case "signal":
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case "imessage":
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
default:
let status = self.channelStatusDictionary(channel.id)
return status?["lastError"]?.stringValue?.isEmpty == false
}
}
private func resolveChannelTitle(_ id: String) -> String {
let label = self.store.resolveChannelLabel(id)
if label != id { return label }
return id.prefix(1).uppercased() + id.dropFirst()
}
private func resolveChannelDetailTitle(_ id: String) -> String {
self.store.resolveChannelDetailLabel(id)
}
private func resolveChannelSystemImage(_ id: String) -> String {
self.store.resolveChannelSystemImage(id)
}
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
self.store.snapshot?.channels[id]?.dictionaryValue
}
}

View File

@@ -0,0 +1,17 @@
import AppKit
extension ChannelsSettings {
func date(fromMs ms: Double?) -> Date? {
guard let ms else { return nil }
return Date(timeIntervalSince1970: ms / 1000)
}
func qrImage(from dataUrl: String) -> NSImage? {
guard let comma = dataUrl.firstIndex(of: ",") else { return nil }
let header = dataUrl[..<comma]
guard header.contains("base64") else { return nil }
let base64 = dataUrl[dataUrl.index(after: comma)...]
guard let data = Data(base64Encoded: String(base64)) else { return nil }
return NSImage(data: data)
}
}

View File

@@ -0,0 +1,177 @@
import SwiftUI
extension ChannelsSettings {
var body: some View {
let channels = self.orderedChannels
return HStack(spacing: 0) {
self.sidebar(channels: channels)
self.detail
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.settingsDetailContent()
.onAppear {
self.updateActiveWork(active: self.isActive)
self.ensureSelection(in: channels)
}
.onChange(of: self.isActive) { _, active in
self.updateActiveWork(active: active)
}
.onChange(of: channels) { _, newValue in
self.ensureSelection(in: newValue)
}
.onDisappear { self.store.stop() }
}
private func updateActiveWork(active: Bool) {
if active {
self.store.start()
} else {
self.store.stop()
}
}
private func sidebar(channels: [ChannelItem]) -> some View {
let enabled = channels.filter { self.channelEnabled($0) }
let available = channels.filter { !self.channelEnabled($0) }
return SettingsSidebarScroll {
LazyVStack(alignment: .leading, spacing: 8) {
if !enabled.isEmpty {
self.sidebarSectionHeader("Configured")
ForEach(enabled) { channel in
self.sidebarRow(channel)
}
}
if !available.isEmpty {
self.sidebarSectionHeader("Available")
ForEach(available) { channel in
self.sidebarRow(channel)
}
}
}
}
}
private var detail: some View {
Group {
if let channel = self.selectedChannel {
self.channelDetail(channel)
} else {
self.emptyDetail
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.layoutPriority(1)
}
private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Channels")
.font(.title3.weight(.semibold))
Text("Select a channel to view status and settings.")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
}
private func channelDetail(_ channel: ChannelItem) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
self.detailHeader(for: channel)
Divider()
self.channelSection(channel)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
}
}
private func sidebarRow(_ channel: ChannelItem) -> some View {
let isSelected = self.selectedChannel == channel
return Button {
self.selectedChannel = channel
} label: {
HStack(spacing: 8) {
Circle()
.fill(self.channelTint(channel))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(channel.title)
Text(self.channelSummary(channel))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(Color.clear) // ensure full-width hit test area
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
.contentShape(Rectangle())
}
private func sidebarSectionHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
.padding(.horizontal, 4)
.padding(.top, 2)
}
private func detailHeader(for channel: ChannelItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Label(channel.detailTitle, systemImage: channel.systemImage)
.font(.title3.weight(.semibold))
self.statusBadge(
self.channelSummary(channel),
color: self.channelTint(channel))
Spacer(minLength: 12)
self.channelHeaderActions(channel)
}
HStack(spacing: 10) {
Text("Last check \(self.channelLastCheckText(channel))")
.font(.caption)
.foregroundStyle(.secondary)
if self.channelHasError(channel) {
Text("Error")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.red.opacity(0.15))
.foregroundStyle(.red)
.clipShape(Capsule())
}
}
if let details = self.channelDetails(channel) {
Text(details)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private func statusBadge(_ text: String, color: Color) -> some View {
Text(text)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(color.opacity(0.16))
.foregroundStyle(color)
.clipShape(Capsule())
}
}

View File

@@ -0,0 +1,21 @@
import AppKit
import SwiftUI
struct ChannelsSettings: View {
struct ChannelItem: Identifiable, Hashable {
let id: String
let title: String
let detailTitle: String
let systemImage: String
let sortOrder: Int
}
@Bindable var store: ChannelsStore
let isActive: Bool
@State var selectedChannel: ChannelItem?
init(store: ChannelsStore = .shared, isActive: Bool = true) {
self.store = store
self.isActive = isActive
}
}

View File

@@ -0,0 +1,359 @@
import Foundation
import OpenClawProtocol
extension ChannelsStore {
func loadConfigSchema(force: Bool = false) async {
let sourceKey = self.currentConfigCacheSourceKey()
self.resetConfigSchemaCacheIfSourceChanged(sourceKey)
if !force, self.configSchema != nil {
return
}
guard !self.queueConfigSchemaReloadIfLoading(sourceKey: sourceKey, force: force) else { return }
self.configSchemaLoading = true
self.configSchemaLoadingSourceKey = sourceKey
defer {
self.configSchemaLoading = false
self.configSchemaLoadingSourceKey = nil
}
var requestSourceKey = sourceKey
while true {
self.configSchemaLoadingSourceKey = requestSourceKey
do {
let res: ConfigSchemaResponse = try await GatewayConnection.shared.requestDecoded(
method: .configSchema,
params: nil,
timeoutMs: 8000)
self.applyConfigSchemaResponse(res, sourceKey: requestSourceKey)
} catch {
self.configStatus = error.localizedDescription
}
guard self.configSchemaReloadPending else { break }
self.configSchemaReloadPending = false
requestSourceKey = self.currentConfigCacheSourceKey()
self.resetConfigSchemaCacheIfSourceChanged(requestSourceKey)
}
}
@discardableResult
func loadConfigSchemaLookup(path: String, force: Bool = false) async -> ConfigSchemaLookupNode? {
let sourceKey = self.currentConfigCacheSourceKey()
self.resetConfigSchemaCacheIfSourceChanged(sourceKey)
let normalizedPath = Self.normalizeConfigLookupPath(path)
if !force, let cached = self.configLookupNode(path: normalizedPath) {
return cached
}
if self.configLookupLoadingPaths.contains(normalizedPath) {
return self.configLookupNode(path: normalizedPath)
}
self.configLookupLoadingPaths.insert(normalizedPath)
defer { self.configLookupLoadingPaths.remove(normalizedPath) }
do {
let res: ConfigSchemaLookupResult = try await GatewayConnection.shared.requestDecoded(
method: .configSchemaLookup,
params: ["path": AnyCodable(normalizedPath)],
timeoutMs: 5000)
guard let node = self.makeConfigLookupNode(res) else {
self.configStatus = "Config schema lookup returned an unsupported payload."
return nil
}
self.applyConfigLookupNode(node, sourceKey: sourceKey)
return node
} catch {
self.configStatus = error.localizedDescription
return nil
}
}
func loadConfig(force: Bool = true) async {
let sourceKey = self.currentConfigCacheSourceKey()
self.resetConfigCacheIfSourceChanged(sourceKey)
if !force, self.configLoaded {
return
}
guard !self.queueConfigReloadIfLoading(sourceKey: sourceKey, force: force) else { return }
self.configLoading = true
self.configLoadingSourceKey = sourceKey
defer {
self.configLoading = false
self.configLoadingSourceKey = nil
}
var requestForce = force
var requestSourceKey = sourceKey
while true {
self.configLoadingSourceKey = requestSourceKey
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 10000)
self.applyConfigSnapshot(snap, sourceKey: requestSourceKey, force: requestForce)
} catch {
self.configStatus = error.localizedDescription
}
guard self.configForceReloadPending else { break }
self.configForceReloadPending = false
requestForce = true
requestSourceKey = self.currentConfigCacheSourceKey()
self.resetConfigCacheIfSourceChanged(requestSourceKey)
}
}
func applyConfigSnapshot(_ snap: ConfigSnapshot, sourceKey: String, force: Bool) {
guard self.configSourceKey == sourceKey else { return }
guard force || !self.configDirty else { return }
self.configStatus = snap.valid == false
? "Config invalid; fix it in ~/.openclaw/openclaw.json."
: nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configDraft = cloneConfigValue(self.configRoot) as? [String: Any] ?? self.configRoot
self.configDirty = false
self.configLoaded = true
self.configSourceKey = sourceKey
self.applyUIConfig(snap)
}
func applyConfigSchemaResponse(_ res: ConfigSchemaResponse, sourceKey: String) {
guard self.configSchemaSourceKey == sourceKey else { return }
let schemaValue = res.schema.foundationValue
self.configSchema = ConfigSchemaNode(raw: schemaValue)
let hintValues = res.uihints.mapValues { $0.foundationValue }
self.configUiHints = decodeUiHints(hintValues)
self.configSchemaSourceKey = sourceKey
}
func configLookupNode(path: String) -> ConfigSchemaLookupNode? {
let normalizedPath = Self.normalizeConfigLookupPath(path)
if normalizedPath == "." {
return self.configLookupRoot
}
return self.configLookupCache[normalizedPath]
}
func makeConfigLookupNode(_ res: ConfigSchemaLookupResult) -> ConfigSchemaLookupNode? {
let schemaValue = res.schema.foundationValue
guard let schema = ConfigSchemaNode(raw: schemaValue) else { return nil }
let hint = res.hint.map { ConfigUiHint(raw: $0.mapValues(\.foundationValue)) }
let children = res.children.compactMap(ConfigSchemaLookupChild.init(raw:))
return ConfigSchemaLookupNode(
path: Self.normalizeConfigLookupPath(res.path),
schema: schema,
hint: hint,
hintPath: res.hintpath,
children: children)
}
func applyConfigLookupNode(_ node: ConfigSchemaLookupNode, sourceKey: String) {
guard self.configSchemaSourceKey == sourceKey else { return }
if node.path == "." {
self.configLookupRoot = node
} else {
self.configLookupCache[node.path] = node
}
if let hint = node.hint {
self.configUiHints[node.path] = hint
}
for child in node.children {
if let hint = child.hint {
self.configUiHints[child.path] = hint
}
}
}
private func applyUIConfig(_ snap: ConfigSnapshot) {
let ui = snap.config?["ui"]?.dictionaryValue
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
}
func channelConfigSchema(for channelId: String) -> ConfigSchemaNode? {
guard let root = self.configSchema else { return nil }
return root.node(at: [.key("channels"), .key(channelId)])
}
func configValue(at path: ConfigPath) -> Any? {
if let value = valueAtPath(self.configDraft, path: path) {
return value
}
guard path.count >= 2 else { return nil }
if case .key("channels") = path[0], case .key = path[1] {
let fallbackPath = Array(path.dropFirst())
return valueAtPath(self.configDraft, path: fallbackPath)
}
return nil
}
func updateConfigValue(path: ConfigPath, value: Any?) {
var root: Any = self.configDraft
setValue(&root, path: path, value: value)
self.configDraft = root as? [String: Any] ?? self.configDraft
self.configDirty = true
}
func saveConfigDraft() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
defer { self.isSavingConfig = false }
do {
try await ConfigStore.save(self.configDraft)
await self.loadConfig()
} catch {
self.configStatus = error.localizedDescription
}
}
func reloadConfigDraft() async {
await self.loadConfig(force: true)
}
func resetConfigSchemaCacheIfSourceChanged(_ sourceKey: String) {
guard let cachedSourceKey = self.configSchemaSourceKey else {
self.configSchemaSourceKey = sourceKey
return
}
guard cachedSourceKey != sourceKey else { return }
self.configSchema = nil
self.configLookupRoot = nil
self.configLookupCache.removeAll(keepingCapacity: true)
self.configLookupLoadingPaths.removeAll(keepingCapacity: true)
self.configUiHints = [:]
self.configSchemaSourceKey = sourceKey
}
func resetConfigCacheIfSourceChanged(_ sourceKey: String) {
guard let cachedSourceKey = self.configSourceKey else {
self.configSourceKey = sourceKey
return
}
guard cachedSourceKey != sourceKey else { return }
self.configRoot = [:]
self.configDraft = [:]
self.configDirty = false
self.configLoaded = false
self.configSourceKey = sourceKey
}
func queueConfigReloadIfLoading(sourceKey: String, force: Bool) -> Bool {
guard self.configLoading else { return false }
if force || self.configLoadingSourceKey != sourceKey {
self.configForceReloadPending = true
}
return true
}
func queueConfigSchemaReloadIfLoading(sourceKey: String, force: Bool) -> Bool {
guard self.configSchemaLoading else { return false }
if force || self.configSchemaLoadingSourceKey != sourceKey {
self.configSchemaReloadPending = true
}
return true
}
private func currentConfigCacheSourceKey() -> String {
let root = OpenClawConfigFile.loadDict()
let settings = CommandResolver.connectionSettings(configRoot: root)
let env = ProcessInfo.processInfo.environment
return [
"mode:\(settings.mode.rawValue)",
"target:\(settings.target)",
"identity:\(settings.identity)",
"project:\(settings.projectRoot)",
"cli:\(settings.cliPath)",
"port:\(GatewayEnvironment.gatewayPort())",
"gateway:\(Self.configFingerprint(root["gateway"]))",
"token:\(Self.configFingerprint(env["OPENCLAW_GATEWAY_TOKEN"]))",
"password:\(Self.configFingerprint(env["OPENCLAW_GATEWAY_PASSWORD"]))",
].joined(separator: "|")
}
static func normalizeConfigLookupPath(_ path: String) -> String {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "." : trimmed
}
private static func configFingerprint(_ value: Any?) -> String {
guard let value else { return "nil" }
if JSONSerialization.isValidJSONObject(value),
let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
{
return "\(data.count):\(data.hashValue)"
}
let text = String(describing: value)
return "\(text.count):\(text.hashValue)"
}
}
private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
var current: Any? = root
for segment in path {
switch segment {
case let .key(key):
guard let dict = current as? [String: Any] else { return nil }
current = dict[key]
case let .index(index):
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
current = array[index]
}
}
return current
}
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
guard let segment = path.first else { return }
switch segment {
case let .key(key):
var dict = root as? [String: Any] ?? [:]
if path.count == 1 {
if let value {
dict[key] = value
} else {
dict.removeValue(forKey: key)
}
root = dict
return
}
var child = dict[key] ?? [:]
setValue(&child, path: Array(path.dropFirst()), value: value)
dict[key] = child
root = dict
case let .index(index):
var array = root as? [Any] ?? []
if index >= array.count {
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
}
if path.count == 1 {
if let value {
array[index] = value
} else if array.indices.contains(index) {
array.remove(at: index)
}
root = array
return
}
var child = array[index]
setValue(&child, path: Array(path.dropFirst()), value: value)
array[index] = child
root = array
}
}
private func cloneConfigValue(_ value: Any) -> Any {
guard JSONSerialization.isValidJSONObject(value) else { return value }
do {
let data = try JSONSerialization.data(withJSONObject: value, options: [])
return try JSONSerialization.jsonObject(with: data, options: [])
} catch {
return value
}
}

View File

@@ -0,0 +1,201 @@
import Foundation
import OpenClawProtocol
func whatsappLoginWaitRequestTimeoutMs(
startedAt: Date,
timeoutMs: Int,
didRunFinalWait: inout Bool,
now: Date = Date()) -> Int?
{
let elapsedMs = Int(now.timeIntervalSince(startedAt) * 1000)
let remainingMs = max(timeoutMs - elapsedMs, 0)
if remainingMs > 0 {
return remainingMs
}
if didRunFinalWait {
return nil
}
didRunFinalWait = true
return 1
}
extension ChannelsStore {
func start() {
guard !self.isPreview else { return }
self.startCount += 1
guard self.startCount == 1 else { return }
guard self.pollTask == nil else { return }
self.pollTask = Task.detached { [weak self] in
guard let self else { return }
await self.refresh(probe: false)
async let schemaLoad: Void = self.loadConfigSchema()
async let configLoad: Void = self.loadConfig(force: false)
_ = await (schemaLoad, configLoad)
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
await self.refresh(probe: false)
}
}
}
func stop() {
guard !self.isPreview else { return }
guard self.startCount > 0 else { return }
self.startCount -= 1
guard self.startCount == 0 else { return }
self.pollTask?.cancel()
self.pollTask = nil
}
func refresh(probe: Bool) async {
guard !self.isRefreshing else { return }
self.isRefreshing = true
defer { self.isRefreshing = false }
do {
let statusTimeoutMs = probe ? 8000 : 2500
let params: [String: AnyCodable] = [
"probe": AnyCodable(probe),
"timeoutMs": AnyCodable(statusTimeoutMs),
]
let snap: ChannelsStatusSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .channelsStatus,
params: params,
timeoutMs: probe ? 12000 : 5000)
self.snapshot = snap
self.lastSuccess = Date()
self.lastError = nil
} catch {
self.lastError = error.localizedDescription
}
}
func startWhatsAppLogin(force: Bool, autoWait: Bool = true) async {
guard !self.whatsappBusy else { return }
self.whatsappBusy = true
defer { self.whatsappBusy = false }
var shouldAutoWait = false
do {
let params: [String: AnyCodable] = [
"force": AnyCodable(force),
"timeoutMs": AnyCodable(30000),
]
let result: WhatsAppLoginStartResult = try await GatewayConnection.shared.requestDecoded(
method: .webLoginStart,
params: params,
timeoutMs: 35000)
self.whatsappLoginMessage = result.message
self.whatsappLoginQrDataUrl = result.qrDataUrl
self.whatsappLoginConnected = result.connected
shouldAutoWait = autoWait && result.qrDataUrl != nil
} catch {
self.whatsappLoginMessage = error.localizedDescription
self.whatsappLoginQrDataUrl = nil
self.whatsappLoginConnected = nil
}
await self.refresh(probe: true)
if shouldAutoWait {
Task { await self.waitWhatsAppLogin() }
}
}
func waitWhatsAppLogin(timeoutMs: Int = 120_000) async {
guard !self.whatsappBusy else { return }
self.whatsappBusy = true
defer { self.whatsappBusy = false }
let startedAt = Date()
var didRunFinalWait = false
do {
while let remainingMs = whatsappLoginWaitRequestTimeoutMs(
startedAt: startedAt,
timeoutMs: timeoutMs,
didRunFinalWait: &didRunFinalWait)
{
var params: [String: AnyCodable] = [
"timeoutMs": AnyCodable(remainingMs),
]
if let currentQrDataUrl = self.whatsappLoginQrDataUrl {
params["currentQrDataUrl"] = AnyCodable(currentQrDataUrl)
}
let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded(
method: .webLoginWait,
params: params,
timeoutMs: Double(remainingMs) + 5000)
self.applyWhatsAppLoginWaitResult(result)
if result.connected || result.qrDataUrl == nil || didRunFinalWait {
break
}
}
} catch {
self.whatsappLoginMessage = error.localizedDescription
}
await self.refresh(probe: true)
}
func logoutWhatsApp() async {
guard !self.whatsappBusy else { return }
self.whatsappBusy = true
defer { self.whatsappBusy = false }
do {
let params: [String: AnyCodable] = [
"channel": AnyCodable("whatsapp"),
]
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .channelsLogout,
params: params,
timeoutMs: 15000)
self.whatsappLoginMessage = result.cleared
? "Logged out and cleared credentials."
: "No WhatsApp session found."
self.whatsappLoginQrDataUrl = nil
} catch {
self.whatsappLoginMessage = error.localizedDescription
}
await self.refresh(probe: true)
}
func logoutTelegram() async {
guard !self.telegramBusy else { return }
self.telegramBusy = true
defer { self.telegramBusy = false }
do {
let params: [String: AnyCodable] = [
"channel": AnyCodable("telegram"),
]
let result: ChannelLogoutResult = try await GatewayConnection.shared.requestDecoded(
method: .channelsLogout,
params: params,
timeoutMs: 15000)
if result.envToken == true {
self.configStatus = "Telegram token still set via env; config cleared."
} else {
self.configStatus = result.cleared
? "Telegram token cleared."
: "No Telegram token configured."
}
await self.loadConfig()
} catch {
self.configStatus = error.localizedDescription
}
await self.refresh(probe: true)
}
}
private struct WhatsAppLoginStartResult: Codable {
let qrDataUrl: String?
let message: String
let connected: Bool?
}
struct WhatsAppLoginWaitResult: Codable {
let connected: Bool
let message: String
let qrDataUrl: String?
}
private struct ChannelLogoutResult: Codable {
let channel: String?
let accountId: String?
let cleared: Bool
let envToken: Bool?
}

View File

@@ -0,0 +1,382 @@
import Foundation
import Observation
import OpenClawProtocol
struct ChannelsStatusSnapshot: Codable {
struct WhatsAppSelf: Codable {
let e164: String?
let jid: String?
}
struct WhatsAppDisconnect: Codable {
let at: Double
let status: Int?
let error: String?
let loggedOut: Bool?
}
struct WhatsAppStatus: Codable {
let configured: Bool
let linked: Bool
let authAgeMs: Double?
let `self`: WhatsAppSelf?
let running: Bool
let connected: Bool
let lastConnectedAt: Double?
let lastDisconnect: WhatsAppDisconnect?
let reconnectAttempts: Int
let lastMessageAt: Double?
let lastEventAt: Double?
let lastError: String?
}
struct TelegramBot: Codable {
let id: Int?
let username: String?
}
struct TelegramWebhook: Codable {
let url: String?
let hasCustomCert: Bool?
}
struct TelegramProbe: Codable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
let bot: TelegramBot?
let webhook: TelegramWebhook?
}
struct TelegramStatus: Codable {
let configured: Bool
let tokenSource: String?
let running: Bool
let mode: String?
let lastStartAt: Double?
let lastStopAt: Double?
let lastError: String?
let probe: TelegramProbe?
let lastProbeAt: Double?
}
struct DiscordBot: Codable {
let id: String?
let username: String?
}
struct DiscordProbe: Codable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
let bot: DiscordBot?
}
struct DiscordStatus: Codable {
let configured: Bool
let tokenSource: String?
let running: Bool
let lastStartAt: Double?
let lastStopAt: Double?
let lastError: String?
let probe: DiscordProbe?
let lastProbeAt: Double?
}
struct GoogleChatProbe: Codable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
}
struct GoogleChatStatus: Codable {
let configured: Bool
let credentialSource: String?
let audienceType: String?
let audience: String?
let webhookPath: String?
let webhookUrl: String?
let running: Bool
let lastStartAt: Double?
let lastStopAt: Double?
let lastError: String?
let probe: GoogleChatProbe?
let lastProbeAt: Double?
}
struct SignalProbe: Codable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
let version: String?
}
struct SignalStatus: Codable {
let configured: Bool
let baseUrl: String
let running: Bool
let lastStartAt: Double?
let lastStopAt: Double?
let lastError: String?
let probe: SignalProbe?
let lastProbeAt: Double?
}
struct IMessageProbe: Codable {
let ok: Bool
let error: String?
}
struct IMessageStatus: Codable {
let configured: Bool
let running: Bool
let lastStartAt: Double?
let lastStopAt: Double?
let lastError: String?
let cliPath: String?
let dbPath: String?
let probe: IMessageProbe?
let lastProbeAt: Double?
}
struct ChannelAccountSnapshot: Codable {
let accountId: String
let name: String?
let enabled: Bool?
let configured: Bool?
let linked: Bool?
let running: Bool?
let connected: Bool?
let reconnectAttempts: Int?
let lastConnectedAt: Double?
let lastError: String?
let lastStartAt: Double?
let lastStopAt: Double?
let lastInboundAt: Double?
let lastOutboundAt: Double?
let lastProbeAt: Double?
let mode: String?
let dmPolicy: String?
let allowFrom: [String]?
let tokenSource: String?
let botTokenSource: String?
let appTokenSource: String?
let baseUrl: String?
let allowUnmentionedGroups: Bool?
let cliPath: String?
let dbPath: String?
let port: Int?
let probe: AnyCodable?
let audit: AnyCodable?
let application: AnyCodable?
}
struct ChannelUiMetaEntry: Codable {
let id: String
let label: String
let detailLabel: String
let systemImage: String?
}
let ts: Double
let channelOrder: [String]
let channelLabels: [String: String]
let channelDetailLabels: [String: String]?
let channelSystemImages: [String: String]?
let channelMeta: [ChannelUiMetaEntry]?
let channels: [String: AnyCodable]
let channelAccounts: [String: [ChannelAccountSnapshot]]
let channelDefaultAccountId: [String: String]
func decodeChannel<T: Decodable>(_ id: String, as type: T.Type) -> T? {
guard let value = self.channels[id] else { return nil }
do {
let data = try JSONEncoder().encode(value)
return try JSONDecoder().decode(type, from: data)
} catch {
return nil
}
}
}
struct ConfigSnapshot: Codable {
struct Issue: Codable {
let path: String
let message: String
}
let path: String?
let exists: Bool?
let raw: String?
let hash: String?
let parsed: AnyCodable?
let valid: Bool?
let config: [String: AnyCodable]?
let issues: [Issue]?
}
struct ConfigSchemaLookupChild: Identifiable {
let key: String
let path: String
let typeLabel: String?
let required: Bool
let hasChildren: Bool
let hint: ConfigUiHint?
let hintPath: String?
var id: String {
self.path
}
init?(raw: [String: AnyCodable]) {
guard let key = raw["key"]?.stringValue,
let path = raw["path"]?.stringValue
else {
return nil
}
self.key = key
self.path = path
if let type = raw["type"]?.stringValue {
self.typeLabel = type
} else if let types = raw["type"]?.arrayValue {
self.typeLabel = types.compactMap(\.stringValue).joined(separator: " / ")
} else {
self.typeLabel = nil
}
self.required = raw["required"]?.boolValue ?? false
self.hasChildren = raw["hasChildren"]?.boolValue ?? false
if let hint = raw["hint"]?.dictionaryValue {
self.hint = ConfigUiHint(raw: hint.mapValues(\.foundationValue))
} else {
self.hint = nil
}
self.hintPath = raw["hintPath"]?.stringValue
}
}
struct ConfigSchemaLookupNode {
let path: String
let schema: ConfigSchemaNode
let hint: ConfigUiHint?
let hintPath: String?
let children: [ConfigSchemaLookupChild]
}
@MainActor
@Observable
final class ChannelsStore {
static let shared = ChannelsStore()
var snapshot: ChannelsStatusSnapshot? {
didSet {
self.decodedChannelCache.removeAll(keepingCapacity: true)
}
}
var lastError: String?
var lastSuccess: Date?
var isRefreshing = false
var whatsappLoginMessage: String?
var whatsappLoginQrDataUrl: String?
var whatsappLoginConnected: Bool?
var whatsappBusy = false
var telegramBusy = false
var configStatus: String?
var isSavingConfig = false
var configSchemaLoading = false
var configSchema: ConfigSchemaNode?
var configLookupRoot: ConfigSchemaLookupNode?
var configLookupCache: [String: ConfigSchemaLookupNode] = [:]
var configLookupLoadingPaths: Set<String> = []
var configUiHints: [String: ConfigUiHint] = [:]
var configSchemaSourceKey: String?
var configSchemaLoadingSourceKey: String?
var configSchemaReloadPending = false
var configLoading = false
var configLoadingSourceKey: String?
var configForceReloadPending = false
var configDraft: [String: Any] = [:]
var configDirty = false
let interval: TimeInterval = 45
let isPreview: Bool
var startCount = 0
var pollTask: Task<Void, Never>?
var configRoot: [String: Any] = [:]
var configLoaded = false
var configSourceKey: String?
@ObservationIgnored private var decodedChannelCache: [String: Any] = [:]
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
self.snapshot?.channelMeta?.first(where: { $0.id == id })
}
func resolveChannelLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.label.isEmpty {
return meta.label
}
if let label = self.snapshot?.channelLabels[id], !label.isEmpty {
return label
}
return id
}
func resolveChannelDetailLabel(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty {
return meta.detailLabel
}
if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
return detail
}
return self.resolveChannelLabel(id)
}
func resolveChannelSystemImage(_ id: String) -> String {
if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty {
return symbol
}
if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
return symbol
}
return "message"
}
func orderedChannelIds() -> [String] {
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
return meta.map(\.id)
}
return self.snapshot?.channelOrder ?? []
}
func decodedChannel<T: Decodable>(_ id: String, as type: T.Type) -> T? {
let key = "\(id)#\(ObjectIdentifier(type))"
if let cached = self.decodedChannelCache[key] as? T {
return cached
}
guard let decoded = self.snapshot?.decodeChannel(id, as: type) else {
return nil
}
self.decodedChannelCache[key] = decoded
return decoded
}
func applyWhatsAppLoginWaitResult(_ result: WhatsAppLoginWaitResult) {
self.whatsappLoginMessage = result.message
self.whatsappLoginConnected = result.connected
if let qrDataUrl = result.qrDataUrl {
self.whatsappLoginQrDataUrl = qrDataUrl
} else if result.connected {
self.whatsappLoginQrDataUrl = nil
}
}
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview
}
}

View File

@@ -0,0 +1,110 @@
import CoreServices
import Foundation
final class CoalescingFSEventsWatcher: @unchecked Sendable {
private let queue: DispatchQueue
private var stream: FSEventStreamRef?
private var pending = false
private let paths: [String]
private let shouldNotify: (Int, UnsafeMutableRawPointer?) -> Bool
private let onChange: () -> Void
private let coalesceDelay: TimeInterval
init(
paths: [String],
queueLabel: String,
coalesceDelay: TimeInterval = 0.12,
shouldNotify: @escaping (Int, UnsafeMutableRawPointer?) -> Bool = { _, _ in true },
onChange: @escaping () -> Void)
{
self.paths = paths
self.queue = DispatchQueue(label: queueLabel)
self.coalesceDelay = coalesceDelay
self.shouldNotify = shouldNotify
self.onChange = onChange
}
deinit {
self.stop()
}
func start() {
guard self.stream == nil else { return }
let retainedSelf = Unmanaged.passRetained(self)
var context = FSEventStreamContext(
version: 0,
info: retainedSelf.toOpaque(),
retain: nil,
release: { pointer in
guard let pointer else { return }
Unmanaged<CoalescingFSEventsWatcher>.fromOpaque(pointer).release()
},
copyDescription: nil)
let paths = self.paths as CFArray
let flags = FSEventStreamCreateFlags(
kFSEventStreamCreateFlagFileEvents |
kFSEventStreamCreateFlagUseCFTypes |
kFSEventStreamCreateFlagNoDefer)
guard let stream = FSEventStreamCreate(
kCFAllocatorDefault,
Self.callback,
&context,
paths,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
0.05,
flags)
else {
retainedSelf.release()
return
}
self.stream = stream
FSEventStreamSetDispatchQueue(stream, self.queue)
if FSEventStreamStart(stream) == false {
self.stream = nil
FSEventStreamSetDispatchQueue(stream, nil)
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
}
}
func stop() {
guard let stream = self.stream else { return }
self.stream = nil
FSEventStreamStop(stream)
FSEventStreamSetDispatchQueue(stream, nil)
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
}
}
extension CoalescingFSEventsWatcher {
private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in
guard let info else { return }
let watcher = Unmanaged<CoalescingFSEventsWatcher>.fromOpaque(info).takeUnretainedValue()
watcher.handleEvents(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags)
}
private func handleEvents(
numEvents: Int,
eventPaths: UnsafeMutableRawPointer?,
eventFlags: UnsafePointer<FSEventStreamEventFlags>?)
{
guard numEvents > 0 else { return }
guard eventFlags != nil else { return }
guard self.shouldNotify(numEvents, eventPaths) else { return }
// Coalesce rapid changes (common during builds/atomic saves).
if self.pending { return }
self.pending = true
self.queue.asyncAfter(deadline: .now() + self.coalesceDelay) { [weak self] in
guard let self else { return }
self.pending = false
self.onChange()
}
}
}

View File

@@ -0,0 +1,14 @@
import SwiftUI
enum ColorHexSupport {
static func color(fromHex raw: String?) -> Color? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
let r = Double((value >> 16) & 0xFF) / 255.0
let g = Double((value >> 8) & 0xFF) / 255.0
let b = Double(value & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
}

View File

@@ -0,0 +1,667 @@
import Foundation
enum CommandResolver {
private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath"
private static let helperName = "openclaw"
static func gatewayEntrypoint(in root: URL) -> String? {
let distEntry = root.appendingPathComponent("dist/index.js").path
if FileManager().isReadableFile(atPath: distEntry) { return distEntry }
let openclawEntry = root.appendingPathComponent("openclaw.mjs").path
if FileManager().isReadableFile(atPath: openclawEntry) { return openclawEntry }
let binEntry = root.appendingPathComponent("bin/openclaw.js").path
if FileManager().isReadableFile(atPath: binEntry) { return binEntry }
return nil
}
static func runtimeResolution() -> Result<RuntimeResolution, RuntimeResolutionError> {
RuntimeLocator.resolve(searchPaths: self.preferredPaths())
}
static func runtimeResolution(searchPaths: [String]?) -> Result<RuntimeResolution, RuntimeResolutionError> {
RuntimeLocator.resolve(searchPaths: searchPaths ?? self.preferredPaths())
}
static func makeRuntimeCommand(
runtime: RuntimeResolution,
entrypoint: String,
subcommand: String,
extraArgs: [String]) -> [String]
{
[runtime.path, entrypoint, subcommand] + extraArgs
}
static func runtimeErrorCommand(_ error: RuntimeResolutionError) -> [String] {
let message = RuntimeLocator.describeFailure(error)
return self.errorCommand(with: message)
}
static func errorCommand(with message: String) -> [String] {
let script = """
cat <<'__OPENCLAW_ERR__' >&2
\(message)
__OPENCLAW_ERR__
exit 1
"""
return ["/bin/sh", "-c", script]
}
static func projectRoot() -> URL {
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
let url = self.expandPath(stored),
FileManager().fileExists(atPath: url.path)
{
return url
}
let fallback = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Projects/openclaw")
if FileManager().fileExists(atPath: fallback.path) {
return fallback
}
return FileManager().homeDirectoryForCurrentUser
}
static func setProjectRoot(_ path: String) {
UserDefaults.standard.set(path, forKey: self.projectRootDefaultsKey)
}
static func projectRootPath() -> String {
self.projectRoot().path
}
static func preferredPaths() -> [String] {
let current = ProcessInfo.processInfo.environment["PATH"]?
.split(separator: ":").map(String.init) ?? []
let home = FileManager().homeDirectoryForCurrentUser
let projectRoot = self.projectRoot()
let validatedExecutable = self.validatedOpenClawExecutable(
defaults: .standard,
fileManager: .default,
requiredVersion: GatewayEnvironment.expectedGatewayVersionString())
return self.preferredPaths(
home: home,
current: current,
projectRoot: projectRoot,
validatedExecutable: validatedExecutable)
}
static func preferredPaths(
home: URL,
current: [String],
projectRoot: URL,
validatedExecutable: String? = nil) -> [String]
{
var preferredPaths: [String] = []
let managedPaths = self.openclawManagedPaths(home: home)
if let validatedExecutable {
let validatedBin = URL(fileURLWithPath: validatedExecutable).deletingLastPathComponent().path
if managedPaths.contains(validatedBin) {
preferredPaths.append(contentsOf: managedPaths)
} else {
preferredPaths.append(validatedBin)
}
}
let externalPaths = [
home.appendingPathComponent("Library/pnpm").path,
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
home.appendingPathComponent(".local/bin").path,
]
#if DEBUG
// Dev-only convenience. Avoid project-local PATH hijacking in release builds.
preferredPaths.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
#endif
var seen = Set<String>()
let fallbackPaths = self.nodeManagerBinPaths(home: home) + externalPaths + managedPaths
// Preserve order while stripping duplicates so PATH lookups remain deterministic.
return (preferredPaths + fallbackPaths + current).filter { seen.insert($0).inserted }
}
static func validatedOpenClawExecutable(
defaults: UserDefaults,
fileManager: FileManager,
requiredVersion: String?) -> String?
{
guard let executable = defaults.string(forKey: cliValidatedExecutableKey),
fileManager.isExecutableFile(atPath: executable),
let validatedVersion = Semver.parse(defaults.string(forKey: cliValidatedVersionKey))
else {
return nil
}
guard let required = Semver.parse(requiredVersion) else { return executable }
return validatedVersion.compatible(with: required) ? executable : nil
}
private static func openclawManagedPaths(home: URL) -> [String] {
let bases = [
home.appendingPathComponent(".openclaw"),
]
var paths: [String] = []
for base in bases {
let bin = base.appendingPathComponent("bin")
let nodeBin = base.appendingPathComponent("tools/node/bin")
if FileManager().fileExists(atPath: bin.path) {
paths.append(bin.path)
}
if FileManager().fileExists(atPath: nodeBin.path) {
paths.append(nodeBin.path)
}
}
return paths
}
private static func nodeManagerBinPaths(home: URL) -> [String] {
var bins: [String] = []
// Volta
let volta = home.appendingPathComponent(".volta/bin")
if FileManager().fileExists(atPath: volta.path) {
bins.append(volta.path)
}
// asdf
let asdf = home.appendingPathComponent(".asdf/shims")
if FileManager().fileExists(atPath: asdf.path) {
bins.append(asdf.path)
}
// fnm
bins.append(contentsOf: self.versionedNodeBinPaths(
base: home.appendingPathComponent(".local/share/fnm/node-versions"),
suffix: "installation/bin"))
// nvm
bins.append(contentsOf: self.versionedNodeBinPaths(
base: home.appendingPathComponent(".nvm/versions/node"),
suffix: "bin"))
return bins
}
private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] {
guard FileManager().fileExists(atPath: base.path) else { return [] }
let entries: [String]
do {
entries = try FileManager().contentsOfDirectory(atPath: base.path)
} catch {
return []
}
func parseVersion(_ name: String) -> [Int] {
let trimmed = name.hasPrefix("v") ? String(name.dropFirst()) : name
return trimmed.split(separator: ".").compactMap { Int($0) }
}
let sorted = entries.sorted { a, b in
let va = parseVersion(a)
let vb = parseVersion(b)
let maxCount = max(va.count, vb.count)
for i in 0..<maxCount {
let ai = i < va.count ? va[i] : 0
let bi = i < vb.count ? vb[i] : 0
if ai != bi { return ai > bi }
}
// If identical numerically, keep stable ordering.
return a > b
}
var paths: [String] = []
for entry in sorted {
let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix)
let node = binDir.appendingPathComponent("node")
if FileManager().isExecutableFile(atPath: node.path) {
paths.append(binDir.path)
}
}
return paths
}
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
for dir in searchPaths ?? self.preferredPaths() {
let candidate = (dir as NSString).appendingPathComponent(name)
if FileManager().isExecutableFile(atPath: candidate) {
return candidate
}
}
return nil
}
static func openclawExecutable(searchPaths: [String]? = nil) -> String? {
self.findExecutable(named: self.helperName, searchPaths: searchPaths)
}
static func projectOpenClawExecutable(projectRoot: URL? = nil) -> String? {
#if DEBUG
let root = projectRoot ?? self.projectRoot()
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
#else
return nil
#endif
}
static func nodeCliPath() -> String? {
let root = self.projectRoot()
let candidates = [
root.appendingPathComponent("openclaw.mjs").path,
root.appendingPathComponent("bin/openclaw.js").path,
]
for candidate in candidates where FileManager().isReadableFile(atPath: candidate) {
return candidate
}
return nil
}
static func hasAnyOpenClawInvoker(searchPaths: [String]? = nil) -> Bool {
if self.openclawExecutable(searchPaths: searchPaths) != nil { return true }
if self.findExecutable(named: "pnpm", searchPaths: searchPaths) != nil { return true }
if self.findExecutable(named: "node", searchPaths: searchPaths) != nil,
self.nodeCliPath() != nil
{
return true
}
return false
}
static func openclawNodeCommand(
subcommand: String,
extraArgs: [String] = [],
defaults: UserDefaults = .standard,
configRoot: [String: Any]? = nil,
searchPaths: [String]? = nil,
projectRoot: URL? = nil) -> [String]
{
let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot)
if settings.mode == .remote, let ssh = self.sshNodeCommand(
subcommand: subcommand,
extraArgs: extraArgs,
settings: settings)
{
return ssh
}
let root = projectRoot ?? self.projectRoot()
if let openclawPath = self.projectOpenClawExecutable(projectRoot: root) {
return [openclawPath, subcommand] + extraArgs
}
if let openclawPath = self.openclawExecutable(searchPaths: searchPaths) {
return [openclawPath, subcommand] + extraArgs
}
let runtimeResult = self.runtimeResolution(searchPaths: searchPaths)
switch runtimeResult {
case let .success(runtime):
if let entry = self.gatewayEntrypoint(in: root) {
return self.makeRuntimeCommand(
runtime: runtime,
entrypoint: entry,
subcommand: subcommand,
extraArgs: extraArgs)
}
case .failure:
break
}
if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) {
// Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs.
return [pnpm, "--silent", "openclaw", subcommand] + extraArgs
}
switch runtimeResult {
case .success:
let missingEntry = """
openclaw entrypoint missing (looked for dist/index.js or openclaw.mjs); run pnpm build.
"""
return self.errorCommand(with: missingEntry)
case let .failure(error):
return self.runtimeErrorCommand(error)
}
}
static func openclawCommand(
subcommand: String,
extraArgs: [String] = [],
defaults: UserDefaults = .standard,
configRoot: [String: Any]? = nil,
searchPaths: [String]? = nil,
projectRoot: URL? = nil) -> [String]
{
self.openclawNodeCommand(
subcommand: subcommand,
extraArgs: extraArgs,
defaults: defaults,
configRoot: configRoot,
searchPaths: searchPaths,
projectRoot: projectRoot)
}
// MARK: - SSH helpers
static func sshEnvironment(
base: [String: String] = ProcessInfo.processInfo.environment,
searchPaths: [String]? = nil) -> [String: String]
{
var environment = base
environment["PATH"] = (searchPaths ?? self.preferredPaths()).joined(separator: ":")
return environment
}
private static func sshNodeCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? {
guard !settings.target.isEmpty else { return nil }
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
// Run the real openclaw CLI on the remote host.
let exportedPath = [
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
"$HOME/Library/pnpm",
"$PATH",
].joined(separator: ":")
let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines)
let userCLI = settings.cliPath.trimmingCharacters(in: .whitespacesAndNewlines)
let projectSection = if userPRJ.isEmpty {
"""
DEFAULT_PRJ="$HOME/Projects/openclaw"
if [ -d "$DEFAULT_PRJ" ]; then
PRJ="$DEFAULT_PRJ"
cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
fi
"""
} else {
"""
PRJ=\(self.shellQuote(userPRJ))
cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
"""
}
let cliSection = if userCLI.isEmpty {
""
} else {
"""
CLI_HINT=\(self.shellQuote(userCLI))
if [ -n "$CLI_HINT" ]; then
if [ -x "$CLI_HINT" ]; then
CLI="$CLI_HINT"
"$CLI_HINT" \(quotedArgs);
exit $?;
elif [ -f "$CLI_HINT" ]; then
if command -v node >/dev/null 2>&1; then
CLI="node $CLI_HINT"
node "$CLI_HINT" \(quotedArgs);
exit $?;
fi
fi
fi
"""
}
let scriptBody = """
PATH=\(exportedPath);
CLI="";
\(cliSection)
\(projectSection)
if command -v openclaw >/dev/null 2>&1; then
CLI="$(command -v openclaw)"
openclaw \(quotedArgs);
elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/dist/index.js" ]; then
if command -v node >/dev/null 2>&1; then
CLI="node $PRJ/dist/index.js"
node "$PRJ/dist/index.js" \(quotedArgs);
else
echo "Node >=22 required on remote host"; exit 127;
fi
elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/openclaw.mjs" ]; then
if command -v node >/dev/null 2>&1; then
CLI="node $PRJ/openclaw.mjs"
node "$PRJ/openclaw.mjs" \(quotedArgs);
else
echo "Node >=22 required on remote host"; exit 127;
fi
elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/bin/openclaw.js" ]; then
if command -v node >/dev/null 2>&1; then
CLI="node $PRJ/bin/openclaw.js"
node "$PRJ/bin/openclaw.js" \(quotedArgs);
else
echo "Node >=22 required on remote host"; exit 127;
fi
elif command -v pnpm >/dev/null 2>&1; then
CLI="pnpm --silent openclaw"
pnpm --silent openclaw \(quotedArgs);
else
echo "openclaw CLI missing on remote host"; exit 127;
fi
"""
// Remote credentials require strict host verification unless config explicitly opts into OpenSSH policy.
let options: [String] = [
"-o", "BatchMode=yes",
] + settings.sshHostKeyPolicy.commandOptions
let args = self.sshArguments(
target: parsed,
identity: settings.identity,
options: options,
remoteCommand: ["/bin/sh", "-c", scriptBody])
return ["/usr/bin/ssh"] + args
}
enum SSHHostKeyPolicy: String {
case strict
case openssh
var hostKeyOptions: [String] {
switch self {
case .strict:
[
"-o", "StrictHostKeyChecking=yes",
"-o", "UpdateHostKeys=yes",
]
case .openssh:
[]
}
}
var commandOptions: [String] {
[
"-o", "ControlMaster=no",
"-o", "ControlPath=none",
"-o", "ControlPersist=no",
"-o", "ForkAfterAuthentication=no",
] + self.hostKeyOptions
}
}
struct RemoteSettings {
let mode: AppState.ConnectionMode
let target: String
let identity: String
let projectRoot: String
let cliPath: String
let sshHostKeyPolicy: SSHHostKeyPolicy
}
static func connectionSettings(
defaults: UserDefaults = .standard,
configRoot: [String: Any]? = nil) -> RemoteSettings
{
let root = configRoot ?? OpenClawConfigFile.loadDict()
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
let remote = (root["gateway"] as? [String: Any])?["remote"] as? [String: Any]
let configuredTarget = self.sanitizedTarget(remote?["sshTarget"] as? String ?? "")
let target = self.sanitizedTarget(
defaults.string(forKey: remoteTargetKey)?.nonEmpty ?? configuredTarget)
let identity = defaults.string(forKey: remoteIdentityKey)?.nonEmpty
?? remote?["sshIdentity"] as? String
?? ""
let projectRoot = defaults.string(forKey: remoteProjectRootKey)?.nonEmpty ?? ""
let cliPath = defaults.string(forKey: remoteCliPathKey)?.nonEmpty ?? ""
let rawHostKeyPolicy = remote?["sshHostKeyPolicy"] as? String
let configuredHostKeyPolicy = rawHostKeyPolicy.flatMap(SSHHostKeyPolicy.init(rawValue:)) ?? .strict
let sshHostKeyPolicy: SSHHostKeyPolicy = if configuredHostKeyPolicy == .openssh,
!target.isEmpty,
target == configuredTarget
{
.openssh
} else {
.strict
}
return RemoteSettings(
mode: mode,
target: target,
identity: identity,
projectRoot: projectRoot,
cliPath: cliPath,
sshHostKeyPolicy: sshHostKeyPolicy)
}
static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool {
self.connectionSettings(defaults: defaults).mode == .remote
}
private static func sanitizedTarget(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("ssh ") {
return trimmed.replacingOccurrences(of: "ssh ", with: "").trimmingCharacters(in: .whitespacesAndNewlines)
}
return trimmed
}
struct SSHParsedTarget {
let user: String?
let host: String
let port: Int
}
static func parseSSHTarget(_ target: String) -> SSHParsedTarget? {
let trimmed = self.normalizeSSHTargetInput(target)
guard !trimmed.isEmpty else { return nil }
if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
return nil
}
let userHostPort: String
let user: String?
if let atRange = trimmed.range(of: "@") {
user = String(trimmed[..<atRange.lowerBound])
userHostPort = String(trimmed[atRange.upperBound...])
} else {
user = nil
userHostPort = trimmed
}
let host: String
let port: Int
if let colon = userHostPort.lastIndex(of: ":"), colon != userHostPort.startIndex {
host = String(userHostPort[..<colon])
let portStr = String(userHostPort[userHostPort.index(after: colon)...])
guard let parsedPort = Int(portStr), parsedPort > 0, parsedPort <= 65535 else {
return nil
}
port = parsedPort
} else {
host = userHostPort
port = 22
}
return self.makeSSHTarget(user: user, host: host, port: port)
}
static func sshTargetValidationMessage(_ target: String) -> String? {
let trimmed = self.normalizeSSHTargetInput(target)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("-") {
return "SSH target cannot start with '-'"
}
if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
return "SSH target cannot contain spaces"
}
if self.parseSSHTarget(trimmed) == nil {
return "SSH target must look like user@host[:port]"
}
return nil
}
private static func shellQuote(_ text: String) -> String {
if text.isEmpty { return "''" }
let escaped = text.replacingOccurrences(of: "'", with: "'\\''")
return "'\(escaped)'"
}
private static func expandPath(_ path: String) -> URL? {
var expanded = path
if expanded.hasPrefix("~") {
let home = FileManager().homeDirectoryForCurrentUser.path
expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home)
}
return URL(fileURLWithPath: expanded)
}
private static func normalizeSSHTargetInput(_ target: String) -> String {
var trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("ssh ") {
trimmed = trimmed.replacingOccurrences(of: "ssh ", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return trimmed
}
private static func isValidSSHComponent(_ value: String, allowLeadingDash: Bool = false) -> Bool {
if value.isEmpty { return false }
if !allowLeadingDash, value.hasPrefix("-") { return false }
let invalid = CharacterSet.whitespacesAndNewlines.union(.controlCharacters)
return value.rangeOfCharacter(from: invalid) == nil
}
static func makeSSHTarget(user: String?, host: String, port: Int) -> SSHParsedTarget? {
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.isValidSSHComponent(trimmedHost) else { return nil }
let trimmedUser = user?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedUser: String?
if let trimmedUser {
guard self.isValidSSHComponent(trimmedUser) else { return nil }
normalizedUser = trimmedUser.isEmpty ? nil : trimmedUser
} else {
normalizedUser = nil
}
guard port > 0, port <= 65535 else { return nil }
return SSHParsedTarget(user: normalizedUser, host: trimmedHost, port: port)
}
private static func sshTargetString(_ target: SSHParsedTarget) -> String {
target.user.map { "\($0)@\(target.host)" } ?? target.host
}
static func sshArguments(
target: SSHParsedTarget,
identity: String,
options: [String],
remoteCommand: [String] = []) -> [String]
{
var args = options
if target.port > 0 {
args.append(contentsOf: ["-p", String(target.port)])
}
let trimmedIdentity = identity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
// Only use IdentitiesOnly when an explicit identity file is provided.
// This allows 1Password SSH agent and other SSH agents to provide keys.
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
args.append(contentsOf: ["-i", trimmedIdentity])
}
args.append("--")
args.append(self.sshTargetString(target))
args.append(contentsOf: remoteCommand)
return args
}
#if SWIFT_PACKAGE
static func _testNodeManagerBinPaths(home: URL) -> [String] {
self.nodeManagerBinPaths(home: home)
}
#endif
}

View File

@@ -0,0 +1,33 @@
import Foundation
final class ConfigFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
private let url: URL
private let watchedDir: URL
private let targetPath: String
private let targetName: String
let watcher: SimpleFileWatcher
init(url: URL, onChange: @escaping () -> Void) {
self.url = url
self.watchedDir = url.deletingLastPathComponent()
self.targetPath = url.path
self.targetName = url.lastPathComponent
let watchedDirPath = self.watchedDir.path
let targetPath = self.targetPath
let targetName = self.targetName
self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher(
paths: [watchedDirPath],
queueLabel: "ai.openclaw.configwatcher",
shouldNotify: { _, eventPaths in
guard let eventPaths else { return true }
let paths = unsafeBitCast(eventPaths, to: NSArray.self)
for case let path as String in paths {
if path == targetPath { return true }
if path.hasSuffix("/\(targetName)") { return true }
if path == watchedDirPath { return true }
}
return false
},
onChange: onChange))
}
}

View File

@@ -0,0 +1,238 @@
import Foundation
enum ConfigPathSegment: Hashable {
case key(String)
case index(Int)
}
typealias ConfigPath = [ConfigPathSegment]
struct ConfigUiHint {
let label: String?
let help: String?
let order: Double?
let advanced: Bool?
let sensitive: Bool?
let placeholder: String?
init(raw: [String: Any]) {
self.label = raw["label"] as? String
self.help = raw["help"] as? String
if let order = raw["order"] as? Double {
self.order = order
} else if let orderInt = raw["order"] as? Int {
self.order = Double(orderInt)
} else {
self.order = nil
}
self.advanced = raw["advanced"] as? Bool
self.sensitive = raw["sensitive"] as? Bool
self.placeholder = raw["placeholder"] as? String
}
}
struct ConfigSchemaNode {
let raw: [String: Any]
init?(raw: Any) {
guard let dict = raw as? [String: Any] else { return nil }
self.raw = dict
}
var title: String? {
self.raw["title"] as? String
}
var description: String? {
self.raw["description"] as? String
}
var enumValues: [Any]? {
self.raw["enum"] as? [Any]
}
var constValue: Any? {
self.raw["const"]
}
var explicitDefault: Any? {
self.raw["default"]
}
var requiredKeys: Set<String> {
Set((self.raw["required"] as? [String]) ?? [])
}
var typeList: [String] {
if let type = self.raw["type"] as? String { return [type] }
if let types = self.raw["type"] as? [String] { return types }
return []
}
var schemaType: String? {
let filtered = self.typeList.filter { $0 != "null" }
if let first = filtered.first { return first }
return self.typeList.first
}
var isNullSchema: Bool {
let types = self.typeList
return types.count == 1 && types.first == "null"
}
var properties: [String: ConfigSchemaNode] {
guard let props = self.raw["properties"] as? [String: Any] else { return [:] }
return props.compactMapValues { ConfigSchemaNode(raw: $0) }
}
var anyOf: [ConfigSchemaNode] {
guard let raw = self.raw["anyOf"] as? [Any] else { return [] }
return raw.compactMap { ConfigSchemaNode(raw: $0) }
}
var oneOf: [ConfigSchemaNode] {
guard let raw = self.raw["oneOf"] as? [Any] else { return [] }
return raw.compactMap { ConfigSchemaNode(raw: $0) }
}
var literalValue: Any? {
if let constValue { return constValue }
if let enumValues, enumValues.count == 1 { return enumValues[0] }
return nil
}
var items: ConfigSchemaNode? {
if let items = self.raw["items"] as? [Any], let first = items.first {
return ConfigSchemaNode(raw: first)
}
if let items = self.raw["items"] {
return ConfigSchemaNode(raw: items)
}
return nil
}
var additionalProperties: ConfigSchemaNode? {
if let additional = self.raw["additionalProperties"] as? [String: Any] {
return ConfigSchemaNode(raw: additional)
}
return nil
}
var allowsAdditionalProperties: Bool {
if let allow = self.raw["additionalProperties"] as? Bool { return allow }
return self.additionalProperties != nil
}
var defaultValue: Any {
if let value = self.raw["default"] { return value }
switch self.schemaType {
case "object":
return [String: Any]()
case "array":
return [Any]()
case "boolean":
return false
case "integer":
return 0
case "number":
return 0.0
case "string":
return ""
default:
return ""
}
}
func node(at path: ConfigPath) -> ConfigSchemaNode? {
var current: ConfigSchemaNode? = self
for segment in path {
guard let node = current else { return nil }
switch segment {
case let .key(key):
if node.schemaType == "object" {
if let next = node.properties[key] {
current = next
continue
}
if let additional = node.additionalProperties {
current = additional
continue
}
return nil
}
return nil
case .index:
guard node.schemaType == "array" else { return nil }
current = node.items
}
}
return current
}
}
func decodeUiHints(_ raw: [String: Any]) -> [String: ConfigUiHint] {
raw.reduce(into: [:]) { result, entry in
if let hint = entry.value as? [String: Any] {
result[entry.key] = ConfigUiHint(raw: hint)
}
}
}
func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiHint? {
let key = pathKey(path)
if let direct = hints[key] { return direct }
let segments = key.split(separator: ".").map(String.init)
for (hintKey, hint) in hints {
guard hintKey.contains("*") else { continue }
let hintSegments = hintKey.split(separator: ".").map(String.init)
guard hintSegments.count == segments.count else { continue }
var match = true
for (index, seg) in segments.enumerated() {
let hintSegment = hintSegments[index]
if hintSegment != "*", hintSegment != seg {
match = false
break
}
}
if match { return hint }
}
return nil
}
func isSensitivePath(_ path: ConfigPath) -> Bool {
let key = pathKey(path).lowercased()
return key.contains("token")
|| key.contains("password")
|| key.contains("secret")
|| key.contains("apikey")
|| key.hasSuffix("key")
}
func labelForConfigPath(_ path: ConfigPath) -> String? {
for segment in path.reversed() {
if case let .key(key) = segment {
return humanizeConfigKey(key)
}
}
return nil
}
func humanizeConfigKey(_ key: String) -> String {
key.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.replacingOccurrences(
of: "([a-z0-9])([A-Z])",
with: "$1 $2",
options: .regularExpression)
.capitalized
}
func pathKey(_ path: ConfigPath) -> String {
path.compactMap { segment -> String? in
switch segment {
case let .key(key): return key
case .index: return nil
}
}
.joined(separator: ".")
}

View File

@@ -0,0 +1,460 @@
import SwiftUI
@MainActor
struct ConfigSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview
private let isNixMode = ProcessInfo.processInfo.isNixMode
@Bindable var store: ChannelsStore
@State private var hasLoaded = false
@State private var activePath: String?
@State private var failedLookupPaths: Set<String> = []
init(store: ChannelsStore = .shared) {
self.store = store
}
var body: some View {
HStack(spacing: 16) {
self.sidebar
self.detail
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.settingsDetailContent()
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
self.hasLoaded = true
Task { await self.store.loadConfig(force: false) }
_ = await self.store.loadConfigSchemaLookup(path: ".")
self.ensureSelection()
}
.task(id: self.activePath) {
guard let activePath = self.activePath else { return }
await self.loadPath(activePath)
}
.onAppear { self.ensureSelection() }
.onChange(of: self.store.configLookupRoot?.path) { _, _ in
self.failedLookupPaths.removeAll()
self.ensureSelection()
}
}
}
extension ConfigSettings {
private struct ConfigSection: Identifiable {
let key: String
let label: String
let help: String?
let path: String
let hasChildren: Bool
var id: String {
self.path
}
}
private struct ConfigSubsection: Identifiable {
let key: String
let label: String
let help: String?
let path: String
let hasChildren: Bool
var id: String {
self.path
}
}
private var sections: [ConfigSection] {
guard let root = self.store.configLookupRoot else { return [] }
return self.resolveSections(root.children)
}
private var activeSection: ConfigSection? {
guard let activePath = self.activePath else { return nil }
return self.sections.first { activePath == $0.path || activePath.hasPrefix("\($0.path).") }
}
private var sidebar: some View {
SettingsSidebarScroll {
LazyVStack(alignment: .leading, spacing: 4) {
if self.sections.isEmpty {
Text("No config sections available.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 4)
} else {
ForEach(self.sections) { section in
self.sidebarSection(section)
}
}
}
}
}
private var detail: some View {
VStack(alignment: .leading, spacing: 16) {
if self.store.configLookupRoot == nil,
!self.hasLoaded || self.store.configLookupLoadingPaths.contains(".")
{
ProgressView().controlSize(.small)
} else if let section = self.activeSection {
self.sectionDetail(section)
} else if self.store.configLookupRoot != nil {
self.emptyDetail
} else {
self.schemaUnavailableDetail
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.layoutPriority(1)
}
private var emptyDetail: some View {
VStack(alignment: .leading, spacing: 8) {
self.header
Text("Select a config section to view settings.")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
}
private var schemaUnavailableDetail: some View {
VStack(alignment: .leading, spacing: 8) {
self.header
Text(self.store.configStatus ?? "Schema unavailable.")
.font(.callout)
.foregroundStyle(.secondary)
self.actionRow
}
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
}
private func sectionDetail(_ section: ConfigSection) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
self.header
if let status = self.store.configStatus {
Text(status)
.font(.callout)
.foregroundStyle(.secondary)
}
self.actionRow
self.sectionHeader(section)
self.sectionForm(section)
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
}
@ViewBuilder
private var header: some View {
Text("Config")
.font(.title3.weight(.semibold))
Text(self.isNixMode
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
: "Edit ~/.openclaw/openclaw.json using the schema-driven form.")
.font(.callout)
.foregroundStyle(.secondary)
}
private func sectionHeader(_ section: ConfigSection) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(section.label)
.font(.title3.weight(.semibold))
if let help = section.help {
Text(help)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
private var actionRow: some View {
HStack(spacing: 10) {
Button("Reload") {
Task { await self.store.reloadConfigDraft() }
}
.disabled(!self.store.configLoaded)
Button(self.store.isSavingConfig ? "Saving…" : "Save") {
Task { await self.store.saveConfigDraft() }
}
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configLoaded || !self.store
.configDirty)
}
.buttonStyle(.bordered)
}
private func sidebarSection(_ section: ConfigSection) -> some View {
let isExpanded = self.activePath == section.path || self.activePath?.hasPrefix("\(section.path).") == true
let subsections = isExpanded ? self.resolveSubsections(for: section) : []
return VStack(alignment: .leading, spacing: 2) {
Button {
self.selectSection(section)
} label: {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
Text(section.label)
.lineLimit(1)
}
.padding(.vertical, 5)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(self.activePath == section.path
? Color.accentColor.opacity(0.18)
: Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contentShape(Rectangle())
if isExpanded, !subsections.isEmpty {
VStack(alignment: .leading, spacing: 1) {
ForEach(subsections) { sub in
self.sidebarSubRow(sub)
}
}
.padding(.leading, 20)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.animation(.easeInOut(duration: 0.18), value: isExpanded)
}
private func sidebarSubRow(_ subsection: ConfigSubsection) -> some View {
let isSelected = self.activePath == subsection.path
return Button {
self.selectPath(subsection.path)
} label: {
Text(subsection.label)
.font(.callout)
.lineLimit(1)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contentShape(Rectangle())
}
private func sectionForm(_ section: ConfigSection) -> some View {
let path = self.activePath ?? section.path
if self.store.configLookupLoadingPaths.contains(path) {
return AnyView(ProgressView().controlSize(.small))
}
guard let node = self.store.configLookupNode(path: path) else {
if self.failedLookupPaths.contains(path) {
return AnyView(self.lookupUnavailable(path: path))
}
return AnyView(ProgressView().controlSize(.small))
}
if !node.children.isEmpty, !Self.shouldRenderFormEditor(for: node.schema) {
return AnyView(self.lookupChildrenList(node))
}
guard self.store.configLoaded else {
return AnyView(
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Loading current values…")
.font(.caption)
.foregroundStyle(.secondary)
})
}
guard let configPath = Self.configPath(from: node.path) else {
return AnyView(
Text("Wildcard config entries are edited from their concrete key.")
.font(.caption)
.foregroundStyle(.secondary))
}
return AnyView(
ConfigSchemaForm(store: self.store, schema: node.schema, path: configPath)
.disabled(self.isNixMode))
}
private func ensureSelection() {
let sections = self.sections
guard !sections.isEmpty else { return }
if let activePath = self.activePath,
sections.contains(where: { activePath == $0.path || activePath.hasPrefix("\($0.path).") })
{
return
}
self.selectSection(sections[0])
}
private func selectSection(_ section: ConfigSection) {
self.activePath = section.path
}
private func selectPath(_ path: String) {
self.activePath = path
}
private func lookupUnavailable(path: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(self.store.configStatus ?? "Schema unavailable.")
.font(.callout)
.foregroundStyle(.secondary)
Button("Retry") {
self.failedLookupPaths.remove(path)
Task { await self.loadPath(path) }
}
.buttonStyle(.bordered)
}
}
private func lookupChildrenList(_ node: ConfigSchemaLookupNode) -> some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(node.children) { child in
Button {
self.selectPath(child.path)
} label: {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(self.label(for: child))
.font(.callout.weight(.semibold))
if let help = child.hint?.help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else if let type = child.typeLabel {
Text(type)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if child.required {
Text("Required")
.font(.caption)
.foregroundStyle(.secondary)
}
Image(systemName: child.hasChildren ? "chevron.right" : "slider.horizontal.3")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.background(Color.primary.opacity(0.04))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
private func resolveSections(_ children: [ConfigSchemaLookupChild]) -> [ConfigSection] {
children
.sorted(by: self.sortLookupChildren)
.map { child in
ConfigSection(
key: child.key,
label: self.label(for: child),
help: child.hint?.help,
path: child.path,
hasChildren: child.hasChildren)
}
}
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
guard let node = self.store.configLookupNode(path: section.path) else {
return []
}
return node.children
.sorted(by: self.sortLookupChildren)
.map { child in
ConfigSubsection(
key: child.key,
label: self.label(for: child),
help: child.hint?.help,
path: child.path,
hasChildren: child.hasChildren)
}
}
private func loadPath(_ path: String) async {
guard self.store.configLookupNode(path: path) == nil else {
self.failedLookupPaths.remove(path)
return
}
guard !self.store.configLookupLoadingPaths.contains(path) else { return }
if await self.store.loadConfigSchemaLookup(path: path) == nil {
self.failedLookupPaths.insert(path)
} else {
self.failedLookupPaths.remove(path)
}
}
private func label(for child: ConfigSchemaLookupChild) -> String {
child.hint?.label
?? self.humanize(child.key)
}
private func sortLookupChildren(_ lhs: ConfigSchemaLookupChild, _ rhs: ConfigSchemaLookupChild) -> Bool {
let orderA = lhs.hint?.order ?? 0
let orderB = rhs.hint?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs.key < rhs.key
}
private static func configPath(from lookupPath: String) -> ConfigPath? {
guard lookupPath != "." else { return [] }
let normalized = lookupPath
.replacingOccurrences(of: "[", with: ".")
.replacingOccurrences(of: "]", with: "")
let parts = normalized
.split(separator: ".")
.map(String.init)
.filter { !$0.isEmpty }
guard !parts.contains("*") else { return nil }
return parts.map { part in
if let index = Int(part) {
return .index(index)
}
return .key(part)
}
}
private static func shouldRenderFormEditor(for schema: ConfigSchemaNode) -> Bool {
if schema.schemaType == "array" { return true }
return schema.additionalProperties != nil
}
private func humanize(_ key: String) -> String {
key.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.capitalized
}
}
struct ConfigSettings_Previews: PreviewProvider {
static var previews: some View {
ConfigSettings()
}
}

View File

@@ -0,0 +1,155 @@
import Foundation
import OpenClawProtocol
enum ConfigStore {
struct Overrides {
var isRemoteMode: (@Sendable () async -> Bool)?
var loadLocal: (@MainActor @Sendable () -> [String: Any])?
var saveLocal: (@MainActor @Sendable ([String: Any]) -> Void)?
var loadRemote: (@MainActor @Sendable () async -> [String: Any])?
var saveRemote: (@MainActor @Sendable ([String: Any]) async throws -> Void)?
var saveGateway: (@MainActor @Sendable ([String: Any]) async throws -> Void)?
}
private actor OverrideStore {
var overrides = Overrides()
func setOverride(_ overrides: Overrides) {
self.overrides = overrides
}
}
private static let overrideStore = OverrideStore()
@MainActor private static var lastHash: String?
private static func isRemoteMode() async -> Bool {
let overrides = await self.overrideStore.overrides
if let override = overrides.isRemoteMode {
return await override()
}
return await MainActor.run { AppStateStore.shared.connectionMode == .remote }
}
@MainActor
static func load() async -> [String: Any] {
let overrides = await self.overrideStore.overrides
if await self.isRemoteMode() {
if let override = overrides.loadRemote {
return await override()
}
return await self.loadFromGateway() ?? [:]
}
if let override = overrides.loadLocal {
return override()
}
if let gateway = await self.loadFromGateway() {
return gateway
}
return OpenClawConfigFile.loadDict()
}
@MainActor
static func save(
_ root: sending [String: Any],
allowGatewayAuthMutation: Bool = false) async throws
{
let overrides = await self.overrideStore.overrides
if await self.isRemoteMode() {
if let override = overrides.saveRemote {
try await override(root)
} else {
try await self.saveToGateway(root)
}
} else {
if let override = overrides.saveLocal {
override(root)
} else {
do {
try await self.saveToGateway(root)
} catch {
guard self.shouldFallbackToLocalWrite(afterGatewaySaveError: error) else {
self.lastHash = nil
throw error
}
guard OpenClawConfigFile.saveDict(
root,
preserveExistingKeys: true,
allowGatewayAuthMutation: allowGatewayAuthMutation)
else {
throw NSError(domain: "ConfigStore", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Local config write rejected to protect gateway auth/mode.",
])
}
}
}
}
}
@MainActor
private static func loadFromGateway() async -> [String: Any]? {
do {
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
method: .configGet,
params: nil,
timeoutMs: 8000)
self.lastHash = snap.hash
return snap.config?.mapValues { $0.foundationValue } ?? [:]
} catch {
return nil
}
}
private static func shouldFallbackToLocalWrite(afterGatewaySaveError error: Error) -> Bool {
let nsError = error as NSError
let message = "\(nsError.domain) \(nsError.localizedDescription)".lowercased()
let blockedFragments = [
"invalid_request",
"invalid request",
"invalid config",
"config changed since last load",
"base hash",
"basehash",
"unauthorized",
"token mismatch",
"auth",
]
return !blockedFragments.contains { message.contains($0) }
}
@MainActor
private static func saveToGateway(_ root: [String: Any]) async throws {
let overrides = await self.overrideStore.overrides
if let saveGateway = overrides.saveGateway {
try await saveGateway(root)
return
}
if self.lastHash == nil {
_ = await self.loadFromGateway()
}
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode config.",
])
}
var params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
if let baseHash = self.lastHash {
params["baseHash"] = AnyCodable(baseHash)
}
_ = try await GatewayConnection.shared.requestRaw(
method: .configSet,
params: params,
timeoutMs: 10000)
_ = await self.loadFromGateway()
}
#if DEBUG
static func _testSetOverrides(_ overrides: Overrides) async {
await self.overrideStore.setOverride(overrides)
}
static func _testClearOverrides() async {
await self.overrideStore.setOverride(.init())
}
#endif
}

View File

@@ -0,0 +1,79 @@
import Foundation
import OSLog
@MainActor
final class ConnectionModeCoordinator {
static let shared = ConnectionModeCoordinator()
private let logger = Logger(subsystem: "ai.openclaw", category: "connection")
private var lastMode: AppState.ConnectionMode?
/// Apply the requested connection mode by starting/stopping local gateway,
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
if let lastMode = self.lastMode, lastMode != mode {
GatewayProcessManager.shared.clearLastFailure()
NodesStore.shared.lastError = nil
}
self.lastMode = mode
switch mode {
case .unconfigured:
_ = await NodeServiceManager.stop()
NodesStore.shared.lastError = nil
await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
GatewayProcessManager.shared.stop()
await GatewayConnection.shared.shutdown()
await ControlChannel.shared.disconnect()
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
case .local:
_ = await NodeServiceManager.stop()
NodesStore.shared.lastError = nil
await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
if shouldStart {
GatewayProcessManager.shared.setActive(true)
if GatewayAutostartPolicy.shouldEnsureLaunchAgent(
mode: .local,
paused: paused)
{
Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() }
}
_ = await GatewayProcessManager.shared.waitForGatewayReady()
} else {
GatewayProcessManager.shared.stop()
}
do {
try await ControlChannel.shared.configure(mode: .local)
} catch {
// Control channel will mark itself degraded; nothing else to do here.
self.logger.error(
"control channel local configure failed: \(error.localizedDescription, privacy: .public)")
}
Task.detached { await PortGuardian.shared.sweep(mode: .local) }
case .remote:
// Never run a local gateway in remote mode.
GatewayProcessManager.shared.stop()
WebChatManager.shared.resetTunnels()
do {
NodesStore.shared.lastError = nil
if let error = await NodeServiceManager.start() {
NodesStore.shared.lastError = "Node service start failed: \(error)"
}
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote(
target: settings.target,
identity: settings.identity))
} catch {
self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)")
}
Task.detached { await PortGuardian.shared.sweep(mode: .remote) }
}
}
}

View File

@@ -0,0 +1,49 @@
import Foundation
enum EffectiveConnectionModeSource: Equatable {
case configMode
case configRemoteURL
case userDefaults
case onboarding
}
struct EffectiveConnectionMode: Equatable {
let mode: AppState.ConnectionMode
let source: EffectiveConnectionModeSource
}
enum ConnectionModeResolver {
static func resolve(
root: [String: Any],
defaults: UserDefaults = .standard) -> EffectiveConnectionMode
{
let gateway = root["gateway"] as? [String: Any]
let configModeRaw = (gateway?["mode"] as? String) ?? ""
let configMode = configModeRaw
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
switch configMode {
case "local":
return EffectiveConnectionMode(mode: .local, source: .configMode)
case "remote":
return EffectiveConnectionMode(mode: .remote, source: .configMode)
default:
break
}
let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? ""
let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines)
if !remoteURL.isEmpty {
return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL)
}
if let storedModeRaw = defaults.string(forKey: connectionModeKey) {
let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local
return EffectiveConnectionMode(mode: storedMode, source: .userDefaults)
}
let seen = defaults.bool(forKey: "openclaw.onboardingSeen")
return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding)
}
}

View File

@@ -0,0 +1,52 @@
import Foundation
// Stable identifier used for both the macOS LaunchAgent label and Nix-managed defaults suite.
// nix-openclaw writes app defaults into this suite to survive app bundle identifier churn.
let launchdLabel = "ai.openclaw.mac"
let gatewayLaunchdLabel = "ai.openclaw.gateway"
let onboardingVersionKey = "openclaw.onboardingVersion"
let onboardingSeenKey = "openclaw.onboardingSeen"
let currentOnboardingVersion = 8
let pauseDefaultsKey = "openclaw.pauseEnabled"
let iconAnimationsEnabledKey = "openclaw.iconAnimationsEnabled"
let swabbleEnabledKey = "openclaw.swabbleEnabled"
let swabbleTriggersKey = "openclaw.swabbleTriggers"
let voiceWakeTriggerChimeKey = "openclaw.voiceWakeTriggerChime"
let voiceWakeSendChimeKey = "openclaw.voiceWakeSendChime"
let showDockIconKey = "openclaw.showDockIcon"
let defaultVoiceWakeTriggers = ["openclaw"]
let voiceWakeMaxWords = 32
let voiceWakeMaxWordLength = 64
let voiceWakeMicKey = "openclaw.voiceWakeMicID"
let voiceWakeMicNameKey = "openclaw.voiceWakeMicName"
let voiceWakeLocaleKey = "openclaw.voiceWakeLocaleID"
let voiceWakeAdditionalLocalesKey = "openclaw.voiceWakeAdditionalLocaleIDs"
let voicePushToTalkEnabledKey = "openclaw.voicePushToTalkEnabled"
let voiceWakeTriggersTalkModeKey = "openclaw.voiceWakeTriggersTalkMode"
let talkEnabledKey = "openclaw.talkEnabled"
let talkPhaseSoundsEnabledKey = "openclaw.talkPhaseSoundsEnabled"
let talkShiftToStopEnabledKey = "openclaw.talkShiftToStopEnabled"
let iconOverrideKey = "openclaw.iconOverride"
let connectionModeKey = "openclaw.connectionMode"
let remoteTargetKey = "openclaw.remoteTarget"
let remoteIdentityKey = "openclaw.remoteIdentity"
let remoteProjectRootKey = "openclaw.remoteProjectRoot"
let remoteCliPathKey = "openclaw.remoteCliPath"
let canvasEnabledKey = "openclaw.canvasEnabled"
let cameraEnabledKey = "openclaw.cameraEnabled"
let systemRunPolicyKey = "openclaw.systemRunPolicy"
let systemRunAllowlistKey = "openclaw.systemRunAllowlist"
let systemRunEnabledKey = "openclaw.systemRunEnabled"
let locationModeKey = "openclaw.locationMode"
let locationPreciseKey = "openclaw.locationPreciseEnabled"
let peekabooBridgeEnabledKey = "openclaw.peekabooBridgeEnabled"
let deepLinkKeyKey = "openclaw.deepLinkKey"
let cliInstallPromptedVersionKey = "openclaw.cliInstallPromptedVersion"
let cliValidatedExecutableKey = "openclaw.cliValidatedExecutable"
let cliValidatedVersionKey = "openclaw.cliValidatedVersion"
let macNodeIdentityProfileKey = "openclaw.macNodeIdentityProfile"
let heartbeatsEnabledKey = "openclaw.heartbeatsEnabled"
let debugPaneEnabledKey = "openclaw.debugPaneEnabled"
let debugFileLogEnabledKey = "openclaw.debug.fileLogEnabled"
let appLogLevelKey = "openclaw.debug.appLogLevel"
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26

View File

@@ -0,0 +1,103 @@
import Foundation
import SwiftUI
/// Context usage card shown at the top of the menubar menu.
struct ContextMenuCardView: View {
private let rows: [SessionRow]
private let statusText: String?
private let isLoading: Bool
private let barHeight: CGFloat = 3
init(
rows: [SessionRow],
statusText: String? = nil,
isLoading: Bool = false)
{
self.rows = rows
self.statusText = statusText
self.isLoading = isLoading
}
var body: some View {
MenuHeaderCard(
title: "Context",
subtitle: self.subtitle,
statusText: self.statusText,
paddingBottom: 8)
{
if self.statusText == nil {
if self.rows.isEmpty, !self.isLoading {
Text("No active sessions")
.font(.caption)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 12) {
if self.rows.isEmpty, self.isLoading {
ForEach(0..<2, id: \.self) { _ in
self.placeholderRow
}
} else {
ForEach(self.rows) { row in
self.sessionRow(row)
}
}
}
}
}
}
}
private var subtitle: String {
let count = self.rows.count
if count == 1 { return "1 session · 24h" }
return "\(count) sessions · 24h"
}
private func sessionRow(_ row: SessionRow) -> some View {
VStack(alignment: .leading, spacing: 5) {
ContextUsageBar(
usedTokens: row.tokens.total,
contextTokens: row.tokens.contextTokens,
height: self.barHeight)
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(row.label)
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 8)
Text(row.tokens.contextSummaryShort)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
}
}
.padding(.vertical, 2)
}
private var placeholderRow: some View {
VStack(alignment: .leading, spacing: 5) {
ContextUsageBar(
usedTokens: 0,
contextTokens: 200_000,
height: self.barHeight)
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("main")
.font(.caption.weight(.semibold))
.lineLimit(1)
.layoutPriority(1)
Spacer(minLength: 8)
Text("000k/000k")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
}
.redacted(reason: .placeholder)
}
}
}

View File

@@ -0,0 +1,59 @@
import SwiftUI
struct ContextRootMenuLabelView: View {
let subtitle: String
let width: CGFloat
@Environment(\.menuItemHighlighted) private var isHighlighted
private var palette: MenuItemHighlightColors.Palette {
MenuItemHighlightColors.palette(self.isHighlighted)
}
private var usesStackedLayout: Bool {
self.subtitle.count > 28 || self.subtitle.contains("\n")
}
var body: some View {
HStack(alignment: self.usesStackedLayout ? .top : .firstTextBaseline, spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text("Context")
.font(.callout.weight(.semibold))
.foregroundStyle(self.palette.primary)
.lineLimit(1)
if self.usesStackedLayout {
self.subtitleText
.lineLimit(5)
.fixedSize(horizontal: false, vertical: true)
}
}
.layoutPriority(1)
Spacer(minLength: 8)
if !self.usesStackedLayout {
self.subtitleText
.lineLimit(1)
.layoutPriority(2)
}
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(self.palette.secondary)
.padding(.leading, 2)
.padding(.top, self.usesStackedLayout ? 2 : 0)
}
.padding(.vertical, self.usesStackedLayout ? 7 : 8)
.padding(.leading, 22)
.padding(.trailing, 14)
.frame(width: max(1, self.width), alignment: .leading)
}
private var subtitleText: some View {
Text(self.subtitle)
.font(.caption.monospacedDigit())
.foregroundStyle(self.palette.secondary)
.multilineTextAlignment(.leading)
.truncationMode(.tail)
}
}

View File

@@ -0,0 +1,93 @@
import SwiftUI
struct ContextUsageBar: View {
let usedTokens: Int
let contextTokens: Int
var width: CGFloat?
var height: CGFloat = 6
private static let okGreen: NSColor = .init(name: nil) { appearance in
let base = NSColor.systemGreen
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
if match == .darkAqua { return base }
return base.blended(withFraction: 0.24, of: .black) ?? base
}
private static let trackFill: NSColor = .init(name: nil) { appearance in
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) }
return NSColor.black.withAlphaComponent(0.12)
}
private static let trackStroke: NSColor = .init(name: nil) { appearance in
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) }
return NSColor.black.withAlphaComponent(0.2)
}
private var clampedFractionUsed: Double {
guard self.contextTokens > 0 else { return 0 }
return min(1, max(0, Double(self.usedTokens) / Double(self.contextTokens)))
}
private var percentUsed: Int? {
guard self.contextTokens > 0, self.usedTokens > 0 else { return nil }
return min(100, Int(round(self.clampedFractionUsed * 100)))
}
private var tint: Color {
guard let pct = self.percentUsed else { return .secondary }
if pct >= 95 { return Color(nsColor: .systemRed) }
if pct >= 80 { return Color(nsColor: .systemOrange) }
if pct >= 60 { return Color(nsColor: .systemYellow) }
return Color(nsColor: Self.okGreen)
}
var body: some View {
let fraction = self.clampedFractionUsed
Group {
if let width = self.width, width > 0 {
self.barBody(width: width, fraction: fraction)
.frame(width: width, height: self.height)
} else {
GeometryReader { proxy in
self.barBody(width: proxy.size.width, fraction: fraction)
.frame(width: proxy.size.width, height: self.height)
}
.frame(height: self.height)
}
}
.accessibilityLabel("Context usage")
.accessibilityValue(self.accessibilityValue)
}
private var accessibilityValue: String {
if self.contextTokens <= 0 { return "Unknown context window" }
let pct = Int(round(self.clampedFractionUsed * 100))
return "\(pct) percent used"
}
@ViewBuilder
private func barBody(width: CGFloat, fraction: Double) -> some View {
let radius = self.height / 2
let trackFill = Color(nsColor: Self.trackFill)
let trackStroke = Color(nsColor: Self.trackStroke)
let fillWidth = max(1, floor(width * CGFloat(fraction)))
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(trackFill)
.overlay {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.strokeBorder(trackStroke, lineWidth: 0.75)
}
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(self.tint)
.frame(width: fillWidth)
.mask {
RoundedRectangle(cornerRadius: radius, style: .continuous)
}
}
}
}

View File

@@ -0,0 +1,532 @@
import Foundation
import Observation
import OpenClawKit
import OpenClawProtocol
import SwiftUI
struct ControlHeartbeatEvent: Codable {
let ts: Double
let status: String
let to: String?
let preview: String?
let durationMs: Double?
let hasMedia: Bool?
let reason: String?
}
struct ControlAgentEvent: Codable, Identifiable {
var id: String {
"\(self.runId)-\(self.seq)"
}
let runId: String
let seq: Int
let stream: String
let ts: Double
let data: [String: OpenClawProtocol.AnyCodable]
let summary: String?
}
enum ControlChannelError: Error, LocalizedError {
case disconnected
case badResponse(String)
var errorDescription: String? {
switch self {
case .disconnected: "Control channel disconnected"
case let .badResponse(msg): msg
}
}
}
struct ControlChannelStateDebouncer {
private let interval: TimeInterval
private var lastAppliedAt: Date
init(interval: TimeInterval = 0.5, lastAppliedAt: Date = .distantPast) {
self.interval = interval
self.lastAppliedAt = lastAppliedAt
}
mutating func delayBeforeApplying(
currentState: ControlChannel.ConnectionState,
newState: ControlChannel.ConnectionState,
now: Date) -> TimeInterval?
{
if Self.isTerminal(currentState) || Self.isTerminal(newState) {
self.lastAppliedAt = now
return nil
}
let elapsed = now.timeIntervalSince(self.lastAppliedAt)
guard elapsed < self.interval else {
self.lastAppliedAt = now
return nil
}
return self.interval - max(0, elapsed)
}
mutating func recordDeferredApply(at date: Date) {
self.lastAppliedAt = date
}
private static func isTerminal(_ state: ControlChannel.ConnectionState) -> Bool {
switch state {
case .connected, .disconnected:
true
case .connecting, .degraded:
false
}
}
}
@MainActor
@Observable
final class ControlChannel {
static let shared = ControlChannel()
enum Mode {
case local
case remote(target: String, identity: String)
}
enum ConnectionState: Equatable {
case disconnected
case connecting
case connected
case degraded(String)
}
private(set) var state: ConnectionState = .disconnected {
didSet {
CanvasManager.shared.refreshDebugStatus()
guard oldValue != self.state else { return }
switch self.state {
case .connected:
self.logger.info("control channel state -> connected")
case .connecting:
self.logger.info("control channel state -> connecting")
case .disconnected:
self.logger.info("control channel state -> disconnected")
self.scheduleRecovery(reason: "disconnected")
case let .degraded(message):
let detail = message.isEmpty ? "degraded" : "degraded: \(message)"
self.logger.info("control channel state -> \(detail, privacy: .public)")
self.scheduleRecovery(reason: message)
}
}
}
private(set) var lastPingMs: Double?
private(set) var authSourceLabel: String?
private let logger = Logger(subsystem: "ai.openclaw", category: "control")
private var eventTask: Task<Void, Never>?
private var recoveryTask: Task<Void, Never>?
private var lastRecoveryAt: Date?
// Coalesce rapid connecting/degraded oscillations so SwiftUI does not churn
// MenuBarExtra status items while the gateway connection is unstable.
private var pendingStateTask: Task<Void, Never>?
private var stateDebouncer = ControlChannelStateDebouncer()
private func setStateThrottled(_ newState: ConnectionState) {
let now = Date()
if let delay = self.stateDebouncer.delayBeforeApplying(
currentState: self.state,
newState: newState,
now: now)
{
self.pendingStateTask?.cancel()
self.pendingStateTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: Self.nanoseconds(for: delay))
guard let self, !Task.isCancelled else { return }
self.pendingStateTask = nil
self.stateDebouncer.recordDeferredApply(at: Date())
self.applyState(newState)
}
return
}
self.cancelPendingStateTask()
self.applyState(newState)
}
private func cancelPendingStateTask() {
self.pendingStateTask?.cancel()
self.pendingStateTask = nil
}
private func applyState(_ newState: ConnectionState) {
self.state = newState
}
private static func nanoseconds(for interval: TimeInterval) -> UInt64 {
UInt64(max(0, interval) * 1_000_000_000)
}
private init() {
self.startEventStream()
}
func configure() async {
self.logger.info("control channel configure mode=local")
await self.refreshEndpoint(reason: "configure")
}
func configure(mode: Mode = .local) async throws {
switch mode {
case .local:
await self.configure()
case let .remote(target, identity):
do {
_ = (target, identity)
let idSet = !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
self.logger.info(
"control channel configure mode=remote " +
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
self.setStateThrottled(.connecting)
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await self.refreshEndpoint(reason: "configure")
} catch {
self.setStateThrottled(.degraded(error.localizedDescription))
throw error
}
}
}
func refreshEndpoint(reason: String) async {
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
self.setStateThrottled(.connecting)
do {
try await self.establishGatewayConnection()
self.setStateThrottled(.connected)
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
let message = self.friendlyGatewayMessage(error)
self.setStateThrottled(.degraded(message))
}
}
func disconnect() async {
await GatewayConnection.shared.shutdown()
self.setStateThrottled(.disconnected)
self.lastPingMs = nil
self.authSourceLabel = nil
}
func health(timeout: TimeInterval? = nil) async throws -> Data {
do {
let start = Date()
var params: [String: AnyHashable]?
if let timeout {
params = ["timeout": AnyHashable(Int(timeout * 1000))]
}
let timeoutMs = (timeout ?? 15) * 1000
let payload = try await self.request(method: "health", params: params, timeoutMs: timeoutMs)
let ms = Date().timeIntervalSince(start) * 1000
self.lastPingMs = ms
self.setStateThrottled(.connected)
return payload
} catch {
let message = self.friendlyGatewayMessage(error)
self.setStateThrottled(.degraded(message))
throw ControlChannelError.badResponse(message)
}
}
func lastHeartbeat() async throws -> ControlHeartbeatEvent? {
let data = try await self.request(method: "last-heartbeat")
return try JSONDecoder().decode(ControlHeartbeatEvent?.self, from: data)
}
func request(
method: String,
params: [String: AnyHashable]? = nil,
timeoutMs: Double? = nil,
retryTransportFailures: Bool = true) async throws -> Data
{
do {
let rawParams = params?.reduce(into: [String: OpenClawKit.AnyCodable]()) {
$0[$1.key] = OpenClawKit.AnyCodable($1.value.base)
}
let data = try await GatewayConnection.shared.request(
method: method,
params: rawParams,
timeoutMs: timeoutMs,
retryTransportFailures: retryTransportFailures)
self.setStateThrottled(.connected)
return data
} catch {
let message = self.friendlyGatewayMessage(error)
self.setStateThrottled(.degraded(message))
throw ControlChannelError.badResponse(message)
}
}
private func friendlyGatewayMessage(_ error: Error) -> String {
// Map URLSession/WS errors into user-facing, actionable text.
if let ctrlErr = error as? ControlChannelError, let desc = ctrlErr.errorDescription {
return desc
}
if let authIssue = RemoteGatewayAuthIssue(error: error) {
return authIssue.statusMessage
}
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
if let urlErr = error as? URLError,
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
{
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
let tokenKey = CommandResolver.connectionModeIsRemote()
? "gateway.remote.token"
: "gateway.auth.token"
return
"Gateway rejected token; set \(tokenKey) or clear it on the gateway. Reason: \(reason)"
}
// Common misfire: we connected to the configured localhost port but it is occupied
// by some other process (e.g. a local dev gateway or a stuck SSH forward).
// The gateway handshake returns something we can't parse, which currently
// surfaces as "hello failed (unexpected response)". Give the user a pointer
// to free the port instead of a vague message.
let nsError = error as NSError
if nsError.domain == "Gateway",
nsError.localizedDescription.contains("hello failed (unexpected response)")
{
let port = GatewayEnvironment.gatewayPort()
return """
Gateway handshake got non-gateway data on localhost:\(port).
Another process is using that port or the SSH forward failed.
Stop the local gateway/port-forward on \(port) and retry Remote mode.
"""
}
if let urlError = error as? URLError {
let port = GatewayEnvironment.gatewayPort()
switch urlError.code {
case .cancelled:
return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
case .cannotFindHost, .cannotConnectToHost:
let isRemote = CommandResolver.connectionModeIsRemote()
if isRemote {
return """
Cannot reach gateway at localhost:\(port).
Remote mode uses an SSH tunnel—check the SSH target and that the tunnel is running.
"""
}
return "Cannot reach gateway at localhost:\(port); ensure the gateway is running."
case .networkConnectionLost:
return "Gateway connection dropped; gateway likely restarted—retry."
case .timedOut:
return "Gateway request timed out; check gateway on localhost:\(port)."
case .notConnectedToInternet:
if Self.isLikelyLocalNetworkPermissionBlock() {
return """
macOS is blocking OpenClaw Local Network access.
Allow OpenClaw in System Settings → Privacy & Security → Local Network, then relaunch the app.
"""
}
return "No network connectivity; cannot reach gateway."
default:
break
}
}
if nsError.domain == "Gateway", nsError.code == 5 {
let port = GatewayEnvironment.gatewayPort()
return "Gateway request timed out; check the gateway process on localhost:\(port)."
}
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.lowercased().hasPrefix("gateway error:") { return trimmed }
return "Gateway error: \(trimmed)"
}
private static func isLikelyLocalNetworkPermissionBlock() -> Bool {
let root = OpenClawConfigFile.loadDict()
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
guard ConnectionModeResolver.resolve(root: root).mode == .remote,
resolution.transport == .direct,
let url = resolution.directURL,
url.scheme?.lowercased() == "ws",
let host = url.host,
GatewayRemoteConfig.isTrustedPlaintextRemoteHost(host),
!LoopbackHost.isLoopbackHost(host)
else {
return false
}
return true
}
private func scheduleRecovery(reason: String) {
let now = Date()
if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return }
guard self.recoveryTask == nil else { return }
self.lastRecoveryAt = now
self.recoveryTask = Task { [weak self] in
guard let self else { return }
let mode = await MainActor.run { AppStateStore.shared.connectionMode }
guard mode != .unconfigured else {
self.recoveryTask = nil
return
}
let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
let reasonText = trimmedReason.isEmpty ? "unknown" : trimmedReason
self.logger.info(
"control channel recovery starting " +
"mode=\(String(describing: mode), privacy: .public) " +
"reason=\(reasonText, privacy: .public)")
if mode == .local {
GatewayProcessManager.shared.setActive(true)
}
if mode == .remote {
do {
let port = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
self.logger.info("control channel recovery ensured SSH tunnel port=\(port, privacy: .public)")
} catch {
self.logger.error(
"control channel recovery tunnel failed \(error.localizedDescription, privacy: .public)")
}
}
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
if case .connected = self.state {
self.logger.info("control channel recovery finished")
} else if case let .degraded(message) = self.state {
self.logger.error("control channel recovery failed \(message, privacy: .public)")
}
self.recoveryTask = nil
}
}
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
try await GatewayConnection.shared.refresh()
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
if ok == false {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
}
await self.refreshAuthSourceLabel()
}
private func refreshAuthSourceLabel() async {
let isRemote = CommandResolver.connectionModeIsRemote()
let authSource = await GatewayConnection.shared.authSource()
self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote)
}
private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? {
guard let source else { return nil }
switch source {
case .deviceToken:
return "Auth: device token (paired device)"
case .bootstrapToken:
return "Auth: bootstrap token (setup code)"
case .sharedToken:
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
case .password:
return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))"
case .none:
return "Auth: none"
}
}
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
var merged = params
merged["text"] = AnyHashable(text)
_ = try await self.request(method: "system-event", params: merged)
}
private func startEventStream() {
GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
self?.handle(push: push)
}
}
private func handle(push: GatewayPush) {
switch push {
case let .event(evt) where evt.event == "agent":
if let payload = evt.payload,
let agent = try? GatewayPayloadDecoding.decode(payload, as: ControlAgentEvent.self)
{
AgentEventStore.shared.append(agent)
self.routeWorkActivity(from: agent)
}
case let .event(evt) where evt.event == "heartbeat":
if let payload = evt.payload,
let heartbeat = try? GatewayPayloadDecoding.decode(payload, as: ControlHeartbeatEvent.self),
let data = try? JSONEncoder().encode(heartbeat)
{
NotificationCenter.default.post(name: .controlHeartbeat, object: data)
}
case let .event(evt) where evt.event == "shutdown":
self.setStateThrottled(.degraded("gateway shutdown"))
case .snapshot:
self.setStateThrottled(.connected)
default:
break
}
}
private func routeWorkActivity(from event: ControlAgentEvent) {
// We currently treat VoiceWake as the "main" session for UI purposes.
// In the future, the gateway can include a sessionKey to distinguish runs.
let sessionKey = (event.data["sessionKey"]?.value as? String) ?? "main"
switch event.stream.lowercased() {
case "job":
if let state = event.data["state"]?.value as? String {
WorkActivityStore.shared.handleJob(sessionKey: sessionKey, state: state)
}
case "tool":
let phase = event.data["phase"]?.value as? String ?? ""
let name = event.data["name"]?.value as? String
let meta = event.data["meta"]?.value as? String
let args = Self.bridgeToProtocolArgs(event.data["args"])
WorkActivityStore.shared.handleTool(
sessionKey: sessionKey,
phase: phase,
name: name,
meta: meta,
args: args)
default:
break
}
}
private static func bridgeToProtocolArgs(
_ value: OpenClawProtocol.AnyCodable?) -> [String: OpenClawProtocol.AnyCodable]?
{
guard let value else { return nil }
if let dict = value.value as? [String: OpenClawProtocol.AnyCodable] {
return dict
}
if let dict = value.value as? [String: OpenClawKit.AnyCodable],
let data = try? JSONEncoder().encode(dict),
let decoded = try? JSONDecoder().decode([String: OpenClawProtocol.AnyCodable].self, from: data)
{
return decoded
}
if let data = try? JSONEncoder().encode(value),
let decoded = try? JSONDecoder().decode([String: OpenClawProtocol.AnyCodable].self, from: data)
{
return decoded
}
return nil
}
}
extension Notification.Name {
static let controlHeartbeat = Notification.Name("openclaw.control.heartbeat")
static let controlAgentEvent = Notification.Name("openclaw.control.agent")
}

View File

@@ -0,0 +1,99 @@
import Charts
import SwiftUI
struct CostUsageHistoryMenuView: View {
let summary: GatewayCostUsageSummary
let width: CGFloat
var body: some View {
VStack(alignment: .leading, spacing: 10) {
self.header
self.chart
self.footer
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(width: max(1, self.width), alignment: .leading)
}
private var header: some View {
let todayKey = CostUsageMenuDateParser.format(Date())
let todayEntry = self.summary.daily.first { $0.date == todayKey }
let todayCost = CostUsageFormatting.formatUsd(todayEntry?.totalCost) ?? "n/a"
let totalCost = CostUsageFormatting.formatUsd(self.summary.totals.totalCost) ?? "n/a"
return HStack(alignment: .firstTextBaseline, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("Today")
.font(.caption2)
.foregroundStyle(.secondary)
Text(todayCost)
.font(.system(size: 14, weight: .semibold))
}
VStack(alignment: .leading, spacing: 2) {
Text("Last \(self.summary.days)d")
.font(.caption2)
.foregroundStyle(.secondary)
Text(totalCost)
.font(.system(size: 14, weight: .semibold))
}
Spacer()
}
}
private var chart: some View {
let entries = self.summary.daily.compactMap { entry -> (Date, Double)? in
guard let date = CostUsageMenuDateParser.parse(entry.date) else { return nil }
return (date, entry.totalCost)
}
return Chart(entries, id: \.0) { entry in
BarMark(
x: .value("Day", entry.0),
y: .value("Cost", entry.1))
.foregroundStyle(Color.accentColor)
.cornerRadius(3)
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: 7)) {
AxisGridLine().foregroundStyle(.clear)
AxisValueLabel(format: .dateTime.month().day())
}
}
.chartYAxis {
AxisMarks(position: .leading) {
AxisGridLine()
AxisValueLabel()
}
}
.frame(height: 110)
}
private var footer: some View {
if self.summary.totals.missingCostEntries == 0 {
return AnyView(EmptyView())
}
return AnyView(
Text("Partial: \(self.summary.totals.missingCostEntries) entries missing cost")
.font(.caption2)
.foregroundStyle(.secondary))
}
}
private enum CostUsageMenuDateParser {
static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
return formatter
}()
static func parse(_ value: String) -> Date? {
self.formatter.date(from: value)
}
static func format(_ date: Date) -> String {
self.formatter.string(from: date)
}
}

View File

@@ -0,0 +1,379 @@
import AppKit
enum CritterIconRenderer {
private static let size = NSSize(width: 18, height: 18)
struct Badge {
let symbolName: String
let prominence: IconState.BadgeProminence
}
private struct Canvas {
let w: CGFloat
let h: CGFloat
let stepX: CGFloat
let stepY: CGFloat
let snapX: (CGFloat) -> CGFloat
let snapY: (CGFloat) -> CGFloat
let context: CGContext
}
private struct Geometry {
let bodyRect: CGRect
let leftArmRect: CGRect
let rightArmRect: CGRect
let leftEarRect: CGRect
let rightEarRect: CGRect
let antennaLineWidth: CGFloat
let legW: CGFloat
let legH: CGFloat
let legSpacing: CGFloat
let legStartX: CGFloat
let legYBase: CGFloat
let legLift: CGFloat
let legHeightScale: CGFloat
let eyeSize: CGSize
let eyeY: CGFloat
let eyeOffset: CGFloat
init(canvas: Canvas, legWiggle: CGFloat, earWiggle: CGFloat, earScale: CGFloat) {
let w = canvas.w
let h = canvas.h
let snapX = canvas.snapX
let snapY = canvas.snapY
let bodyW = snapX(w * 0.68)
let bodyH = snapY(h * 0.68)
let bodyX = snapX((w - bodyW) / 2)
let bodyY = snapY(h * 0.24)
let armSize = snapX(w * 0.2)
let armY = snapY(bodyY + bodyH * 0.36)
let leftArmRect = CGRect(
x: snapX(bodyX - armSize * 0.62),
y: armY,
width: armSize,
height: armSize)
let rightArmRect = CGRect(
x: snapX(bodyX + bodyW - armSize * 0.38),
y: armY,
width: armSize,
height: armSize)
let antennaW = snapX(w * 0.22)
let antennaH = snapY(min(bodyH * 0.24 * earScale, h * 0.19))
let antennaLineWidth = max(snapX(w * 0.095), canvas.stepX * 2) * min(1.2, 0.94 + earScale * 0.06)
let antennaLift = snapY(earWiggle * 0.35)
let leftEarRect = CGRect(
x: snapX(bodyX + bodyW * 0.18 - antennaW * 0.35 - earWiggle * 0.28),
y: snapY(bodyY + bodyH * 0.86 + antennaLift),
width: antennaW,
height: antennaH)
let rightEarRect = CGRect(
x: snapX(bodyX + bodyW * 0.82 - antennaW * 0.65 + earWiggle * 0.28),
y: snapY(bodyY + bodyH * 0.86 - antennaLift),
width: antennaW,
height: antennaH)
let legW = snapX(w * 0.15)
let legH = snapY(h * 0.25)
let legSpacing = snapX(w * 0.16)
let legsWidth = snapX(2 * legW + legSpacing)
let legStartX = snapX((w - legsWidth) / 2)
let legLift = snapY(legH * 0.35 * legWiggle)
let legYBase = snapY(bodyY - legH * 0.58)
let legHeightScale = 1 - 0.12 * legWiggle
let eyeSize = CGSize(
width: snapX(bodyW * 0.15),
height: snapY(bodyH * 0.2))
let eyeY = snapY(bodyY + bodyH * 0.58)
let eyeOffset = snapX(bodyW * 0.22)
self.bodyRect = CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH)
self.leftArmRect = leftArmRect
self.rightArmRect = rightArmRect
self.leftEarRect = leftEarRect
self.rightEarRect = rightEarRect
self.antennaLineWidth = antennaLineWidth
self.legW = legW
self.legH = legH
self.legSpacing = legSpacing
self.legStartX = legStartX
self.legYBase = legYBase
self.legLift = legLift
self.legHeightScale = legHeightScale
self.eyeSize = eyeSize
self.eyeY = eyeY
self.eyeOffset = eyeOffset
}
}
private struct FaceOptions {
let blink: CGFloat
let eyesClosedLines: Bool
}
static func makeIcon(
blink: CGFloat,
legWiggle: CGFloat = 0,
earWiggle: CGFloat = 0,
earScale: CGFloat = 1,
earHoles: Bool = false,
eyesClosedLines: Bool = false,
badge: Badge? = nil) -> NSImage
{
guard let rep = self.makeBitmapRep() else {
return NSImage(size: self.size)
}
rep.size = self.size
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
guard let context = NSGraphicsContext(bitmapImageRep: rep) else {
return NSImage(size: self.size)
}
NSGraphicsContext.current = context
context.imageInterpolation = .none
context.cgContext.setShouldAntialias(true)
let canvas = self.makeCanvas(for: rep, context: context)
let geometry = Geometry(
canvas: canvas,
legWiggle: legWiggle,
earWiggle: earWiggle,
earScale: earHoles ? max(earScale, 1.2) : earScale)
self.drawBody(in: canvas, geometry: geometry)
let face = FaceOptions(
blink: blink,
eyesClosedLines: eyesClosedLines)
self.drawFace(in: canvas, geometry: geometry, options: face)
if let badge {
self.drawBadge(badge, canvas: canvas)
}
let image = NSImage(size: size)
image.addRepresentation(rep)
image.isTemplate = true
return image
}
private static func makeBitmapRep() -> NSBitmapImageRep? {
// Force a 36×36px backing store (2× for the 18pt logical canvas) so the menu bar icon stays crisp on Retina.
let pixelsWide = 36
let pixelsHigh = 36
return NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: pixelsWide,
pixelsHigh: pixelsHigh,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bitmapFormat: [],
bytesPerRow: 0,
bitsPerPixel: 0)
}
private static func makeCanvas(for rep: NSBitmapImageRep, context: NSGraphicsContext) -> Canvas {
let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1)
let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1)
let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX }
let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY }
let w = snapX(size.width)
let h = snapY(size.height)
return Canvas(
w: w,
h: h,
stepX: stepX,
stepY: stepY,
snapX: snapX,
snapY: snapY,
context: context.cgContext)
}
private static func drawBody(in canvas: Canvas, geometry: Geometry) {
canvas.context.setStrokeColor(NSColor.labelColor.cgColor)
canvas.context.setLineWidth(geometry.antennaLineWidth)
canvas.context.setLineCap(.round)
canvas.context.setLineJoin(.round)
let leftStart = CGPoint(
x: canvas.snapX(geometry.bodyRect.minX + geometry.bodyRect.width * 0.34),
y: canvas.snapY(geometry.bodyRect.maxY - geometry.antennaLineWidth * 0.22))
let leftEnd = CGPoint(
x: canvas.snapX(geometry.leftEarRect.minX),
y: canvas.snapY(geometry.leftEarRect.maxY))
let leftControl = CGPoint(
x: canvas.snapX(geometry.leftEarRect.midX),
y: canvas.snapY(geometry.leftEarRect.minY))
let rightStart = CGPoint(
x: canvas.snapX(geometry.bodyRect.maxX - geometry.bodyRect.width * 0.34),
y: canvas.snapY(geometry.bodyRect.maxY - geometry.antennaLineWidth * 0.22))
let rightEnd = CGPoint(
x: canvas.snapX(geometry.rightEarRect.maxX),
y: canvas.snapY(geometry.rightEarRect.maxY))
let rightControl = CGPoint(
x: canvas.snapX(geometry.rightEarRect.midX),
y: canvas.snapY(geometry.rightEarRect.minY))
let antennae = CGMutablePath()
antennae.move(to: leftStart)
antennae.addQuadCurve(to: leftEnd, control: leftControl)
antennae.move(to: rightStart)
antennae.addQuadCurve(to: rightEnd, control: rightControl)
canvas.context.addPath(antennae)
canvas.context.strokePath()
canvas.context.setFillColor(NSColor.labelColor.cgColor)
for i in 0..<2 {
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
let lift = i % 2 == 0 ? geometry.legLift : -geometry.legLift
let rect = CGRect(
x: x,
y: geometry.legYBase + lift,
width: geometry.legW,
height: geometry.legH * geometry.legHeightScale)
canvas.context.addPath(CGPath(
roundedRect: rect,
cornerWidth: geometry.legW * 0.34,
cornerHeight: geometry.legW * 0.34,
transform: nil))
}
canvas.context.addEllipse(in: geometry.leftArmRect)
canvas.context.addEllipse(in: geometry.rightArmRect)
canvas.context.addEllipse(in: geometry.bodyRect)
canvas.context.fillPath()
}
private static func drawFace(
in canvas: Canvas,
geometry: Geometry,
options: FaceOptions)
{
canvas.context.saveGState()
canvas.context.setBlendMode(.clear)
let leftCenter = CGPoint(
x: canvas.snapX(canvas.w / 2 - geometry.eyeOffset),
y: canvas.snapY(geometry.eyeY))
let rightCenter = CGPoint(
x: canvas.snapX(canvas.w / 2 + geometry.eyeOffset),
y: canvas.snapY(geometry.eyeY))
if options.eyesClosedLines {
let lineW = canvas.snapX(geometry.eyeSize.width * 1.15)
let lineH = canvas.snapY(max(canvas.stepY * 2, geometry.bodyRect.height * 0.06))
let corner = canvas.snapX(lineH * 0.6)
let leftRect = CGRect(
x: canvas.snapX(leftCenter.x - lineW / 2),
y: canvas.snapY(leftCenter.y - lineH / 2),
width: lineW,
height: lineH)
let rightRect = CGRect(
x: canvas.snapX(rightCenter.x - lineW / 2),
y: canvas.snapY(rightCenter.y - lineH / 2),
width: lineW,
height: lineH)
canvas.context.addPath(CGPath(
roundedRect: leftRect,
cornerWidth: corner,
cornerHeight: corner,
transform: nil))
canvas.context.addPath(CGPath(
roundedRect: rightRect,
cornerWidth: corner,
cornerHeight: corner,
transform: nil))
} else {
let eyeOpen = max(0.05, 1 - options.blink)
let eyeH = canvas.snapY(geometry.eyeSize.height * eyeOpen)
let leftRect = CGRect(
x: canvas.snapX(leftCenter.x - geometry.eyeSize.width / 2),
y: canvas.snapY(leftCenter.y - eyeH / 2),
width: geometry.eyeSize.width,
height: eyeH)
let rightRect = CGRect(
x: canvas.snapX(rightCenter.x - geometry.eyeSize.width / 2),
y: canvas.snapY(rightCenter.y - eyeH / 2),
width: geometry.eyeSize.width,
height: eyeH)
canvas.context.addEllipse(in: leftRect)
canvas.context.addEllipse(in: rightRect)
}
canvas.context.fillPath()
canvas.context.restoreGState()
}
private static func drawBadge(_ badge: Badge, canvas: Canvas) {
let strength: CGFloat = switch badge.prominence {
case .primary: 1.0
case .secondary: 0.58
case .overridden: 0.85
}
// Bigger, higher-contrast badge:
// - Increase diameter so tool activity is noticeable.
// - Draw a filled "puck", then knock out the symbol shape (transparent hole).
// This reads better in template-rendered menu bar icons than tiny monochrome glyphs.
let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~910pt on an 18pt canvas
let margin = canvas.snapX(max(0.45, canvas.w * 0.03))
let rect = CGRect(
x: canvas.snapX(canvas.w - diameter - margin),
y: canvas.snapY(margin),
width: diameter,
height: diameter)
canvas.context.saveGState()
canvas.context.setShouldAntialias(true)
// Clear the underlying pixels so the badge stays readable over the critter.
canvas.context.saveGState()
canvas.context.setBlendMode(.clear)
canvas.context.addEllipse(in: rect.insetBy(dx: -1.0, dy: -1.0))
canvas.context.fillPath()
canvas.context.restoreGState()
let fillAlpha: CGFloat = min(1.0, 0.36 + 0.24 * strength)
let strokeAlpha: CGFloat = min(1.0, 0.78 + 0.22 * strength)
canvas.context.setFillColor(NSColor.labelColor.withAlphaComponent(fillAlpha).cgColor)
canvas.context.addEllipse(in: rect)
canvas.context.fillPath()
canvas.context.setStrokeColor(NSColor.labelColor.withAlphaComponent(strokeAlpha).cgColor)
canvas.context.setLineWidth(max(1.25, canvas.snapX(canvas.w * 0.075)))
canvas.context.strokeEllipse(in: rect.insetBy(dx: 0.45, dy: 0.45))
if let base = NSImage(systemSymbolName: badge.symbolName, accessibilityDescription: nil) {
let pointSize = max(7.0, diameter * 0.82)
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .black)
let symbol = base.withSymbolConfiguration(config) ?? base
symbol.isTemplate = true
let symbolRect = rect.insetBy(dx: diameter * 0.17, dy: diameter * 0.17)
canvas.context.saveGState()
canvas.context.setBlendMode(.clear)
symbol.draw(
in: symbolRect,
from: .zero,
operation: .sourceOver,
fraction: 1,
respectFlipped: true,
hints: nil)
canvas.context.restoreGState()
}
canvas.context.restoreGState()
}
}

View File

@@ -0,0 +1,316 @@
import AppKit
import SwiftUI
extension CritterStatusLabel {
private var isWorkingNow: Bool {
self.iconState.isWorking || self.isWorking
}
private var effectiveAnimationsEnabled: Bool {
self.animationsEnabled && !self.isSleeping
}
var body: some View {
ZStack(alignment: .topTrailing) {
self.iconImage
.frame(width: 18, height: 18)
.rotationEffect(.degrees(self.wiggleAngle), anchor: .center)
.offset(x: self.wiggleOffset)
// Avoid Combine's TimerPublisher here: on macOS 26.2 we've seen crashes inside executor checks
// triggered by its callbacks. Drive periodic updates via a Swift-concurrency task instead.
.task(id: self.tickTaskID) {
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
await MainActor.run { self.resetMotion() }
return
}
while !Task.isCancelled {
let now = Date()
await MainActor.run { self.tick(now) }
try? await Task.sleep(nanoseconds: 350_000_000)
}
}
.onChange(of: self.isPaused) { _, _ in self.resetMotion() }
.onChange(of: self.blinkTick) { _, _ in
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
self.blink()
}
.onChange(of: self.sendCelebrationTick) { _, _ in
guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return }
self.wiggleLegs()
}
.onChange(of: self.animationsEnabled) { _, enabled in
if enabled, !self.isSleeping {
self.scheduleRandomTimers(from: Date())
} else {
self.resetMotion()
}
}
.onChange(of: self.isSleeping) { _, _ in
self.resetMotion()
}
.onChange(of: self.earBoostActive) { _, active in
if active {
self.resetMotion()
} else if self.effectiveAnimationsEnabled {
self.scheduleRandomTimers(from: Date())
}
}
if self.gatewayNeedsAttention {
Circle()
.fill(self.gatewayBadgeColor)
.frame(width: 6, height: 6)
.padding(1)
}
if self.voiceWakeMeterActive {
Circle()
.fill(.orange)
.frame(width: 5, height: 5)
.padding(2)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
}
}
.frame(width: 18, height: 18)
}
private var tickTaskID: Int {
// Ensure SwiftUI restarts (and cancels) the task when these change.
(self.effectiveAnimationsEnabled ? 1 : 0) | (self.earBoostActive ? 2 : 0)
}
private func tick(_ now: Date) {
guard self.effectiveAnimationsEnabled, !self.earBoostActive else {
self.resetMotion()
return
}
if now >= self.nextBlink {
self.blink()
self.nextBlink = now.addingTimeInterval(Double.random(in: 3.5...8.5))
}
if now >= self.nextWiggle {
self.wiggle()
self.nextWiggle = now.addingTimeInterval(Double.random(in: 6.5...14))
}
if now >= self.nextLegWiggle {
self.wiggleLegs()
self.nextLegWiggle = now.addingTimeInterval(Double.random(in: 5.0...11.0))
}
if now >= self.nextEarWiggle {
self.wiggleEars()
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
}
if self.isWorkingNow {
self.scurry()
}
}
private var iconImage: Image {
let badge: CritterIconRenderer.Badge? = if let prominence = self.iconState.badgeProminence, !self.isPaused {
CritterIconRenderer.Badge(
symbolName: self.iconState.badgeSymbolName,
prominence: prominence)
} else {
nil
}
if self.isPaused {
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil))
}
if self.isSleeping {
return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, eyesClosedLines: true, badge: nil))
}
return Image(nsImage: CritterIconRenderer.makeIcon(
blink: self.blinkAmount,
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
earWiggle: self.earWiggle,
earScale: self.earBoostActive ? 1.9 : 1.0,
earHoles: self.earBoostActive,
badge: badge))
}
private func resetMotion() {
self.blinkAmount = 0
self.wiggleAngle = 0
self.wiggleOffset = 0
self.legWiggle = 0
self.earWiggle = 0
}
private func blink() {
withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 }
Task { @MainActor in
try? await Task.sleep(nanoseconds: 160_000_000)
withAnimation(.easeOut(duration: 0.12)) { self.blinkAmount = 0 }
}
}
private func wiggle() {
let targetAngle = Double.random(in: -4.5...4.5)
let targetOffset = CGFloat.random(in: -0.5...0.5)
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
self.wiggleAngle = targetAngle
self.wiggleOffset = targetOffset
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 360_000_000)
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
self.wiggleAngle = 0
self.wiggleOffset = 0
}
}
}
private func wiggleLegs() {
let target = CGFloat.random(in: 0.35...0.9)
withAnimation(.easeInOut(duration: 0.14)) {
self.legWiggle = target
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 220_000_000)
withAnimation(.easeOut(duration: 0.18)) { self.legWiggle = 0 }
}
}
private func scurry() {
let target = CGFloat.random(in: 0.7...1.0)
withAnimation(.easeInOut(duration: 0.12)) {
self.legWiggle = target
self.wiggleOffset = CGFloat.random(in: -0.6...0.6)
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 180_000_000)
withAnimation(.easeOut(duration: 0.16)) {
self.legWiggle = 0.25
self.wiggleOffset = 0
}
}
}
private func wiggleEars() {
let target = CGFloat.random(in: -1.2...1.2)
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
self.earWiggle = target
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 320_000_000)
withAnimation(.interpolatingSpring(stiffness: 260, damping: 19)) {
self.earWiggle = 0
}
}
}
private func scheduleRandomTimers(from date: Date) {
self.nextBlink = date.addingTimeInterval(Double.random(in: 3.5...8.5))
self.nextWiggle = date.addingTimeInterval(Double.random(in: 6.5...14))
self.nextLegWiggle = date.addingTimeInterval(Double.random(in: 5.0...11.0))
self.nextEarWiggle = date.addingTimeInterval(Double.random(in: 7.0...14.0))
}
private var gatewayNeedsAttention: Bool {
if self.isSleeping { return false }
switch self.gatewayStatus {
case .failed, .stopped:
return !self.isPaused
case .starting, .running, .attachedExisting:
return false
}
}
private var gatewayBadgeColor: Color {
switch self.gatewayStatus {
case .failed: .red
case .stopped: .orange
default: .clear
}
}
}
#if DEBUG
@MainActor
extension CritterStatusLabel {
static func exerciseForTesting() async {
var label = CritterStatusLabel(
isPaused: false,
isSleeping: false,
isWorking: true,
earBoostActive: false,
blinkTick: 1,
sendCelebrationTick: 1,
gatewayStatus: .running(details: nil),
animationsEnabled: true,
iconState: .workingMain(.tool(.bash)),
voiceWakeMeterActive: true)
_ = label.body
_ = label.iconImage
_ = label.tickTaskID
label.tick(Date())
label.resetMotion()
label.blink()
label.wiggle()
label.wiggleLegs()
label.wiggleEars()
label.scurry()
label.scheduleRandomTimers(from: Date())
_ = label.gatewayNeedsAttention
_ = label.gatewayBadgeColor
label.isPaused = true
_ = label.iconImage
label.isPaused = false
label.isSleeping = true
_ = label.iconImage
label.isSleeping = false
label.iconState = .idle
_ = label.iconImage
let failed = CritterStatusLabel(
isPaused: false,
isSleeping: false,
isWorking: false,
earBoostActive: false,
blinkTick: 0,
sendCelebrationTick: 0,
gatewayStatus: .failed("boom"),
animationsEnabled: false,
iconState: .idle,
voiceWakeMeterActive: false)
_ = failed.gatewayNeedsAttention
_ = failed.gatewayBadgeColor
let stopped = CritterStatusLabel(
isPaused: false,
isSleeping: false,
isWorking: false,
earBoostActive: false,
blinkTick: 0,
sendCelebrationTick: 0,
gatewayStatus: .stopped,
animationsEnabled: false,
iconState: .idle,
voiceWakeMeterActive: false)
_ = stopped.gatewayNeedsAttention
_ = stopped.gatewayBadgeColor
_ = CritterIconRenderer.makeIcon(
blink: 0.6,
legWiggle: 0.8,
earWiggle: 0.4,
earScale: 1.4,
earHoles: true,
eyesClosedLines: true,
badge: .init(symbolName: "gearshape.fill", prominence: .secondary))
}
}
#endif

View File

@@ -0,0 +1,24 @@
import SwiftUI
struct CritterStatusLabel: View {
var isPaused: Bool
var isSleeping: Bool
var isWorking: Bool
var earBoostActive: Bool
var blinkTick: Int
var sendCelebrationTick: Int
var gatewayStatus: GatewayProcessManager.Status
var animationsEnabled: Bool
var iconState: IconState
var voiceWakeMeterActive: Bool = false
@State var blinkAmount: CGFloat = 0
@State var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
@State var wiggleAngle: Double = 0
@State var wiggleOffset: CGFloat = 0
@State var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5...14))
@State var legWiggle: CGFloat = 0
@State var nextLegWiggle = Date().addingTimeInterval(Double.random(in: 5.0...11.0))
@State var earWiggle: CGFloat = 0
@State var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
}

View File

@@ -0,0 +1,302 @@
import Foundation
import OpenClawProtocol
import SwiftUI
extension CronJobEditor {
func gridLabel(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
.frame(width: self.labelColumnWidth, alignment: .leading)
}
func hydrateFromJob() {
guard let job else { return }
self.name = job.name
self.description = job.description ?? ""
self.agentId = job.agentId ?? ""
self.enabled = job.enabled
self.deleteAfterRun = job.deleteAfterRun ?? false
switch job.parsedSessionTarget {
case let .predefined(target):
self.sessionTarget = target
self.preservedSessionTargetRaw = nil
case let .session(id):
self.sessionTarget = .isolated
self.preservedSessionTargetRaw = "session:\(id)"
}
self.wakeMode = job.wakeMode
switch job.schedule {
case let .at(at):
self.scheduleKind = .at
if let date = CronSchedule.parseAtDate(at) {
self.atDate = date
}
case let .every(everyMs, _):
self.scheduleKind = .every
self.everyText = self.formatDuration(ms: everyMs)
case let .cron(expr, tz):
self.scheduleKind = .cron
self.cronExpr = expr
self.cronTz = tz ?? ""
case .onExit:
// on-exit jobs are CLI-managed and have no editor form yet; fall back to
// the cron form so the editor still OPENS instead of failing to compile.
// Saving an on-exit job from the editor should be gated to avoid rewriting
// it as cron tracked as a macOS read-only-editor follow-up.
self.scheduleKind = .cron
}
switch job.payload {
case let .systemEvent(text):
self.payloadKind = .systemEvent
self.systemEventText = text
case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _):
self.payloadKind = .agentTurn
self.agentMessage = message
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
}
if let delivery = job.delivery {
self.deliveryMode = delivery.mode == .announce ? .announce : .none
let trimmed = (delivery.channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
self.channel = trimmed.isEmpty ? "last" : trimmed
self.to = delivery.to ?? ""
self.bestEffortDeliver = delivery.bestEffort ?? false
} else if self.isIsolatedLikeSessionTarget {
self.deliveryMode = .announce
}
}
func save() {
do {
self.error = nil
let payload = try self.buildPayload()
self.onSave(payload)
} catch {
self.error = error.localizedDescription
}
}
func buildPayload() throws -> [String: AnyCodable] {
// Gate on-exit saves: the editor has no on-exit schedule form (it falls back to
// the cron form), so saving would rewrite the on-exit schedule as cron and
// corrupt the job. Block it until a read-only/native on-exit editor exists.
if let job, case .onExit = job.schedule {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [
NSLocalizedDescriptionKey:
"on-exit cron jobs can't be edited in the macOS app yet; manage them with the CLI.",
])
}
let name = try self.requireName()
let description = self.trimmed(self.description)
let agentId = self.trimmed(self.agentId)
let schedule = try self.buildSchedule()
let payload = try self.buildSelectedPayload()
try self.validateSessionTarget(payload)
try self.validatePayloadRequiredFields(payload)
var root: [String: Any] = [
"name": name,
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.effectiveSessionTargetRaw,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
self.applyDeleteAfterRun(to: &root)
if !description.isEmpty { root["description"] = description }
if !agentId.isEmpty {
root["agentId"] = agentId
} else if self.job?.agentId != nil {
root["agentId"] = NSNull()
}
if self.isIsolatedLikeSessionTarget {
root["delivery"] = self.buildDelivery()
}
return root.mapValues { AnyCodable($0) }
}
func buildDelivery() -> [String: Any] {
let mode = self.deliveryMode == .announce ? "announce" : "none"
var delivery: [String: Any] = ["mode": mode]
if self.deliveryMode == .announce {
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
delivery["channel"] = trimmed.isEmpty ? "last" : trimmed
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { delivery["to"] = to }
if self.bestEffortDeliver {
delivery["bestEffort"] = true
} else if self.job?.delivery?.bestEffort == true {
delivery["bestEffort"] = false
}
}
return delivery
}
func trimmed(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
func requireName() throws -> String {
let name = self.trimmed(self.name)
if name.isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
}
return name
}
func buildSchedule() throws -> [String: Any] {
switch self.scheduleKind {
case .at:
return ["kind": "at", "at": CronSchedule.formatIsoDate(self.atDate)]
case .every:
guard let ms = Self.parseDurationMs(self.everyText) else {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
}
return ["kind": "every", "everyMs": ms]
case .cron:
let expr = self.trimmed(self.cronExpr)
if expr.isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
}
let tz = self.trimmed(self.cronTz)
if tz.isEmpty {
return ["kind": "cron", "expr": expr]
}
return ["kind": "cron", "expr": expr, "tz": tz]
}
}
func buildSelectedPayload() throws -> [String: Any] {
if self.isIsolatedLikeSessionTarget { return self.buildAgentTurnPayload() }
switch self.payloadKind {
case .systemEvent:
let text = self.trimmed(self.systemEventText)
return ["kind": "systemEvent", "text": text]
case .agentTurn:
return self.buildAgentTurnPayload()
}
}
func validateSessionTarget(_ payload: [String: Any]) throws {
if self.effectiveSessionTargetRaw == "main", payload["kind"] as? String == "agentTurn" {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [
NSLocalizedDescriptionKey:
"Main session jobs require systemEvent payloads (switch Session target to isolated).",
])
}
if self.effectiveSessionTargetRaw != "main", payload["kind"] as? String == "systemEvent" {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
}
}
func validatePayloadRequiredFields(_ payload: [String: Any]) throws {
if payload["kind"] as? String == "systemEvent" {
if (payload["text"] as? String ?? "").isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
}
}
if payload["kind"] as? String == "agentTurn" {
if (payload["message"] as? String ?? "").isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
}
}
}
func applyDeleteAfterRun(
to root: inout [String: Any],
scheduleKind: ScheduleKind? = nil,
deleteAfterRun: Bool? = nil)
{
let resolvedSchedule = scheduleKind ?? self.scheduleKind
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun
if resolvedSchedule == .at {
root["deleteAfterRun"] = resolvedDelete
} else if self.job?.deleteAfterRun != nil {
root["deleteAfterRun"] = false
}
}
func buildAgentTurnPayload() -> [String: Any] {
let msg = self.agentMessage.trimmingCharacters(in: .whitespacesAndNewlines)
var payload: [String: Any] = ["kind": "agentTurn", "message": msg]
let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines)
if !thinking.isEmpty { payload["thinking"] = thinking }
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
return payload
}
static func parseDurationMs(_ input: String) -> Int? {
let raw = input.trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty { return nil }
let rx = try? NSRegularExpression(pattern: "^(\\d+(?:\\.\\d+)?)(ms|s|m|h|d)$", options: [.caseInsensitive])
guard let match = rx?.firstMatch(in: raw, range: NSRange(location: 0, length: raw.utf16.count)) else {
return nil
}
func group(_ idx: Int) -> String {
let range = match.range(at: idx)
guard let r = Range(range, in: raw) else { return "" }
return String(raw[r])
}
let n = Double(group(1)) ?? 0
if !n.isFinite || n <= 0 { return nil }
let unit = group(2).lowercased()
let factor: Double = switch unit {
case "ms": 1
case "s": 1000
case "m": 60000
case "h": 3_600_000
default: 86_400_000
}
return Int(floor(n * factor))
}
var effectiveSessionTargetRaw: String {
if self.sessionTarget == .isolated,
let preserved = self.preservedSessionTargetRaw?.trimmingCharacters(in: .whitespacesAndNewlines),
!preserved.isEmpty
{
return preserved
}
return self.sessionTarget.rawValue
}
var isIsolatedLikeSessionTarget: Bool {
self.effectiveSessionTargetRaw != "main"
}
func formatDuration(ms: Int) -> String {
DurationFormattingSupport.conciseDuration(ms: ms)
}
}

View File

@@ -0,0 +1,28 @@
#if DEBUG
extension CronJobEditor {
mutating func exerciseForTesting() {
self.name = "Test job"
self.description = "Test description"
self.agentId = "ops"
self.enabled = true
self.sessionTarget = .isolated
self.wakeMode = .now
self.scheduleKind = .every
self.everyText = "15m"
self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic"
self.deliveryMode = .announce
self.channel = "last"
self.to = "+15551230000"
self.thinking = "low"
self.timeoutSeconds = "90"
self.bestEffortDeliver = true
_ = self.buildAgentTurnPayload()
_ = try? self.buildPayload()
_ = self.formatDuration(ms: 45000)
}
}
#endif

View File

@@ -0,0 +1,367 @@
import Observation
import OpenClawProtocol
import SwiftUI
struct CronJobEditor: View {
let job: CronJob?
@Binding var isSaving: Bool
@Binding var error: String?
@Bindable var channelsStore: ChannelsStore
let onCancel: () -> Void
let onSave: ([String: AnyCodable]) -> Void
let labelColumnWidth: CGFloat = 160
static let introText =
"Create a schedule that wakes OpenClaw via the Gateway. "
+ "Use an isolated session for agent turns so your main chat stays clean."
static let sessionTargetNote =
"Main jobs post a system event into the current main session. "
+ "Current and isolated-style jobs run agent turns and can announce results to a channel."
static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote =
"Isolated jobs always run an agent turn. Announce sends a short summary to a channel."
static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target."
@State var name: String = ""
@State var description: String = ""
@State var agentId: String = ""
@State var enabled: Bool = true
@State var sessionTarget: CronSessionTarget = .main
@State var preservedSessionTargetRaw: String?
@State var wakeMode: CronWakeMode = .now
@State var deleteAfterRun: Bool = false
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String {
rawValue
} }
@State var scheduleKind: ScheduleKind = .every
@State var atDate: Date = .init().addingTimeInterval(60 * 5)
@State var everyText: String = "1h"
@State var cronExpr: String = "0 9 * * 3"
@State var cronTz: String = ""
enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String {
rawValue
} }
@State var payloadKind: PayloadKind = .systemEvent
@State var systemEventText: String = ""
@State var agentMessage: String = ""
enum DeliveryChoice: String, CaseIterable, Identifiable { case announce, none; var id: String {
rawValue
} }
@State var deliveryMode: DeliveryChoice = .announce
@State var channel: String = "last"
@State var to: String = ""
@State var thinking: String = ""
@State var timeoutSeconds: String = ""
@State var bestEffortDeliver: Bool = false
var channelOptions: [String] {
let ordered = self.channelsStore.orderedChannelIds()
var options = ["last"] + ordered
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, !options.contains(trimmed) {
options.append(trimmed)
}
var seen = Set<String>()
return options.filter { seen.insert($0).inserted }
}
func channelLabel(for id: String) -> String {
if id == "last" { return "last" }
return self.channelsStore.resolveChannelLabel(id)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(self.job == nil ? "New cron job" : "Edit cron job")
.font(.title3.weight(.semibold))
Text(Self.introText)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 14) {
GroupBox("Basics") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Name")
TextField("Required (e.g. “Daily summary”)", text: self.$name)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Description")
TextField("Optional notes", text: self.$description)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Agent ID")
TextField("Optional (default agent)", text: self.$agentId)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$enabled)
.labelsHidden()
.toggleStyle(.switch)
}
GridRow {
self.gridLabel("Session target")
Picker("", selection: self.$sessionTarget) {
Text("main").tag(CronSessionTarget.main)
Text("isolated").tag(CronSessionTarget.isolated)
Text("current").tag(CronSessionTarget.current)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Wake mode")
Picker("", selection: self.$wakeMode) {
Text("now").tag(CronWakeMode.now)
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
Self.sessionTargetNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
GroupBox("Schedule") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Kind")
Picker("", selection: self.$scheduleKind) {
Text("at").tag(ScheduleKind.at)
Text("every").tag(ScheduleKind.every)
Text("cron").tag(ScheduleKind.cron)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
Self.scheduleKindNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
switch self.scheduleKind {
case .at:
GridRow {
self.gridLabel("At")
DatePicker(
"",
selection: self.$atDate,
displayedComponents: [.date, .hourAndMinute])
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Auto-delete")
Toggle("Delete after successful run", isOn: self.$deleteAfterRun)
.toggleStyle(.switch)
}
case .every:
GridRow {
self.gridLabel("Every")
TextField("10m, 1h, 1d", text: self.$everyText)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
case .cron:
GridRow {
self.gridLabel("Expression")
TextField("e.g. 0 9 * * 3", text: self.$cronExpr)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Timezone")
TextField("Optional (e.g. America/Los_Angeles)", text: self.$cronTz)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
}
}
}
GroupBox("Payload") {
VStack(alignment: .leading, spacing: 10) {
if self.isIsolatedLikeSessionTarget {
Text(Self.isolatedPayloadNote)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
self.agentTurnEditor
} else {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Kind")
Picker("", selection: self.$payloadKind) {
Text("systemEvent").tag(PayloadKind.systemEvent)
Text("agentTurn").tag(PayloadKind.agentTurn)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
Self.mainPayloadNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
switch self.payloadKind {
case .systemEvent:
TextField("System event text", text: self.$systemEventText, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(3...7)
.frame(maxWidth: .infinity)
case .agentTurn:
self.agentTurnEditor
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 2)
}
if let error, !error.isEmpty {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Button("Cancel") { self.onCancel() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.bordered)
Spacer()
Button {
self.save()
} label: {
if self.isSaving {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(self.isSaving)
}
}
.padding(24)
.frame(minWidth: 720, minHeight: 640)
.onAppear { self.hydrateFromJob() }
.onChange(of: self.payloadKind) { _, newValue in
if newValue == .agentTurn, self.sessionTarget == .main {
self.sessionTarget = .isolated
}
}
.onChange(of: self.sessionTarget) { oldValue, newValue in
if oldValue != newValue {
self.preservedSessionTargetRaw = nil
}
if newValue != .main {
self.payloadKind = .agentTurn
} else if newValue == .main, self.payloadKind == .agentTurn {
self.payloadKind = .systemEvent
}
}
}
var agentTurnEditor: some View {
VStack(alignment: .leading, spacing: 10) {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Message")
TextField("What should OpenClaw do?", text: self.$agentMessage, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(3...7)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Thinking")
TextField("Optional (e.g. low)", text: self.$thinking)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Timeout")
TextField("Seconds (optional)", text: self.$timeoutSeconds)
.textFieldStyle(.roundedBorder)
.frame(width: 180, alignment: .leading)
}
GridRow {
self.gridLabel("Delivery")
Picker("", selection: self.$deliveryMode) {
Text("Announce summary").tag(DeliveryChoice.announce)
Text("None").tag(DeliveryChoice.none)
}
.labelsHidden()
.pickerStyle(.segmented)
}
}
if self.deliveryMode == .announce {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Channel")
Picker("", selection: self.$channel) {
ForEach(self.channelOptions, id: \.self) { channel in
Text(self.channelLabel(for: channel)).tag(channel)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("To")
TextField("Optional override (phone number / chat id / Discord channel)", text: self.$to)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Best-effort")
Toggle("Do not fail the job if announce fails", isOn: self.$bestEffortDeliver)
.toggleStyle(.switch)
}
}
}
}
}
}

View File

@@ -0,0 +1,188 @@
import Foundation
import Observation
import OpenClawKit
import OpenClawProtocol
import OSLog
@MainActor
@Observable
final class CronJobsStore {
static let shared = CronJobsStore()
var jobs: [CronJob] = []
var selectedJobId: String?
var runEntries: [CronRunLogEntry] = []
var schedulerEnabled: Bool?
var schedulerStorePath: String?
var schedulerNextWakeAtMs: Int?
var isLoadingJobs = false
var isLoadingRuns = false
var lastError: String?
var statusMessage: String?
private let logger = Logger(subsystem: "ai.openclaw", category: "cron.ui")
private var refreshTask: Task<Void, Never>?
private var runsTask: Task<Void, Never>?
private var eventTask: Task<Void, Never>?
private var pollTask: Task<Void, Never>?
private let interval: TimeInterval = 30
private let isPreview: Bool
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview
}
func start() {
guard !self.isPreview else { return }
guard self.eventTask == nil else { return }
GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
self?.handle(push: push)
}
self.pollTask = Task.detached { [weak self] in
guard let self else { return }
await self.refreshJobs()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
await self.refreshJobs()
}
}
}
func stop() {
self.refreshTask?.cancel()
self.refreshTask = nil
self.runsTask?.cancel()
self.runsTask = nil
self.eventTask?.cancel()
self.eventTask = nil
self.pollTask?.cancel()
self.pollTask = nil
}
func refreshJobs() async {
guard !self.isLoadingJobs else { return }
self.isLoadingJobs = true
self.lastError = nil
self.statusMessage = nil
defer { self.isLoadingJobs = false }
do {
if let status = try? await GatewayConnection.shared.cronStatus() {
self.schedulerEnabled = status.enabled
self.schedulerStorePath = status.sqlitePath ?? status.storePath
self.schedulerNextWakeAtMs = status.nextWakeAtMs
}
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)
if self.jobs.isEmpty {
self.statusMessage = "No cron jobs yet."
}
} catch {
self.logger.error("cron.list failed \(error.localizedDescription, privacy: .public)")
self.lastError = error.localizedDescription
}
}
func refreshRuns(jobId: String, limit: Int = 200) async {
guard !self.isLoadingRuns else { return }
self.isLoadingRuns = true
defer { self.isLoadingRuns = false }
do {
self.runEntries = try await GatewayConnection.shared.cronRuns(jobId: jobId, limit: limit)
} catch {
self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)")
self.lastError = error.localizedDescription
}
}
func runJob(id: String, force: Bool = true) async {
do {
try await GatewayConnection.shared.cronRun(jobId: id, force: force)
} catch {
self.lastError = error.localizedDescription
}
}
func removeJob(id: String) async {
do {
try await GatewayConnection.shared.cronRemove(jobId: id)
await self.refreshJobs()
if self.selectedJobId == id {
self.selectedJobId = nil
self.runEntries = []
}
} catch {
self.lastError = error.localizedDescription
}
}
func setJobEnabled(id: String, enabled: Bool) async {
do {
try await GatewayConnection.shared.cronUpdate(
jobId: id,
patch: ["enabled": AnyCodable(enabled)])
await self.refreshJobs()
} catch {
self.lastError = error.localizedDescription
}
}
func upsertJob(
id: String?,
payload: [String: AnyCodable]) async throws
{
if let id {
try await GatewayConnection.shared.cronUpdate(jobId: id, patch: payload)
} else {
try await GatewayConnection.shared.cronAdd(payload: payload)
}
await self.refreshJobs()
}
// MARK: - Gateway events
private func handle(push: GatewayPush) {
switch push {
case let .event(evt) where evt.event == "cron":
guard let payload = evt.payload else { return }
if let cronEvt = try? GatewayPayloadDecoding.decode(payload, as: CronEvent.self) {
self.handle(cronEvent: cronEvt)
}
case .seqGap:
self.scheduleRefresh()
default:
break
}
}
private func handle(cronEvent evt: CronEvent) {
// Keep UI in sync with the gateway scheduler.
self.scheduleRefresh(delayMs: 250)
if evt.action == "finished", let selected = self.selectedJobId, selected == evt.jobId {
self.scheduleRunsRefresh(jobId: selected, delayMs: 200)
}
}
private func scheduleRefresh(delayMs: Int = 250) {
self.refreshTask?.cancel()
self.refreshTask = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
await self.refreshJobs()
}
}
private func scheduleRunsRefresh(jobId: String, delayMs: Int = 200) {
self.runsTask?.cancel()
self.runsTask = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
await self.refreshRuns(jobId: jobId)
}
}
// MARK: - (no additional RPC helpers)
}

View File

@@ -0,0 +1,429 @@
import Foundation
enum CronSessionTarget: String, CaseIterable, Identifiable, Codable {
case main
case isolated
case current
var id: String {
self.rawValue
}
}
enum CronCustomSessionTarget: Codable, Equatable {
case predefined(CronSessionTarget)
case session(id: String)
var rawValue: String {
switch self {
case let .predefined(target):
target.rawValue
case let .session(id):
"session:\(id)"
}
}
static func from(_ value: String) -> CronCustomSessionTarget {
if let predefined = CronSessionTarget(rawValue: value) {
return .predefined(predefined)
}
if value.hasPrefix("session:") {
let sessionId = String(value.dropFirst(8))
return .session(id: sessionId)
}
// Fallback to isolated for unknown values
return .predefined(.isolated)
}
}
enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
case now
case nextHeartbeat = "next-heartbeat"
var id: String {
self.rawValue
}
}
enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable {
case none
case announce
case webhook
var id: String {
self.rawValue
}
}
struct CronDelivery: Codable, Equatable {
var mode: CronDeliveryMode
var channel: String?
var to: String?
var bestEffort: Bool?
}
enum CronSchedule: Codable, Equatable {
case at(at: String)
case every(everyMs: Int, anchorMs: Int?)
case cron(expr: String, tz: String?)
case onExit(command: String, cwd: String?)
enum CodingKeys: String, CodingKey { case kind, at, atMs, everyMs, anchorMs, expr, tz, command, cwd }
var kind: String {
switch self {
case .at: "at"
case .every: "every"
case .cron: "cron"
case .onExit: "on-exit"
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let kind = try container.decode(String.self, forKey: .kind)
switch kind {
case "at":
if let at = try container.decodeIfPresent(String.self, forKey: .at),
!at.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
self = .at(at: at)
return
}
if let atMs = try container.decodeIfPresent(Int.self, forKey: .atMs) {
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
self = .at(at: Self.formatIsoDate(date))
return
}
throw DecodingError.dataCorruptedError(
forKey: .at,
in: container,
debugDescription: "Missing schedule.at")
case "every":
self = try .every(
everyMs: container.decode(Int.self, forKey: .everyMs),
anchorMs: container.decodeIfPresent(Int.self, forKey: .anchorMs))
case "cron":
self = try .cron(
expr: container.decode(String.self, forKey: .expr),
tz: container.decodeIfPresent(String.self, forKey: .tz))
case "on-exit":
self = try .onExit(
command: container.decode(String.self, forKey: .command),
cwd: container.decodeIfPresent(String.self, forKey: .cwd))
default:
throw DecodingError.dataCorruptedError(
forKey: .kind,
in: container,
debugDescription: "Unknown schedule kind: \(kind)")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.kind, forKey: .kind)
switch self {
case let .at(at):
try container.encode(at, forKey: .at)
case let .every(everyMs, anchorMs):
try container.encode(everyMs, forKey: .everyMs)
try container.encodeIfPresent(anchorMs, forKey: .anchorMs)
case let .cron(expr, tz):
try container.encode(expr, forKey: .expr)
try container.encodeIfPresent(tz, forKey: .tz)
case let .onExit(command, cwd):
try container.encode(command, forKey: .command)
try container.encodeIfPresent(cwd, forKey: .cwd)
}
}
static func parseAtDate(_ value: String) -> Date? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if let date = makeIsoFormatter(withFractional: true).date(from: trimmed) { return date }
return self.makeIsoFormatter(withFractional: false).date(from: trimmed)
}
static func formatIsoDate(_ date: Date) -> String {
self.makeIsoFormatter(withFractional: false).string(from: date)
}
private static func makeIsoFormatter(withFractional: Bool) -> ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = withFractional
? [.withInternetDateTime, .withFractionalSeconds]
: [.withInternetDateTime]
return formatter
}
}
enum CronPayload: Codable, Equatable {
case systemEvent(text: String)
case agentTurn(
message: String,
thinking: String?,
timeoutSeconds: Int?,
deliver: Bool?,
channel: String?,
to: String?,
bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey {
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
}
var kind: String {
switch self {
case .systemEvent: "systemEvent"
case .agentTurn: "agentTurn"
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let kind = try container.decode(String.self, forKey: .kind)
switch kind {
case "systemEvent":
self = try .systemEvent(text: container.decode(String.self, forKey: .text))
case "agentTurn":
self = try .agentTurn(
message: container.decode(String.self, forKey: .message),
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
channel: container.decodeIfPresent(String.self, forKey: .channel)
?? container.decodeIfPresent(String.self, forKey: .provider),
to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default:
throw DecodingError.dataCorruptedError(
forKey: .kind,
in: container,
debugDescription: "Unknown payload kind: \(kind)")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.kind, forKey: .kind)
switch self {
case let .systemEvent(text):
try container.encode(text, forKey: .text)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
try container.encode(message, forKey: .message)
try container.encodeIfPresent(thinking, forKey: .thinking)
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try container.encodeIfPresent(deliver, forKey: .deliver)
try container.encodeIfPresent(channel, forKey: .channel)
try container.encodeIfPresent(to, forKey: .to)
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
}
}
}
struct CronJobState: Codable, Equatable {
var nextRunAtMs: Int?
var runningAtMs: Int?
var lastRunAtMs: Int?
var lastStatus: String?
var lastError: String?
var lastDurationMs: Int?
}
struct CronJob: Identifiable, Codable, Equatable {
let id: String
let agentId: String?
var name: String
var description: String?
var enabled: Bool
var deleteAfterRun: Bool?
let createdAtMs: Int
let updatedAtMs: Int
let schedule: CronSchedule
private let sessionTargetRaw: String
let wakeMode: CronWakeMode
let payload: CronPayload
let delivery: CronDelivery?
let state: CronJobState
enum CodingKeys: String, CodingKey {
case id
case agentId
case name
case description
case enabled
case deleteAfterRun
case createdAtMs
case updatedAtMs
case schedule
case sessionTargetRaw = "sessionTarget"
case wakeMode
case payload
case delivery
case state
}
init(
id: String,
agentId: String?,
name: String,
description: String?,
enabled: Bool,
deleteAfterRun: Bool?,
createdAtMs: Int,
updatedAtMs: Int,
schedule: CronSchedule,
sessionTarget: CronSessionTarget,
wakeMode: CronWakeMode,
payload: CronPayload,
delivery: CronDelivery?,
state: CronJobState)
{
self.init(
id: id,
agentId: agentId,
name: name,
description: description,
enabled: enabled,
deleteAfterRun: deleteAfterRun,
createdAtMs: createdAtMs,
updatedAtMs: updatedAtMs,
schedule: schedule,
sessionTarget: .predefined(sessionTarget),
wakeMode: wakeMode,
payload: payload,
delivery: delivery,
state: state)
}
init(
id: String,
agentId: String?,
name: String,
description: String?,
enabled: Bool,
deleteAfterRun: Bool?,
createdAtMs: Int,
updatedAtMs: Int,
schedule: CronSchedule,
sessionTarget: CronCustomSessionTarget,
wakeMode: CronWakeMode,
payload: CronPayload,
delivery: CronDelivery?,
state: CronJobState)
{
self.id = id
self.agentId = agentId
self.name = name
self.description = description
self.enabled = enabled
self.deleteAfterRun = deleteAfterRun
self.createdAtMs = createdAtMs
self.updatedAtMs = updatedAtMs
self.schedule = schedule
self.sessionTargetRaw = sessionTarget.rawValue
self.wakeMode = wakeMode
self.payload = payload
self.delivery = delivery
self.state = state
}
/// Parsed session target (predefined or custom session ID)
var parsedSessionTarget: CronCustomSessionTarget {
CronCustomSessionTarget.from(self.sessionTargetRaw)
}
/// Compatibility shim for existing editor/UI code paths that still use the
/// predefined enum.
var sessionTarget: CronSessionTarget {
switch self.parsedSessionTarget {
case let .predefined(target):
target
case .session:
.isolated
}
}
var sessionTargetDisplayValue: String {
self.parsedSessionTarget.rawValue
}
var transcriptSessionKey: String? {
switch self.parsedSessionTarget {
case .predefined(.main):
nil
case .predefined(.isolated), .predefined(.current):
"cron:\(self.id)"
case let .session(id):
id
}
}
var supportsAnnounceDelivery: Bool {
switch self.parsedSessionTarget {
case .predefined(.main):
false
case .predefined(.isolated), .predefined(.current), .session:
true
}
}
var displayName: String {
let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Untitled job" : trimmed
}
var nextRunDate: Date? {
guard let ms = self.state.nextRunAtMs else { return nil }
return Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
}
var lastRunDate: Date? {
guard let ms = self.state.lastRunAtMs else { return nil }
return Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
}
}
struct CronEvent: Codable {
let jobId: String
let action: String
let runAtMs: Int?
let durationMs: Int?
let status: String?
let error: String?
let summary: String?
let nextRunAtMs: Int?
}
struct CronRunLogEntry: Codable, Identifiable {
var id: String {
"\(self.jobId)-\(self.ts)"
}
let ts: Int
let jobId: String
let action: String
let status: String?
let error: String?
let summary: String?
let runAtMs: Int?
let durationMs: Int?
let nextRunAtMs: Int?
var date: Date {
Date(timeIntervalSince1970: TimeInterval(self.ts) / 1000)
}
var runDate: Date? {
guard let runAtMs else { return nil }
return Date(timeIntervalSince1970: TimeInterval(runAtMs) / 1000)
}
}
struct CronListResponse: Codable {
let jobs: [CronJob]
}
struct CronRunsResponse: Codable {
let entries: [CronRunLogEntry]
}

View File

@@ -0,0 +1,23 @@
import Foundation
import OpenClawProtocol
extension CronSettings {
func save(payload: [String: AnyCodable]) async {
guard !self.isSaving else { return }
self.isSaving = true
self.editorError = nil
do {
try await self.store.upsertJob(id: self.editingJob?.id, payload: payload)
await MainActor.run {
self.isSaving = false
self.showEditor = false
self.editingJob = nil
}
} catch {
await MainActor.run {
self.isSaving = false
self.editorError = error.localizedDescription
}
}
}
}

View File

@@ -0,0 +1,51 @@
import SwiftUI
extension CronSettings {
var selectedJob: CronJob? {
guard let id = self.store.selectedJobId else { return nil }
return self.store.jobs.first(where: { $0.id == id })
}
func statusTint(_ status: String?) -> Color {
switch (status ?? "").lowercased() {
case "ok": .green
case "error": .red
case "skipped": .orange
default: .secondary
}
}
func scheduleSummary(_ schedule: CronSchedule) -> String {
switch schedule {
case let .at(at):
if let date = CronSchedule.parseAtDate(at) {
return "at \(date.formatted(date: .abbreviated, time: .standard))"
}
return "at \(at)"
case let .every(everyMs, _):
return "every \(self.formatDuration(ms: everyMs))"
case let .cron(expr, tz):
if let tz, !tz.isEmpty { return "cron \(expr) (\(tz))" }
return "cron \(expr)"
case let .onExit(command, cwd):
if let cwd, !cwd.isEmpty { return "on exit: \(command) (cwd: \(cwd))" }
return "on exit: \(command)"
}
}
func formatDuration(ms: Int) -> String {
DurationFormattingSupport.conciseDuration(ms: ms)
}
func nextRunLabel(_ date: Date, now: Date = .init()) -> String {
let delta = date.timeIntervalSince(now)
if delta <= 0 { return "due" }
if delta < 60 { return "in <1m" }
let minutes = Int(round(delta / 60))
if minutes < 60 { return "in \(minutes)m" }
let hours = Int(round(Double(minutes) / 60))
if hours < 48 { return "in \(hours)h" }
let days = Int(round(Double(hours) / 24))
return "in \(days)d"
}
}

View File

@@ -0,0 +1,216 @@
import SwiftUI
extension CronSettings {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
self.header
self.schedulerBanner
self.content
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.settingsDetailContent()
.onAppear {
self.updateActiveWork(active: self.isActive)
}
.onChange(of: self.isActive) { _, active in
self.updateActiveWork(active: active)
}
.onDisappear {
self.store.stop()
self.channelsStore.stop()
}
.sheet(isPresented: self.$showEditor) {
CronJobEditor(
job: self.editingJob,
isSaving: self.$isSaving,
error: self.$editorError,
channelsStore: self.channelsStore,
onCancel: {
self.showEditor = false
self.editingJob = nil
},
onSave: { payload in
Task {
await self.save(payload: payload)
}
})
}
.alert("Delete cron job?", isPresented: Binding(
get: { self.confirmDelete != nil },
set: { if !$0 { self.confirmDelete = nil } }))
{
Button("Cancel", role: .cancel) { self.confirmDelete = nil }
Button("Delete", role: .destructive) {
if let job = self.confirmDelete {
Task { await self.store.removeJob(id: job.id) }
}
self.confirmDelete = nil
}
} message: {
if let job = self.confirmDelete {
Text(job.displayName)
}
}
}
private func updateActiveWork(active: Bool) {
if active {
self.store.start()
self.channelsStore.start()
} else {
self.store.stop()
self.channelsStore.stop()
}
}
var schedulerBanner: some View {
Group {
if self.store.schedulerEnabled == false {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Cron scheduler is disabled")
.font(.headline)
Spacer()
}
Text(
"Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " +
"and the Gateway restarts.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
Text(storePath)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.orange.opacity(0.10))
.cornerRadius(8)
}
}
}
var header: some View {
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 5) {
Text("Cron Jobs")
.font(.title3.weight(.semibold))
Text("Manage Gateway cron jobs and inspect run history.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 16)
HStack(spacing: 8) {
Button {
Task { await self.store.refreshJobs() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.disabled(self.store.isLoadingJobs)
Button {
self.editorError = nil
self.editingJob = nil
self.showEditor = true
} label: {
Label("New Job", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
}
}
}
var content: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
if let err = self.store.lastError {
Text("Error: \(err)")
.font(.footnote)
.foregroundStyle(.red)
} else if let msg = self.store.statusMessage {
Text(msg)
.font(.footnote)
.foregroundStyle(.secondary)
}
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: 4) {
ForEach(self.store.jobs) { job in
Button {
self.selectJob(job.id)
} label: {
self.jobRow(job)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.background(
self.store.selectedJobId == job.id
? Color.accentColor.opacity(0.18) : .clear)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.buttonStyle(.plain)
.contextMenu { self.jobContextMenu(job) }
}
if self.store.jobs.isEmpty {
Text("No cron jobs yet.")
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
}
.padding(.vertical, 4)
}
}
.frame(width: 250)
Divider()
self.detail
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
private func selectJob(_ id: String) {
self.store.selectedJobId = id
Task { await self.store.refreshRuns(jobId: id) }
}
@ViewBuilder
var detail: some View {
if let selected = self.selectedJob {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 12) {
self.detailHeader(selected)
self.detailCard(selected)
self.runHistoryCard(selected)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
} else {
VStack(alignment: .leading, spacing: 8) {
Text("Select a job to inspect details and run history.")
.font(.callout)
.foregroundStyle(.secondary)
Text("Tip: use New Job to add one, or enable cron in your gateway config.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.top, 8)
}
}
}

View File

@@ -0,0 +1,246 @@
import SwiftUI
extension CronSettings {
func jobRow(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(job.displayName)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.truncationMode(.middle)
Spacer()
if !job.enabled {
StatusPill(text: "disabled", tint: .secondary)
} else if let next = job.nextRunDate {
StatusPill(text: self.nextRunLabel(next), tint: .secondary)
} else {
StatusPill(text: "no next run", tint: .secondary)
}
}
HStack(spacing: 6) {
StatusPill(text: job.sessionTargetDisplayValue, tint: .secondary)
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
if let agentId = job.agentId, !agentId.isEmpty {
StatusPill(text: "agent \(agentId)", tint: .secondary)
}
if let status = job.state.lastStatus {
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
}
}
}
.padding(.vertical, 6)
}
@ViewBuilder
func jobContextMenu(_ job: CronJob) -> some View {
Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } }
if let transcriptSessionKey = job.transcriptSessionKey {
Button("Open transcript") {
WebChatManager.shared.show(sessionKey: transcriptSessionKey)
}
}
Divider()
Button(job.enabled ? "Disable" : "Enable") {
Task { await self.store.setJobEnabled(id: job.id, enabled: !job.enabled) }
}
Button("Edit…") {
self.editingJob = job
self.editorError = nil
self.showEditor = true
}
Divider()
Button("Delete…", role: .destructive) {
self.confirmDelete = job
}
}
func detailHeader(_ job: CronJob) -> some View {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text(job.displayName)
.font(.title3.weight(.semibold))
Text(job.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
HStack(spacing: 8) {
Toggle("Enabled", isOn: Binding(
get: { job.enabled },
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
.toggleStyle(.switch)
.labelsHidden()
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
.buttonStyle(.borderedProminent)
if let transcriptSessionKey = job.transcriptSessionKey {
Button("Transcript") {
WebChatManager.shared.show(sessionKey: transcriptSessionKey)
}
.buttonStyle(.bordered)
}
Button("Edit") {
self.editingJob = job
self.editorError = nil
self.showEditor = true
}
.buttonStyle(.bordered)
}
}
}
func detailCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
if case .at = job.schedule, job.deleteAfterRun == true {
LabeledContent("Auto-delete") { Text("after success") }
}
if let desc = job.description, !desc.isEmpty {
LabeledContent("Description") { Text(desc).font(.callout) }
}
if let agentId = job.agentId, !agentId.isEmpty {
LabeledContent("Agent") { Text(agentId) }
}
LabeledContent("Session") { Text(job.sessionTargetDisplayValue) }
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
LabeledContent("Next run") {
if let date = job.nextRunDate {
Text(date.formatted(date: .abbreviated, time: .standard))
} else {
Text("").foregroundStyle(.secondary)
}
}
LabeledContent("Last run") {
if let date = job.lastRunDate {
Text("\(date.formatted(date: .abbreviated, time: .standard)) · \(relativeAge(from: date))")
} else {
Text("").foregroundStyle(.secondary)
}
}
if let status = job.state.lastStatus {
LabeledContent("Last status") { Text(status) }
}
if let err = job.state.lastError, !err.isEmpty {
Text(err)
.font(.footnote)
.foregroundStyle(.orange)
.textSelection(.enabled)
}
self.payloadSummary(job)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.secondary.opacity(0.06))
.cornerRadius(8)
}
func runHistoryCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Run history")
.font(.headline)
Spacer()
Button {
Task { await self.store.refreshRuns(jobId: job.id) }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.disabled(self.store.isLoadingRuns)
}
if self.store.isLoadingRuns {
ProgressView().controlSize(.small)
}
if self.store.runEntries.isEmpty {
Text("No run log entries yet.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.store.runEntries) { entry in
self.runRow(entry)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.secondary.opacity(0.06))
.cornerRadius(8)
}
func runRow(_ entry: CronRunLogEntry) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
StatusPill(text: entry.status ?? "unknown", tint: self.statusTint(entry.status))
Text(entry.date.formatted(date: .abbreviated, time: .standard))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if let ms = entry.durationMs {
Text("\(ms)ms")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
}
if let summary = entry.summary, !summary.isEmpty {
Text(summary)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(2)
}
if let error = entry.error, !error.isEmpty {
Text(error)
.font(.caption)
.foregroundStyle(.orange)
.textSelection(.enabled)
.lineLimit(2)
}
}
.padding(.vertical, 4)
}
func payloadSummary(_ job: CronJob) -> some View {
let payload = job.payload
return VStack(alignment: .leading, spacing: 6) {
Text("Payload")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
switch payload {
case let .systemEvent(text):
Text(text)
.font(.callout)
.textSelection(.enabled)
case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _):
VStack(alignment: .leading, spacing: 4) {
Text(message)
.font(.callout)
.textSelection(.enabled)
HStack(spacing: 8) {
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
if job.supportsAnnounceDelivery {
let delivery = job.delivery
if let delivery {
if delivery.mode == .announce {
StatusPill(text: "announce", tint: .secondary)
if let channel = delivery.channel, !channel.isEmpty {
StatusPill(text: channel, tint: .secondary)
}
if let to = delivery.to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
} else {
StatusPill(text: "no delivery", tint: .secondary)
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,120 @@
import SwiftUI
#if DEBUG
struct CronSettings_Previews: PreviewProvider {
static var previews: some View {
let store = CronJobsStore(isPreview: true)
store.jobs = [
CronJob(
id: "job-1",
agentId: "ops",
name: "Daily summary",
description: nil,
enabled: true,
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
sessionTarget: .isolated,
wakeMode: .now,
payload: .agentTurn(
message: "Summarize inbox",
thinking: "low",
timeoutSeconds: 600,
deliver: nil,
channel: nil,
to: nil,
bestEffortDeliver: nil),
delivery: CronDelivery(mode: .announce, channel: "last", to: nil, bestEffort: true),
state: CronJobState(
nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
runningAtMs: nil,
lastRunAtMs: nil,
lastStatus: nil,
lastError: nil,
lastDurationMs: nil)),
]
store.selectedJobId = "job-1"
store.runEntries = [
CronRunLogEntry(
ts: Int(Date().timeIntervalSince1970 * 1000),
jobId: "job-1",
action: "finished",
status: "ok",
error: nil,
summary: "All good.",
runAtMs: nil,
durationMs: 1234,
nextRunAtMs: nil),
]
return CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
@MainActor
extension CronSettings {
static func exerciseForTesting() {
let store = CronJobsStore(isPreview: true)
store.schedulerEnabled = false
store.schedulerStorePath = "/tmp/openclaw-cron-store.json"
let job = CronJob(
id: "job-1",
agentId: "ops",
name: "Daily summary",
description: "Summary job",
enabled: true,
deleteAfterRun: nil,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000,
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
sessionTarget: .isolated,
wakeMode: .nextHeartbeat,
payload: .agentTurn(
message: "Summarize",
thinking: "low",
timeoutSeconds: 120,
deliver: nil,
channel: nil,
to: nil,
bestEffortDeliver: nil),
delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true),
state: CronJobState(
nextRunAtMs: 1_700_000_200_000,
runningAtMs: nil,
lastRunAtMs: 1_700_000_050_000,
lastStatus: "ok",
lastError: nil,
lastDurationMs: 1200))
let run = CronRunLogEntry(
ts: 1_700_000_050_000,
jobId: job.id,
action: "finished",
status: "ok",
error: nil,
summary: "done",
runAtMs: 1_700_000_050_000,
durationMs: 1200,
nextRunAtMs: 1_700_000_200_000)
store.jobs = [job]
store.selectedJobId = job.id
store.runEntries = [run]
let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
_ = view.jobRow(job)
_ = view.jobContextMenu(job)
_ = view.detailHeader(job)
_ = view.detailCard(job)
_ = view.runHistoryCard(job)
_ = view.runRow(run)
_ = view.payloadSummary(job)
_ = view.scheduleSummary(job.schedule)
_ = view.statusTint(job.state.lastStatus)
_ = view.nextRunLabel(Date())
_ = view.formatDuration(ms: 1234)
}
}
#endif

View File

@@ -0,0 +1,19 @@
import Observation
import SwiftUI
struct CronSettings: View {
@Bindable var store: CronJobsStore
@Bindable var channelsStore: ChannelsStore
let isActive: Bool
@State var showEditor = false
@State var editingJob: CronJob?
@State var editorError: String?
@State var isSaving = false
@State var confirmDelete: CronJob?
init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared, isActive: Bool = true) {
self.store = store
self.channelsStore = channelsStore
self.isActive = isActive
}
}

View File

@@ -0,0 +1,135 @@
import AppKit
import Foundation
import OpenClawKit
import OSLog
private let dashboardManagerLogger = Logger(subsystem: "ai.openclaw", category: "DashboardManager")
@MainActor
final class DashboardManager {
static let shared = DashboardManager()
private var controller: DashboardWindowController?
private static let failureURL = URL(string: "about:blank")!
private init() {}
@discardableResult
func showConfiguredWindowIfPossible() -> Bool {
let mode = AppStateStore.shared.connectionMode
guard let config = self.immediateDashboardConfig(mode: mode),
let url = try? GatewayEndpointStore.dashboardURL(
for: config,
mode: mode,
authToken: config.token)
else {
return false
}
let auth = DashboardWindowAuth(
gatewayUrl: Self.websocketURLString(for: url),
token: config.token,
password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
guard auth.hasCredential else {
return false
}
if let controller {
controller.show(url: url, auth: auth)
} else {
let controller = DashboardWindowController(url: url, auth: auth)
self.controller = controller
controller.show(url: url, auth: auth)
}
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
return true
}
func show() async throws {
let mode = AppStateStore.shared.connectionMode
dashboardManagerLogger.info("dashboard show requested mode=\(String(describing: mode), privacy: .public)")
let config = try await self.dashboardConfig(mode: mode)
dashboardManagerLogger.info("dashboard config url=\(config.url.absoluteString, privacy: .public)")
let token = await GatewayConnection.shared.controlUiAutoAuthToken(config: config)
let url = try GatewayEndpointStore.dashboardURL(for: config, mode: mode, authToken: token)
let auth = DashboardWindowAuth(
gatewayUrl: Self.websocketURLString(for: url),
token: token,
password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
if let controller {
dashboardManagerLogger.info("dashboard reuse window url=\(url.absoluteString, privacy: .public)")
controller.show(url: url, auth: auth)
return
}
dashboardManagerLogger.info("dashboard create window url=\(url.absoluteString, privacy: .public)")
let controller = DashboardWindowController(url: url, auth: auth)
self.controller = controller
controller.show(url: url, auth: auth)
// Refresh the cached hello payload without blocking window creation.
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()
}
private static func websocketURLString(for dashboardURL: URL) -> String {
guard var components = URLComponents(url: dashboardURL, resolvingAgainstBaseURL: false) else {
return dashboardURL.absoluteString
}
switch components.scheme?.lowercased() {
case "https":
components.scheme = "wss"
default:
components.scheme = "ws"
}
components.queryItems = nil
components.fragment = nil
return components.url?.absoluteString ?? dashboardURL.absoluteString
}
private func dashboardConfig(mode: AppState.ConnectionMode) async throws -> GatewayConnection.Config {
if let config = self.immediateDashboardConfig(mode: mode) {
return config
}
return try await Task.detached(priority: .userInitiated) {
await GatewayEndpointStore.shared.refresh()
return try await GatewayEndpointStore.shared.requireConfig()
}.value
}
private func immediateDashboardConfig(mode: AppState.ConnectionMode) -> GatewayConnection.Config? {
let root = OpenClawConfigFile.loadDict()
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
if mode == .remote,
resolution.transport == .direct,
let url = resolution.directURL
{
return (
url,
GatewayRemoteConfig.resolveTokenString(root: root),
GatewayRemoteConfig.resolvePasswordString(root: root))
}
if mode == .local {
return GatewayEndpointStore.localConfig()
}
return nil
}
}

View File

@@ -0,0 +1,21 @@
import AppKit
import Foundation
import OSLog
let dashboardWindowLogger = Logger(subsystem: "ai.openclaw", category: "DashboardWindow")
enum DashboardWindowLayout {
static let windowSize = NSSize(width: 1240, height: 860)
static let windowMinSize = NSSize(width: 900, height: 620)
}
struct DashboardWindowAuth: Equatable {
var gatewayUrl: String?
var token: String?
var password: String?
var hasCredential: Bool {
self.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ||
self.password?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
}

View File

@@ -0,0 +1,439 @@
import AppKit
import Foundation
import WebKit
private final class DashboardWindowContentView: NSView {
override var mouseDownCanMoveWindow: Bool {
true
}
}
private final class DashboardWindowDragRegionView: NSView {
override var mouseDownCanMoveWindow: Bool {
true
}
override func mouseDown(with event: NSEvent) {
self.window?.performDrag(with: event)
}
}
@MainActor
final class DashboardWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
private let webView: WKWebView
private var currentURL: URL
private var auth: DashboardWindowAuth
init(url: URL, auth: DashboardWindowAuth) {
self.currentURL = url
self.auth = auth
let config = WKWebViewConfiguration()
config.preferences.isElementFullscreenEnabled = true
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
config.userContentController = WKUserContentController()
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")
let window = Self.makeWindow(contentView: self.webView)
super.init(window: window)
self.webView.navigationDelegate = self
self.webView.uiDelegate = self
self.window?.delegate = self
}
// 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 frame: WKFrameInfo,
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void)
{
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = parameters.allowsDirectories
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
panel.resolvesAliases = true
if let window = self.window {
panel.beginSheetModal(for: window) { response in
completionHandler(response == .OK ? panel.urls : nil)
}
return
}
panel.begin { response in
completionHandler(response == .OK ? panel.urls : nil)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
func show(url: URL, auth: DashboardWindowAuth) {
self.currentURL = url
self.auth = auth
self.refreshNativeAuthScript(url: url, auth: auth)
self.load(url)
self.show()
}
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)
}
}
self.showWindow(nil)
self.window?.makeKeyAndOrderFront(nil)
self.window?.makeFirstResponder(self.webView)
self.window?.orderFrontRegardless()
NSApp.activate(ignoringOtherApps: true)
}
func closeDashboard() {
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))
}
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 static func makeWindow(contentView: NSView) -> NSWindow {
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false)
let container = DashboardWindowContentView(frame: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize))
contentView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(contentView)
let topDragRegion = DashboardWindowDragRegionView()
topDragRegion.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(topDragRegion)
let topRightDragRegion = DashboardWindowDragRegionView()
topRightDragRegion.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(topRightDragRegion)
let sidebarDragRegion = DashboardWindowDragRegionView()
sidebarDragRegion.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(sidebarDragRegion)
NSLayoutConstraint.activate([
contentView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
contentView.topAnchor.constraint(equalTo: container.topAnchor),
contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
topDragRegion.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 78),
topDragRegion.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -380),
topDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
topDragRegion.heightAnchor.constraint(equalToConstant: 28),
topRightDragRegion.leadingAnchor.constraint(equalTo: topDragRegion.trailingAnchor),
topRightDragRegion.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8),
topRightDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
topRightDragRegion.heightAnchor.constraint(equalToConstant: 6),
sidebarDragRegion.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 78),
sidebarDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
sidebarDragRegion.widthAnchor.constraint(equalToConstant: 176),
sidebarDragRegion.heightAnchor.constraint(equalToConstant: 46),
])
window.title = "OpenClaw"
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.hasShadow = true
window.backgroundColor = .windowBackgroundColor
window.isOpaque = true
let viewController = NSViewController()
viewController.view = container
window.contentViewController = viewController
window.center()
window.minSize = DashboardWindowLayout.windowMinSize
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
return window
}
private static func installNativeChromeScript(into userContentController: WKUserContentController) {
let css = """
html.openclaw-native-macos {
--openclaw-native-titlebar-height: 50px;
}
@media (min-width: 700px) {
html.openclaw-native-macos .sidebar-shell {
padding-top: max(14px, var(--openclaw-native-titlebar-height)) !important;
}
html.openclaw-native-macos .sidebar-shell__header {
padding-left: 10px !important;
padding-right: 8px !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");
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
}
private 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())
}
func webView(
_: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
{
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
if Self.shouldAllowNavigation(to: url, dashboardURL: self.currentURL) {
decisionHandler(.allow)
return
}
NSWorkspace.shared.open(url)
decisionHandler(.cancel)
}
func webView(_: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
self.showLoadFailure(error)
}
func webView(_: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
self.showLoadFailure(error)
}
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
}
func windowWillClose(_: Notification) {
self.webView.stopLoading()
}
private func showLoadFailure(_ error: Error) {
let nsError = error as NSError
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(
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(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: #101114;
color: rgba(255,255,255,.92);
font: 15px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
}
main {
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 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;
}
code {
display: block;
margin-top: 18px;
padding: 12px;
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>
<div class="badge">!</div>
<h1>\(self.htmlEscape(title))</h1>
<p>\(self.htmlEscape(message))</p>
\(detailHTML)
\(urlHTML)
</main>
</body>
</html>
"""
}
private static func htmlEscape(_ value: String) -> String {
value
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&#39;")
}
}

View File

@@ -0,0 +1,265 @@
import AppKit
import Foundation
import SwiftUI
enum DebugActions {
private static let verboseDefaultsKey = "openclaw.debug.verboseMain"
private static let sessionMenuLimit = 12
private static let onboardingSeenKey = "openclaw.onboardingSeen"
@MainActor
static func openAgentEventsWindow() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 620, height: 420),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false)
window.title = "Agent Events"
window.isReleasedWhenClosed = false
window.contentView = NSHostingView(rootView: AgentEventsWindow())
window.center()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
@MainActor
static func openLog() {
let path = self.pinoLogPath()
let url = URL(fileURLWithPath: path)
guard FileManager().fileExists(atPath: path) else {
let alert = NSAlert()
alert.messageText = "Log file not found"
alert.informativeText = path
alert.runModal()
return
}
NSWorkspace.shared.activateFileViewerSelecting([url])
}
@MainActor
static func openConfigFolder() {
let url = OpenClawPaths.stateDirURL
NSWorkspace.shared.activateFileViewerSelecting([url])
}
@MainActor
static func openSessionStore() {
if AppStateStore.shared.connectionMode == .remote {
let alert = NSAlert()
alert.messageText = "Remote mode"
alert.informativeText = "Session store lives on the gateway host in remote mode."
alert.runModal()
return
}
let path = self.resolveSessionStorePath()
let url = URL(fileURLWithPath: path)
if FileManager().fileExists(atPath: path) {
NSWorkspace.shared.activateFileViewerSelecting([url])
} else {
NSWorkspace.shared.open(url.deletingLastPathComponent())
}
}
static func sendTestNotification() async {
_ = await NotificationManager().send(title: "OpenClaw", body: "Test notification", sound: nil)
}
static func sendDebugVoice() async -> Result<String, DebugActionError> {
let message = """
This is a debug test from the Mac app. Reply with "Debug test works (and a funny pun)" \
if you received that.
"""
let result = await VoiceWakeForwarder.forward(transcript: message)
switch result {
case .success:
return .success("Sent. Await reply.")
case let .failure(error):
let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
return .failure(.message("Send failed: \(detail)"))
}
}
static func restartGateway() {
Task { @MainActor in
switch AppStateStore.shared.connectionMode {
case .local:
GatewayProcessManager.shared.stop()
// Kick the control channel + health check so the UI recovers immediately.
await GatewayConnection.shared.shutdown()
try? await Task.sleep(nanoseconds: 300_000_000)
GatewayProcessManager.shared.setActive(true)
Task { try? await ControlChannel.shared.configure(mode: .local) }
Task { await HealthStore.shared.refresh(onDemand: true) }
case .remote:
// In remote mode, there is no local gateway to restart. "Restart Gateway" should
// reset the SSH control tunnel + reconnect so the menu recovers.
await RemoteTunnelManager.shared.stopAll()
await GatewayConnection.shared.shutdown()
do {
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote(
target: settings.target,
identity: settings.identity))
} catch {
// ControlChannel will surface a degraded state; also refresh health to update the menu text.
Task { await HealthStore.shared.refresh(onDemand: true) }
}
case .unconfigured:
await GatewayConnection.shared.shutdown()
await ControlChannel.shared.disconnect()
}
}
}
static func resetGatewayTunnel() async -> Result<String, DebugActionError> {
let mode = CommandResolver.connectionSettings().mode
guard mode == .remote else {
return .failure(.message("Remote mode is not enabled."))
}
await RemoteTunnelManager.shared.stopAll()
await GatewayConnection.shared.shutdown()
do {
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote(
target: settings.target,
identity: settings.identity))
await HealthStore.shared.refresh(onDemand: true)
return .success("SSH tunnel reset.")
} catch {
Task { await HealthStore.shared.refresh(onDemand: true) }
return .failure(.message(error.localizedDescription))
}
}
static func pinoLogPath() -> String {
LogLocator.bestLogFile()?.path ?? LogLocator.launchdLogPath
}
@MainActor
static func runHealthCheckNow() async {
await HealthStore.shared.refresh(onDemand: true)
}
static func sendTestHeartbeat() async -> Result<ControlHeartbeatEvent?, Error> {
do {
_ = await GatewayConnection.shared.setHeartbeatsEnabled(true)
await ControlChannel.shared.configure()
let data = try await ControlChannel.shared.request(method: "last-heartbeat")
if let evt = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) {
return .success(evt)
}
return .success(nil)
} catch {
return .failure(error)
}
}
static var verboseLoggingEnabledMain: Bool {
UserDefaults.standard.bool(forKey: self.verboseDefaultsKey)
}
static func toggleVerboseLoggingMain() async -> Bool {
let newValue = !self.verboseLoggingEnabledMain
UserDefaults.standard.set(newValue, forKey: self.verboseDefaultsKey)
_ = try? await ControlChannel.shared.request(
method: "system-event",
params: ["text": AnyHashable("verbose-main:\(newValue ? "on" : "off")")])
return newValue
}
@MainActor
static func restartApp() {
let url = Bundle.main.bundleURL
let task = Process()
// Relaunch shortly after this instance exits so we get a true restart even in debug.
task.launchPath = "/bin/sh"
task.arguments = ["-c", "sleep 0.2; open -n \"$1\"", "_", url.path]
try? task.run()
NSApp.terminate(nil)
}
@MainActor
static func restartOnboarding() {
UserDefaults.standard.set(false, forKey: self.onboardingSeenKey)
UserDefaults.standard.set(0, forKey: onboardingVersionKey)
AppStateStore.shared.onboardingSeen = false
OnboardingController.shared.restart()
}
@MainActor
private static func resolveSessionStorePath() -> String {
let defaultPath = SessionLoader.defaultStorePath
let configURL = OpenClawPaths.configURL
guard
let data = try? Data(contentsOf: configURL),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let session = parsed["session"] as? [String: Any],
let path = session["store"] as? String,
!path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
return defaultPath
}
return path
}
// MARK: - Sessions (thinking / verbose)
static func recentSessions(limit: Int = sessionMenuLimit) async -> [SessionRow] {
guard let snapshot = try? await SessionLoader.loadSnapshot(limit: limit) else { return [] }
return Array(snapshot.rows.prefix(limit))
}
static func updateSession(
key: String,
thinking: String?,
verbose: String?) async throws
{
var params: [String: AnyHashable] = ["key": AnyHashable(key)]
params["thinkingLevel"] = thinking.map(AnyHashable.init) ?? AnyHashable(NSNull())
params["verboseLevel"] = verbose.map(AnyHashable.init) ?? AnyHashable(NSNull())
_ = try await ControlChannel.shared.request(method: "sessions.patch", params: params)
}
// MARK: - Port diagnostics
typealias PortListener = PortGuardian.ReportListener
typealias PortReport = PortGuardian.PortReport
static func checkGatewayPorts() async -> [PortReport] {
let mode = CommandResolver.connectionSettings().mode
return await PortGuardian.shared.diagnose(mode: mode)
}
static func killProcess(_ pid: Int) async -> Result<Void, DebugActionError> {
let primary = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
if primary.ok { return .success(()) }
let force = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
if force.ok { return .success(()) }
let detail = force.message ?? primary.message ?? "kill failed"
return .failure(.message(detail))
}
@MainActor
static func openSessionStoreInCode() {
let path = SessionLoader.defaultStorePath
let proc = Process()
proc.launchPath = "/usr/bin/env"
proc.arguments = ["code", path]
try? proc.run()
}
}
enum DebugActionError: LocalizedError {
case message(String)
var errorDescription: String? {
switch self {
case let .message(text):
text
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,212 @@
import AppKit
import Foundation
import OpenClawKit
import OSLog
import Security
private let deepLinkLogger = Logger(subsystem: "ai.openclaw", category: "DeepLink")
enum DeepLinkAgentPolicy {
static let maxMessageChars = 20000
static let maxUnkeyedConfirmChars = 240
enum ValidationError: Error, Equatable, LocalizedError {
case messageTooLongForConfirmation(max: Int, actual: Int)
var errorDescription: String? {
switch self {
case let .messageTooLongForConfirmation(max, actual):
"Message is too long to confirm safely (\(actual) chars; max \(max) without key)."
}
}
}
static func validateMessageForHandle(message: String, allowUnattended: Bool) -> Result<Void, ValidationError> {
if !allowUnattended, message.count > self.maxUnkeyedConfirmChars {
return .failure(.messageTooLongForConfirmation(max: self.maxUnkeyedConfirmChars, actual: message.count))
}
return .success(())
}
static func effectiveDelivery(
link: AgentDeepLink,
allowUnattended: Bool) -> (deliver: Bool, to: String?, channel: GatewayAgentChannel)
{
if !allowUnattended {
// Without the unattended key, ignore delivery/routing knobs to reduce exfiltration risk.
return (deliver: false, to: nil, channel: .last)
}
let channel = GatewayAgentChannel(raw: link.channel)
let deliver = channel.shouldDeliver(link.deliver)
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
return (deliver: deliver, to: to, channel: channel)
}
}
@MainActor
final class DeepLinkHandler {
static let shared = DeepLinkHandler()
private var lastPromptAt: Date = .distantPast
/// Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas.
/// This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt:
/// outside callers can't know this randomly generated key.
private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey()
func handle(url: URL) async {
guard let route = DeepLinkParser.parse(url) else {
deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)")
return
}
switch route {
case .dashboard:
await self.openDashboard()
return
case let .agent(link):
guard !AppStateStore.shared.isPaused else {
self.presentAlert(title: "OpenClaw is paused", message: "Unpause OpenClaw to run agent actions.")
return
}
await self.handleAgent(link: link, originalURL: url)
case .gateway:
guard !AppStateStore.shared.isPaused else {
self.presentAlert(title: "OpenClaw is paused", message: "Unpause OpenClaw to run agent actions.")
return
}
}
}
private func handleAgent(link: AgentDeepLink, originalURL: URL) async {
let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
if messagePreview.count > DeepLinkAgentPolicy.maxMessageChars {
self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.")
return
}
let allowUnattended = link.key == Self.canvasUnattendedKey || link.key == Self.expectedKey()
if !allowUnattended {
if Date().timeIntervalSince(self.lastPromptAt) < 1.0 {
deepLinkLogger.debug("throttling deep link prompt")
return
}
self.lastPromptAt = Date()
if case let .failure(error) = DeepLinkAgentPolicy.validateMessageForHandle(
message: messagePreview,
allowUnattended: allowUnattended)
{
self.presentAlert(title: "Deep link blocked", message: error.localizedDescription)
return
}
let urlText = originalURL.absoluteString
let urlPreview = urlText.count > 500 ? "\(urlText.prefix(500))" : urlText
let body =
"Run the agent with this message?\n\n\(messagePreview)\n\nURL:\n\(urlPreview)"
guard self.confirm(title: "Run OpenClaw agent?", message: body) else { return }
}
if AppStateStore.shared.connectionMode == .local {
GatewayProcessManager.shared.setActive(true)
}
do {
let effectiveDelivery = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: allowUnattended)
let explicitSessionKey = link.sessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
let resolvedSessionKey: String = if let explicitSessionKey {
explicitSessionKey
} else {
await GatewayConnection.shared.mainSessionKey()
}
let invocation = GatewayAgentInvocation(
message: messagePreview,
sessionKey: resolvedSessionKey,
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
deliver: effectiveDelivery.deliver,
to: effectiveDelivery.to,
channel: effectiveDelivery.channel,
timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString)
let res = await GatewayConnection.shared.sendAgent(invocation)
if !res.ok {
throw NSError(
domain: "DeepLink",
code: 1,
userInfo: [NSLocalizedDescriptionKey: res.error ?? "agent request failed"])
}
} catch {
self.presentAlert(title: "Agent request failed", message: error.localizedDescription)
}
}
// MARK: - Auth
static func currentKey() -> String {
self.expectedKey()
}
static func currentCanvasKey() -> String {
self.canvasUnattendedKey
}
private static func expectedKey() -> String {
let defaults = UserDefaults.standard
if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty {
return key
}
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
let data = Data(bytes)
let key = data
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
defaults.set(key, forKey: deepLinkKeyKey)
return key
}
private nonisolated static func generateRandomKey() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
let data = Data(bytes)
return data
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
// MARK: - UI
private func openDashboard() async {
do {
try await DashboardManager.shared.show()
} catch {
DashboardManager.shared.showFailure(error)
}
}
private func confirm(title: String, message: String) -> Bool {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: "Run")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
return alert.runModal() == .alertFirstButtonReturn
}
private func presentAlert(title: String, message: String) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: "OK")
alert.alertStyle = .informational
alert.runModal()
}
}

View File

@@ -0,0 +1,188 @@
import Foundation
struct DevicePresentation {
let title: String
let symbol: String?
}
enum DeviceModelCatalog {
private static let modelIdentifierToName: [String: String] = loadModelIdentifierToName()
private static let resourceBundle: Bundle? = locateResourceBundle()
private static let resourceSubdirectory = "DeviceModels"
static func presentation(deviceFamily: String?, modelIdentifier: String?) -> DevicePresentation? {
let family = (deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let model = (modelIdentifier ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let friendlyName = model.isEmpty ? nil : self.modelIdentifierToName[model]
let symbol = self.symbol(deviceFamily: family, modelIdentifier: model, friendlyName: friendlyName)
let title = if let friendlyName, !friendlyName.isEmpty {
friendlyName
} else if !family.isEmpty, !model.isEmpty {
"\(family) (\(model))"
} else if !family.isEmpty {
family
} else if !model.isEmpty {
model
} else {
""
}
if title.isEmpty { return nil }
return DevicePresentation(title: title, symbol: symbol)
}
static func symbol(
deviceFamily familyRaw: String,
modelIdentifier modelIdentifierRaw: String,
friendlyName: String?) -> String?
{
let family = familyRaw.trimmingCharacters(in: .whitespacesAndNewlines)
let modelIdentifier = modelIdentifierRaw.trimmingCharacters(in: .whitespacesAndNewlines)
return self.symbolFor(modelIdentifier: modelIdentifier, friendlyName: friendlyName)
?? self.fallbackSymbol(for: family, modelIdentifier: modelIdentifier)
}
private static func symbolFor(modelIdentifier rawModelIdentifier: String, friendlyName: String?) -> String? {
let modelIdentifier = rawModelIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !modelIdentifier.isEmpty else { return nil }
let lower = modelIdentifier.lowercased()
if lower.hasPrefix("ipad") { return "ipad" }
if lower.hasPrefix("iphone") { return "iphone" }
if lower.hasPrefix("ipod") { return "iphone" }
if lower.hasPrefix("watch") { return "applewatch" }
if lower.hasPrefix("appletv") { return "appletv" }
if lower.hasPrefix("audio") || lower.hasPrefix("homepod") { return "speaker" }
if lower.hasPrefix("macbook") || lower.hasPrefix("macbookpro") || lower.hasPrefix("macbookair") {
return "laptopcomputer"
}
if lower.hasPrefix("macstudio") { return "macstudio" }
if lower.hasPrefix("macmini") { return "macmini" }
if lower.hasPrefix("imac") || lower.hasPrefix("macpro") { return "desktopcomputer" }
if lower.hasPrefix("mac"), let friendlyNameLower = friendlyName?.lowercased() {
if friendlyNameLower.contains("macbook") { return "laptopcomputer" }
if friendlyNameLower.contains("imac") { return "desktopcomputer" }
if friendlyNameLower.contains("mac mini") { return "macmini" }
if friendlyNameLower.contains("mac studio") { return "macstudio" }
if friendlyNameLower.contains("mac pro") { return "desktopcomputer" }
}
return nil
}
private static func fallbackSymbol(for familyRaw: String, modelIdentifier: String) -> String? {
let family = familyRaw.trimmingCharacters(in: .whitespacesAndNewlines)
if family.isEmpty { return nil }
switch family.lowercased() {
case "ipad":
return "ipad"
case "iphone":
return "iphone"
case "mac":
return "laptopcomputer"
case "android":
return "android"
case "linux":
return "cpu"
default:
return "cpu"
}
}
private static func loadModelIdentifierToName() -> [String: String] {
var combined: [String: String] = [:]
combined.merge(
self.loadMapping(resourceName: "ios-device-identifiers"),
uniquingKeysWith: { current, _ in current })
combined.merge(
self.loadMapping(resourceName: "mac-device-identifiers"),
uniquingKeysWith: { current, _ in current })
return combined
}
private static func loadMapping(resourceName: String) -> [String: String] {
guard let url = self.resourceBundle?.url(
forResource: resourceName,
withExtension: "json",
subdirectory: self.resourceSubdirectory)
else { return [:] }
do {
let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode([String: NameValue].self, from: data)
return decoded.compactMapValues { $0.normalizedName }
} catch {
return [:]
}
}
private static func locateResourceBundle() -> Bundle? {
// Prefer main bundle (packaged app), then module bundle (SwiftPM/tests).
// Accessing Bundle.module in the packaged app can crash if the bundle isn't where SwiftPM expects it.
if let bundle = self.bundleIfContainsDeviceModels(Bundle.main) {
return bundle
}
if let bundle = self.bundleIfContainsDeviceModels(Bundle.module) {
return bundle
}
return nil
}
private static func bundleIfContainsDeviceModels(_ bundle: Bundle) -> Bundle? {
if bundle.url(
forResource: "ios-device-identifiers",
withExtension: "json",
subdirectory: self.resourceSubdirectory) != nil
{
return bundle
}
if bundle.url(
forResource: "mac-device-identifiers",
withExtension: "json",
subdirectory: self.resourceSubdirectory) != nil
{
return bundle
}
return nil
}
private enum NameValue: Decodable {
case string(String)
case stringArray([String])
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let s = try? container.decode(String.self) {
self = .string(s)
return
}
if let arr = try? container.decode([String].self) {
self = .stringArray(arr)
return
}
throw DecodingError.typeMismatch(
String.self,
.init(codingPath: decoder.codingPath, debugDescription: "Expected string or string array"))
}
var normalizedName: String? {
switch self {
case let .string(s):
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
case let .stringArray(arr):
let values = arr
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !values.isEmpty else { return nil }
return values.joined(separator: " / ")
}
}
}
}

View File

@@ -0,0 +1,400 @@
import AppKit
import Foundation
import Observation
import OpenClawKit
import OpenClawProtocol
import OSLog
@MainActor
@Observable
final class DevicePairingApprovalPrompter {
static let shared = DevicePairingApprovalPrompter()
private let logger = Logger(subsystem: "ai.openclaw", category: "device-pairing")
private var task: Task<Void, Never>?
private var isStopping = false
private var isPresenting = false
private var queue: [PendingRequest] = []
var pendingCount: Int = 0
var pendingRepairCount: Int = 0
private let alertState = PairingAlertState()
private var resolvedByRequestId: Set<String> = []
private struct PairingList: Codable {
let pending: [PendingRequest]
let paired: [PairedDevice]?
}
private struct PairedDevice: Codable, Equatable {
let deviceId: String
let approvedAtMs: Double?
let displayName: String?
let platform: String?
let remoteIp: String?
}
struct PendingRequest: Codable, Equatable, Identifiable {
let requestId: String
let deviceId: String
let publicKey: String
let displayName: String?
let platform: String?
let clientId: String?
let clientMode: String?
let role: String?
let scopes: [String]?
let remoteIp: String?
let silent: Bool?
let isRepair: Bool?
let ts: Double
var id: String {
self.requestId
}
}
private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent
func start() {
self.startPushTask()
}
private func startPushTask() {
PairingAlertSupport.startPairingPushTask(
task: &self.task,
isStopping: &self.isStopping,
loadPending: self.loadPendingRequestsFromGateway,
handlePush: self.handle(push:))
}
func stop() {
self.stopPushTask()
self.updatePendingCounts()
self.resolvedByRequestId.removeAll(keepingCapacity: false)
}
private func stopPushTask() {
PairingAlertSupport.stopPairingPrompter(
isStopping: &self.isStopping,
task: &self.task,
queue: &self.queue,
isPresenting: &self.isPresenting,
state: self.alertState)
}
private func loadPendingRequestsFromGateway() async {
do {
let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList)
await self.apply(list: list)
} catch {
self.logger.error("failed to load device pairing requests: \(error.localizedDescription, privacy: .public)")
}
}
private func apply(list: PairingList) async {
self.queue = list.pending.sorted(by: { $0.ts > $1.ts })
self.updatePendingCounts()
self.presentNextIfNeeded()
}
private func updatePendingCounts() {
self.pendingCount = self.queue.count
self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true })
}
private func presentNextIfNeeded() {
guard !self.isStopping else { return }
guard !self.isPresenting else { return }
guard let next = self.queue.first else { return }
self.isPresenting = true
self.presentAlert(for: next)
}
private func presentAlert(for req: PendingRequest) {
self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)")
PairingAlertSupport.presentPairingAlert(
request: req,
requestId: req.requestId,
messageText: Self.alertTitle(for: req),
informativeText: Self.alertSummary(for: req),
buttonTitles: PairingAlertSupport.ButtonTitles(approve: Self.approveButtonTitle(for: req)),
accessoryView: Self.buildAccessoryView(for: req),
state: self.alertState,
onResponse: self.handleAlertResponse)
}
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
var shouldRemove = response != .alertSecondButtonReturn
defer {
if shouldRemove {
if self.queue.first == request {
self.queue.removeFirst()
} else {
self.queue.removeAll { $0 == request }
}
}
self.updatePendingCounts()
self.isPresenting = false
self.presentNextIfNeeded()
}
guard !self.isStopping else { return }
if self.resolvedByRequestId.remove(request.requestId) != nil {
return
}
switch response {
case .alertFirstButtonReturn:
_ = await self.approve(requestId: request.requestId)
case .alertSecondButtonReturn:
shouldRemove = false
if let idx = self.queue.firstIndex(of: request) {
self.queue.remove(at: idx)
}
self.queue.append(request)
return
case .alertThirdButtonReturn:
await self.reject(requestId: request.requestId)
default:
return
}
}
private func approve(requestId: String) async -> Bool {
await PairingAlertSupport.approveRequest(
requestId: requestId,
kind: "device",
logger: self.logger)
{
try await GatewayConnection.shared.devicePairApprove(requestId: requestId)
}
}
private func reject(requestId: String) async {
await PairingAlertSupport.rejectRequest(
requestId: requestId,
kind: "device",
logger: self.logger)
{
try await GatewayConnection.shared.devicePairReject(requestId: requestId)
}
}
private func endActiveAlert() {
PairingAlertSupport.endActiveAlert(state: self.alertState)
}
private func handle(push: GatewayPush) {
switch push {
case let .event(evt) where evt.event == "device.pair.requested":
guard let payload = evt.payload else { return }
do {
let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self)
self.enqueue(req)
} catch {
self.logger
.error("failed to decode device pairing request: \(error.localizedDescription, privacy: .public)")
}
case let .event(evt) where evt.event == "device.pair.resolved":
guard let payload = evt.payload else { return }
do {
let resolved = try GatewayPayloadDecoding.decode(payload, as: PairingResolvedEvent.self)
self.handleResolved(resolved)
} catch {
self.logger
.error(
"failed to decode device pairing resolution: \(error.localizedDescription, privacy: .public)")
}
default:
break
}
}
private func enqueue(_ req: PendingRequest) {
guard !self.queue.contains(req) else { return }
self.queue.append(req)
self.updatePendingCounts()
self.presentNextIfNeeded()
}
private func handleResolved(_ resolved: PairingResolvedEvent) {
let resolution = resolved.decision == PairingAlertSupport.PairingResolution.approved.rawValue
? PairingAlertSupport.PairingResolution.approved
: PairingAlertSupport.PairingResolution.rejected
if let activeRequestId = self.alertState.activeRequestId, activeRequestId == resolved.requestId {
self.resolvedByRequestId.insert(resolved.requestId)
self.endActiveAlert()
let decision = resolution.rawValue
self.logger.info(
"device pairing resolved while active requestId=\(resolved.requestId, privacy: .public) " +
"decision=\(decision, privacy: .public)")
return
}
self.queue.removeAll { $0.requestId == resolved.requestId }
self.updatePendingCounts()
}
static func alertTitle(for req: PendingRequest) -> String {
self.isMac(req.platform) ? "New Mac wants to connect" : "New device wants to connect"
}
static func alertSummary(for req: PendingRequest) -> String {
let subject = self.isMac(req.platform) ? "this Mac app" : "this device"
return "Approve \(subject) to control OpenClaw. Only approve if this is yours; you can remove it later in Settings."
}
static func approveButtonTitle(for req: PendingRequest) -> String {
self.isMac(req.platform) ? "Approve Mac" : "Approve Device"
}
static func buildAccessoryView(for req: PendingRequest) -> NSView {
let stack = NSStackView()
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 8
stack.edgeInsets = NSEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)
stack.addArrangedSubview(self.makeValueRow(label: "Device", value: self.deviceName(for: req)))
if let platform = self.prettyPlatform(req.platform) {
stack.addArrangedSubview(self.makeValueRow(label: "Platform", value: platform))
}
if let role = self.prettyRole(req.role) {
stack.addArrangedSubview(self.makeValueRow(label: "Role", value: role))
}
let accessItems = self.friendlyScopeNames(req.scopes)
if !accessItems.isEmpty {
stack.addArrangedSubview(self.makeSectionLabel("Access requested"))
for item in accessItems {
stack.addArrangedSubview(self.makeBullet(item))
}
}
stack.addArrangedSubview(self.makeDetailLine(req))
let fitting = stack.fittingSize
stack.frame = NSRect(x: 0, y: 0, width: 420, height: fitting.height)
return stack
}
static func deviceName(for req: PendingRequest) -> String {
let trimmedName = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmedName, !trimmedName.isEmpty, trimmedName != req.deviceId {
return trimmedName
}
return self.isMac(req.platform) ? "OpenClaw Mac app" : "New device"
}
static func prettyPlatform(_ raw: String?) -> String? {
let platform = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let platform, !platform.isEmpty else { return nil }
switch platform.lowercased() {
case "macintel", "x86_64-apple-darwin":
return "Mac (Intel)"
case "macarm", "macarm64", "arm64-apple-darwin", "aarch64-apple-darwin":
return "Mac (Apple silicon)"
case "darwin":
return "Mac"
default:
if platform.lowercased().contains("mac") {
return "Mac"
}
return platform
}
}
static func prettyRole(_ raw: String?) -> String? {
let role = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let role, !role.isEmpty else { return nil }
return role == "operator" ? "Operator" : role
}
static func friendlyScopeNames(_ scopes: [String]?) -> [String] {
guard let scopes else { return [] }
var seen = Set<String>()
return scopes.compactMap { scope in
let normalized = scope.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty, seen.insert(normalized).inserted else { return nil }
switch normalized {
case "operator.admin":
return "Admin access"
case "operator.read":
return "Read OpenClaw data"
case "operator.write":
return "Send messages and make changes"
case "operator.approvals":
return "Manage approvals"
case "operator.pairing":
return "Pair and repair devices"
case "operator.talk.secrets":
return "Use Talk credentials"
default:
return normalized
}
}
}
static func shortIdentifier(_ id: String) -> String {
let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count > 20 else { return trimmed }
return "\(trimmed.prefix(8))...\(trimmed.suffix(7))"
}
private static func isMac(_ platform: String?) -> Bool {
guard let platform else { return false }
let lower = platform.lowercased()
return lower.contains("mac") || lower.contains("darwin")
}
private static func makeValueRow(label: String, value: String) -> NSView {
let row = NSStackView()
row.orientation = .horizontal
row.alignment = .firstBaseline
row.spacing = 8
let labelField = self.makeLabel("\(label):", font: .systemFont(ofSize: 12, weight: .semibold))
labelField.textColor = .secondaryLabelColor
labelField.setContentHuggingPriority(.required, for: .horizontal)
let valueField = self.makeLabel(value, font: .systemFont(ofSize: 12, weight: .regular))
valueField.maximumNumberOfLines = 2
row.addArrangedSubview(labelField)
row.addArrangedSubview(valueField)
return row
}
private static func makeSectionLabel(_ text: String) -> NSTextField {
let label = self.makeLabel(text, font: .systemFont(ofSize: 12, weight: .semibold))
label.textColor = .secondaryLabelColor
return label
}
private static func makeBullet(_ text: String) -> NSTextField {
let label = self.makeLabel("\(text)", font: .systemFont(ofSize: 12, weight: .regular))
label.maximumNumberOfLines = 2
return label
}
private static func makeDetailLine(_ req: PendingRequest) -> NSTextField {
var parts = ["ID \(self.shortIdentifier(req.deviceId))"]
if let remoteIp = req.remoteIp?.trimmingCharacters(in: .whitespacesAndNewlines), !remoteIp.isEmpty {
parts.append("IP \(remoteIp.replacingOccurrences(of: "::ffff:", with: ""))")
}
if req.isRepair == true {
parts.append("repair request")
}
let label = self.makeLabel(
parts.joined(separator: " · "),
font: .monospacedSystemFont(ofSize: 11, weight: .regular))
label.textColor = .tertiaryLabelColor
label.maximumNumberOfLines = 2
return label
}
private static func makeLabel(_ text: String, font: NSFont) -> NSTextField {
let label = NSTextField(labelWithString: text)
label.font = font
label.lineBreakMode = .byWordWrapping
label.textColor = .labelColor
return label
}
}

View File

@@ -0,0 +1,133 @@
import Foundation
actor DiagnosticsFileLog {
static let shared = DiagnosticsFileLog()
private let fileName = "diagnostics.jsonl"
private let maxBytes: Int64 = 5 * 1024 * 1024
private let maxBackups = 5
struct Record: Codable {
let ts: String
let pid: Int32
let category: String
let event: String
let fields: [String: String]?
}
nonisolated static func isEnabled() -> Bool {
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
}
nonisolated static func logDirectoryURL() -> URL {
let library = FileManager().urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
return library
.appendingPathComponent("Logs", isDirectory: true)
.appendingPathComponent("OpenClaw", isDirectory: true)
}
nonisolated static func logFileURL() -> URL {
self.logDirectoryURL().appendingPathComponent("diagnostics.jsonl", isDirectory: false)
}
nonisolated func log(category: String, event: String, fields: [String: String]? = nil) {
guard Self.isEnabled() else { return }
let record = Record(
ts: ISO8601DateFormatter().string(from: Date()),
pid: ProcessInfo.processInfo.processIdentifier,
category: category,
event: event,
fields: fields)
Task { await self.write(record: record) }
}
func clear() throws {
let fm = FileManager()
let base = Self.logFileURL()
if fm.fileExists(atPath: base.path) {
try fm.removeItem(at: base)
}
for idx in 1...self.maxBackups {
let url = self.rotatedURL(index: idx)
if fm.fileExists(atPath: url.path) {
try fm.removeItem(at: url)
}
}
}
private func write(record: Record) {
do {
try self.ensureDirectory()
try self.rotateIfNeeded()
try self.append(record: record)
} catch {
// Best-effort only: never crash or block the app on logging.
}
}
private func ensureDirectory() throws {
try FileManager().createDirectory(
at: Self.logDirectoryURL(),
withIntermediateDirectories: true)
}
private func append(record: Record) throws {
let url = Self.logFileURL()
let data = try JSONEncoder().encode(record)
var line = Data()
line.append(data)
line.append(0x0A) // newline
let fm = FileManager()
if !fm.fileExists(atPath: url.path) {
fm.createFile(atPath: url.path, contents: nil)
}
let handle = try FileHandle(forWritingTo: url)
defer { try? handle.close() }
try handle.seekToEnd()
try handle.write(contentsOf: line)
}
private func rotateIfNeeded() throws {
let url = Self.logFileURL()
guard let attrs = try? FileManager().attributesOfItem(atPath: url.path),
let size = attrs[.size] as? NSNumber
else { return }
if size.int64Value < self.maxBytes { return }
let fm = FileManager()
let oldest = self.rotatedURL(index: self.maxBackups)
if fm.fileExists(atPath: oldest.path) {
try fm.removeItem(at: oldest)
}
if self.maxBackups > 1 {
for idx in stride(from: self.maxBackups - 1, through: 1, by: -1) {
let src = self.rotatedURL(index: idx)
let dst = self.rotatedURL(index: idx + 1)
if fm.fileExists(atPath: src.path) {
if fm.fileExists(atPath: dst.path) {
try fm.removeItem(at: dst)
}
try fm.moveItem(at: src, to: dst)
}
}
}
let first = self.rotatedURL(index: 1)
if fm.fileExists(atPath: first.path) {
try fm.removeItem(at: first)
}
if fm.fileExists(atPath: url.path) {
try fm.moveItem(at: url, to: first)
}
}
private func rotatedURL(index: Int) -> URL {
Self.logDirectoryURL().appendingPathComponent("\(self.fileName).\(index)", isDirectory: false)
}
}

View File

@@ -0,0 +1,116 @@
import AppKit
/// Central manager for Dock icon visibility.
/// Shows the Dock icon while any windows are visible, regardless of user preference.
final class DockIconManager: NSObject, @unchecked Sendable {
static let shared = DockIconManager()
private var windowsObservation: NSKeyValueObservation?
private let logger = Logger(subsystem: "ai.openclaw", category: "DockIconManager")
override private init() {
super.init()
self.setupObservers()
Task { @MainActor in
self.updateDockVisibility()
}
}
deinit {
self.windowsObservation?.invalidate()
NotificationCenter.default.removeObserver(self)
}
func updateDockVisibility() {
Task { @MainActor in
guard NSApp != nil else {
self.logger.warning("NSApp not ready, skipping Dock visibility update")
return
}
let userWantsDockHidden = (UserDefaults.standard.object(forKey: showDockIconKey) as? Bool) == false
let visibleWindows = NSApp?.windows.filter { window in
window.isVisible &&
window.frame.width > 1 &&
window.frame.height > 1 &&
!window.isKind(of: NSPanel.self) &&
"\(type(of: window))" != "NSPopupMenuWindow" &&
window.contentViewController != nil
} ?? []
let hasVisibleWindows = !visibleWindows.isEmpty
if !userWantsDockHidden || hasVisibleWindows {
NSApp?.setActivationPolicy(.regular)
} else {
NSApp?.setActivationPolicy(.accessory)
}
}
}
func temporarilyShowDock() {
Task { @MainActor in
guard NSApp != nil else {
self.logger.warning("NSApp not ready, cannot show Dock icon")
return
}
NSApp.setActivationPolicy(.regular)
}
}
private func setupObservers() {
Task { @MainActor in
guard let app = NSApp else {
self.logger.warning("NSApp not ready, delaying Dock observers")
try? await Task.sleep(for: .milliseconds(200))
self.setupObservers()
return
}
self.windowsObservation = app.observe(\.windows, options: [.new]) { [weak self] _, _ in
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(50))
self?.updateDockVisibility()
}
}
NotificationCenter.default.addObserver(
self,
selector: #selector(self.windowVisibilityChanged),
name: NSWindow.didBecomeKeyNotification,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.windowVisibilityChanged),
name: NSWindow.didResignKeyNotification,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.windowVisibilityChanged),
name: NSWindow.willCloseNotification,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(self.dockPreferenceChanged),
name: UserDefaults.didChangeNotification,
object: nil)
}
}
@objc
private func windowVisibilityChanged(_: Notification) {
Task { @MainActor in
self.updateDockVisibility()
}
}
@objc
private func dockPreferenceChanged(_ notification: Notification) {
guard let userDefaults = notification.object as? UserDefaults,
userDefaults == UserDefaults.standard
else { return }
Task { @MainActor in
self.updateDockVisibility()
}
}
}

View File

@@ -0,0 +1,15 @@
import Foundation
enum DurationFormattingSupport {
static func conciseDuration(ms: Int) -> String {
if ms < 1000 { return "\(ms)ms" }
let s = Double(ms) / 1000.0
if s < 60 { return "\(Int(round(s)))s" }
let m = s / 60.0
if m < 60 { return "\(Int(round(m)))m" }
let h = m / 60.0
if h < 48 { return "\(Int(round(h)))h" }
let d = h / 24.0
return "\(Int(round(d)))d"
}
}

View File

@@ -0,0 +1,100 @@
import Foundation
enum ExecAllowlistMatcher {
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
guard let resolution, !entries.isEmpty else { return nil }
let rawExecutable = resolution.rawExecutable
let resolvedPath = resolution.resolvedPath
for entry in entries {
switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) {
case let .valid(pattern):
if ExecApprovalHelpers.patternHasPathSelector(pattern) {
let target = resolvedPath ?? rawExecutable
if self.matches(pattern: pattern, target: target) { return entry }
} else if pattern != "*",
!ExecApprovalHelpers.patternHasPathSelector(rawExecutable),
self.matchesExecutableBasename(pattern: pattern, resolution: resolution)
{
return entry
}
case .invalid:
continue
}
}
return nil
}
static func matchAll(
entries: [ExecAllowlistEntry],
resolutions: [ExecCommandResolution]) -> [ExecAllowlistEntry]
{
guard !entries.isEmpty, !resolutions.isEmpty else { return [] }
var matches: [ExecAllowlistEntry] = []
matches.reserveCapacity(resolutions.count)
for resolution in resolutions {
guard let match = self.match(entries: entries, resolution: resolution) else {
return []
}
matches.append(match)
}
return matches
}
private static func matchesExecutableBasename(
pattern: String,
resolution: ExecCommandResolution) -> Bool
{
var candidates = Set<String>()
if !resolution.executableName.isEmpty {
candidates.insert(resolution.executableName)
}
if let resolvedPath = resolution.resolvedPath, !resolvedPath.isEmpty {
candidates.insert(URL(fileURLWithPath: resolvedPath).lastPathComponent)
}
return candidates.contains { self.matches(pattern: pattern, target: $0) }
}
private static func matches(pattern: String, target: String) -> Bool {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
let normalizedPattern = self.normalizeMatchTarget(expanded)
let normalizedTarget = self.normalizeMatchTarget(target)
guard let regex = self.regex(for: normalizedPattern) else { return false }
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
}
private static func normalizeMatchTarget(_ value: String) -> String {
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
}
private static func regex(for pattern: String) -> NSRegularExpression? {
var regex = "^"
var idx = pattern.startIndex
while idx < pattern.endIndex {
let ch = pattern[idx]
if ch == "*" {
let next = pattern.index(after: idx)
if next < pattern.endIndex, pattern[next] == "*" {
regex += ".*"
idx = pattern.index(after: next)
} else {
regex += "[^/]*"
idx = next
}
continue
}
if ch == "?" {
regex += "."
idx = pattern.index(after: idx)
continue
}
regex += NSRegularExpression.escapedPattern(for: String(ch))
idx = pattern.index(after: idx)
}
regex += "$"
return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive])
}
}

View File

@@ -0,0 +1,45 @@
import Foundation
enum ExecApprovalCommandDisplaySanitizer {
private static let invisibleCodePoints: Set<UInt32> = [
0x115F,
0x1160,
0x3164,
0xFFA0,
]
static func sanitize(_ text: String) -> String {
var sanitized = ""
sanitized.reserveCapacity(text.count)
for scalar in text.unicodeScalars {
if self.shouldEscape(scalar) {
sanitized.append(self.escape(scalar))
} else {
sanitized.append(String(scalar))
}
}
return sanitized
}
private static func shouldEscape(_ scalar: UnicodeScalar) -> Bool {
let category = scalar.properties.generalCategory
if category == .control
|| category == .format
|| category == .lineSeparator
|| category == .paragraphSeparator
{
return true
}
// Escape non-ASCII space separators (NBSP, narrow NBSP, ideographic space, etc.) so
// attackers cannot spoof token boundaries in the approval UI with spaces that render
// like a plain space but are handled differently by shells/parsers.
if category == .spaceSeparator, scalar.value != 0x20 {
return true
}
return self.invisibleCodePoints.contains(scalar.value)
}
private static func escape(_ scalar: UnicodeScalar) -> String {
"\\u{\(String(scalar.value, radix: 16, uppercase: true))}"
}
}

View File

@@ -0,0 +1,100 @@
import Foundation
struct ExecApprovalEvaluation {
let command: [String]
let displayCommand: String
let agentId: String?
let security: ExecSecurity
let ask: ExecAsk
let env: [String: String]
let resolution: ExecCommandResolution?
let allowlistResolutions: [ExecCommandResolution]
let allowAlwaysPatterns: [String]
let allowlistMatches: [ExecAllowlistEntry]
let allowlistSatisfied: Bool
let allowlistMatch: ExecAllowlistEntry?
let skillAllow: Bool
}
enum ExecApprovalEvaluator {
static func evaluate(
command: [String],
rawCommand: String?,
cwd: String?,
envOverrides: [String: String]?,
agentId: String?) async -> ExecApprovalEvaluation
{
let trimmedAgent = agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedAgentId = (trimmedAgent?.isEmpty == false) ? trimmedAgent : nil
let approvals = ExecApprovalsStore.resolve(agentId: normalizedAgentId)
let security = approvals.agent.security
let ask = approvals.agent.ask
let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper
let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper)
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
let allowlistRawCommand = ExecSystemRunCommandValidator.allowlistEvaluationRawCommand(
command: command,
rawCommand: rawCommand)
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: allowlistRawCommand,
cwd: cwd,
env: env)
let allowAlwaysPatterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: command,
cwd: cwd,
env: env,
rawCommand: allowlistRawCommand)
let allowlistMatches = security == .allowlist
? ExecAllowlistMatcher.matchAll(entries: approvals.allowlist, resolutions: allowlistResolutions)
: []
let allowlistSatisfied = security == .allowlist &&
!allowlistResolutions.isEmpty &&
allowlistMatches.count == allowlistResolutions.count
let skillAllow: Bool
if approvals.agent.autoAllowSkills, !allowlistResolutions.isEmpty {
let bins = await SkillBinsCache.shared.currentTrust()
skillAllow = self.isSkillAutoAllowed(allowlistResolutions, trustedBinsByName: bins)
} else {
skillAllow = false
}
return ExecApprovalEvaluation(
command: command,
displayCommand: displayCommand,
agentId: normalizedAgentId,
security: security,
ask: ask,
env: env,
resolution: allowlistResolutions.first,
allowlistResolutions: allowlistResolutions,
allowAlwaysPatterns: allowAlwaysPatterns,
allowlistMatches: allowlistMatches,
allowlistSatisfied: allowlistSatisfied,
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,
skillAllow: skillAllow)
}
static func isSkillAutoAllowed(
_ resolutions: [ExecCommandResolution],
trustedBinsByName: [String: Set<String>]) -> Bool
{
guard !resolutions.isEmpty, !trustedBinsByName.isEmpty else { return false }
return resolutions.allSatisfy { resolution in
guard let executableName = SkillBinsCache.normalizeSkillBinName(resolution.executableName),
let resolvedPath = SkillBinsCache.normalizeResolvedPath(resolution.resolvedPath)
else {
return false
}
return trustedBinsByName[executableName]?.contains(resolvedPath) == true
}
}
static func _testIsSkillAutoAllowed(
_ resolutions: [ExecCommandResolution],
trustedBinsByName: [String: Set<String>]) -> Bool
{
self.isSkillAutoAllowed(resolutions, trustedBinsByName: trustedBinsByName)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,246 @@
import CoreGraphics
import Foundation
import OpenClawKit
import OpenClawProtocol
import OSLog
@MainActor
final class ExecApprovalsGatewayPrompter {
static let shared = ExecApprovalsGatewayPrompter()
private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.gateway")
private var task: Task<Void, Never>?
struct GatewayApprovalRequest: Codable {
var id: String
var request: ExecApprovalPromptRequest
var createdAtMs: Int
var expiresAtMs: Int
}
func start() {
SimpleTaskSupport.start(task: &self.task) { [weak self] in
await self?.run()
}
}
func stop() {
SimpleTaskSupport.stop(task: &self.task)
}
private func run() async {
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
for await push in stream {
if Task.isCancelled { return }
await self.handle(push: push)
}
}
private func handle(push: GatewayPush) async {
guard case let .event(evt) = push else { return }
guard evt.event == "exec.approval.requested" else { return }
guard let payload = evt.payload else { return }
do {
let data = try JSONEncoder().encode(payload)
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
let presentation = self.shouldPresent(request: request)
guard presentation.shouldAsk else {
// Ask policy says no prompt needed resolve based on security policy
let decision: ExecApprovalDecision = presentation.security == .full ? .allowOnce : .deny
try await GatewayConnection.shared.requestVoid(
method: .execApprovalResolve,
params: [
"id": AnyCodable(request.id),
"decision": AnyCodable(decision.rawValue),
],
timeoutMs: 10000)
return
}
guard presentation.canPresent else {
let decision = Self.fallbackDecision(
request: request.request,
askFallback: presentation.askFallback,
allowlist: presentation.allowlist)
try await GatewayConnection.shared.requestVoid(
method: .execApprovalResolve,
params: [
"id": AnyCodable(request.id),
"decision": AnyCodable(decision.rawValue),
],
timeoutMs: 10000)
return
}
guard let decision = ExecApprovalsPromptPresenter.prompt(request.request) else {
return
}
try await GatewayConnection.shared.requestVoid(
method: .execApprovalResolve,
params: [
"id": AnyCodable(request.id),
"decision": AnyCodable(decision.rawValue),
],
timeoutMs: 10000)
} catch {
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
}
}
/// Whether the ask policy requires prompting the user.
/// Note: this only determines if a prompt is shown, not whether the action is allowed.
/// The security policy (full/deny/allowlist) decides the actual outcome.
private static func shouldAsk(security: ExecSecurity, ask: ExecAsk) -> Bool {
switch ask {
case .always:
true
case .onMiss:
security == .allowlist
case .off:
false
}
}
struct PresentationDecision {
/// Whether the ask policy requires prompting the user (not whether the action is allowed).
var shouldAsk: Bool
/// Whether the prompt can actually be shown (session match, recent activity, etc.).
var canPresent: Bool
/// The resolved security policy, used to determine allow/deny when no prompt is shown.
var security: ExecSecurity
/// Fallback security policy when a prompt is needed but can't be presented.
var askFallback: ExecSecurity
var allowlist: [ExecAllowlistEntry]
}
private func shouldPresent(request: GatewayApprovalRequest) -> PresentationDecision {
let mode = AppStateStore.shared.connectionMode
let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
// Read-only resolve to avoid disk writes on the MainActor
let approvals = ExecApprovalsStore.resolveReadOnly(agentId: request.request.agentId)
let security = approvals.agent.security
let ask = approvals.agent.ask
let shouldAsk = Self.shouldAsk(security: security, ask: ask)
let canPresent = shouldAsk && Self.shouldPresent(
mode: mode,
activeSession: activeSession,
requestSession: requestSession,
lastInputSeconds: Self.lastInputSeconds(),
thresholdSeconds: 120)
return PresentationDecision(
shouldAsk: shouldAsk,
canPresent: canPresent,
security: security,
askFallback: approvals.agent.askFallback,
allowlist: approvals.allowlist)
}
private static func fallbackDecision(
request: ExecApprovalPromptRequest,
askFallback: ExecSecurity,
allowlist: [ExecAllowlistEntry]) -> ExecApprovalDecision
{
guard askFallback == .allowlist else {
return askFallback == .full ? .allowOnce : .deny
}
let resolution = self.fallbackResolution(for: request)
let match = ExecAllowlistMatcher.match(entries: allowlist, resolution: resolution)
return match == nil ? .deny : .allowOnce
}
private static func fallbackResolution(for request: ExecApprovalPromptRequest) -> ExecCommandResolution? {
let resolvedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedResolvedPath = (resolvedPath?.isEmpty == false) ? resolvedPath : nil
let rawExecutable = self.firstToken(from: request.command) ?? trimmedResolvedPath ?? ""
guard !rawExecutable.isEmpty || trimmedResolvedPath != nil else { return nil }
let executableName = trimmedResolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? rawExecutable
return ExecCommandResolution(
rawExecutable: rawExecutable,
resolvedPath: trimmedResolvedPath,
executableName: executableName,
cwd: request.cwd)
}
private static func firstToken(from command: String) -> String? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
}
private static func shouldPresent(
mode: AppState.ConnectionMode,
activeSession: String?,
requestSession: String?,
lastInputSeconds: Int?,
thresholdSeconds: Int) -> Bool
{
let active = activeSession?.trimmingCharacters(in: .whitespacesAndNewlines)
let requested = requestSession?.trimmingCharacters(in: .whitespacesAndNewlines)
let recentlyActive = lastInputSeconds.map { $0 <= thresholdSeconds } ?? (mode == .local)
if let session = requested, !session.isEmpty {
if let active, !active.isEmpty {
return active == session
}
return recentlyActive
}
if let active, !active.isEmpty {
return true
}
return mode == .local
}
private static func lastInputSeconds() -> Int? {
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
return Int(seconds.rounded())
}
}
#if DEBUG
extension ExecApprovalsGatewayPrompter {
static func _testShouldPresent(
mode: AppState.ConnectionMode,
activeSession: String?,
requestSession: String?,
lastInputSeconds: Int?,
thresholdSeconds: Int = 120) -> Bool
{
self.shouldPresent(
mode: mode,
activeSession: activeSession,
requestSession: requestSession,
lastInputSeconds: lastInputSeconds,
thresholdSeconds: thresholdSeconds)
}
static func _testShouldAsk(security: ExecSecurity, ask: ExecAsk) -> Bool {
self.shouldAsk(security: security, ask: ask)
}
static func _testFallbackDecision(
command: String,
resolvedPath: String?,
askFallback: ExecSecurity,
allowlistPatterns: [String]) -> ExecApprovalDecision
{
self.fallbackDecision(
request: ExecApprovalPromptRequest(
command: command,
cwd: nil,
host: nil,
security: nil,
ask: nil,
agentId: nil,
resolvedPath: resolvedPath,
sessionKey: nil),
askFallback: askFallback,
allowlist: allowlistPatterns.map { ExecAllowlistEntry(pattern: $0) })
}
}
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,531 @@
import Foundation
struct ExecCommandResolution {
let rawExecutable: String
let resolvedPath: String?
let executableName: String
let cwd: String?
static func resolve(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
}
return self.resolve(command: command, cwd: cwd, env: env)
}
static func resolveForAllowlist(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> [ExecCommandResolution]
{
// Allowlist resolution must follow actual argv execution for wrappers.
// `rawCommand` is caller-supplied display text and may be canonicalized.
let shell = ExecShellWrapperParser.extractForAllowlist(command: command, rawCommand: rawCommand)
if shell.isWrapper {
// Fail closed when env modifiers precede a shell wrapper. This mirrors
// system-run binding behavior where such invocations must stay bound to
// full argv and must not be auto-allowlisted by payload-only matches.
if ExecSystemRunCommandValidator.hasEnvManipulationBeforeShellWrapper(command) {
return []
}
guard let shellCommand = shell.command,
let segments = self.splitShellCommandChain(shellCommand)
else {
// Fail closed: if we cannot safely parse a shell wrapper payload,
// treat this as an allowlist miss and require approval.
return []
}
var resolutions: [ExecCommandResolution] = []
resolutions.reserveCapacity(segments.count)
for segment in segments {
guard let resolution = self.resolveShellSegmentExecutable(segment, cwd: cwd, env: env)
else {
return []
}
resolutions.append(resolution)
}
return resolutions
}
guard let resolution = self.resolveForAllowlistCommand(
command: command,
rawCommand: rawCommand,
cwd: cwd,
env: env)
else {
return []
}
return [resolution]
}
static func resolveAllowAlwaysPatterns(
command: [String],
cwd: String?,
env: [String: String]?,
rawCommand: String? = nil) -> [String]
{
var patterns: [String] = []
var seen = Set<String>()
self.collectAllowAlwaysPatterns(
command: command,
cwd: cwd,
env: env,
rawCommand: rawCommand,
depth: 0,
patterns: &patterns,
seen: &seen)
return patterns
}
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
let effective = ExecEnvInvocationUnwrapper.unwrapTransparentDispatchWrappersForResolution(command)
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
}
private static func resolveForAllowlistCommand(
command: [String],
rawCommand: String?,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
}
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
}
private static func resolveExecutable(
rawExecutable: String,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
let resolvedPath: String? = {
if hasPathSeparator {
if expanded.hasPrefix("/") {
return expanded
}
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
}
let searchPaths = self.searchPaths(from: env)
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
}()
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
return ExecCommandResolution(
rawExecutable: expanded,
resolvedPath: resolvedPath,
executableName: name,
cwd: cwd)
}
private static func resolveShellSegmentExecutable(
_ segment: String,
cwd: String?,
env: [String: String]?) -> ExecCommandResolution?
{
let tokens = self.tokenizeShellWords(segment)
guard !tokens.isEmpty else { return nil }
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(tokens)
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
}
private static func collectAllowAlwaysPatterns(
command: [String],
cwd: String?,
env: [String: String]?,
rawCommand: String?,
depth: Int,
patterns: inout [String],
seen: inout Set<String>)
{
guard depth < 3, !command.isEmpty else {
return
}
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
ExecCommandToken.basenameLower(token0) == "env",
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrapWithMetadata(command),
!envUnwrapped.command.isEmpty
{
if envUnwrapped.usesModifiers,
self.isAllowlistShellWrapper(command: envUnwrapped.command, rawCommand: rawCommand)
{
return
}
self.collectAllowAlwaysPatterns(
command: envUnwrapped.command,
cwd: cwd,
env: env,
rawCommand: rawCommand,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
return
}
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(command) {
self.collectAllowAlwaysPatterns(
command: shellMultiplexer,
cwd: cwd,
env: env,
rawCommand: rawCommand,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
return
}
let shell = ExecShellWrapperParser.extractForAllowlist(command: command, rawCommand: rawCommand)
if shell.isWrapper {
guard let shellCommand = shell.command,
let segments = self.splitShellCommandChain(shellCommand)
else {
return
}
for segment in segments {
let tokens = self.tokenizeShellWords(segment)
guard !tokens.isEmpty else {
continue
}
self.collectAllowAlwaysPatterns(
command: tokens,
cwd: cwd,
env: env,
rawCommand: nil,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
}
return
}
guard let resolution = self.resolve(command: command, cwd: cwd, env: env),
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
seen.insert(pattern).inserted
else {
return
}
patterns.append(pattern)
}
private static func isAllowlistShellWrapper(command: [String], rawCommand: String?) -> Bool {
ExecShellWrapperParser.extractForAllowlist(command: command, rawCommand: rawCommand).isWrapper
}
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
return nil
}
let wrapper = ExecCommandToken.basenameLower(token0)
guard wrapper == "busybox" || wrapper == "toybox" else {
return nil
}
var appletIndex = 1
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
appletIndex += 1
}
guard appletIndex < argv.count else {
return nil
}
let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines)
guard !applet.isEmpty else {
return nil
}
let normalizedApplet = ExecCommandToken.basenameLower(applet)
let shellWrappers = Set([
"ash",
"bash",
"dash",
"fish",
"ksh",
"powershell",
"pwsh",
"sh",
"zsh",
])
guard shellWrappers.contains(normalizedApplet) else {
return nil
}
return Array(argv[appletIndex...])
}
private static func parseFirstToken(_ command: String) -> String? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let first = trimmed.first else { return nil }
if first == "\"" || first == "'" {
let rest = trimmed.dropFirst()
if let end = rest.firstIndex(of: first) {
return String(rest[..<end])
}
return String(rest)
}
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
}
private static func tokenizeShellWords(_ command: String) -> [String] {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
var tokens: [String] = []
var current = ""
var inSingle = false
var inDouble = false
var escaped = false
func appendCurrent() {
guard !current.isEmpty else { return }
tokens.append(current)
current.removeAll(keepingCapacity: true)
}
for ch in trimmed {
if escaped {
current.append(ch)
escaped = false
continue
}
if ch == "\\", !inSingle {
escaped = true
continue
}
if ch == "'", !inDouble {
inSingle.toggle()
continue
}
if ch == "\"", !inSingle {
inDouble.toggle()
continue
}
if ch.isWhitespace, !inSingle, !inDouble {
appendCurrent()
continue
}
current.append(ch)
}
if escaped {
current.append("\\")
}
appendCurrent()
return tokens
}
private enum ShellTokenContext {
case unquoted
case doubleQuoted
}
private struct ShellFailClosedRule {
let token: Character
let next: Character?
}
private static let shellFailClosedRules: [ShellTokenContext: [ShellFailClosedRule]] = [
.unquoted: [
ShellFailClosedRule(token: "`", next: nil),
ShellFailClosedRule(token: "$", next: "("),
ShellFailClosedRule(token: "<", next: "("),
ShellFailClosedRule(token: ">", next: "("),
],
.doubleQuoted: [
ShellFailClosedRule(token: "`", next: nil),
ShellFailClosedRule(token: "$", next: "("),
],
]
private static func splitShellCommandChain(_ command: String) -> [String]? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
var segments: [String] = []
var current = ""
var inSingle = false
var inDouble = false
var escaped = false
let chars = Array(trimmed)
var idx = 0
func appendCurrent() -> Bool {
let segment = current.trimmingCharacters(in: .whitespacesAndNewlines)
guard !segment.isEmpty else { return false }
segments.append(segment)
current.removeAll(keepingCapacity: true)
return true
}
while idx < chars.count {
let ch = chars[idx]
let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil
let lookahead = self.nextShellSignificantCharacter(chars: chars, after: idx, inSingle: inSingle)
if escaped {
if ch == "\n" {
escaped = false
idx += 1
continue
}
current.append(ch)
escaped = false
idx += 1
continue
}
if ch == "\\", !inSingle {
if next == "\n" {
idx += 2
continue
}
current.append(ch)
escaped = true
idx += 1
continue
}
if ch == "'", !inDouble {
inSingle.toggle()
current.append(ch)
idx += 1
continue
}
if ch == "\"", !inSingle {
inDouble.toggle()
current.append(ch)
idx += 1
continue
}
if !inSingle, self.shouldFailClosedForShell(ch: ch, next: lookahead, inDouble: inDouble) {
// Fail closed on command/process substitution in allowlist mode,
// including command substitution inside double-quoted shell strings.
return nil
}
if !inSingle, !inDouble {
let prev: Character? = idx > 0 ? chars[idx - 1] : nil
if let delimiterStep = self.chainDelimiterStep(ch: ch, prev: prev, next: next) {
guard appendCurrent() else { return nil }
idx += delimiterStep
continue
}
}
current.append(ch)
idx += 1
}
if escaped || inSingle || inDouble { return nil }
guard appendCurrent() else { return nil }
return segments
}
private static func nextShellSignificantCharacter(
chars: [Character],
after idx: Int,
inSingle: Bool) -> Character?
{
guard !inSingle else {
return idx + 1 < chars.count ? chars[idx + 1] : nil
}
var cursor = idx + 1
while cursor < chars.count {
if chars[cursor] == "\\", cursor + 1 < chars.count, chars[cursor + 1] == "\n" {
cursor += 2
continue
}
return chars[cursor]
}
return nil
}
private static func shouldFailClosedForShell(ch: Character, next: Character?, inDouble: Bool) -> Bool {
let context: ShellTokenContext = inDouble ? .doubleQuoted : .unquoted
guard let rules = self.shellFailClosedRules[context] else {
return false
}
for rule in rules {
if ch == rule.token, rule.next == nil || next == rule.next {
return true
}
}
return false
}
private static func chainDelimiterStep(ch: Character, prev: Character?, next: Character?) -> Int? {
if ch == ";" || ch == "\n" {
return 1
}
if ch == "&" {
if next == "&" {
return 2
}
// Keep fd redirections like 2>&1 or &>file intact.
let prevIsRedirect = prev == ">"
let nextIsRedirect = next == ">"
return (!prevIsRedirect && !nextIsRedirect) ? 1 : nil
}
if ch == "|" {
if next == "|" || next == "&" {
return 2
}
return 1
}
return nil
}
private static func searchPaths(from env: [String: String]?) -> [String] {
let raw = env?["PATH"]
if let raw, !raw.isEmpty {
return raw.split(separator: ":").map(String.init)
}
return CommandResolver.preferredPaths()
}
}
enum ExecCommandFormatter {
static func displayString(for argv: [String]) -> String {
argv.map { arg in
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "\"\"" }
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
if !needsQuotes { return trimmed }
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}.joined(separator: " ")
}
static func displayString(for argv: [String], rawCommand: String?) -> String {
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty { return trimmed }
return self.displayString(for: argv)
}
}

View File

@@ -0,0 +1,159 @@
import Foundation
enum ExecCommandToken {
static func basenameLower(_ token: String) -> String {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
let normalized = trimmed.replacingOccurrences(of: "\\", with: "/")
return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased()
}
}
enum ExecEnvInvocationUnwrapper {
static let maxWrapperDepth = 4
struct UnwrapResult {
let command: [String]
let usesModifiers: Bool
}
private static func isEnvAssignment(_ token: String) -> Bool {
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
return token.range(of: pattern, options: .regularExpression) != nil
}
static func unwrap(_ command: [String]) -> [String]? {
self.unwrapWithMetadata(command)?.command
}
static func unwrapWithMetadata(_ command: [String]) -> UnwrapResult? {
var idx = 1
var expectsOptionValue = false
var usesModifiers = false
while idx < command.count {
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
idx += 1
continue
}
if expectsOptionValue {
expectsOptionValue = false
usesModifiers = true
idx += 1
continue
}
if token == "--" || token == "-" {
idx += 1
break
}
if self.isEnvAssignment(token) {
usesModifiers = true
idx += 1
continue
}
if token.hasPrefix("-"), token != "-" {
let lower = token.lowercased()
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
if ExecEnvOptions.flagOnly.contains(flag) {
usesModifiers = true
idx += 1
continue
}
if ExecEnvOptions.withValue.contains(flag) {
usesModifiers = true
if !lower.contains("=") {
expectsOptionValue = true
}
idx += 1
continue
}
if lower.hasPrefix("-u") ||
lower.hasPrefix("-c") ||
lower.hasPrefix("-s") ||
lower.hasPrefix("--unset=") ||
lower.hasPrefix("--chdir=") ||
lower.hasPrefix("--split-string=") ||
lower.hasPrefix("--default-signal=") ||
lower.hasPrefix("--ignore-signal=") ||
lower.hasPrefix("--block-signal=")
{
usesModifiers = true
idx += 1
continue
}
return nil
}
break
}
guard !expectsOptionValue, idx < command.count else { return nil }
return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers)
}
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
var current = command
var depth = 0
while depth < self.maxWrapperDepth {
guard let token = current.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else {
break
}
guard ExecCommandToken.basenameLower(token) == "env" else {
break
}
guard let unwrapped = self.unwrapWithMetadata(current), !unwrapped.command.isEmpty else {
break
}
if unwrapped.usesModifiers {
break
}
current = unwrapped.command
depth += 1
}
return current
}
private static func unwrapTransparentEnvInvocation(_ command: [String]) -> [String]? {
var idx = 1
while idx < command.count {
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
idx += 1
continue
}
if token == "--" {
idx += 1
break
}
if token == "-" {
return nil
}
if self.isEnvAssignment(token) {
return nil
}
if token.hasPrefix("-"), token != "-" {
return nil
}
break
}
guard idx < command.count else { return nil }
return Array(command[idx...])
}
static func unwrapTransparentDispatchWrappersForResolution(_ command: [String]) -> [String] {
var current = command
var depth = 0
while depth < self.maxWrapperDepth {
guard let token = current.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else {
break
}
guard ExecCommandToken.basenameLower(token) == "env" else {
break
}
guard let unwrapped = self.unwrapTransparentEnvInvocation(current), !unwrapped.isEmpty else {
break
}
current = unwrapped
depth += 1
}
return current
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
enum ExecEnvOptions {
static let withValue = Set([
"-u",
"--unset",
"-c",
"--chdir",
"-s",
"--split-string",
"--default-signal",
"--ignore-signal",
"--block-signal",
])
static let flagOnly = Set(["-i", "--ignore-environment", "-0", "--null"])
static let inlineValuePrefixes = [
"-u",
"-c",
"-s",
"--unset=",
"--chdir=",
"--split-string=",
"--default-signal=",
"--ignore-signal=",
"--block-signal=",
]
}

View File

@@ -0,0 +1,88 @@
import Foundation
struct ExecHostValidatedRequest {
let command: [String]
let displayCommand: String
let evaluationRawCommand: String?
}
enum ExecHostPolicyDecision {
case deny(ExecHostError)
case requiresPrompt
case allow(approvedByAsk: Bool)
}
enum ExecHostRequestEvaluator {
static func validateRequest(_ request: ExecHostRequest) -> Result<ExecHostValidatedRequest, ExecHostError> {
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !command.isEmpty else {
return .failure(
ExecHostError(
code: "INVALID_REQUEST",
message: "command required",
reason: "invalid"))
}
let validatedCommand = ExecSystemRunCommandValidator.resolve(
command: command,
rawCommand: request.rawCommand)
switch validatedCommand {
case let .ok(resolved):
return .success(ExecHostValidatedRequest(
command: command,
displayCommand: resolved.displayCommand,
evaluationRawCommand: resolved.evaluationRawCommand))
case let .invalid(message):
return .failure(
ExecHostError(
code: "INVALID_REQUEST",
message: message,
reason: "invalid"))
}
}
static func evaluate(
context: ExecApprovalEvaluation,
approvalDecision: ExecApprovalDecision?) -> ExecHostPolicyDecision
{
if context.security == .deny {
return .deny(
ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DISABLED: security=deny",
reason: "security=deny"))
}
if approvalDecision == .deny {
return .deny(
ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: user denied",
reason: "user-denied"))
}
let approvedByAsk = approvalDecision != nil
let requiresPrompt = ExecApprovalHelpers.requiresAsk(
ask: context.ask,
security: context.security,
allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow) && approvalDecision == nil
if requiresPrompt {
return .requiresPrompt
}
if context.security == .allowlist,
!context.allowlistSatisfied,
!context.skillAllow,
!approvedByAsk
{
return .deny(
ExecHostError(
code: "UNAVAILABLE",
message: "SYSTEM_RUN_DENIED: allowlist miss",
reason: "allowlist-miss"))
}
return .allow(approvedByAsk: approvedByAsk)
}
}

View File

@@ -0,0 +1,278 @@
import Foundation
enum ExecInlineCommandParser {
struct Match {
let tokenIndex: Int
let inlineCommand: String?
let valueTokenOffset: Int
init(tokenIndex: Int, inlineCommand: String?, valueTokenOffset: Int = 1) {
self.tokenIndex = tokenIndex
self.inlineCommand = inlineCommand
self.valueTokenOffset = valueTokenOffset
}
}
private struct CombinedCommandFlag {
let attachedCommand: String?
let separateValueCount: Int
}
private static let posixShellOptionsWithSeparateValues = Set([
"--init-file",
"--rcfile",
"-O",
"-o",
"+O",
"+o",
])
static func hasPosixInteractiveStartupBeforeInlineCommand(
_ argv: [String],
flags: Set<String>) -> Bool
{
var idx = 1
var sawInteractiveMode = false
while idx < argv.count {
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
idx += 1
continue
}
if token == "--" {
return false
}
if self.isPosixInteractiveModeOption(token) {
sawInteractiveMode = true
}
if flags.contains(token) || self.isCombinedCommandFlag(token) {
return sawInteractiveMode
}
if !token.hasPrefix("-"), !token.hasPrefix("+") {
return false
}
let combinedValueCount = self.combinedSeparateValueOptionCount(token)
if combinedValueCount > 0 {
idx += 1 + combinedValueCount
continue
}
if self.consumesSeparateValue(token) {
idx += 2
continue
}
idx += 1
}
return false
}
static func hasPosixLoginStartupBeforeInlineCommand(
_ argv: [String],
flags: Set<String>) -> Bool
{
var idx = 1
var sawLoginMode = false
while idx < argv.count {
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
idx += 1
continue
}
if token == "--" {
return false
}
if token == "--login" || self.isPosixShortOption(token, containing: "l") {
sawLoginMode = true
}
if flags.contains(token) || self.isCombinedCommandFlag(token) {
return sawLoginMode
}
if !token.hasPrefix("-"), !token.hasPrefix("+") {
return false
}
let combinedValueCount = self.combinedSeparateValueOptionCount(token)
if combinedValueCount > 0 {
idx += 1 + combinedValueCount
continue
}
if self.consumesSeparateValue(token) {
idx += 2
continue
}
idx += 1
}
return false
}
static func hasFishInitCommandOption(_ argv: [String]) -> Bool {
var idx = 1
while idx < argv.count {
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
idx += 1
continue
}
if token == "--" {
return false
}
if token == "-C" || token == "--init-command" {
return true
}
if token.hasPrefix("-C"), token != "-C" {
return true
}
if token.hasPrefix("--init-command=") {
return true
}
if !token.hasPrefix("-"), !token.hasPrefix("+") {
return false
}
idx += 1
}
return false
}
static func hasFishAttachedCommandOption(_ argv: [String]) -> Bool {
var idx = 1
while idx < argv.count {
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
idx += 1
continue
}
if token == "--" {
return false
}
if token.hasPrefix("-c"), token != "-c" {
return true
}
if !token.hasPrefix("-"), !token.hasPrefix("+") {
return false
}
idx += 1
}
return false
}
static func findMatch(
_ argv: [String],
flags: Set<String>,
allowCombinedC: Bool) -> Match?
{
var idx = 1
while idx < argv.count {
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
idx += 1
continue
}
if token == "--" {
break
}
let comparableToken = allowCombinedC ? token : token.lowercased()
if flags.contains(comparableToken) {
return Match(tokenIndex: idx, inlineCommand: nil)
}
if allowCombinedC, let combined = self.parseCombinedCommandFlag(token) {
if let attachedCommand = combined.attachedCommand {
return Match(tokenIndex: idx, inlineCommand: attachedCommand, valueTokenOffset: 0)
}
return Match(
tokenIndex: idx,
inlineCommand: nil,
valueTokenOffset: 1 + combined.separateValueCount)
}
if allowCombinedC, !token.hasPrefix("-"), !token.hasPrefix("+") {
break
}
let combinedValueCount = allowCombinedC ? self.combinedSeparateValueOptionCount(token) : 0
if combinedValueCount > 0 {
idx += 1 + combinedValueCount
continue
}
if allowCombinedC, self.consumesSeparateValue(token) {
idx += 2
continue
}
idx += 1
}
return nil
}
static func extractInlineCommand(
_ argv: [String],
flags: Set<String>,
allowCombinedC: Bool) -> String?
{
guard let match = self.findMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
return nil
}
if let inlineCommand = match.inlineCommand {
return inlineCommand
}
let nextIndex = match.tokenIndex + match.valueTokenOffset
let payload = nextIndex < argv.count
? argv[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines)
: ""
return payload.isEmpty ? nil : payload
}
private static func isCombinedCommandFlag(_ token: String) -> Bool {
self.parseCombinedCommandFlag(token) != nil
}
private static func parseCombinedCommandFlag(_ token: String) -> CombinedCommandFlag? {
let chars = Array(token)
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
return nil
}
let optionChars = Array(chars.dropFirst())
guard let commandFlagIndex = optionChars.firstIndex(of: "c") else {
return nil
}
if optionChars.contains("-") {
return nil
}
let suffix = String(optionChars.dropFirst(commandFlagIndex + 1))
if !suffix.isEmpty,
suffix.range(of: #"[^A-Za-z]"#, options: .regularExpression) != nil
{
return CombinedCommandFlag(attachedCommand: suffix, separateValueCount: 0)
}
let separateValueCount = optionChars.reduce(0) { count, char in
count + ((char == "o" || char == "O") ? 1 : 0)
}
return CombinedCommandFlag(attachedCommand: nil, separateValueCount: separateValueCount)
}
private static func combinedSeparateValueOptionCount(_ token: String) -> Int {
let chars = Array(token)
guard chars.count >= 2, chars[0] == "-" || chars[0] == "+", chars[1] != "-" else {
return 0
}
if chars.dropFirst().contains("-") {
return 0
}
return chars.dropFirst().reduce(0) { count, char in
count + ((char == "o" || char == "O") ? 1 : 0)
}
}
private static func consumesSeparateValue(_ token: String) -> Bool {
self.posixShellOptionsWithSeparateValues.contains(token)
}
private static func isPosixInteractiveModeOption(_ token: String) -> Bool {
token == "--interactive" || self.isPosixShortOption(token, containing: "i")
}
private static func isPosixShortOption(_ token: String, containing option: Character) -> Bool {
let chars = Array(token)
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
return false
}
if chars.dropFirst().contains("-") {
return false
}
return chars.dropFirst().contains(option)
}
}

View File

@@ -0,0 +1,204 @@
import Foundation
enum ExecShellWrapperParser {
struct ParsedShellWrapper {
let isWrapper: Bool
let command: String?
static let notWrapper = ParsedShellWrapper(isWrapper: false, command: nil)
static let blockedWrapper = ParsedShellWrapper(isWrapper: true, command: nil)
}
private enum Kind: Equatable {
case posix
case cmd
case powershell
}
private struct WrapperSpec {
let kind: Kind
let names: Set<String>
}
private static let posixInlineFlags = Set(["-lc", "-c", "--command"])
private static let powershellInlineFlags = Set(["-c", "-command", "--command"])
private static let wrapperSpecs: [WrapperSpec] = [
WrapperSpec(kind: .posix, names: ["ash", "sh", "bash", "zsh", "dash", "ksh", "fish"]),
WrapperSpec(kind: .cmd, names: ["cmd.exe", "cmd"]),
WrapperSpec(kind: .powershell, names: ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]),
]
private static let loginStartupShellNames = Set(["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"])
static func extract(command: [String], rawCommand: String?) -> ParsedShellWrapper {
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw
return self.extract(
command: command,
preferredRaw: preferredRaw,
failClosedOnStartupWrappers: false,
depth: 0)
}
static func extractForAllowlist(command: [String], rawCommand: String?) -> ParsedShellWrapper {
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw
return self.extract(
command: command,
preferredRaw: preferredRaw,
failClosedOnStartupWrappers: true,
depth: 0)
}
private static func extract(
command: [String],
preferredRaw: String?,
failClosedOnStartupWrappers: Bool,
depth: Int) -> ParsedShellWrapper
{
guard depth < ExecEnvInvocationUnwrapper.maxWrapperDepth else {
return .notWrapper
}
guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
return .notWrapper
}
let base0 = ExecCommandToken.basenameLower(token0)
if base0 == "env" {
guard let unwrapped = ExecEnvInvocationUnwrapper.unwrap(command) else {
return .notWrapper
}
return self.extract(
command: unwrapped,
preferredRaw: preferredRaw,
failClosedOnStartupWrappers: failClosedOnStartupWrappers,
depth: depth + 1)
}
guard let spec = self.wrapperSpecs.first(where: { $0.names.contains(base0) }) else {
return .notWrapper
}
if spec.kind == .posix,
base0 == "fish",
ExecInlineCommandParser.hasFishAttachedCommandOption(command)
{
return .blockedWrapper
}
let includeLegacyLoginInlineForm = failClosedOnStartupWrappers &&
!self.legacyLoginInlinePayloadMatchesRaw(
command: command,
spec: spec,
base0: base0,
preferredRaw: preferredRaw)
if self.startupWrapperRequiresFullArgv(
command: command,
spec: spec,
base0: base0,
includeLegacyLoginInlineForm: includeLegacyLoginInlineForm)
{
return .blockedWrapper
}
guard let payload = self.extractPayload(command: command, spec: spec) else {
return .notWrapper
}
let normalized = failClosedOnStartupWrappers ? payload : preferredRaw ?? payload
return ParsedShellWrapper(isWrapper: true, command: normalized)
}
private static func startupWrapperRequiresFullArgv(
command: [String],
spec: WrapperSpec,
base0: String,
includeLegacyLoginInlineForm: Bool) -> Bool
{
guard spec.kind == .posix else {
return false
}
if base0 == "fish",
ExecInlineCommandParser.hasFishInitCommandOption(command)
{
return true
}
if self.loginStartupShellNames.contains(base0),
ExecInlineCommandParser.hasPosixLoginStartupBeforeInlineCommand(
command,
flags: self.posixInlineFlags)
{
return includeLegacyLoginInlineForm || !self.isLegacyShLoginInlineForm(command, base0: base0)
}
return ExecInlineCommandParser.hasPosixInteractiveStartupBeforeInlineCommand(
command,
flags: self.posixInlineFlags)
}
private static func isLegacyLoginInlineForm(_ command: [String]) -> Bool {
guard command.count > 1 else {
return false
}
return command[1].trimmingCharacters(in: .whitespacesAndNewlines) == "-lc"
}
private static func isLegacyShLoginInlineForm(_ command: [String], base0: String) -> Bool {
base0 == "sh" && self.isLegacyLoginInlineForm(command)
}
private static func legacyLoginInlinePayloadMatchesRaw(
command: [String],
spec: WrapperSpec,
base0: String,
preferredRaw: String?) -> Bool
{
guard let preferredRaw,
base0 == "sh",
self.isLegacyLoginInlineForm(command),
let payload = self.extractPayload(command: command, spec: spec)
else {
return false
}
return payload == preferredRaw.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func extractPayload(command: [String], spec: WrapperSpec) -> String? {
switch spec.kind {
case .posix:
self.extractPosixInlineCommand(command)
case .cmd:
self.extractCmdInlineCommand(command)
case .powershell:
self.extractPowerShellInlineCommand(command)
}
}
private static func extractPosixInlineCommand(_ command: [String]) -> String? {
ExecInlineCommandParser.extractInlineCommand(
command,
flags: self.posixInlineFlags,
allowCombinedC: true)
}
private static func extractCmdInlineCommand(_ command: [String]) -> String? {
guard let idx = command
.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" })
else {
return nil
}
let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ")
let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines)
return payload.isEmpty ? nil : payload
}
private static func extractPowerShellInlineCommand(_ command: [String]) -> String? {
for idx in 1..<command.count {
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if token.isEmpty { continue }
if token == "--" { break }
if self.powershellInlineFlags.contains(token) {
return ExecInlineCommandParser.extractInlineCommand(
command,
flags: self.powershellInlineFlags,
allowCombinedC: false)
}
}
return nil
}
}

View File

@@ -0,0 +1,400 @@
import Foundation
enum ExecSystemRunCommandValidator {
struct ResolvedCommand {
let displayCommand: String
let evaluationRawCommand: String?
}
enum ValidationResult {
case ok(ResolvedCommand)
case invalid(message: String)
}
private static let shellWrapperNames = Set([
"ash",
"bash",
"cmd",
"dash",
"fish",
"ksh",
"powershell",
"pwsh",
"sh",
"zsh",
])
private static let posixOrPowerShellInlineWrapperNames = Set([
"ash",
"bash",
"dash",
"fish",
"ksh",
"powershell",
"pwsh",
"sh",
"zsh",
])
private static let shellMultiplexerWrapperNames = Set(["busybox", "toybox"])
private static let posixInlineCommandFlags = Set(["-lc", "-c", "--command"])
private static let powershellInlineCommandFlags = Set(["-c", "-command", "--command"])
private struct EnvUnwrapResult {
let argv: [String]
let usesModifiers: Bool
}
static func resolve(command: [String], rawCommand: String?) -> ValidationResult {
let normalizedRaw = self.normalizeRaw(rawCommand)
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
let shellCommand = shell.isWrapper ? self.trimmedNonEmpty(shell.command) : nil
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
let canonicalDisplay = ExecCommandFormatter.displayString(for: command)
let legacyShellDisplay: String? = if let shellCommand, !mustBindDisplayToFullArgv {
shellCommand
} else {
nil
}
if let raw = normalizedRaw {
let matchesCanonical = raw == canonicalDisplay
let matchesLegacyShellText = legacyShellDisplay == raw
if !matchesCanonical, !matchesLegacyShellText {
return .invalid(message: "INVALID_REQUEST: rawCommand does not match command")
}
}
return .ok(ResolvedCommand(
displayCommand: canonicalDisplay,
evaluationRawCommand: self.allowlistEvaluationRawCommand(
normalizedRaw: normalizedRaw,
shellIsWrapper: shell.isWrapper,
previewCommand: legacyShellDisplay)))
}
static func allowlistEvaluationRawCommand(command: [String], rawCommand: String?) -> String? {
let normalizedRaw = self.normalizeRaw(rawCommand)
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
let shellCommand = shell.isWrapper ? self.trimmedNonEmpty(shell.command) : nil
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
shellCommand
} else {
nil
}
return self.allowlistEvaluationRawCommand(
normalizedRaw: normalizedRaw,
shellIsWrapper: shell.isWrapper,
previewCommand: previewCommand)
}
private static func normalizeRaw(_ rawCommand: String?) -> String? {
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func trimmedNonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func allowlistEvaluationRawCommand(
normalizedRaw: String?,
shellIsWrapper: Bool,
previewCommand: String?) -> String?
{
guard shellIsWrapper else {
return normalizedRaw
}
guard let normalizedRaw else {
return nil
}
return normalizedRaw == previewCommand ? normalizedRaw : nil
}
private static func normalizeExecutableToken(_ token: String) -> String {
let base = ExecCommandToken.basenameLower(token)
if base.hasSuffix(".exe") {
return String(base.dropLast(4))
}
return base
}
private static func isEnvAssignment(_ token: String) -> Bool {
token.range(of: #"^[A-Za-z_][A-Za-z0-9_]*=.*"#, options: .regularExpression) != nil
}
private static func hasEnvInlineValuePrefix(_ lowerToken: String) -> Bool {
ExecEnvOptions.inlineValuePrefixes.contains { lowerToken.hasPrefix($0) }
}
private static func unwrapEnvInvocationWithMetadata(_ argv: [String]) -> EnvUnwrapResult? {
var idx = 1
var expectsOptionValue = false
var usesModifiers = false
while idx < argv.count {
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
idx += 1
continue
}
if expectsOptionValue {
expectsOptionValue = false
usesModifiers = true
idx += 1
continue
}
if token == "--" {
idx += 1
break
}
if token == "-" {
usesModifiers = true
idx += 1
break
}
if self.isEnvAssignment(token) {
usesModifiers = true
idx += 1
continue
}
if !token.hasPrefix("-") || token == "-" {
break
}
let lower = token.lowercased()
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
if ExecEnvOptions.flagOnly.contains(flag) {
usesModifiers = true
idx += 1
continue
}
if ExecEnvOptions.withValue.contains(flag) {
usesModifiers = true
if !lower.contains("=") {
expectsOptionValue = true
}
idx += 1
continue
}
if self.hasEnvInlineValuePrefix(lower) {
usesModifiers = true
idx += 1
continue
}
return nil
}
if expectsOptionValue {
return nil
}
guard idx < argv.count else {
return nil
}
return EnvUnwrapResult(argv: Array(argv[idx...]), usesModifiers: usesModifiers)
}
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
guard let token0 = self.trimmedNonEmpty(argv.first) else {
return nil
}
let wrapper = self.normalizeExecutableToken(token0)
guard self.shellMultiplexerWrapperNames.contains(wrapper) else {
return nil
}
var appletIndex = 1
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
appletIndex += 1
}
guard appletIndex < argv.count else {
return nil
}
let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines)
guard !applet.isEmpty else {
return nil
}
let normalizedApplet = self.normalizeExecutableToken(applet)
guard self.shellWrapperNames.contains(normalizedApplet) else {
return nil
}
return Array(argv[appletIndex...])
}
static func hasEnvManipulationBeforeShellWrapper(
_ argv: [String],
depth: Int = 0,
envManipulationSeen: Bool = false) -> Bool
{
if depth >= ExecEnvInvocationUnwrapper.maxWrapperDepth {
return false
}
guard let token0 = self.trimmedNonEmpty(argv.first) else {
return false
}
let normalized = self.normalizeExecutableToken(token0)
if normalized == "env" {
guard let envUnwrap = self.unwrapEnvInvocationWithMetadata(argv) else {
return false
}
return self.hasEnvManipulationBeforeShellWrapper(
envUnwrap.argv,
depth: depth + 1,
envManipulationSeen: envManipulationSeen || envUnwrap.usesModifiers)
}
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(argv) {
return self.hasEnvManipulationBeforeShellWrapper(
shellMultiplexer,
depth: depth + 1,
envManipulationSeen: envManipulationSeen)
}
guard self.shellWrapperNames.contains(normalized) else {
return false
}
guard self.extractShellInlinePayload(argv, normalizedWrapper: normalized) != nil else {
return false
}
return envManipulationSeen
}
private static func hasTrailingPositionalArgvAfterInlineCommand(_ argv: [String]) -> Bool {
let wrapperArgv = self.unwrapShellWrapperArgv(argv)
guard let token0 = self.trimmedNonEmpty(wrapperArgv.first) else {
return false
}
let wrapper = self.normalizeExecutableToken(token0)
guard self.posixOrPowerShellInlineWrapperNames.contains(wrapper) else {
return false
}
let inlineCommandIndex: Int? = if wrapper == "powershell" || wrapper == "pwsh" {
self.resolveInlineCommandTokenIndex(
wrapperArgv,
flags: self.powershellInlineCommandFlags,
allowCombinedC: false)
} else {
self.resolveInlineCommandTokenIndex(
wrapperArgv,
flags: self.posixInlineCommandFlags,
allowCombinedC: true)
}
guard let inlineCommandIndex else {
return false
}
let start = inlineCommandIndex + 1
guard start < wrapperArgv.count else {
return false
}
return wrapperArgv[start...].contains { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
}
private static func unwrapShellWrapperArgv(_ argv: [String]) -> [String] {
var current = argv
for _ in 0..<ExecEnvInvocationUnwrapper.maxWrapperDepth {
guard let token0 = self.trimmedNonEmpty(current.first) else {
break
}
let normalized = self.normalizeExecutableToken(token0)
if normalized == "env" {
guard let envUnwrap = self.unwrapEnvInvocationWithMetadata(current),
!envUnwrap.usesModifiers,
!envUnwrap.argv.isEmpty
else {
break
}
current = envUnwrap.argv
continue
}
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(current) {
current = shellMultiplexer
continue
}
break
}
return current
}
private static func findInlineCommandTokenMatch(
_ argv: [String],
flags: Set<String>,
allowCombinedC: Bool) -> ExecInlineCommandParser.Match?
{
ExecInlineCommandParser.findMatch(argv, flags: flags, allowCombinedC: allowCombinedC)
}
private static func resolveInlineCommandTokenIndex(
_ argv: [String],
flags: Set<String>,
allowCombinedC: Bool) -> Int?
{
guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
return nil
}
if match.inlineCommand != nil {
return match.tokenIndex
}
let nextIndex = match.tokenIndex + match.valueTokenOffset
return nextIndex < argv.count ? nextIndex : nil
}
private static func extractShellInlinePayload(
_ argv: [String],
normalizedWrapper: String) -> String?
{
if normalizedWrapper == "cmd" {
return self.extractCmdInlineCommand(argv)
}
if normalizedWrapper == "powershell" || normalizedWrapper == "pwsh" {
return self.extractInlineCommandByFlags(
argv,
flags: self.powershellInlineCommandFlags,
allowCombinedC: false)
}
return self.extractInlineCommandByFlags(
argv,
flags: self.posixInlineCommandFlags,
allowCombinedC: true)
}
private static func extractInlineCommandByFlags(
_ argv: [String],
flags: Set<String>,
allowCombinedC: Bool) -> String?
{
guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
return nil
}
if let inlineCommand = match.inlineCommand {
return inlineCommand
}
let nextIndex = match.tokenIndex + match.valueTokenOffset
return self.trimmedNonEmpty(nextIndex < argv.count ? argv[nextIndex] : nil)
}
private static func extractCmdInlineCommand(_ argv: [String]) -> String? {
guard let idx = argv.firstIndex(where: {
let token = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return token == "/c" || token == "/k"
}) else {
return nil
}
let tailIndex = idx + 1
guard tailIndex < argv.count else {
return nil
}
let payload = argv[tailIndex...].joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
return payload.isEmpty ? nil : payload
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
extension FileHandle {
/// Reads until EOF using the throwing FileHandle API and returns empty `Data` on failure.
///
/// Important: Avoid legacy, non-throwing FileHandle read APIs (e.g. `readDataToEndOfFile()` and
/// `availableData`). They can raise Objective-C exceptions when the handle is closed/invalid, which
/// will abort the process.
func readToEndSafely() -> Data {
do {
return try self.readToEnd() ?? Data()
} catch {
return Data()
}
}
/// Reads up to `count` bytes using the throwing FileHandle API and returns empty `Data` on failure/EOF.
///
/// Important: Use this instead of `availableData` in callbacks like `readabilityHandler` to avoid
/// Objective-C exceptions terminating the process.
func readSafely(upToCount count: Int) -> Data {
do {
return try self.read(upToCount: count) ?? Data()
} catch {
return Data()
}
}
}

View File

@@ -0,0 +1,14 @@
import Foundation
enum GatewayAutostartPolicy {
static func shouldStartGateway(mode: AppState.ConnectionMode, paused: Bool) -> Bool {
mode == .local && !paused
}
static func shouldEnsureLaunchAgent(
mode: AppState.ConnectionMode,
paused: Bool) -> Bool
{
self.shouldStartGateway(mode: mode, paused: paused)
}
}

View File

@@ -0,0 +1,845 @@
import Foundation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "ai.openclaw", category: "gateway.connection")
enum GatewayAgentChannel: String, Codable, CaseIterable {
case last
case whatsapp
case telegram
case discord
case googlechat
case slack
case signal
case imessage
case msteams
case webchat
init(raw: String?) {
let normalized = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self = GatewayAgentChannel(rawValue: normalized) ?? .last
}
var isDeliverable: Bool {
self != .webchat
}
func shouldDeliver(_ deliver: Bool) -> Bool {
deliver && self.isDeliverable
}
}
struct GatewayAgentInvocation {
var message: String
var sessionKey: String = "main"
var thinking: String?
var deliver: Bool = false
var to: String?
var channel: GatewayAgentChannel = .last
var timeoutSeconds: Int?
var idempotencyKey: String = UUID().uuidString
var voiceWakeTrigger: String?
}
/// Single, shared Gateway websocket connection for the whole app.
///
/// This owns exactly one `GatewayChannelActor` and reuses it across all callers
/// (ControlChannel, debug actions, SwiftUI WebChat, etc.).
actor GatewayConnection {
static let shared = GatewayConnection()
typealias Config = (url: URL, token: String?, password: String?)
enum Method: String {
case agent
case status
case setHeartbeats = "set-heartbeats"
case systemEvent = "system-event"
case health
case channelsStatus = "channels.status"
case configGet = "config.get"
case configSet = "config.set"
case configPatch = "config.patch"
case configSchema = "config.schema"
case configSchemaLookup = "config.schema.lookup"
case wizardStart = "wizard.start"
case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel"
case wizardStatus = "wizard.status"
case talkConfig = "talk.config"
case talkMode = "talk.mode"
case talkSpeak = "talk.speak"
case webLoginStart = "web.login.start"
case webLoginWait = "web.login.wait"
case channelsLogout = "channels.logout"
case modelsList = "models.list"
case chatHistory = "chat.history"
case sessionsPreview = "sessions.preview"
case chatSend = "chat.send"
case chatAbort = "chat.abort"
case skillsStatus = "skills.status"
case skillsInstall = "skills.install"
case skillsUpdate = "skills.update"
case voicewakeGet = "voicewake.get"
case voicewakeSet = "voicewake.set"
case nodePairApprove = "node.pair.approve"
case nodePairReject = "node.pair.reject"
case devicePairList = "device.pair.list"
case devicePairApprove = "device.pair.approve"
case devicePairReject = "device.pair.reject"
case execApprovalResolve = "exec.approval.resolve"
case cronList = "cron.list"
case cronRuns = "cron.runs"
case cronRun = "cron.run"
case cronRemove = "cron.remove"
case cronUpdate = "cron.update"
case cronAdd = "cron.add"
case cronStatus = "cron.status"
}
private let configProvider: @Sendable () async throws -> Config
private let sessionBox: WebSocketSessionBox?
private let decoder = JSONDecoder()
private var client: GatewayChannelActor?
private var configuredURL: URL?
private var configuredToken: String?
private var configuredPassword: String?
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
private var lastSnapshot: HelloOk?
private struct LossyDecodable<Value: Decodable>: Decodable {
let value: Value?
init(from decoder: Decoder) throws {
do {
self.value = try Value(from: decoder)
} catch {
self.value = nil
}
}
}
private struct LossyCronListResponse: Decodable {
let jobs: [LossyDecodable<CronJob>]
enum CodingKeys: String, CodingKey {
case jobs
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.jobs = try container.decodeIfPresent([LossyDecodable<CronJob>].self, forKey: .jobs) ?? []
}
}
private struct LossyCronRunsResponse: Decodable {
let entries: [LossyDecodable<CronRunLogEntry>]
enum CodingKeys: String, CodingKey {
case entries
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.entries = try container.decodeIfPresent([LossyDecodable<CronRunLogEntry>].self, forKey: .entries) ?? []
}
}
init(
configProvider: @escaping @Sendable () async throws -> Config = GatewayConnection.defaultConfigProvider,
sessionBox: WebSocketSessionBox? = nil)
{
self.configProvider = configProvider
self.sessionBox = sessionBox
}
// MARK: - Low-level request
func request(
method: String,
params: [String: AnyCodable]?,
timeoutMs: Double? = nil,
retryTransportFailures: Bool = true) async throws -> Data
{
let cfg = try await self.configProvider()
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
guard let client else {
throw NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
if !retryTransportFailures || error is GatewayResponseError || error is GatewayDecodingError {
throw error
}
// Auto-recover in local mode by spawning/attaching a gateway and retrying a few times.
// Canvas interactions should "just work" even if the local gateway isn't running yet.
let mode = await MainActor.run { AppStateStore.shared.connectionMode }
switch mode {
case .local:
await MainActor.run { GatewayProcessManager.shared.setActive(true) }
var lastError: Error = error
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
let nsError = lastError as NSError
if nsError.domain == URLError.errorDomain,
let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url)
{
await self.configure(url: fallback.url, token: fallback.token, password: fallback.password)
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
guard let client = self.client else {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
}
throw lastError
case .remote:
let nsError = error as NSError
guard nsError.domain == URLError.errorDomain else { throw error }
var lastError: Error = error
await RemoteTunnelManager.shared.stopAll()
do {
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
} catch {
lastError = error
}
for delayMs in [150, 400, 900] {
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
do {
let cfg = try await self.configProvider()
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
guard let client = self.client else {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
}
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
lastError = error
}
}
throw lastError
case .unconfigured:
throw error
}
}
}
func requestRaw(
method: Method,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws -> Data
{
try await self.request(method: method.rawValue, params: params, timeoutMs: timeoutMs)
}
func requestRaw(
method: String,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws -> Data
{
try await self.request(method: method, params: params, timeoutMs: timeoutMs)
}
func requestDecoded<T: Decodable>(
method: Method,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws -> T
{
let data = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs)
do {
return try self.decoder.decode(T.self, from: data)
} catch {
throw GatewayDecodingError(method: method.rawValue, message: error.localizedDescription)
}
}
func requestVoid(
method: Method,
params: [String: AnyCodable]? = nil,
timeoutMs: Double? = nil) async throws
{
_ = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs)
}
/// Ensure the underlying socket is configured (and replaced if config changed).
func refresh() async throws {
let cfg = try await self.configProvider()
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
}
func authSource() async -> GatewayAuthSource? {
guard let client else { return nil }
return await client.authSource()
}
func shutdown() async {
if let client {
await client.shutdown()
}
self.client = nil
self.configuredURL = nil
self.configuredToken = nil
self.lastSnapshot = nil
}
func canvasPluginSurfaceUrl() async -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let raw = snapshot.pluginsurfaceurls?["canvas"]?.value as? String
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func controlUiAutoAuthToken(config: Config) async -> String? {
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return token
}
if let deviceToken = self.lastSnapshot?.auth["deviceToken"]?.value as? String {
let trimmed = deviceToken.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
let identity = DeviceIdentityStore.loadOrCreate()
if let entry = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator") {
let trimmed = entry.token.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
return nil
}
private func sessionDefaultString(_ defaults: [String: OpenClawProtocol.AnyCodable]?, key: String) -> String {
let raw = defaults?[key]?.value as? String
return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
}
func cachedMainSessionKey() -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey")
return trimmed.isEmpty ? nil : trimmed
}
func cachedGatewayVersion() -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let raw = snapshot.server["version"]?.value as? String
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
return (
configPath?.isEmpty == false ? configPath : nil,
stateDir?.isEmpty == false ? stateDir : nil)
}
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
let id = UUID()
let snapshot = self.lastSnapshot
let connection = self
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
if let snapshot {
continuation.yield(.snapshot(snapshot))
}
self.subscribers[id] = continuation
continuation.onTermination = { @Sendable _ in
Task { await connection.removeSubscriber(id) }
}
}
}
private func removeSubscriber(_ id: UUID) {
self.subscribers[id] = nil
}
private func broadcast(_ push: GatewayPush) {
if case let .snapshot(snapshot) = push {
self.lastSnapshot = snapshot
if let mainSessionKey = self.cachedMainSessionKey() {
Task { @MainActor in
WorkActivityStore.shared.setMainSessionKey(mainSessionKey)
}
}
}
for (_, continuation) in self.subscribers {
continuation.yield(push)
}
}
private func canonicalizeSessionKey(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
guard let defaults = self.lastSnapshot?.snapshot.sessiondefaults else { return trimmed }
let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey")
guard !mainSessionKey.isEmpty else { return trimmed }
let mainKey = self.sessionDefaultString(defaults, key: "mainKey")
let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId")
let isMainAlias =
trimmed == "main" ||
(!mainKey.isEmpty && trimmed == mainKey) ||
trimmed == mainSessionKey ||
(!defaultAgentId.isEmpty &&
(trimmed == "agent:\(defaultAgentId):main" ||
(mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)")))
return isMainAlias ? mainSessionKey : trimmed
}
private func configure(url: URL, token: String?, password: String?) async {
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
self.configuredPassword == password
{
return
}
if let client {
await client.shutdown()
}
self.lastSnapshot = nil
self.client = GatewayChannelActor(
url: url,
token: token,
password: password,
session: self.sessionBox,
pushHandler: { [weak self] push in
await self?.handle(push: push)
})
self.configuredURL = url
self.configuredToken = token
self.configuredPassword = password
}
private func handle(push: GatewayPush) {
self.broadcast(push)
}
private static func defaultConfigProvider() async throws -> Config {
try await GatewayEndpointStore.shared.requireConfig()
}
}
// MARK: - Typed gateway API
extension GatewayConnection {
struct ConfigGetSnapshot: Decodable {
struct SnapshotConfig: Decodable {
struct Session: Decodable {
let mainKey: String?
let scope: String?
}
let session: Session?
}
let config: SnapshotConfig?
}
static func mainSessionKey(fromConfigGetData data: Data) throws -> String {
let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data)
let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines)
if scope == "global" {
return "global"
}
return "main"
}
func mainSessionKey(timeoutMs: Double = 15000) async -> String {
if let cached = self.cachedMainSessionKey() {
return cached
}
do {
let data = try await self.requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs)
return try Self.mainSessionKey(fromConfigGetData: data)
} catch {
return "main"
}
}
func status() async -> (ok: Bool, error: String?) {
do {
_ = try await self.requestRaw(method: .status)
return (true, nil)
} catch {
return (false, error.localizedDescription)
}
}
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
do {
try await self.requestVoid(method: .setHeartbeats, params: ["enabled": AnyCodable(enabled)])
return true
} catch {
gatewayConnectionLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)")
return false
}
}
func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) {
let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return (false, "message empty") }
let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey)
var params: [String: AnyCodable] = [
"message": AnyCodable(trimmed),
"sessionKey": AnyCodable(sessionKey),
"deliver": AnyCodable(invocation.deliver),
"to": AnyCodable(invocation.to ?? ""),
"channel": AnyCodable(invocation.channel.rawValue),
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
]
if let thinking = invocation.thinking?.trimmingCharacters(in: .whitespacesAndNewlines),
!thinking.isEmpty
{
params["thinking"] = AnyCodable(thinking)
}
if let timeout = invocation.timeoutSeconds {
params["timeout"] = AnyCodable(timeout)
}
if let trigger = invocation.voiceWakeTrigger {
params["voiceWakeTrigger"] = AnyCodable(
trigger.trimmingCharacters(in: .whitespacesAndNewlines))
}
do {
try await self.requestVoid(method: .agent, params: params)
return (true, nil)
} catch {
return (false, error.localizedDescription)
}
}
func sendAgent(
message: String,
thinking: String?,
sessionKey: String,
deliver: Bool,
to: String?,
channel: GatewayAgentChannel = .last,
timeoutSeconds: Int? = nil,
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
{
await self.sendAgent(GatewayAgentInvocation(
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: deliver,
to: to,
channel: channel,
timeoutSeconds: timeoutSeconds,
idempotencyKey: idempotencyKey))
}
func sendSystemEvent(_ params: [String: AnyCodable]) async {
do {
try await self.requestVoid(method: .systemEvent, params: params)
} catch {
// Best-effort only.
}
}
// MARK: - Health
func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot {
let data = try await self.requestRaw(method: .health, timeoutMs: timeoutMs)
if let snap = decodeHealthSnapshot(from: data) { return snap }
throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot")
}
func healthOK(timeoutMs: Int = 8000) async throws -> Bool {
let data = try await self.requestRaw(method: .health, timeoutMs: Double(timeoutMs))
return (try? self.decoder.decode(OpenClawGatewayHealthOK.self, from: data))?.ok ?? true
}
// MARK: - Skills
func skillsStatus() async throws -> SkillsStatusReport {
try await self.requestDecoded(method: .skillsStatus)
}
func skillsInstall(
name: String,
installId: String,
dangerouslyForceUnsafeInstall: Bool? = nil,
timeoutMs: Int? = nil) async throws -> SkillInstallResult
{
var params: [String: AnyCodable] = [
"name": AnyCodable(name),
"installId": AnyCodable(installId),
]
if let dangerouslyForceUnsafeInstall {
params["dangerouslyForceUnsafeInstall"] = AnyCodable(dangerouslyForceUnsafeInstall)
}
if let timeoutMs {
params["timeoutMs"] = AnyCodable(timeoutMs)
}
return try await self.requestDecoded(method: .skillsInstall, params: params)
}
func skillsUpdate(
skillKey: String,
enabled: Bool? = nil,
apiKey: String? = nil,
env: [String: String]? = nil) async throws -> SkillUpdateResult
{
var params: [String: AnyCodable] = [
"skillKey": AnyCodable(skillKey),
]
if let enabled { params["enabled"] = AnyCodable(enabled) }
if let apiKey { params["apiKey"] = AnyCodable(apiKey) }
if let env, !env.isEmpty { params["env"] = AnyCodable(env) }
return try await self.requestDecoded(method: .skillsUpdate, params: params)
}
// MARK: - Sessions
func sessionsPreview(
keys: [String],
limit: Int? = nil,
maxChars: Int? = nil,
timeoutMs: Int? = nil) async throws -> OpenClawSessionsPreviewPayload
{
let resolvedKeys = keys
.map { self.canonicalizeSessionKey($0) }
.filter { !$0.isEmpty }
if resolvedKeys.isEmpty {
return OpenClawSessionsPreviewPayload(ts: 0, previews: [])
}
var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)]
if let limit { params["limit"] = AnyCodable(limit) }
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
let timeout = timeoutMs.map { Double($0) }
return try await self.requestDecoded(
method: .sessionsPreview,
params: params,
timeoutMs: timeout)
}
// MARK: - Chat
func chatHistory(
sessionKey: String,
limit: Int? = nil,
maxChars: Int? = nil,
timeoutMs: Int? = nil) async throws -> OpenClawChatHistoryPayload
{
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)]
if let limit { params["limit"] = AnyCodable(limit) }
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
let timeout = timeoutMs.map { Double($0) }
return try await self.requestDecoded(
method: .chatHistory,
params: params,
timeoutMs: timeout)
}
func chatSend(
sessionKey: String,
message: String,
thinking: String?,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload],
timeoutMs: Int = 30000) async throws -> OpenClawChatSendResponse
{
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
var params: [String: AnyCodable] = [
"sessionKey": AnyCodable(resolvedKey),
"message": AnyCodable(message),
"idempotencyKey": AnyCodable(idempotencyKey),
"timeoutMs": AnyCodable(timeoutMs),
]
if let thinking = thinking?.trimmingCharacters(in: .whitespacesAndNewlines),
!thinking.isEmpty
{
params["thinking"] = AnyCodable(thinking)
}
if !attachments.isEmpty {
let encoded = attachments.map { att in
[
"type": att.type,
"mimeType": att.mimeType,
"fileName": att.fileName,
"content": att.content,
]
}
params["attachments"] = AnyCodable(encoded)
}
return try await self.requestDecoded(
method: .chatSend,
params: params,
timeoutMs: Double(timeoutMs))
}
func chatAbort(sessionKey: String, runId: String) async throws -> Bool {
let resolvedKey = self.canonicalizeSessionKey(sessionKey)
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
let res: AbortResponse = try await self.requestDecoded(
method: .chatAbort,
params: ["sessionKey": AnyCodable(resolvedKey), "runId": AnyCodable(runId)])
return res.aborted ?? false
}
func talkMode(enabled: Bool, phase: String? = nil) async {
var params: [String: AnyCodable] = ["enabled": AnyCodable(enabled)]
if let phase { params["phase"] = AnyCodable(phase) }
try? await self.requestVoid(method: .talkMode, params: params)
}
// MARK: - VoiceWake
func voiceWakeGetTriggers() async throws -> [String] {
struct VoiceWakePayload: Decodable { let triggers: [String] }
let payload: VoiceWakePayload = try await self.requestDecoded(method: .voicewakeGet)
return payload.triggers
}
func voiceWakeSetTriggers(_ triggers: [String]) async {
do {
try await self.requestVoid(
method: .voicewakeSet,
params: ["triggers": AnyCodable(triggers)],
timeoutMs: 10000)
} catch {
// Best-effort only.
}
}
// MARK: - Node pairing
func nodePairApprove(requestId: String) async throws {
try await self.requestVoid(
method: .nodePairApprove,
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
}
func nodePairReject(requestId: String) async throws {
try await self.requestVoid(
method: .nodePairReject,
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
}
// MARK: - Device pairing
func devicePairApprove(requestId: String) async throws {
try await self.requestVoid(
method: .devicePairApprove,
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
}
func devicePairReject(requestId: String) async throws {
try await self.requestVoid(
method: .devicePairReject,
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
}
// MARK: - Cron
struct CronSchedulerStatus: Decodable {
let enabled: Bool
let storePath: String
let sqlitePath: String?
let jobs: Int
let nextWakeAtMs: Int?
}
func cronStatus() async throws -> CronSchedulerStatus {
try await self.requestDecoded(method: .cronStatus)
}
func cronList(includeDisabled: Bool = true) async throws -> [CronJob] {
let data = try await self.requestRaw(
method: .cronList,
params: ["includeDisabled": AnyCodable(includeDisabled)])
return try Self.decodeCronListResponse(data)
}
func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] {
let data = try await self.requestRaw(
method: .cronRuns,
params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)])
return try Self.decodeCronRunsResponse(data)
}
func cronRun(jobId: String, force: Bool = true) async throws {
try await self.requestVoid(
method: .cronRun,
params: [
"id": AnyCodable(jobId),
"mode": AnyCodable(force ? "force" : "due"),
],
timeoutMs: 20000)
}
func cronRemove(jobId: String) async throws {
try await self.requestVoid(method: .cronRemove, params: ["id": AnyCodable(jobId)])
}
func cronUpdate(jobId: String, patch: [String: AnyCodable]) async throws {
try await self.requestVoid(
method: .cronUpdate,
params: ["id": AnyCodable(jobId), "patch": AnyCodable(patch)])
}
func cronAdd(payload: [String: AnyCodable]) async throws {
try await self.requestVoid(method: .cronAdd, params: payload)
}
nonisolated static func decodeCronListResponse(_ data: Data) throws -> [CronJob] {
let decoded = try JSONDecoder().decode(LossyCronListResponse.self, from: data)
let jobs = decoded.jobs.compactMap(\.value)
let skipped = decoded.jobs.count - jobs.count
if skipped > 0 {
gatewayConnectionLogger.warning("cron.list skipped \(skipped, privacy: .public) malformed jobs")
}
return jobs
}
nonisolated static func decodeCronRunsResponse(_ data: Data) throws -> [CronRunLogEntry] {
let decoded = try JSONDecoder().decode(LossyCronRunsResponse.self, from: data)
let entries = decoded.entries.compactMap(\.value)
let skipped = decoded.entries.count - entries.count
if skipped > 0 {
gatewayConnectionLogger.warning("cron.runs skipped \(skipped, privacy: .public) malformed entries")
}
return entries
}
}

View File

@@ -0,0 +1,63 @@
import Foundation
import Observation
import OSLog
@MainActor
@Observable
final class GatewayConnectivityCoordinator {
static let shared = GatewayConnectivityCoordinator()
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway.connectivity")
private var endpointTask: Task<Void, Never>?
private var lastResolvedURL: URL?
private(set) var endpointState: GatewayEndpointState?
private(set) var resolvedURL: URL?
private(set) var resolvedMode: AppState.ConnectionMode?
private(set) var resolvedHostLabel: String?
private init() {
self.start()
}
func start() {
guard self.endpointTask == nil else { return }
self.endpointTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayEndpointStore.shared.subscribe()
for await state in stream {
await MainActor.run { self.handleEndpointState(state) }
}
}
}
var localEndpointHostLabel: String? {
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
return Self.hostLabel(for: url)
}
private func handleEndpointState(_ state: GatewayEndpointState) {
self.endpointState = state
switch state {
case let .ready(mode, url, _, _):
self.resolvedMode = mode
self.resolvedURL = url
self.resolvedHostLabel = Self.hostLabel(for: url)
let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString
if urlChanged {
self.lastResolvedURL = url
Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") }
}
case let .connecting(mode, _):
self.resolvedMode = mode
case let .unavailable(mode, _):
self.resolvedMode = mode
}
}
private static func hostLabel(for url: URL) -> String {
let host = url.host ?? url.absoluteString
if let port = url.port { return "\(host):\(port)" }
return host
}
}

View File

@@ -0,0 +1,87 @@
import Foundation
import OpenClawDiscovery
enum GatewayDiscoveryHelpers {
static func resolvedServiceHost(
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String?
{
self.resolvedServiceHost(gateway.serviceHost)
}
static func resolvedServiceHost(_ host: String?) -> String? {
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
return host
}
static func serviceEndpoint(
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (host: String, port: Int)?
{
self.serviceEndpoint(serviceHost: gateway.serviceHost, servicePort: gateway.servicePort)
}
static func serviceEndpoint(
serviceHost: String?,
servicePort: Int?) -> (host: String, port: Int)?
{
guard let host = self.resolvedServiceHost(serviceHost) else { return nil }
guard let port = servicePort, port > 0, port <= 65535 else { return nil }
return (host, port)
}
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
guard let host = self.resolvedServiceHost(for: gateway) else { return nil }
let user = NSUserName()
var target = "\(user)@\(host)"
if gateway.sshPort != 22 {
target += ":\(gateway.sshPort)"
}
return target
}
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
self.directGatewayUrl(
serviceHost: gateway.serviceHost,
servicePort: gateway.servicePort,
gatewayTls: gateway.gatewayTls)
}
static func directGatewayUrl(
serviceHost: String?,
servicePort: Int?,
gatewayTls: Bool = false) -> String?
{
// Security: do not route using unauthenticated TXT hints (tailnetDns/lanHost/gatewayPort).
// Prefer the resolved service endpoint (SRV + A/AAAA).
guard let endpoint = self.serviceEndpoint(serviceHost: serviceHost, servicePort: servicePort) else {
return nil
}
let scheme: String
if gatewayTls {
scheme = "wss"
} else if self.isLoopbackHost(endpoint.host)
|| GatewayRemoteConfig.isTrustedPlaintextRemoteHost(endpoint.host)
{
scheme = "ws"
} else {
return nil
}
let portSuffix = scheme == "wss" && endpoint.port == 443 ? "" : ":\(endpoint.port)"
return "\(scheme)://\(endpoint.host)\(portSuffix)"
}
private static func trimmed(_ value: String?) -> String? {
value?.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func isLoopbackHost(_ rawHost: String) -> Bool {
let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !host.isEmpty else { return false }
if host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" {
return true
}
if host.hasPrefix("::ffff:127.") {
return true
}
return host.hasPrefix("127.")
}
}

Some files were not shown because too many files have changed in this diff Show More