chore: merge origin/main
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user