From c5b073702c427853ff16edfb472f1d679cc4978d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Dec 2025 22:04:02 +0100 Subject: [PATCH] macos: control channel diagnostics and tunnel-based testing --- .../Sources/Clawdis/ControlChannel.swift | 41 ++++++++++++++ .../Sources/Clawdis/GeneralSettings.swift | 55 ++++++++++++++----- .../Sources/Clawdis/HeartbeatStore.swift | 8 +++ apps/macos/Sources/Clawdis/MenuBar.swift | 6 +- .../Sources/Clawdis/VoiceWakeChime.swift | 2 +- .../Sources/Clawdis/VoiceWakeRuntime.swift | 5 +- 6 files changed, 100 insertions(+), 17 deletions(-) diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 07859d5a9..d1d71fc60 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -74,6 +74,13 @@ final class ControlChannel: ObservableObject { case remote(target: String, identity: String) } + enum ConnectionState: Equatable { + case disconnected + case connecting + case connected + case degraded(String) + } + private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control") private var connection: NWConnection? private var sshProcess: Process? @@ -82,6 +89,10 @@ final class ControlChannel: ObservableObject { private var listenTask: Task? private var mode: Mode = .local private var localPort: UInt16 = 18789 + private var pingTask: Task? + + @Published private(set) var state: ConnectionState = .disconnected + @Published private(set) var lastPingMs: Double? func configure(mode: Mode) async throws { if mode == self.mode, self.connection != nil { return } @@ -93,6 +104,8 @@ final class ControlChannel: ObservableObject { func disconnect() async { self.listenTask?.cancel() self.listenTask = nil + self.pingTask?.cancel() + self.pingTask = nil if let conn = self.connection { conn.cancel() } @@ -103,6 +116,7 @@ final class ControlChannel: ObservableObject { cont.resume(throwing: ControlChannelError.disconnected) } self.pending.removeAll() + self.state = .disconnected } func health(timeout: TimeInterval? = nil) async throws -> Data { @@ -111,6 +125,13 @@ final class ControlChannel: ObservableObject { return payload } + func lastHeartbeat() async throws -> ControlHeartbeatEvent? { + try await self.ensureConnected() + let data = try await self.request(method: "last-heartbeat") + if data.isEmpty { return nil } + return try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) + } + private func request(method: String, params: [String: Any]? = nil) async throws -> Data { try await self.ensureConnected() let id = UUID().uuidString @@ -141,6 +162,8 @@ final class ControlChannel: ObservableObject { self.localPort = try self.startSSHTunnel(target: target, identity: identity) } + self.state = .connecting + let host = NWEndpoint.Host("127.0.0.1") let port = NWEndpoint.Port(rawValue: self.localPort)! let conn = NWConnection(host: host, port: port, using: .tcp) @@ -151,9 +174,12 @@ final class ControlChannel: ObservableObject { switch state { case .ready: cont.resume(returning: ()) + Task { @MainActor in self.state = .connected } case let .failed(err): + Task { @MainActor in self.state = .degraded(err.localizedDescription) } cont.resume(throwing: err) case let .waiting(err): + Task { @MainActor in self.state = .degraded(err.localizedDescription) } cont.resume(throwing: err) default: break @@ -165,6 +191,21 @@ final class ControlChannel: ObservableObject { self.listenTask = Task.detached { [weak self] in await self?.listen() } + + self.pingTask = Task.detached { [weak self] in + guard let self else { return } + while !Task.isCancelled { + do { + try await Task.sleep(nanoseconds: 30 * 1_000_000_000) + let start = Date() + _ = try await self.request(method: "ping") + let ms = Date().timeIntervalSince(start) * 1000 + await MainActor.run { self.lastPingMs = ms; self.state = .connected } + } catch { + await MainActor.run { self.state = .degraded(error.localizedDescription) } + } + } + } } private func startSSHTunnel(target: String, identity: String) throws -> UInt16 { diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index 53c3dec1a..12ee8d809 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -135,31 +135,60 @@ struct GeneralSettings: View { .disabled(self.remoteStatus == .checking || self.state.remoteTarget .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - switch self.remoteStatus { - case .idle: - EmptyView() - case .checking: - Text("Checking…").font(.caption).foregroundStyle(.secondary) + switch self.remoteStatus { + case .idle: + EmptyView() + case .checking: + Text("Checking…").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) - } + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) } + } - Text("Tip: enable Tailscale for stable remote access.") - .font(.footnote) + // Diagnostics + VStack(alignment: .leading, spacing: 4) { + Text("Control channel") + .font(.caption.weight(.semibold)) + Text(self.controlStatusLine) + .font(.caption) .foregroundStyle(.secondary) - .lineLimit(1) + if let ping = ControlChannel.shared.lastPingMs { + Text("Last ping: \(Int(ping)) ms") + .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) } + private var controlStatusLine: String { + switch ControlChannel.shared.state { + case .connected: return "Connected" + case .connecting: return "Connecting…" + case .disconnected: return "Disconnected" + case let .degraded(msg): return "Degraded: \(msg)" + } + } + private var cliInstaller: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { diff --git a/apps/macos/Sources/Clawdis/HeartbeatStore.swift b/apps/macos/Sources/Clawdis/HeartbeatStore.swift index 84d00891b..382136e0d 100644 --- a/apps/macos/Sources/Clawdis/HeartbeatStore.swift +++ b/apps/macos/Sources/Clawdis/HeartbeatStore.swift @@ -19,6 +19,14 @@ final class HeartbeatStore: ObservableObject { Task { @MainActor in self?.lastEvent = decoded } } } + + Task { + if self.lastEvent == nil { + if let evt = try? await ControlChannel.shared.lastHeartbeat() { + self.lastEvent = evt + } + } + } } @MainActor diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 3b786abf2..52f6ce21e 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -58,6 +58,7 @@ private struct MenuContent: View { @ObservedObject private var relayManager = RelayProcessManager.shared @ObservedObject private var healthStore = HealthStore.shared @ObservedObject private var heartbeatStore = HeartbeatStore.shared + @ObservedObject private var controlChannel = ControlChannel.shared @Environment(\.openSettings) private var openSettings @State private var availableMics: [AudioInputDevice] = [] @State private var loadingMics = false @@ -176,7 +177,10 @@ private struct MenuContent: View { let label: String let color: Color - if let evt = self.heartbeatStore.lastEvent { + if case .degraded = self.controlChannel.state { + label = "Control channel disconnected" + color = .red + } else if let evt = self.heartbeatStore.lastEvent { let ageText = age(from: Date(timeIntervalSince1970: evt.ts / 1000)) switch evt.status { case "sent": diff --git a/apps/macos/Sources/Clawdis/VoiceWakeChime.swift b/apps/macos/Sources/Clawdis/VoiceWakeChime.swift index a0e661076..de57ecdd4 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeChime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeChime.swift @@ -1,7 +1,7 @@ import AppKit import Foundation -enum VoiceWakeChime: Codable, Equatable { +enum VoiceWakeChime: Codable, Equatable, Sendable { case none case system(name: String) case custom(displayName: String, bookmark: Data) diff --git a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift index 7b34ec869..3153c74a5 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift @@ -119,7 +119,7 @@ actor VoiceWakeRuntime { } } - private func stop() { + private func stop(dismissOverlay: Bool = true) { self.captureTask?.cancel() self.captureTask = nil self.isCapturing = false @@ -135,6 +135,7 @@ actor VoiceWakeRuntime { self.currentConfig = nil self.logger.debug("voicewake runtime stopped") + guard dismissOverlay else { return } Task { @MainActor in VoiceWakeOverlayController.shared.dismiss() } @@ -298,7 +299,7 @@ actor VoiceWakeRuntime { private func restartRecognizer() { // Restart the recognizer so we listen for the next trigger with a clean buffer. let current = self.currentConfig - self.stop() + self.stop(dismissOverlay: false) if let current { Task { await self.start(with: current) } }