mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-30 07:01:02 +03:00
chore: rename project to clawdbot
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AboutSettings: View {
|
||||
weak var updater: UpdaterProviding?
|
||||
@State private var iconHover = false
|
||||
@AppStorage("autoUpdateEnabled") private var autoCheckEnabled = true
|
||||
@State private var didLoadUpdaterState = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
let appIcon = NSApplication.shared.applicationIconImage ?? CritterIconRenderer.makeIcon(blink: 0)
|
||||
Button {
|
||||
if let url = URL(string: "https://github.com/clawdbot/clawdbot") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Image(nsImage: appIcon)
|
||||
.resizable()
|
||||
.frame(width: 160, height: 160)
|
||||
.cornerRadius(24)
|
||||
.shadow(color: self.iconHover ? .accentColor.opacity(0.25) : .clear, radius: 10)
|
||||
.scaleEffect(self.iconHover ? 1.05 : 1.0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
.pointingHandCursor()
|
||||
.onHover { hover in
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) { self.iconHover = hover }
|
||||
}
|
||||
|
||||
VStack(spacing: 3) {
|
||||
Text("Clawdbot")
|
||||
.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/clawdbot/clawdbot")
|
||||
AboutLinkRow(icon: "globe", title: "Website", url: "https://steipete.me")
|
||||
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("© 2025 Peter Steinberger — MIT License.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 4)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.top, 4)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
.onAppear {
|
||||
guard let updater, !self.didLoadUpdaterState else { return }
|
||||
// Keep Sparkle’s 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: "ClawdbotBuildTimestamp") 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: "ClawdbotGitCommit") 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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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: 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": AnyCodable("start"), "name": AnyCodable("bash")],
|
||||
summary: nil)
|
||||
AgentEventStore.shared.append(sample)
|
||||
return AgentEventsWindow()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum AgentWorkspace {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", 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,
|
||||
]
|
||||
enum BootstrapSafety: Equatable {
|
||||
case safe
|
||||
case unsafe(reason: String)
|
||||
}
|
||||
|
||||
static func displayPath(for url: URL) -> String {
|
||||
let home = FileManager.default.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 ClawdbotConfigFile.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.default.contentsOfDirectory(atPath: workspaceURL.path)
|
||||
return contents.filter { !self.ignoredEntries.contains($0) }
|
||||
}
|
||||
|
||||
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
|
||||
let fm = FileManager.default
|
||||
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.default
|
||||
var isDir: ObjCBool = false
|
||||
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
||||
return .safe
|
||||
}
|
||||
if !isDir.boolValue {
|
||||
return .unsafe(reason: "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
|
||||
: .unsafe(reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.")
|
||||
} catch {
|
||||
return .unsafe(reason: "Couldn't inspect the workspace folder.")
|
||||
}
|
||||
}
|
||||
|
||||
static func bootstrap(workspaceURL: URL) throws -> URL {
|
||||
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
|
||||
try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
|
||||
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
||||
if !FileManager.default.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.default.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.default.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.default.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.default.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.default
|
||||
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 - Clawdbot 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) ~/.clawdbot/clawdbot.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.default.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.
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct AnthropicAuthControls: View {
|
||||
let connectionMode: AppState.ConnectionMode
|
||||
|
||||
@State private var oauthStatus: ClawdbotOAuthStore.AnthropicOAuthStatus = ClawdbotOAuthStore.anthropicOAuthStatus()
|
||||
@State private var pkce: AnthropicOAuth.PKCE?
|
||||
@State private var code: String = ""
|
||||
@State private var busy = false
|
||||
@State private var statusText: String?
|
||||
@State private var autoDetectClipboard = true
|
||||
@State private var autoConnectClipboard = true
|
||||
@State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount
|
||||
|
||||
private static let clipboardPoll: AnyPublisher<Date, Never> = {
|
||||
if ProcessInfo.processInfo.isRunningTests {
|
||||
return Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||
}
|
||||
return Timer.publish(every: 0.4, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.eraseToAnyPublisher()
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.connectionMode != .local {
|
||||
Text("Gateway isn’t running locally; OAuth must be created on the gateway host.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.oauthStatus.isConnected ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(self.oauthStatus.shortDescription)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!FileManager.default.fileExists(atPath: ClawdbotOAuthStore.oauthURL().path))
|
||||
|
||||
Button("Refresh") {
|
||||
self.refresh()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Text(ClawdbotOAuthStore.oauthURL().path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
self.startOAuth()
|
||||
} label: {
|
||||
if self.busy {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.connectionMode != .local || self.busy)
|
||||
|
||||
if self.pkce != nil {
|
||||
Button("Cancel") {
|
||||
self.pkce = nil
|
||||
self.code = ""
|
||||
self.statusText = nil
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.busy)
|
||||
}
|
||||
}
|
||||
|
||||
if self.pkce != nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paste `code#state`")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("code#state", text: self.$code)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.busy)
|
||||
|
||||
Toggle("Auto-detect from clipboard", isOn: self.$autoDetectClipboard)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(self.busy)
|
||||
|
||||
Toggle("Auto-connect when detected", isOn: self.$autoConnectClipboard)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(self.busy)
|
||||
|
||||
Button("Connect") {
|
||||
Task { await self.finishOAuth() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.busy || self.connectionMode != .local || self.code
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
if let statusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.refresh()
|
||||
}
|
||||
.onReceive(Self.clipboardPoll) { _ in
|
||||
self.pollClipboardIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
let imported = ClawdbotOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
||||
self.oauthStatus = ClawdbotOAuthStore.anthropicOAuthStatus()
|
||||
if imported != nil {
|
||||
self.statusText = "Imported existing OAuth credentials."
|
||||
}
|
||||
}
|
||||
|
||||
private func startOAuth() {
|
||||
guard self.connectionMode == .local else { return }
|
||||
guard !self.busy else { return }
|
||||
self.busy = true
|
||||
defer { self.busy = false }
|
||||
|
||||
do {
|
||||
let pkce = try AnthropicOAuth.generatePKCE()
|
||||
self.pkce = pkce
|
||||
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
|
||||
NSWorkspace.shared.open(url)
|
||||
self.statusText = "Browser opened. After approving, paste the `code#state` value here."
|
||||
} catch {
|
||||
self.statusText = "Failed to start OAuth: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func finishOAuth() async {
|
||||
guard self.connectionMode == .local else { return }
|
||||
guard !self.busy else { return }
|
||||
guard let pkce = self.pkce else { return }
|
||||
self.busy = true
|
||||
defer { self.busy = false }
|
||||
|
||||
guard let parsed = AnthropicOAuthCodeState.parse(from: self.code) else {
|
||||
self.statusText = "OAuth failed: missing or invalid code/state."
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let creds = try await AnthropicOAuth.exchangeCode(
|
||||
code: parsed.code,
|
||||
state: parsed.state,
|
||||
verifier: pkce.verifier)
|
||||
try ClawdbotOAuthStore.saveAnthropicOAuth(creds)
|
||||
self.refresh()
|
||||
self.pkce = nil
|
||||
self.code = ""
|
||||
self.statusText = "Connected. Clawdbot can now use Claude via OAuth."
|
||||
} catch {
|
||||
self.statusText = "OAuth failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func pollClipboardIfNeeded() {
|
||||
guard self.connectionMode == .local else { return }
|
||||
guard self.pkce != nil else { return }
|
||||
guard !self.busy else { return }
|
||||
guard self.autoDetectClipboard else { return }
|
||||
|
||||
let pb = NSPasteboard.general
|
||||
let changeCount = pb.changeCount
|
||||
guard changeCount != self.lastPasteboardChangeCount else { return }
|
||||
self.lastPasteboardChangeCount = changeCount
|
||||
|
||||
guard let raw = pb.string(forType: .string), !raw.isEmpty else { return }
|
||||
guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return }
|
||||
guard let pkce = self.pkce, parsed.state == pkce.verifier else { return }
|
||||
|
||||
let next = "\(parsed.code)#\(parsed.state)"
|
||||
if self.code != next {
|
||||
self.code = next
|
||||
self.statusText = "Detected `code#state` from clipboard."
|
||||
}
|
||||
|
||||
guard self.autoConnectClipboard else { return }
|
||||
Task { await self.finishOAuth() }
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension AnthropicAuthControls {
|
||||
init(
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
oauthStatus: ClawdbotOAuthStore.AnthropicOAuthStatus,
|
||||
pkce: AnthropicOAuth.PKCE? = nil,
|
||||
code: String = "",
|
||||
busy: Bool = false,
|
||||
statusText: String? = nil,
|
||||
autoDetectClipboard: Bool = true,
|
||||
autoConnectClipboard: Bool = true)
|
||||
{
|
||||
self.connectionMode = connectionMode
|
||||
self._oauthStatus = State(initialValue: oauthStatus)
|
||||
self._pkce = State(initialValue: pkce)
|
||||
self._code = State(initialValue: code)
|
||||
self._busy = State(initialValue: busy)
|
||||
self._statusText = State(initialValue: statusText)
|
||||
self._autoDetectClipboard = State(initialValue: autoDetectClipboard)
|
||||
self._autoConnectClipboard = State(initialValue: autoConnectClipboard)
|
||||
self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,384 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
|
||||
struct AnthropicOAuthCredentials: Codable {
|
||||
let type: String
|
||||
let refresh: String
|
||||
let access: String
|
||||
let expires: Int64
|
||||
}
|
||||
|
||||
enum AnthropicAuthMode: Equatable {
|
||||
case oauthFile
|
||||
case oauthEnv
|
||||
case apiKeyEnv
|
||||
case missing
|
||||
|
||||
var shortLabel: String {
|
||||
switch self {
|
||||
case .oauthFile: "OAuth (Clawdbot token file)"
|
||||
case .oauthEnv: "OAuth (env var)"
|
||||
case .apiKeyEnv: "API key (env var)"
|
||||
case .missing: "Missing credentials"
|
||||
}
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
switch self {
|
||||
case .missing: false
|
||||
case .oauthFile, .oauthEnv, .apiKeyEnv: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AnthropicAuthResolver {
|
||||
static func resolve(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
oauthStatus: ClawdbotOAuthStore.AnthropicOAuthStatus = ClawdbotOAuthStore
|
||||
.anthropicOAuthStatus()) -> AnthropicAuthMode
|
||||
{
|
||||
if oauthStatus.isConnected { return .oauthFile }
|
||||
|
||||
if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
{
|
||||
return .oauthEnv
|
||||
}
|
||||
|
||||
if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!key.isEmpty
|
||||
{
|
||||
return .apiKeyEnv
|
||||
}
|
||||
|
||||
return .missing
|
||||
}
|
||||
}
|
||||
|
||||
enum AnthropicOAuth {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "anthropic-oauth")
|
||||
|
||||
private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")!
|
||||
private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")!
|
||||
private static let redirectURI = "https://console.anthropic.com/oauth/code/callback"
|
||||
private static let scopes = "org:create_api_key user:profile user:inference"
|
||||
|
||||
struct PKCE {
|
||||
let verifier: String
|
||||
let challenge: String
|
||||
}
|
||||
|
||||
static func generatePKCE() throws -> PKCE {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
guard status == errSecSuccess else {
|
||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||
}
|
||||
let verifier = Data(bytes).base64URLEncodedString()
|
||||
let hash = SHA256.hash(data: Data(verifier.utf8))
|
||||
let challenge = Data(hash).base64URLEncodedString()
|
||||
return PKCE(verifier: verifier, challenge: challenge)
|
||||
}
|
||||
|
||||
static func buildAuthorizeURL(pkce: PKCE) -> URL {
|
||||
var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "code", value: "true"),
|
||||
URLQueryItem(name: "client_id", value: self.clientId),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "redirect_uri", value: self.redirectURI),
|
||||
URLQueryItem(name: "scope", value: self.scopes),
|
||||
URLQueryItem(name: "code_challenge", value: pkce.challenge),
|
||||
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||||
// Match legacy flow: state is the verifier.
|
||||
URLQueryItem(name: "state", value: pkce.verifier),
|
||||
]
|
||||
return components.url!
|
||||
}
|
||||
|
||||
static func exchangeCode(
|
||||
code: String,
|
||||
state: String,
|
||||
verifier: String) async throws -> AnthropicOAuthCredentials
|
||||
{
|
||||
let payload: [String: Any] = [
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.clientId,
|
||||
"code": code,
|
||||
"state": state,
|
||||
"redirect_uri": self.redirectURI,
|
||||
"code_verifier": verifier,
|
||||
]
|
||||
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
|
||||
|
||||
var request = URLRequest(url: self.tokenURL)
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
|
||||
throw NSError(
|
||||
domain: "AnthropicOAuth",
|
||||
code: http.statusCode,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"])
|
||||
}
|
||||
|
||||
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
let access = decoded?["access_token"] as? String
|
||||
let refresh = decoded?["refresh_token"] as? String
|
||||
let expiresIn = decoded?["expires_in"] as? Double
|
||||
guard let access, let refresh, let expiresIn else {
|
||||
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected token response.",
|
||||
])
|
||||
}
|
||||
|
||||
// Match legacy flow: expiresAt = now + expires_in - 5 minutes.
|
||||
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
+ Int64(expiresIn * 1000)
|
||||
- Int64(5 * 60 * 1000)
|
||||
|
||||
self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
||||
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
||||
}
|
||||
|
||||
static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials {
|
||||
let payload: [String: Any] = [
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.clientId,
|
||||
"refresh_token": refreshToken,
|
||||
]
|
||||
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
|
||||
|
||||
var request = URLRequest(url: self.tokenURL)
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
|
||||
throw NSError(
|
||||
domain: "AnthropicOAuth",
|
||||
code: http.statusCode,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"])
|
||||
}
|
||||
|
||||
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
let access = decoded?["access_token"] as? String
|
||||
let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken
|
||||
let expiresIn = decoded?["expires_in"] as? Double
|
||||
guard let access, let expiresIn else {
|
||||
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected token response.",
|
||||
])
|
||||
}
|
||||
|
||||
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
+ Int64(expiresIn * 1000)
|
||||
- Int64(5 * 60 * 1000)
|
||||
|
||||
self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
||||
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
||||
}
|
||||
}
|
||||
|
||||
enum ClawdbotOAuthStore {
|
||||
static let oauthFilename = "oauth.json"
|
||||
private static let providerKey = "anthropic"
|
||||
private static let clawdbotOAuthDirEnv = "CLAWDBOT_OAUTH_DIR"
|
||||
private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
|
||||
|
||||
enum AnthropicOAuthStatus: Equatable {
|
||||
case missingFile
|
||||
case unreadableFile
|
||||
case invalidJSON
|
||||
case missingProviderEntry
|
||||
case missingTokens
|
||||
case connected(expiresAtMs: Int64?)
|
||||
|
||||
var isConnected: Bool {
|
||||
if case .connected = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var shortDescription: String {
|
||||
switch self {
|
||||
case .missingFile: "Clawdbot OAuth token file not found"
|
||||
case .unreadableFile: "Clawdbot OAuth token file not readable"
|
||||
case .invalidJSON: "Clawdbot OAuth token file invalid"
|
||||
case .missingProviderEntry: "No Anthropic entry in Clawdbot OAuth token file"
|
||||
case .missingTokens: "Anthropic entry missing tokens"
|
||||
case .connected: "Clawdbot OAuth credentials found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func oauthDir() -> URL {
|
||||
if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!override.isEmpty
|
||||
{
|
||||
let expanded = NSString(string: override).expandingTildeInPath
|
||||
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||
}
|
||||
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot", isDirectory: true)
|
||||
.appendingPathComponent("credentials", isDirectory: true)
|
||||
}
|
||||
|
||||
static func oauthURL() -> URL {
|
||||
self.oauthDir().appendingPathComponent(self.oauthFilename)
|
||||
}
|
||||
|
||||
static func legacyOAuthURLs() -> [URL] {
|
||||
var urls: [URL] = []
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!override.isEmpty
|
||||
{
|
||||
let expanded = NSString(string: override).expandingTildeInPath
|
||||
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
|
||||
}
|
||||
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)"))
|
||||
|
||||
var seen = Set<String>()
|
||||
return urls.filter { url in
|
||||
let path = url.standardizedFileURL.path
|
||||
if seen.contains(path) { return false }
|
||||
seen.insert(path)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
|
||||
let dest = self.oauthURL()
|
||||
guard !FileManager.default.fileExists(atPath: dest.path) else { return nil }
|
||||
|
||||
for url in self.legacyOAuthURLs() {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { continue }
|
||||
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
|
||||
guard let storage = self.loadStorage(at: url) else { continue }
|
||||
do {
|
||||
try self.saveStorage(storage)
|
||||
return url
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
|
||||
self.anthropicOAuthStatus(at: self.oauthURL())
|
||||
}
|
||||
|
||||
static func hasAnthropicOAuth() -> Bool {
|
||||
self.anthropicOAuthStatus().isConnected
|
||||
}
|
||||
|
||||
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return .missingFile }
|
||||
|
||||
guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
|
||||
guard let storage = json as? [String: Any] else { return .invalidJSON }
|
||||
guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry }
|
||||
guard let entry = rawEntry as? [String: Any] else { return .invalidJSON }
|
||||
|
||||
let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"])
|
||||
let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"])
|
||||
guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens }
|
||||
|
||||
let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"]
|
||||
let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 {
|
||||
ms
|
||||
} else if let number = expiresAny as? NSNumber {
|
||||
number.int64Value
|
||||
} else if let ms = expiresAny as? Double {
|
||||
Int64(ms)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
return .connected(expiresAtMs: expiresAtMs)
|
||||
}
|
||||
|
||||
static func loadAnthropicOAuthRefreshToken() -> String? {
|
||||
let url = self.oauthURL()
|
||||
guard let storage = self.loadStorage(at: url) else { return nil }
|
||||
guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil }
|
||||
let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"])
|
||||
return refresh?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
if let value = dict[key] as? String { return value }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func loadStorage(at url: URL) -> [String: Any]? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
|
||||
return json as? [String: Any]
|
||||
}
|
||||
|
||||
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
|
||||
let url = self.oauthURL()
|
||||
let existing: [String: Any] = self.loadStorage(at: url) ?? [:]
|
||||
|
||||
var updated = existing
|
||||
updated[self.providerKey] = [
|
||||
"type": creds.type,
|
||||
"refresh": creds.refresh,
|
||||
"access": creds.access,
|
||||
"expires": creds.expires,
|
||||
]
|
||||
|
||||
try self.saveStorage(updated)
|
||||
}
|
||||
|
||||
private static func saveStorage(_ storage: [String: Any]) throws {
|
||||
let dir = self.oauthDir()
|
||||
try FileManager.default.createDirectory(
|
||||
at: dir,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700])
|
||||
|
||||
let url = self.oauthURL()
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: storage,
|
||||
options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
fileprivate func base64URLEncodedString() -> String {
|
||||
self.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
enum AnthropicOAuthCodeState {
|
||||
struct Parsed: Equatable {
|
||||
let code: String
|
||||
let state: String
|
||||
}
|
||||
|
||||
/// Extracts a `code#state` payload from arbitrary text.
|
||||
///
|
||||
/// Supports:
|
||||
/// - raw `code#state`
|
||||
/// - OAuth callback URLs containing `code=` and `state=` query params
|
||||
/// - surrounding text/backticks from instructions pages
|
||||
static func extract(from raw: String) -> String? {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "`"))
|
||||
if text.isEmpty { return nil }
|
||||
|
||||
if let fromURL = self.extractFromURL(text) { return fromURL }
|
||||
if let fromToken = self.extractFromToken(text) { return fromToken }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func parse(from raw: String) -> Parsed? {
|
||||
guard let extracted = self.extract(from: raw) else { return nil }
|
||||
let parts = extracted.split(separator: "#", maxSplits: 1).map(String.init)
|
||||
let code = parts.first ?? ""
|
||||
let state = parts.count > 1 ? parts[1] : ""
|
||||
guard !code.isEmpty, !state.isEmpty else { return nil }
|
||||
return Parsed(code: code, state: state)
|
||||
}
|
||||
|
||||
private static func extractFromURL(_ text: String) -> String? {
|
||||
// Users might copy the callback URL from the browser address bar.
|
||||
guard let components = URLComponents(string: text),
|
||||
let items = components.queryItems,
|
||||
let code = items.first(where: { $0.name == "code" })?.value,
|
||||
let state = items.first(where: { $0.name == "state" })?.value,
|
||||
!code.isEmpty, !state.isEmpty
|
||||
else { return nil }
|
||||
|
||||
return "\(code)#\(state)"
|
||||
}
|
||||
|
||||
private static func extractFromToken(_ text: String) -> String? {
|
||||
// Base64url-ish tokens; keep this fairly strict to avoid false positives.
|
||||
let pattern = #"([A-Za-z0-9._~-]{8,})#([A-Za-z0-9._~-]{8,})"#
|
||||
guard let re = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = re.firstMatch(in: text, range: range),
|
||||
match.numberOfRanges == 3,
|
||||
let full = Range(match.range(at: 0), in: text)
|
||||
else { return nil }
|
||||
|
||||
return String(text[full])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
extension AnyCodable {
|
||||
var stringValue: String? { self.value as? String }
|
||||
var boolValue: Bool? { self.value as? Bool }
|
||||
var intValue: Int? { self.value as? Int }
|
||||
var doubleValue: Double? { self.value as? Double }
|
||||
var dictionaryValue: [String: AnyCodable]? { self.value as? [String: AnyCodable] }
|
||||
var arrayValue: [AnyCodable]? { self.value as? [AnyCodable] }
|
||||
|
||||
var foundationValue: Any {
|
||||
switch self.value {
|
||||
case let dict as [String: AnyCodable]:
|
||||
dict.mapValues { $0.foundationValue }
|
||||
case let array as [AnyCodable]:
|
||||
array.map(\.foundationValue)
|
||||
default:
|
||||
self.value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
|
||||
/// Marked `@unchecked Sendable` because it can hold reference types.
|
||||
struct AnyCodable: Codable, @unchecked Sendable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) { self.value = value }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
|
||||
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
|
||||
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
|
||||
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
|
||||
if container.decodeNil() { self.value = NSNull(); return }
|
||||
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
|
||||
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container,
|
||||
debugDescription: "Unsupported type")
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self.value {
|
||||
case let intVal as Int: try container.encode(intVal)
|
||||
case let doubleVal as Double: try container.encode(doubleVal)
|
||||
case let boolVal as Bool: try container.encode(boolVal)
|
||||
case let stringVal as String: try container.encode(stringVal)
|
||||
case is NSNull: try container.encodeNil()
|
||||
case let dict as [String: AnyCodable]: try container.encode(dict)
|
||||
case let array as [AnyCodable]: try container.encode(array)
|
||||
case let dict as [String: Any]:
|
||||
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||
case let array as [Any]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dict as NSDictionary:
|
||||
var converted: [String: AnyCodable] = [:]
|
||||
for (k, v) in dict {
|
||||
guard let key = k as? String else { continue }
|
||||
converted[key] = AnyCodable(v)
|
||||
}
|
||||
try container.encode(converted)
|
||||
case let array as NSArray:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
default:
|
||||
let context = EncodingError.Context(
|
||||
codingPath: encoder.codingPath,
|
||||
debugDescription: "Unsupported type")
|
||||
throw EncodingError.invalidValue(self.value, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import ServiceManagement
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppState {
|
||||
private let isPreview: Bool
|
||||
private var isInitializing = true
|
||||
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
|
||||
}
|
||||
|
||||
var isPaused: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
||||
}
|
||||
|
||||
var launchAtLogin: Bool {
|
||||
didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } }
|
||||
}
|
||||
|
||||
var onboardingSeen: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: "clawdbot.onboardingSeen") }
|
||||
}
|
||||
}
|
||||
|
||||
var debugPaneEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: "clawdbot.debugPaneEnabled") }
|
||||
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 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 talkEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.talkEnabled, forKey: talkEnabledKey)
|
||||
Task { await TalkModeController.shared.setEnabled(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 canvasEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||
}
|
||||
|
||||
/// 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 attachExistingGatewayOnly: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.attachExistingGatewayOnly, forKey: attachExistingGatewayOnlyKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var remoteTarget: String {
|
||||
didSet {
|
||||
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
|
||||
self.syncGatewayConfigIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
self.isPreview = preview
|
||||
let onboardingSeen = UserDefaults.standard.bool(forKey: "clawdbot.onboardingSeen")
|
||||
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
self.launchAtLogin = false
|
||||
self.onboardingSeen = onboardingSeen
|
||||
self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdbot.debugPaneEnabled")
|
||||
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)
|
||||
}
|
||||
self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey)
|
||||
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
|
||||
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.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey)
|
||||
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 = ClawdbotConfigFile.loadDict()
|
||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let configMode: ConnectionMode? = {
|
||||
switch configModeRaw {
|
||||
case "local":
|
||||
return .local
|
||||
case "remote":
|
||||
return .remote
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let configHasRemoteUrl = !(configRemoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
|
||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||
if let configMode {
|
||||
self.connectionMode = configMode
|
||||
} else if configHasRemoteUrl {
|
||||
self.connectionMode = .remote
|
||||
} else if let storedMode {
|
||||
self.connectionMode = ConnectionMode(rawValue: storedMode) ?? .local
|
||||
} else {
|
||||
self.connectionMode = onboardingSeen ? .local : .unconfigured
|
||||
}
|
||||
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
if self.connectionMode == .remote,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||
{
|
||||
self.remoteTarget = "\(NSUserName())@\(host)"
|
||||
} else {
|
||||
self.remoteTarget = storedRemoteTarget
|
||||
}
|
||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||
self.attachExistingGatewayOnly = UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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 func startConfigWatcher() {
|
||||
let configUrl = ClawdbotConfigFile.url()
|
||||
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.applyConfigFromDisk()
|
||||
}
|
||||
}
|
||||
self.configWatcher?.start()
|
||||
}
|
||||
|
||||
private func applyConfigFromDisk() {
|
||||
let root = ClawdbotConfigFile.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 = (gateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let hasRemoteUrl = !(remoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
|
||||
let desiredMode: ConnectionMode? = {
|
||||
switch modeRaw {
|
||||
case "local":
|
||||
return .local
|
||||
case "remote":
|
||||
return .remote
|
||||
case "unconfigured":
|
||||
return .unconfigured
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
if let desiredMode {
|
||||
if desiredMode != self.connectionMode {
|
||||
self.connectionMode = desiredMode
|
||||
}
|
||||
} else if hasRemoteUrl, self.connectionMode != .remote {
|
||||
self.connectionMode = .remote
|
||||
}
|
||||
|
||||
let targetMode = desiredMode ?? self.connectionMode
|
||||
if targetMode == .remote,
|
||||
let host = AppState.remoteHost(from: remoteUrl)
|
||||
{
|
||||
self.updateRemoteTarget(host: host)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRemoteTarget(host: String) {
|
||||
let parsed = CommandResolver.parseSSHTarget(self.remoteTarget)
|
||||
let user = parsed?.user ?? NSUserName()
|
||||
let port = parsed?.port ?? 22
|
||||
let assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
|
||||
if assembled != self.remoteTarget {
|
||||
self.remoteTarget = assembled
|
||||
}
|
||||
}
|
||||
|
||||
private func syncGatewayConfigIfNeeded() {
|
||||
guard !self.isPreview, !self.isInitializing else { return }
|
||||
|
||||
var root = ClawdbotConfigFile.loadDict()
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var changed = false
|
||||
|
||||
let desiredMode: String?
|
||||
switch self.connectionMode {
|
||||
case .local:
|
||||
desiredMode = "local"
|
||||
case .remote:
|
||||
desiredMode = "remote"
|
||||
case .unconfigured:
|
||||
desiredMode = 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 self.connectionMode == .remote,
|
||||
let host = CommandResolver.parseSSHTarget(self.remoteTarget)?.host
|
||||
{
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
gateway["remote"] = remote
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
guard changed else { return }
|
||||
root["gateway"] = gateway
|
||||
ClawdbotConfigFile.saveDict(root)
|
||||
}
|
||||
|
||||
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.voiceWakeLocaleID = Locale.current.identifier
|
||||
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
|
||||
state.voicePushToTalkEnabled = false
|
||||
state.talkEnabled = false
|
||||
state.iconOverride = .system
|
||||
state.heartbeatsEnabled = true
|
||||
state.connectionMode = .local
|
||||
state.canvasEnabled = true
|
||||
state.remoteTarget = "user@example.com"
|
||||
state.remoteIdentity = "~/.ssh/id_ed25519"
|
||||
state.remoteProjectRoot = "~/Projects/clawdbot"
|
||||
state.remoteCliPath = ""
|
||||
state.attachExistingGatewayOnly = false
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
static var attachExistingGatewayOnly: Bool {
|
||||
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum AppActivationPolicy {
|
||||
static func apply(showDockIcon: Bool) {
|
||||
_ = showDockIcon
|
||||
DockIconManager.shared.updateDockVisibility()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
enum AsyncTimeout {
|
||||
static func withTimeout<T: Sendable>(
|
||||
seconds: Double,
|
||||
onTimeout: @escaping @Sendable () -> Error,
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
let clamped = max(0, seconds)
|
||||
return try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000))
|
||||
throw onTimeout()
|
||||
}
|
||||
let result = try await group.next()
|
||||
group.cancelAll()
|
||||
if let result { return result }
|
||||
throw onTimeout()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
struct BridgeNodeInfo: Sendable {
|
||||
var nodeId: String
|
||||
var displayName: String?
|
||||
var platform: String?
|
||||
var version: String?
|
||||
var deviceFamily: String?
|
||||
var modelIdentifier: String?
|
||||
var remoteAddress: String?
|
||||
var caps: [String]?
|
||||
}
|
||||
|
||||
actor BridgeConnectionHandler {
|
||||
private let connection: NWConnection
|
||||
private let logger: Logger
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private let queue = DispatchQueue(label: "com.clawdbot.bridge.connection")
|
||||
|
||||
private var buffer = Data()
|
||||
private var isAuthenticated = false
|
||||
private var nodeId: String?
|
||||
private var pendingInvokes: [String: CheckedContinuation<BridgeInvokeResponse, Error>] = [:]
|
||||
private var isClosed = false
|
||||
|
||||
init(connection: NWConnection, logger: Logger) {
|
||||
self.connection = connection
|
||||
self.logger = logger
|
||||
}
|
||||
|
||||
enum AuthResult: Sendable {
|
||||
case ok
|
||||
case notPaired
|
||||
case unauthorized
|
||||
case error(code: String, message: String)
|
||||
}
|
||||
|
||||
enum PairResult: Sendable {
|
||||
case ok(token: String)
|
||||
case rejected
|
||||
case error(code: String, message: String)
|
||||
}
|
||||
|
||||
private struct FrameContext: Sendable {
|
||||
var serverName: String
|
||||
var resolveAuth: @Sendable (BridgeHello) async -> AuthResult
|
||||
var handlePair: @Sendable (BridgePairRequest) async -> PairResult
|
||||
var onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)?
|
||||
var onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?
|
||||
var onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?
|
||||
}
|
||||
|
||||
func run(
|
||||
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
|
||||
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
|
||||
onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)? = nil,
|
||||
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil,
|
||||
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? = nil) async
|
||||
{
|
||||
self.configureStateLogging()
|
||||
self.connection.start(queue: self.queue)
|
||||
|
||||
let context = FrameContext(
|
||||
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName,
|
||||
resolveAuth: resolveAuth,
|
||||
handlePair: handlePair,
|
||||
onAuthenticated: onAuthenticated,
|
||||
onEvent: onEvent,
|
||||
onRequest: onRequest)
|
||||
|
||||
while true {
|
||||
do {
|
||||
guard let line = try await self.receiveLine() else { break }
|
||||
guard let data = line.data(using: .utf8) else { continue }
|
||||
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||
try await self.handleFrame(
|
||||
baseType: base.type,
|
||||
data: data,
|
||||
context: context)
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
await self.close(with: onDisconnected)
|
||||
}
|
||||
|
||||
private func configureStateLogging() {
|
||||
self.connection.stateUpdateHandler = { [logger] state in
|
||||
switch state {
|
||||
case .ready:
|
||||
logger.debug("bridge conn ready")
|
||||
case let .failed(err):
|
||||
logger.error("bridge conn failed: \(err.localizedDescription, privacy: .public)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFrame(
|
||||
baseType: String,
|
||||
data: Data,
|
||||
context: FrameContext) async throws
|
||||
{
|
||||
switch baseType {
|
||||
case "hello":
|
||||
await self.handleHelloFrame(
|
||||
data: data,
|
||||
context: context)
|
||||
case "pair-request":
|
||||
await self.handlePairRequestFrame(
|
||||
data: data,
|
||||
context: context)
|
||||
case "event":
|
||||
await self.handleEventFrame(data: data, onEvent: context.onEvent)
|
||||
case "req":
|
||||
try await self.handleRPCRequestFrame(data: data, onRequest: context.onRequest)
|
||||
case "ping":
|
||||
try await self.handlePingFrame(data: data)
|
||||
case "invoke-res":
|
||||
await self.handleInvokeResponseFrame(data: data)
|
||||
default:
|
||||
await self.sendError(code: "INVALID_REQUEST", message: "unknown type")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHelloFrame(
|
||||
data: Data,
|
||||
context: FrameContext) async
|
||||
{
|
||||
do {
|
||||
let hello = try self.decoder.decode(BridgeHello.self, from: data)
|
||||
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.nodeId = nodeId
|
||||
let result = await context.resolveAuth(hello)
|
||||
await self.handleAuthResult(result, serverName: context.serverName)
|
||||
if case .ok = result {
|
||||
await context.onAuthenticated?(
|
||||
BridgeNodeInfo(
|
||||
nodeId: nodeId,
|
||||
displayName: hello.displayName,
|
||||
platform: hello.platform,
|
||||
version: hello.version,
|
||||
deviceFamily: hello.deviceFamily,
|
||||
modelIdentifier: hello.modelIdentifier,
|
||||
remoteAddress: self.remoteAddressString(),
|
||||
caps: hello.caps))
|
||||
}
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePairRequestFrame(
|
||||
data: Data,
|
||||
context: FrameContext) async
|
||||
{
|
||||
do {
|
||||
let req = try self.decoder.decode(BridgePairRequest.self, from: data)
|
||||
let nodeId = req.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.nodeId = nodeId
|
||||
let enriched = BridgePairRequest(
|
||||
type: req.type,
|
||||
nodeId: nodeId,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
deviceFamily: req.deviceFamily,
|
||||
modelIdentifier: req.modelIdentifier,
|
||||
caps: req.caps,
|
||||
commands: req.commands,
|
||||
remoteAddress: self.remoteAddressString(),
|
||||
silent: req.silent)
|
||||
let result = await context.handlePair(enriched)
|
||||
await self.handlePairResult(result, serverName: context.serverName)
|
||||
if case .ok = result {
|
||||
await context.onAuthenticated?(
|
||||
BridgeNodeInfo(
|
||||
nodeId: nodeId,
|
||||
displayName: enriched.displayName,
|
||||
platform: enriched.platform,
|
||||
version: enriched.version,
|
||||
deviceFamily: enriched.deviceFamily,
|
||||
modelIdentifier: enriched.modelIdentifier,
|
||||
remoteAddress: enriched.remoteAddress,
|
||||
caps: enriched.caps))
|
||||
}
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEventFrame(
|
||||
data: Data,
|
||||
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?) async
|
||||
{
|
||||
guard self.isAuthenticated, let nodeId = self.nodeId else {
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
|
||||
await onEvent?(nodeId, evt)
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRPCRequestFrame(
|
||||
data: Data,
|
||||
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?) async throws
|
||||
{
|
||||
let req = try self.decoder.decode(BridgeRPCRequest.self, from: data)
|
||||
guard self.isAuthenticated, let nodeId = self.nodeId else {
|
||||
try await self.send(
|
||||
BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAUTHORIZED", message: "not authenticated")))
|
||||
return
|
||||
}
|
||||
|
||||
if let onRequest {
|
||||
let res = await onRequest(nodeId, req)
|
||||
try await self.send(res)
|
||||
} else {
|
||||
try await self.send(
|
||||
BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAVAILABLE", message: "RPC not supported")))
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePingFrame(data: Data) async throws {
|
||||
guard self.isAuthenticated else {
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
|
||||
return
|
||||
}
|
||||
let ping = try self.decoder.decode(BridgePing.self, from: data)
|
||||
try await self.send(BridgePong(type: "pong", id: ping.id))
|
||||
}
|
||||
|
||||
private func handleInvokeResponseFrame(data: Data) async {
|
||||
guard self.isAuthenticated else {
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let res = try self.decoder.decode(BridgeInvokeResponse.self, from: data)
|
||||
if let cont = self.pendingInvokes.removeValue(forKey: res.id) {
|
||||
cont.resume(returning: res)
|
||||
}
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func remoteAddressString() -> String? {
|
||||
switch self.connection.endpoint {
|
||||
case let .hostPort(host: host, port: _):
|
||||
let value = String(describing: host)
|
||||
return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func remoteAddress() -> String? {
|
||||
self.remoteAddressString()
|
||||
}
|
||||
|
||||
private func handlePairResult(_ result: PairResult, serverName: String) async {
|
||||
switch result {
|
||||
case let .ok(token):
|
||||
do {
|
||||
try await self.send(BridgePairOk(type: "pair-ok", token: token))
|
||||
self.isAuthenticated = true
|
||||
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
|
||||
} catch {
|
||||
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
case .rejected:
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "pairing rejected")
|
||||
case let .error(code, message):
|
||||
await self.sendError(code: code, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAuthResult(_ result: AuthResult, serverName: String) async {
|
||||
switch result {
|
||||
case .ok:
|
||||
self.isAuthenticated = true
|
||||
do {
|
||||
try await self.send(BridgeHelloOk(type: "hello-ok", serverName: serverName))
|
||||
} catch {
|
||||
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
case .notPaired:
|
||||
await self.sendError(code: "NOT_PAIRED", message: "pairing required")
|
||||
case .unauthorized:
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "invalid token")
|
||||
case let .error(code, message):
|
||||
await self.sendError(code: code, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendError(code: String, message: String) async {
|
||||
do {
|
||||
try await self.send(BridgeErrorFrame(type: "error", code: code, message: message))
|
||||
} catch {
|
||||
self.logger.error("bridge send error failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func invoke(command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
|
||||
guard self.isAuthenticated else {
|
||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAUTHORIZED: not authenticated",
|
||||
])
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
let req = BridgeInvokeRequest(type: "invoke", id: id, command: command, paramsJSON: paramsJSON)
|
||||
|
||||
let timeoutTask = Task {
|
||||
try await Task.sleep(nanoseconds: 15 * 1_000_000_000)
|
||||
await self.timeoutInvoke(id: id)
|
||||
}
|
||||
defer { timeoutTask.cancel() }
|
||||
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.beginInvoke(id: id, request: req, continuation: cont)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginInvoke(
|
||||
id: String,
|
||||
request: BridgeInvokeRequest,
|
||||
continuation: CheckedContinuation<BridgeInvokeResponse, Error>) async
|
||||
{
|
||||
self.pendingInvokes[id] = continuation
|
||||
do {
|
||||
try await self.send(request)
|
||||
} catch {
|
||||
await self.failInvoke(id: id, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutInvoke(id: String) async {
|
||||
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: invoke timeout",
|
||||
]))
|
||||
}
|
||||
|
||||
private func failInvoke(id: String, error: Error) async {
|
||||
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
|
||||
private func send(_ obj: some Encodable) async throws {
|
||||
let data = try self.encoder.encode(obj)
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A) // \n
|
||||
let _: Void = try await withCheckedThrowingContinuation { cont in
|
||||
self.connection.send(content: line, completion: .contentProcessed { err in
|
||||
if let err {
|
||||
cont.resume(throwing: err)
|
||||
} else {
|
||||
cont.resume(returning: ())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sendServerEvent(event: String, payloadJSON: String?) async {
|
||||
guard self.isAuthenticated else { return }
|
||||
do {
|
||||
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
|
||||
} catch {
|
||||
self.logger.error("bridge send event failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveLine() async throws -> String? {
|
||||
while true {
|
||||
if let idx = self.buffer.firstIndex(of: 0x0A) {
|
||||
let lineData = self.buffer.prefix(upTo: idx)
|
||||
self.buffer.removeSubrange(...idx)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
|
||||
let chunk = try await self.receiveChunk()
|
||||
if chunk.isEmpty { return nil }
|
||||
self.buffer.append(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveChunk() async throws -> Data {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.connection
|
||||
.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
cont.resume(returning: Data())
|
||||
return
|
||||
}
|
||||
cont.resume(returning: data ?? Data())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func close(with onDisconnected: (@Sendable (String) async -> Void)? = nil) async {
|
||||
if self.isClosed { return }
|
||||
self.isClosed = true
|
||||
|
||||
let nodeId = self.nodeId
|
||||
let pending = self.pendingInvokes.values
|
||||
self.pendingInvokes.removeAll()
|
||||
for cont in pending {
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
|
||||
]))
|
||||
}
|
||||
|
||||
self.connection.cancel()
|
||||
if let nodeId {
|
||||
await onDisconnected?(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import AppKit
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
actor BridgeServer {
|
||||
static let shared = BridgeServer()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "bridge")
|
||||
private var listener: NWListener?
|
||||
private var isRunning = false
|
||||
private var store: PairedNodesStore?
|
||||
private var connections: [String: BridgeConnectionHandler] = [:]
|
||||
private var nodeInfoById: [String: BridgeNodeInfo] = [:]
|
||||
private var presenceTasks: [String: Task<Void, Never>] = [:]
|
||||
private var chatSubscriptions: [String: Set<String>] = [:]
|
||||
private var gatewayPushTask: Task<Void, Never>?
|
||||
|
||||
func start() async {
|
||||
if self.isRunning { return }
|
||||
self.isRunning = true
|
||||
|
||||
do {
|
||||
let storeURL = try Self.defaultStoreURL()
|
||||
let store = PairedNodesStore(fileURL: storeURL)
|
||||
await store.load()
|
||||
self.store = store
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let listener = try NWListener(using: params, on: .any)
|
||||
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
guard let self else { return }
|
||||
Task { await self.handle(connection: connection) }
|
||||
}
|
||||
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
Task { await self.handleListenerState(state) }
|
||||
}
|
||||
|
||||
listener.start(queue: DispatchQueue(label: "com.clawdbot.bridge"))
|
||||
self.listener = listener
|
||||
} catch {
|
||||
self.logger.error("bridge start failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.isRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
self.isRunning = false
|
||||
self.listener?.cancel()
|
||||
self.listener = nil
|
||||
}
|
||||
|
||||
private func handleListenerState(_ state: NWListener.State) {
|
||||
switch state {
|
||||
case .ready:
|
||||
self.logger.info("bridge listening")
|
||||
case let .failed(err):
|
||||
self.logger.error("bridge listener failed: \(err.localizedDescription, privacy: .public)")
|
||||
case .cancelled:
|
||||
self.logger.info("bridge listener cancelled")
|
||||
case .waiting:
|
||||
self.logger.info("bridge listener waiting")
|
||||
case .setup:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(connection: NWConnection) async {
|
||||
let handler = BridgeConnectionHandler(connection: connection, logger: self.logger)
|
||||
await handler.run(
|
||||
resolveAuth: { [weak self] hello in
|
||||
await self?.authorize(hello: hello) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
|
||||
},
|
||||
handlePair: { [weak self] request in
|
||||
await self?.pair(request: request) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
|
||||
},
|
||||
onAuthenticated: { [weak self] node in
|
||||
await self?.registerConnection(handler: handler, node: node)
|
||||
},
|
||||
onDisconnected: { [weak self] nodeId in
|
||||
await self?.unregisterConnection(nodeId: nodeId)
|
||||
},
|
||||
onEvent: { [weak self] nodeId, evt in
|
||||
await self?.handleEvent(nodeId: nodeId, evt: evt)
|
||||
},
|
||||
onRequest: { [weak self] nodeId, req in
|
||||
await self?.handleRequest(nodeId: nodeId, req: req)
|
||||
?? BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAVAILABLE", message: "bridge unavailable"))
|
||||
})
|
||||
}
|
||||
|
||||
func invoke(nodeId: String, command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
|
||||
guard let handler = self.connections[nodeId] else {
|
||||
throw NSError(domain: "Bridge", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: node not connected",
|
||||
])
|
||||
}
|
||||
return try await handler.invoke(command: command, paramsJSON: paramsJSON)
|
||||
}
|
||||
|
||||
func connectedNodeIds() -> [String] {
|
||||
Array(self.connections.keys).sorted()
|
||||
}
|
||||
|
||||
func connectedNodes() -> [BridgeNodeInfo] {
|
||||
self.nodeInfoById.values.sorted { a, b in
|
||||
(a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
func pairedNodes() async -> [PairedNode] {
|
||||
guard let store = self.store else { return [] }
|
||||
return await store.all()
|
||||
}
|
||||
|
||||
private func registerConnection(handler: BridgeConnectionHandler, node: BridgeNodeInfo) async {
|
||||
self.connections[node.nodeId] = handler
|
||||
self.nodeInfoById[node.nodeId] = node
|
||||
await self.beaconPresence(nodeId: node.nodeId, reason: "connect")
|
||||
self.startPresenceTask(nodeId: node.nodeId)
|
||||
self.ensureGatewayPushTask()
|
||||
}
|
||||
|
||||
private func unregisterConnection(nodeId: String) async {
|
||||
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
|
||||
self.stopPresenceTask(nodeId: nodeId)
|
||||
self.connections.removeValue(forKey: nodeId)
|
||||
self.nodeInfoById.removeValue(forKey: nodeId)
|
||||
self.chatSubscriptions[nodeId] = nil
|
||||
self.stopGatewayPushTaskIfIdle()
|
||||
}
|
||||
|
||||
private struct VoiceTranscriptPayload: Codable, Sendable {
|
||||
var text: String
|
||||
var sessionKey: String?
|
||||
}
|
||||
|
||||
private func handleEvent(nodeId: String, evt: BridgeEventFrame) async {
|
||||
switch evt.event {
|
||||
case "chat.subscribe":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
|
||||
struct Subscribe: Codable { var sessionKey: String }
|
||||
guard let payload = try? JSONDecoder().decode(Subscribe.self, from: data) else { return }
|
||||
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return }
|
||||
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
|
||||
set.insert(key)
|
||||
self.chatSubscriptions[nodeId] = set
|
||||
|
||||
case "chat.unsubscribe":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
|
||||
struct Unsubscribe: Codable { var sessionKey: String }
|
||||
guard let payload = try? JSONDecoder().decode(Unsubscribe.self, from: data) else { return }
|
||||
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return }
|
||||
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
|
||||
set.remove(key)
|
||||
self.chatSubscriptions[nodeId] = set.isEmpty ? nil : set
|
||||
|
||||
case "voice.transcript":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
guard let payload = try? JSONDecoder().decode(VoiceTranscriptPayload.self, from: data) else {
|
||||
return
|
||||
}
|
||||
let text = payload.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
|
||||
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? "main"
|
||||
|
||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: text,
|
||||
sessionKey: sessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: .last))
|
||||
|
||||
case "agent.request":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
guard let link = try? JSONDecoder().decode(AgentDeepLink.self, from: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !message.isEmpty else { return }
|
||||
guard message.count <= 20000 else { return }
|
||||
|
||||
let sessionKey = link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? "node-\(nodeId)"
|
||||
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
|
||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: message,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
deliver: link.deliver,
|
||||
to: to,
|
||||
channel: channel))
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRequest(nodeId: String, req: BridgeRPCRequest) async -> BridgeRPCResponse {
|
||||
let allowed: Set<String> = ["chat.history", "chat.send", "health"]
|
||||
guard allowed.contains(req.method) else {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
|
||||
}
|
||||
|
||||
let params: [String: AnyCodable]?
|
||||
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
|
||||
guard let data = json.data(using: .utf8) else {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
|
||||
}
|
||||
do {
|
||||
params = try JSONDecoder().decode([String: AnyCodable].self, from: data)
|
||||
} catch {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "INVALID_REQUEST", message: error.localizedDescription))
|
||||
}
|
||||
} else {
|
||||
params = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(method: req.method, params: params, timeoutMs: 30000)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAVAILABLE", message: "Response not UTF-8"))
|
||||
}
|
||||
return BridgeRPCResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
} catch {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAVAILABLE", message: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureGatewayPushTask() {
|
||||
if self.gatewayPushTask != nil { return }
|
||||
self.gatewayPushTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
} catch {
|
||||
// We'll still forward events once the gateway comes up.
|
||||
}
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await self.forwardGatewayPush(push)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopGatewayPushTaskIfIdle() {
|
||||
guard self.connections.isEmpty else { return }
|
||||
self.gatewayPushTask?.cancel()
|
||||
self.gatewayPushTask = nil
|
||||
}
|
||||
|
||||
private func forwardGatewayPush(_ push: GatewayPush) async {
|
||||
let subscribedNodes = self.chatSubscriptions.keys.filter { self.connections[$0] != nil }
|
||||
guard !subscribedNodes.isEmpty else { return }
|
||||
|
||||
switch push {
|
||||
case let .snapshot(hello):
|
||||
let payloadJSON = (try? JSONEncoder().encode(hello.snapshot.health))
|
||||
.flatMap { String(data: $0, encoding: .utf8) }
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
|
||||
}
|
||||
case let .event(evt):
|
||||
switch evt.event {
|
||||
case "health":
|
||||
guard let payload = evt.payload else { return }
|
||||
let payloadJSON = (try? JSONEncoder().encode(payload))
|
||||
.flatMap { String(data: $0, encoding: .utf8) }
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
|
||||
}
|
||||
case "tick":
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "tick", payloadJSON: nil)
|
||||
}
|
||||
case "chat":
|
||||
guard let payload = evt.payload else { return }
|
||||
let payloadData = try? JSONEncoder().encode(payload)
|
||||
let payloadJSON = payloadData.flatMap { String(data: $0, encoding: .utf8) }
|
||||
|
||||
struct MinimalChat: Codable { var sessionKey: String }
|
||||
let sessionKey = payloadData.flatMap { try? JSONDecoder().decode(MinimalChat.self, from: $0) }?
|
||||
.sessionKey
|
||||
if let sessionKey {
|
||||
for nodeId in subscribedNodes {
|
||||
guard self.chatSubscriptions[nodeId]?.contains(sessionKey) == true else { continue }
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
|
||||
}
|
||||
} else {
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .seqGap:
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "seqGap", payloadJSON: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beaconPresence(nodeId: String, reason: String) async {
|
||||
let paired = await self.store?.find(nodeId: nodeId)
|
||||
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? nodeId
|
||||
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let ip = await self.connections[nodeId]?.remoteAddress()
|
||||
|
||||
var tags: [String] = ["node", "ios"]
|
||||
if let platform { tags.append(platform) }
|
||||
|
||||
let summary = [
|
||||
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
|
||||
platform.map { "platform \($0)" },
|
||||
version.map { "app \($0)" },
|
||||
"mode node",
|
||||
"reason \(reason)",
|
||||
].compactMap(\.self).joined(separator: " · ")
|
||||
|
||||
var params: [String: AnyCodable] = [
|
||||
"text": AnyCodable(summary),
|
||||
"instanceId": AnyCodable(nodeId),
|
||||
"host": AnyCodable(host),
|
||||
"mode": AnyCodable("node"),
|
||||
"reason": AnyCodable(reason),
|
||||
"tags": AnyCodable(tags),
|
||||
]
|
||||
if let ip { params["ip"] = AnyCodable(ip) }
|
||||
if let version { params["version"] = AnyCodable(version) }
|
||||
await GatewayConnection.shared.sendSystemEvent(params)
|
||||
}
|
||||
|
||||
private func startPresenceTask(nodeId: String) {
|
||||
self.presenceTasks[nodeId]?.cancel()
|
||||
self.presenceTasks[nodeId] = Task.detached { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 180 * 1_000_000_000)
|
||||
if Task.isCancelled { return }
|
||||
await self?.beaconPresence(nodeId: nodeId, reason: "periodic")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPresenceTask(nodeId: String) {
|
||||
self.presenceTasks[nodeId]?.cancel()
|
||||
self.presenceTasks.removeValue(forKey: nodeId)
|
||||
}
|
||||
|
||||
private func authorize(hello: BridgeHello) async -> BridgeConnectionHandler.AuthResult {
|
||||
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if nodeId.isEmpty {
|
||||
return .error(code: "INVALID_REQUEST", message: "nodeId required")
|
||||
}
|
||||
guard let store = self.store else {
|
||||
return .error(code: "UNAVAILABLE", message: "store unavailable")
|
||||
}
|
||||
guard let paired = await store.find(nodeId: nodeId) else {
|
||||
return .notPaired
|
||||
}
|
||||
guard let token = hello.token, token == paired.token else {
|
||||
return .unauthorized
|
||||
}
|
||||
|
||||
do {
|
||||
var updated = paired
|
||||
let name = hello.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let platform = hello.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let version = hello.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let deviceFamily = hello.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let modelIdentifier = hello.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
|
||||
if updated.displayName != name { updated.displayName = name }
|
||||
if updated.platform != platform { updated.platform = platform }
|
||||
if updated.version != version { updated.version = version }
|
||||
if updated.deviceFamily != deviceFamily { updated.deviceFamily = deviceFamily }
|
||||
if updated.modelIdentifier != modelIdentifier { updated.modelIdentifier = modelIdentifier }
|
||||
|
||||
if updated != paired {
|
||||
try await store.upsert(updated)
|
||||
} else {
|
||||
try await store.touchSeen(nodeId: nodeId)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
|
||||
private func pair(request: BridgePairRequest) async -> BridgeConnectionHandler.PairResult {
|
||||
let nodeId = request.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if nodeId.isEmpty {
|
||||
return .error(code: "INVALID_REQUEST", message: "nodeId required")
|
||||
}
|
||||
guard let store = self.store else {
|
||||
return .error(code: "UNAVAILABLE", message: "store unavailable")
|
||||
}
|
||||
let existing = await store.find(nodeId: nodeId)
|
||||
|
||||
let approved = await BridgePairingApprover.approve(request: request, isRepair: existing != nil)
|
||||
if !approved {
|
||||
return .rejected
|
||||
}
|
||||
|
||||
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "")
|
||||
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let node = PairedNode(
|
||||
nodeId: nodeId,
|
||||
displayName: request.displayName,
|
||||
platform: request.platform,
|
||||
version: request.version,
|
||||
deviceFamily: request.deviceFamily,
|
||||
modelIdentifier: request.modelIdentifier,
|
||||
token: token,
|
||||
createdAtMs: nowMs,
|
||||
lastSeenAtMs: nowMs)
|
||||
do {
|
||||
try await store.upsert(node)
|
||||
return .ok(token: token)
|
||||
} catch {
|
||||
return .error(code: "UNAVAILABLE", message: "failed to persist pairing")
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultStoreURL() throws -> URL {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
guard let base else {
|
||||
throw NSError(
|
||||
domain: "Bridge",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Application Support unavailable"])
|
||||
}
|
||||
return base
|
||||
.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||
.appendingPathComponent("bridge", isDirectory: true)
|
||||
.appendingPathComponent("paired-nodes.json", isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum BridgePairingApprover {
|
||||
static func approve(request: BridgePairRequest, isRepair: Bool) async -> Bool {
|
||||
await withCheckedContinuation { cont in
|
||||
let name = request.displayName ?? request.nodeId
|
||||
let remote = request.remoteAddress?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let alert = NSAlert()
|
||||
alert.messageText = isRepair ? "Re-pair Clawdbot Node?" : "Pair Clawdbot Node?"
|
||||
alert.informativeText = """
|
||||
Node: \(name)
|
||||
IP: \(remote ?? "unknown")
|
||||
Platform: \(request.platform ?? "unknown")
|
||||
Version: \(request.version ?? "unknown")
|
||||
"""
|
||||
alert.addButton(withTitle: "Approve")
|
||||
alert.addButton(withTitle: "Reject")
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
|
||||
alert.buttons[1].hasDestructiveAction = true
|
||||
}
|
||||
let resp = alert.runModal()
|
||||
cont.resume(returning: resp == .alertFirstButtonReturn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension BridgeServer {
|
||||
func exerciseForTesting() async {
|
||||
let conn = NWConnection(to: .hostPort(host: "127.0.0.1", port: 22), using: .tcp)
|
||||
let handler = BridgeConnectionHandler(connection: conn, logger: self.logger)
|
||||
self.connections["node-1"] = handler
|
||||
self.nodeInfoById["node-1"] = BridgeNodeInfo(
|
||||
nodeId: "node-1",
|
||||
displayName: "Node One",
|
||||
platform: "macOS",
|
||||
version: "1.0.0",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "MacBookPro18,1",
|
||||
remoteAddress: "127.0.0.1",
|
||||
caps: ["chat", "voice"])
|
||||
|
||||
_ = self.connectedNodeIds()
|
||||
_ = self.connectedNodes()
|
||||
|
||||
self.handleListenerState(.ready)
|
||||
self.handleListenerState(.failed(NWError.posix(.ECONNREFUSED)))
|
||||
self.handleListenerState(.waiting(NWError.posix(.ETIMEDOUT)))
|
||||
self.handleListenerState(.cancelled)
|
||||
self.handleListenerState(.setup)
|
||||
|
||||
let subscribe = BridgeEventFrame(event: "chat.subscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
|
||||
await self.handleEvent(nodeId: "node-1", evt: subscribe)
|
||||
|
||||
let unsubscribe = BridgeEventFrame(event: "chat.unsubscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
|
||||
await self.handleEvent(nodeId: "node-1", evt: unsubscribe)
|
||||
|
||||
let invalid = BridgeRPCRequest(id: "req-1", method: "invalid.method", paramsJSON: nil)
|
||||
_ = await self.handleRequest(nodeId: "node-1", req: invalid)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
struct PairedNode: Codable, Equatable {
|
||||
var nodeId: String
|
||||
var displayName: String?
|
||||
var platform: String?
|
||||
var version: String?
|
||||
var deviceFamily: String?
|
||||
var modelIdentifier: String?
|
||||
var token: String
|
||||
var createdAtMs: Int
|
||||
var lastSeenAtMs: Int?
|
||||
}
|
||||
|
||||
actor PairedNodesStore {
|
||||
private let fileURL: URL
|
||||
private var nodes: [String: PairedNode] = [:]
|
||||
|
||||
init(fileURL: URL) {
|
||||
self.fileURL = fileURL
|
||||
}
|
||||
|
||||
func load() {
|
||||
do {
|
||||
let data = try Data(contentsOf: self.fileURL)
|
||||
let decoded = try JSONDecoder().decode([String: PairedNode].self, from: data)
|
||||
self.nodes = decoded
|
||||
} catch {
|
||||
self.nodes = [:]
|
||||
}
|
||||
}
|
||||
|
||||
func all() -> [PairedNode] {
|
||||
self.nodes.values.sorted { a, b in (a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId) }
|
||||
}
|
||||
|
||||
func find(nodeId: String) -> PairedNode? {
|
||||
self.nodes[nodeId]
|
||||
}
|
||||
|
||||
func upsert(_ node: PairedNode) async throws {
|
||||
self.nodes[node.nodeId] = node
|
||||
try await self.persist()
|
||||
}
|
||||
|
||||
func touchSeen(nodeId: String) async throws {
|
||||
guard var node = self.nodes[nodeId] else { return }
|
||||
node.lastSeenAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
self.nodes[nodeId] = node
|
||||
try await self.persist()
|
||||
}
|
||||
|
||||
private func persist() async throws {
|
||||
let dir = self.fileURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(self.nodes)
|
||||
try data.write(to: self.fileURL, options: [.atomic])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
enum BridgeDiscoveryPreferences {
|
||||
private static let preferredStableIDKey = "bridge.preferredStableID"
|
||||
|
||||
static func preferredStableID() -> String? {
|
||||
let raw = UserDefaults.standard.string(forKey: self.preferredStableIDKey)
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed?.isEmpty == false ? trimmed : nil
|
||||
}
|
||||
|
||||
static func setPreferredStableID(_ stableID: String?) {
|
||||
let trimmed = stableID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let trimmed, !trimmed.isEmpty {
|
||||
UserDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: self.preferredStableIDKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
enum BridgeEndpointID {
|
||||
static func stableID(_ endpoint: NWEndpoint) -> String {
|
||||
switch endpoint {
|
||||
case let .service(name, type, domain, _):
|
||||
// Keep stable across encoded/decoded differences (e.g. \032 for spaces).
|
||||
let normalizedName = Self.normalizeServiceNameForID(name)
|
||||
return "\(type)|\(domain)|\(normalizedName)"
|
||||
default:
|
||||
return String(describing: endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
|
||||
BonjourEscapes.decode(String(describing: endpoint))
|
||||
}
|
||||
|
||||
private static func normalizeServiceNameForID(_ rawName: String) -> String {
|
||||
let decoded = BonjourEscapes.decode(rawName)
|
||||
let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||
return normalized.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
enum CLIInstaller {
|
||||
private static func embeddedHelperURL() -> URL {
|
||||
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdbot")
|
||||
}
|
||||
|
||||
static func installedLocation() -> String? {
|
||||
self.installedLocation(
|
||||
searchPaths: cliHelperSearchPaths,
|
||||
embeddedHelper: self.embeddedHelperURL(),
|
||||
fileManager: .default)
|
||||
}
|
||||
|
||||
static func installedLocation(
|
||||
searchPaths: [String],
|
||||
embeddedHelper: URL,
|
||||
fileManager: FileManager) -> String?
|
||||
{
|
||||
let embedded = embeddedHelper.resolvingSymlinksInPath()
|
||||
|
||||
for basePath in searchPaths {
|
||||
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdbot").path
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
|
||||
!isDirectory.boolValue
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
|
||||
|
||||
let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
|
||||
if resolved == embedded {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func isInstalled() -> Bool {
|
||||
self.installedLocation() != nil
|
||||
}
|
||||
|
||||
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
||||
let helper = self.embeddedHelperURL()
|
||||
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
|
||||
await statusHandler(
|
||||
"Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
|
||||
"(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
|
||||
return
|
||||
}
|
||||
|
||||
let targets = cliHelperSearchPaths.map { "\($0)/clawdbot" }
|
||||
let result = await self.privilegedSymlink(source: helper.path, targets: targets)
|
||||
await statusHandler(result)
|
||||
}
|
||||
|
||||
private static func privilegedSymlink(source: String, targets: [String]) async -> String {
|
||||
let escapedSource = self.shellEscape(source)
|
||||
let targetList = targets.map(self.shellEscape).joined(separator: " ")
|
||||
let cmds = [
|
||||
"mkdir -p /usr/local/bin /opt/homebrew/bin",
|
||||
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
|
||||
].joined(separator: "; ")
|
||||
|
||||
let script = """
|
||||
do shell script "\(cmds)" with administrator privileges
|
||||
"""
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||
proc.arguments = ["-e", script]
|
||||
|
||||
let pipe = Pipe()
|
||||
proc.standardOutput = pipe
|
||||
proc.standardError = pipe
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if proc.terminationStatus == 0 {
|
||||
return output.isEmpty ? "CLI helper linked into \(targetList)" : output
|
||||
}
|
||||
if output.lowercased().contains("user canceled") {
|
||||
return "Install canceled"
|
||||
}
|
||||
return "Failed to install CLI helper: \(output)"
|
||||
} catch {
|
||||
return "Failed to run installer: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private static func shellEscape(_ path: String) -> String {
|
||||
"'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import AVFoundation
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
actor CameraCaptureService {
|
||||
struct CameraDeviceInfo: Encodable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let position: String
|
||||
let deviceType: String
|
||||
}
|
||||
|
||||
enum CameraError: LocalizedError, Sendable {
|
||||
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: "com.clawdbot", 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 session = AVCaptureSession()
|
||||
session.sessionPreset = .photo
|
||||
|
||||
guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
|
||||
let input = try AVCaptureDeviceInput(device: device)
|
||||
guard session.canAddInput(input) else {
|
||||
throw CameraError.captureFailed("Failed to add camera input")
|
||||
}
|
||||
session.addInput(input)
|
||||
|
||||
let output = AVCapturePhotoOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
throw CameraError.captureFailed("Failed to add photo output")
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.maxPhotoQualityPrioritization = .quality
|
||||
|
||||
session.startRunning()
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
await self.waitForExposureAndWhiteBalance(device: device)
|
||||
await self.sleepDelayMs(delayMs)
|
||||
|
||||
let settings: AVCapturePhotoSettings = {
|
||||
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||
return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
|
||||
}
|
||||
return AVCapturePhotoSettings()
|
||||
}()
|
||||
settings.photoQualityPrioritization = .quality
|
||||
|
||||
var delegate: PhotoCaptureDelegate?
|
||||
let rawData: Data = try await withCheckedThrowingContinuation { cont in
|
||||
let d = PhotoCaptureDelegate(cont)
|
||||
delegate = d
|
||||
output.capturePhoto(with: settings, delegate: d)
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
let maxPayloadBytes = 5 * 1024 * 1024
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
let res = try JPEGTranscoder.transcodeToJPEG(
|
||||
imageData: rawData,
|
||||
maxWidthPx: maxWidth,
|
||||
quality: quality,
|
||||
maxBytes: maxEncodedBytes)
|
||||
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 session = AVCaptureSession()
|
||||
session.sessionPreset = .high
|
||||
|
||||
guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
let cameraInput = try AVCaptureDeviceInput(device: camera)
|
||||
guard session.canAddInput(cameraInput) else {
|
||||
throw CameraError.captureFailed("Failed to add camera input")
|
||||
}
|
||||
session.addInput(cameraInput)
|
||||
|
||||
if includeAudio {
|
||||
guard let mic = AVCaptureDevice.default(for: .audio) else {
|
||||
throw CameraError.microphoneUnavailable
|
||||
}
|
||||
let micInput = try AVCaptureDeviceInput(device: mic)
|
||||
guard session.canAddInput(micInput) else {
|
||||
throw CameraError.captureFailed("Failed to add microphone input")
|
||||
}
|
||||
session.addInput(micInput)
|
||||
}
|
||||
|
||||
let output = AVCaptureMovieFileOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
throw CameraError.captureFailed("Failed to add movie output")
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000)
|
||||
|
||||
session.startRunning()
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
|
||||
let tmpMovURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
|
||||
defer { try? FileManager.default.removeItem(at: tmpMovURL) }
|
||||
|
||||
let outputURL: URL = {
|
||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return URL(fileURLWithPath: outPath)
|
||||
}
|
||||
return FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
|
||||
}()
|
||||
|
||||
// Ensure we don't fail exporting due to an existing file.
|
||||
try? FileManager.default.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 {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .notDetermined:
|
||||
let ok = await withCheckedContinuation(isolation: nil) { cont in
|
||||
AVCaptureDevice.requestAccess(for: mediaType) { granted in
|
||||
cont.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
}
|
||||
case .denied, .restricted:
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
@unknown default:
|
||||
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 = Self.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 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 nonisolated static func warmUpCaptureSession() async {
|
||||
// A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
|
||||
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
|
||||
}
|
||||
|
||||
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, 10_000)) * 1_000_000
|
||||
try? await Task.sleep(nanoseconds: ns)
|
||||
}
|
||||
|
||||
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
|
||||
switch position {
|
||||
case .front: "front"
|
||||
case .back: "back"
|
||||
default: "unspecified"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import AppKit
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
static let messageName = "clawdbotCanvasA2UIAction"
|
||||
|
||||
private let sessionKey: String
|
||||
|
||||
init(sessionKey: String) {
|
||||
self.sessionKey = sessionKey
|
||||
super.init()
|
||||
}
|
||||
|
||||
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
guard message.name == Self.messageName else { return }
|
||||
|
||||
// Only accept actions from local Canvas content (not arbitrary web pages).
|
||||
guard let webView = message.webView, let url = webView.url else { return }
|
||||
if url.scheme == CanvasScheme.scheme {
|
||||
// ok
|
||||
} else if Self.isLocalNetworkCanvasURL(url) {
|
||||
// ok
|
||||
} 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 = ClawdbotCanvasA2UIAction.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 = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
|
||||
|
||||
// Token-efficient and unambiguous. The agent should treat this as a UI event and (by default) update Canvas.
|
||||
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
|
||||
actionName: name,
|
||||
session: .init(key: self.sessionKey, surfaceId: surfaceId),
|
||||
component: .init(id: sourceComponentId, host: InstanceIdentity.displayName, instanceId: instanceId),
|
||||
contextJSON: contextJSON)
|
||||
let text = ClawdbotCanvasA2UIAction.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 = ClawdbotCanvasA2UIAction.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)
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||
return false
|
||||
}
|
||||
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" { return true }
|
||||
if host.hasSuffix(".local") { return true }
|
||||
if host.hasSuffix(".ts.net") { return true }
|
||||
if host.hasSuffix(".tailscale.net") { return true }
|
||||
if !host.contains("."), !host.contains(":") { return true }
|
||||
if let ipv4 = Self.parseIPv4(host) {
|
||||
return Self.isLocalNetworkIPv4(ipv4)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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: [UInt8] = parts.compactMap { UInt8($0) }
|
||||
guard bytes.count == 4 else { return nil }
|
||||
return (bytes[0], bytes[1], bytes[2], bytes[3])
|
||||
}
|
||||
|
||||
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 ClawdbotKit (`ClawdbotCanvasA2UIAction`).
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class CanvasFileWatcher: @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let queue: DispatchQueue
|
||||
private var stream: FSEventStreamRef?
|
||||
private var pending = false
|
||||
private let onChange: () -> Void
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.url = url
|
||||
self.queue = DispatchQueue(label: "com.clawdbot.canvaswatcher")
|
||||
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<CanvasFileWatcher>.fromOpaque(pointer).release()
|
||||
},
|
||||
copyDescription: nil)
|
||||
|
||||
let paths = [self.url.path] 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 CanvasFileWatcher {
|
||||
private static let callback: FSEventStreamCallback = { _, info, numEvents, _, eventFlags, _ in
|
||||
guard let info else { return }
|
||||
let watcher = Unmanaged<CanvasFileWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||
watcher.handleEvents(numEvents: numEvents, eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
private func handleEvents(numEvents: Int, eventFlags: UnsafePointer<FSEventStreamEventFlags>?) {
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
|
||||
// Coalesce rapid changes (common during builds/atomic saves).
|
||||
if self.pending { return }
|
||||
self.pending = true
|
||||
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.onChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import AppKit
|
||||
import ClawdbotIPC
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class CanvasManager {
|
||||
static let shared = CanvasManager()
|
||||
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", 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.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return base.appendingPathComponent("Clawdbot/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.default.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.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if raw.isEmpty {
|
||||
Self.logger.debug("canvas host url missing in gateway snapshot")
|
||||
} else {
|
||||
Self.logger.debug("canvas host url snapshot=\(raw, privacy: .public)")
|
||||
}
|
||||
let a2uiUrl = Self.resolveA2UIHostUrl(from: raw)
|
||||
if a2uiUrl == nil, !raw.isEmpty {
|
||||
Self.logger.debug("canvas host 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)
|
||||
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.canvasHostUrl()
|
||||
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("__clawdbot__/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.default.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.default
|
||||
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.default
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
enum CanvasScheme {
|
||||
static let scheme = "clawdbot-canvas"
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import WebKit
|
||||
|
||||
private let canvasLogger = Logger(subsystem: "com.clawdbot", 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 url.scheme == CanvasScheme.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.default.fileExists(atPath: indexA.path),
|
||||
!FileManager.default.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")
|
||||
}
|
||||
|
||||
// Directory traversal guard: served files must live under the session root.
|
||||
let standardizedRoot = sessionRoot.standardizedFileURL
|
||||
let standardizedFile = fileURL.standardizedFileURL
|
||||
guard standardizedFile.path.hasPrefix(standardizedRoot.path) else {
|
||||
return self.html("Forbidden", title: "Canvas: 403")
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: standardizedFile)
|
||||
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
|
||||
let servedPath = standardizedFile.path
|
||||
canvasLogger.debug(
|
||||
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)")
|
||||
return CanvasResponse(mime: mime, data: data)
|
||||
} catch {
|
||||
let failedPath = standardizedFile.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.default
|
||||
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.default
|
||||
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 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: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
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 = ClawdbotKitResources.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
|
||||
@@ -0,0 +1,26 @@
|
||||
import AppKit
|
||||
|
||||
let canvasWindowLogger = Logger(subsystem: "com.clawdbot", 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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
"clawdbot.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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 == "clawdbot" {
|
||||
if self.webView.url?.scheme == CanvasScheme.scheme {
|
||||
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 scheme == CanvasScheme.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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#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)? {
|
||||
CanvasA2UIActionMessageHandler.parseIPv4(host)
|
||||
}
|
||||
|
||||
static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||
CanvasA2UIActionMessageHandler.isLocalNetworkIPv4(ip)
|
||||
}
|
||||
|
||||
static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
CanvasA2UIActionMessageHandler.isLocalNetworkCanvasURL(url)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,166 @@
|
||||
import AppKit
|
||||
import ClawdbotIPC
|
||||
|
||||
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 = "Clawdbot 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import AppKit
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
final class CanvasWindowController: NSWindowController, WKNavigationDelegate, 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.default.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")
|
||||
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: CanvasScheme.scheme)
|
||||
canvasWindowLogger.debug("CanvasWindowController init scheme handler installed")
|
||||
|
||||
// Bridge A2UI "a2uiaction" DOM events back into the native agent loop.
|
||||
//
|
||||
// Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link
|
||||
// (includes the app-generated key so it won't prompt).
|
||||
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
|
||||
let deepLinkKey = DeepLinkHandler.currentCanvasKey()
|
||||
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
|
||||
let bridgeScript = """
|
||||
(() => {
|
||||
try {
|
||||
if (location.protocol !== '\(CanvasScheme.scheme):') return;
|
||||
if (globalThis.__clawdbotA2UIBridgeInstalled) return;
|
||||
globalThis.__clawdbotA2UIBridgeInstalled = true;
|
||||
|
||||
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
|
||||
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?.clawdbotCanvasA2UIAction;
|
||||
|
||||
// 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.clawdbotA2UI || !!document.querySelector('clawdbot-a2ui-host');
|
||||
if (hasBundledA2UIHost && handler?.postMessage) return;
|
||||
|
||||
// Otherwise, forward directly when possible.
|
||||
if (!hasBundledA2UIHost && handler?.postMessage) {
|
||||
handler.postMessage({ userAction });
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : '';
|
||||
const message =
|
||||
'CANVAS_A2UI action=' + userAction.name +
|
||||
' session=' + sessionKey +
|
||||
' surface=' + userAction.surfaceId +
|
||||
' component=' + (userAction.sourceComponentId || '-') +
|
||||
' host=' + machineName.replace(/\\s+/g, '_') +
|
||||
' instance=' + instanceId +
|
||||
ctx +
|
||||
' default=update_canvas';
|
||||
const params = new URLSearchParams();
|
||||
params.set('message', message);
|
||||
params.set('sessionKey', sessionKey);
|
||||
params.set('thinking', 'low');
|
||||
params.set('deliver', 'false');
|
||||
params.set('channel', 'last');
|
||||
params.set('key', deepLinkKey);
|
||||
location.href = 'clawdbot://agent?' + params.toString();
|
||||
} 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 webView.url?.scheme == CanvasScheme.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.default.fileExists(atPath: indexA.path),
|
||||
!FileManager.default.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
|
||||
self.webView.configuration.userContentController.add(handler, name: CanvasA2UIActionMessageHandler.messageName)
|
||||
|
||||
self.webView.navigationDelegate = 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 {
|
||||
self.webView.configuration.userContentController
|
||||
.removeScriptMessageHandler(forName: CanvasA2UIActionMessageHandler.messageName)
|
||||
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.default.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() {
|
||||
let enabled = self.debugStatusEnabled
|
||||
let title = Self.jsOptionalStringLiteral(self.debugStatusTitle)
|
||||
let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle)
|
||||
let js = """
|
||||
(() => {
|
||||
try {
|
||||
const api = globalThis.__clawdbot;
|
||||
if (!api) return;
|
||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
||||
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
|
||||
}
|
||||
if (!\(enabled ? "true" : "false")) return;
|
||||
if (typeof api.setStatus === 'function') {
|
||||
api.setStatus(\(title), \(subtitle));
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
"""
|
||||
self.webView.evaluateJavaScript(js) { _, _ in }
|
||||
}
|
||||
|
||||
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 withCheckedThrowingContinuation { cont in
|
||||
self.webView.evaluateJavaScript(javaScript) { result, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if let result {
|
||||
cont.resume(returning: String(describing: result))
|
||||
} else {
|
||||
cont.resume(returning: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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/clawdbot-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?) -> Bool {
|
||||
let trimmed = (self.currentTarget ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty || trimmed == "/" { return true }
|
||||
if let lastAuto = lastAutoTarget?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!lastAuto.isEmpty,
|
||||
trimmed == lastAuto
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
|
||||
enum ClawdbotConfigFile {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "config")
|
||||
|
||||
static func url() -> URL {
|
||||
ClawdbotPaths.configURL
|
||||
}
|
||||
|
||||
static func stateDirURL() -> URL {
|
||||
ClawdbotPaths.stateDirURL
|
||||
}
|
||||
|
||||
static func defaultWorkspaceURL() -> URL {
|
||||
ClawdbotPaths.workspaceURL
|
||||
}
|
||||
|
||||
static func loadDict() -> [String: Any] {
|
||||
let url = self.url()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
self.logger.warning("config JSON root invalid")
|
||||
return [:]
|
||||
}
|
||||
return root
|
||||
} catch {
|
||||
self.logger.warning("config read failed: \(error.localizedDescription)")
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
static func saveDict(_ dict: [String: Any]) {
|
||||
if ProcessInfo.processInfo.isNixMode { return }
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||
let url = self.url()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
} catch {
|
||||
self.logger.error("config save failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
static func loadGatewayDict() -> [String: Any] {
|
||||
let root = self.loadDict()
|
||||
return root["gateway"] as? [String: Any] ?? [:]
|
||||
}
|
||||
|
||||
static func updateGatewayDict(_ mutate: (inout [String: Any]) -> Void) {
|
||||
var root = self.loadDict()
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
mutate(&gateway)
|
||||
if gateway.isEmpty {
|
||||
root.removeValue(forKey: "gateway")
|
||||
} else {
|
||||
root["gateway"] = gateway
|
||||
}
|
||||
self.saveDict(root)
|
||||
}
|
||||
|
||||
static func browserControlEnabled(defaultValue: Bool = true) -> Bool {
|
||||
let root = self.loadDict()
|
||||
let browser = root["browser"] as? [String: Any]
|
||||
return browser?["enabled"] as? Bool ?? defaultValue
|
||||
}
|
||||
|
||||
static func setBrowserControlEnabled(_ enabled: Bool) {
|
||||
var root = self.loadDict()
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
browser["enabled"] = enabled
|
||||
root["browser"] = browser
|
||||
self.saveDict(root)
|
||||
self.logger.debug("browser control updated enabled=\(enabled)")
|
||||
}
|
||||
|
||||
static func agentWorkspace() -> String? {
|
||||
let root = self.loadDict()
|
||||
let agent = root["agent"] as? [String: Any]
|
||||
return agent?["workspace"] as? String
|
||||
}
|
||||
|
||||
static func setAgentWorkspace(_ workspace: String?) {
|
||||
var root = self.loadDict()
|
||||
var agent = root["agent"] as? [String: Any] ?? [:]
|
||||
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty {
|
||||
agent.removeValue(forKey: "workspace")
|
||||
} else {
|
||||
agent["workspace"] = trimmed
|
||||
}
|
||||
root["agent"] = agent
|
||||
self.saveDict(root)
|
||||
self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)")
|
||||
}
|
||||
|
||||
static func gatewayPassword() -> String? {
|
||||
let root = self.loadDict()
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return remote["password"] as? String
|
||||
}
|
||||
|
||||
static func gatewayPort() -> Int? {
|
||||
let root = self.loadDict()
|
||||
guard let gateway = root["gateway"] as? [String: Any] else { return nil }
|
||||
if let port = gateway["port"] as? Int, port > 0 { return port }
|
||||
if let number = gateway["port"] as? NSNumber, number.intValue > 0 {
|
||||
return number.intValue
|
||||
}
|
||||
if let raw = gateway["port"] as? String,
|
||||
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
parsed > 0
|
||||
{
|
||||
return parsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
enum ClawdbotEnv {
|
||||
static func path(_ key: String) -> String? {
|
||||
// Normalize env overrides once so UI + file IO stay consistent.
|
||||
guard let value = ProcessInfo.processInfo.environment[key]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
enum ClawdbotPaths {
|
||||
private static let configPathEnv = "CLAWDBOT_CONFIG_PATH"
|
||||
private static let stateDirEnv = "CLAWDBOT_STATE_DIR"
|
||||
|
||||
static var stateDirURL: URL {
|
||||
if let override = ClawdbotEnv.path(self.stateDirEnv) {
|
||||
return URL(fileURLWithPath: override, isDirectory: true)
|
||||
}
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot", isDirectory: true)
|
||||
}
|
||||
|
||||
static var configURL: URL {
|
||||
if let override = ClawdbotEnv.path(self.configPathEnv) {
|
||||
return URL(fileURLWithPath: override)
|
||||
}
|
||||
return self.stateDirURL.appendingPathComponent("clawdbot.json")
|
||||
}
|
||||
|
||||
static var workspaceURL: URL {
|
||||
self.stateDirURL.appendingPathComponent("workspace", isDirectory: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import Foundation
|
||||
|
||||
enum CommandResolver {
|
||||
private static let projectRootDefaultsKey = "clawdbot.gatewayProjectRootPath"
|
||||
private static let helperName = "clawdbot"
|
||||
|
||||
static func gatewayEntrypoint(in root: URL) -> String? {
|
||||
let distEntry = root.appendingPathComponent("dist/index.js").path
|
||||
if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry }
|
||||
let binEntry = root.appendingPathComponent("bin/clawdbot.js").path
|
||||
if FileManager.default.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 <<'__CLAWDBOT_ERR__' >&2
|
||||
\(message)
|
||||
__CLAWDBOT_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.default.fileExists(atPath: url.path)
|
||||
{
|
||||
return url
|
||||
}
|
||||
let fallback = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Projects/clawdbot")
|
||||
if FileManager.default.fileExists(atPath: fallback.path) {
|
||||
return fallback
|
||||
}
|
||||
return FileManager.default.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.default.homeDirectoryForCurrentUser
|
||||
let projectRoot = self.projectRoot()
|
||||
return self.preferredPaths(home: home, current: current, projectRoot: projectRoot)
|
||||
}
|
||||
|
||||
static func preferredPaths(home: URL, current: [String], projectRoot: URL) -> [String] {
|
||||
var extras = [
|
||||
home.appendingPathComponent("Library/pnpm").path,
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
]
|
||||
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||
extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1)
|
||||
var seen = Set<String>()
|
||||
// Preserve order while stripping duplicates so PATH lookups remain deterministic.
|
||||
return (extras + current).filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
private static func nodeManagerBinPaths(home: URL) -> [String] {
|
||||
var bins: [String] = []
|
||||
|
||||
// Volta
|
||||
let volta = home.appendingPathComponent(".volta/bin")
|
||||
if FileManager.default.fileExists(atPath: volta.path) {
|
||||
bins.append(volta.path)
|
||||
}
|
||||
|
||||
// asdf
|
||||
let asdf = home.appendingPathComponent(".asdf/shims")
|
||||
if FileManager.default.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.default.fileExists(atPath: base.path) else { return [] }
|
||||
let entries: [String]
|
||||
do {
|
||||
entries = try FileManager.default.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.default.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.default.isExecutableFile(atPath: candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func clawdbotExecutable(searchPaths: [String]? = nil) -> String? {
|
||||
self.findExecutable(named: self.helperName, searchPaths: searchPaths)
|
||||
}
|
||||
|
||||
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
|
||||
let root = projectRoot ?? self.projectRoot()
|
||||
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
||||
return FileManager.default.isExecutableFile(atPath: candidate) ? candidate : nil
|
||||
}
|
||||
|
||||
static func nodeCliPath() -> String? {
|
||||
let candidate = self.projectRoot().appendingPathComponent("bin/clawdbot.js").path
|
||||
return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil
|
||||
}
|
||||
|
||||
static func hasAnyClawdbotInvoker(searchPaths: [String]? = nil) -> Bool {
|
||||
if self.clawdbotExecutable(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 clawdbotNodeCommand(
|
||||
subcommand: String,
|
||||
extraArgs: [String] = [],
|
||||
defaults: UserDefaults = .standard,
|
||||
searchPaths: [String]? = nil) -> [String]
|
||||
{
|
||||
let settings = self.connectionSettings(defaults: defaults)
|
||||
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs,
|
||||
settings: settings)
|
||||
{
|
||||
return ssh
|
||||
}
|
||||
|
||||
let runtimeResult = self.runtimeResolution(searchPaths: searchPaths)
|
||||
|
||||
switch runtimeResult {
|
||||
case let .success(runtime):
|
||||
let root = self.projectRoot()
|
||||
if let clawdbotPath = self.projectClawdbotExecutable(projectRoot: root) {
|
||||
return [clawdbotPath, subcommand] + extraArgs
|
||||
}
|
||||
|
||||
if let entry = self.gatewayEntrypoint(in: root) {
|
||||
return self.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs)
|
||||
}
|
||||
if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) {
|
||||
// Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs.
|
||||
return [pnpm, "--silent", "clawdbot", subcommand] + extraArgs
|
||||
}
|
||||
if let clawdbotPath = self.clawdbotExecutable(searchPaths: searchPaths) {
|
||||
return [clawdbotPath, subcommand] + extraArgs
|
||||
}
|
||||
|
||||
let missingEntry = """
|
||||
clawdbot entrypoint missing (looked for dist/index.js or bin/clawdbot.js); run pnpm build.
|
||||
"""
|
||||
return self.errorCommand(with: missingEntry)
|
||||
|
||||
case let .failure(error):
|
||||
return self.runtimeErrorCommand(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Existing callers still refer to clawdbotCommand; keep it as node alias.
|
||||
static func clawdbotCommand(
|
||||
subcommand: String,
|
||||
extraArgs: [String] = [],
|
||||
defaults: UserDefaults = .standard,
|
||||
searchPaths: [String]? = nil) -> [String]
|
||||
{
|
||||
self.clawdbotNodeCommand(
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs,
|
||||
defaults: defaults,
|
||||
searchPaths: searchPaths)
|
||||
}
|
||||
|
||||
// MARK: - SSH helpers
|
||||
|
||||
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 }
|
||||
|
||||
var args: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
args.append(contentsOf: ["-i", settings.identity])
|
||||
}
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
|
||||
// Run the real clawdbot 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/clawdbot"
|
||||
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 \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; 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 clawdbot >/dev/null 2>&1; then
|
||||
CLI="$(command -v clawdbot)"
|
||||
clawdbot \(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/bin/clawdbot.js" ]; then
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
CLI="node $PRJ/bin/clawdbot.js"
|
||||
node "$PRJ/bin/clawdbot.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 clawdbot"
|
||||
pnpm --silent clawdbot \(quotedArgs);
|
||||
else
|
||||
echo "clawdbot CLI missing on remote host"; exit 127;
|
||||
fi
|
||||
"""
|
||||
args.append(contentsOf: ["/bin/sh", "-c", scriptBody])
|
||||
return ["/usr/bin/ssh"] + args
|
||||
}
|
||||
|
||||
struct RemoteSettings {
|
||||
let mode: AppState.ConnectionMode
|
||||
let target: String
|
||||
let identity: String
|
||||
let projectRoot: String
|
||||
let cliPath: String
|
||||
}
|
||||
|
||||
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||
let modeRaw = defaults.string(forKey: connectionModeKey)
|
||||
let mode: AppState.ConnectionMode
|
||||
if let modeRaw {
|
||||
mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
||||
} else {
|
||||
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
||||
mode = seen ? .local : .unconfigured
|
||||
}
|
||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
|
||||
let cliPath = defaults.string(forKey: remoteCliPathKey) ?? ""
|
||||
return RemoteSettings(
|
||||
mode: mode,
|
||||
target: self.sanitizedTarget(target),
|
||||
identity: identity,
|
||||
projectRoot: projectRoot,
|
||||
cliPath: cliPath)
|
||||
}
|
||||
|
||||
static var attachExistingGatewayOnly: Bool {
|
||||
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||
}
|
||||
|
||||
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 = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { 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)...])
|
||||
port = Int(portStr) ?? 22
|
||||
} else {
|
||||
host = userHostPort
|
||||
port = 22
|
||||
}
|
||||
|
||||
return SSHParsedTarget(user: user, host: host, port: port)
|
||||
}
|
||||
|
||||
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.default.homeDirectoryForCurrentUser.path
|
||||
expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home)
|
||||
}
|
||||
return URL(fileURLWithPath: expanded)
|
||||
}
|
||||
|
||||
#if SWIFT_PACKAGE
|
||||
static func _testNodeManagerBinPaths(home: URL) -> [String] {
|
||||
self.nodeManagerBinPaths(home: home)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class ConfigFileWatcher: @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let queue: DispatchQueue
|
||||
private var stream: FSEventStreamRef?
|
||||
private var pending = false
|
||||
private let onChange: () -> Void
|
||||
private let watchedDir: URL
|
||||
private let targetPath: String
|
||||
private let targetName: String
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.url = url
|
||||
self.queue = DispatchQueue(label: "com.clawdbot.configwatcher")
|
||||
self.onChange = onChange
|
||||
self.watchedDir = url.deletingLastPathComponent()
|
||||
self.targetPath = url.path
|
||||
self.targetName = url.lastPathComponent
|
||||
}
|
||||
|
||||
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<ConfigFileWatcher>.fromOpaque(pointer).release()
|
||||
},
|
||||
copyDescription: nil)
|
||||
|
||||
let paths = [self.watchedDir.path] 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 ConfigFileWatcher {
|
||||
private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in
|
||||
guard let info else { return }
|
||||
let watcher = Unmanaged<ConfigFileWatcher>.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.matchesTarget(eventPaths: eventPaths) else { return }
|
||||
|
||||
if self.pending { return }
|
||||
self.pending = true
|
||||
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.onChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func matchesTarget(eventPaths: UnsafeMutableRawPointer?) -> Bool {
|
||||
guard let eventPaths else { return true }
|
||||
let paths = unsafeBitCast(eventPaths, to: NSArray.self)
|
||||
for case let path as String in paths {
|
||||
if path == self.targetPath { return true }
|
||||
if path.hasSuffix("/\(self.targetName)") { return true }
|
||||
if path == self.watchedDir.path { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ConfigSettings: View {
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||
private let state = AppStateStore.shared
|
||||
private let labelColumnWidth: CGFloat = 120
|
||||
private static let browserAttachOnlyHelp =
|
||||
"When enabled, the browser server will only connect if the clawd browser is already running."
|
||||
private static let browserProfileNote =
|
||||
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
|
||||
+ "so it won’t interfere with your daily browser."
|
||||
@State private var configModel: String = ""
|
||||
@State private var customModel: String = ""
|
||||
@State private var configSaving = false
|
||||
@State private var hasLoaded = false
|
||||
@State private var models: [ModelChoice] = []
|
||||
@State private var modelsLoading = false
|
||||
@State private var modelError: String?
|
||||
@State private var modelsSourceLabel: String?
|
||||
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||||
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||||
@State private var allowAutosave = false
|
||||
@State private var heartbeatMinutes: Int?
|
||||
@State private var heartbeatBody: String = "HEARTBEAT"
|
||||
|
||||
// clawd browser settings (stored in ~/.clawdbot/clawdbot.json under "browser")
|
||||
@State private var browserEnabled: Bool = true
|
||||
@State private var browserControlUrl: String = "http://127.0.0.1:18791"
|
||||
@State private var browserColorHex: String = "#FF4500"
|
||||
@State private var browserAttachOnly: Bool = false
|
||||
|
||||
// Talk mode settings (stored in ~/.clawdbot/clawdbot.json under "talk")
|
||||
@State private var talkVoiceId: String = ""
|
||||
@State private var talkInterruptOnSpeech: Bool = true
|
||||
@State private var talkApiKey: String = ""
|
||||
@State private var gatewayApiKeyFound = false
|
||||
|
||||
private struct ConfigDraft {
|
||||
let configModel: String
|
||||
let customModel: String
|
||||
let heartbeatMinutes: Int?
|
||||
let heartbeatBody: String
|
||||
let browserEnabled: Bool
|
||||
let browserControlUrl: String
|
||||
let browserColorHex: String
|
||||
let browserAttachOnly: Bool
|
||||
let talkVoiceId: String
|
||||
let talkApiKey: String
|
||||
let talkInterruptOnSpeech: Bool
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView { self.content }
|
||||
.onChange(of: self.modelCatalogPath) { _, _ in
|
||||
Task { await self.loadModels() }
|
||||
}
|
||||
.onChange(of: self.modelCatalogReloadBump) { _, _ in
|
||||
Task { await self.loadModels() }
|
||||
}
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
self.hasLoaded = true
|
||||
await self.loadConfig()
|
||||
await self.loadModels()
|
||||
await self.refreshGatewayTalkApiKey()
|
||||
self.allowAutosave = true
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
self.agentSection
|
||||
.disabled(self.isNixMode)
|
||||
self.heartbeatSection
|
||||
.disabled(self.isNixMode)
|
||||
self.talkSection
|
||||
.disabled(self.isNixMode)
|
||||
self.browserSection
|
||||
.disabled(self.isNixMode)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
Text("Clawdbot CLI config")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text(self.isNixMode
|
||||
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
|
||||
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var agentSection: some View {
|
||||
GroupBox("Agent") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Model")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
self.modelPicker
|
||||
self.customModelField
|
||||
self.modelMetaLabels
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var modelPicker: some View {
|
||||
Picker("Model", selection: self.$configModel) {
|
||||
ForEach(self.models) { choice in
|
||||
Text("\(choice.name) — \(choice.provider.uppercased())")
|
||||
.tag(choice.id)
|
||||
}
|
||||
Text("Manual entry…").tag("__custom__")
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||||
.onChange(of: self.configModel) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var customModelField: some View {
|
||||
if self.configModel == "__custom__" {
|
||||
TextField("Enter model ID", text: self.$customModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.customModel) { _, newValue in
|
||||
self.configModel = newValue
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var modelMetaLabels: some View {
|
||||
if let contextLabel = self.selectedContextLabel {
|
||||
Text(contextLabel)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let authMode = self.selectedAnthropicAuthMode {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(authMode.isConfigured ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text("Anthropic auth: \(authMode.shortLabel)")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(authMode.isConfigured ? Color.secondary : Color.orange)
|
||||
.help(self.anthropicAuthHelpText)
|
||||
|
||||
AnthropicAuthControls(connectionMode: self.state.connectionMode)
|
||||
}
|
||||
|
||||
if let modelError {
|
||||
Text(modelError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let modelsSourceLabel {
|
||||
Text("Model catalog: \(modelsSourceLabel)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var anthropicAuthHelpText: String {
|
||||
"Determined from Clawdbot OAuth token file (~/.clawdbot/credentials/oauth.json) " +
|
||||
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
|
||||
}
|
||||
|
||||
private var heartbeatSection: some View {
|
||||
GroupBox("Heartbeat") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Schedule")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 12) {
|
||||
Stepper(
|
||||
value: Binding(
|
||||
get: { self.heartbeatMinutes ?? 10 },
|
||||
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
|
||||
in: 0...720)
|
||||
{
|
||||
Text("Every \(self.heartbeatMinutes ?? 10) min")
|
||||
.frame(width: 150, alignment: .leading)
|
||||
}
|
||||
.help("Set to 0 to disable automatic heartbeats")
|
||||
|
||||
TextField("HEARTBEAT", text: self.$heartbeatBody)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.heartbeatBody) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
.help("Message body sent on each heartbeat")
|
||||
}
|
||||
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var browserSection: some View {
|
||||
GroupBox("Browser (clawd)") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$browserEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Control URL")
|
||||
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Browser path")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let label = self.browserPathLabel {
|
||||
Text(label)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
} else {
|
||||
Text("—")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Accent")
|
||||
HStack(spacing: 8) {
|
||||
TextField("#FF4500", text: self.$browserColorHex)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 120)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
|
||||
Circle()
|
||||
.fill(self.browserColor)
|
||||
.frame(width: 12, height: 12)
|
||||
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
||||
Text("lobster-orange")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attach only")
|
||||
Toggle("", isOn: self.$browserAttachOnly)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
|
||||
.help(Self.browserAttachOnlyHelp)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(Self.browserProfileNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var talkSection: some View {
|
||||
GroupBox("Talk Mode") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Voice ID")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("ElevenLabs voice ID", text: self.$talkVoiceId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.talkVoiceId) { _, _ in self.autosaveConfig() }
|
||||
if !self.talkVoiceSuggestions.isEmpty {
|
||||
Menu {
|
||||
ForEach(self.talkVoiceSuggestions, id: \.self) { value in
|
||||
Button(value) {
|
||||
self.talkVoiceId = value
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Suggestions", systemImage: "chevron.up.chevron.down")
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
Text("Defaults to ELEVENLABS_VOICE_ID / SAG_VOICE_ID if unset.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("API key")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
SecureField("ELEVENLABS_API_KEY", text: self.$talkApiKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(self.hasEnvApiKey)
|
||||
.onChange(of: self.talkApiKey) { _, _ in self.autosaveConfig() }
|
||||
if !self.hasEnvApiKey, !self.talkApiKey.isEmpty {
|
||||
Button("Clear") {
|
||||
self.talkApiKey = ""
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
self.statusLine(label: self.apiKeyStatusLabel, color: self.apiKeyStatusColor)
|
||||
if self.hasEnvApiKey {
|
||||
Text("Using ELEVENLABS_API_KEY from the environment.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.gatewayApiKeyFound,
|
||||
self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
Text("Using API key from the gateway profile.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Interrupt")
|
||||
Toggle("Stop speaking when you start talking", isOn: self.$talkInterruptOnSpeech)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.onChange(of: self.talkInterruptOnSpeech) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func gridLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
private func statusLine(label: String, color: Color) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
private func loadConfig() async {
|
||||
let parsed = await ConfigStore.load()
|
||||
let agent = parsed["agent"] as? [String: Any]
|
||||
let heartbeatMinutes = agent?["heartbeatMinutes"] as? Int
|
||||
let heartbeatBody = agent?["heartbeatBody"] as? String
|
||||
let browser = parsed["browser"] as? [String: Any]
|
||||
let talk = parsed["talk"] as? [String: Any]
|
||||
|
||||
let loadedModel = (agent?["model"] as? String) ?? ""
|
||||
if !loadedModel.isEmpty {
|
||||
self.configModel = loadedModel
|
||||
self.customModel = loadedModel
|
||||
} else {
|
||||
self.configModel = SessionLoader.fallbackModel
|
||||
self.customModel = SessionLoader.fallbackModel
|
||||
}
|
||||
|
||||
if let heartbeatMinutes { self.heartbeatMinutes = heartbeatMinutes }
|
||||
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
|
||||
|
||||
if let browser {
|
||||
if let enabled = browser["enabled"] as? Bool { self.browserEnabled = enabled }
|
||||
if let url = browser["controlUrl"] as? String, !url.isEmpty { self.browserControlUrl = url }
|
||||
if let color = browser["color"] as? String, !color.isEmpty { self.browserColorHex = color }
|
||||
if let attachOnly = browser["attachOnly"] as? Bool { self.browserAttachOnly = attachOnly }
|
||||
}
|
||||
|
||||
if let talk {
|
||||
if let voice = talk["voiceId"] as? String { self.talkVoiceId = voice }
|
||||
if let apiKey = talk["apiKey"] as? String { self.talkApiKey = apiKey }
|
||||
if let interrupt = talk["interruptOnSpeech"] as? Bool {
|
||||
self.talkInterruptOnSpeech = interrupt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshGatewayTalkApiKey() async {
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
let talk = snap.config?["talk"]?.dictionaryValue
|
||||
let apiKey = talk?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.gatewayApiKeyFound = !(apiKey ?? "").isEmpty
|
||||
} catch {
|
||||
self.gatewayApiKeyFound = false
|
||||
}
|
||||
}
|
||||
|
||||
private func autosaveConfig() {
|
||||
guard self.allowAutosave, !self.isNixMode else { return }
|
||||
Task { await self.saveConfig() }
|
||||
}
|
||||
|
||||
private func saveConfig() async {
|
||||
guard !self.configSaving else { return }
|
||||
self.configSaving = true
|
||||
defer { self.configSaving = false }
|
||||
|
||||
let configModel = self.configModel
|
||||
let customModel = self.customModel
|
||||
let heartbeatMinutes = self.heartbeatMinutes
|
||||
let heartbeatBody = self.heartbeatBody
|
||||
let browserEnabled = self.browserEnabled
|
||||
let browserControlUrl = self.browserControlUrl
|
||||
let browserColorHex = self.browserColorHex
|
||||
let browserAttachOnly = self.browserAttachOnly
|
||||
let talkVoiceId = self.talkVoiceId
|
||||
let talkApiKey = self.talkApiKey
|
||||
let talkInterruptOnSpeech = self.talkInterruptOnSpeech
|
||||
|
||||
let draft = ConfigDraft(
|
||||
configModel: configModel,
|
||||
customModel: customModel,
|
||||
heartbeatMinutes: heartbeatMinutes,
|
||||
heartbeatBody: heartbeatBody,
|
||||
browserEnabled: browserEnabled,
|
||||
browserControlUrl: browserControlUrl,
|
||||
browserColorHex: browserColorHex,
|
||||
browserAttachOnly: browserAttachOnly,
|
||||
talkVoiceId: talkVoiceId,
|
||||
talkApiKey: talkApiKey,
|
||||
talkInterruptOnSpeech: talkInterruptOnSpeech)
|
||||
|
||||
let errorMessage = await ConfigSettings.buildAndSaveConfig(draft)
|
||||
|
||||
if let errorMessage {
|
||||
self.modelError = errorMessage
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
|
||||
var root = await ConfigStore.load()
|
||||
var agent = root["agent"] as? [String: Any] ?? [:]
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
var talk = root["talk"] as? [String: Any] ?? [:]
|
||||
|
||||
let chosenModel = (draft.configModel == "__custom__" ? draft.customModel : draft.configModel)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedModel = chosenModel
|
||||
if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
|
||||
|
||||
if let heartbeatMinutes = draft.heartbeatMinutes {
|
||||
agent["heartbeatMinutes"] = heartbeatMinutes
|
||||
}
|
||||
|
||||
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedBody.isEmpty {
|
||||
agent["heartbeatBody"] = trimmedBody
|
||||
}
|
||||
|
||||
root["agent"] = agent
|
||||
|
||||
browser["enabled"] = draft.browserEnabled
|
||||
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedUrl.isEmpty { browser["controlUrl"] = trimmedUrl }
|
||||
let trimmedColor = draft.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedColor.isEmpty { browser["color"] = trimmedColor }
|
||||
browser["attachOnly"] = draft.browserAttachOnly
|
||||
root["browser"] = browser
|
||||
|
||||
let trimmedVoice = draft.talkVoiceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedVoice.isEmpty {
|
||||
talk.removeValue(forKey: "voiceId")
|
||||
} else {
|
||||
talk["voiceId"] = trimmedVoice
|
||||
}
|
||||
let trimmedApiKey = draft.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedApiKey.isEmpty {
|
||||
talk.removeValue(forKey: "apiKey")
|
||||
} else {
|
||||
talk["apiKey"] = trimmedApiKey
|
||||
}
|
||||
talk["interruptOnSpeech"] = draft.talkInterruptOnSpeech
|
||||
root["talk"] = talk
|
||||
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return nil
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private var browserColor: Color {
|
||||
let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw
|
||||
guard hex.count == 6, let value = Int(hex, radix: 16) else { return .orange }
|
||||
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)
|
||||
}
|
||||
|
||||
private var talkVoiceSuggestions: [String] {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
let candidates = [
|
||||
self.talkVoiceId,
|
||||
env["ELEVENLABS_VOICE_ID"] ?? "",
|
||||
env["SAG_VOICE_ID"] ?? "",
|
||||
]
|
||||
var seen = Set<String>()
|
||||
return candidates
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
private var hasEnvApiKey: Bool {
|
||||
let raw = ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] ?? ""
|
||||
return !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private var apiKeyStatusLabel: String {
|
||||
if self.hasEnvApiKey { return "ElevenLabs API key: found (environment)" }
|
||||
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "ElevenLabs API key: stored in config"
|
||||
}
|
||||
if self.gatewayApiKeyFound { return "ElevenLabs API key: found (gateway)" }
|
||||
return "ElevenLabs API key: missing"
|
||||
}
|
||||
|
||||
private var apiKeyStatusColor: Color {
|
||||
if self.hasEnvApiKey { return .green }
|
||||
if !self.talkApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return .green }
|
||||
if self.gatewayApiKeyFound { return .green }
|
||||
return .red
|
||||
}
|
||||
|
||||
private var browserPathLabel: String? {
|
||||
guard self.browserEnabled else { return nil }
|
||||
|
||||
let host = (URL(string: self.browserControlUrl)?.host ?? "").lowercased()
|
||||
if !host.isEmpty, !Self.isLoopbackHost(host) {
|
||||
return "remote (\(host))"
|
||||
}
|
||||
|
||||
guard let candidate = Self.detectedBrowserCandidate() else { return nil }
|
||||
return candidate.executablePath ?? candidate.appPath
|
||||
}
|
||||
|
||||
private struct BrowserCandidate {
|
||||
let name: String
|
||||
let appPath: String
|
||||
let executablePath: String?
|
||||
}
|
||||
|
||||
private static func detectedBrowserCandidate() -> BrowserCandidate? {
|
||||
let candidates: [(name: String, appName: String)] = [
|
||||
("Google Chrome Canary", "Google Chrome Canary.app"),
|
||||
("Chromium", "Chromium.app"),
|
||||
("Google Chrome", "Google Chrome.app"),
|
||||
]
|
||||
|
||||
let roots = [
|
||||
"/Applications",
|
||||
"\(NSHomeDirectory())/Applications",
|
||||
]
|
||||
|
||||
let fm = FileManager.default
|
||||
for (name, appName) in candidates {
|
||||
for root in roots {
|
||||
let appPath = "\(root)/\(appName)"
|
||||
if fm.fileExists(atPath: appPath) {
|
||||
let bundle = Bundle(url: URL(fileURLWithPath: appPath))
|
||||
let exec = bundle?.executableURL?.path
|
||||
return BrowserCandidate(name: name, appPath: appPath, executablePath: exec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isLoopbackHost(_ host: String) -> Bool {
|
||||
if host == "localhost" { return true }
|
||||
if host == "127.0.0.1" { return true }
|
||||
if host == "::1" { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func loadModels() async {
|
||||
guard !self.modelsLoading else { return }
|
||||
self.modelsLoading = true
|
||||
self.modelError = nil
|
||||
self.modelsSourceLabel = nil
|
||||
do {
|
||||
let res: ModelsListResult =
|
||||
try await GatewayConnection.shared
|
||||
.requestDecoded(
|
||||
method: .modelsList,
|
||||
timeoutMs: 15000)
|
||||
self.models = res.models
|
||||
self.modelsSourceLabel = "gateway"
|
||||
if !self.configModel.isEmpty,
|
||||
!res.models.contains(where: { $0.id == self.configModel })
|
||||
{
|
||||
self.customModel = self.configModel
|
||||
self.configModel = "__custom__"
|
||||
}
|
||||
} catch {
|
||||
do {
|
||||
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
|
||||
self.models = loaded
|
||||
self.modelsSourceLabel = "local fallback"
|
||||
if !self.configModel.isEmpty,
|
||||
!loaded.contains(where: { $0.id == self.configModel })
|
||||
{
|
||||
self.customModel = self.configModel
|
||||
self.configModel = "__custom__"
|
||||
}
|
||||
} catch {
|
||||
self.modelError = error.localizedDescription
|
||||
self.models = []
|
||||
}
|
||||
}
|
||||
self.modelsLoading = false
|
||||
}
|
||||
|
||||
private struct ModelsListResult: Decodable {
|
||||
let models: [ModelChoice]
|
||||
}
|
||||
|
||||
private var selectedContextLabel: String? {
|
||||
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
|
||||
guard
|
||||
!chosenId.isEmpty,
|
||||
let choice = self.models.first(where: { $0.id == chosenId }),
|
||||
let context = choice.contextWindow
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let human = context >= 1000 ? "\(context / 1000)k" : "\(context)"
|
||||
return "Context window: \(human) tokens"
|
||||
}
|
||||
|
||||
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
|
||||
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
|
||||
guard !chosenId.isEmpty, let choice = self.models.first(where: { $0.id == chosenId }) else { return nil }
|
||||
guard choice.provider.lowercased() == "anthropic" else { return nil }
|
||||
return AnthropicAuthResolver.resolve()
|
||||
}
|
||||
|
||||
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
configuration.label
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
configuration.content
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ConfigSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ConfigSettings()
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
|
||||
enum ConfigStore {
|
||||
struct Overrides: Sendable {
|
||||
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)?
|
||||
}
|
||||
|
||||
private actor OverrideStore {
|
||||
var overrides = Overrides()
|
||||
|
||||
func setOverride(_ overrides: Overrides) {
|
||||
self.overrides = overrides
|
||||
}
|
||||
}
|
||||
|
||||
private static let overrideStore = OverrideStore()
|
||||
|
||||
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()
|
||||
}
|
||||
return ClawdbotConfigFile.loadDict()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func save(_ root: sending [String: Any]) 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 {
|
||||
ClawdbotConfigFile.saveDict(root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func loadFromGateway() async -> [String: Any] {
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
return snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
} catch {
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func saveToGateway(_ root: [String: Any]) async throws {
|
||||
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."
|
||||
])
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configSet,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func _testSetOverrides(_ overrides: Overrides) async {
|
||||
await self.overrideStore.setOverride(overrides)
|
||||
}
|
||||
|
||||
static func _testClearOverrides() async {
|
||||
await self.overrideStore.setOverride(.init())
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class ConnectionModeCoordinator {
|
||||
static let shared = ConnectionModeCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "connection")
|
||||
|
||||
/// 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 {
|
||||
switch mode {
|
||||
case .unconfigured:
|
||||
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 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,
|
||||
attachExistingOnly: AppStateStore.attachExistingGatewayOnly)
|
||||
{
|
||||
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 {
|
||||
_ = 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
let launchdLabel = "com.clawdbot.mac"
|
||||
let gatewayLaunchdLabel = "com.clawdbot.gateway"
|
||||
let onboardingVersionKey = "clawdbot.onboardingVersion"
|
||||
let currentOnboardingVersion = 7
|
||||
let pauseDefaultsKey = "clawdbot.pauseEnabled"
|
||||
let iconAnimationsEnabledKey = "clawdbot.iconAnimationsEnabled"
|
||||
let swabbleEnabledKey = "clawdbot.swabbleEnabled"
|
||||
let swabbleTriggersKey = "clawdbot.swabbleTriggers"
|
||||
let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
|
||||
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
|
||||
let showDockIconKey = "clawdbot.showDockIcon"
|
||||
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
||||
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
|
||||
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
|
||||
let voiceWakeAdditionalLocalesKey = "clawdbot.voiceWakeAdditionalLocaleIDs"
|
||||
let voicePushToTalkEnabledKey = "clawdbot.voicePushToTalkEnabled"
|
||||
let talkEnabledKey = "clawdbot.talkEnabled"
|
||||
let iconOverrideKey = "clawdbot.iconOverride"
|
||||
let connectionModeKey = "clawdbot.connectionMode"
|
||||
let remoteTargetKey = "clawdbot.remoteTarget"
|
||||
let remoteIdentityKey = "clawdbot.remoteIdentity"
|
||||
let remoteProjectRootKey = "clawdbot.remoteProjectRoot"
|
||||
let remoteCliPathKey = "clawdbot.remoteCliPath"
|
||||
let canvasEnabledKey = "clawdbot.canvasEnabled"
|
||||
let cameraEnabledKey = "clawdbot.cameraEnabled"
|
||||
let locationModeKey = "clawdbot.locationMode"
|
||||
let locationPreciseKey = "clawdbot.locationPreciseEnabled"
|
||||
let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
|
||||
let deepLinkKeyKey = "clawdbot.deepLinkKey"
|
||||
let modelCatalogPathKey = "clawdbot.modelCatalogPath"
|
||||
let modelCatalogReloadKey = "clawdbot.modelCatalogReload"
|
||||
let attachExistingGatewayOnlyKey = "clawdbot.gateway.attachExistingOnly"
|
||||
let heartbeatsEnabledKey = "clawdbot.heartbeatsEnabled"
|
||||
let debugFileLogEnabledKey = "clawdbot.debug.fileLogEnabled"
|
||||
let appLogLevelKey = "clawdbot.debug.appLogLevel"
|
||||
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
|
||||
@@ -0,0 +1,121 @@
|
||||
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 paddingTop: CGFloat = 8
|
||||
private let paddingBottom: CGFloat = 8
|
||||
private let paddingTrailing: CGFloat = 10
|
||||
private let paddingLeading: CGFloat = 20
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Context")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 10)
|
||||
Text(self.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let statusText {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, self.paddingTop)
|
||||
.padding(.bottom, self.paddingBottom)
|
||||
.padding(.leading, self.paddingLeading)
|
||||
.padding(.trailing, self.paddingTrailing)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
|
||||
.transaction { txn in txn.animation = nil }
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
let count = self.rows.count
|
||||
if count == 1 { return "1 session · 24h" }
|
||||
return "\(count) sessions · 24h"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
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, Sendable, Identifiable {
|
||||
var id: String { "\(self.runId)-\(self.seq)" }
|
||||
let runId: String
|
||||
let seq: Int
|
||||
let stream: String
|
||||
let ts: Double
|
||||
let data: [String: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 != state else { return }
|
||||
switch 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 let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
||||
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var recoveryTask: Task<Void, Never>?
|
||||
private var lastRecoveryAt: Date?
|
||||
|
||||
private init() {
|
||||
self.startEventStream()
|
||||
}
|
||||
|
||||
func configure() async {
|
||||
self.logger.info("control channel configure mode=local")
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
}
|
||||
}
|
||||
|
||||
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)")
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
await self.configure()
|
||||
} catch {
|
||||
self.state = .degraded(error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
await GatewayConnection.shared.shutdown()
|
||||
self.state = .disconnected
|
||||
self.lastPingMs = 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.state = .connected
|
||||
return payload
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .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) async throws -> Data
|
||||
{
|
||||
do {
|
||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) {
|
||||
$0[$1.key] = AnyCodable($1.value.base)
|
||||
}
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: method,
|
||||
params: rawParams,
|
||||
timeoutMs: timeoutMs)
|
||||
self.state = .connected
|
||||
return data
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .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 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
|
||||
return
|
||||
"Gateway rejected token; set CLAWDBOT_GATEWAY_TOKEN in the mac app environment " +
|
||||
"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 AppStateStore.attachExistingGatewayOnly, !isRemote {
|
||||
return """
|
||||
Cannot reach gateway at localhost:\(port) and “Attach existing gateway only” is enabled.
|
||||
Disable it in Debug Settings or start a gateway on that port.
|
||||
"""
|
||||
}
|
||||
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:
|
||||
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 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)")
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.logger.info("control channel recovery finished")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
self.recoveryTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] 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.state = .degraded("gateway shutdown")
|
||||
case .snapshot:
|
||||
self.state = .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 = event.data["args"]?.value as? [String: AnyCodable]
|
||||
WorkActivityStore.shared.handleTool(
|
||||
sessionKey: sessionKey,
|
||||
phase: phase,
|
||||
name: name,
|
||||
meta: meta,
|
||||
args: args)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let controlHeartbeat = Notification.Name("clawdbot.control.heartbeat")
|
||||
static let controlAgentEvent = Notification.Name("clawdbot.control.agent")
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
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 bodyCorner: CGFloat
|
||||
let leftEarRect: CGRect
|
||||
let rightEarRect: CGRect
|
||||
let earCorner: CGFloat
|
||||
let earW: CGFloat
|
||||
let earH: CGFloat
|
||||
let legW: CGFloat
|
||||
let legH: CGFloat
|
||||
let legSpacing: CGFloat
|
||||
let legStartX: CGFloat
|
||||
let legYBase: CGFloat
|
||||
let legLift: CGFloat
|
||||
let legHeightScale: CGFloat
|
||||
let eyeW: CGFloat
|
||||
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.78)
|
||||
let bodyH = snapY(h * 0.58)
|
||||
let bodyX = snapX((w - bodyW) / 2)
|
||||
let bodyY = snapY(h * 0.36)
|
||||
let bodyCorner = snapX(w * 0.09)
|
||||
|
||||
let earW = snapX(w * 0.22)
|
||||
let earH = snapY(bodyH * 0.54 * earScale * (1 - 0.08 * abs(earWiggle)))
|
||||
let earCorner = snapX(earW * 0.24)
|
||||
let leftEarRect = CGRect(
|
||||
x: snapX(bodyX - earW * 0.55 + earWiggle),
|
||||
y: snapY(bodyY + bodyH * 0.08 + earWiggle * 0.4),
|
||||
width: earW,
|
||||
height: earH)
|
||||
let rightEarRect = CGRect(
|
||||
x: snapX(bodyX + bodyW - earW * 0.45 - earWiggle),
|
||||
y: snapY(bodyY + bodyH * 0.08 - earWiggle * 0.4),
|
||||
width: earW,
|
||||
height: earH)
|
||||
|
||||
let legW = snapX(w * 0.11)
|
||||
let legH = snapY(h * 0.26)
|
||||
let legSpacing = snapX(w * 0.085)
|
||||
let legsWidth = snapX(4 * legW + 3 * legSpacing)
|
||||
let legStartX = snapX((w - legsWidth) / 2)
|
||||
let legLift = snapY(legH * 0.35 * legWiggle)
|
||||
let legYBase = snapY(bodyY - legH + h * 0.05)
|
||||
let legHeightScale = 1 - 0.12 * legWiggle
|
||||
|
||||
let eyeW = snapX(bodyW * 0.2)
|
||||
let eyeY = snapY(bodyY + bodyH * 0.56)
|
||||
let eyeOffset = snapX(bodyW * 0.24)
|
||||
|
||||
self.bodyRect = CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH)
|
||||
self.bodyCorner = bodyCorner
|
||||
self.leftEarRect = leftEarRect
|
||||
self.rightEarRect = rightEarRect
|
||||
self.earCorner = earCorner
|
||||
self.earW = earW
|
||||
self.earH = earH
|
||||
self.legW = legW
|
||||
self.legH = legH
|
||||
self.legSpacing = legSpacing
|
||||
self.legStartX = legStartX
|
||||
self.legYBase = legYBase
|
||||
self.legLift = legLift
|
||||
self.legHeightScale = legHeightScale
|
||||
self.eyeW = eyeW
|
||||
self.eyeY = eyeY
|
||||
self.eyeOffset = eyeOffset
|
||||
}
|
||||
}
|
||||
|
||||
private struct FaceOptions {
|
||||
let blink: CGFloat
|
||||
let earHoles: Bool
|
||||
let earScale: 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(false)
|
||||
|
||||
let canvas = self.makeCanvas(for: rep, context: context)
|
||||
let geometry = Geometry(canvas: canvas, legWiggle: legWiggle, earWiggle: earWiggle, earScale: earScale)
|
||||
|
||||
self.drawBody(in: canvas, geometry: geometry)
|
||||
let face = FaceOptions(
|
||||
blink: blink,
|
||||
earHoles: earHoles,
|
||||
earScale: earScale,
|
||||
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.setFillColor(NSColor.labelColor.cgColor)
|
||||
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: geometry.bodyRect,
|
||||
cornerWidth: geometry.bodyCorner,
|
||||
cornerHeight: geometry.bodyCorner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: geometry.leftEarRect,
|
||||
cornerWidth: geometry.earCorner,
|
||||
cornerHeight: geometry.earCorner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: geometry.rightEarRect,
|
||||
cornerWidth: geometry.earCorner,
|
||||
cornerHeight: geometry.earCorner,
|
||||
transform: nil))
|
||||
|
||||
for i in 0..<4 {
|
||||
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.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.earHoles || options.earScale > 1.05 {
|
||||
let holeW = canvas.snapX(geometry.earW * 0.6)
|
||||
let holeH = canvas.snapY(geometry.earH * 0.46)
|
||||
let holeCorner = canvas.snapX(holeW * 0.34)
|
||||
let leftHoleRect = CGRect(
|
||||
x: canvas.snapX(geometry.leftEarRect.midX - holeW / 2),
|
||||
y: canvas.snapY(geometry.leftEarRect.midY - holeH / 2 + geometry.earH * 0.04),
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
let rightHoleRect = CGRect(
|
||||
x: canvas.snapX(geometry.rightEarRect.midX - holeW / 2),
|
||||
y: canvas.snapY(geometry.rightEarRect.midY - holeH / 2 + geometry.earH * 0.04),
|
||||
width: holeW,
|
||||
height: holeH)
|
||||
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: leftHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
canvas.context.addPath(CGPath(
|
||||
roundedRect: rightHoleRect,
|
||||
cornerWidth: holeCorner,
|
||||
cornerHeight: holeCorner,
|
||||
transform: nil))
|
||||
}
|
||||
|
||||
if options.eyesClosedLines {
|
||||
let lineW = canvas.snapX(geometry.eyeW * 0.95)
|
||||
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.bodyRect.height * 0.26 * eyeOpen)
|
||||
|
||||
let left = CGMutablePath()
|
||||
left.move(to: CGPoint(
|
||||
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y - eyeH)))
|
||||
left.addLine(to: CGPoint(
|
||||
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y)))
|
||||
left.addLine(to: CGPoint(
|
||||
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(leftCenter.y + eyeH)))
|
||||
left.closeSubpath()
|
||||
|
||||
let right = CGMutablePath()
|
||||
right.move(to: CGPoint(
|
||||
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y - eyeH)))
|
||||
right.addLine(to: CGPoint(
|
||||
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y)))
|
||||
right.addLine(to: CGPoint(
|
||||
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||
y: canvas.snapY(rightCenter.y + eyeH)))
|
||||
right.closeSubpath()
|
||||
|
||||
canvas.context.addPath(left)
|
||||
canvas.context.addPath(right)
|
||||
}
|
||||
|
||||
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)) // ~9–10pt 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
.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)))
|
||||
|
||||
_ = 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)
|
||||
_ = 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)
|
||||
_ = 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
|
||||
@@ -0,0 +1,23 @@
|
||||
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
|
||||
|
||||
@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))
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import Foundation
|
||||
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.enabled = job.enabled
|
||||
self.sessionTarget = job.sessionTarget
|
||||
self.wakeMode = job.wakeMode
|
||||
|
||||
switch job.schedule {
|
||||
case let .at(atMs):
|
||||
self.scheduleKind = .at
|
||||
self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||
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 ?? ""
|
||||
}
|
||||
|
||||
switch job.payload {
|
||||
case let .systemEvent(text):
|
||||
self.payloadKind = .systemEvent
|
||||
self.systemEventText = text
|
||||
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = message
|
||||
self.thinking = thinking ?? ""
|
||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||
self.deliver = deliver ?? false
|
||||
self.channel = GatewayAgentChannel(raw: channel)
|
||||
self.to = to ?? ""
|
||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||
}
|
||||
|
||||
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
|
||||
}
|
||||
|
||||
func save() {
|
||||
do {
|
||||
self.error = nil
|
||||
let payload = try self.buildPayload()
|
||||
self.onSave(payload)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func buildPayload() throws -> [String: AnyCodable] {
|
||||
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if name.isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
|
||||
}
|
||||
let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let schedule: [String: Any]
|
||||
switch self.scheduleKind {
|
||||
case .at:
|
||||
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||
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)."])
|
||||
}
|
||||
schedule = ["kind": "every", "everyMs": ms]
|
||||
case .cron:
|
||||
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if expr.isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
|
||||
}
|
||||
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if tz.isEmpty {
|
||||
schedule = ["kind": "cron", "expr": expr]
|
||||
} else {
|
||||
schedule = ["kind": "cron", "expr": expr, "tz": tz]
|
||||
}
|
||||
}
|
||||
|
||||
let payload: [String: Any] = {
|
||||
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return ["kind": "systemEvent", "text": text]
|
||||
case .agentTurn:
|
||||
return self.buildAgentTurnPayload()
|
||||
}
|
||||
}()
|
||||
|
||||
if self.sessionTarget == .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.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
|
||||
}
|
||||
|
||||
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."])
|
||||
}
|
||||
} else if payload["kind"] as? String == "agentTurn" {
|
||||
if (payload["message"] as? String ?? "").isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
|
||||
}
|
||||
}
|
||||
|
||||
var root: [String: Any] = [
|
||||
"name": name,
|
||||
"enabled": self.enabled,
|
||||
"schedule": schedule,
|
||||
"sessionTarget": self.sessionTarget.rawValue,
|
||||
"wakeMode": self.wakeMode.rawValue,
|
||||
"payload": payload,
|
||||
]
|
||||
if !description.isEmpty { root["description"] = description }
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
root["isolation"] = [
|
||||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||
]
|
||||
}
|
||||
|
||||
return root.mapValues { AnyCodable($0) }
|
||||
}
|
||||
|
||||
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 }
|
||||
payload["deliver"] = self.deliver
|
||||
if self.deliver {
|
||||
payload["channel"] = self.channel.rawValue
|
||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !to.isEmpty { payload["to"] = to }
|
||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
func formatDuration(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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#if DEBUG
|
||||
extension CronJobEditor {
|
||||
mutating func exerciseForTesting() {
|
||||
self.name = "Test job"
|
||||
self.description = "Test description"
|
||||
self.enabled = true
|
||||
self.sessionTarget = .isolated
|
||||
self.wakeMode = .now
|
||||
|
||||
self.scheduleKind = .every
|
||||
self.everyText = "15m"
|
||||
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = "Run diagnostic"
|
||||
self.deliver = true
|
||||
self.channel = .last
|
||||
self.to = "+15551230000"
|
||||
self.thinking = "low"
|
||||
self.timeoutSeconds = "90"
|
||||
self.bestEffortDeliver = true
|
||||
self.postPrefix = "Cron"
|
||||
|
||||
_ = self.buildAgentTurnPayload()
|
||||
_ = try? self.buildPayload()
|
||||
_ = self.formatDuration(ms: 45000)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,346 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CronJobEditor: View {
|
||||
let job: CronJob?
|
||||
@Binding var isSaving: Bool
|
||||
@Binding var error: String?
|
||||
let onCancel: () -> Void
|
||||
let onSave: ([String: AnyCodable]) -> Void
|
||||
|
||||
let labelColumnWidth: CGFloat = 160
|
||||
static let introText =
|
||||
"Create a schedule that wakes clawd 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. "
|
||||
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
|
||||
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. The result can be delivered to a surface, "
|
||||
+ "and a short summary is posted back to your main chat."
|
||||
static let mainPayloadNote =
|
||||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||
static let mainSummaryNote =
|
||||
"Controls the label used when posting the completion summary back to the main session."
|
||||
|
||||
@State var name: String = ""
|
||||
@State var description: String = ""
|
||||
@State var enabled: Bool = true
|
||||
@State var sessionTarget: CronSessionTarget = .main
|
||||
@State var wakeMode: CronWakeMode = .nextHeartbeat
|
||||
|
||||
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 = ""
|
||||
@State var deliver: Bool = false
|
||||
@State var channel: GatewayAgentChannel = .last
|
||||
@State var to: String = ""
|
||||
@State var thinking: String = ""
|
||||
@State var timeoutSeconds: String = ""
|
||||
@State var bestEffortDeliver: Bool = false
|
||||
@State var postPrefix: String = "Cron"
|
||||
|
||||
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("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)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Wake mode")
|
||||
Picker("", selection: self.$wakeMode) {
|
||||
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
|
||||
Text("now").tag(CronWakeMode.now)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
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.sessionTarget == .isolated {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
GroupBox("Main session summary") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Prefix")
|
||||
TextField("Cron", text: self.$postPrefix)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(
|
||||
Self.mainSummaryNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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) { _, newValue in
|
||||
if newValue == .isolated {
|
||||
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 clawd 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("Deliver")
|
||||
Toggle("Deliver result to a surface", isOn: self.$deliver)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
|
||||
if self.deliver {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Channel")
|
||||
Picker("", selection: self.$channel) {
|
||||
Text("last").tag(GatewayAgentChannel.last)
|
||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
||||
Text("discord").tag(GatewayAgentChannel.discord)
|
||||
}
|
||||
.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 delivery fails", isOn: self.$bestEffortDeliver)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
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: "com.clawdbot", 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 }
|
||||
self.startGatewaySubscription()
|
||||
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.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 startGatewaySubscription() {
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import Foundation
|
||||
|
||||
enum CronSessionTarget: String, CaseIterable, Identifiable, Codable {
|
||||
case main
|
||||
case isolated
|
||||
|
||||
var id: String { self.rawValue }
|
||||
}
|
||||
|
||||
enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
|
||||
case now
|
||||
case nextHeartbeat = "next-heartbeat"
|
||||
|
||||
var id: String { self.rawValue }
|
||||
}
|
||||
|
||||
enum CronSchedule: Codable, Equatable {
|
||||
case at(atMs: Int)
|
||||
case every(everyMs: Int, anchorMs: Int?)
|
||||
case cron(expr: String, tz: String?)
|
||||
|
||||
enum CodingKeys: String, CodingKey { case kind, atMs, everyMs, anchorMs, expr, tz }
|
||||
|
||||
var kind: String {
|
||||
switch self {
|
||||
case .at: "at"
|
||||
case .every: "every"
|
||||
case .cron: "cron"
|
||||
}
|
||||
}
|
||||
|
||||
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":
|
||||
self = try .at(atMs: container.decode(Int.self, forKey: .atMs))
|
||||
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))
|
||||
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(atMs):
|
||||
try container.encode(atMs, forKey: .atMs)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, 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),
|
||||
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 CronIsolation: Codable, Equatable {
|
||||
var postToMainPrefix: String?
|
||||
}
|
||||
|
||||
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
|
||||
var name: String
|
||||
var description: String?
|
||||
var enabled: Bool
|
||||
let createdAtMs: Int
|
||||
let updatedAtMs: Int
|
||||
let schedule: CronSchedule
|
||||
let sessionTarget: CronSessionTarget
|
||||
let wakeMode: CronWakeMode
|
||||
let payload: CronPayload
|
||||
let isolation: CronIsolation?
|
||||
let state: CronJobState
|
||||
|
||||
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, Sendable {
|
||||
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, Sendable {
|
||||
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]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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(atMs):
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
|
||||
return "at \(date.formatted(date: .abbreviated, time: .standard))"
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
func formatDuration(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"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import SwiftUI
|
||||
|
||||
extension CronSettings {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.header
|
||||
self.schedulerBanner
|
||||
self.content
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.onAppear { self.store.start() }
|
||||
.onDisappear { self.store.stop() }
|
||||
.sheet(isPresented: self.$showEditor) {
|
||||
CronJobEditor(
|
||||
job: self.editingJob,
|
||||
isSaving: self.$isSaving,
|
||||
error: self.$editorError,
|
||||
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)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.store.selectedJobId) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
Task { await self.store.refreshRuns(jobId: newValue) }
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Cron")
|
||||
.font(.headline)
|
||||
Text("Manage Gateway cron jobs (main session vs isolated runs) and inspect run history.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
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)
|
||||
}
|
||||
|
||||
List(selection: self.$store.selectedJobId) {
|
||||
ForEach(self.store.jobs) { job in
|
||||
self.jobRow(job)
|
||||
.tag(job.id)
|
||||
.contextMenu { self.jobContextMenu(job) }
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
.frame(width: 250)
|
||||
|
||||
Divider()
|
||||
|
||||
self.detail
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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.sessionTarget.rawValue, tint: .secondary)
|
||||
StatusPill(text: job.wakeMode.rawValue, 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 job.sessionTarget == .isolated {
|
||||
Button("Open transcript") {
|
||||
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
|
||||
}
|
||||
}
|
||||
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 job.sessionTarget == .isolated {
|
||||
Button("Transcript") {
|
||||
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
|
||||
}
|
||||
.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 let desc = job.description, !desc.isEmpty {
|
||||
LabeledContent("Description") { Text(desc).font(.callout) }
|
||||
}
|
||||
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
|
||||
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.payload)
|
||||
}
|
||||
.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(_ payload: CronPayload) -> some View {
|
||||
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, deliver, channel, to, _):
|
||||
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 deliver ?? false {
|
||||
StatusPill(text: "deliver", tint: .secondary)
|
||||
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
|
||||
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
struct CronSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let store = CronJobsStore(isPreview: true)
|
||||
store.jobs = [
|
||||
CronJob(
|
||||
id: "job-1",
|
||||
name: "Daily summary",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
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: true,
|
||||
channel: "last",
|
||||
to: nil,
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
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)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension CronSettings {
|
||||
static func exerciseForTesting() {
|
||||
let store = CronJobsStore(isPreview: true)
|
||||
store.schedulerEnabled = false
|
||||
store.schedulerStorePath = "/tmp/clawdbot-cron-store.json"
|
||||
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
name: "Daily summary",
|
||||
description: "Summary job",
|
||||
enabled: true,
|
||||
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: true,
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
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)
|
||||
_ = view.body
|
||||
_ = view.jobRow(job)
|
||||
_ = view.jobContextMenu(job)
|
||||
_ = view.detailHeader(job)
|
||||
_ = view.detailCard(job)
|
||||
_ = view.runHistoryCard(job)
|
||||
_ = view.runRow(run)
|
||||
_ = view.payloadSummary(job.payload)
|
||||
_ = view.scheduleSummary(job.schedule)
|
||||
_ = view.statusTint(job.state.lastStatus)
|
||||
_ = view.nextRunLabel(Date())
|
||||
_ = view.formatDuration(ms: 1234)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,15 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct CronSettings: View {
|
||||
@Bindable var store: CronJobsStore
|
||||
@State var showEditor = false
|
||||
@State var editingJob: CronJob?
|
||||
@State var editorError: String?
|
||||
@State var isSaving = false
|
||||
@State var confirmDelete: CronJob?
|
||||
|
||||
init(store: CronJobsStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum DebugActions {
|
||||
private static let verboseDefaultsKey = "clawdbot.debug.verboseMain"
|
||||
private static let sessionMenuLimit = 12
|
||||
|
||||
@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.default.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 = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot", isDirectory: true)
|
||||
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.default.fileExists(atPath: path) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
} else {
|
||||
NSWorkspace.shared.open(url.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
|
||||
static func sendTestNotification() async {
|
||||
_ = await NotificationManager().send(title: "Clawdbot", 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
|
||||
private static func resolveSessionStorePath() -> String {
|
||||
let defaultPath = SessionLoader.defaultStorePath
|
||||
let configURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot/clawdbot.json")
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct DebugSettings: View {
|
||||
@Bindable var state: AppState
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private let labelColumnWidth: CGFloat = 140
|
||||
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||||
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||||
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
|
||||
@AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true
|
||||
@State private var modelsCount: Int?
|
||||
@State private var modelsLoading = false
|
||||
@State private var modelsError: String?
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let healthStore = HealthStore.shared
|
||||
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
||||
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
||||
@State private var sessionStoreSaveError: String?
|
||||
@State private var debugSendInFlight = false
|
||||
@State private var debugSendStatus: String?
|
||||
@State private var debugSendError: String?
|
||||
@State private var portCheckInFlight = false
|
||||
@State private var portReports: [DebugActions.PortReport] = []
|
||||
@State private var portKillStatus: String?
|
||||
@State private var tunnelResetInFlight = false
|
||||
@State private var tunnelResetStatus: String?
|
||||
@State private var pendingKill: DebugActions.PortListener?
|
||||
@AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false
|
||||
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
|
||||
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
|
||||
|
||||
@State private var canvasSessionKey: String = "main"
|
||||
@State private var canvasStatus: String?
|
||||
@State private var canvasError: String?
|
||||
@State private var canvasEvalJS: String = "document.title"
|
||||
@State private var canvasEvalResult: String?
|
||||
@State private var canvasSnapshotPath: String?
|
||||
|
||||
init(state: AppState = AppStateStore.shared) {
|
||||
self.state = state
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
|
||||
self.appInfoSection
|
||||
self.gatewaySection
|
||||
self.logsSection
|
||||
self.portsSection
|
||||
self.pathsSection
|
||||
self.quickActionsSection
|
||||
self.canvasSection
|
||||
self.experimentsSection
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
.task {
|
||||
guard !self.isPreview else { return }
|
||||
await self.reloadModels()
|
||||
self.loadSessionStorePath()
|
||||
}
|
||||
.alert(item: self.$pendingKill) { listener in
|
||||
Alert(
|
||||
title: Text("Kill \(listener.command) (\(listener.pid))?"),
|
||||
message: Text("This process looks expected for the current mode. Kill anyway?"),
|
||||
primaryButton: .destructive(Text("Kill")) {
|
||||
Task { await self.killConfirmed(listener.pid) }
|
||||
},
|
||||
secondaryButton: .cancel())
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Debug")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Tools for diagnosing local issues (Gateway, ports, logs, Canvas).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func gridLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||
}
|
||||
|
||||
private var appInfoSection: some View {
|
||||
GroupBox("App") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Health")
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(self.healthStore.state.tint).frame(width: 10, height: 10)
|
||||
Text(self.healthStore.summaryLine)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("CLI helper")
|
||||
let loc = CLIInstaller.installedLocation()
|
||||
Text(loc ?? "missing")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(loc == nil ? Color.red : Color.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("PID")
|
||||
Text("\(ProcessInfo.processInfo.processIdentifier)")
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Binary path")
|
||||
Text(Bundle.main.bundlePath)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewaySection: some View {
|
||||
GroupBox("Gateway") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Status")
|
||||
HStack(spacing: 8) {
|
||||
Text(self.gatewayManager.status.label)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attach only")
|
||||
Toggle("", isOn: self.$attachExistingGatewayOnly)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.help(
|
||||
"When enabled in local mode, the mac app will only connect " +
|
||||
"to an already-running gateway " +
|
||||
"and will not start one itself.")
|
||||
}
|
||||
}
|
||||
|
||||
let key = DeepLinkHandler.currentKey()
|
||||
HStack(spacing: 8) {
|
||||
Text("Key")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||||
Text(key)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Button("Copy") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(key, forType: .string)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Copy sample URL") {
|
||||
let msg = "Hello from deep link"
|
||||
let encoded = msg.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? msg
|
||||
let url = "clawdbot://agent?message=\(encoded)&key=\(key)"
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url, forType: .string)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Text("Deep links (clawdbot://…) are always enabled; the key controls unattended runs.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Stdout / stderr")
|
||||
.font(.caption.weight(.semibold))
|
||||
ScrollView {
|
||||
Text(self.gatewayManager.log.isEmpty ? "—" : self.gatewayManager.log)
|
||||
.font(.caption.monospaced())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(height: 180)
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2)))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if self.canRestartGateway {
|
||||
Button("Restart Gateway") { DebugActions.restartGateway() }
|
||||
}
|
||||
Button("Clear log") { GatewayProcessManager.shared.clearLog() }
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var logsSection: some View {
|
||||
GroupBox("Logs") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Pino log")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Button("Open") { DebugActions.openLog() }
|
||||
.buttonStyle(.bordered)
|
||||
Text(DebugActions.pinoLogPath())
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GridRow {
|
||||
self.gridLabel("App logging")
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Picker("Verbosity", selection: self.$appLogLevelRaw) {
|
||||
ForEach(AppLogLevel.allCases) { level in
|
||||
Text(level.title).tag(level.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.help("Controls the macOS app log verbosity.")
|
||||
|
||||
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
|
||||
.toggleStyle(.checkbox)
|
||||
.help(
|
||||
"Writes a rotating, local-only log under ~/Library/Logs/Clawdbot/. " +
|
||||
"Enable only while actively debugging.")
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Open folder") {
|
||||
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Clear") {
|
||||
Task { try? await DiagnosticsFileLog.shared.clear() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Text(DiagnosticsFileLog.logFileURL().path)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var portsSection: some View {
|
||||
GroupBox("Ports") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Text("Port diagnostics")
|
||||
.font(.caption.weight(.semibold))
|
||||
if self.portCheckInFlight { ProgressView().controlSize(.small) }
|
||||
Spacer()
|
||||
Button("Check gateway ports") {
|
||||
Task { await self.runPortCheck() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.portCheckInFlight)
|
||||
Button("Reset SSH tunnel") {
|
||||
Task { await self.resetGatewayTunnel() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.tunnelResetInFlight || !self.isRemoteMode)
|
||||
}
|
||||
|
||||
if let portKillStatus {
|
||||
Text(portKillStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if let tunnelResetStatus {
|
||||
Text(tunnelResetStatus)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if self.portReports.isEmpty, !self.portCheckInFlight {
|
||||
Text("Check which process owns \(GatewayEnvironment.gatewayPort()) and suggest fixes.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.portReports) { report in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Port \(report.port)")
|
||||
.font(.footnote.weight(.semibold))
|
||||
Text(report.summary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
ForEach(report.listeners) { listener in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 8) {
|
||||
Text("\(listener.command) (\(listener.pid))")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(listener.expected ? .secondary : Color.red)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button("Kill") {
|
||||
self.requestKill(listener)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Text(listener.fullCommand)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(6)
|
||||
.background(Color.secondary.opacity(0.05))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pathsSection: some View {
|
||||
GroupBox("Paths") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Clawdbot project root")
|
||||
.font(.caption.weight(.semibold))
|
||||
HStack(spacing: 8) {
|
||||
TextField("Path to clawdbot repo", text: self.$gatewayRootInput)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.onSubmit { self.saveRelayRoot() }
|
||||
Button("Save") { self.saveRelayRoot() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Reset") {
|
||||
let def = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Projects/clawdbot").path
|
||||
self.gatewayRootInput = def
|
||||
self.saveRelayRoot()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
Text("Used for pnpm/node fallback and PATH population when launching the gateway.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Session store")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Path", text: self.$sessionStorePath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.frame(width: 360)
|
||||
Button("Save") { self.saveSessionStorePath() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
if let sessionStoreSaveError {
|
||||
Text(sessionStoreSaveError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Used by the CLI session loader; stored in ~/.clawdbot/clawdbot.json.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Model catalog")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(self.modelCatalogPath)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
self.chooseCatalogFile()
|
||||
} label: {
|
||||
Label("Choose models.generated.ts…", systemImage: "folder")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
Task { await self.reloadModels() }
|
||||
} label: {
|
||||
Label(
|
||||
self.modelsLoading ? "Reloading…" : "Reload models",
|
||||
systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.modelsLoading)
|
||||
}
|
||||
if let modelsError {
|
||||
Text(modelsError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let modelsCount {
|
||||
Text("Loaded \(modelsCount) models")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Local fallback for model picker when gateway models.list is unavailable.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var quickActionsSection: some View {
|
||||
GroupBox("Quick actions") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Button("Send Test Notification") {
|
||||
Task { await DebugActions.sendTestNotification() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Open Agent Events") {
|
||||
DebugActions.openAgentEventsWindow()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Button {
|
||||
Task { await self.sendVoiceDebug() }
|
||||
} label: {
|
||||
Label(
|
||||
self.debugSendInFlight ? "Sending debug voice…" : "Send debug voice",
|
||||
systemImage: self.debugSendInFlight ? "bolt.horizontal.circle" : "waveform")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.debugSendInFlight)
|
||||
|
||||
if !self.debugSendInFlight {
|
||||
if let debugSendStatus {
|
||||
Text(debugSendStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let debugSendError {
|
||||
Text(debugSendError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text(
|
||||
"""
|
||||
Uses the Voice Wake path: forwards over SSH when configured,
|
||||
otherwise runs locally via rpc.
|
||||
""")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Restart app") { DebugActions.restartApp() }
|
||||
Button("Reveal app in Finder") { self.revealApp() }
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var canvasSection: some View {
|
||||
GroupBox("Canvas") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Enable/disable Canvas in General settings.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
TextField("Session", text: self.$canvasSessionKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.frame(width: 160)
|
||||
Button("Show panel") {
|
||||
Task { await self.canvasPresent() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Hide panel") {
|
||||
CanvasManager.shared.hideAll()
|
||||
self.canvasStatus = "hidden"
|
||||
self.canvasError = nil
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Write sample page") {
|
||||
Task { await self.canvasWriteSamplePage() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
TextField("Eval JS", text: self.$canvasEvalJS)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption.monospaced())
|
||||
.frame(maxWidth: 520)
|
||||
Button("Eval") {
|
||||
Task { await self.canvasEval() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button("Snapshot") {
|
||||
Task { await self.canvasSnapshot() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
if let canvasStatus {
|
||||
Text(canvasStatus)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let canvasEvalResult {
|
||||
Text("eval → \(canvasEvalResult)")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let canvasSnapshotPath {
|
||||
HStack(spacing: 8) {
|
||||
Text("snapshot → \(canvasSnapshotPath)")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared
|
||||
.activateFileViewerSelecting([URL(fileURLWithPath: canvasSnapshotPath)])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
if let canvasError {
|
||||
Text(canvasError)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text("Tip: the session directory is returned by “Show panel”.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var experimentsSection: some View {
|
||||
GroupBox("Experiments") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Icon override")
|
||||
Picker("", selection: self.bindingOverride) {
|
||||
ForEach(IconOverrideSelection.allCases) { option in
|
||||
Text(option.label).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 280, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Chat")
|
||||
Text("Native SwiftUI")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runPortCheck() async {
|
||||
self.portCheckInFlight = true
|
||||
self.portKillStatus = nil
|
||||
let reports = await DebugActions.checkGatewayPorts()
|
||||
self.portReports = reports
|
||||
self.portCheckInFlight = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resetGatewayTunnel() async {
|
||||
self.tunnelResetInFlight = true
|
||||
self.tunnelResetStatus = nil
|
||||
let result = await DebugActions.resetGatewayTunnel()
|
||||
switch result {
|
||||
case let .success(message):
|
||||
self.tunnelResetStatus = message
|
||||
case let .failure(err):
|
||||
self.tunnelResetStatus = err.localizedDescription
|
||||
}
|
||||
await self.runPortCheck()
|
||||
self.tunnelResetInFlight = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func requestKill(_ listener: DebugActions.PortListener) {
|
||||
if listener.expected {
|
||||
self.pendingKill = listener
|
||||
} else {
|
||||
Task { await self.killConfirmed(listener.pid) }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func killConfirmed(_ pid: Int32) async {
|
||||
let result = await DebugActions.killProcess(Int(pid))
|
||||
switch result {
|
||||
case .success:
|
||||
self.portKillStatus = "Sent kill to \(pid)."
|
||||
await self.runPortCheck()
|
||||
case let .failure(err):
|
||||
self.portKillStatus = "Kill \(pid) failed: \(err.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseCatalogFile() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Select models.generated.ts"
|
||||
let tsType = UTType(filenameExtension: "ts")
|
||||
?? UTType(tag: "ts", tagClass: .filenameExtension, conformingTo: .sourceCode)
|
||||
?? .item
|
||||
panel.allowedContentTypes = [tsType]
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = URL(fileURLWithPath: self.modelCatalogPath).deletingLastPathComponent()
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
self.modelCatalogPath = url.path
|
||||
self.modelCatalogReloadBump += 1
|
||||
Task { await self.reloadModels() }
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadModels() async {
|
||||
guard !self.modelsLoading else { return }
|
||||
self.modelsLoading = true
|
||||
self.modelsError = nil
|
||||
self.modelCatalogReloadBump += 1
|
||||
defer { self.modelsLoading = false }
|
||||
do {
|
||||
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
|
||||
self.modelsCount = loaded.count
|
||||
} catch {
|
||||
self.modelsCount = nil
|
||||
self.modelsError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func sendVoiceDebug() async {
|
||||
await MainActor.run {
|
||||
self.debugSendInFlight = true
|
||||
self.debugSendError = nil
|
||||
self.debugSendStatus = nil
|
||||
}
|
||||
|
||||
let result = await DebugActions.sendDebugVoice()
|
||||
|
||||
await MainActor.run {
|
||||
self.debugSendInFlight = false
|
||||
switch result {
|
||||
case let .success(message):
|
||||
self.debugSendStatus = message
|
||||
self.debugSendError = nil
|
||||
case let .failure(error):
|
||||
self.debugSendStatus = nil
|
||||
self.debugSendError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func revealApp() {
|
||||
let url = Bundle.main.bundleURL
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
|
||||
private func saveRelayRoot() {
|
||||
GatewayProcessManager.shared.setProjectRoot(path: self.gatewayRootInput)
|
||||
}
|
||||
|
||||
private func loadSessionStorePath() {
|
||||
let url = self.configURL()
|
||||
guard
|
||||
let data = try? Data(contentsOf: url),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let session = parsed["session"] as? [String: Any],
|
||||
let path = session["store"] as? String
|
||||
else {
|
||||
self.sessionStorePath = SessionLoader.defaultStorePath
|
||||
return
|
||||
}
|
||||
self.sessionStorePath = path
|
||||
}
|
||||
|
||||
private func saveSessionStorePath() {
|
||||
let trimmed = self.sessionStorePath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var root: [String: Any] = [:]
|
||||
let url = self.configURL()
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
{
|
||||
root = parsed
|
||||
}
|
||||
|
||||
var session = root["session"] as? [String: Any] ?? [:]
|
||||
session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed
|
||||
root["session"] = session
|
||||
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
self.sessionStoreSaveError = nil
|
||||
} catch {
|
||||
self.sessionStoreSaveError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private var bindingOverride: Binding<String> {
|
||||
Binding {
|
||||
self.iconOverrideRaw
|
||||
} set: { newValue in
|
||||
self.iconOverrideRaw = newValue
|
||||
if let selection = IconOverrideSelection(rawValue: newValue) {
|
||||
Task { @MainActor in
|
||||
AppStateStore.shared.iconOverride = selection
|
||||
WorkActivityStore.shared.resolveIconState(override: selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isRemoteMode: Bool {
|
||||
CommandResolver.connectionSettings().mode == .remote
|
||||
}
|
||||
|
||||
private var canRestartGateway: Bool {
|
||||
self.state.connectionMode == .local && !self.attachExistingGatewayOnly
|
||||
}
|
||||
|
||||
private func configURL() -> URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
}
|
||||
}
|
||||
|
||||
extension DebugSettings {
|
||||
// MARK: - Canvas debug actions
|
||||
|
||||
@MainActor
|
||||
private func canvasPresent() async {
|
||||
self.canvasError = nil
|
||||
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
do {
|
||||
let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
|
||||
self.canvasStatus = "dir: \(dir)"
|
||||
} catch {
|
||||
self.canvasError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func canvasWriteSamplePage() async {
|
||||
self.canvasError = nil
|
||||
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
do {
|
||||
let dir = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
|
||||
let url = URL(fileURLWithPath: dir).appendingPathComponent("index.html", isDirectory: false)
|
||||
let now = ISO8601DateFormatter().string(from: Date())
|
||||
let html = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Canvas Debug</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html,body { height:100%; margin:0; background:#0b1020; color:#e5e7eb; }
|
||||
body { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.wrap { padding:16px; }
|
||||
.row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
|
||||
.pill { padding:6px 10px; border-radius:999px; background:rgba(255,255,255,.08);
|
||||
border:1px solid rgba(255,255,255,.12); }
|
||||
button { background:#22c55e; color:#04110a; border:0; border-radius:10px;
|
||||
padding:8px 10px; font-weight:700; cursor:pointer; }
|
||||
button:active { transform: translateY(1px); }
|
||||
.panel { margin-top:14px; padding:14px; border-radius:14px; background:rgba(255,255,255,.06);
|
||||
border:1px solid rgba(255,255,255,.1); }
|
||||
.grid { display:grid; grid-template-columns: repeat(12, 1fr); gap:10px; margin-top:12px; }
|
||||
.box { grid-column: span 4; height:80px; border-radius:14px;
|
||||
background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(168,85,247,.25));
|
||||
border:1px solid rgba(255,255,255,.12); }
|
||||
.muted { color: rgba(229,231,235,.7); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="row">
|
||||
<div class="pill">Canvas Debug</div>
|
||||
<div class="pill muted">generated: \(now)</div>
|
||||
<div class="pill muted">userAgent: <span id="ua"></span></div>
|
||||
<button id="btn">Click me</button>
|
||||
<div class="pill">count: <span id="count">0</span></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="muted">This is a local file served by the WKURLSchemeHandler.</div>
|
||||
<div class="grid">
|
||||
<div class="box"></div><div class="box"></div><div class="box"></div>
|
||||
<div class="box"></div><div class="box"></div><div class="box"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('ua').textContent = navigator.userAgent;
|
||||
let n = 0;
|
||||
document.getElementById('btn').addEventListener('click', () => {
|
||||
n++;
|
||||
document.getElementById('count').textContent = String(n);
|
||||
document.title = 'Canvas Debug (' + n + ')';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
try html.write(to: url, atomically: true, encoding: .utf8)
|
||||
self.canvasStatus = "wrote: \(url.path)"
|
||||
_ = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
|
||||
} catch {
|
||||
self.canvasError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func canvasEval() async {
|
||||
self.canvasError = nil
|
||||
self.canvasEvalResult = nil
|
||||
do {
|
||||
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let result = try await CanvasManager.shared.eval(
|
||||
sessionKey: session.isEmpty ? "main" : session,
|
||||
javaScript: self.canvasEvalJS)
|
||||
self.canvasEvalResult = result
|
||||
} catch {
|
||||
self.canvasError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func canvasSnapshot() async {
|
||||
self.canvasError = nil
|
||||
self.canvasSnapshotPath = nil
|
||||
do {
|
||||
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let path = try await CanvasManager.shared.snapshot(
|
||||
sessionKey: session.isEmpty ? "main" : session,
|
||||
outPath: nil)
|
||||
self.canvasSnapshotPath = path
|
||||
} catch {
|
||||
self.canvasError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
configuration.label
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
configuration.content
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct DebugSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
DebugSettings(state: .preview)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension DebugSettings {
|
||||
static func exerciseForTesting() async {
|
||||
let view = DebugSettings(state: .preview)
|
||||
view.modelsCount = 3
|
||||
view.modelsLoading = false
|
||||
view.modelsError = "Failed to load models"
|
||||
view.gatewayRootInput = "/tmp/clawdbot"
|
||||
view.sessionStorePath = "/tmp/sessions.json"
|
||||
view.sessionStoreSaveError = "Save failed"
|
||||
view.debugSendInFlight = true
|
||||
view.debugSendStatus = "Sent"
|
||||
view.debugSendError = "Failed"
|
||||
view.portCheckInFlight = true
|
||||
view.portReports = [
|
||||
DebugActions.PortReport(
|
||||
port: GatewayEnvironment.gatewayPort(),
|
||||
expected: "Gateway websocket (node/tsx)",
|
||||
status: .missing("Missing"),
|
||||
listeners: []),
|
||||
]
|
||||
view.portKillStatus = "Killed"
|
||||
view.pendingKill = DebugActions.PortListener(
|
||||
pid: 1,
|
||||
command: "node",
|
||||
fullCommand: "node",
|
||||
user: nil,
|
||||
expected: true)
|
||||
view.canvasSessionKey = "main"
|
||||
view.canvasStatus = "Canvas ok"
|
||||
view.canvasError = "Canvas error"
|
||||
view.canvasEvalJS = "document.title"
|
||||
view.canvasEvalResult = "Canvas"
|
||||
view.canvasSnapshotPath = "/tmp/snapshot.png"
|
||||
|
||||
_ = view.body
|
||||
_ = view.header
|
||||
_ = view.appInfoSection
|
||||
_ = view.gatewaySection
|
||||
_ = view.logsSection
|
||||
_ = view.portsSection
|
||||
_ = view.pathsSection
|
||||
_ = view.quickActionsSection
|
||||
_ = view.canvasSection
|
||||
_ = view.experimentsSection
|
||||
_ = view.gridLabel("Test")
|
||||
|
||||
view.loadSessionStorePath()
|
||||
await view.reloadModels()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,151 @@
|
||||
import AppKit
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "com.clawdbot", category: "DeepLink")
|
||||
|
||||
@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
|
||||
}
|
||||
guard !AppStateStore.shared.isPaused else {
|
||||
self.presentAlert(title: "Clawdbot is paused", message: "Unpause Clawdbot to run agent actions.")
|
||||
return
|
||||
}
|
||||
|
||||
switch route {
|
||||
case let .agent(link):
|
||||
await self.handleAgent(link: link, originalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAgent(link: AgentDeepLink, originalURL: URL) async {
|
||||
let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if messagePreview.count > 20000 {
|
||||
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()
|
||||
|
||||
let trimmed = messagePreview.count > 240 ? "\(messagePreview.prefix(240))…" : messagePreview
|
||||
let body =
|
||||
"Run the agent with this message?\n\n\(trimmed)\n\nURL:\n\(originalURL.absoluteString)"
|
||||
guard self.confirm(title: "Run Clawdbot agent?", message: body) else { return }
|
||||
}
|
||||
|
||||
if AppStateStore.shared.connectionMode == .local {
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
}
|
||||
|
||||
do {
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
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: channel.shouldDeliver(link.deliver),
|
||||
to: link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
|
||||
channel: 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 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import Foundation
|
||||
|
||||
struct DevicePresentation: Sendable {
|
||||
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: " / ")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, Sendable {
|
||||
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.default.urls(for: .libraryDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
||||
return library
|
||||
.appendingPathComponent("Logs", isDirectory: true)
|
||||
.appendingPathComponent("Clawdbot", 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.default
|
||||
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.default.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.default
|
||||
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.default.attributesOfItem(atPath: url.path),
|
||||
let size = attrs[.size] as? NSNumber
|
||||
else { return }
|
||||
|
||||
if size.int64Value < self.maxBytes { return }
|
||||
|
||||
let fm = FileManager.default
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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: "com.clawdbot", 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.bool(forKey: showDockIconKey)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayAutostartPolicy {
|
||||
static func shouldStartGateway(mode: AppState.ConnectionMode, paused: Bool) -> Bool {
|
||||
mode == .local && !paused
|
||||
}
|
||||
|
||||
static func shouldEnsureLaunchAgent(
|
||||
mode: AppState.ConnectionMode,
|
||||
paused: Bool,
|
||||
attachExistingOnly: Bool) -> Bool
|
||||
{
|
||||
self.shouldStartGateway(mode: mode, paused: paused) && !attachExistingOnly
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
protocol WebSocketTasking: AnyObject {
|
||||
var state: URLSessionTask.State { get }
|
||||
func resume()
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message
|
||||
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||
}
|
||||
|
||||
extension URLSessionWebSocketTask: WebSocketTasking {}
|
||||
|
||||
struct WebSocketTaskBox: @unchecked Sendable {
|
||||
let task: any WebSocketTasking
|
||||
|
||||
var state: URLSessionTask.State { self.task.state }
|
||||
|
||||
func resume() { self.task.resume() }
|
||||
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
self.task.cancel(with: closeCode, reason: reason)
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
try await self.task.send(message)
|
||||
}
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
try await self.task.receive()
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
self.task.receive(completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
protocol WebSocketSessioning: AnyObject {
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox
|
||||
}
|
||||
|
||||
extension URLSession: WebSocketSessioning {
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
let task = self.webSocketTask(with: url)
|
||||
// Avoid "Message too long" receive errors for large snapshots / history payloads.
|
||||
task.maximumMessageSize = 16 * 1024 * 1024 // 16 MB
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
}
|
||||
|
||||
struct WebSocketSessionBox: @unchecked Sendable {
|
||||
let session: any WebSocketSessioning
|
||||
}
|
||||
|
||||
// Avoid ambiguity with the app's own AnyCodable type.
|
||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
|
||||
actor GatewayChannelActor {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway")
|
||||
private var task: WebSocketTaskBox?
|
||||
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
|
||||
private var connected = false
|
||||
private var isConnecting = false
|
||||
private var connectWaiters: [CheckedContinuation<Void, Error>] = []
|
||||
private var url: URL
|
||||
private var token: String?
|
||||
private var password: String?
|
||||
private let session: WebSocketSessioning
|
||||
private var backoffMs: Double = 500
|
||||
private var shouldReconnect = true
|
||||
private var lastSeq: Int?
|
||||
private var lastTick: Date?
|
||||
private var tickIntervalMs: Double = 30000
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private var watchdogTask: Task<Void, Never>?
|
||||
private var tickTask: Task<Void, Never>?
|
||||
private let defaultRequestTimeoutMs: Double = 15000
|
||||
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
token: String?,
|
||||
password: String? = nil,
|
||||
session: WebSocketSessionBox? = nil,
|
||||
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil)
|
||||
{
|
||||
self.url = url
|
||||
self.token = token
|
||||
self.password = password
|
||||
self.session = session?.session ?? URLSession(configuration: .default)
|
||||
self.pushHandler = pushHandler
|
||||
Task { [weak self] in
|
||||
await self?.startWatchdog()
|
||||
}
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
self.shouldReconnect = false
|
||||
self.connected = false
|
||||
|
||||
self.watchdogTask?.cancel()
|
||||
self.watchdogTask = nil
|
||||
|
||||
self.tickTask?.cancel()
|
||||
self.tickTask = nil
|
||||
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.task = nil
|
||||
|
||||
await self.failPending(NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
|
||||
let waiters = self.connectWaiters
|
||||
self.connectWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume(throwing: NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||
}
|
||||
}
|
||||
|
||||
private func startWatchdog() {
|
||||
self.watchdogTask?.cancel()
|
||||
self.watchdogTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.watchdogLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private func watchdogLoop() async {
|
||||
// Keep nudging reconnect in case exponential backoff stalls.
|
||||
while self.shouldReconnect {
|
||||
try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30s cadence
|
||||
guard self.shouldReconnect else { return }
|
||||
if self.connected { continue }
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway watchdog reconnect")
|
||||
self.logger.error("gateway watchdog reconnect failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
if self.connected, self.task?.state == .running { return }
|
||||
if self.isConnecting {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.connectWaiters.append(cont)
|
||||
}
|
||||
return
|
||||
}
|
||||
self.isConnecting = true
|
||||
defer { self.isConnecting = false }
|
||||
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.task = self.session.makeWebSocketTask(url: self.url)
|
||||
self.task?.resume()
|
||||
do {
|
||||
try await self.sendConnect()
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
let waiters = self.connectWaiters
|
||||
self.connectWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume(throwing: wrapped)
|
||||
}
|
||||
self.logger.error("gateway ws connect failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
throw wrapped
|
||||
}
|
||||
self.listen()
|
||||
self.connected = true
|
||||
self.backoffMs = 500
|
||||
self.lastSeq = nil
|
||||
|
||||
let waiters = self.connectWaiters
|
||||
self.connectWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
waiter.resume(returning: ())
|
||||
}
|
||||
}
|
||||
|
||||
private func sendConnect() async throws {
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||
let clientName = InstanceIdentity.displayName
|
||||
|
||||
let reqId = UUID().uuidString
|
||||
var client: [String: ProtoAnyCodable] = [
|
||||
"name": ProtoAnyCodable(clientName),
|
||||
"version": ProtoAnyCodable(
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||
"platform": ProtoAnyCodable(platform),
|
||||
"mode": ProtoAnyCodable("app"),
|
||||
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
||||
]
|
||||
client["deviceFamily"] = ProtoAnyCodable("Mac")
|
||||
if let model = InstanceIdentity.modelIdentifier {
|
||||
client["modelIdentifier"] = ProtoAnyCodable(model)
|
||||
}
|
||||
var params: [String: ProtoAnyCodable] = [
|
||||
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"client": ProtoAnyCodable(client),
|
||||
"caps": ProtoAnyCodable([] as [String]),
|
||||
"locale": ProtoAnyCodable(primaryLocale),
|
||||
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||
]
|
||||
if let token = self.token {
|
||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||
} else if let password = self.password {
|
||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||
}
|
||||
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: reqId,
|
||||
method: "connect",
|
||||
params: ProtoAnyCodable(params))
|
||||
let data = try self.encoder.encode(frame)
|
||||
try await self.task?.send(.data(data))
|
||||
guard let msg = try await task?.receive() else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
|
||||
}
|
||||
try await self.handleConnectResponse(msg, reqId: reqId)
|
||||
}
|
||||
|
||||
private func handleConnectResponse(_ msg: URLSessionWebSocketTask.Message, reqId: String) async throws {
|
||||
let data: Data? = switch msg {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (empty response)"])
|
||||
}
|
||||
let decoder = JSONDecoder()
|
||||
guard let frame = try? decoder.decode(GatewayFrame.self, from: data) else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
|
||||
}
|
||||
guard case let .res(res) = frame, res.id == reqId else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (unexpected response)"])
|
||||
}
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
}
|
||||
guard let payload = res.payload else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect failed (missing payload)"])
|
||||
}
|
||||
let payloadData = try self.encoder.encode(payload)
|
||||
let ok = try decoder.decode(HelloOk.self, from: payloadData)
|
||||
if let tick = ok.policy["tickIntervalMs"]?.value as? Double {
|
||||
self.tickIntervalMs = tick
|
||||
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
|
||||
self.tickIntervalMs = Double(tick)
|
||||
}
|
||||
self.lastTick = Date()
|
||||
self.tickTask?.cancel()
|
||||
self.tickTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.watchTicks()
|
||||
}
|
||||
await self.pushHandler?(.snapshot(ok))
|
||||
}
|
||||
|
||||
private func listen() {
|
||||
self.task?.receive { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case let .failure(err):
|
||||
Task { await self.handleReceiveFailure(err) }
|
||||
case let .success(msg):
|
||||
Task {
|
||||
await self.handle(msg)
|
||||
await self.listen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleReceiveFailure(_ err: Error) async {
|
||||
let wrapped = self.wrap(err, context: "gateway receive")
|
||||
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
self.connected = false
|
||||
await self.failPending(wrapped)
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
|
||||
private func handle(_ msg: URLSessionWebSocketTask.Message) async {
|
||||
let data: Data? = switch msg {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else { return }
|
||||
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else {
|
||||
self.logger.error("gateway decode failed")
|
||||
return
|
||||
}
|
||||
switch frame {
|
||||
case let .res(res):
|
||||
let id = res.id
|
||||
if let waiter = pending.removeValue(forKey: id) {
|
||||
waiter.resume(returning: .res(res))
|
||||
}
|
||||
case let .event(evt):
|
||||
if let seq = evt.seq {
|
||||
if let last = lastSeq, seq > last + 1 {
|
||||
await self.pushHandler?(.seqGap(expected: last + 1, received: seq))
|
||||
}
|
||||
self.lastSeq = seq
|
||||
}
|
||||
if evt.event == "tick" { self.lastTick = Date() }
|
||||
await self.pushHandler?(.event(evt))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func watchTicks() async {
|
||||
let tolerance = self.tickIntervalMs * 2
|
||||
while self.connected {
|
||||
try? await Task.sleep(nanoseconds: UInt64(tolerance * 1_000_000))
|
||||
guard self.connected else { return }
|
||||
if let last = self.lastTick {
|
||||
let delta = Date().timeIntervalSince(last) * 1000
|
||||
if delta > tolerance {
|
||||
self.logger.error("gateway tick missed; reconnecting")
|
||||
self.connected = false
|
||||
await self.failPending(
|
||||
NSError(
|
||||
domain: "Gateway",
|
||||
code: 4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway tick missed; reconnecting"]))
|
||||
await self.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleReconnect() async {
|
||||
guard self.shouldReconnect else { return }
|
||||
let delay = self.backoffMs / 1000
|
||||
self.backoffMs = min(self.backoffMs * 2, 30000)
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
guard self.shouldReconnect else { return }
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway reconnect")
|
||||
self.logger.error("gateway reconnect failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func request(method: String, params: [String: AnyCodable]?, timeoutMs: Double? = nil) async throws -> Data {
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
throw self.wrap(error, context: "gateway connect")
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
|
||||
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
||||
let paramsObject: ProtoAnyCodable? = params.map { entries in
|
||||
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
|
||||
dict[entry.key] = ProtoAnyCodable(entry.value.value)
|
||||
}
|
||||
return ProtoAnyCodable(dict)
|
||||
}
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: id,
|
||||
method: method,
|
||||
params: paramsObject)
|
||||
let data = try self.encoder.encode(frame)
|
||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
||||
self.pending[id] = cont
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
|
||||
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await self.task?.send(.data(data))
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
||||
let waiter = self.pending.removeValue(forKey: id)
|
||||
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
if let waiter { waiter.resume(throwing: wrapped) }
|
||||
}
|
||||
}
|
||||
}
|
||||
guard case let .res(res) = response else {
|
||||
throw NSError(domain: "Gateway", code: 2, userInfo: [NSLocalizedDescriptionKey: "unexpected frame"])
|
||||
}
|
||||
if res.ok == false {
|
||||
let code = res.error?["code"]?.value as? String
|
||||
let msg = res.error?["message"]?.value as? String
|
||||
let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in
|
||||
acc[pair.key] = AnyCodable(pair.value.value)
|
||||
}
|
||||
throw GatewayResponseError(method: method, code: code, message: msg, details: details)
|
||||
}
|
||||
if let payload = res.payload {
|
||||
// Encode back to JSON with Swift's encoder to preserve types and avoid ObjC bridging exceptions.
|
||||
return try self.encoder.encode(payload)
|
||||
}
|
||||
return Data() // Should not happen, but tolerate empty payloads.
|
||||
}
|
||||
|
||||
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
||||
private func wrap(_ error: Error, context: String) -> Error {
|
||||
if let urlError = error as? URLError {
|
||||
let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription
|
||||
return NSError(
|
||||
domain: URLError.errorDomain,
|
||||
code: urlError.errorCode,
|
||||
userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
||||
}
|
||||
let ns = error as NSError
|
||||
let desc = ns.localizedDescription.isEmpty ? "unknown" : ns.localizedDescription
|
||||
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
||||
}
|
||||
|
||||
private func failPending(_ error: Error) async {
|
||||
let waiters = self.pending
|
||||
self.pending.removeAll()
|
||||
for (_, waiter) in waiters {
|
||||
waiter.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutRequest(id: String, timeoutMs: Double) async {
|
||||
guard let waiter = self.pending.removeValue(forKey: id) else { return }
|
||||
let err = NSError(
|
||||
domain: "Gateway",
|
||||
code: 5,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway request timed out after \(Int(timeoutMs))ms"])
|
||||
waiter.resume(throwing: err)
|
||||
}
|
||||
}
|
||||
|
||||
// Intentionally no `GatewayChannel` wrapper: the app should use the single shared `GatewayConnection`.
|
||||
@@ -0,0 +1,607 @@
|
||||
import ClawdbotChatUI
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
private let gatewayConnectionLogger = Logger(subsystem: "com.clawdbot", category: "gateway.connection")
|
||||
|
||||
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
case last
|
||||
case whatsapp
|
||||
case telegram
|
||||
case discord
|
||||
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: Sendable {
|
||||
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
|
||||
}
|
||||
|
||||
/// 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, Sendable {
|
||||
case agent
|
||||
case status
|
||||
case setHeartbeats = "set-heartbeats"
|
||||
case systemEvent = "system-event"
|
||||
case health
|
||||
case providersStatus = "providers.status"
|
||||
case configGet = "config.get"
|
||||
case configSet = "config.set"
|
||||
case wizardStart = "wizard.start"
|
||||
case wizardNext = "wizard.next"
|
||||
case wizardCancel = "wizard.cancel"
|
||||
case wizardStatus = "wizard.status"
|
||||
case talkMode = "talk.mode"
|
||||
case webLoginStart = "web.login.start"
|
||||
case webLoginWait = "web.login.wait"
|
||||
case webLogout = "web.logout"
|
||||
case telegramLogout = "telegram.logout"
|
||||
case modelsList = "models.list"
|
||||
case chatHistory = "chat.history"
|
||||
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 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?
|
||||
|
||||
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) 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 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
|
||||
}
|
||||
}
|
||||
|
||||
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 shutdown() async {
|
||||
if let client {
|
||||
await client.shutdown()
|
||||
}
|
||||
self.client = nil
|
||||
self.configuredURL = nil
|
||||
self.configuredToken = nil
|
||||
self.lastSnapshot = nil
|
||||
}
|
||||
|
||||
func canvasHostUrl() async -> String? {
|
||||
guard let snapshot = self.lastSnapshot else { return nil }
|
||||
let trimmed = snapshot.canvashosturl?.trimmingCharacters(in: .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
|
||||
}
|
||||
for (_, continuation) in self.subscribers {
|
||||
continuation.yield(push)
|
||||
}
|
||||
}
|
||||
|
||||
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, Sendable {
|
||||
struct SnapshotConfig: Decodable, Sendable {
|
||||
struct Session: Decodable, Sendable {
|
||||
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 {
|
||||
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") }
|
||||
|
||||
var params: [String: AnyCodable] = [
|
||||
"message": AnyCodable(trimmed),
|
||||
"sessionKey": AnyCodable(invocation.sessionKey),
|
||||
"thinking": AnyCodable(invocation.thinking ?? "default"),
|
||||
"deliver": AnyCodable(invocation.deliver),
|
||||
"to": AnyCodable(invocation.to ?? ""),
|
||||
"channel": AnyCodable(invocation.channel.rawValue),
|
||||
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
|
||||
]
|
||||
if let timeout = invocation.timeoutSeconds {
|
||||
params["timeout"] = AnyCodable(timeout)
|
||||
}
|
||||
|
||||
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(ClawdbotGatewayHealthOK.self, from: data))?.ok ?? true
|
||||
}
|
||||
|
||||
// MARK: - Skills
|
||||
|
||||
func skillsStatus() async throws -> SkillsStatusReport {
|
||||
try await self.requestDecoded(method: .skillsStatus)
|
||||
}
|
||||
|
||||
func skillsInstall(
|
||||
name: String,
|
||||
installId: String,
|
||||
timeoutMs: Int? = nil) async throws -> SkillInstallResult
|
||||
{
|
||||
var params: [String: AnyCodable] = [
|
||||
"name": AnyCodable(name),
|
||||
"installId": AnyCodable(installId),
|
||||
]
|
||||
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: - Chat
|
||||
|
||||
func chatHistory(
|
||||
sessionKey: String,
|
||||
limit: Int? = nil,
|
||||
timeoutMs: Int? = nil) async throws -> ClawdbotChatHistoryPayload
|
||||
{
|
||||
var params: [String: AnyCodable] = ["sessionKey": AnyCodable(sessionKey)]
|
||||
if let limit { params["limit"] = AnyCodable(limit) }
|
||||
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: [ClawdbotChatAttachmentPayload],
|
||||
timeoutMs: Int = 30000) async throws -> ClawdbotChatSendResponse
|
||||
{
|
||||
var params: [String: AnyCodable] = [
|
||||
"sessionKey": AnyCodable(sessionKey),
|
||||
"message": AnyCodable(message),
|
||||
"thinking": AnyCodable(thinking),
|
||||
"idempotencyKey": AnyCodable(idempotencyKey),
|
||||
"timeoutMs": AnyCodable(timeoutMs),
|
||||
]
|
||||
|
||||
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 {
|
||||
struct AbortResponse: Decodable { let ok: Bool?; let aborted: Bool? }
|
||||
let res: AbortResponse = try await self.requestDecoded(
|
||||
method: .chatAbort,
|
||||
params: ["sessionKey": AnyCodable(sessionKey), "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: - Cron
|
||||
|
||||
struct CronSchedulerStatus: Decodable, Sendable {
|
||||
let enabled: Bool
|
||||
let storePath: 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 res: CronListResponse = try await self.requestDecoded(
|
||||
method: .cronList,
|
||||
params: ["includeDisabled": AnyCodable(includeDisabled)])
|
||||
return res.jobs
|
||||
}
|
||||
|
||||
func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] {
|
||||
let res: CronRunsResponse = try await self.requestDecoded(
|
||||
method: .cronRuns,
|
||||
params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)])
|
||||
return res.entries
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayDiscoveryInlineList: View {
|
||||
var discovery: GatewayDiscoveryModel
|
||||
var currentTarget: String?
|
||||
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
||||
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.discovery.statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if self.discovery.gateways.isEmpty {
|
||||
Text("No bridges found yet.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
||||
let target = self.suggestedSSHTarget(gateway)
|
||||
let selected = (target != nil && self.currentTarget?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
||||
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||
self.onSelect(gateway)
|
||||
}
|
||||
} label: {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(gateway.displayName)
|
||||
.font(.callout.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Text(target ?? "Bridge pairing only")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if selected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
} else {
|
||||
Image(systemName: "arrow.right.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.rowBackground(
|
||||
selected: selected,
|
||||
hovered: self.hoveredGatewayID == gateway.id)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(
|
||||
selected ? Color.accentColor.opacity(0.45) : Color.clear,
|
||||
lineWidth: 1))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { hovering in
|
||||
self.hoveredGatewayID = hovering ? gateway
|
||||
.id : (self.hoveredGatewayID == gateway.id ? nil : self.hoveredGatewayID)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(NSColor.controlBackgroundColor)))
|
||||
}
|
||||
}
|
||||
.help("Click a discovered bridge to fill the SSH target.")
|
||||
}
|
||||
|
||||
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
||||
guard let host else { return nil }
|
||||
let user = NSUserName()
|
||||
return GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
}
|
||||
|
||||
private func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||
guard let host else { return nil }
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
||||
if selected { return Color.accentColor.opacity(0.12) }
|
||||
if hovered { return Color.secondary.opacity(0.08) }
|
||||
return Color.clear
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayDiscoveryMenu: View {
|
||||
var discovery: GatewayDiscoveryModel
|
||||
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
if self.discovery.gateways.isEmpty {
|
||||
Button(self.discovery.statusText) {}
|
||||
.disabled(true)
|
||||
} else {
|
||||
ForEach(self.discovery.gateways) { gateway in
|
||||
Button(gateway.displayName) { self.onSelect(gateway) }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
}
|
||||
.help("Discover Clawdbot bridges on your LAN")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GatewayDiscoveryModel {
|
||||
struct LocalIdentity: Equatable {
|
||||
var hostTokens: Set<String>
|
||||
var displayTokens: Set<String>
|
||||
}
|
||||
|
||||
struct DiscoveredGateway: Identifiable, Equatable {
|
||||
var id: String { self.stableID }
|
||||
var displayName: String
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var cliPath: String?
|
||||
var stableID: String
|
||||
var debugID: String
|
||||
var isLocal: Bool
|
||||
}
|
||||
|
||||
var gateways: [DiscoveredGateway] = []
|
||||
var statusText: String = "Idle"
|
||||
|
||||
private var browsers: [String: NWBrowser] = [:]
|
||||
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
|
||||
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
||||
private var statesByDomain: [String: NWBrowser.State] = [:]
|
||||
private var localIdentity: LocalIdentity
|
||||
private var resolvedTXTByID: [String: [String: String]] = [:]
|
||||
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-discovery")
|
||||
|
||||
init() {
|
||||
self.localIdentity = Self.buildLocalIdentityFast()
|
||||
self.refreshLocalIdentity()
|
||||
}
|
||||
|
||||
func start() {
|
||||
if !self.browsers.isEmpty { return }
|
||||
|
||||
for domain in ClawdbotBonjour.bridgeServiceDomains {
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let browser = NWBrowser(
|
||||
for: .bonjour(type: ClawdbotBonjour.bridgeServiceType, domain: domain),
|
||||
using: params)
|
||||
|
||||
browser.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.statesByDomain[domain] = state
|
||||
self.updateStatusText()
|
||||
}
|
||||
}
|
||||
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.resultsByDomain[domain] = results
|
||||
self.updateGateways(for: domain)
|
||||
self.recomputeGateways()
|
||||
}
|
||||
}
|
||||
|
||||
self.browsers[domain] = browser
|
||||
browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.gateway-discovery.\(domain)"))
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
for browser in self.browsers.values {
|
||||
browser.cancel()
|
||||
}
|
||||
self.browsers = [:]
|
||||
self.resultsByDomain = [:]
|
||||
self.gatewaysByDomain = [:]
|
||||
self.statesByDomain = [:]
|
||||
self.resolvedTXTByID = [:]
|
||||
self.pendingTXTResolvers.values.forEach { $0.cancel() }
|
||||
self.pendingTXTResolvers = [:]
|
||||
self.gateways = []
|
||||
self.statusText = "Stopped"
|
||||
}
|
||||
|
||||
private func recomputeGateways() {
|
||||
self.gateways = self.gatewaysByDomain.values
|
||||
.flatMap(\.self)
|
||||
.filter { !$0.isLocal }
|
||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||
}
|
||||
|
||||
private func updateGateways(for domain: String) {
|
||||
guard let results = self.resultsByDomain[domain] else {
|
||||
self.gatewaysByDomain[domain] = []
|
||||
return
|
||||
}
|
||||
|
||||
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
|
||||
guard case let .service(name, type, resultDomain, _) = result.endpoint else { return nil }
|
||||
|
||||
let decodedName = BonjourEscapes.decode(name)
|
||||
let stableID = BridgeEndpointID.stableID(result.endpoint)
|
||||
let resolvedTXT = self.resolvedTXTByID[stableID] ?? [:]
|
||||
let txt = Self.txtDictionary(from: result).merging(
|
||||
resolvedTXT,
|
||||
uniquingKeysWith: { _, new in new })
|
||||
|
||||
let advertisedName = txt["displayName"]
|
||||
.map(Self.prettifyInstanceName)
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
let prettyName =
|
||||
advertisedName ?? Self.prettifyServiceName(decodedName)
|
||||
|
||||
let parsedTXT = Self.parseGatewayTXT(txt)
|
||||
|
||||
if parsedTXT.lanHost == nil || parsedTXT.tailnetDns == nil {
|
||||
self.ensureTXTResolution(
|
||||
stableID: stableID,
|
||||
serviceName: name,
|
||||
type: type,
|
||||
domain: resultDomain)
|
||||
}
|
||||
|
||||
let isLocal = Self.isLocalGateway(
|
||||
lanHost: parsedTXT.lanHost,
|
||||
tailnetDns: parsedTXT.tailnetDns,
|
||||
displayName: prettyName,
|
||||
serviceName: decodedName,
|
||||
local: self.localIdentity)
|
||||
return DiscoveredGateway(
|
||||
displayName: prettyName,
|
||||
lanHost: parsedTXT.lanHost,
|
||||
tailnetDns: parsedTXT.tailnetDns,
|
||||
sshPort: parsedTXT.sshPort,
|
||||
cliPath: parsedTXT.cliPath,
|
||||
stableID: stableID,
|
||||
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
|
||||
isLocal: isLocal)
|
||||
}
|
||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||
}
|
||||
|
||||
private func updateGatewaysForAllDomains() {
|
||||
for domain in self.resultsByDomain.keys {
|
||||
self.updateGateways(for: domain)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateStatusText() {
|
||||
let states = Array(self.statesByDomain.values)
|
||||
if states.isEmpty {
|
||||
self.statusText = self.browsers.isEmpty ? "Idle" : "Setup"
|
||||
return
|
||||
}
|
||||
|
||||
if let failed = states.first(where: { state in
|
||||
if case .failed = state { return true }
|
||||
return false
|
||||
}) {
|
||||
if case let .failed(err) = failed {
|
||||
self.statusText = "Failed: \(err)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let waiting = states.first(where: { state in
|
||||
if case .waiting = state { return true }
|
||||
return false
|
||||
}) {
|
||||
if case let .waiting(err) = waiting {
|
||||
self.statusText = "Waiting: \(err)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if states.contains(where: { if case .ready = $0 { true } else { false } }) {
|
||||
self.statusText = "Searching…"
|
||||
return
|
||||
}
|
||||
|
||||
if states.contains(where: { if case .setup = $0 { true } else { false } }) {
|
||||
self.statusText = "Setup"
|
||||
return
|
||||
}
|
||||
|
||||
self.statusText = "Searching…"
|
||||
}
|
||||
|
||||
private static func txtDictionary(from result: NWBrowser.Result) -> [String: String] {
|
||||
var merged: [String: String] = [:]
|
||||
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
merged.merge(txt.dictionary, uniquingKeysWith: { _, new in new })
|
||||
}
|
||||
|
||||
if let endpointTxt = result.endpoint.txtRecord?.dictionary {
|
||||
merged.merge(endpointTxt, uniquingKeysWith: { _, new in new })
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
static func parseGatewayTXT(_ txt: [String: String])
|
||||
-> (lanHost: String?, tailnetDns: String?, sshPort: Int, cliPath: String?)
|
||||
{
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort = 22
|
||||
var cliPath: String?
|
||||
|
||||
if let value = txt["lanHost"] {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
lanHost = trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
if let value = txt["tailnetDns"] {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
tailnetDns = trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
if let value = txt["sshPort"],
|
||||
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
parsed > 0
|
||||
{
|
||||
sshPort = parsed
|
||||
}
|
||||
if let value = txt["cliPath"] {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
cliPath = trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
return (lanHost, tailnetDns, sshPort, cliPath)
|
||||
}
|
||||
|
||||
static func buildSSHTarget(user: String, host: String, port: Int) -> String {
|
||||
var target = "\(user)@\(host)"
|
||||
if port != 22 {
|
||||
target += ":\(port)"
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private func ensureTXTResolution(
|
||||
stableID: String,
|
||||
serviceName: String,
|
||||
type: String,
|
||||
domain: String)
|
||||
{
|
||||
guard self.resolvedTXTByID[stableID] == nil else { return }
|
||||
guard self.pendingTXTResolvers[stableID] == nil else { return }
|
||||
|
||||
let resolver = GatewayTXTResolver(
|
||||
name: serviceName,
|
||||
type: type,
|
||||
domain: domain,
|
||||
logger: self.logger)
|
||||
{ [weak self] result in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.pendingTXTResolvers[stableID] = nil
|
||||
switch result {
|
||||
case let .success(txt):
|
||||
self.resolvedTXTByID[stableID] = txt
|
||||
self.updateGatewaysForAllDomains()
|
||||
self.recomputeGateways()
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.pendingTXTResolvers[stableID] = resolver
|
||||
resolver.start()
|
||||
}
|
||||
|
||||
private nonisolated static func prettifyInstanceName(_ decodedName: String) -> String {
|
||||
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||
let stripped = normalized.replacingOccurrences(of: " (Clawdbot)", with: "")
|
||||
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
||||
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private nonisolated static func prettifyServiceName(_ decodedName: String) -> String {
|
||||
let normalized = Self.prettifyInstanceName(decodedName)
|
||||
var cleaned = normalized.replacingOccurrences(of: #"\s*-?bridge$"#, with: "", options: .regularExpression)
|
||||
cleaned = cleaned
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cleaned.isEmpty {
|
||||
cleaned = normalized
|
||||
}
|
||||
let words = cleaned.split(separator: " ")
|
||||
let titled = words.map { word -> String in
|
||||
let lower = word.lowercased()
|
||||
guard let first = lower.first else { return "" }
|
||||
return String(first).uppercased() + lower.dropFirst()
|
||||
}.joined(separator: " ")
|
||||
return titled.isEmpty ? normalized : titled
|
||||
}
|
||||
|
||||
nonisolated static func isLocalGateway(
|
||||
lanHost: String?,
|
||||
tailnetDns: String?,
|
||||
displayName: String?,
|
||||
serviceName: String?,
|
||||
local: LocalIdentity) -> Bool
|
||||
{
|
||||
if let host = normalizeHostToken(lanHost),
|
||||
local.hostTokens.contains(host)
|
||||
{
|
||||
return true
|
||||
}
|
||||
if let host = normalizeHostToken(tailnetDns),
|
||||
local.hostTokens.contains(host)
|
||||
{
|
||||
return true
|
||||
}
|
||||
if let name = normalizeDisplayToken(displayName),
|
||||
local.displayTokens.contains(name)
|
||||
{
|
||||
return true
|
||||
}
|
||||
if let service = normalizeServiceToken(serviceName) {
|
||||
for token in local.hostTokens where service.contains(token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func refreshLocalIdentity() {
|
||||
let fastIdentity = self.localIdentity
|
||||
Task.detached(priority: .utility) {
|
||||
let slowIdentity = Self.buildLocalIdentitySlow()
|
||||
let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard self.localIdentity != merged else { return }
|
||||
self.localIdentity = merged
|
||||
self.recomputeGateways()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func mergeLocalIdentity(
|
||||
fast: LocalIdentity,
|
||||
slow: LocalIdentity) -> LocalIdentity
|
||||
{
|
||||
LocalIdentity(
|
||||
hostTokens: fast.hostTokens.union(slow.hostTokens),
|
||||
displayTokens: fast.displayTokens.union(slow.displayTokens))
|
||||
}
|
||||
|
||||
private nonisolated static func buildLocalIdentityFast() -> LocalIdentity {
|
||||
var hostTokens: Set<String> = []
|
||||
var displayTokens: Set<String> = []
|
||||
|
||||
let hostName = ProcessInfo.processInfo.hostName
|
||||
if let token = normalizeHostToken(hostName) {
|
||||
hostTokens.insert(token)
|
||||
}
|
||||
|
||||
if let token = normalizeDisplayToken(InstanceIdentity.displayName) {
|
||||
displayTokens.insert(token)
|
||||
}
|
||||
|
||||
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
|
||||
}
|
||||
|
||||
private nonisolated static func buildLocalIdentitySlow() -> LocalIdentity {
|
||||
var hostTokens: Set<String> = []
|
||||
var displayTokens: Set<String> = []
|
||||
|
||||
if let host = Host.current().name,
|
||||
let token = normalizeHostToken(host)
|
||||
{
|
||||
hostTokens.insert(token)
|
||||
}
|
||||
|
||||
if let token = normalizeDisplayToken(Host.current().localizedName) {
|
||||
displayTokens.insert(token)
|
||||
}
|
||||
|
||||
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
|
||||
}
|
||||
|
||||
private nonisolated static func normalizeHostToken(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
let lower = trimmed.lowercased()
|
||||
let strippedTrailingDot = lower.hasSuffix(".")
|
||||
? String(lower.dropLast())
|
||||
: lower
|
||||
let withoutLocal = strippedTrailingDot.hasSuffix(".local")
|
||||
? String(strippedTrailingDot.dropLast(6))
|
||||
: strippedTrailingDot
|
||||
let firstLabel = withoutLocal.split(separator: ".").first.map(String.init)
|
||||
let token = (firstLabel ?? withoutLocal).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return token.isEmpty ? nil : token
|
||||
}
|
||||
|
||||
private nonisolated static func normalizeDisplayToken(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
let prettified = Self.prettifyInstanceName(raw)
|
||||
let trimmed = prettified.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
return trimmed.lowercased()
|
||||
}
|
||||
|
||||
private nonisolated static func normalizeServiceToken(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
return trimmed.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
final class GatewayTXTResolver: NSObject, NetServiceDelegate {
|
||||
private let service: NetService
|
||||
private let completion: (Result<[String: String], Error>) -> Void
|
||||
private let logger: Logger
|
||||
private var didFinish = false
|
||||
|
||||
init(
|
||||
name: String,
|
||||
type: String,
|
||||
domain: String,
|
||||
logger: Logger,
|
||||
completion: @escaping (Result<[String: String], Error>) -> Void)
|
||||
{
|
||||
self.service = NetService(domain: domain, type: type, name: name)
|
||||
self.completion = completion
|
||||
self.logger = logger
|
||||
super.init()
|
||||
self.service.delegate = self
|
||||
}
|
||||
|
||||
func start(timeout: TimeInterval = 2.0) {
|
||||
self.service.schedule(in: .main, forMode: .common)
|
||||
self.service.resolve(withTimeout: timeout)
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
self.finish(result: .failure(GatewayTXTResolverError.cancelled))
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
let txt = Self.decodeTXT(sender.txtRecordData())
|
||||
if !txt.isEmpty {
|
||||
let payload = self.formatTXT(txt)
|
||||
self.logger.debug(
|
||||
"discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, privacy: .public)")
|
||||
}
|
||||
self.finish(result: .success(txt))
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||
self.finish(result: .failure(GatewayTXTResolverError.resolveFailed(errorDict)))
|
||||
}
|
||||
|
||||
private func finish(result: Result<[String: String], Error>) {
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
self.service.stop()
|
||||
self.service.remove(from: .main, forMode: .common)
|
||||
self.completion(result)
|
||||
}
|
||||
|
||||
private static func decodeTXT(_ data: Data?) -> [String: String] {
|
||||
guard let data else { return [:] }
|
||||
let dict = NetService.dictionary(fromTXTRecord: data)
|
||||
var out: [String: String] = [:]
|
||||
out.reserveCapacity(dict.count)
|
||||
for (key, value) in dict {
|
||||
if let str = String(data: value, encoding: .utf8) {
|
||||
out[key] = str
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private func formatTXT(_ txt: [String: String]) -> String {
|
||||
txt.sorted(by: { $0.key < $1.key })
|
||||
.map { "\($0.key)=\($0.value)" }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
enum GatewayTXTResolverError: Error {
|
||||
case cancelled
|
||||
case resolveFailed([String: NSNumber])
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum GatewayEndpointState: Sendable, Equatable {
|
||||
case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?)
|
||||
case unavailable(mode: AppState.ConnectionMode, reason: String)
|
||||
}
|
||||
|
||||
/// Single place to resolve (and publish) the effective gateway control endpoint.
|
||||
///
|
||||
/// This is intentionally separate from `GatewayConnection`:
|
||||
/// - `GatewayConnection` consumes the resolved endpoint (no tunnel side-effects).
|
||||
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
|
||||
actor GatewayEndpointStore {
|
||||
static let shared = GatewayEndpointStore()
|
||||
|
||||
struct Deps: Sendable {
|
||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||
let token: @Sendable () -> String?
|
||||
let password: @Sendable () -> String?
|
||||
let localPort: @Sendable () -> Int
|
||||
let remotePortIfRunning: @Sendable () async -> UInt16?
|
||||
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
|
||||
|
||||
static let live = Deps(
|
||||
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||
token: { ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] },
|
||||
password: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
return GatewayEndpointStore.resolveGatewayPassword(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
},
|
||||
localPort: { GatewayEnvironment.gatewayPort() },
|
||||
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
||||
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
|
||||
}
|
||||
|
||||
private static func resolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]
|
||||
) -> String? {
|
||||
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let password = remote["password"] as? String
|
||||
{
|
||||
let pw = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !pw.isEmpty {
|
||||
return pw
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let password = auth["password"] as? String
|
||||
{
|
||||
let pw = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !pw.isEmpty {
|
||||
return pw
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private let deps: Deps
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||
|
||||
private var state: GatewayEndpointState
|
||||
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
|
||||
|
||||
init(deps: Deps = .live) {
|
||||
self.deps = deps
|
||||
let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||
let initialMode: AppState.ConnectionMode
|
||||
if let modeRaw {
|
||||
initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
||||
} else {
|
||||
let seen = UserDefaults.standard.bool(forKey: "clawdbot.onboardingSeen")
|
||||
initialMode = seen ? .local : .unconfigured
|
||||
}
|
||||
|
||||
let port = deps.localPort()
|
||||
let token = deps.token()
|
||||
let password = deps.password()
|
||||
switch initialMode {
|
||||
case .local:
|
||||
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password)
|
||||
case .remote:
|
||||
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
|
||||
case .unconfigured:
|
||||
self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured")
|
||||
}
|
||||
}
|
||||
|
||||
func subscribe(bufferingNewest: Int = 1) -> AsyncStream<GatewayEndpointState> {
|
||||
let id = UUID()
|
||||
let initial = self.state
|
||||
let store = self
|
||||
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
|
||||
continuation.yield(initial)
|
||||
self.subscribers[id] = continuation
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task { await store.removeSubscriber(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
let mode = await self.deps.mode()
|
||||
await self.setMode(mode)
|
||||
}
|
||||
|
||||
func setMode(_ mode: AppState.ConnectionMode) async {
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
switch mode {
|
||||
case .local:
|
||||
let port = self.deps.localPort()
|
||||
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password))
|
||||
case .remote:
|
||||
let port = await self.deps.remotePortIfRunning()
|
||||
guard let port else {
|
||||
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
|
||||
return
|
||||
}
|
||||
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token, password: password))
|
||||
case .unconfigured:
|
||||
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint.
|
||||
func ensureRemoteControlTunnel() async throws -> UInt16 {
|
||||
let mode = await self.deps.mode()
|
||||
guard mode == .remote else {
|
||||
throw NSError(
|
||||
domain: "RemoteTunnel",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
let port = try await self.deps.ensureRemoteTunnel()
|
||||
await self.setMode(.remote)
|
||||
return port
|
||||
}
|
||||
|
||||
func requireConfig() async throws -> GatewayConnection.Config {
|
||||
await self.refresh()
|
||||
switch self.state {
|
||||
case let .ready(_, url, token, password):
|
||||
return (url, token, password)
|
||||
case let .unavailable(mode, reason):
|
||||
guard mode == .remote else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
|
||||
}
|
||||
|
||||
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet),
|
||||
// recreate it on demand so callers can recover without a manual reconnect.
|
||||
do {
|
||||
self.logger.info(
|
||||
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
|
||||
let forwarded = try await self.deps.ensureRemoteTunnel()
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return (url, token, password)
|
||||
} catch {
|
||||
let msg = "\(reason) (\(error.localizedDescription))"
|
||||
self.setState(.unavailable(mode: .remote, reason: msg))
|
||||
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeSubscriber(_ id: UUID) {
|
||||
self.subscribers[id] = nil
|
||||
}
|
||||
|
||||
private func setState(_ next: GatewayEndpointState) {
|
||||
guard next != self.state else { return }
|
||||
self.state = next
|
||||
for (_, continuation) in self.subscribers {
|
||||
continuation.yield(next)
|
||||
}
|
||||
switch next {
|
||||
case let .ready(mode, url, _, _):
|
||||
let modeDesc = String(describing: mode)
|
||||
let urlDesc = url.absoluteString
|
||||
self.logger
|
||||
.debug(
|
||||
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
|
||||
case let .unavailable(mode, reason):
|
||||
let modeDesc = String(describing: mode)
|
||||
self.logger
|
||||
.debug(
|
||||
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GatewayEndpointStore {
|
||||
static func _testResolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]
|
||||
) -> String? {
|
||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,354 @@
|
||||
import ClawdbotIPC
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks.
|
||||
struct Semver: Comparable, CustomStringConvertible, Sendable {
|
||||
let major: Int
|
||||
let minor: Int
|
||||
let patch: Int
|
||||
|
||||
var description: String { "\(self.major).\(self.minor).\(self.patch)" }
|
||||
|
||||
static func < (lhs: Semver, rhs: Semver) -> Bool {
|
||||
if lhs.major != rhs.major { return lhs.major < rhs.major }
|
||||
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
|
||||
return lhs.patch < rhs.patch
|
||||
}
|
||||
|
||||
static func parse(_ raw: String?) -> Semver? {
|
||||
guard let raw, !raw.isEmpty else { return nil }
|
||||
let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "^v", with: "", options: .regularExpression)
|
||||
let parts = cleaned.split(separator: ".")
|
||||
guard parts.count >= 3,
|
||||
let major = Int(parts[0]),
|
||||
let minor = Int(parts[1])
|
||||
else { return nil }
|
||||
let patch = Int(parts[2]) ?? 0
|
||||
return Semver(major: major, minor: minor, patch: patch)
|
||||
}
|
||||
|
||||
func compatible(with required: Semver) -> Bool {
|
||||
// Same major and not older than required.
|
||||
self.major == required.major && self >= required
|
||||
}
|
||||
}
|
||||
|
||||
enum GatewayEnvironmentKind: Equatable {
|
||||
case checking
|
||||
case ok
|
||||
case missingNode
|
||||
case missingGateway
|
||||
case incompatible(found: String, required: String)
|
||||
case error(String)
|
||||
}
|
||||
|
||||
struct GatewayEnvironmentStatus: Equatable {
|
||||
let kind: GatewayEnvironmentKind
|
||||
let nodeVersion: String?
|
||||
let gatewayVersion: String?
|
||||
let requiredGateway: String?
|
||||
let message: String
|
||||
|
||||
static var checking: Self {
|
||||
.init(kind: .checking, nodeVersion: nil, gatewayVersion: nil, requiredGateway: nil, message: "Checking…")
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayCommandResolution {
|
||||
let status: GatewayEnvironmentStatus
|
||||
let command: [String]?
|
||||
}
|
||||
|
||||
enum GatewayEnvironment {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.env")
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
|
||||
static func bundledGatewayExecutable() -> String? {
|
||||
guard let res = Bundle.main.resourceURL else { return nil }
|
||||
let path = res.appendingPathComponent("Relay/clawdbot").path
|
||||
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
|
||||
}
|
||||
|
||||
static func gatewayPort() -> Int {
|
||||
if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PORT"] {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let parsed = Int(trimmed), parsed > 0 { return parsed }
|
||||
}
|
||||
if let configPort = ClawdbotConfigFile.gatewayPort(), configPort > 0 {
|
||||
return configPort
|
||||
}
|
||||
let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
|
||||
return stored > 0 ? stored : 18789
|
||||
}
|
||||
|
||||
static func expectedGatewayVersion() -> Semver? {
|
||||
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
return Semver.parse(bundleVersion)
|
||||
}
|
||||
|
||||
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
|
||||
static func expectedGatewayVersion(from versionString: String?) -> Semver? {
|
||||
Semver.parse(versionString)
|
||||
}
|
||||
|
||||
static func check() -> GatewayEnvironmentStatus {
|
||||
let start = Date()
|
||||
defer {
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
if elapsedMs > 500 {
|
||||
self.logger.warning("gateway env check slow (\(elapsedMs, privacy: .public)ms)")
|
||||
} else {
|
||||
self.logger.debug("gateway env check ok (\(elapsedMs, privacy: .public)ms)")
|
||||
}
|
||||
}
|
||||
let expected = self.expectedGatewayVersion()
|
||||
|
||||
if let bundled = self.bundledGatewayExecutable() {
|
||||
let installed = self.readGatewayVersion(binary: bundled)
|
||||
if let expected, let installed, !installed.compatible(with: expected) {
|
||||
let message =
|
||||
"Bundled gateway \(installed.description) is incompatible with app " +
|
||||
"\(expected.description); rebuild the app bundle."
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .incompatible(found: installed.description, required: expected.description),
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: installed.description,
|
||||
requiredGateway: expected.description,
|
||||
message: message)
|
||||
}
|
||||
let gatewayVersionText = installed?.description ?? "unknown"
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .ok,
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: gatewayVersionText,
|
||||
requiredGateway: expected?.description,
|
||||
message: "Bundled gateway \(gatewayVersionText) (bun)")
|
||||
}
|
||||
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||
|
||||
switch RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) {
|
||||
case let .failure(err):
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .missingNode,
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
message: RuntimeLocator.describeFailure(err))
|
||||
case let .success(runtime):
|
||||
let gatewayBin = CommandResolver.clawdbotExecutable()
|
||||
|
||||
if gatewayBin == nil, projectEntrypoint == nil {
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .missingGateway,
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
message: "clawdbot CLI not found in PATH; install the global package.")
|
||||
}
|
||||
|
||||
let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) }
|
||||
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
|
||||
|
||||
if let expected, let installed, !installed.compatible(with: expected) {
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .incompatible(found: installed.description, required: expected.description),
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: installed.description,
|
||||
requiredGateway: expected.description,
|
||||
message: """
|
||||
Gateway version \(installed.description) is incompatible with app \(expected.description);
|
||||
install or update the global package.
|
||||
""")
|
||||
}
|
||||
|
||||
let gatewayLabel = gatewayBin != nil ? "global" : "local"
|
||||
let gatewayVersionText = installed?.description ?? "unknown"
|
||||
// Avoid repeating "(local)" twice; if using the local entrypoint, show the path once.
|
||||
let localPathHint = gatewayBin == nil && projectEntrypoint != nil
|
||||
? " (local: \(projectEntrypoint ?? "unknown"))"
|
||||
: ""
|
||||
let gatewayLabelText = gatewayBin != nil
|
||||
? "(\(gatewayLabel))"
|
||||
: localPathHint.isEmpty ? "(\(gatewayLabel))" : localPathHint
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .ok,
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: gatewayVersionText,
|
||||
requiredGateway: expected?.description,
|
||||
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
|
||||
}
|
||||
}
|
||||
|
||||
static func resolveGatewayCommand() -> GatewayCommandResolution {
|
||||
let start = Date()
|
||||
defer {
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
if elapsedMs > 500 {
|
||||
self.logger.warning("gateway command resolve slow (\(elapsedMs, privacy: .public)ms)")
|
||||
} else {
|
||||
self.logger.debug("gateway command resolve ok (\(elapsedMs, privacy: .public)ms)")
|
||||
}
|
||||
}
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||
let status = self.check()
|
||||
let gatewayBin = CommandResolver.clawdbotExecutable()
|
||||
let bundled = self.bundledGatewayExecutable()
|
||||
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
|
||||
|
||||
guard case .ok = status.kind else {
|
||||
return GatewayCommandResolution(status: status, command: nil)
|
||||
}
|
||||
|
||||
let port = self.gatewayPort()
|
||||
if let bundled {
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let cmd = [bundled, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
return GatewayCommandResolution(status: status, command: cmd)
|
||||
}
|
||||
if let gatewayBin {
|
||||
let cmd = [gatewayBin, "gateway", "--port", "\(port)"]
|
||||
return GatewayCommandResolution(status: status, command: cmd)
|
||||
}
|
||||
|
||||
if let entry = projectEntrypoint,
|
||||
case let .success(resolvedRuntime) = runtime
|
||||
{
|
||||
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)"]
|
||||
return GatewayCommandResolution(status: status, command: cmd)
|
||||
}
|
||||
|
||||
return GatewayCommandResolution(status: status, command: nil)
|
||||
}
|
||||
|
||||
private static func preferredGatewayBind() -> String? {
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return nil
|
||||
}
|
||||
if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] {
|
||||
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let bind = gateway["bind"] as? String
|
||||
{
|
||||
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
||||
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let target = version?.description ?? "latest"
|
||||
let npm = CommandResolver.findExecutable(named: "npm")
|
||||
let pnpm = CommandResolver.findExecutable(named: "pnpm")
|
||||
let bun = CommandResolver.findExecutable(named: "bun")
|
||||
let (label, cmd): (String, [String]) =
|
||||
if let npm {
|
||||
("npm", [npm, "install", "-g", "clawdbot@\(target)"])
|
||||
} else if let pnpm {
|
||||
("pnpm", [pnpm, "add", "-g", "clawdbot@\(target)"])
|
||||
} else if let bun {
|
||||
("bun", [bun, "add", "-g", "clawdbot@\(target)"])
|
||||
} else {
|
||||
("npm", ["npm", "install", "-g", "clawdbot@\(target)"])
|
||||
}
|
||||
|
||||
statusHandler("Installing clawdbot@\(target) via \(label)…")
|
||||
|
||||
func summarize(_ text: String) -> String? {
|
||||
let lines = text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let last = lines.last else { return nil }
|
||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||
}
|
||||
|
||||
let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300)
|
||||
if response.success {
|
||||
statusHandler("Installed clawdbot@\(target)")
|
||||
} else {
|
||||
if response.timedOut {
|
||||
statusHandler("Install failed: timed out. Check your internet connection and try again.")
|
||||
return
|
||||
}
|
||||
|
||||
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
||||
let detail = summarize(response.stderr) ?? summarize(response.stdout)
|
||||
if let detail {
|
||||
statusHandler("Install failed (\(exit)): \(detail)")
|
||||
} else {
|
||||
statusHandler("Install failed (\(exit))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private static func readGatewayVersion(binary: String) -> Semver? {
|
||||
let start = Date()
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: binary)
|
||||
process.arguments = ["--version"]
|
||||
process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
if elapsedMs > 500 {
|
||||
self.logger.warning(
|
||||
"""
|
||||
gateway --version slow (\(elapsedMs, privacy: .public)ms) \
|
||||
bin=\(binary, privacy: .public)
|
||||
""")
|
||||
} else {
|
||||
self.logger.debug(
|
||||
"""
|
||||
gateway --version ok (\(elapsedMs, privacy: .public)ms) \
|
||||
bin=\(binary, privacy: .public)
|
||||
""")
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let raw = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Semver.parse(raw)
|
||||
} catch {
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
self.logger.error(
|
||||
"""
|
||||
gateway --version failed (\(elapsedMs, privacy: .public)ms) \
|
||||
bin=\(binary, privacy: .public) \
|
||||
err=\(error.localizedDescription, privacy: .public)
|
||||
""")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func readLocalGatewayVersion(projectRoot: URL) -> Semver? {
|
||||
let pkg = projectRoot.appendingPathComponent("package.json")
|
||||
guard let data = try? Data(contentsOf: pkg) else { return nil }
|
||||
guard
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let version = json["version"] as? String
|
||||
else { return nil }
|
||||
return Semver.parse(version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
/// Structured error surfaced when the gateway responds with `{ ok: false }`.
|
||||
struct GatewayResponseError: LocalizedError, @unchecked Sendable {
|
||||
let method: String
|
||||
let code: String
|
||||
let message: String
|
||||
let details: [String: AnyCodable]
|
||||
|
||||
init(method: String, code: String?, message: String?, details: [String: AnyCodable]?) {
|
||||
self.method = method
|
||||
self.code = (code?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? code!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: "GATEWAY_ERROR"
|
||||
self.message = (message?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? message!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: "gateway error"
|
||||
self.details = details ?? [:]
|
||||
}
|
||||
|
||||
var errorDescription: String? {
|
||||
if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" }
|
||||
return "\(self.method): [\(self.code)] \(self.message)"
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayDecodingError: LocalizedError, Sendable {
|
||||
let method: String
|
||||
let message: String
|
||||
|
||||
var errorDescription: String? { "\(self.method): \(self.message)" }
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayLaunchAgentManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
|
||||
|
||||
private static var plistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
private static var legacyPlistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
private static func gatewayExecutablePath(bundlePath: String) -> String {
|
||||
"\(bundlePath)/Contents/Resources/Relay/clawdbot"
|
||||
}
|
||||
|
||||
private static func relayDir(bundlePath: String) -> String {
|
||||
"\(bundlePath)/Contents/Resources/Relay"
|
||||
}
|
||||
|
||||
private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] {
|
||||
#if DEBUG
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
|
||||
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
|
||||
}
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
||||
case let .success(runtime) = CommandResolver.runtimeResolution()
|
||||
{
|
||||
return CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
#endif
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
}
|
||||
|
||||
static func status() async -> Bool {
|
||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
||||
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return result.status == 0
|
||||
}
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||
if enabled {
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyGatewayLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
||||
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
|
||||
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
|
||||
}
|
||||
self.logger.info("launchd enable requested port=\(port)")
|
||||
self.writePlist(bundlePath: bundlePath, port: port)
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||
if bootstrap.status != 0 {
|
||||
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.logger.error("launchd bootstrap failed: \(msg)")
|
||||
return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "Failed to bootstrap gateway launchd job"
|
||||
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
// Note: removed redundant `kickstart -k` that caused race condition.
|
||||
// bootstrap already starts the job; kickstart -k would kill it immediately
|
||||
// and with KeepAlive=true, cause a restart loop with port conflicts.
|
||||
return nil
|
||||
}
|
||||
|
||||
self.logger.info("launchd disable requested")
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.plistURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
static func kickstart() async {
|
||||
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
}
|
||||
|
||||
private static func writePlist(bundlePath: String, port: Int) {
|
||||
let relayDir = self.relayDir(bundlePath: bundlePath)
|
||||
let preferredPath = ([relayDir] + CommandResolver.preferredPaths())
|
||||
.joined(separator: ":")
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let programArguments = self.gatewayProgramArguments(bundlePath: bundlePath, port: port, bind: bind)
|
||||
let token = self.preferredGatewayToken()
|
||||
let password = self.preferredGatewayPassword()
|
||||
var envEntries = """
|
||||
<key>PATH</key>
|
||||
<string>\(preferredPath)</string>
|
||||
<key>CLAWDBOT_IMAGE_BACKEND</key>
|
||||
<string>sips</string>
|
||||
"""
|
||||
if let token {
|
||||
let escapedToken = self.escapePlistValue(token)
|
||||
envEntries += """
|
||||
<key>CLAWDBOT_GATEWAY_TOKEN</key>
|
||||
<string>\(escapedToken)</string>
|
||||
"""
|
||||
}
|
||||
if let password {
|
||||
let escapedPassword = self.escapePlistValue(password)
|
||||
envEntries += """
|
||||
<key>CLAWDBOT_GATEWAY_PASSWORD</key>
|
||||
<string>\(escapedPassword)</string>
|
||||
"""
|
||||
}
|
||||
let argsXml = programArguments
|
||||
.map { "<string>\(self.escapePlistValue($0))</string>" }
|
||||
.joined(separator: "\n ")
|
||||
let plist = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>\(gatewayLaunchdLabel)</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
\(argsXml)
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
\(envEntries)
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>\(LogLocator.launchdGatewayLogPath)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
do {
|
||||
try plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
self.logger.error("launchd plist write failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func preferredGatewayBind() -> String? {
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return nil
|
||||
}
|
||||
if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] {
|
||||
let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let bind = gateway["bind"] as? String
|
||||
{
|
||||
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if self.supportedBindModes.contains(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func preferredGatewayToken() -> String? {
|
||||
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func preferredGatewayPassword() -> String? {
|
||||
// First check environment variable
|
||||
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
// Then check config file (gateway.auth.password)
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let password = auth["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func escapePlistValue(_ raw: String) -> String {
|
||||
raw
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
|
||||
private struct LaunchctlResult {
|
||||
let status: Int32
|
||||
let output: String
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func runLaunchctl(_ args: [String]) async -> LaunchctlResult {
|
||||
await Task.detached(priority: .utility) { () -> LaunchctlResult in
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
process.arguments = args
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
return LaunchctlResult(status: process.terminationStatus, output: output)
|
||||
} catch {
|
||||
return LaunchctlResult(status: -1, output: error.localizedDescription)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GatewayLaunchAgentManager {
|
||||
static func _testGatewayExecutablePath(bundlePath: String) -> String {
|
||||
self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
}
|
||||
|
||||
static func _testRelayDir(bundlePath: String) -> String {
|
||||
self.relayDir(bundlePath: bundlePath)
|
||||
}
|
||||
|
||||
static func _testPreferredGatewayBind() -> String? {
|
||||
self.preferredGatewayBind()
|
||||
}
|
||||
|
||||
static func _testPreferredGatewayToken() -> String? {
|
||||
self.preferredGatewayToken()
|
||||
}
|
||||
|
||||
static func _testEscapePlistValue(_ raw: String) -> String {
|
||||
self.escapePlistValue(raw)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,16 @@
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
enum GatewayPayloadDecoding {
|
||||
static func decode<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable, as _: T.Type = T.self) throws -> T {
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
static func decodeIfPresent<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws
|
||||
-> T?
|
||||
{
|
||||
guard let payload else { return nil }
|
||||
return try self.decode(payload, as: T.self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GatewayProcessManager {
|
||||
static let shared = GatewayProcessManager()
|
||||
|
||||
enum Status: Equatable {
|
||||
case stopped
|
||||
case starting
|
||||
case running(details: String?)
|
||||
case attachedExisting(details: String?)
|
||||
case failed(String)
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .stopped: return "Stopped"
|
||||
case .starting: return "Starting…"
|
||||
case let .running(details):
|
||||
if let details, !details.isEmpty { return "Running (\(details))" }
|
||||
return "Running"
|
||||
case let .attachedExisting(details):
|
||||
if let details, !details.isEmpty {
|
||||
return "Using existing gateway (\(details))"
|
||||
}
|
||||
return "Using existing gateway"
|
||||
case let .failed(reason): return "Failed: \(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var status: Status = .stopped {
|
||||
didSet { CanvasManager.shared.refreshDebugStatus() }
|
||||
}
|
||||
|
||||
private(set) var log: String = ""
|
||||
private(set) var environmentStatus: GatewayEnvironmentStatus = .checking
|
||||
private(set) var existingGatewayDetails: String?
|
||||
private(set) var lastFailureReason: String?
|
||||
private var desiredActive = false
|
||||
private var environmentRefreshTask: Task<Void, Never>?
|
||||
private var lastEnvironmentRefresh: Date?
|
||||
private var logRefreshTask: Task<Void, Never>?
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.process")
|
||||
|
||||
private let logLimit = 20000 // characters to keep in-memory
|
||||
private let environmentRefreshMinInterval: TimeInterval = 30
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
// Remote mode should never spawn a local gateway; treat as stopped.
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
self.desiredActive = false
|
||||
self.stop()
|
||||
self.status = .stopped
|
||||
self.appendLog("[gateway] remote mode active; skipping local gateway\n")
|
||||
self.logger.info("gateway process skipped: remote mode active")
|
||||
return
|
||||
}
|
||||
self.logger.debug("gateway active requested active=\(active)")
|
||||
self.desiredActive = active
|
||||
self.refreshEnvironmentStatus()
|
||||
if active {
|
||||
self.startIfNeeded()
|
||||
} else {
|
||||
self.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||
guard !AppStateStore.attachExistingGatewayOnly else { return }
|
||||
let enabled = await GatewayLaunchAgentManager.status()
|
||||
guard !enabled else { return }
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
self.appendLog("[gateway] auto-enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
||||
let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port)
|
||||
if let err {
|
||||
self.appendLog("[gateway] launchd auto-enable failed: \(err)\n")
|
||||
}
|
||||
}
|
||||
|
||||
func startIfNeeded() {
|
||||
guard self.desiredActive else { return }
|
||||
// Do not spawn in remote mode (the gateway should run on the remote host).
|
||||
guard !CommandResolver.connectionModeIsRemote() else {
|
||||
self.status = .stopped
|
||||
return
|
||||
}
|
||||
self.status = .starting
|
||||
self.logger.debug("gateway start requested")
|
||||
|
||||
// First try to latch onto an already-running gateway to avoid spawning a duplicate.
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
if await self.attachExistingGatewayIfAvailable() {
|
||||
return
|
||||
}
|
||||
// Respect debug toggle: only attach, never spawn, when enabled.
|
||||
if AppStateStore.attachExistingGatewayOnly {
|
||||
await MainActor.run {
|
||||
self.status = .failed("Attach-only enabled; no gateway to attach")
|
||||
self.appendLog("[gateway] attach-only enabled; not spawning local gateway\n")
|
||||
self.logger.warning("gateway attach-only enabled; not spawning")
|
||||
}
|
||||
return
|
||||
}
|
||||
await self.enableLaunchdGateway()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.desiredActive = false
|
||||
self.existingGatewayDetails = nil
|
||||
self.lastFailureReason = nil
|
||||
self.status = .stopped
|
||||
self.logger.info("gateway stop requested")
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
Task {
|
||||
_ = await GatewayLaunchAgentManager.set(
|
||||
enabled: false,
|
||||
bundlePath: bundlePath,
|
||||
port: GatewayEnvironment.gatewayPort())
|
||||
}
|
||||
}
|
||||
|
||||
func refreshEnvironmentStatus(force: Bool = false) {
|
||||
let now = Date()
|
||||
if !force {
|
||||
if self.environmentRefreshTask != nil { return }
|
||||
if let last = self.lastEnvironmentRefresh,
|
||||
now.timeIntervalSince(last) < self.environmentRefreshMinInterval
|
||||
{
|
||||
return
|
||||
}
|
||||
}
|
||||
self.lastEnvironmentRefresh = now
|
||||
self.environmentRefreshTask = Task { [weak self] in
|
||||
let status = await Task.detached(priority: .utility) {
|
||||
GatewayEnvironment.check()
|
||||
}.value
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.environmentStatus = status
|
||||
self.environmentRefreshTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshLog() {
|
||||
guard self.logRefreshTask == nil else { return }
|
||||
let path = LogLocator.launchdGatewayLogPath
|
||||
let limit = self.logLimit
|
||||
self.logRefreshTask = Task { [weak self] in
|
||||
let log = await Task.detached(priority: .utility) {
|
||||
Self.readGatewayLog(path: path, limit: limit)
|
||||
}.value
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
if !log.isEmpty {
|
||||
self.log = log
|
||||
}
|
||||
self.logRefreshTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// Attempt to connect to an already-running gateway on the configured port.
|
||||
/// If successful, mark status as attached and skip spawning a new process.
|
||||
private func attachExistingGatewayIfAvailable() async -> Bool {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
let instance = await PortGuardian.shared.describe(port: port)
|
||||
let instanceText = instance.map { self.describe(instance: $0) }
|
||||
let hasListener = instance != nil
|
||||
|
||||
let attemptAttach = {
|
||||
try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
|
||||
}
|
||||
|
||||
for attempt in 0..<(hasListener ? 3 : 1) {
|
||||
do {
|
||||
let data = try await attemptAttach()
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
let details = self.describe(details: instanceText, port: port, snap: snap)
|
||||
self.existingGatewayDetails = details
|
||||
self.status = .attachedExisting(details: details)
|
||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||
self.logger.info("gateway using existing instance details=\(details)")
|
||||
self.refreshControlChannelIfNeeded(reason: "attach existing")
|
||||
self.refreshLog()
|
||||
return true
|
||||
} catch {
|
||||
if attempt < 2, hasListener {
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasListener {
|
||||
let reason = self.describeAttachFailure(error, port: port, instance: instance)
|
||||
self.existingGatewayDetails = instanceText
|
||||
self.status = .failed(reason)
|
||||
self.lastFailureReason = reason
|
||||
self.appendLog("[gateway] existing listener on port \(port) but attach failed: \(reason)\n")
|
||||
self.logger.warning("gateway attach failed reason=\(reason)")
|
||||
return true
|
||||
}
|
||||
|
||||
// No reachable gateway (and no listener) — fall through to spawn.
|
||||
self.existingGatewayDetails = nil
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
self.existingGatewayDetails = nil
|
||||
return false
|
||||
}
|
||||
|
||||
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
||||
let instanceText = instance ?? "pid unknown"
|
||||
if let snap {
|
||||
let linked = snap.web.linked ? "linked" : "not linked"
|
||||
let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
return "port \(port), \(linked), auth \(authAge), \(instanceText)"
|
||||
}
|
||||
return "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
|
||||
private func describe(instance: PortGuardian.Descriptor) -> String {
|
||||
let path = instance.executablePath ?? "path unknown"
|
||||
return "pid \(instance.pid) \(instance.command) @ \(path)"
|
||||
}
|
||||
|
||||
private func describeAttachFailure(_ error: Error, port: Int, instance: PortGuardian.Descriptor?) -> String {
|
||||
let ns = error as NSError
|
||||
let message = ns.localizedDescription.isEmpty ? "unknown error" : ns.localizedDescription
|
||||
let lower = message.lowercased()
|
||||
if self.isGatewayAuthFailure(error) {
|
||||
return """
|
||||
Gateway on port \(port) rejected auth. Set CLAWDBOT_GATEWAY_TOKEN in the app \
|
||||
to match the running gateway (or clear it on the gateway) and retry.
|
||||
"""
|
||||
}
|
||||
if lower.contains("protocol mismatch") {
|
||||
return "Gateway on port \(port) is incompatible (protocol mismatch). Update the app/gateway."
|
||||
}
|
||||
if lower.contains("unexpected response") || lower.contains("invalid response") {
|
||||
return "Port \(port) returned non-gateway data; another process is using it."
|
||||
}
|
||||
if let instance {
|
||||
let instanceText = self.describe(instance: instance)
|
||||
return "Gateway listener found on port \(port) (\(instanceText)) but health check failed: \(message)"
|
||||
}
|
||||
return "Gateway listener found on port \(port) but health check failed: \(message)"
|
||||
}
|
||||
|
||||
private func isGatewayAuthFailure(_ error: Error) -> Bool {
|
||||
if let urlError = error as? URLError, urlError.code == .dataNotAllowed {
|
||||
return true
|
||||
}
|
||||
let ns = error as NSError
|
||||
if ns.domain == "Gateway", ns.code == 1008 { return true }
|
||||
let lower = ns.localizedDescription.lowercased()
|
||||
return lower.contains("unauthorized") || lower.contains("auth")
|
||||
}
|
||||
|
||||
private func enableLaunchdGateway() async {
|
||||
self.existingGatewayDetails = nil
|
||||
let resolution = await Task.detached(priority: .utility) {
|
||||
GatewayEnvironment.resolveGatewayCommand()
|
||||
}.value
|
||||
await MainActor.run { self.environmentStatus = resolution.status }
|
||||
guard resolution.command != nil else {
|
||||
await MainActor.run {
|
||||
self.status = .failed(resolution.status.message)
|
||||
}
|
||||
self.logger.error("gateway command resolve failed: \(resolution.status.message)")
|
||||
return
|
||||
}
|
||||
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
||||
self.logger.info("gateway enabling launchd port=\(port)")
|
||||
let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port)
|
||||
if let err {
|
||||
self.status = .failed(err)
|
||||
self.lastFailureReason = err
|
||||
self.logger.error("gateway launchd enable failed: \(err)")
|
||||
return
|
||||
}
|
||||
|
||||
// Best-effort: wait for the gateway to accept connections.
|
||||
let deadline = Date().addingTimeInterval(6)
|
||||
while Date() < deadline {
|
||||
if !self.desiredActive { return }
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
|
||||
let instance = await PortGuardian.shared.describe(port: port)
|
||||
let details = instance.map { "pid \($0.pid)" }
|
||||
self.status = .running(details: details)
|
||||
self.logger.info("gateway started details=\(details ?? "ok")")
|
||||
self.refreshControlChannelIfNeeded(reason: "gateway started")
|
||||
self.refreshLog()
|
||||
return
|
||||
} catch {
|
||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
self.status = .failed("Gateway did not start in time")
|
||||
self.lastFailureReason = "launchd start timeout"
|
||||
self.logger.warning("gateway start timed out")
|
||||
}
|
||||
|
||||
private func appendLog(_ chunk: String) {
|
||||
self.log.append(chunk)
|
||||
if self.log.count > self.logLimit {
|
||||
self.log = String(self.log.suffix(self.logLimit))
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshControlChannelIfNeeded(reason: String) {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected, .connecting:
|
||||
return
|
||||
case .disconnected, .degraded:
|
||||
break
|
||||
}
|
||||
self.appendLog("[gateway] refreshing control channel (\(reason))\n")
|
||||
self.logger.debug("gateway control channel refresh reason=\(reason)")
|
||||
Task { await ControlChannel.shared.configure() }
|
||||
}
|
||||
|
||||
func waitForGatewayReady(timeout: TimeInterval = 6) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if !self.desiredActive { return false }
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
|
||||
return true
|
||||
} catch {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
}
|
||||
}
|
||||
self.appendLog("[gateway] readiness wait timed out\n")
|
||||
self.logger.warning("gateway readiness wait timed out")
|
||||
return false
|
||||
}
|
||||
|
||||
func clearLog() {
|
||||
self.log = ""
|
||||
try? FileManager.default.removeItem(atPath: LogLocator.launchdGatewayLogPath)
|
||||
self.logger.debug("gateway log cleared")
|
||||
}
|
||||
|
||||
func setProjectRoot(path: String) {
|
||||
CommandResolver.setProjectRoot(path)
|
||||
}
|
||||
|
||||
func projectRootPath() -> String {
|
||||
CommandResolver.projectRootPath()
|
||||
}
|
||||
|
||||
private nonisolated static func readGatewayLog(path: String, limit: Int) -> String {
|
||||
guard FileManager.default.fileExists(atPath: path) else { return "" }
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" }
|
||||
let text = String(data: data, encoding: .utf8) ?? ""
|
||||
if text.count <= limit { return text }
|
||||
return String(text.suffix(limit))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import ClawdbotProtocol
|
||||
|
||||
// The generated gateway protocol models are value types, but they don't currently declare Sendable.
|
||||
// We use them across actors via GatewayConnection's event stream, so mark them as unchecked.
|
||||
extension HelloOk: @unchecked Sendable {}
|
||||
extension EventFrame: @unchecked Sendable {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import ClawdbotProtocol
|
||||
|
||||
/// Server-push messages from the gateway websocket.
|
||||
///
|
||||
/// This is the in-process replacement for the legacy `NotificationCenter` fan-out.
|
||||
enum GatewayPush: Sendable {
|
||||
/// A full snapshot that arrives on connect (or reconnect).
|
||||
case snapshot(HelloOk)
|
||||
/// A server push event frame.
|
||||
case event(EventFrame)
|
||||
/// A detected sequence gap (`expected...received`) for event frames.
|
||||
case seqGap(expected: Int, received: Int)
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
import AppKit
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSettings: View {
|
||||
@Bindable var state: AppState
|
||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||
private let healthStore = HealthStore.shared
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel()
|
||||
@State private var isInstallingCLI = false
|
||||
@State private var cliStatus: String?
|
||||
@State private var cliInstalled = false
|
||||
@State private var cliInstallLocation: String?
|
||||
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
||||
@State private var remoteStatus: RemoteStatus = .idle
|
||||
@State private var showRemoteAdvanced = false
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !self.state.onboardingSeen {
|
||||
Text("Complete onboarding to finish setup")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundColor(.accentColor)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SettingsToggleRow(
|
||||
title: "Clawdbot active",
|
||||
subtitle: "Pause to stop the Clawdbot gateway; no messages will be processed.",
|
||||
binding: self.activeBinding)
|
||||
|
||||
self.connectionSection
|
||||
|
||||
Divider()
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Launch at login",
|
||||
subtitle: "Automatically start Clawdbot after you sign in.",
|
||||
binding: self.$state.launchAtLogin)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Show Dock icon",
|
||||
subtitle: "Keep Clawdbot visible in the Dock instead of menu-bar-only mode.",
|
||||
binding: self.$state.showDockIcon)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Play menu bar icon animations",
|
||||
subtitle: "Enable idle blinks and wiggles on the status icon.",
|
||||
binding: self.$state.iconAnimationsEnabled)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Allow Canvas",
|
||||
subtitle: "Allow the agent to show and control the Canvas panel.",
|
||||
binding: self.$state.canvasEnabled)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Allow Camera",
|
||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||
binding: self.$cameraEnabled)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Location Access")
|
||||
.font(.body)
|
||||
|
||||
Picker("", selection: self.$locationModeRaw) {
|
||||
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
|
||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||
.disabled(self.locationMode == .off)
|
||||
|
||||
Text("Always may require System Settings to approve background location.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Enable Peekaboo Bridge",
|
||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||
binding: self.$state.peekabooBridgeEnabled)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Enable debug tools",
|
||||
subtitle: "Show the Debug tab with development utilities.",
|
||||
binding: self.$state.debugPaneEnabled)
|
||||
}
|
||||
|
||||
Spacer(minLength: 12)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Quit Clawdbot") { NSApp.terminate(nil) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.onAppear {
|
||||
guard !self.isPreview else { return }
|
||||
self.refreshCLIStatus()
|
||||
self.refreshGatewayStatus()
|
||||
self.lastLocationModeRaw = self.locationModeRaw
|
||||
}
|
||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
CanvasManager.shared.hideAll()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.locationModeRaw) { _, newValue in
|
||||
let previous = self.lastLocationModeRaw
|
||||
self.lastLocationModeRaw = newValue
|
||||
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
|
||||
Task {
|
||||
let granted = await self.requestLocationAuthorization(mode: mode)
|
||||
if !granted {
|
||||
await MainActor.run {
|
||||
self.locationModeRaw = previous
|
||||
self.lastLocationModeRaw = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { !self.state.isPaused },
|
||||
set: { self.state.isPaused = !$0 })
|
||||
}
|
||||
|
||||
private var locationMode: ClawdbotLocationMode {
|
||||
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||
}
|
||||
|
||||
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
||||
guard mode != .off else { return true }
|
||||
let status = CLLocationManager.authorizationStatus()
|
||||
if status == .authorizedAlways || status == .authorizedWhenInUse {
|
||||
if mode == .always && status != .authorizedAlways {
|
||||
let updated = await LocationPermissionRequester.shared.request(always: true)
|
||||
return updated == .authorizedAlways || updated == .authorizedWhenInUse
|
||||
}
|
||||
return true
|
||||
}
|
||||
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
|
||||
switch updated {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Clawdbot runs")
|
||||
.font(.title3.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Picker("", selection: self.$state.connectionMode) {
|
||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 380, alignment: .leading)
|
||||
|
||||
if self.state.connectionMode == .unconfigured {
|
||||
Text("Pick Local or Remote to start the Gateway.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if self.state.connectionMode == .local {
|
||||
// In Nix mode, gateway is managed declaratively - no install buttons.
|
||||
if !self.isNixMode {
|
||||
self.gatewayInstallerCard
|
||||
}
|
||||
TailscaleIntegrationSection(
|
||||
connectionMode: self.state.connectionMode,
|
||||
isPaused: self.state.isPaused)
|
||||
self.healthRow
|
||||
}
|
||||
|
||||
if self.state.connectionMode == .remote {
|
||||
self.remoteCard
|
||||
}
|
||||
|
||||
self.cliInstaller
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("SSH")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: 48, alignment: .leading)
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
GatewayDiscoveryInlineList(
|
||||
discovery: self.gatewayDiscovery,
|
||||
currentTarget: self.state.remoteTarget)
|
||||
{ gateway in
|
||||
self.applyDiscoveredGateway(gateway)
|
||||
}
|
||||
.padding(.leading, 58)
|
||||
|
||||
self.remoteStatusView
|
||||
.padding(.leading, 58)
|
||||
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("CLI path") {
|
||||
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
} label: {
|
||||
Text("Advanced")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
|
||||
// Diagnostics
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Control channel")
|
||||
.font(.caption.weight(.semibold))
|
||||
if !self.isControlStatusDuplicate || ControlChannel.shared.lastPingMs != nil {
|
||||
let status = self.isControlStatusDuplicate ? nil : self.controlStatusLine
|
||||
let ping = ControlChannel.shared.lastPingMs.map { "Ping \(Int($0)) ms" }
|
||||
let line = [status, ping].compactMap(\.self).joined(separator: " · ")
|
||||
if !line.isEmpty {
|
||||
Text(line)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let hb = HeartbeatStore.shared.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000))
|
||||
Text("Last heartbeat: \(hb.status) · \(ageText)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.transition(.opacity)
|
||||
.onAppear { self.gatewayDiscovery.start() }
|
||||
.onDisappear { self.gatewayDiscovery.stop() }
|
||||
}
|
||||
|
||||
private var controlStatusLine: String {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected: "Connected"
|
||||
case .connecting: "Connecting…"
|
||||
case .disconnected: "Disconnected"
|
||||
case let .degraded(msg): msg
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var remoteStatusView: some View {
|
||||
switch self.remoteStatus {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .checking:
|
||||
Text("Testing…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .ok:
|
||||
Label("Ready", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
case let .failed(message):
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
private var isControlStatusDuplicate: Bool {
|
||||
guard case let .failed(message) = self.remoteStatus else { return false }
|
||||
return message == self.controlStatusLine
|
||||
}
|
||||
|
||||
private var cliInstaller: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
Task { await self.installCLI() }
|
||||
} label: {
|
||||
let title = self.cliInstalled ? "Reinstall CLI helper" : "Install CLI helper"
|
||||
ZStack {
|
||||
Text(title)
|
||||
.opacity(self.isInstallingCLI ? 0 : 1)
|
||||
if self.isInstallingCLI {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 150)
|
||||
}
|
||||
.disabled(self.isInstallingCLI)
|
||||
|
||||
if self.isInstallingCLI {
|
||||
Text("Working...")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.cliInstalled {
|
||||
Label("Installed", systemImage: "checkmark.circle.fill")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Not installed")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let status = cliStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if let installLocation = self.cliInstallLocation {
|
||||
Text("Found at \(installLocation)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text("Symlink \"clawdbot\" into /usr/local/bin and /opt/homebrew/bin for scripts.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayInstallerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.gatewayStatusColor)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(self.gatewayStatus.message)
|
||||
.font(.callout)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if let gatewayVersion = self.gatewayStatus.gatewayVersion,
|
||||
let required = self.gatewayStatus.requiredGateway,
|
||||
gatewayVersion != required
|
||||
{
|
||||
Text("Installed: \(gatewayVersion) · Required: \(required)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let gatewayVersion = self.gatewayStatus.gatewayVersion {
|
||||
Text("Gateway \(gatewayVersion) detected")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let node = self.gatewayStatus.nodeVersion {
|
||||
Text("Node \(node)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if case let .attachedExisting(details) = self.gatewayManager.status {
|
||||
Text(details ?? "Using existing gateway instance")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let failure = self.gatewayManager.lastFailureReason {
|
||||
Text("Last failure: \(failure)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Button("Recheck") { self.refreshGatewayStatus() }
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Text("Gateway auto-starts in local mode via launchd (\(gatewayLaunchdLabel)).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private func installCLI() async {
|
||||
guard !self.isInstallingCLI else { return }
|
||||
self.isInstallingCLI = true
|
||||
defer { isInstallingCLI = false }
|
||||
await CLIInstaller.install { status in
|
||||
await MainActor.run {
|
||||
self.cliStatus = status
|
||||
self.refreshCLIStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshCLIStatus() {
|
||||
let installLocation = CLIInstaller.installedLocation()
|
||||
self.cliInstallLocation = installLocation
|
||||
self.cliInstalled = installLocation != nil
|
||||
}
|
||||
|
||||
private func refreshGatewayStatus() {
|
||||
Task {
|
||||
let status = await Task.detached(priority: .utility) {
|
||||
GatewayEnvironment.check()
|
||||
}.value
|
||||
self.gatewayStatus = status
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayStatusColor: Color {
|
||||
switch self.gatewayStatus.kind {
|
||||
case .ok: .green
|
||||
case .checking: .secondary
|
||||
case .missingNode, .missingGateway, .incompatible, .error: .orange
|
||||
}
|
||||
}
|
||||
|
||||
private var healthCard: some View {
|
||||
let snapshot = self.healthStore.snapshot
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(self.healthStore.state.tint)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(self.healthStore.summaryLine)
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
|
||||
if let snap = snapshot {
|
||||
Text("Linked auth age: \(healthAgeString(snap.web.authAgeMs))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let recent = snap.sessions.recent.first {
|
||||
let lastActivity = recent.updatedAt != nil
|
||||
? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000))
|
||||
: "unknown"
|
||||
Text("Last activity: \(recent.key) \(lastActivity)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Last check: \(relativeAge(from: self.healthStore.lastSuccess))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let error = self.healthStore.lastError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Text("Health check pending…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.healthStore.refresh(onDemand: true) }
|
||||
} label: {
|
||||
if self.healthStore.isRefreshing {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Label("Run Health Check", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
.disabled(self.healthStore.isRefreshing)
|
||||
|
||||
Divider().frame(height: 18)
|
||||
|
||||
Button {
|
||||
self.revealLogs()
|
||||
} label: {
|
||||
Label("Reveal Logs", systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.gray.opacity(0.08))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
}
|
||||
|
||||
private enum RemoteStatus: Equatable {
|
||||
case idle
|
||||
case checking
|
||||
case ok
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
extension GeneralSettings {
|
||||
private var healthRow: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.healthStore.state.tint)
|
||||
.frame(width: 10, height: 10)
|
||||
Text(self.healthStore.summaryLine)
|
||||
.font(.callout)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if let detail = self.healthStore.detailLine {
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button("Retry now") {
|
||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||
}
|
||||
.disabled(self.healthStore.isRefreshing)
|
||||
|
||||
Button("Open logs") { self.revealLogs() }
|
||||
.buttonStyle(.link)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testRemote() async {
|
||||
self.remoteStatus = .checking
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
guard !settings.target.isEmpty else {
|
||||
self.remoteStatus = .failed("Set an SSH target first")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 1: basic SSH reachability check
|
||||
let sshResult = await ShellExecutor.run(
|
||||
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 8)
|
||||
|
||||
guard sshResult.ok else {
|
||||
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: control channel health over tunnel
|
||||
let originalMode = AppStateStore.shared.connectionMode
|
||||
do {
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
target: settings.target,
|
||||
identity: settings.identity))
|
||||
let data = try await ControlChannel.shared.health(timeout: 10)
|
||||
if decodeHealthSnapshot(from: data) != nil {
|
||||
self.remoteStatus = .ok
|
||||
} else {
|
||||
self.remoteStatus = .failed("Control channel returned invalid health JSON")
|
||||
}
|
||||
} catch {
|
||||
self.remoteStatus = .failed(error.localizedDescription)
|
||||
}
|
||||
|
||||
// Restore original mode if we temporarily switched
|
||||
switch originalMode {
|
||||
case .remote:
|
||||
break
|
||||
case .local:
|
||||
try? await ControlChannel.shared.configure(mode: .local)
|
||||
case .unconfigured:
|
||||
await ControlChannel.shared.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private static func sshCheckCommand(target: String, identity: String) -> [String] {
|
||||
var args: [String] = [
|
||||
"/usr/bin/ssh",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
if !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
args.append(contentsOf: ["-i", identity])
|
||||
}
|
||||
args.append(target)
|
||||
args.append("echo ok")
|
||||
return args
|
||||
}
|
||||
|
||||
private func formatSSHFailure(_ response: Response, target: String) -> String {
|
||||
let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) }
|
||||
let trimmed = payload?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.joined(separator: " ")
|
||||
if let trimmed,
|
||||
trimmed.localizedCaseInsensitiveContains("host key verification failed")
|
||||
{
|
||||
let host = CommandResolver.parseSSHTarget(target)?.host ?? target
|
||||
return "SSH check failed: Host key verification failed. Remove the old key with " +
|
||||
"`ssh-keygen -R \(host)` and try again."
|
||||
}
|
||||
if let trimmed, !trimmed.isEmpty {
|
||||
if let message = response.message, message.hasPrefix("exit ") {
|
||||
return "SSH check failed: \(trimmed) (\(message))"
|
||||
}
|
||||
return "SSH check failed: \(trimmed)"
|
||||
}
|
||||
if let message = response.message {
|
||||
return "SSH check failed (\(message))"
|
||||
}
|
||||
return "SSH check failed"
|
||||
}
|
||||
|
||||
private func revealLogs() {
|
||||
let target = LogLocator.bestLogFile()
|
||||
|
||||
if let target {
|
||||
NSWorkspace.shared.selectFile(
|
||||
target.path,
|
||||
inFileViewerRootedAtPath: target.deletingLastPathComponent().path)
|
||||
return
|
||||
}
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Log file not found"
|
||||
alert.informativeText = """
|
||||
Looked for clawdbot logs in /tmp/clawdbot/.
|
||||
Run a health check or send a message to generate activity, then try again.
|
||||
"""
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
||||
MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID)
|
||||
|
||||
let host = gateway.tailnetDns ?? gateway.lanHost
|
||||
guard let host else { return }
|
||||
let user = NSUserName()
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
private func healthAgeString(_ ms: Double?) -> String {
|
||||
guard let ms else { return "unknown" }
|
||||
return msToAge(ms)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct GeneralSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GeneralSettings(state: .preview)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
.environment(TailscaleService.shared)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension GeneralSettings {
|
||||
static func exerciseForTesting() {
|
||||
let state = AppState(preview: true)
|
||||
state.connectionMode = .remote
|
||||
state.remoteTarget = "user@host:2222"
|
||||
state.remoteIdentity = "/tmp/id_ed25519"
|
||||
state.remoteProjectRoot = "/tmp/clawdbot"
|
||||
state.remoteCliPath = "/tmp/clawdbot"
|
||||
|
||||
let view = GeneralSettings(state: state)
|
||||
view.gatewayStatus = GatewayEnvironmentStatus(
|
||||
kind: .ok,
|
||||
nodeVersion: "1.0.0",
|
||||
gatewayVersion: "1.0.0",
|
||||
requiredGateway: nil,
|
||||
message: "Gateway ready")
|
||||
view.remoteStatus = .failed("SSH failed")
|
||||
view.showRemoteAdvanced = true
|
||||
view.cliInstalled = true
|
||||
view.cliInstallLocation = "/usr/local/bin/clawdbot"
|
||||
view.cliStatus = "Installed"
|
||||
_ = view.body
|
||||
|
||||
state.connectionMode = .unconfigured
|
||||
_ = view.body
|
||||
|
||||
state.connectionMode = .local
|
||||
view.gatewayStatus = GatewayEnvironmentStatus(
|
||||
kind: .error("Gateway offline"),
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: nil,
|
||||
message: "Gateway offline")
|
||||
_ = view.body
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,255 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct HealthSnapshot: Codable, Sendable {
|
||||
struct Telegram: Codable, Sendable {
|
||||
struct Probe: Codable, Sendable {
|
||||
struct Bot: Codable, Sendable {
|
||||
let id: Int?
|
||||
let username: String?
|
||||
}
|
||||
|
||||
let ok: Bool
|
||||
let status: Int?
|
||||
let error: String?
|
||||
let elapsedMs: Double?
|
||||
let bot: Bot?
|
||||
}
|
||||
|
||||
let configured: Bool
|
||||
let probe: Probe?
|
||||
}
|
||||
|
||||
struct Web: Codable, Sendable {
|
||||
struct Connect: Codable, Sendable {
|
||||
let ok: Bool
|
||||
let status: Int?
|
||||
let error: String?
|
||||
let elapsedMs: Double?
|
||||
}
|
||||
|
||||
let linked: Bool
|
||||
let authAgeMs: Double?
|
||||
let connect: Connect?
|
||||
}
|
||||
|
||||
struct SessionInfo: Codable, Sendable {
|
||||
let key: String
|
||||
let updatedAt: Double?
|
||||
let age: Double?
|
||||
}
|
||||
|
||||
struct Sessions: Codable, Sendable {
|
||||
let path: String
|
||||
let count: Int
|
||||
let recent: [SessionInfo]
|
||||
}
|
||||
|
||||
let ok: Bool?
|
||||
let ts: Double
|
||||
let durationMs: Double
|
||||
let web: Web
|
||||
let telegram: Telegram?
|
||||
let heartbeatSeconds: Int?
|
||||
let sessions: Sessions
|
||||
}
|
||||
|
||||
enum HealthState: Equatable {
|
||||
case unknown
|
||||
case ok
|
||||
case linkingNeeded
|
||||
case degraded(String)
|
||||
|
||||
var tint: Color {
|
||||
switch self {
|
||||
case .ok: .green
|
||||
case .linkingNeeded: .red
|
||||
case .degraded: .orange
|
||||
case .unknown: .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class HealthStore {
|
||||
static let shared = HealthStore()
|
||||
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "health")
|
||||
|
||||
private(set) var snapshot: HealthSnapshot?
|
||||
private(set) var lastSuccess: Date?
|
||||
private(set) var lastError: String?
|
||||
private(set) var isRefreshing = false
|
||||
|
||||
private var loopTask: Task<Void, Never>?
|
||||
private let refreshInterval: TimeInterval = 60
|
||||
|
||||
private init() {
|
||||
// Avoid background health polling in SwiftUI previews and tests.
|
||||
if !ProcessInfo.processInfo.isPreview, !ProcessInfo.processInfo.isRunningTests {
|
||||
self.start()
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.loopTask == nil else { return }
|
||||
self.loopTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
while !Task.isCancelled {
|
||||
await self.refresh()
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.refreshInterval * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.loopTask?.cancel()
|
||||
self.loopTask = nil
|
||||
}
|
||||
|
||||
func refresh(onDemand: Bool = false) async {
|
||||
guard !self.isRefreshing else { return }
|
||||
self.isRefreshing = true
|
||||
defer { self.isRefreshing = false }
|
||||
let previousError = self.lastError
|
||||
|
||||
do {
|
||||
let data = try await ControlChannel.shared.health(timeout: 15)
|
||||
if let decoded = decodeHealthSnapshot(from: data) {
|
||||
self.snapshot = decoded
|
||||
self.lastSuccess = Date()
|
||||
self.lastError = nil
|
||||
if previousError != nil {
|
||||
Self.logger.info("health refresh recovered")
|
||||
}
|
||||
} else {
|
||||
self.lastError = "health output not JSON"
|
||||
if onDemand { self.snapshot = nil }
|
||||
if previousError != self.lastError {
|
||||
Self.logger.warning("health refresh failed: output not JSON")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
let desc = error.localizedDescription
|
||||
self.lastError = desc
|
||||
if onDemand { self.snapshot = nil }
|
||||
if previousError != desc {
|
||||
Self.logger.error("health refresh failed \(desc, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func isTelegramHealthy(_ snap: HealthSnapshot) -> Bool {
|
||||
guard let tg = snap.telegram, tg.configured else { return false }
|
||||
// If probe is missing, treat it as "configured but unknown health" (not a hard fail).
|
||||
return tg.probe?.ok ?? true
|
||||
}
|
||||
|
||||
var state: HealthState {
|
||||
if let error = self.lastError, !error.isEmpty {
|
||||
return .degraded(error)
|
||||
}
|
||||
guard let snap = self.snapshot else { return .unknown }
|
||||
if !snap.web.linked {
|
||||
// WhatsApp Web linking is optional if Telegram is healthy; don't paint the whole app red.
|
||||
return Self.isTelegramHealthy(snap) ? .degraded("Not linked") : .linkingNeeded
|
||||
}
|
||||
if let connect = snap.web.connect, !connect.ok {
|
||||
let reason = connect.error ?? "connect failed"
|
||||
return .degraded(reason)
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
|
||||
var summaryLine: String {
|
||||
if self.isRefreshing { return "Health check running…" }
|
||||
if let error = self.lastError { return "Health check failed: \(error)" }
|
||||
guard let snap = self.snapshot else { return "Health check pending" }
|
||||
if !snap.web.linked {
|
||||
if let tg = snap.telegram, tg.configured {
|
||||
let tgLabel = (tg.probe?.ok ?? true) ? "Telegram ok" : "Telegram degraded"
|
||||
return "\(tgLabel) · Not linked — run clawdbot login"
|
||||
}
|
||||
return "Not linked — run clawdbot login"
|
||||
}
|
||||
let auth = snap.web.authAgeMs.map { msToAge($0) } ?? "unknown"
|
||||
if let connect = snap.web.connect, !connect.ok {
|
||||
let code = connect.status.map(String.init) ?? "?"
|
||||
return "Link stale? status \(code)"
|
||||
}
|
||||
return "linked · auth \(auth) · socket ok"
|
||||
}
|
||||
|
||||
/// Short, human-friendly detail for the last failure, used in the UI.
|
||||
var detailLine: String? {
|
||||
if let error = self.lastError, !error.isEmpty {
|
||||
let lower = error.lowercased()
|
||||
if lower.contains("connection refused") {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — restart Clawdbot to bring it back."
|
||||
}
|
||||
if lower.contains("timeout") {
|
||||
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
||||
}
|
||||
return error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String {
|
||||
if !snap.web.linked {
|
||||
return "Not linked — run clawdbot login"
|
||||
}
|
||||
if let connect = snap.web.connect, !connect.ok {
|
||||
let elapsed = connect.elapsedMs.map { "\(Int($0))ms" } ?? "unknown duration"
|
||||
if let err = connect.error, err.lowercased().contains("timeout") || connect.status == nil {
|
||||
return "Health check timed out (\(elapsed))"
|
||||
}
|
||||
let code = connect.status.map { "status \($0)" } ?? "status unknown"
|
||||
let reason = connect.error ?? "connect failed"
|
||||
return "\(reason) (\(code), \(elapsed))"
|
||||
}
|
||||
if let fallback, !fallback.isEmpty {
|
||||
return fallback
|
||||
}
|
||||
return "health probe failed"
|
||||
}
|
||||
|
||||
var degradedSummary: String? {
|
||||
guard case let .degraded(reason) = self.state else { return nil }
|
||||
if reason == "[object Object]" || reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let snap = self.snapshot
|
||||
{
|
||||
return self.describeFailure(from: snap, fallback: reason)
|
||||
}
|
||||
return reason
|
||||
}
|
||||
}
|
||||
|
||||
func msToAge(_ ms: Double) -> String {
|
||||
let minutes = Int(round(ms / 60000))
|
||||
if minutes < 1 { return "just now" }
|
||||
if minutes < 60 { return "\(minutes)m" }
|
||||
let hours = Int(round(Double(minutes) / 60))
|
||||
if hours < 48 { return "\(hours)h" }
|
||||
let days = Int(round(Double(hours) / 24))
|
||||
return "\(days)d"
|
||||
}
|
||||
|
||||
/// Decode a health snapshot, tolerating stray log lines before/after the JSON blob.
|
||||
func decodeHealthSnapshot(from data: Data) -> HealthSnapshot? {
|
||||
let decoder = JSONDecoder()
|
||||
if let snap = try? decoder.decode(HealthSnapshot.self, from: data) {
|
||||
return snap
|
||||
}
|
||||
guard let text = String(data: data, encoding: .utf8) else { return nil }
|
||||
guard let firstBrace = text.firstIndex(of: "{"), let lastBrace = text.lastIndex(of: "}") else {
|
||||
return nil
|
||||
}
|
||||
let slice = text[firstBrace...lastBrace]
|
||||
let cleaned = Data(slice.utf8)
|
||||
return try? decoder.decode(HealthSnapshot.self, from: cleaned)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class HeartbeatStore {
|
||||
static let shared = HeartbeatStore()
|
||||
|
||||
private(set) var lastEvent: ControlHeartbeatEvent?
|
||||
|
||||
private var observer: NSObjectProtocol?
|
||||
|
||||
private init() {
|
||||
self.observer = NotificationCenter.default.addObserver(
|
||||
forName: .controlHeartbeat,
|
||||
object: nil,
|
||||
queue: .main)
|
||||
{ [weak self] note in
|
||||
guard let data = note.object as? Data else { return }
|
||||
if let decoded = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) {
|
||||
Task { @MainActor in self?.lastEvent = decoded }
|
||||
}
|
||||
}
|
||||
|
||||
Task {
|
||||
if self.lastEvent == nil {
|
||||
if let evt = try? await ControlChannel.shared.lastHeartbeat() {
|
||||
self.lastEvent = evt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
deinit {
|
||||
if let observer { NotificationCenter.default.removeObserver(observer) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import QuartzCore
|
||||
import SwiftUI
|
||||
|
||||
/// Hover-only HUD anchored to the menu bar item. Click expands into full Web Chat.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class HoverHUDController {
|
||||
static let shared = HoverHUDController()
|
||||
|
||||
struct Model {
|
||||
var isVisible: Bool = false
|
||||
var isSuppressed: Bool = false
|
||||
var hoveringStatusItem: Bool = false
|
||||
var hoveringPanel: Bool = false
|
||||
}
|
||||
|
||||
private(set) var model = Model()
|
||||
|
||||
private var window: NSPanel?
|
||||
private var hostingView: NSHostingView<HoverHUDView>?
|
||||
private var dismissMonitor: Any?
|
||||
private var dismissTask: Task<Void, Never>?
|
||||
private var showTask: Task<Void, Never>?
|
||||
private var anchorProvider: (() -> NSRect?)?
|
||||
|
||||
private let width: CGFloat = 360
|
||||
private let height: CGFloat = 74
|
||||
private let padding: CGFloat = 8
|
||||
private let hoverShowDelay: TimeInterval = 0.18
|
||||
|
||||
func setSuppressed(_ suppressed: Bool) {
|
||||
self.model.isSuppressed = suppressed
|
||||
if suppressed {
|
||||
self.showTask?.cancel()
|
||||
self.showTask = nil
|
||||
self.dismiss(reason: "suppressed")
|
||||
}
|
||||
}
|
||||
|
||||
func statusItemHoverChanged(inside: Bool, anchorProvider: @escaping () -> NSRect?) {
|
||||
self.model.hoveringStatusItem = inside
|
||||
self.anchorProvider = anchorProvider
|
||||
|
||||
guard !self.model.isSuppressed else { return }
|
||||
|
||||
if inside {
|
||||
self.dismissTask?.cancel()
|
||||
self.dismissTask = nil
|
||||
self.showTask?.cancel()
|
||||
self.showTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.hoverShowDelay * 1_000_000_000))
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard !Task.isCancelled else { return }
|
||||
guard self.model.hoveringStatusItem else { return }
|
||||
guard !self.model.isSuppressed else { return }
|
||||
self.present()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.showTask?.cancel()
|
||||
self.showTask = nil
|
||||
self.scheduleDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
func panelHoverChanged(inside: Bool) {
|
||||
self.model.hoveringPanel = inside
|
||||
if inside {
|
||||
self.dismissTask?.cancel()
|
||||
self.dismissTask = nil
|
||||
} else if !self.model.hoveringStatusItem {
|
||||
self.scheduleDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
func openChat() {
|
||||
guard let anchorProvider = self.anchorProvider else { return }
|
||||
self.dismiss(reason: "openChat")
|
||||
Task { @MainActor in
|
||||
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||
WebChatManager.shared.togglePanel(sessionKey: sessionKey, anchorProvider: anchorProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss(reason: String = "explicit") {
|
||||
self.dismissTask?.cancel()
|
||||
self.dismissTask = nil
|
||||
self.removeDismissMonitor()
|
||||
guard let window else {
|
||||
self.model.isVisible = false
|
||||
return
|
||||
}
|
||||
|
||||
if !self.model.isVisible {
|
||||
window.orderOut(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let target = window.frame.offsetBy(dx: 0, dy: 6)
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.14
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(target, display: true)
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
Task { @MainActor in
|
||||
window.orderOut(nil)
|
||||
self.model.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func scheduleDismiss() {
|
||||
self.dismissTask?.cancel()
|
||||
self.dismissTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
if self.model.hoveringStatusItem || self.model.hoveringPanel { return }
|
||||
self.dismiss(reason: "hoverExit")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func present() {
|
||||
guard !self.model.isSuppressed else { return }
|
||||
self.ensureWindow()
|
||||
self.hostingView?.rootView = HoverHUDView(controller: self)
|
||||
let target = self.targetFrame()
|
||||
|
||||
guard let window else { return }
|
||||
self.installDismissMonitor()
|
||||
|
||||
if !self.model.isVisible {
|
||||
self.model.isVisible = true
|
||||
let start = target.offsetBy(dx: 0, dy: 8)
|
||||
window.setFrame(start, display: true)
|
||||
window.alphaValue = 0
|
||||
window.orderFrontRegardless()
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(target, display: true)
|
||||
window.animator().alphaValue = 1
|
||||
}
|
||||
} else {
|
||||
window.orderFrontRegardless()
|
||||
self.updateWindowFrame(animate: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureWindow() {
|
||||
if self.window != nil { return }
|
||||
let panel = NSPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height),
|
||||
styleMask: [.nonactivatingPanel, .borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = true
|
||||
panel.level = .statusBar
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.isMovable = false
|
||||
panel.isFloatingPanel = true
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
panel.titleVisibility = .hidden
|
||||
panel.titlebarAppearsTransparent = true
|
||||
|
||||
let host = NSHostingView(rootView: HoverHUDView(controller: self))
|
||||
host.translatesAutoresizingMaskIntoConstraints = false
|
||||
panel.contentView = host
|
||||
self.hostingView = host
|
||||
self.window = panel
|
||||
}
|
||||
|
||||
private func targetFrame() -> NSRect {
|
||||
guard let anchor = self.anchorProvider?() else {
|
||||
return WindowPlacement.topRightFrame(
|
||||
size: NSSize(width: self.width, height: self.height),
|
||||
padding: self.padding)
|
||||
}
|
||||
|
||||
let screen = NSScreen.screens.first { screen in
|
||||
screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
|
||||
} ?? NSScreen.main
|
||||
|
||||
let bounds = (screen?.visibleFrame ?? .zero).insetBy(dx: self.padding, dy: self.padding)
|
||||
return WindowPlacement.anchoredBelowFrame(
|
||||
size: NSSize(width: self.width, height: self.height),
|
||||
anchor: anchor,
|
||||
padding: self.padding,
|
||||
in: bounds)
|
||||
}
|
||||
|
||||
private func updateWindowFrame(animate: Bool = false) {
|
||||
guard let window else { return }
|
||||
let frame = self.targetFrame()
|
||||
if animate {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.12
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(frame, display: true)
|
||||
}
|
||||
} else {
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func installDismissMonitor() {
|
||||
if ProcessInfo.processInfo.isRunningTests { return }
|
||||
guard self.dismissMonitor == nil, let window else { return }
|
||||
self.dismissMonitor = NSEvent.addGlobalMonitorForEvents(matching: [
|
||||
.leftMouseDown,
|
||||
.rightMouseDown,
|
||||
.otherMouseDown,
|
||||
]) { [weak self] _ in
|
||||
guard let self, self.model.isVisible else { return }
|
||||
let pt = NSEvent.mouseLocation
|
||||
if !window.frame.contains(pt) {
|
||||
Task { @MainActor in self.dismiss(reason: "outsideClick") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeDismissMonitor() {
|
||||
if let monitor = self.dismissMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
self.dismissMonitor = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HoverHUDView: View {
|
||||
var controller: HoverHUDController
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
|
||||
private var statusTitle: String {
|
||||
if self.activityStore.iconState.isWorking { return "Working" }
|
||||
return "Idle"
|
||||
}
|
||||
|
||||
private var detail: String {
|
||||
if let current = self.activityStore.current?.label, !current.isEmpty { return current }
|
||||
if let last = self.activityStore.lastToolLabel, !last.isEmpty { return last }
|
||||
return "No recent activity"
|
||||
}
|
||||
|
||||
private var symbolName: String {
|
||||
if self.activityStore.iconState.isWorking {
|
||||
return self.activityStore.iconState.badgeSymbolName
|
||||
}
|
||||
return "moon.zzz.fill"
|
||||
}
|
||||
|
||||
private var dotColor: Color {
|
||||
if self.activityStore.iconState.isWorking {
|
||||
return Color(nsColor: NSColor.systemGreen.withAlphaComponent(0.7))
|
||||
}
|
||||
return .secondary
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.dotColor)
|
||||
.frame(width: 7, height: 7)
|
||||
.padding(.top, 5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.statusTitle)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(self.detail)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(systemName: self.symbolName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.regularMaterial))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(Color.black.opacity(0.10), lineWidth: 1))
|
||||
.contentShape(Rectangle())
|
||||
.onHover { inside in
|
||||
self.controller.panelHoverChanged(inside: inside)
|
||||
}
|
||||
.onTapGesture {
|
||||
self.controller.openChat()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum SessionRole {
|
||||
case main
|
||||
case other
|
||||
}
|
||||
|
||||
enum ToolKind: String, Codable {
|
||||
case bash, read, write, edit, attach, other
|
||||
}
|
||||
|
||||
enum ActivityKind: Codable, Equatable {
|
||||
case job
|
||||
case tool(ToolKind)
|
||||
}
|
||||
|
||||
enum IconState: Equatable {
|
||||
case idle
|
||||
case workingMain(ActivityKind)
|
||||
case workingOther(ActivityKind)
|
||||
case overridden(ActivityKind)
|
||||
|
||||
enum BadgeProminence: Equatable {
|
||||
case primary
|
||||
case secondary
|
||||
case overridden
|
||||
}
|
||||
|
||||
var badgeSymbolName: String {
|
||||
switch self.activity {
|
||||
case .tool(.bash): "chevron.left.slash.chevron.right"
|
||||
case .tool(.read): "doc"
|
||||
case .tool(.write): "pencil"
|
||||
case .tool(.edit): "pencil.tip"
|
||||
case .tool(.attach): "paperclip"
|
||||
case .tool(.other), .job: "gearshape.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var badgeProminence: BadgeProminence? {
|
||||
switch self {
|
||||
case .idle: nil
|
||||
case .workingMain: .primary
|
||||
case .workingOther: .secondary
|
||||
case .overridden: .overridden
|
||||
}
|
||||
}
|
||||
|
||||
var isWorking: Bool {
|
||||
switch self {
|
||||
case .idle: false
|
||||
default: true
|
||||
}
|
||||
}
|
||||
|
||||
private var activity: ActivityKind {
|
||||
switch self {
|
||||
case let .workingMain(kind),
|
||||
let .workingOther(kind),
|
||||
let .overridden(kind):
|
||||
kind
|
||||
case .idle:
|
||||
.job
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum IconOverrideSelection: String, CaseIterable, Identifiable {
|
||||
case system
|
||||
case idle
|
||||
case mainBash, mainRead, mainWrite, mainEdit, mainOther
|
||||
case otherBash, otherRead, otherWrite, otherEdit, otherOther
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .system: "System (auto)"
|
||||
case .idle: "Idle"
|
||||
case .mainBash: "Working main – bash"
|
||||
case .mainRead: "Working main – read"
|
||||
case .mainWrite: "Working main – write"
|
||||
case .mainEdit: "Working main – edit"
|
||||
case .mainOther: "Working main – other"
|
||||
case .otherBash: "Working other – bash"
|
||||
case .otherRead: "Working other – read"
|
||||
case .otherWrite: "Working other – write"
|
||||
case .otherEdit: "Working other – edit"
|
||||
case .otherOther: "Working other – other"
|
||||
}
|
||||
}
|
||||
|
||||
func toIconState() -> IconState {
|
||||
let map: (ToolKind) -> ActivityKind = { .tool($0) }
|
||||
switch self {
|
||||
case .system: return .idle
|
||||
case .idle: return .idle
|
||||
case .mainBash: return .workingMain(map(.bash))
|
||||
case .mainRead: return .workingMain(map(.read))
|
||||
case .mainWrite: return .workingMain(map(.write))
|
||||
case .mainEdit: return .workingMain(map(.edit))
|
||||
case .mainOther: return .workingMain(map(.other))
|
||||
case .otherBash: return .workingOther(map(.bash))
|
||||
case .otherRead: return .workingOther(map(.read))
|
||||
case .otherWrite: return .workingOther(map(.write))
|
||||
case .otherEdit: return .workingOther(map(.edit))
|
||||
case .otherOther: return .workingOther(map(.other))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
enum InstanceIdentity {
|
||||
private static let suiteName = "com.clawdbot.shared"
|
||||
private static let instanceIdKey = "instanceId"
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
|
||||
static let instanceId: String = {
|
||||
let defaults = Self.defaults
|
||||
if let existing = defaults.string(forKey: instanceIdKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!existing.isEmpty
|
||||
{
|
||||
return existing
|
||||
}
|
||||
|
||||
let id = UUID().uuidString.lowercased()
|
||||
defaults.set(id, forKey: instanceIdKey)
|
||||
return id
|
||||
}()
|
||||
|
||||
static let displayName: String = {
|
||||
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty
|
||||
{
|
||||
return name
|
||||
}
|
||||
return "clawdbot"
|
||||
}()
|
||||
|
||||
static let modelIdentifier: String? = {
|
||||
var size = 0
|
||||
guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil }
|
||||
|
||||
var buffer = [CChar](repeating: 0, count: size)
|
||||
guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return nil }
|
||||
|
||||
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
|
||||
guard let raw = String(bytes: bytes, encoding: .utf8) else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct InstancesSettings: View {
|
||||
var store: InstancesStore
|
||||
|
||||
init(store: InstancesStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.header
|
||||
if let err = store.lastError {
|
||||
Text("Error: \(err)")
|
||||
.foregroundStyle(.red)
|
||||
} else if let info = store.statusMessage {
|
||||
Text(info)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if self.store.instances.isEmpty {
|
||||
Text("No instances reported yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
List(self.store.instances) { inst in
|
||||
self.instanceRow(inst)
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.onAppear { self.store.start() }
|
||||
.onDisappear { self.store.stop() }
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Connected Instances")
|
||||
.font(.headline)
|
||||
Text("Latest presence beacons from Clawdbot nodes. Updated periodically.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if self.store.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button {
|
||||
Task { await self.store.refresh() }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.help("Refresh")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func instanceRow(_ inst: InstanceInfo) -> some View {
|
||||
let isGateway = (inst.mode ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "gateway"
|
||||
let prettyPlatform = inst.platform.flatMap { self.prettyPlatform($0) }
|
||||
let device = DeviceModelCatalog.presentation(
|
||||
deviceFamily: inst.deviceFamily,
|
||||
modelIdentifier: inst.modelIdentifier)
|
||||
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
self.leadingDeviceIcon(inst, device: device)
|
||||
.frame(width: 28, height: 28, alignment: .center)
|
||||
.padding(.top, 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Text(inst.host ?? "unknown host").font(.subheadline.bold())
|
||||
self.presenceIndicator(inst)
|
||||
if let ip = inst.ip { Text("(") + Text(ip).monospaced() + Text(")") }
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if let version = inst.version {
|
||||
self.label(icon: "shippingbox", text: version)
|
||||
}
|
||||
|
||||
if let device {
|
||||
// Avoid showing generic "Mac"/"iPhone"/etc; prefer the concrete model name.
|
||||
let family = (inst.deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let isGeneric = !family.isEmpty && device.title == family
|
||||
if !isGeneric {
|
||||
if let prettyPlatform {
|
||||
self.label(icon: device.symbol, text: "\(device.title) · \(prettyPlatform)")
|
||||
} else {
|
||||
self.label(icon: device.symbol, text: device.title)
|
||||
}
|
||||
} else if let prettyPlatform, let platform = inst.platform {
|
||||
self.label(icon: self.platformIcon(platform), text: prettyPlatform)
|
||||
}
|
||||
} else if let prettyPlatform, let platform = inst.platform {
|
||||
self.label(icon: self.platformIcon(platform), text: prettyPlatform)
|
||||
}
|
||||
|
||||
if let mode = inst.mode { self.label(icon: "network", text: mode) }
|
||||
}
|
||||
.layoutPriority(1)
|
||||
|
||||
if !isGateway, self.shouldShowUpdateRow(inst) {
|
||||
HStack(spacing: 8) {
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Last local input is helpful for interactive nodes, but noisy/meaningless for the gateway.
|
||||
if let secs = inst.lastInputSeconds {
|
||||
self.label(icon: "clock", text: "\(secs)s ago")
|
||||
}
|
||||
|
||||
if let update = self.updateSummaryText(inst, isGateway: isGateway) {
|
||||
self.label(icon: "arrow.clockwise", text: update)
|
||||
.help(self.presenceUpdateSourceHelp(inst.reason ?? ""))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.help(inst.text)
|
||||
.contextMenu {
|
||||
Button("Copy Debug Summary") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(inst.text, forType: .string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func label(icon: String?, text: String) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
if let icon {
|
||||
if icon == Self.androidSymbolToken {
|
||||
AndroidMark()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 12, height: 12, alignment: .center)
|
||||
} else if self.isSystemSymbolAvailable(icon) {
|
||||
Image(systemName: icon).foregroundStyle(.secondary).font(.caption)
|
||||
}
|
||||
}
|
||||
Text(text)
|
||||
}
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
private func presenceIndicator(_ inst: InstanceInfo) -> some View {
|
||||
let status = self.presenceStatus(for: inst)
|
||||
return HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(status.color)
|
||||
.frame(width: 6, height: 6)
|
||||
.accessibilityHidden(true)
|
||||
Text(status.label)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
.help("Presence updated \(inst.ageDescription).")
|
||||
.accessibilityLabel("\(status.label) presence")
|
||||
}
|
||||
|
||||
private func presenceStatus(for inst: InstanceInfo) -> (label: String, color: Color) {
|
||||
let nowMs = Date().timeIntervalSince1970 * 1000
|
||||
let ageSeconds = max(0, Int((nowMs - inst.ts) / 1000))
|
||||
if ageSeconds <= 120 { return ("Active", .green) }
|
||||
if ageSeconds <= 300 { return ("Idle", .yellow) }
|
||||
return ("Stale", .gray)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func leadingDeviceIcon(_ inst: InstanceInfo, device: DevicePresentation?) -> some View {
|
||||
let symbol = self.leadingDeviceSymbol(inst, device: device)
|
||||
if symbol == Self.androidSymbolToken {
|
||||
AndroidMark()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 24, height: 24, alignment: .center)
|
||||
.accessibilityHidden(true)
|
||||
} else {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 26, weight: .regular))
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private static let androidSymbolToken = "android"
|
||||
|
||||
private func leadingDeviceSymbol(_ inst: InstanceInfo, device: DevicePresentation?) -> String {
|
||||
let family = (inst.deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if family == "android" {
|
||||
return Self.androidSymbolToken
|
||||
}
|
||||
|
||||
if let title = device?.title.lowercased() {
|
||||
if title.contains("mac studio") {
|
||||
return self.safeSystemSymbol("macstudio", fallback: "desktopcomputer")
|
||||
}
|
||||
if title.contains("macbook") {
|
||||
return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer")
|
||||
}
|
||||
if title.contains("ipad") {
|
||||
return self.safeSystemSymbol("ipad", fallback: "ipad")
|
||||
}
|
||||
if title.contains("iphone") {
|
||||
return self.safeSystemSymbol("iphone", fallback: "iphone")
|
||||
}
|
||||
}
|
||||
|
||||
if let symbol = device?.symbol {
|
||||
return self.safeSystemSymbol(symbol, fallback: "cpu")
|
||||
}
|
||||
|
||||
if let platform = inst.platform {
|
||||
return self.safeSystemSymbol(self.platformIcon(platform), fallback: "cpu")
|
||||
}
|
||||
|
||||
return "cpu"
|
||||
}
|
||||
|
||||
private func shouldShowUpdateRow(_ inst: InstanceInfo) -> Bool {
|
||||
if inst.lastInputSeconds != nil { return true }
|
||||
if self.updateSummaryText(inst, isGateway: false) != nil { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func safeSystemSymbol(_ preferred: String, fallback: String) -> String {
|
||||
if self.isSystemSymbolAvailable(preferred) { return preferred }
|
||||
return fallback
|
||||
}
|
||||
|
||||
private func isSystemSymbolAvailable(_ name: String) -> Bool {
|
||||
NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil
|
||||
}
|
||||
|
||||
private struct AndroidMark: View {
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let w = geo.size.width
|
||||
let h = geo.size.height
|
||||
let headHeight = h * 0.68
|
||||
let headWidth = w * 0.92
|
||||
let headY = h * 0.18
|
||||
let corner = headHeight * 0.28
|
||||
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: corner, style: .continuous)
|
||||
.frame(width: headWidth, height: headHeight)
|
||||
.position(x: w / 2, y: headY + headHeight / 2)
|
||||
|
||||
Circle()
|
||||
.frame(width: max(1, w * 0.1), height: max(1, w * 0.1))
|
||||
.position(x: w * 0.38, y: headY + headHeight * 0.55)
|
||||
.blendMode(.destinationOut)
|
||||
|
||||
Circle()
|
||||
.frame(width: max(1, w * 0.1), height: max(1, w * 0.1))
|
||||
.position(x: w * 0.62, y: headY + headHeight * 0.55)
|
||||
.blendMode(.destinationOut)
|
||||
|
||||
Rectangle()
|
||||
.frame(width: max(1, w * 0.08), height: max(1, h * 0.18))
|
||||
.rotationEffect(.degrees(-25))
|
||||
.position(x: w * 0.34, y: h * 0.12)
|
||||
|
||||
Rectangle()
|
||||
.frame(width: max(1, w * 0.08), height: max(1, h * 0.18))
|
||||
.rotationEffect(.degrees(25))
|
||||
.position(x: w * 0.66, y: h * 0.12)
|
||||
}
|
||||
.compositingGroup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func platformIcon(_ raw: String) -> String {
|
||||
let (prefix, _) = self.parsePlatform(raw)
|
||||
switch prefix {
|
||||
case "macos":
|
||||
return "laptopcomputer"
|
||||
case "ios":
|
||||
return "iphone"
|
||||
case "ipados":
|
||||
return "ipad"
|
||||
case "tvos":
|
||||
return "appletv"
|
||||
case "watchos":
|
||||
return "applewatch"
|
||||
default:
|
||||
return "cpu"
|
||||
}
|
||||
}
|
||||
|
||||
private func prettyPlatform(_ raw: String) -> String? {
|
||||
let (prefix, version) = self.parsePlatform(raw)
|
||||
if prefix.isEmpty { return nil }
|
||||
let name: String = switch prefix {
|
||||
case "macos": "macOS"
|
||||
case "ios": "iOS"
|
||||
case "ipados": "iPadOS"
|
||||
case "tvos": "tvOS"
|
||||
case "watchos": "watchOS"
|
||||
default: prefix.prefix(1).uppercased() + prefix.dropFirst()
|
||||
}
|
||||
guard let version, !version.isEmpty else { return name }
|
||||
let parts = version.split(separator: ".").map(String.init)
|
||||
if parts.count >= 2 {
|
||||
return "\(name) \(parts[0]).\(parts[1])"
|
||||
}
|
||||
return "\(name) \(version)"
|
||||
}
|
||||
|
||||
private func parsePlatform(_ raw: String) -> (prefix: String, version: String?) {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return ("", nil) }
|
||||
let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
let prefix = parts.first?.lowercased() ?? ""
|
||||
let versionToken = parts.dropFirst().first
|
||||
return (prefix, versionToken)
|
||||
}
|
||||
|
||||
private func presenceUpdateSourceShortText(_ reason: String) -> String? {
|
||||
let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
switch trimmed {
|
||||
case "self":
|
||||
return "Self"
|
||||
case "connect":
|
||||
return "Connect"
|
||||
case "disconnect":
|
||||
return "Disconnect"
|
||||
case "node-connected":
|
||||
return "Node connect"
|
||||
case "node-disconnected":
|
||||
return "Node disconnect"
|
||||
case "launch":
|
||||
return "Launch"
|
||||
case "periodic":
|
||||
return "Heartbeat"
|
||||
case "instances-refresh":
|
||||
return "Instances"
|
||||
case "seq gap":
|
||||
return "Resync"
|
||||
default:
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSummaryText(_ inst: InstanceInfo, isGateway: Bool) -> String? {
|
||||
// For gateway rows, omit the "updated via/by" provenance entirely.
|
||||
if isGateway {
|
||||
return nil
|
||||
}
|
||||
|
||||
let age = inst.ageDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !age.isEmpty else { return nil }
|
||||
|
||||
let source = self.presenceUpdateSourceShortText(inst.reason ?? "")
|
||||
if let source, !source.isEmpty {
|
||||
return "\(age) · \(source)"
|
||||
}
|
||||
return age
|
||||
}
|
||||
|
||||
private func presenceUpdateSourceHelp(_ reason: String) -> String {
|
||||
let trimmed = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
return "Why this presence entry was last updated (debug marker)."
|
||||
}
|
||||
return "Why this presence entry was last updated (debug marker). Raw: \(trimmed)"
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension InstancesSettings {
|
||||
static func exerciseForTesting() {
|
||||
let view = InstancesSettings(store: InstancesStore(isPreview: true))
|
||||
let mac = InstanceInfo(
|
||||
id: "mac",
|
||||
host: "studio",
|
||||
ip: "10.0.0.2",
|
||||
version: "1.2.3",
|
||||
platform: "macOS 14.2",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "Mac14,10",
|
||||
lastInputSeconds: 12,
|
||||
mode: "local",
|
||||
reason: "self",
|
||||
text: "Mac Studio",
|
||||
ts: 1_700_000_000_000)
|
||||
let genericIOS = InstanceInfo(
|
||||
id: "iphone",
|
||||
host: "phone",
|
||||
ip: "10.0.0.3",
|
||||
version: "2.0.0",
|
||||
platform: "iOS 17.2",
|
||||
deviceFamily: "iPhone",
|
||||
modelIdentifier: nil,
|
||||
lastInputSeconds: 35,
|
||||
mode: "node",
|
||||
reason: "connect",
|
||||
text: "iPhone node",
|
||||
ts: 1_700_000_100_000)
|
||||
let android = InstanceInfo(
|
||||
id: "android",
|
||||
host: "pixel",
|
||||
ip: nil,
|
||||
version: "3.1.0",
|
||||
platform: "Android 14",
|
||||
deviceFamily: "Android",
|
||||
modelIdentifier: nil,
|
||||
lastInputSeconds: 90,
|
||||
mode: "node",
|
||||
reason: "seq gap",
|
||||
text: "Android node",
|
||||
ts: 1_700_000_200_000)
|
||||
let gateway = InstanceInfo(
|
||||
id: "gateway",
|
||||
host: "gateway",
|
||||
ip: "10.0.0.9",
|
||||
version: "4.0.0",
|
||||
platform: "Linux",
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
lastInputSeconds: nil,
|
||||
mode: "gateway",
|
||||
reason: "periodic",
|
||||
text: "Gateway",
|
||||
ts: 1_700_000_300_000)
|
||||
|
||||
_ = view.instanceRow(mac)
|
||||
_ = view.instanceRow(genericIOS)
|
||||
_ = view.instanceRow(android)
|
||||
_ = view.instanceRow(gateway)
|
||||
|
||||
_ = view.leadingDeviceSymbol(
|
||||
mac,
|
||||
device: DevicePresentation(title: "Mac Studio", symbol: "macstudio"))
|
||||
_ = view.leadingDeviceSymbol(
|
||||
mac,
|
||||
device: DevicePresentation(title: "MacBook Pro", symbol: "laptopcomputer"))
|
||||
_ = view.leadingDeviceSymbol(android, device: nil)
|
||||
_ = view.platformIcon("tvOS 17.1")
|
||||
_ = view.platformIcon("watchOS 10")
|
||||
_ = view.platformIcon("unknown 1.0")
|
||||
_ = view.prettyPlatform("macOS 14.2")
|
||||
_ = view.prettyPlatform("iOS 17")
|
||||
_ = view.prettyPlatform("ipados 17.1")
|
||||
_ = view.prettyPlatform("linux")
|
||||
_ = view.prettyPlatform(" ")
|
||||
_ = view.parsePlatform("macOS 14.1")
|
||||
_ = view.parsePlatform(" ")
|
||||
_ = view.presenceUpdateSourceShortText("self")
|
||||
_ = view.presenceUpdateSourceShortText("instances-refresh")
|
||||
_ = view.presenceUpdateSourceShortText("seq gap")
|
||||
_ = view.presenceUpdateSourceShortText("custom")
|
||||
_ = view.presenceUpdateSourceShortText(" ")
|
||||
_ = view.updateSummaryText(mac, isGateway: false)
|
||||
_ = view.updateSummaryText(gateway, isGateway: true)
|
||||
_ = view.presenceUpdateSourceHelp("")
|
||||
_ = view.presenceUpdateSourceHelp("connect")
|
||||
_ = view.safeSystemSymbol("not-a-symbol", fallback: "cpu")
|
||||
_ = view.isSystemSymbolAvailable("sparkles")
|
||||
_ = view.label(icon: "android", text: "Android")
|
||||
_ = view.label(icon: "sparkles", text: "Sparkles")
|
||||
_ = view.label(icon: nil, text: "Plain")
|
||||
_ = AndroidMark().body
|
||||
}
|
||||
}
|
||||
|
||||
struct InstancesSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
InstancesSettings(store: .preview())
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,381 @@
|
||||
import ClawdbotProtocol
|
||||
import Cocoa
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
struct InstanceInfo: Identifiable, Codable {
|
||||
let id: String
|
||||
let host: String?
|
||||
let ip: String?
|
||||
let version: String?
|
||||
let platform: String?
|
||||
let deviceFamily: String?
|
||||
let modelIdentifier: String?
|
||||
let lastInputSeconds: Int?
|
||||
let mode: String?
|
||||
let reason: String?
|
||||
let text: String
|
||||
let ts: Double
|
||||
|
||||
var ageDescription: String {
|
||||
let date = Date(timeIntervalSince1970: ts / 1000)
|
||||
return age(from: date)
|
||||
}
|
||||
|
||||
var lastInputDescription: String {
|
||||
guard let secs = lastInputSeconds else { return "unknown" }
|
||||
return "\(secs)s ago"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class InstancesStore {
|
||||
static let shared = InstancesStore()
|
||||
let isPreview: Bool
|
||||
|
||||
var instances: [InstanceInfo] = []
|
||||
var lastError: String?
|
||||
var statusMessage: String?
|
||||
var isLoading = false
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "instances")
|
||||
private var task: Task<Void, Never>?
|
||||
private let interval: TimeInterval = 30
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var startCount = 0
|
||||
private var lastPresenceById: [String: InstanceInfo] = [:]
|
||||
private var lastLoginNotifiedAtMs: [String: Double] = [:]
|
||||
|
||||
private struct PresenceEventPayload: Codable {
|
||||
let presence: [PresenceEntry]
|
||||
}
|
||||
|
||||
init(isPreview: Bool = false) {
|
||||
self.isPreview = isPreview
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !self.isPreview else { return }
|
||||
self.startCount += 1
|
||||
guard self.startCount == 1 else { return }
|
||||
guard self.task == nil else { return }
|
||||
self.startGatewaySubscription()
|
||||
self.task = Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refresh()
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard !self.isPreview else { return }
|
||||
guard self.startCount > 0 else { return }
|
||||
self.startCount -= 1
|
||||
guard self.startCount == 0 else { return }
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = nil
|
||||
}
|
||||
|
||||
private func startGatewaySubscription() {
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
switch push {
|
||||
case let .event(evt) where evt.event == "presence":
|
||||
if let payload = evt.payload {
|
||||
self.handlePresenceEventPayload(payload)
|
||||
}
|
||||
case .seqGap:
|
||||
Task { await self.refresh() }
|
||||
case let .snapshot(hello):
|
||||
self.applyPresence(hello.snapshot.presence)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
if self.isLoading { return }
|
||||
self.statusMessage = nil
|
||||
self.isLoading = true
|
||||
defer { self.isLoading = false }
|
||||
do {
|
||||
PresenceReporter.shared.sendImmediate(reason: "instances-refresh")
|
||||
let data = try await ControlChannel.shared.request(method: "system-presence")
|
||||
self.lastPayload = data
|
||||
if data.isEmpty {
|
||||
self.logger.error("instances fetch returned empty payload")
|
||||
self.instances = [self.localFallbackInstance(reason: "no presence payload")]
|
||||
self.lastError = nil
|
||||
self.statusMessage = "No presence payload from gateway; showing local fallback + health probe."
|
||||
await self.probeHealthIfNeeded(reason: "no payload")
|
||||
return
|
||||
}
|
||||
let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data)
|
||||
let withIDs = self.normalizePresence(decoded)
|
||||
if withIDs.isEmpty {
|
||||
self.instances = [self.localFallbackInstance(reason: "no presence entries")]
|
||||
self.lastError = nil
|
||||
self.statusMessage = "Presence list was empty; showing local fallback + health probe."
|
||||
await self.probeHealthIfNeeded(reason: "empty list")
|
||||
} else {
|
||||
self.instances = withIDs
|
||||
self.lastError = nil
|
||||
self.statusMessage = nil
|
||||
}
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"""
|
||||
instances fetch failed: \(error.localizedDescription, privacy: .public) \
|
||||
len=\(self.lastPayload?.count ?? 0, privacy: .public) \
|
||||
utf8=\(self.snippet(self.lastPayload), privacy: .public)
|
||||
""")
|
||||
self.instances = [self.localFallbackInstance(reason: "presence decode failed")]
|
||||
self.lastError = nil
|
||||
self.statusMessage = "Presence data invalid; showing local fallback + health probe."
|
||||
await self.probeHealthIfNeeded(reason: "decode failed")
|
||||
}
|
||||
}
|
||||
|
||||
private func localFallbackInstance(reason: String) -> InstanceInfo {
|
||||
let host = Host.current().localizedName ?? "this-mac"
|
||||
let ip = Self.primaryIPv4Address()
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
let text = "Local node: \(host)\(ip.map { " (\($0))" } ?? "") · app \(version ?? "dev")"
|
||||
let ts = Date().timeIntervalSince1970 * 1000
|
||||
return InstanceInfo(
|
||||
id: "local-\(host)",
|
||||
host: host,
|
||||
ip: ip,
|
||||
version: version,
|
||||
platform: platform,
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||
lastInputSeconds: Self.lastInputSeconds(),
|
||||
mode: "local",
|
||||
reason: reason,
|
||||
text: text,
|
||||
ts: ts)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
var fallback: String?
|
||||
var en0: String?
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let name = String(cString: ptr.pointee.ifa_name)
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
|
||||
if name == "en0" { en0 = ip; break }
|
||||
if fallback == nil { fallback = ip }
|
||||
}
|
||||
|
||||
return en0 ?? fallback
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Keep the last raw payload for logging.
|
||||
private var lastPayload: Data?
|
||||
|
||||
private func snippet(_ data: Data?, limit: Int = 256) -> String {
|
||||
guard let data else { return "<none>" }
|
||||
if data.isEmpty { return "<empty>" }
|
||||
let prefix = data.prefix(limit)
|
||||
if let asString = String(data: prefix, encoding: .utf8) {
|
||||
return asString.replacingOccurrences(of: "\n", with: " ")
|
||||
}
|
||||
return "<\(data.count) bytes non-utf8>"
|
||||
}
|
||||
|
||||
private func probeHealthIfNeeded(reason: String? = nil) async {
|
||||
do {
|
||||
let data = try await ControlChannel.shared.health(timeout: 8)
|
||||
guard let snap = decodeHealthSnapshot(from: data) else { return }
|
||||
let entry = InstanceInfo(
|
||||
id: "health-\(snap.ts)",
|
||||
host: "gateway (health)",
|
||||
ip: nil,
|
||||
version: nil,
|
||||
platform: nil,
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
lastInputSeconds: nil,
|
||||
mode: "health",
|
||||
reason: "health probe",
|
||||
text: "Health ok · linked=\(snap.web.linked)",
|
||||
ts: snap.ts)
|
||||
if !self.instances.contains(where: { $0.id == entry.id }) {
|
||||
self.instances.insert(entry, at: 0)
|
||||
}
|
||||
self.lastError = nil
|
||||
self.statusMessage =
|
||||
"Presence unavailable (\(reason ?? "refresh")); showing health probe + local fallback."
|
||||
} catch {
|
||||
self.logger.error("instances health probe failed: \(error.localizedDescription, privacy: .public)")
|
||||
if let reason {
|
||||
self.statusMessage =
|
||||
"Presence unavailable (\(reason)), health probe failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeAndApplyPresenceData(_ data: Data) {
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data)
|
||||
self.applyPresence(decoded)
|
||||
} catch {
|
||||
self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func handlePresenceEventPayload(_ payload: ClawdbotProtocol.AnyCodable) {
|
||||
do {
|
||||
let wrapper = try GatewayPayloadDecoding.decode(payload, as: PresenceEventPayload.self)
|
||||
self.applyPresence(wrapper.presence)
|
||||
} catch {
|
||||
self.logger.error("presence event decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizePresence(_ entries: [PresenceEntry]) -> [InstanceInfo] {
|
||||
entries.map { entry -> InstanceInfo in
|
||||
let key = entry.instanceid ?? entry.host ?? entry.ip ?? entry.text ?? "entry-\(entry.ts)"
|
||||
return InstanceInfo(
|
||||
id: key,
|
||||
host: entry.host,
|
||||
ip: entry.ip,
|
||||
version: entry.version,
|
||||
platform: entry.platform,
|
||||
deviceFamily: entry.devicefamily,
|
||||
modelIdentifier: entry.modelidentifier,
|
||||
lastInputSeconds: entry.lastinputseconds,
|
||||
mode: entry.mode,
|
||||
reason: entry.reason,
|
||||
text: entry.text ?? "Unnamed node",
|
||||
ts: Double(entry.ts))
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPresence(_ entries: [PresenceEntry]) {
|
||||
let withIDs = self.normalizePresence(entries)
|
||||
self.notifyOnNodeLogin(withIDs)
|
||||
self.lastPresenceById = Dictionary(uniqueKeysWithValues: withIDs.map { ($0.id, $0) })
|
||||
self.instances = withIDs
|
||||
self.statusMessage = nil
|
||||
self.lastError = nil
|
||||
}
|
||||
|
||||
private func notifyOnNodeLogin(_ instances: [InstanceInfo]) {
|
||||
for inst in instances {
|
||||
guard let reason = inst.reason?.trimmingCharacters(in: .whitespacesAndNewlines) else { continue }
|
||||
guard reason == "node-connected" else { continue }
|
||||
if let mode = inst.mode?.lowercased(), mode == "local" { continue }
|
||||
|
||||
let previous = self.lastPresenceById[inst.id]
|
||||
if previous?.reason == "node-connected", previous?.ts == inst.ts { continue }
|
||||
|
||||
let lastNotified = self.lastLoginNotifiedAtMs[inst.id] ?? 0
|
||||
if inst.ts <= lastNotified { continue }
|
||||
self.lastLoginNotifiedAtMs[inst.id] = inst.ts
|
||||
|
||||
let name = inst.host?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let device = name?.isEmpty == false ? name! : inst.id
|
||||
Task { @MainActor in
|
||||
_ = await NotificationManager().send(
|
||||
title: "Node connected",
|
||||
body: device,
|
||||
sound: nil,
|
||||
priority: .active)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension InstancesStore {
|
||||
static func preview(instances: [InstanceInfo] = [
|
||||
InstanceInfo(
|
||||
id: "local",
|
||||
host: "steipete-mac",
|
||||
ip: "10.0.0.12",
|
||||
version: "1.2.3",
|
||||
platform: "macos 26.2.0",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "Mac16,6",
|
||||
lastInputSeconds: 12,
|
||||
mode: "local",
|
||||
reason: "preview",
|
||||
text: "Local node: steipete-mac (10.0.0.12) · app 1.2.3",
|
||||
ts: Date().timeIntervalSince1970 * 1000),
|
||||
InstanceInfo(
|
||||
id: "gateway",
|
||||
host: "gateway",
|
||||
ip: "100.64.0.2",
|
||||
version: "1.2.3",
|
||||
platform: "linux 6.6.0",
|
||||
deviceFamily: "Linux",
|
||||
modelIdentifier: "x86_64",
|
||||
lastInputSeconds: 45,
|
||||
mode: "remote",
|
||||
reason: "preview",
|
||||
text: "Gateway node · tunnel ok",
|
||||
ts: Date().timeIntervalSince1970 * 1000 - 45000),
|
||||
]) -> InstancesStore {
|
||||
let store = InstancesStore(isPreview: true)
|
||||
store.instances = instances
|
||||
store.statusMessage = "Preview data"
|
||||
return store
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
|
||||
enum LaunchAgentManager {
|
||||
private static let legacyLaunchdLabel = "com.steipete.clawdbot"
|
||||
private static var plistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist")
|
||||
}
|
||||
private static var legacyPlistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
static func status() async -> Bool {
|
||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
||||
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
|
||||
return result == 0
|
||||
}
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String) async {
|
||||
if enabled {
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||
self.writePlist(bundlePath: bundlePath)
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
||||
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"])
|
||||
} else {
|
||||
// Disable autostart going forward but leave the current app running.
|
||||
// bootout would terminate the launchd job immediately (and crash the app if launched via agent).
|
||||
try? FileManager.default.removeItem(at: self.plistURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func writePlist(bundlePath: String) {
|
||||
let plist = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.clawdbot.mac</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>\(bundlePath)/Contents/MacOS/Clawdbot</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>\(CommandResolver.preferredPaths().joined(separator: ":"))</string>
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>\(LogLocator.launchdLogPath)</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>\(LogLocator.launchdLogPath)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func runLaunchctl(_ args: [String]) async -> Int32 {
|
||||
await Task.detached(priority: .utility) { () -> Int32 in
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
process.arguments = args
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus
|
||||
} catch {
|
||||
return -1
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
enum LaunchdManager {
|
||||
private static func runLaunchctl(_ args: [String]) {
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
process.arguments = args
|
||||
try? process.run()
|
||||
}
|
||||
|
||||
static func startClawdbot() {
|
||||
let userTarget = "gui/\(getuid())/\(launchdLabel)"
|
||||
self.runLaunchctl(["kickstart", "-k", userTarget])
|
||||
}
|
||||
|
||||
static func stopClawdbot() {
|
||||
let userTarget = "gui/\(getuid())/\(launchdLabel)"
|
||||
self.runLaunchctl(["stop", userTarget])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
enum LogLocator {
|
||||
private static let logDir = URL(fileURLWithPath: "/tmp/clawdbot")
|
||||
private static let stdoutLog = logDir.appendingPathComponent("clawdbot-stdout.log")
|
||||
private static let gatewayLog = logDir.appendingPathComponent("clawdbot-gateway.log")
|
||||
|
||||
private static func modificationDate(for url: URL) -> Date {
|
||||
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
}
|
||||
|
||||
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
|
||||
static func bestLogFile() -> URL? {
|
||||
let fm = FileManager.default
|
||||
let files = (try? fm.contentsOfDirectory(
|
||||
at: self.logDir,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles])) ?? []
|
||||
|
||||
return files
|
||||
.filter { $0.lastPathComponent.hasPrefix("clawdbot") && $0.pathExtension == "log" }
|
||||
.max { lhs, rhs in
|
||||
self.modificationDate(for: lhs) < self.modificationDate(for: rhs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Path to use for launchd stdout/err.
|
||||
static var launchdLogPath: String {
|
||||
stdoutLog.path
|
||||
}
|
||||
|
||||
/// Path to use for the embedded Gateway launchd job stdout/err.
|
||||
static var launchdGatewayLogPath: String {
|
||||
gatewayLog.path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
@_exported import Logging
|
||||
import Foundation
|
||||
import OSLog
|
||||
import os
|
||||
|
||||
typealias Logger = Logging.Logger
|
||||
|
||||
enum AppLogSettings {
|
||||
static let logLevelKey = appLogLevelKey
|
||||
|
||||
static func logLevel() -> Logger.Level {
|
||||
if let raw = UserDefaults.standard.string(forKey: self.logLevelKey),
|
||||
let level = Logger.Level(rawValue: raw)
|
||||
{
|
||||
return level
|
||||
}
|
||||
return .info
|
||||
}
|
||||
|
||||
static func setLogLevel(_ level: Logger.Level) {
|
||||
UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey)
|
||||
}
|
||||
|
||||
static func fileLoggingEnabled() -> Bool {
|
||||
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum AppLogLevel: String, CaseIterable, Identifiable {
|
||||
case trace
|
||||
case debug
|
||||
case info
|
||||
case notice
|
||||
case warning
|
||||
case error
|
||||
case critical
|
||||
|
||||
static let `default`: AppLogLevel = .info
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .trace: "Trace"
|
||||
case .debug: "Debug"
|
||||
case .info: "Info"
|
||||
case .notice: "Notice"
|
||||
case .warning: "Warning"
|
||||
case .error: "Error"
|
||||
case .critical: "Critical"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ClawdbotLogging {
|
||||
private static let labelSeparator = "::"
|
||||
|
||||
private static let didBootstrap: Void = {
|
||||
LoggingSystem.bootstrap { label in
|
||||
let (subsystem, category) = Self.parseLabel(label)
|
||||
let osHandler = ClawdbotOSLogHandler(subsystem: subsystem, category: category)
|
||||
let fileHandler = ClawdbotFileLogHandler(label: label)
|
||||
return MultiplexLogHandler([osHandler, fileHandler])
|
||||
}
|
||||
}()
|
||||
|
||||
static func bootstrapIfNeeded() {
|
||||
_ = Self.didBootstrap
|
||||
}
|
||||
|
||||
static func makeLabel(subsystem: String, category: String) -> String {
|
||||
"\(subsystem)\(Self.labelSeparator)\(category)"
|
||||
}
|
||||
|
||||
static func parseLabel(_ label: String) -> (String, String) {
|
||||
guard let range = label.range(of: Self.labelSeparator) else {
|
||||
return ("com.clawdbot", label)
|
||||
}
|
||||
let subsystem = String(label[..<range.lowerBound])
|
||||
let category = String(label[range.upperBound...])
|
||||
return (subsystem, category)
|
||||
}
|
||||
}
|
||||
|
||||
extension Logging.Logger {
|
||||
init(subsystem: String, category: String) {
|
||||
ClawdbotLogging.bootstrapIfNeeded()
|
||||
let label = ClawdbotLogging.makeLabel(subsystem: subsystem, category: category)
|
||||
self.init(label: label)
|
||||
}
|
||||
}
|
||||
|
||||
extension Logger.Message.StringInterpolation {
|
||||
mutating func appendInterpolation<T>(_ value: T, privacy: OSLogPrivacy) {
|
||||
self.appendInterpolation(String(describing: value))
|
||||
}
|
||||
}
|
||||
|
||||
struct ClawdbotOSLogHandler: LogHandler {
|
||||
private let osLogger: os.Logger
|
||||
var metadata: Logger.Metadata = [:]
|
||||
|
||||
var logLevel: Logger.Level {
|
||||
get { AppLogSettings.logLevel() }
|
||||
set { AppLogSettings.setLogLevel(newValue) }
|
||||
}
|
||||
|
||||
init(subsystem: String, category: String) {
|
||||
self.osLogger = os.Logger(subsystem: subsystem, category: category)
|
||||
}
|
||||
|
||||
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
||||
get { self.metadata[key] }
|
||||
set { self.metadata[key] = newValue }
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
metadata: Logger.Metadata?,
|
||||
source: String,
|
||||
file: String,
|
||||
function: String,
|
||||
line: UInt)
|
||||
{
|
||||
let merged = Self.mergeMetadata(self.metadata, metadata)
|
||||
let rendered = Self.renderMessage(message, metadata: merged)
|
||||
self.osLogger.log(level: Self.osLogType(for: level), "\(rendered, privacy: .public)")
|
||||
}
|
||||
|
||||
private static func osLogType(for level: Logger.Level) -> OSLogType {
|
||||
switch level {
|
||||
case .trace, .debug:
|
||||
return .debug
|
||||
case .info, .notice:
|
||||
return .info
|
||||
case .warning:
|
||||
return .default
|
||||
case .error:
|
||||
return .error
|
||||
case .critical:
|
||||
return .fault
|
||||
}
|
||||
}
|
||||
|
||||
private static func mergeMetadata(
|
||||
_ base: Logger.Metadata,
|
||||
_ extra: Logger.Metadata?) -> Logger.Metadata
|
||||
{
|
||||
guard let extra else { return base }
|
||||
return base.merging(extra, uniquingKeysWith: { _, new in new })
|
||||
}
|
||||
|
||||
private static func renderMessage(_ message: Logger.Message, metadata: Logger.Metadata) -> String {
|
||||
guard !metadata.isEmpty else { return message.description }
|
||||
let meta = metadata
|
||||
.sorted(by: { $0.key < $1.key })
|
||||
.map { "\($0.key)=\(stringify($0.value))" }
|
||||
.joined(separator: " ")
|
||||
return "\(message.description) [\(meta)]"
|
||||
}
|
||||
|
||||
private static func stringify(_ value: Logger.Metadata.Value) -> String {
|
||||
switch value {
|
||||
case let .string(text):
|
||||
text
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ClawdbotFileLogHandler: LogHandler {
|
||||
let label: String
|
||||
var metadata: Logger.Metadata = [:]
|
||||
|
||||
var logLevel: Logger.Level {
|
||||
get { AppLogSettings.logLevel() }
|
||||
set { AppLogSettings.setLogLevel(newValue) }
|
||||
}
|
||||
|
||||
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
||||
get { self.metadata[key] }
|
||||
set { self.metadata[key] = newValue }
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
metadata: Logger.Metadata?,
|
||||
source: String,
|
||||
file: String,
|
||||
function: String,
|
||||
line: UInt)
|
||||
{
|
||||
guard AppLogSettings.fileLoggingEnabled() else { return }
|
||||
let (subsystem, category) = ClawdbotLogging.parseLabel(self.label)
|
||||
var fields: [String: String] = [
|
||||
"subsystem": subsystem,
|
||||
"category": category,
|
||||
"level": level.rawValue,
|
||||
"source": source,
|
||||
"file": file,
|
||||
"function": function,
|
||||
"line": "\(line)",
|
||||
]
|
||||
let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new })
|
||||
for (key, value) in merged {
|
||||
fields["meta.\(key)"] = Self.stringify(value)
|
||||
}
|
||||
DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields)
|
||||
}
|
||||
|
||||
private static func stringify(_ value: Logger.Metadata.Value) -> String {
|
||||
switch value {
|
||||
case let .string(text):
|
||||
text
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import AppKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
import MenuBarExtraAccess
|
||||
import Observation
|
||||
import Security
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ClawdbotApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||
@State private var state: AppState
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isPanelVisible = false
|
||||
@State private var tailscaleService = TailscaleService.shared
|
||||
|
||||
@MainActor
|
||||
private func updateStatusHighlight() {
|
||||
self.statusItem?.button?.highlight(self.isPanelVisible)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateHoverHUDSuppression() {
|
||||
HoverHUDController.shared.setSuppressed(self.isMenuPresented || self.isPanelVisible)
|
||||
}
|
||||
|
||||
init() {
|
||||
ClawdbotLogging.bootstrapIfNeeded()
|
||||
_state = State(initialValue: AppStateStore.shared)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: {
|
||||
CritterStatusLabel(
|
||||
isPaused: self.state.isPaused,
|
||||
isSleeping: self.isGatewaySleeping,
|
||||
isWorking: self.state.isWorking,
|
||||
earBoostActive: self.state.earBoostActive,
|
||||
blinkTick: self.state.blinkTick,
|
||||
sendCelebrationTick: self.state.sendCelebrationTick,
|
||||
gatewayStatus: self.gatewayManager.status,
|
||||
animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping,
|
||||
iconState: self.effectiveIconState)
|
||||
}
|
||||
.menuBarExtraStyle(.menu)
|
||||
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
|
||||
self.statusItem = item
|
||||
MenuSessionsInjector.shared.install(into: item)
|
||||
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
|
||||
self.installStatusItemMouseHandler(for: item)
|
||||
self.updateHoverHUDSuppression()
|
||||
}
|
||||
.onChange(of: self.state.isPaused) { _, paused in
|
||||
self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping)
|
||||
if self.state.connectionMode == .local {
|
||||
self.gatewayManager.setActive(!paused)
|
||||
} else {
|
||||
self.gatewayManager.stop()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.controlChannel.state) { _, _ in
|
||||
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
|
||||
}
|
||||
.onChange(of: self.gatewayManager.status) { _, _ in
|
||||
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
|
||||
}
|
||||
.onChange(of: self.state.connectionMode) { _, mode in
|
||||
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
|
||||
}
|
||||
|
||||
Settings {
|
||||
SettingsRootView(state: self.state, updater: self.delegate.updaterController)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
|
||||
.environment(self.tailscaleService)
|
||||
}
|
||||
.defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
.windowResizability(.contentSize)
|
||||
.onChange(of: self.isMenuPresented) { _, _ in
|
||||
self.updateStatusHighlight()
|
||||
self.updateHoverHUDSuppression()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) {
|
||||
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
||||
}
|
||||
|
||||
private var isGatewaySleeping: Bool {
|
||||
if self.state.isPaused { return false }
|
||||
switch self.state.connectionMode {
|
||||
case .unconfigured:
|
||||
return true
|
||||
case .remote:
|
||||
if case .connected = self.controlChannel.state { return false }
|
||||
return true
|
||||
case .local:
|
||||
switch self.gatewayManager.status {
|
||||
case .running, .starting, .attachedExisting:
|
||||
if case .connected = self.controlChannel.state { return false }
|
||||
return true
|
||||
case .failed, .stopped:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func installStatusItemMouseHandler(for item: NSStatusItem) {
|
||||
guard let button = item.button else { return }
|
||||
if button.subviews.contains(where: { $0 is StatusItemMouseHandlerView }) { return }
|
||||
|
||||
WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in
|
||||
self.isPanelVisible = visible
|
||||
self.updateStatusHighlight()
|
||||
self.updateHoverHUDSuppression()
|
||||
}
|
||||
CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in
|
||||
self.state.canvasPanelVisible = visible
|
||||
}
|
||||
CanvasManager.shared.defaultAnchorProvider = { [self] in self.statusButtonScreenFrame() }
|
||||
|
||||
let handler = StatusItemMouseHandlerView()
|
||||
handler.translatesAutoresizingMaskIntoConstraints = false
|
||||
handler.onLeftClick = { [self] in
|
||||
HoverHUDController.shared.dismiss(reason: "statusItemClick")
|
||||
self.toggleWebChatPanel()
|
||||
}
|
||||
handler.onRightClick = { [self] in
|
||||
HoverHUDController.shared.dismiss(reason: "statusItemRightClick")
|
||||
WebChatManager.shared.closePanel()
|
||||
self.isMenuPresented = true
|
||||
self.updateStatusHighlight()
|
||||
}
|
||||
handler.onHoverChanged = { [self] inside in
|
||||
HoverHUDController.shared.statusItemHoverChanged(
|
||||
inside: inside,
|
||||
anchorProvider: { [self] in self.statusButtonScreenFrame() })
|
||||
}
|
||||
|
||||
button.addSubview(handler)
|
||||
NSLayoutConstraint.activate([
|
||||
handler.leadingAnchor.constraint(equalTo: button.leadingAnchor),
|
||||
handler.trailingAnchor.constraint(equalTo: button.trailingAnchor),
|
||||
handler.topAnchor.constraint(equalTo: button.topAnchor),
|
||||
handler.bottomAnchor.constraint(equalTo: button.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func toggleWebChatPanel() {
|
||||
HoverHUDController.shared.setSuppressed(true)
|
||||
self.isMenuPresented = false
|
||||
Task { @MainActor in
|
||||
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||
WebChatManager.shared.togglePanel(
|
||||
sessionKey: sessionKey,
|
||||
anchorProvider: { [self] in self.statusButtonScreenFrame() })
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func statusButtonScreenFrame() -> NSRect? {
|
||||
guard let button = self.statusItem?.button, let window = button.window else { return nil }
|
||||
let inWindow = button.convert(button.bounds, to: nil)
|
||||
return window.convertToScreen(inWindow)
|
||||
}
|
||||
|
||||
private var effectiveIconState: IconState {
|
||||
let selection = self.state.iconOverride
|
||||
if selection == .system {
|
||||
return self.activityStore.iconState
|
||||
}
|
||||
let overrideState = selection.toIconState()
|
||||
switch overrideState {
|
||||
case let .workingMain(kind): return .overridden(kind)
|
||||
case let .workingOther(kind): return .overridden(kind)
|
||||
case .idle: return .idle
|
||||
case let .overridden(kind): return .overridden(kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparent overlay that intercepts clicks without stealing MenuBarExtra ownership.
|
||||
private final class StatusItemMouseHandlerView: NSView {
|
||||
var onLeftClick: (() -> Void)?
|
||||
var onRightClick: (() -> Void)?
|
||||
var onHoverChanged: ((Bool) -> Void)?
|
||||
private var tracking: NSTrackingArea?
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
if let onLeftClick {
|
||||
onLeftClick()
|
||||
} else {
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
override func rightMouseDown(with event: NSEvent) {
|
||||
self.onRightClick?()
|
||||
// Do not call super; menu will be driven by isMenuPresented binding.
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let tracking {
|
||||
self.removeTrackingArea(tracking)
|
||||
}
|
||||
let options: NSTrackingArea.Options = [
|
||||
.mouseEnteredAndExited,
|
||||
.activeAlways,
|
||||
.inVisibleRect,
|
||||
]
|
||||
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
||||
self.addTrackingArea(area)
|
||||
self.tracking = area
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
self.onHoverChanged?(true)
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
self.onHoverChanged?(false)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var state: AppState?
|
||||
private let webChatAutoLogger = Logger(subsystem: "com.clawdbot", category: "Chat")
|
||||
let updaterController: UpdaterProviding = makeUpdaterController()
|
||||
|
||||
func application(_: NSApplication, open urls: [URL]) {
|
||||
Task { @MainActor in
|
||||
for url in urls {
|
||||
await DeepLinkHandler.shared.handle(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
if self.isDuplicateInstance() {
|
||||
NSApp.terminate(nil)
|
||||
return
|
||||
}
|
||||
self.state = AppStateStore.shared
|
||||
AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false)
|
||||
if let state {
|
||||
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
|
||||
}
|
||||
TerminationSignalWatcher.shared.start()
|
||||
NodePairingApprovalPrompter.shared.start()
|
||||
MacNodeModeCoordinator.shared.start()
|
||||
VoiceWakeGlobalSettingsSync.shared.start()
|
||||
Task { PresenceReporter.shared.start() }
|
||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
||||
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) }
|
||||
self.scheduleFirstRunOnboardingIfNeeded()
|
||||
|
||||
// Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat).
|
||||
if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") {
|
||||
self.webChatAutoLogger.debug("Auto-opening chat via CLI flag")
|
||||
Task { @MainActor in
|
||||
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||
WebChatManager.shared.show(sessionKey: sessionKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
PresenceReporter.shared.stop()
|
||||
NodePairingApprovalPrompter.shared.stop()
|
||||
MacNodeModeCoordinator.shared.stop()
|
||||
TerminationSignalWatcher.shared.stop()
|
||||
VoiceWakeGlobalSettingsSync.shared.stop()
|
||||
WebChatManager.shared.close()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
Task { await RemoteTunnelManager.shared.stopAll() }
|
||||
Task { await GatewayConnection.shared.shutdown() }
|
||||
Task { await PeekabooBridgeHostCoordinator.shared.stop() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleFirstRunOnboardingIfNeeded() {
|
||||
let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey)
|
||||
let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen
|
||||
guard shouldShow else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
OnboardingController.shared.show()
|
||||
}
|
||||
}
|
||||
|
||||
private func isDuplicateInstance() -> Bool {
|
||||
guard let bundleID = Bundle.main.bundleIdentifier else { return false }
|
||||
let running = NSWorkspace.shared.runningApplications.filter { $0.bundleIdentifier == bundleID }
|
||||
return running.count > 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sparkle updater (disabled for unsigned/dev builds)
|
||||
|
||||
@MainActor
|
||||
protocol UpdaterProviding: AnyObject {
|
||||
var automaticallyChecksForUpdates: Bool { get set }
|
||||
var automaticallyDownloadsUpdates: Bool { get set }
|
||||
var isAvailable: Bool { get }
|
||||
var updateStatus: UpdateStatus { get }
|
||||
func checkForUpdates(_ sender: Any?)
|
||||
}
|
||||
|
||||
// No-op updater used for debug/dev runs to suppress Sparkle dialogs.
|
||||
final class DisabledUpdaterController: UpdaterProviding {
|
||||
var automaticallyChecksForUpdates: Bool = false
|
||||
var automaticallyDownloadsUpdates: Bool = false
|
||||
let isAvailable: Bool = false
|
||||
let updateStatus = UpdateStatus()
|
||||
func checkForUpdates(_: Any?) {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class UpdateStatus {
|
||||
static let disabled = UpdateStatus()
|
||||
var isUpdateReady: Bool
|
||||
|
||||
init(isUpdateReady: Bool = false) {
|
||||
self.isUpdateReady = isUpdateReady
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(Sparkle)
|
||||
import Sparkle
|
||||
|
||||
@MainActor
|
||||
final class SparkleUpdaterController: NSObject, UpdaterProviding {
|
||||
private lazy var controller = SPUStandardUpdaterController(
|
||||
startingUpdater: false,
|
||||
updaterDelegate: self,
|
||||
userDriverDelegate: nil)
|
||||
let updateStatus = UpdateStatus()
|
||||
|
||||
init(savedAutoUpdate: Bool) {
|
||||
super.init()
|
||||
let updater = self.controller.updater
|
||||
updater.automaticallyChecksForUpdates = savedAutoUpdate
|
||||
updater.automaticallyDownloadsUpdates = savedAutoUpdate
|
||||
self.controller.startUpdater()
|
||||
}
|
||||
|
||||
var automaticallyChecksForUpdates: Bool {
|
||||
get { self.controller.updater.automaticallyChecksForUpdates }
|
||||
set { self.controller.updater.automaticallyChecksForUpdates = newValue }
|
||||
}
|
||||
|
||||
var automaticallyDownloadsUpdates: Bool {
|
||||
get { self.controller.updater.automaticallyDownloadsUpdates }
|
||||
set { self.controller.updater.automaticallyDownloadsUpdates = newValue }
|
||||
}
|
||||
|
||||
var isAvailable: Bool { true }
|
||||
|
||||
func checkForUpdates(_ sender: Any?) {
|
||||
self.controller.checkForUpdates(sender)
|
||||
}
|
||||
|
||||
func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
|
||||
self.updateStatus.isUpdateReady = true
|
||||
}
|
||||
|
||||
func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) {
|
||||
self.updateStatus.isUpdateReady = false
|
||||
}
|
||||
|
||||
func userDidCancelDownload(_ updater: SPUUpdater) {
|
||||
self.updateStatus.isUpdateReady = false
|
||||
}
|
||||
|
||||
func updater(
|
||||
_ updater: SPUUpdater,
|
||||
userDidMakeChoice choice: SPUUserUpdateChoice,
|
||||
forUpdate updateItem: SUAppcastItem,
|
||||
state: SPUUserUpdateState)
|
||||
{
|
||||
switch choice {
|
||||
case .install, .skip:
|
||||
self.updateStatus.isUpdateReady = false
|
||||
case .dismiss:
|
||||
self.updateStatus.isUpdateReady = (state.stage == .downloaded)
|
||||
@unknown default:
|
||||
self.updateStatus.isUpdateReady = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {}
|
||||
|
||||
private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
|
||||
var staticCode: SecStaticCode?
|
||||
guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess,
|
||||
let code = staticCode
|
||||
else { return false }
|
||||
|
||||
var infoCF: CFDictionary?
|
||||
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess,
|
||||
let info = infoCF as? [String: Any],
|
||||
let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate],
|
||||
let leaf = certs.first
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
if let summary = SecCertificateCopySubjectSummary(leaf) as String? {
|
||||
return summary.hasPrefix("Developer ID Application:")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeUpdaterController() -> UpdaterProviding {
|
||||
let bundleURL = Bundle.main.bundleURL
|
||||
let isBundledApp = bundleURL.pathExtension == "app"
|
||||
guard isBundledApp, isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController() }
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let autoUpdateKey = "autoUpdateEnabled"
|
||||
// Default to true; honor the user's last choice otherwise.
|
||||
let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true
|
||||
return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate)
|
||||
}
|
||||
#else
|
||||
@MainActor
|
||||
private func makeUpdaterController() -> UpdaterProviding {
|
||||
DisabledUpdaterController()
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,516 @@
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
/// Menu contents for the Clawdbot menu bar extra.
|
||||
struct MenuContent: View {
|
||||
@Bindable var state: AppState
|
||||
let updater: UpdaterProviding?
|
||||
@Bindable private var updateStatus: UpdateStatus
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let healthStore = HealthStore.shared
|
||||
private let heartbeatStore = HeartbeatStore.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
@Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared
|
||||
@Environment(\.openSettings) private var openSettings
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
@State private var browserControlEnabled = true
|
||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
|
||||
@AppStorage(debugFileLogEnabledKey) private var appFileLoggingEnabled: Bool = false
|
||||
|
||||
init(state: AppState, updater: UpdaterProviding?) {
|
||||
self._state = Bindable(wrappedValue: state)
|
||||
self.updater = updater
|
||||
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle(isOn: self.activeBinding) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.connectionLabel)
|
||||
self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color)
|
||||
if self.pairingPrompter.pendingCount > 0 {
|
||||
let repairCount = self.pairingPrompter.pendingRepairCount
|
||||
let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : ""
|
||||
self.statusLine(
|
||||
label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))\(repairSuffix)",
|
||||
color: .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(self.state.connectionMode == .unconfigured)
|
||||
|
||||
Divider()
|
||||
Toggle(isOn: self.heartbeatsBinding) {
|
||||
HStack(spacing: 8) {
|
||||
Label("Send Heartbeats", systemImage: "waveform.path.ecg")
|
||||
Spacer(minLength: 0)
|
||||
self.statusLine(label: self.heartbeatStatus.label, color: self.heartbeatStatus.color)
|
||||
}
|
||||
}
|
||||
Toggle(
|
||||
isOn: Binding(
|
||||
get: { self.browserControlEnabled },
|
||||
set: { enabled in
|
||||
self.browserControlEnabled = enabled
|
||||
Task { await self.saveBrowserControlEnabled(enabled) }
|
||||
})) {
|
||||
Label("Browser Control", systemImage: "globe")
|
||||
}
|
||||
Toggle(isOn: self.$cameraEnabled) {
|
||||
Label("Allow Camera", systemImage: "camera")
|
||||
}
|
||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
||||
}
|
||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
CanvasManager.shared.hideAll()
|
||||
}
|
||||
}
|
||||
Toggle(isOn: self.voiceWakeBinding) {
|
||||
Label("Voice Wake", systemImage: "mic.fill")
|
||||
}
|
||||
.disabled(!voiceWakeSupported)
|
||||
.opacity(voiceWakeSupported ? 1 : 0.5)
|
||||
if self.showVoiceWakeMicPicker {
|
||||
self.voiceWakeMicMenu
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
Task { @MainActor in
|
||||
await self.openDashboard()
|
||||
}
|
||||
} label: {
|
||||
Label("Open Dashboard", systemImage: "gauge")
|
||||
}
|
||||
Button {
|
||||
Task { @MainActor in
|
||||
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||
WebChatManager.shared.show(sessionKey: sessionKey)
|
||||
}
|
||||
} label: {
|
||||
Label("Open Chat", systemImage: "bubble.left.and.bubble.right")
|
||||
}
|
||||
if self.state.canvasEnabled {
|
||||
Button {
|
||||
if self.state.canvasPanelVisible {
|
||||
CanvasManager.shared.hideAll()
|
||||
} else {
|
||||
// Don't force a navigation on re-open: preserve the current web view state.
|
||||
_ = try? CanvasManager.shared.show(sessionKey: "main", path: nil)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas",
|
||||
systemImage: "rectangle.inset.filled.on.rectangle")
|
||||
}
|
||||
}
|
||||
Button {
|
||||
Task { await self.state.setTalkEnabled(!self.state.talkEnabled) }
|
||||
} label: {
|
||||
Label(self.state.talkEnabled ? "Stop Talk Mode" : "Talk Mode", systemImage: "waveform.circle.fill")
|
||||
}
|
||||
.disabled(!voiceWakeSupported)
|
||||
.opacity(voiceWakeSupported ? 1 : 0.5)
|
||||
Divider()
|
||||
Button("Settings…") { self.open(tab: .general) }
|
||||
.keyboardShortcut(",", modifiers: [.command])
|
||||
self.debugMenu
|
||||
Button("About Clawdbot") { self.open(tab: .about) }
|
||||
if let updater, updater.isAvailable, self.updateStatus.isUpdateReady {
|
||||
Button("Update ready, restart now?") { updater.checkForUpdates(nil) }
|
||||
}
|
||||
Button("Quit") { NSApplication.shared.terminate(nil) }
|
||||
}
|
||||
.task(id: self.state.swabbleEnabled) {
|
||||
if self.state.swabbleEnabled {
|
||||
await self.loadMicrophones(force: true)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
|
||||
}
|
||||
.onChange(of: self.state.voicePushToTalkEnabled) { _, enabled in
|
||||
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && enabled)
|
||||
}
|
||||
.task(id: self.state.connectionMode) {
|
||||
await self.loadBrowserControlEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionLabel: String {
|
||||
switch self.state.connectionMode {
|
||||
case .unconfigured:
|
||||
"Clawdbot Not Configured"
|
||||
case .remote:
|
||||
"Remote Clawdbot Active"
|
||||
case .local:
|
||||
"Clawdbot Active"
|
||||
}
|
||||
}
|
||||
|
||||
private func loadBrowserControlEnabled() async {
|
||||
let root = await ConfigStore.load()
|
||||
let browser = root["browser"] as? [String: Any]
|
||||
let enabled = browser?["enabled"] as? Bool ?? true
|
||||
await MainActor.run { self.browserControlEnabled = enabled }
|
||||
}
|
||||
|
||||
private func saveBrowserControlEnabled(_ enabled: Bool) async {
|
||||
let (success, _) = await MenuContent.buildAndSaveBrowserEnabled(enabled)
|
||||
|
||||
if !success {
|
||||
await self.loadBrowserControlEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) {
|
||||
var root = await ConfigStore.load()
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
browser["enabled"] = enabled
|
||||
root["browser"] = browser
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return (true, ())
|
||||
} catch {
|
||||
return (false, ())
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var debugMenu: some View {
|
||||
if self.state.debugPaneEnabled {
|
||||
Menu("Debug") {
|
||||
Button {
|
||||
DebugActions.openConfigFolder()
|
||||
} label: {
|
||||
Label("Open Config Folder", systemImage: "folder")
|
||||
}
|
||||
Button {
|
||||
Task { await DebugActions.runHealthCheckNow() }
|
||||
} label: {
|
||||
Label("Run Health Check Now", systemImage: "stethoscope")
|
||||
}
|
||||
Button {
|
||||
Task { _ = await DebugActions.sendTestHeartbeat() }
|
||||
} label: {
|
||||
Label("Send Test Heartbeat", systemImage: "waveform.path.ecg")
|
||||
}
|
||||
if self.state.connectionMode == .remote {
|
||||
Button {
|
||||
Task { @MainActor in
|
||||
let result = await DebugActions.resetGatewayTunnel()
|
||||
self.presentDebugResult(result, title: "Remote Tunnel")
|
||||
}
|
||||
} label: {
|
||||
Label("Reset Remote Tunnel", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
Button {
|
||||
Task { _ = await DebugActions.toggleVerboseLoggingMain() }
|
||||
} label: {
|
||||
Label(
|
||||
DebugActions.verboseLoggingEnabledMain
|
||||
? "Verbose Logging (Main): On"
|
||||
: "Verbose Logging (Main): Off",
|
||||
systemImage: "text.alignleft")
|
||||
}
|
||||
Menu {
|
||||
Picker("Verbosity", selection: self.$appLogLevelRaw) {
|
||||
ForEach(AppLogLevel.allCases) { level in
|
||||
Text(level.title).tag(level.rawValue)
|
||||
}
|
||||
}
|
||||
Toggle(isOn: self.$appFileLoggingEnabled) {
|
||||
Label(
|
||||
self.appFileLoggingEnabled
|
||||
? "File Logging: On"
|
||||
: "File Logging: Off",
|
||||
systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
} label: {
|
||||
Label("App Logging", systemImage: "doc.text")
|
||||
}
|
||||
Button {
|
||||
DebugActions.openSessionStore()
|
||||
} label: {
|
||||
Label("Open Session Store", systemImage: "externaldrive")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
DebugActions.openAgentEventsWindow()
|
||||
} label: {
|
||||
Label("Open Agent Events…", systemImage: "bolt.horizontal.circle")
|
||||
}
|
||||
Button {
|
||||
DebugActions.openLog()
|
||||
} label: {
|
||||
Label("Open Log", systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
Button {
|
||||
Task { _ = await DebugActions.sendDebugVoice() }
|
||||
} label: {
|
||||
Label("Send Debug Voice Text", systemImage: "waveform.circle")
|
||||
}
|
||||
Button {
|
||||
Task { await DebugActions.sendTestNotification() }
|
||||
} label: {
|
||||
Label("Send Test Notification", systemImage: "bell")
|
||||
}
|
||||
Divider()
|
||||
if self.state.connectionMode == .local, !AppStateStore.attachExistingGatewayOnly {
|
||||
Button {
|
||||
DebugActions.restartGateway()
|
||||
} label: {
|
||||
Label("Restart Gateway", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
Button {
|
||||
DebugActions.restartApp()
|
||||
} label: {
|
||||
Label("Restart App", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func open(tab: SettingsTab) {
|
||||
SettingsTabRouter.request(tab)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
self.openSettings()
|
||||
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func openDashboard() async {
|
||||
do {
|
||||
let config = try await GatewayEndpointStore.shared.requireConfig()
|
||||
let wsURL = config.url
|
||||
guard var components = URLComponents(url: wsURL, resolvingAgainstBaseURL: false) else {
|
||||
throw NSError(domain: "Dashboard", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Invalid gateway URL",
|
||||
])
|
||||
}
|
||||
switch components.scheme?.lowercased() {
|
||||
case "ws":
|
||||
components.scheme = "http"
|
||||
case "wss":
|
||||
components.scheme = "https"
|
||||
default:
|
||||
components.scheme = "http"
|
||||
}
|
||||
components.path = "/"
|
||||
components.query = nil
|
||||
guard let url = components.url else {
|
||||
throw NSError(domain: "Dashboard", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
|
||||
])
|
||||
}
|
||||
NSWorkspace.shared.open(url)
|
||||
} catch {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Dashboard unavailable"
|
||||
alert.informativeText = error.localizedDescription
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
|
||||
private var healthStatus: (label: String, color: Color) {
|
||||
if let activity = self.activityStore.current {
|
||||
let color: Color = activity.role == .main ? .accentColor : .gray
|
||||
let roleLabel = activity.role == .main ? "Main" : "Other"
|
||||
let text = "\(roleLabel) · \(activity.label)"
|
||||
return (text, color)
|
||||
}
|
||||
|
||||
let health = self.healthStore.state
|
||||
let isRefreshing = self.healthStore.isRefreshing
|
||||
let lastAge = self.healthStore.lastSuccess.map { age(from: $0) }
|
||||
|
||||
if isRefreshing {
|
||||
return ("Health check running…", health.tint)
|
||||
}
|
||||
|
||||
switch health {
|
||||
case .ok:
|
||||
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
||||
return ("Health ok\(ageText)", .green)
|
||||
case .linkingNeeded:
|
||||
return ("Health: login required", .red)
|
||||
case let .degraded(reason):
|
||||
let detail = HealthStore.shared.degradedSummary ?? reason
|
||||
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
||||
return ("\(detail)\(ageText)", .orange)
|
||||
case .unknown:
|
||||
return ("Health pending", .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var heartbeatStatus: (label: String, color: Color) {
|
||||
if case .degraded = self.controlChannel.state {
|
||||
return ("Control channel disconnected", .red)
|
||||
} else if let evt = self.heartbeatStore.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: evt.ts / 1000))
|
||||
switch evt.status {
|
||||
case "sent":
|
||||
return ("Last heartbeat sent · \(ageText)", .blue)
|
||||
case "ok-empty", "ok-token":
|
||||
return ("Heartbeat ok · \(ageText)", .green)
|
||||
case "skipped":
|
||||
return ("Heartbeat skipped · \(ageText)", .secondary)
|
||||
case "failed":
|
||||
return ("Heartbeat failed · \(ageText)", .red)
|
||||
default:
|
||||
return ("Heartbeat · \(ageText)", .secondary)
|
||||
}
|
||||
} else {
|
||||
return ("No heartbeat yet", .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusLine(label: String, color: Color) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
private var activeBinding: Binding<Bool> {
|
||||
Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 })
|
||||
}
|
||||
|
||||
private var heartbeatsBinding: Binding<Bool> {
|
||||
Binding(get: { self.state.heartbeatsEnabled }, set: { self.state.heartbeatsEnabled = $0 })
|
||||
}
|
||||
|
||||
private var voiceWakeBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { self.state.swabbleEnabled },
|
||||
set: { newValue in
|
||||
Task { await self.state.setVoiceWakeEnabled(newValue) }
|
||||
})
|
||||
}
|
||||
|
||||
private var showVoiceWakeMicPicker: Bool {
|
||||
voiceWakeSupported && self.state.swabbleEnabled
|
||||
}
|
||||
|
||||
private var voiceWakeMicMenu: some View {
|
||||
Menu {
|
||||
self.microphoneMenuItems
|
||||
|
||||
if self.loadingMics {
|
||||
Divider()
|
||||
Label("Refreshing microphones…", systemImage: "arrow.triangle.2.circlepath")
|
||||
.labelStyle(.titleOnly)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(true)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Microphone")
|
||||
Spacer()
|
||||
Text(self.selectedMicLabel)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.task { await self.loadMicrophones() }
|
||||
}
|
||||
|
||||
private var selectedMicLabel: String {
|
||||
if self.state.voiceWakeMicID.isEmpty { return self.defaultMicLabel }
|
||||
if let match = self.availableMics.first(where: { $0.uid == self.state.voiceWakeMicID }) {
|
||||
return match.name
|
||||
}
|
||||
return "Unavailable"
|
||||
}
|
||||
|
||||
private var microphoneMenuItems: some View {
|
||||
Group {
|
||||
Button {
|
||||
self.state.voiceWakeMicID = ""
|
||||
} label: {
|
||||
Label(self.defaultMicLabel, systemImage: self.state.voiceWakeMicID.isEmpty ? "checkmark" : "")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
ForEach(self.availableMics) { mic in
|
||||
Button {
|
||||
self.state.voiceWakeMicID = mic.uid
|
||||
} label: {
|
||||
Label(mic.name, systemImage: self.state.voiceWakeMicID == mic.uid ? "checkmark" : "")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultMicLabel: String {
|
||||
if let host = Host.current().localizedName, !host.isEmpty {
|
||||
return "Auto-detect (\(host))"
|
||||
}
|
||||
return "System default"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func presentDebugResult(_ result: Result<String, DebugActionError>, title: String) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = title
|
||||
switch result {
|
||||
case let .success(message):
|
||||
alert.informativeText = message
|
||||
alert.alertStyle = .informational
|
||||
case let .failure(error):
|
||||
alert.informativeText = error.localizedDescription
|
||||
alert.alertStyle = .warning
|
||||
}
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadMicrophones(force: Bool = false) async {
|
||||
guard self.showVoiceWakeMicPicker else {
|
||||
self.availableMics = []
|
||||
self.loadingMics = false
|
||||
return
|
||||
}
|
||||
if !force, !self.availableMics.isEmpty { return }
|
||||
self.loadingMics = true
|
||||
let discovery = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.external, .microphone],
|
||||
mediaType: .audio,
|
||||
position: .unspecified)
|
||||
self.availableMics = discovery.devices
|
||||
.sorted { lhs, rhs in
|
||||
lhs.localizedName.localizedCaseInsensitiveCompare(rhs.localizedName) == .orderedAscending
|
||||
}
|
||||
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
|
||||
self.loadingMics = false
|
||||
}
|
||||
|
||||
private struct AudioInputDevice: Identifiable, Equatable {
|
||||
let uid: String
|
||||
let name: String
|
||||
var id: String { self.uid }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
||||
static let shared = MenuContextCardInjector()
|
||||
|
||||
private let tag = 9_415_227
|
||||
private let fallbackCardWidth: CGFloat = 320
|
||||
private var lastKnownMenuWidth: CGFloat?
|
||||
private weak var originalDelegate: NSMenuDelegate?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var warmTask: Task<Void, Never>?
|
||||
private var cachedRows: [SessionRow] = []
|
||||
private var cacheErrorText: String?
|
||||
private var cacheUpdatedAt: Date?
|
||||
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
|
||||
private let refreshIntervalSeconds: TimeInterval = 15
|
||||
private var isMenuOpen = false
|
||||
|
||||
func install(into statusItem: NSStatusItem) {
|
||||
// SwiftUI owns the menu, but we can inject a custom NSMenuItem.view right before display.
|
||||
guard let menu = statusItem.menu else { return }
|
||||
// Preserve SwiftUI's internal NSMenuDelegate, otherwise it may stop populating menu items.
|
||||
if menu.delegate !== self {
|
||||
self.originalDelegate = menu.delegate
|
||||
menu.delegate = self
|
||||
}
|
||||
|
||||
if self.warmTask == nil {
|
||||
self.warmTask = Task { await self.refreshCache(force: true) }
|
||||
}
|
||||
}
|
||||
|
||||
func menuWillOpen(_ menu: NSMenu) {
|
||||
self.originalDelegate?.menuWillOpen?(menu)
|
||||
self.isMenuOpen = true
|
||||
|
||||
// Remove any previous injected card items.
|
||||
for item in menu.items where item.tag == self.tag {
|
||||
menu.removeItem(item)
|
||||
}
|
||||
|
||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||
|
||||
self.loadTask?.cancel()
|
||||
|
||||
let initialRows = self.cachedRows
|
||||
let initialIsLoading = initialRows.isEmpty
|
||||
let initialStatusText = initialIsLoading ? self.cacheErrorText : nil
|
||||
let initialWidth = self.initialCardWidth(for: menu)
|
||||
|
||||
let initial = AnyView(ContextMenuCardView(
|
||||
rows: initialRows,
|
||||
statusText: initialStatusText,
|
||||
isLoading: initialIsLoading))
|
||||
|
||||
let hosting = NSHostingView(rootView: initial)
|
||||
hosting.frame.size.width = max(1, initialWidth)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame = NSRect(
|
||||
origin: .zero,
|
||||
size: NSSize(width: initialWidth, height: size.height))
|
||||
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.view = hosting
|
||||
item.isEnabled = false
|
||||
|
||||
menu.insertItem(item, at: insertIndex)
|
||||
|
||||
// Capture the menu window width for next open, but do not mutate widths while the menu is visible.
|
||||
DispatchQueue.main.async { [weak self, weak hosting] in
|
||||
guard let self, let hosting else { return }
|
||||
self.captureMenuWidthIfAvailable(for: menu, hosting: hosting)
|
||||
}
|
||||
|
||||
if initialIsLoading {
|
||||
self.loadTask = Task { [weak hosting] in
|
||||
await self.refreshCache(force: true)
|
||||
guard let hosting else { return }
|
||||
let view = self.cachedView()
|
||||
await MainActor.run {
|
||||
hosting.rootView = view
|
||||
hosting.invalidateIntrinsicContentSize()
|
||||
self.captureMenuWidthIfAvailable(for: menu, hosting: hosting)
|
||||
hosting.frame.size.width = max(1, initialWidth)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame.size.height = size.height
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Keep the menu stable while it's open; refresh in the background for next open.
|
||||
self.loadTask = Task { await self.refreshCache(force: false) }
|
||||
}
|
||||
}
|
||||
|
||||
func menuDidClose(_ menu: NSMenu) {
|
||||
self.originalDelegate?.menuDidClose?(menu)
|
||||
self.isMenuOpen = false
|
||||
self.loadTask?.cancel()
|
||||
}
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
self.originalDelegate?.menuNeedsUpdate?(menu)
|
||||
}
|
||||
|
||||
func confinementRect(for menu: NSMenu, on screen: NSScreen?) -> NSRect {
|
||||
if let rect = self.originalDelegate?.confinementRect?(for: menu, on: screen) {
|
||||
return rect
|
||||
}
|
||||
return NSRect.zero
|
||||
}
|
||||
|
||||
private func refreshCache(force: Bool) async {
|
||||
if !force, let cacheUpdatedAt, Date().timeIntervalSince(cacheUpdatedAt) < self.refreshIntervalSeconds {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let rows = try await self.loadCurrentRows()
|
||||
self.cachedRows = rows
|
||||
self.cacheErrorText = nil
|
||||
self.cacheUpdatedAt = Date()
|
||||
} catch {
|
||||
if self.cachedRows.isEmpty {
|
||||
let raw = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
self.cacheErrorText = "Could not load sessions"
|
||||
} else {
|
||||
// Keep the menu readable: one line, short.
|
||||
let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed
|
||||
self.cacheErrorText = firstLine.count > 90 ? "\(firstLine.prefix(87))…" : firstLine
|
||||
}
|
||||
}
|
||||
self.cacheUpdatedAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
private func cachedView() -> AnyView {
|
||||
let rows = self.cachedRows
|
||||
let isLoading = rows.isEmpty && self.cacheErrorText == nil
|
||||
return AnyView(ContextMenuCardView(rows: rows, statusText: self.cacheErrorText, isLoading: isLoading))
|
||||
}
|
||||
|
||||
private func loadCurrentRows() async throws -> [SessionRow] {
|
||||
let snapshot = try await SessionLoader.loadSnapshot()
|
||||
let loaded = snapshot.rows
|
||||
let now = Date()
|
||||
let current = loaded.filter { row in
|
||||
if row.key == "main" { return true }
|
||||
guard let updatedAt = row.updatedAt else { return false }
|
||||
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
||||
}
|
||||
|
||||
return current.sorted { lhs, rhs in
|
||||
if lhs.key == "main" { return true }
|
||||
if rhs.key == "main" { return false }
|
||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||
}
|
||||
}
|
||||
|
||||
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
||||
// Prefer inserting before the first separator (so the card sits right below the Active toggle).
|
||||
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
||||
// SwiftUI menus typically include a separator right after the first toggle; insert before it so the
|
||||
// separator appears below the context card.
|
||||
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
|
||||
// Fallback: insert after the first item.
|
||||
if menu.items.count >= 1 { return 1 }
|
||||
return menu.items.count
|
||||
}
|
||||
|
||||
private func initialCardWidth(for menu: NSMenu) -> CGFloat {
|
||||
let widthCandidates: [CGFloat] = [
|
||||
menu.minimumWidth,
|
||||
self.lastKnownMenuWidth ?? 0,
|
||||
self.fallbackCardWidth,
|
||||
]
|
||||
let resolved = widthCandidates.max() ?? self.fallbackCardWidth
|
||||
return max(300, resolved)
|
||||
}
|
||||
|
||||
private func captureMenuWidthIfAvailable(for menu: NSMenu, hosting: NSHostingView<AnyView>) {
|
||||
let targetWidth: CGFloat? = {
|
||||
if let contentWidth = hosting.window?.contentView?.bounds.width, contentWidth > 0 { return contentWidth }
|
||||
if let superWidth = hosting.superview?.bounds.width, superWidth > 0 { return superWidth }
|
||||
let minimumWidth = menu.minimumWidth
|
||||
if minimumWidth > 0 { return minimumWidth }
|
||||
return nil
|
||||
}()
|
||||
|
||||
guard let targetWidth else { return }
|
||||
self.lastKnownMenuWidth = max(300, targetWidth)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension MenuContextCardInjector {
|
||||
func _testSetCache(rows: [SessionRow], errorText: String?, updatedAt: Date?) {
|
||||
self.cachedRows = rows
|
||||
self.cacheErrorText = errorText
|
||||
self.cacheUpdatedAt = updatedAt
|
||||
}
|
||||
|
||||
func _testFindInsertIndex(in menu: NSMenu) -> Int? {
|
||||
self.findInsertIndex(in: menu)
|
||||
}
|
||||
|
||||
func _testInitialCardWidth(for menu: NSMenu) -> CGFloat {
|
||||
self.initialCardWidth(for: menu)
|
||||
}
|
||||
|
||||
func _testCachedView() -> AnyView {
|
||||
self.cachedView()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,103 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
final class HighlightedMenuItemHostView: NSView {
|
||||
private var baseView: AnyView
|
||||
private let hosting: NSHostingView<AnyView>
|
||||
private var targetWidth: CGFloat
|
||||
private var tracking: NSTrackingArea?
|
||||
private var hovered = false {
|
||||
didSet { self.updateHighlight() }
|
||||
}
|
||||
|
||||
init(rootView: AnyView, width: CGFloat) {
|
||||
self.baseView = rootView
|
||||
self.hosting = NSHostingView(rootView: AnyView(rootView.environment(\.menuItemHighlighted, false)))
|
||||
self.targetWidth = max(1, width)
|
||||
super.init(frame: .zero)
|
||||
|
||||
self.addSubview(self.hosting)
|
||||
self.hosting.autoresizingMask = [.width, .height]
|
||||
self.updateSizing()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
let size = self.hosting.fittingSize
|
||||
return NSSize(width: self.targetWidth, height: size.height)
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let tracking {
|
||||
self.removeTrackingArea(tracking)
|
||||
}
|
||||
let options: NSTrackingArea.Options = [
|
||||
.mouseEnteredAndExited,
|
||||
.activeAlways,
|
||||
.inVisibleRect,
|
||||
]
|
||||
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
||||
self.addTrackingArea(area)
|
||||
self.tracking = area
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
_ = event
|
||||
self.hovered = true
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
_ = event
|
||||
self.hovered = false
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
self.hosting.frame = self.bounds
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
if self.hovered {
|
||||
NSColor.selectedContentBackgroundColor.setFill()
|
||||
self.bounds.fill()
|
||||
}
|
||||
super.draw(dirtyRect)
|
||||
}
|
||||
|
||||
func update(rootView: AnyView, width: CGFloat) {
|
||||
self.baseView = rootView
|
||||
self.targetWidth = max(1, width)
|
||||
self.updateHighlight()
|
||||
}
|
||||
|
||||
private func updateHighlight() {
|
||||
self.hosting.rootView = AnyView(self.baseView.environment(\.menuItemHighlighted, self.hovered))
|
||||
self.updateSizing()
|
||||
self.needsDisplay = true
|
||||
}
|
||||
|
||||
private func updateSizing() {
|
||||
let width = max(1, self.targetWidth)
|
||||
self.hosting.frame.size.width = width
|
||||
let size = self.hosting.fittingSize
|
||||
self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
self.invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct MenuHostedHighlightedItem: NSViewRepresentable {
|
||||
let width: CGFloat
|
||||
let rootView: AnyView
|
||||
|
||||
func makeNSView(context _: Context) -> HighlightedMenuItemHostView {
|
||||
HighlightedMenuItemHostView(rootView: self.rootView, width: self.width)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: HighlightedMenuItemHostView, context _: Context) {
|
||||
nsView.update(rootView: self.rootView, width: self.width)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Hosts arbitrary SwiftUI content as an AppKit view so it can be embedded in a native `NSMenuItem.view`.
|
||||
///
|
||||
/// SwiftUI `MenuBarExtraStyle.menu` aggressively simplifies many view hierarchies into a title + image.
|
||||
/// Wrapping the content in an `NSViewRepresentable` forces AppKit-backed menu item rendering.
|
||||
struct MenuHostedItem: NSViewRepresentable {
|
||||
let width: CGFloat
|
||||
let rootView: AnyView
|
||||
|
||||
func makeNSView(context _: Context) -> NSHostingView<AnyView> {
|
||||
let hosting = NSHostingView(rootView: self.rootView)
|
||||
self.applySizing(to: hosting)
|
||||
return hosting
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSHostingView<AnyView>, context _: Context) {
|
||||
nsView.rootView = self.rootView
|
||||
self.applySizing(to: nsView)
|
||||
}
|
||||
|
||||
private func applySizing(to hosting: NSHostingView<AnyView>) {
|
||||
let width = max(1, self.width)
|
||||
hosting.frame.size.width = width
|
||||
let fitting = hosting.fittingSize
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: fitting.height))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MenuSessionsHeaderView: View {
|
||||
let count: Int
|
||||
let statusText: String?
|
||||
|
||||
private let paddingTop: CGFloat = 8
|
||||
private let paddingBottom: CGFloat = 6
|
||||
private let paddingTrailing: CGFloat = 10
|
||||
private let paddingLeading: CGFloat = 20
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Context")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 10)
|
||||
Text(self.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let statusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.padding(.top, self.paddingTop)
|
||||
.padding(.bottom, self.paddingBottom)
|
||||
.padding(.leading, self.paddingLeading)
|
||||
.padding(.trailing, self.paddingTrailing)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
|
||||
.transaction { txn in txn.animation = nil }
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if self.count == 1 { return "1 session · 24h" }
|
||||
return "\(self.count) sessions · 24h"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
static let shared = MenuSessionsInjector()
|
||||
|
||||
private let tag = 9_415_557
|
||||
private let nodesTag = 9_415_558
|
||||
private let fallbackWidth: CGFloat = 320
|
||||
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
|
||||
|
||||
private weak var originalDelegate: NSMenuDelegate?
|
||||
private weak var statusItem: NSStatusItem?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var nodesLoadTask: Task<Void, Never>?
|
||||
private var isMenuOpen = false
|
||||
private var lastKnownMenuWidth: CGFloat?
|
||||
private var menuOpenWidth: CGFloat?
|
||||
|
||||
private var cachedSnapshot: SessionStoreSnapshot?
|
||||
private var cachedErrorText: String?
|
||||
private var cacheUpdatedAt: Date?
|
||||
private let refreshIntervalSeconds: TimeInterval = 12
|
||||
private let nodesStore = NodesStore.shared
|
||||
#if DEBUG
|
||||
private var testControlChannelConnected: Bool?
|
||||
#endif
|
||||
|
||||
func install(into statusItem: NSStatusItem) {
|
||||
self.statusItem = statusItem
|
||||
guard let menu = statusItem.menu else { return }
|
||||
|
||||
// Preserve SwiftUI's internal NSMenuDelegate, otherwise it may stop populating menu items.
|
||||
if menu.delegate !== self {
|
||||
self.originalDelegate = menu.delegate
|
||||
menu.delegate = self
|
||||
}
|
||||
|
||||
if self.loadTask == nil {
|
||||
self.loadTask = Task { await self.refreshCache(force: true) }
|
||||
}
|
||||
|
||||
self.nodesStore.start()
|
||||
}
|
||||
|
||||
func menuWillOpen(_ menu: NSMenu) {
|
||||
self.originalDelegate?.menuWillOpen?(menu)
|
||||
self.isMenuOpen = true
|
||||
self.menuOpenWidth = self.currentMenuWidth(for: menu)
|
||||
|
||||
self.inject(into: menu)
|
||||
self.injectNodes(into: menu)
|
||||
|
||||
// Refresh in background for the next open; keep width stable while open.
|
||||
self.loadTask?.cancel()
|
||||
let forceRefresh = self.cachedSnapshot == nil || self.cachedErrorText != nil
|
||||
self.loadTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refreshCache(force: forceRefresh)
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.inject(into: menu)
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
}
|
||||
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.nodesLoadTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.nodesStore.refresh()
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func menuDidClose(_ menu: NSMenu) {
|
||||
self.originalDelegate?.menuDidClose?(menu)
|
||||
self.isMenuOpen = false
|
||||
self.menuOpenWidth = nil
|
||||
self.loadTask?.cancel()
|
||||
self.nodesLoadTask?.cancel()
|
||||
}
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
self.originalDelegate?.menuNeedsUpdate?(menu)
|
||||
}
|
||||
|
||||
func confinementRect(for menu: NSMenu, on screen: NSScreen?) -> NSRect {
|
||||
if let rect = self.originalDelegate?.confinementRect?(for: menu, on: screen) {
|
||||
return rect
|
||||
}
|
||||
return NSRect.zero
|
||||
}
|
||||
|
||||
// MARK: - Injection
|
||||
|
||||
private func inject(into menu: NSMenu) {
|
||||
// Remove any previous injected items.
|
||||
for item in menu.items where item.tag == self.tag {
|
||||
menu.removeItem(item)
|
||||
}
|
||||
|
||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||
let width = self.initialWidth(for: menu)
|
||||
|
||||
guard self.isControlChannelConnected else { return }
|
||||
|
||||
guard let snapshot = self.cachedSnapshot else {
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
headerItem.view = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(
|
||||
count: 0,
|
||||
statusText: self.cachedErrorText ?? "Loading sessions…")),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
menu.insertItem(headerItem, at: insertIndex)
|
||||
DispatchQueue.main.async { [weak self, weak view = headerItem.view] in
|
||||
guard let self, let view else { return }
|
||||
self.captureMenuWidthIfAvailable(from: view)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
let rows = snapshot.rows.filter { row in
|
||||
if row.key == "main" { return true }
|
||||
guard let updatedAt = row.updatedAt else { return false }
|
||||
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
||||
}.sorted { lhs, rhs in
|
||||
if lhs.key == "main" { return true }
|
||||
if rhs.key == "main" { return false }
|
||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||
}
|
||||
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
let headerView = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = headerView
|
||||
menu.insertItem(headerItem, at: insertIndex)
|
||||
|
||||
var cursor = insertIndex + 1
|
||||
if rows.isEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width),
|
||||
at: cursor)
|
||||
return
|
||||
}
|
||||
|
||||
for row in rows {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.isEnabled = true
|
||||
item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath)
|
||||
item.view = self.makeHostedView(
|
||||
rootView: AnyView(SessionMenuLabelView(row: row, width: width)),
|
||||
width: width,
|
||||
highlighted: true)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self, weak headerView] in
|
||||
guard let self, let headerView else { return }
|
||||
self.captureMenuWidthIfAvailable(from: headerView)
|
||||
}
|
||||
}
|
||||
|
||||
private func injectNodes(into menu: NSMenu) {
|
||||
for item in menu.items where item.tag == self.nodesTag {
|
||||
menu.removeItem(item)
|
||||
}
|
||||
|
||||
guard let insertIndex = self.findNodesInsertIndex(in: menu) else { return }
|
||||
let width = self.initialWidth(for: menu)
|
||||
var cursor = insertIndex
|
||||
|
||||
let entries = self.sortedNodeEntries()
|
||||
let topSeparator = NSMenuItem.separator()
|
||||
topSeparator.tag = self.nodesTag
|
||||
menu.insertItem(topSeparator, at: cursor)
|
||||
cursor += 1
|
||||
|
||||
if let gatewayEntry = self.gatewayEntry() {
|
||||
let gatewayItem = self.makeNodeItem(entry: gatewayEntry, width: width)
|
||||
menu.insertItem(gatewayItem, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else { return }
|
||||
|
||||
if let error = self.nodesStore.lastError?.nonEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(
|
||||
text: "Error: \(error)",
|
||||
symbolName: "exclamationmark.triangle",
|
||||
width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
} else if let status = self.nodesStore.statusMessage?.nonEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: status, symbolName: "info.circle", width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if entries.isEmpty {
|
||||
let title = self.nodesStore.isLoading ? "Loading devices..." : "No devices yet"
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: title, symbolName: "circle.dashed", width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
} else {
|
||||
for entry in entries.prefix(8) {
|
||||
let item = self.makeNodeItem(entry: entry, width: width)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if entries.count > 8 {
|
||||
let moreItem = NSMenuItem()
|
||||
moreItem.tag = self.nodesTag
|
||||
moreItem.title = "More Devices..."
|
||||
moreItem.image = NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: nil)
|
||||
let overflow = Array(entries.dropFirst(8))
|
||||
moreItem.submenu = self.buildNodesOverflowMenu(entries: overflow, width: width)
|
||||
menu.insertItem(moreItem, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
|
||||
_ = cursor
|
||||
}
|
||||
|
||||
private var isControlChannelConnected: Bool {
|
||||
#if DEBUG
|
||||
if let override = self.testControlChannelConnected { return override }
|
||||
#endif
|
||||
if case .connected = ControlChannel.shared.state { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func gatewayEntry() -> NodeInfo? {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let isConnected = self.isControlChannelConnected
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
var host: String?
|
||||
var platform: String?
|
||||
|
||||
switch mode {
|
||||
case .remote:
|
||||
platform = "remote"
|
||||
let target = AppStateStore.shared.remoteTarget
|
||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||
} else {
|
||||
host = target.nonEmpty
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
host = "127.0.0.1:\(port)"
|
||||
case .unconfigured:
|
||||
platform = nil
|
||||
host = nil
|
||||
}
|
||||
|
||||
return NodeInfo(
|
||||
nodeId: "gateway",
|
||||
displayName: "Gateway",
|
||||
platform: platform,
|
||||
version: nil,
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
remoteIp: host,
|
||||
caps: nil,
|
||||
commands: nil,
|
||||
permissions: nil,
|
||||
paired: nil,
|
||||
connected: isConnected)
|
||||
}
|
||||
|
||||
private func makeNodeItem(entry: NodeInfo, width: CGFloat) -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.nodesTag
|
||||
item.target = self
|
||||
item.action = #selector(self.copyNodeSummary(_:))
|
||||
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
||||
item.view = HighlightedMenuItemHostView(
|
||||
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||
width: width)
|
||||
item.submenu = self.buildNodeSubmenu(entry: entry, width: width)
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeSessionPreviewItem(
|
||||
sessionKey: String,
|
||||
title: String,
|
||||
width: CGFloat,
|
||||
maxLines: Int) -> NSMenuItem
|
||||
{
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.isEnabled = false
|
||||
let view = AnyView(SessionMenuPreviewView(
|
||||
sessionKey: sessionKey,
|
||||
width: width,
|
||||
maxItems: 10,
|
||||
maxLines: maxLines,
|
||||
title: title))
|
||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
|
||||
let view = AnyView(
|
||||
Label(text, systemImage: symbolName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.vertical, 6)
|
||||
.frame(minWidth: 300, alignment: .leading))
|
||||
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.isEnabled = false
|
||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
||||
return item
|
||||
}
|
||||
|
||||
// MARK: - Cache
|
||||
|
||||
private func refreshCache(force: Bool) async {
|
||||
if !force, let updated = self.cacheUpdatedAt, Date().timeIntervalSince(updated) < self.refreshIntervalSeconds {
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedSnapshot = nil
|
||||
self.cachedErrorText = nil
|
||||
self.cacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
self.cachedSnapshot = try await SessionLoader.loadSnapshot(limit: 32)
|
||||
self.cachedErrorText = nil
|
||||
self.cacheUpdatedAt = Date()
|
||||
} catch {
|
||||
self.cachedSnapshot = nil
|
||||
self.cachedErrorText = self.compactError(error)
|
||||
self.cacheUpdatedAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
private func compactError(_ error: Error) -> String {
|
||||
if let loadError = error as? SessionLoadError {
|
||||
switch loadError {
|
||||
case .gatewayUnavailable:
|
||||
return "No connection to gateway"
|
||||
case .decodeFailed:
|
||||
return "Sessions unavailable"
|
||||
}
|
||||
}
|
||||
return "Sessions unavailable"
|
||||
}
|
||||
|
||||
// MARK: - Submenus
|
||||
|
||||
private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
let width = self.submenuWidth()
|
||||
|
||||
menu.addItem(self.makeSessionPreviewItem(
|
||||
sessionKey: row.key,
|
||||
title: "Recent messages (last 10)",
|
||||
width: width,
|
||||
maxLines: 3))
|
||||
|
||||
let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "")
|
||||
morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width)
|
||||
menu.addItem(morePreview)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let thinking = NSMenuItem(title: "Thinking", action: nil, keyEquivalent: "")
|
||||
thinking.submenu = self.buildThinkingMenu(for: row)
|
||||
menu.addItem(thinking)
|
||||
|
||||
let verbose = NSMenuItem(title: "Verbose", action: nil, keyEquivalent: "")
|
||||
verbose.submenu = self.buildVerboseMenu(for: row)
|
||||
menu.addItem(verbose)
|
||||
|
||||
if AppStateStore.shared.debugPaneEnabled,
|
||||
AppStateStore.shared.connectionMode == .local,
|
||||
let sessionId = row.sessionId,
|
||||
!sessionId.isEmpty
|
||||
{
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
let openLog = NSMenuItem(
|
||||
title: "Open Session Log",
|
||||
action: #selector(self.openSessionLog(_:)),
|
||||
keyEquivalent: "")
|
||||
openLog.target = self
|
||||
openLog.representedObject = [
|
||||
"sessionId": sessionId,
|
||||
"storePath": storePath,
|
||||
]
|
||||
menu.addItem(openLog)
|
||||
}
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let reset = NSMenuItem(title: "Reset Session", action: #selector(self.resetSession(_:)), keyEquivalent: "")
|
||||
reset.target = self
|
||||
reset.representedObject = row.key
|
||||
menu.addItem(reset)
|
||||
|
||||
let compact = NSMenuItem(
|
||||
title: "Compact Session Log",
|
||||
action: #selector(self.compactSession(_:)),
|
||||
keyEquivalent: "")
|
||||
compact.target = self
|
||||
compact.representedObject = row.key
|
||||
menu.addItem(compact)
|
||||
|
||||
if row.key != "main" && row.key != "global" {
|
||||
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
|
||||
del.target = self
|
||||
del.representedObject = row.key
|
||||
del.isAlternate = false
|
||||
del.keyEquivalentModifierMask = []
|
||||
menu.addItem(del)
|
||||
}
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
private func buildThinkingMenu(for row: SessionRow) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
menu.autoenablesItems = false
|
||||
menu.showsStateColumn = true
|
||||
let levels: [String] = ["off", "minimal", "low", "medium", "high"]
|
||||
let current = levels.contains(row.thinkingLevel ?? "") ? row.thinkingLevel ?? "off" : "off"
|
||||
for level in levels {
|
||||
let title = level.capitalized
|
||||
let item = NSMenuItem(title: title, action: #selector(self.patchThinking(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
item.representedObject = [
|
||||
"key": row.key,
|
||||
"value": level as Any,
|
||||
]
|
||||
item.state = (current == level) ? .on : .off
|
||||
menu.addItem(item)
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
private func buildVerboseMenu(for row: SessionRow) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
menu.autoenablesItems = false
|
||||
menu.showsStateColumn = true
|
||||
let levels: [String] = ["on", "off"]
|
||||
let current = levels.contains(row.verboseLevel ?? "") ? row.verboseLevel ?? "off" : "off"
|
||||
for level in levels {
|
||||
let title = level.capitalized
|
||||
let item = NSMenuItem(title: title, action: #selector(self.patchVerbose(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
item.representedObject = [
|
||||
"key": row.key,
|
||||
"value": level as Any,
|
||||
]
|
||||
item.state = (current == level) ? .on : .off
|
||||
menu.addItem(item)
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
menu.addItem(self.makeSessionPreviewItem(
|
||||
sessionKey: sessionKey,
|
||||
title: "Recent messages (expanded)",
|
||||
width: width,
|
||||
maxLines: 8))
|
||||
return menu
|
||||
}
|
||||
|
||||
private func buildNodesOverflowMenu(entries: [NodeInfo], width: CGFloat) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
for entry in entries {
|
||||
let item = NSMenuItem()
|
||||
item.target = self
|
||||
item.action = #selector(self.copyNodeSummary(_:))
|
||||
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
||||
item.view = HighlightedMenuItemHostView(
|
||||
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||
width: width)
|
||||
item.submenu = self.buildNodeSubmenu(entry: entry, width: width)
|
||||
menu.addItem(item)
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
private func buildNodeSubmenu(entry: NodeInfo, width: CGFloat) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
menu.autoenablesItems = false
|
||||
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Node ID", value: entry.nodeId))
|
||||
|
||||
if let name = entry.displayName?.nonEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Name", value: name))
|
||||
}
|
||||
|
||||
if let ip = entry.remoteIp?.nonEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "IP", value: ip))
|
||||
}
|
||||
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Status", value: NodeMenuEntryFormatter.roleText(entry)))
|
||||
|
||||
if let platform = NodeMenuEntryFormatter.platformText(entry) {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
||||
}
|
||||
|
||||
if let version = entry.version?.nonEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
|
||||
}
|
||||
|
||||
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
|
||||
menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No"))
|
||||
|
||||
if let caps = entry.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
||||
!caps.isEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Caps", value: caps.joined(separator: ", ")))
|
||||
}
|
||||
|
||||
if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
||||
!commands.isEmpty {
|
||||
menu.addItem(self.makeNodeMultilineItem(
|
||||
label: "Commands",
|
||||
value: commands.joined(separator: ", "),
|
||||
width: width))
|
||||
}
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
private func makeNodeDetailItem(label: String, value: String) -> NSMenuItem {
|
||||
let item = NSMenuItem(title: "\(label): \(value)", action: nil, keyEquivalent: "")
|
||||
item.isEnabled = false
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeNodeCopyItem(label: String, value: String) -> NSMenuItem {
|
||||
let item = NSMenuItem(title: "\(label): \(value)", action: #selector(self.copyNodeValue(_:)), keyEquivalent: "")
|
||||
item.target = self
|
||||
item.representedObject = value
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeNodeMultilineItem(label: String, value: String, width: CGFloat) -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
item.target = self
|
||||
item.action = #selector(self.copyNodeValue(_:))
|
||||
item.representedObject = value
|
||||
item.view = HighlightedMenuItemHostView(
|
||||
rootView: AnyView(NodeMenuMultilineView(label: label, value: value, width: width)),
|
||||
width: width)
|
||||
return item
|
||||
}
|
||||
|
||||
private func formatVersionLabel(_ version: String) -> String {
|
||||
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return version }
|
||||
if trimmed.hasPrefix("v") { return trimmed }
|
||||
if let first = trimmed.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) {
|
||||
return "v\(trimmed)"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
@objc
|
||||
private func patchThinking(_ sender: NSMenuItem) {
|
||||
guard let dict = sender.representedObject as? [String: Any],
|
||||
let key = dict["key"] as? String
|
||||
else { return }
|
||||
let value = dict["value"] as? String
|
||||
Task {
|
||||
do {
|
||||
try await SessionActions.patchSession(key: key, thinking: .some(value))
|
||||
await self.refreshCache(force: true)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
SessionActions.presentError(title: "Update thinking failed", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private func patchVerbose(_ sender: NSMenuItem) {
|
||||
guard let dict = sender.representedObject as? [String: Any],
|
||||
let key = dict["key"] as? String
|
||||
else { return }
|
||||
let value = dict["value"] as? String
|
||||
Task {
|
||||
do {
|
||||
try await SessionActions.patchSession(key: key, verbose: .some(value))
|
||||
await self.refreshCache(force: true)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
SessionActions.presentError(title: "Update verbose failed", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private func openSessionLog(_ sender: NSMenuItem) {
|
||||
guard let dict = sender.representedObject as? [String: String],
|
||||
let sessionId = dict["sessionId"],
|
||||
let storePath = dict["storePath"]
|
||||
else { return }
|
||||
SessionActions.openSessionLogInCode(sessionId: sessionId, storePath: storePath)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func resetSession(_ sender: NSMenuItem) {
|
||||
guard let key = sender.representedObject as? String else { return }
|
||||
Task { @MainActor in
|
||||
guard SessionActions.confirmDestructiveAction(
|
||||
title: "Reset session?",
|
||||
message: "Starts a new session id for “\(key)”.",
|
||||
action: "Reset")
|
||||
else { return }
|
||||
|
||||
do {
|
||||
try await SessionActions.resetSession(key: key)
|
||||
await self.refreshCache(force: true)
|
||||
} catch {
|
||||
SessionActions.presentError(title: "Reset failed", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private func compactSession(_ sender: NSMenuItem) {
|
||||
guard let key = sender.representedObject as? String else { return }
|
||||
Task { @MainActor in
|
||||
guard SessionActions.confirmDestructiveAction(
|
||||
title: "Compact session log?",
|
||||
message: "Keeps the last 400 lines; archives the old file.",
|
||||
action: "Compact")
|
||||
else { return }
|
||||
|
||||
do {
|
||||
try await SessionActions.compactSession(key: key, maxLines: 400)
|
||||
await self.refreshCache(force: true)
|
||||
} catch {
|
||||
SessionActions.presentError(title: "Compact failed", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private func deleteSession(_ sender: NSMenuItem) {
|
||||
guard let key = sender.representedObject as? String else { return }
|
||||
Task { @MainActor in
|
||||
guard SessionActions.confirmDestructiveAction(
|
||||
title: "Delete session?",
|
||||
message: "Deletes the “\(key)” entry and archives its transcript.",
|
||||
action: "Delete")
|
||||
else { return }
|
||||
|
||||
do {
|
||||
try await SessionActions.deleteSession(key: key)
|
||||
await self.refreshCache(force: true)
|
||||
} catch {
|
||||
SessionActions.presentError(title: "Delete failed", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private func copyNodeSummary(_ sender: NSMenuItem) {
|
||||
guard let summary = sender.representedObject as? String else { return }
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(summary, forType: .string)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func copyNodeValue(_ sender: NSMenuItem) {
|
||||
guard let value = sender.representedObject as? String else { return }
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
}
|
||||
|
||||
// MARK: - Width + placement
|
||||
|
||||
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
||||
// Insert right before the separator above "Send Heartbeats".
|
||||
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
||||
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
|
||||
if menu.items.count >= 1 { return 1 }
|
||||
return menu.items.count
|
||||
}
|
||||
|
||||
private func findNodesInsertIndex(in menu: NSMenu) -> Int? {
|
||||
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
||||
if let sepIdx = menu.items[..<idx].lastIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
if let sepIdx = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
|
||||
return sepIdx
|
||||
}
|
||||
|
||||
if menu.items.count >= 1 { return 1 }
|
||||
return menu.items.count
|
||||
}
|
||||
|
||||
private func initialWidth(for menu: NSMenu) -> CGFloat {
|
||||
if let openWidth = self.menuOpenWidth {
|
||||
return max(300, openWidth)
|
||||
}
|
||||
return self.currentMenuWidth(for: menu)
|
||||
}
|
||||
|
||||
private func submenuWidth() -> CGFloat {
|
||||
if let openWidth = self.menuOpenWidth {
|
||||
return max(300, openWidth)
|
||||
}
|
||||
if let cached = self.lastKnownMenuWidth {
|
||||
return max(300, cached)
|
||||
}
|
||||
return self.fallbackWidth
|
||||
}
|
||||
|
||||
private func menuWindowWidth(for menu: NSMenu) -> CGFloat? {
|
||||
var menuWindow: NSWindow?
|
||||
for item in menu.items {
|
||||
if let window = item.view?.window {
|
||||
menuWindow = window
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let width = menuWindow?.contentView?.bounds.width, width > 0 else { return nil }
|
||||
return width
|
||||
}
|
||||
|
||||
private func sortedNodeEntries() -> [NodeInfo] {
|
||||
let entries = self.nodesStore.nodes.filter { $0.isConnected }
|
||||
return entries.sorted { lhs, rhs in
|
||||
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
|
||||
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
|
||||
let lhsName = NodeMenuEntryFormatter.primaryName(lhs).lowercased()
|
||||
let rhsName = NodeMenuEntryFormatter.primaryName(rhs).lowercased()
|
||||
if lhsName == rhsName { return lhs.nodeId < rhs.nodeId }
|
||||
return lhsName < rhsName
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
|
||||
if highlighted {
|
||||
let container = HighlightedMenuItemHostView(rootView: rootView, width: width)
|
||||
return container
|
||||
}
|
||||
|
||||
let hosting = NSHostingView(rootView: rootView)
|
||||
hosting.frame.size.width = max(1, width)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
return hosting
|
||||
}
|
||||
|
||||
private func captureMenuWidthIfAvailable(from view: NSView) {
|
||||
guard !self.isMenuOpen else { return }
|
||||
guard let width = view.window?.contentView?.bounds.width, width > 0 else { return }
|
||||
self.lastKnownMenuWidth = max(300, width)
|
||||
}
|
||||
|
||||
private func currentMenuWidth(for menu: NSMenu) -> CGFloat {
|
||||
if let width = self.menuWindowWidth(for: menu) {
|
||||
return max(300, width)
|
||||
}
|
||||
let candidates: [CGFloat] = [
|
||||
menu.size.width,
|
||||
menu.minimumWidth,
|
||||
self.lastKnownMenuWidth ?? 0,
|
||||
self.fallbackWidth,
|
||||
]
|
||||
let resolved = candidates.max() ?? self.fallbackWidth
|
||||
return max(300, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension MenuSessionsInjector {
|
||||
func setTestingControlChannelConnected(_ connected: Bool?) {
|
||||
self.testControlChannelConnected = connected
|
||||
}
|
||||
|
||||
func setTestingSnapshot(_ snapshot: SessionStoreSnapshot?, errorText: String? = nil) {
|
||||
self.cachedSnapshot = snapshot
|
||||
self.cachedErrorText = errorText
|
||||
self.cacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
func injectForTesting(into menu: NSMenu) {
|
||||
self.inject(into: menu)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,81 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
actor MicLevelMonitor {
|
||||
private let engine = AVAudioEngine()
|
||||
private var update: (@Sendable (Double) -> Void)?
|
||||
private var running = false
|
||||
private var smoothedLevel: Double = 0
|
||||
|
||||
func start(onLevel: @Sendable @escaping (Double) -> Void) async throws {
|
||||
self.update = onLevel
|
||||
if self.running { return }
|
||||
let input = self.engine.inputNode
|
||||
let format = input.outputFormat(forBus: 0)
|
||||
input.removeTap(onBus: 0)
|
||||
input.installTap(onBus: 0, bufferSize: 512, format: format) { [weak self] buffer, _ in
|
||||
guard let self else { return }
|
||||
let level = Self.normalizedLevel(from: buffer)
|
||||
Task { await self.push(level: level) }
|
||||
}
|
||||
self.engine.prepare()
|
||||
try self.engine.start()
|
||||
self.running = true
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard self.running else { return }
|
||||
self.engine.inputNode.removeTap(onBus: 0)
|
||||
self.engine.stop()
|
||||
self.running = false
|
||||
}
|
||||
|
||||
private func push(level: Double) {
|
||||
self.smoothedLevel = (self.smoothedLevel * 0.45) + (level * 0.55)
|
||||
guard let update else { return }
|
||||
let value = self.smoothedLevel
|
||||
Task { @MainActor in update(value) }
|
||||
}
|
||||
|
||||
private static func normalizedLevel(from buffer: AVAudioPCMBuffer) -> Double {
|
||||
guard let channel = buffer.floatChannelData?[0] else { return 0 }
|
||||
let frameCount = Int(buffer.frameLength)
|
||||
guard frameCount > 0 else { return 0 }
|
||||
var sum: Float = 0
|
||||
for i in 0..<frameCount {
|
||||
let s = channel[i]
|
||||
sum += s * s
|
||||
}
|
||||
let rms = sqrt(sum / Float(frameCount) + 1e-12)
|
||||
let db = 20 * log10(Double(rms))
|
||||
let normalized = max(0, min(1, (db + 50) / 50))
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
struct MicLevelBar: View {
|
||||
let level: Double
|
||||
let segments: Int = 12
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(0..<self.segments, id: \.self) { idx in
|
||||
let fill = self.level * Double(self.segments) > Double(idx)
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(fill ? self.segmentColor(for: idx) : Color.gray.opacity(0.35))
|
||||
.frame(width: 14, height: 10)
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.gray.opacity(0.25), lineWidth: 1))
|
||||
}
|
||||
|
||||
private func segmentColor(for idx: Int) -> Color {
|
||||
let fraction = Double(idx + 1) / Double(self.segments)
|
||||
if fraction < 0.65 { return .green }
|
||||
if fraction < 0.85 { return .yellow }
|
||||
return .red
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import JavaScriptCore
|
||||
|
||||
enum ModelCatalogLoader {
|
||||
static let defaultPath: String = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
|
||||
|
||||
static func load(from path: String) async throws -> [ModelChoice] {
|
||||
let expanded = (path as NSString).expandingTildeInPath
|
||||
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: expanded).lastPathComponent)")
|
||||
let source = try String(contentsOfFile: expanded, encoding: .utf8)
|
||||
let sanitized = self.sanitize(source: source)
|
||||
|
||||
let ctx = JSContext()
|
||||
ctx?.exceptionHandler = { _, exception in
|
||||
if let exception {
|
||||
self.logger.warning("model catalog JS exception: \(exception)")
|
||||
}
|
||||
}
|
||||
ctx?.evaluateScript(sanitized)
|
||||
guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else {
|
||||
self.logger.error("model catalog parse failed: MODELS missing")
|
||||
throw NSError(
|
||||
domain: "ModelCatalogLoader",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to parse models.generated.ts"])
|
||||
}
|
||||
|
||||
var choices: [ModelChoice] = []
|
||||
for (provider, value) in rawModels {
|
||||
guard let models = value as? [String: Any] else { continue }
|
||||
for (id, payload) in models {
|
||||
guard let dict = payload as? [String: Any] else { continue }
|
||||
let name = dict["name"] as? String ?? id
|
||||
let ctxWindow = dict["contextWindow"] as? Int
|
||||
choices.append(ModelChoice(id: id, name: name, provider: provider, contextWindow: ctxWindow))
|
||||
}
|
||||
}
|
||||
|
||||
let sorted = choices.sorted { lhs, rhs in
|
||||
if lhs.provider == rhs.provider {
|
||||
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
|
||||
}
|
||||
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
|
||||
return sorted
|
||||
}
|
||||
|
||||
private static func sanitize(source: String) -> String {
|
||||
guard let exportRange = source.range(of: "export const MODELS"),
|
||||
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
|
||||
let lastBrace = source.lastIndex(of: "}")
|
||||
else {
|
||||
return "var MODELS = {}"
|
||||
}
|
||||
var body = String(source[firstBrace...lastBrace])
|
||||
body = body.replacingOccurrences(
|
||||
of: #"(?m)\bsatisfies\s+[^,}\n]+"#,
|
||||
with: "",
|
||||
options: .regularExpression)
|
||||
body = body.replacingOccurrences(
|
||||
of: #"(?m)\bas\s+[^;,\n]+"#,
|
||||
with: "",
|
||||
options: .regularExpression)
|
||||
return "var MODELS = \(body);"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
extension NSAttributedString {
|
||||
func strippingForegroundColor() -> NSAttributedString {
|
||||
let mutable = NSMutableAttributedString(attributedString: self)
|
||||
mutable.removeAttribute(.foregroundColor, range: NSRange(location: 0, length: mutable.length))
|
||||
return mutable
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user