chore: merge origin/main

This commit is contained in:
Peter Steinberger
2026-01-02 16:50:29 +01:00
124 changed files with 4179 additions and 618 deletions

View File

@@ -79,7 +79,7 @@ struct ContextMenuCardView: View {
height: self.barHeight)
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(row.key)
Text(row.label)
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
.lineLimit(1)
.truncationMode(.middle)

View File

@@ -63,9 +63,11 @@ final class ControlChannel {
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)
}
}
}
@@ -74,6 +76,8 @@ final class ControlChannel {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
private var eventTask: Task<Void, Never>?
private var recoveryTask: Task<Void, Never>?
private var lastRecoveryAt: Date?
private init() {
self.startEventStream()
@@ -231,7 +235,43 @@ final class ControlChannel {
}
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
return "Gateway error: \(detail)"
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)
}
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 {

View File

@@ -114,6 +114,7 @@ final class HealthStore {
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)
@@ -121,13 +122,23 @@ final class HealthStore {
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 {
self.lastError = error.localizedDescription
let desc = error.localizedDescription
self.lastError = desc
if onDemand { self.snapshot = nil }
if previousError != desc {
Self.logger.error("health refresh failed \(desc, privacy: .public)")
}
}
}

View File

@@ -374,6 +374,10 @@ struct MenuContent: View {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(1)
}
.padding(.top, 2)
}

View File

@@ -106,13 +106,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
let width = self.initialWidth(for: menu)
guard self.isControlChannelConnected else {
menu.insertItem(self.makeMessageItem(
text: self.controlChannelStatusText,
symbolName: "wifi.slash",
width: width), at: insertIndex)
return
}
guard self.isControlChannelConnected else { return }
guard let snapshot = self.cachedSnapshot else {
let headerItem = NSMenuItem()
@@ -195,17 +189,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
menu.insertItem(topSeparator, at: cursor)
cursor += 1
guard self.isControlChannelConnected else {
menu.insertItem(
self.makeMessageItem(text: self.controlChannelStatusText, symbolName: "wifi.slash", width: width),
at: cursor)
if let gatewayEntry = self.gatewayEntry() {
let gatewayItem = self.makeNodeItem(entry: gatewayEntry, width: width)
menu.insertItem(gatewayItem, at: cursor)
cursor += 1
let separator = NSMenuItem.separator()
separator.tag = self.nodesTag
menu.insertItem(separator, at: cursor)
return
}
guard self.isControlChannelConnected else { return }
if let error = self.nodesStore.lastError?.nonEmpty {
menu.insertItem(
self.makeMessageItem(
@@ -229,15 +220,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
cursor += 1
} else {
for entry in entries.prefix(8) {
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)
let item = self.makeNodeItem(entry: entry, width: width)
menu.insertItem(item, at: cursor)
cursor += 1
}
@@ -265,27 +248,56 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
return false
}
private var controlChannelStatusText: String {
switch ControlChannel.shared.state {
case .connected:
return "Connected"
case .connecting:
return "Connecting to gateway…"
case let .degraded(reason):
if self.shouldShowConnecting { return "Connecting to gateway…" }
return reason.nonEmpty ?? "No connection to gateway"
case .disconnected:
return self.shouldShowConnecting ? "Connecting to gateway…" : "No connection to gateway"
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 var shouldShowConnecting: Bool {
switch GatewayProcessManager.shared.status {
case .starting, .running, .attachedExisting:
return true
case .stopped, .failed:
return false
}
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 makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
@@ -293,8 +305,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
Label(text, systemImage: symbolName)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.multilineTextAlignment(.leading)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 18)
.padding(.trailing, 12)
.padding(.vertical, 6)

View File

@@ -2,15 +2,30 @@ import AppKit
import SwiftUI
struct NodeMenuEntryFormatter {
static func isGateway(_ entry: NodeInfo) -> Bool {
entry.nodeId == "gateway"
}
static func isConnected(_ entry: NodeInfo) -> Bool {
entry.isConnected
}
static func primaryName(_ entry: NodeInfo) -> String {
entry.displayName?.nonEmpty ?? entry.nodeId
if self.isGateway(entry) {
return entry.displayName?.nonEmpty ?? "Gateway"
}
return entry.displayName?.nonEmpty ?? entry.nodeId
}
static func summaryText(_ entry: NodeInfo) -> String {
if self.isGateway(entry) {
let role = self.roleText(entry)
let name = self.primaryName(entry)
var parts = ["\(name) · \(role)"]
if let ip = entry.remoteIp?.nonEmpty { parts.append("host \(ip)") }
if let platform = self.platformText(entry) { parts.append(platform) }
return parts.joined(separator: " · ")
}
let name = self.primaryName(entry)
var prefix = "Node: \(name)"
if let ip = entry.remoteIp?.nonEmpty {
@@ -112,6 +127,11 @@ struct NodeMenuEntryFormatter {
}
static func leadingSymbol(_ entry: NodeInfo) -> String {
if self.isGateway(entry) {
return self.safeSystemSymbol(
"antenna.radiowaves.left.and.right",
fallback: "dot.radiowaves.left.and.right")
}
if let family = entry.deviceFamily?.lowercased() {
if family.contains("mac") {
return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer")

View File

@@ -75,10 +75,26 @@ final class NodesStore {
self.lastError = nil
self.statusMessage = nil
} catch {
if Self.isCancelled(error) {
self.logger.debug("node.list cancelled; keeping last nodes")
if self.nodes.isEmpty {
self.statusMessage = "Refreshing devices…"
}
self.lastError = nil
return
}
self.logger.error("node.list failed \(error.localizedDescription, privacy: .public)")
self.nodes = []
self.lastError = error.localizedDescription
self.statusMessage = nil
}
}
private static func isCancelled(_ error: Error) -> Bool {
if error is CancellationError { return true }
if let urlError = error as? URLError, urlError.code == .cancelled { return true }
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return true }
return false
}
}

View File

@@ -31,6 +31,8 @@ enum PermissionManager {
await self.ensureMicrophone(interactive: interactive)
case .speechRecognition:
await self.ensureSpeechRecognition(interactive: interactive)
case .camera:
await self.ensureCamera(interactive: interactive)
}
}
@@ -114,6 +116,24 @@ enum PermissionManager {
return SFSpeechRecognizer.authorizationStatus() == .authorized
}
private static func ensureCamera(interactive: Bool) async -> Bool {
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .authorized:
return true
case .notDetermined:
guard interactive else { return false }
return await AVCaptureDevice.requestAccess(for: .video)
case .denied, .restricted:
if interactive {
CameraPermissionHelper.openSettings()
}
return false
@unknown default:
return false
}
}
static func voiceWakePermissionsGranted() -> Bool {
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
@@ -153,6 +173,9 @@ enum PermissionManager {
case .speechRecognition:
results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized
case .camera:
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
}
}
return results
@@ -189,6 +212,21 @@ enum MicrophonePermissionHelper {
}
}
enum CameraPermissionHelper {
static func openSettings() {
let candidates = [
"x-apple.systempreferences:com.apple.preference.security?Privacy_Camera",
"x-apple.systempreferences:com.apple.preference.security",
]
for candidate in candidates {
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
return
}
}
}
}
enum AppleScriptPermission {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "AppleScriptPermission")

View File

@@ -120,6 +120,7 @@ struct PermissionRow: View {
case .screenRecording: "Screen Recording"
case .microphone: "Microphone"
case .speechRecognition: "Speech Recognition"
case .camera: "Camera"
}
}
@@ -132,6 +133,7 @@ struct PermissionRow: View {
case .screenRecording: "Capture the screen for context or screenshots"
case .microphone: "Allow Voice Wake and audio capture"
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
case .camera: "Capture photos and video from the camera"
}
}
@@ -143,6 +145,7 @@ struct PermissionRow: View {
case .screenRecording: "display"
case .microphone: "mic"
case .speechRecognition: "waveform"
case .camera: "camera"
}
}
}

View File

@@ -8,6 +8,11 @@ struct GatewaySessionDefaultsRecord: Codable {
struct GatewaySessionEntryRecord: Codable {
let key: String
let displayName: String?
let surface: String?
let subject: String?
let room: String?
let space: String?
let updatedAt: Double?
let sessionId: String?
let systemSent: Bool?
@@ -65,6 +70,11 @@ struct SessionRow: Identifiable {
let id: String
let key: String
let kind: SessionKind
let displayName: String?
let surface: String?
let subject: String?
let room: String?
let space: String?
let updatedAt: Date?
let sessionId: String?
let thinkingLevel: String?
@@ -75,6 +85,7 @@ struct SessionRow: Identifiable {
let model: String?
var ageText: String { relativeAge(from: self.updatedAt) }
var label: String { self.displayName ?? self.key }
var flagLabels: [String] {
var flags: [String] = []
@@ -92,6 +103,8 @@ enum SessionKind {
static func from(key: String) -> SessionKind {
if key == "global" { return .global }
if key.hasPrefix("group:") { return .group }
if key.contains(":group:") { return .group }
if key.contains(":channel:") { return .group }
if key == "unknown" { return .unknown }
return .direct
}
@@ -127,6 +140,11 @@ extension SessionRow {
id: "direct-1",
key: "user@example.com",
kind: .direct,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: Date().addingTimeInterval(-90),
sessionId: "sess-direct-1234",
thinkingLevel: "low",
@@ -137,8 +155,13 @@ extension SessionRow {
model: "claude-3.5-sonnet"),
SessionRow(
id: "group-1",
key: "group:engineering",
key: "discord:channel:release-squad",
kind: .group,
displayName: "discord:#release-squad",
surface: "discord",
subject: nil,
room: "#release-squad",
space: nil,
updatedAt: Date().addingTimeInterval(-3600),
sessionId: "sess-group-4321",
thinkingLevel: "medium",
@@ -151,6 +174,11 @@ extension SessionRow {
id: "global",
key: "global",
kind: .global,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: Date().addingTimeInterval(-86400),
sessionId: nil,
thinkingLevel: nil,
@@ -269,6 +297,11 @@ enum SessionLoader {
id: entry.key,
key: entry.key,
kind: SessionKind.from(key: entry.key),
displayName: entry.displayName,
surface: entry.surface,
subject: entry.subject,
room: entry.room,
space: entry.space,
updatedAt: updated,
sessionId: entry.sessionId,
thinkingLevel: entry.thinkingLevel,

View File

@@ -36,7 +36,7 @@ struct SessionMenuLabelView: View {
height: self.barHeight)
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(self.row.key)
Text(self.row.label)
.font(.caption.weight(self.row.key == "main" ? .semibold : .regular))
.foregroundStyle(self.primaryTextColor)
.lineLimit(1)

View File

@@ -89,7 +89,7 @@ struct SessionsSettings: View {
private func sessionRow(_ row: SessionRow) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(row.key)
Text(row.label)
.font(.subheadline.bold())
.lineLimit(1)
.truncationMode(.middle)

View File

@@ -11,6 +11,7 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
case screenRecording
case microphone
case speechRecognition
case camera
}
public enum CameraFacing: String, Codable, Sendable {