From 9dee4c158dff536d8c176c106c2569bebe460535 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Dec 2025 04:29:34 +0100 Subject: [PATCH] chore(instances): log empty payloads and add local fallback --- apps/macos/Sources/Clawdis/AgentRPC.swift | 12 +- .../macos/Sources/Clawdis/DebugSettings.swift | 5 +- .../Sources/Clawdis/InstancesStore.swift | 98 +++++++++++++- apps/macos/Sources/Clawdis/Utilities.swift | 37 +++++- .../Sources/Clawdis/VoiceWakeOverlay.swift | 120 ++++++++++++------ .../macos/Sources/Clawdis/WebChatWindow.swift | 2 +- 6 files changed, 218 insertions(+), 56 deletions(-) diff --git a/apps/macos/Sources/Clawdis/AgentRPC.swift b/apps/macos/Sources/Clawdis/AgentRPC.swift index 96ae93962..7805b60e8 100644 --- a/apps/macos/Sources/Clawdis/AgentRPC.swift +++ b/apps/macos/Sources/Clawdis/AgentRPC.swift @@ -350,11 +350,13 @@ actor AgentRPC { return true } if parsed.ok { - let payloadData: Data = if let payload = parsed.payload { - (try? JSONEncoder().encode(payload)) ?? Data() - } else { - Data() - } + let payloadData: Data = { + if let payload = parsed.payload { + return (try? JSONEncoder().encode(payload)) ?? Data() + } + // Use an empty JSON array to keep callers happy when payload is missing. + return Data("[]".utf8) + }() waiter.resume(returning: payloadData) } else { waiter.resume(throwing: RpcError(message: parsed.error ?? "control error")) diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index 402325682..13da3f778 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -291,8 +291,7 @@ struct DebugSettings: View { 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 config = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } - let trimmedTarget = config.target.trimmingCharacters(in: .whitespacesAndNewlines) - let shouldForward = config.enabled && !trimmedTarget.isEmpty + let shouldForward = config.enabled if shouldForward { let result = await VoiceWakeForwarder.forward(transcript: message, config: config) @@ -300,7 +299,7 @@ struct DebugSettings: View { self.debugSendInFlight = false switch result { case .success: - self.debugSendStatus = "Forwarded via \(trimmedTarget). Await reply." + self.debugSendStatus = "Forwarded. Await reply." self.debugSendError = nil case let .failure(error): let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/macos/Sources/Clawdis/InstancesStore.swift b/apps/macos/Sources/Clawdis/InstancesStore.swift index 22832a80f..438c88c17 100644 --- a/apps/macos/Sources/Clawdis/InstancesStore.swift +++ b/apps/macos/Sources/Clawdis/InstancesStore.swift @@ -1,3 +1,4 @@ +import Cocoa import Foundation import OSLog @@ -58,6 +59,13 @@ final class InstancesStore: ObservableObject { defer { self.isLoading = false } do { 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()] + self.lastError = "No presence data returned from relay yet." + return + } let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data) let withIDs = decoded.map { entry -> InstanceInfo in let key = entry.host ?? entry.ip ?? entry.text @@ -72,11 +80,93 @@ final class InstancesStore: ObservableObject { text: entry.text, ts: entry.ts) } - self.instances = withIDs - self.lastError = nil + if withIDs.isEmpty { + self.instances = [self.localFallbackInstance()] + self.lastError = nil + } else { + self.instances = withIDs + self.lastError = nil + } } catch { - self.logger.error("instances fetch failed: \(error.localizedDescription, privacy: .public)") - self.lastError = error.localizedDescription + 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()] + self.lastError = "Decode failed: \(error.localizedDescription)" } } + + private func localFallbackInstance() -> InstanceInfo { + let host = Host.current().localizedName ?? "this-mac" + let ip = Self.primaryIPv4Address() + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + 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, + lastInputSeconds: Self.lastInputSeconds(), + mode: "local", + reason: "fallback", + 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? = nil + 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 ip = String(decoding: len.map { UInt8(bitPattern: $0) }, as: UTF8.self) + + 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 "" } + if data.isEmpty { return "" } + 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>" + } } diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index ccf0baff1..6a160d34a 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -379,7 +379,7 @@ enum CommandResolver { private static func sshNodeCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? { guard !settings.target.isEmpty else { return nil } - guard let parsed = VoiceWakeForwarder.parse(target: settings.target) else { return nil } + guard let parsed = self.parseSSHTarget(settings.target) else { return nil } var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"] if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } @@ -450,7 +450,7 @@ enum CommandResolver { private static func sshMacHelperCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? { guard !settings.target.isEmpty else { return nil } - guard let parsed = VoiceWakeForwarder.parse(target: settings.target) else { return nil } + guard let parsed = self.parseSSHTarget(settings.target) else { return nil } var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"] if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) } @@ -508,6 +508,39 @@ enum CommandResolver { 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[.. String { if text.isEmpty { return "''" } let escaped = text.replacingOccurrences(of: "'", with: "'\\''") diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift index f4fb8f487..abd0d0022 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift @@ -10,6 +10,8 @@ final class VoiceWakeOverlayController: ObservableObject { private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.overlay") + enum Source: String { case wakeWord, pushToTalk } + @Published private(set) var model = Model() struct Model { @@ -27,8 +29,10 @@ final class VoiceWakeOverlayController: ObservableObject { private var window: NSPanel? private var hostingView: NSHostingView? private var autoSendTask: Task? - private var safetyDismissTask: Task? + private var autoSendToken: UUID? private var forwardConfig: VoiceWakeForwardConfig? + private var activeToken: UUID? + private var activeSource: Source? private let width: CGFloat = 360 private let padding: CGFloat = 10 @@ -39,10 +43,41 @@ final class VoiceWakeOverlayController: ObservableObject { private let minHeight: CGFloat = 48 let closeOverflow: CGFloat = 10 - func showPartial(transcript: String, attributed: NSAttributedString? = nil) { - self.logger.log(level: .info, "overlay showPartial len=\(transcript.count, privacy: .public) visible=\(self.model.isVisible, privacy: .public) isFinal=false") - self.autoSendTask?.cancel() - self.safetyDismissTask?.cancel() + @discardableResult + func startSession( + source: Source, + transcript: String, + attributed: NSAttributedString? = nil, + forwardEnabled: Bool = false, + isFinal: Bool = false) -> UUID + { + let token = UUID() + self.logger.log(level: .info, "overlay session_start source=\(source.rawValue, privacy: .public) len=\(transcript.count, privacy: .public)") + self.activeToken = token + self.activeSource = source + self.forwardConfig = nil + self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil + self.model.text = transcript + self.model.isFinal = isFinal + self.model.forwardEnabled = forwardEnabled + self.model.isSending = false + self.model.isEditing = false + self.model.attributed = attributed ?? self.makeAttributed(from: transcript) + self.model.level = 0 + self.present() + self.updateWindowFrame(animate: true) + return token + } + + func snapshot() -> (token: UUID?, source: Source?, text: String, isVisible: Bool) { + (self.activeToken, self.activeSource, self.model.text, self.model.isVisible) + } + + func updatePartial(token: UUID, transcript: String, attributed: NSAttributedString? = nil) { + guard self.guardToken(token, context: "partial") else { return } + guard !self.model.isFinal else { return } + self.logger.log(level: .info, "overlay partial token=\(token.uuidString, privacy: .public) len=\(transcript.count, privacy: .public)") + self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil self.forwardConfig = nil self.model.text = transcript self.model.isFinal = false @@ -56,15 +91,17 @@ final class VoiceWakeOverlayController: ObservableObject { } func presentFinal( + token: UUID, transcript: String, forwardConfig: VoiceWakeForwardConfig, autoSendAfter delay: TimeInterval?, sendChime: VoiceWakeChime = .none, attributed: NSAttributedString? = nil) { - self.logger.log(level: .info, "overlay presentFinal len=\(transcript.count, privacy: .public) autoSendAfter=\(delay ?? -1, privacy: .public) forwardEnabled=\(forwardConfig.enabled, privacy: .public)") + guard self.guardToken(token, context: "final") else { return } + self.logger.log(level: .info, "overlay presentFinal token=\(token.uuidString, privacy: .public) len=\(transcript.count, privacy: .public) autoSendAfter=\(delay ?? -1, privacy: .public) forwardEnabled=\(forwardConfig.enabled, privacy: .public)") self.autoSendTask?.cancel() - self.safetyDismissTask?.cancel() + self.autoSendToken = token self.forwardConfig = forwardConfig self.model.text = transcript self.model.isFinal = true @@ -75,10 +112,8 @@ final class VoiceWakeOverlayController: ObservableObject { self.model.level = 0 self.present() if let delay { - self.scheduleAutoSend(after: delay, sendChime: sendChime) + self.scheduleAutoSend(token: token, after: delay, sendChime: sendChime) } - // Safety net: ensure the overlay cannot stick around indefinitely. - self.scheduleSafetyDismiss() } func userBeganEditing() { @@ -105,11 +140,10 @@ final class VoiceWakeOverlayController: ObservableObject { self.updateWindowFrame(animate: true) } - func sendNow(sendChime: VoiceWakeChime = .none) { - self.logger.log(level: .info, "overlay sendNow called isSending=\(self.model.isSending, privacy: .public) forwardEnabled=\(self.model.forwardEnabled, privacy: .public) textLen=\(self.model.text.count, privacy: .public)") - self.autoSendTask?.cancel() - self.autoSendTask = nil - self.safetyDismissTask?.cancel() + func sendNow(token: UUID? = nil, sendChime: VoiceWakeChime = .none) { + guard self.guardToken(token, context: "send") else { return } + self.logger.log(level: .info, "overlay sendNow called token=\(self.activeToken?.uuidString ?? "nil", privacy: .public) isSending=\(self.model.isSending, privacy: .public) forwardEnabled=\(self.model.forwardEnabled, privacy: .public) textLen=\(self.model.text.count, privacy: .public)") + self.autoSendTask?.cancel(); self.autoSendToken = nil if self.model.isSending { return } self.model.isEditing = false guard let forwardConfig, forwardConfig.enabled else { @@ -126,7 +160,7 @@ final class VoiceWakeOverlayController: ObservableObject { if sendChime != .none { self.logger.log(level: .info, "overlay sendNow playing sendChime=\(String(describing: sendChime), privacy: .public)") - VoiceWakeChimePlayer.play(sendChime) + VoiceWakeChimePlayer.play(sendChime, reason: "overlay.send") } self.model.isSending = true @@ -136,14 +170,14 @@ final class VoiceWakeOverlayController: ObservableObject { await VoiceWakeForwarder.forward(transcript: payload, config: forwardConfig) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) { - self.dismiss(reason: .explicit, outcome: .sent) + self.dismiss(token: token, reason: .explicit, outcome: .sent) } } - func dismiss(reason: DismissReason = .explicit, outcome: SendOutcome = .empty) { - self.logger.log(level: .info, "overlay dismiss reason=\(String(describing: reason), privacy: .public) outcome=\(String(describing: outcome), privacy: .public) visible=\(self.model.isVisible, privacy: .public) sending=\(self.model.isSending, privacy: .public)") - self.autoSendTask?.cancel() - self.safetyDismissTask?.cancel() + func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) { + guard self.guardToken(token, context: "dismiss") else { return } + self.logger.log(level: .info, "overlay dismiss token=\(self.activeToken?.uuidString ?? "nil", privacy: .public) reason=\(String(describing: reason), privacy: .public) outcome=\(String(describing: outcome), privacy: .public) visible=\(self.model.isVisible, privacy: .public) sending=\(self.model.isSending, privacy: .public)") + self.autoSendTask?.cancel(); self.autoSendToken = nil self.model.isSending = false self.model.isEditing = false guard let window else { return } @@ -160,6 +194,9 @@ final class VoiceWakeOverlayController: ObservableObject { window.orderOut(nil) self.model.isVisible = false self.model.level = 0 + self.activeToken = nil + self.activeSource = nil + self.forwardConfig = nil if outcome == .empty { AppStateStore.shared.blinkOnce() } else if outcome == .sent { @@ -170,7 +207,8 @@ final class VoiceWakeOverlayController: ObservableObject { } } - func updateLevel(_ level: Double) { + func updateLevel(token: UUID, _ level: Double) { + guard self.guardToken(token, context: "level") else { return } self.model.level = max(0, min(1, level)) } @@ -179,6 +217,18 @@ final class VoiceWakeOverlayController: ObservableObject { // MARK: - Private + private func guardToken(_ token: UUID?, context: String) -> Bool { + guard let active = self.activeToken else { + self.logger.debug("overlay drop \(context, privacy: .public) no_active") + return false + } + if let token, token != active { + self.logger.debug("overlay drop \(context, privacy: .public) token_mismatch") + return false + } + return true + } + private func present() { self.ensureWindow() self.hostingView?.rootView = VoiceWakeOverlayView(controller: self) @@ -299,36 +349,24 @@ final class VoiceWakeOverlayController: ObservableObject { } } - private func scheduleAutoSend(after delay: TimeInterval, sendChime: VoiceWakeChime) { - self.logger.log(level: .info, "overlay scheduleAutoSend after=\(delay, privacy: .public) sendChime=\(String(describing: sendChime), privacy: .public)") + private func scheduleAutoSend(token: UUID, after delay: TimeInterval, sendChime: VoiceWakeChime) { + self.logger.log(level: .info, "overlay scheduleAutoSend token=\(token.uuidString, privacy: .public) after=\(delay, privacy: .public) sendChime=\(String(describing: sendChime), privacy: .public)") self.autoSendTask?.cancel() - self.autoSendTask = Task { [weak self, sendChime] in + self.autoSendToken = token + self.autoSendTask = Task { [weak self, sendChime, token] in let nanos = UInt64(max(0, delay) * 1_000_000_000) try? await Task.sleep(nanoseconds: nanos) guard !Task.isCancelled else { return } await MainActor.run { guard let self else { return } - self.logger.log(level: .info, "overlay autoSend firing") - self.sendNow(sendChime: sendChime) + guard self.guardToken(token, context: "autoSend") else { return } + self.logger.log(level: .info, "overlay autoSend firing token=\(token.uuidString, privacy: .public)") + self.sendNow(token: token, sendChime: sendChime) self.autoSendTask = nil } } } - private func scheduleSafetyDismiss() { - self.safetyDismissTask?.cancel() - self.safetyDismissTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 6_000_000_000) // 6s - guard !Task.isCancelled else { return } - await MainActor.run { - guard let self, self.model.isVisible else { return } - self.logger.log(level: .info, "overlay safety dismiss firing") - self.dismiss(reason: .explicit) - self.safetyDismissTask = nil - } - } - } - private func makeAttributed(from text: String) -> NSAttributedString { NSAttributedString( string: text, diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index a46a5c9b1..6e4327562 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -234,7 +234,7 @@ final class WebChatTunnel { static func create(remotePort: Int, preferredLocalPort: UInt16? = nil) async throws -> WebChatTunnel { let settings = CommandResolver.connectionSettings() - guard settings.mode == .remote, let parsed = VoiceWakeForwarder.parse(target: settings.target) else { + guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else { throw NSError(domain: "WebChat", code: 3, userInfo: [NSLocalizedDescriptionKey: "remote not configured"]) }