macos: control channel diagnostics and tunnel-based testing

This commit is contained in:
Peter Steinberger
2025-12-08 22:04:02 +01:00
parent e38bdd0d2d
commit c5b073702c
6 changed files with 100 additions and 17 deletions

View File

@@ -74,6 +74,13 @@ final class ControlChannel: ObservableObject {
case remote(target: String, identity: String) 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 let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
private var connection: NWConnection? private var connection: NWConnection?
private var sshProcess: Process? private var sshProcess: Process?
@@ -82,6 +89,10 @@ final class ControlChannel: ObservableObject {
private var listenTask: Task<Void, Never>? private var listenTask: Task<Void, Never>?
private var mode: Mode = .local private var mode: Mode = .local
private var localPort: UInt16 = 18789 private var localPort: UInt16 = 18789
private var pingTask: Task<Void, Never>?
@Published private(set) var state: ConnectionState = .disconnected
@Published private(set) var lastPingMs: Double?
func configure(mode: Mode) async throws { func configure(mode: Mode) async throws {
if mode == self.mode, self.connection != nil { return } if mode == self.mode, self.connection != nil { return }
@@ -93,6 +104,8 @@ final class ControlChannel: ObservableObject {
func disconnect() async { func disconnect() async {
self.listenTask?.cancel() self.listenTask?.cancel()
self.listenTask = nil self.listenTask = nil
self.pingTask?.cancel()
self.pingTask = nil
if let conn = self.connection { if let conn = self.connection {
conn.cancel() conn.cancel()
} }
@@ -103,6 +116,7 @@ final class ControlChannel: ObservableObject {
cont.resume(throwing: ControlChannelError.disconnected) cont.resume(throwing: ControlChannelError.disconnected)
} }
self.pending.removeAll() self.pending.removeAll()
self.state = .disconnected
} }
func health(timeout: TimeInterval? = nil) async throws -> Data { func health(timeout: TimeInterval? = nil) async throws -> Data {
@@ -111,6 +125,13 @@ final class ControlChannel: ObservableObject {
return payload 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 { private func request(method: String, params: [String: Any]? = nil) async throws -> Data {
try await self.ensureConnected() try await self.ensureConnected()
let id = UUID().uuidString let id = UUID().uuidString
@@ -141,6 +162,8 @@ final class ControlChannel: ObservableObject {
self.localPort = try self.startSSHTunnel(target: target, identity: identity) self.localPort = try self.startSSHTunnel(target: target, identity: identity)
} }
self.state = .connecting
let host = NWEndpoint.Host("127.0.0.1") let host = NWEndpoint.Host("127.0.0.1")
let port = NWEndpoint.Port(rawValue: self.localPort)! let port = NWEndpoint.Port(rawValue: self.localPort)!
let conn = NWConnection(host: host, port: port, using: .tcp) let conn = NWConnection(host: host, port: port, using: .tcp)
@@ -151,9 +174,12 @@ final class ControlChannel: ObservableObject {
switch state { switch state {
case .ready: case .ready:
cont.resume(returning: ()) cont.resume(returning: ())
Task { @MainActor in self.state = .connected }
case let .failed(err): case let .failed(err):
Task { @MainActor in self.state = .degraded(err.localizedDescription) }
cont.resume(throwing: err) cont.resume(throwing: err)
case let .waiting(err): case let .waiting(err):
Task { @MainActor in self.state = .degraded(err.localizedDescription) }
cont.resume(throwing: err) cont.resume(throwing: err)
default: default:
break break
@@ -165,6 +191,21 @@ final class ControlChannel: ObservableObject {
self.listenTask = Task.detached { [weak self] in self.listenTask = Task.detached { [weak self] in
await self?.listen() 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 { private func startSSHTunnel(target: String, identity: String) throws -> UInt16 {

View File

@@ -135,31 +135,60 @@ struct GeneralSettings: View {
.disabled(self.remoteStatus == .checking || self.state.remoteTarget .disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
switch self.remoteStatus { switch self.remoteStatus {
case .idle: case .idle:
EmptyView() EmptyView()
case .checking: case .checking:
Text("Checking…").font(.caption).foregroundStyle(.secondary) Text("Checking…").font(.caption).foregroundStyle(.secondary)
case .ok: case .ok:
Label("Ready", systemImage: "checkmark.circle.fill") Label("Ready", systemImage: "checkmark.circle.fill")
.font(.caption) .font(.caption)
.foregroundStyle(.green) .foregroundStyle(.green)
case let .failed(message): case let .failed(message):
Text(message) Text(message)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2) .lineLimit(2)
}
} }
}
Text("Tip: enable Tailscale for stable remote access.") // Diagnostics
.font(.footnote) VStack(alignment: .leading, spacing: 4) {
Text("Control channel")
.font(.caption.weight(.semibold))
Text(self.controlStatusLine)
.font(.caption)
.foregroundStyle(.secondary) .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) .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 { private var cliInstaller: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) { HStack(spacing: 10) {

View File

@@ -19,6 +19,14 @@ final class HeartbeatStore: ObservableObject {
Task { @MainActor in self?.lastEvent = decoded } Task { @MainActor in self?.lastEvent = decoded }
} }
} }
Task {
if self.lastEvent == nil {
if let evt = try? await ControlChannel.shared.lastHeartbeat() {
self.lastEvent = evt
}
}
}
} }
@MainActor @MainActor

View File

@@ -58,6 +58,7 @@ private struct MenuContent: View {
@ObservedObject private var relayManager = RelayProcessManager.shared @ObservedObject private var relayManager = RelayProcessManager.shared
@ObservedObject private var healthStore = HealthStore.shared @ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var heartbeatStore = HeartbeatStore.shared @ObservedObject private var heartbeatStore = HeartbeatStore.shared
@ObservedObject private var controlChannel = ControlChannel.shared
@Environment(\.openSettings) private var openSettings @Environment(\.openSettings) private var openSettings
@State private var availableMics: [AudioInputDevice] = [] @State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false @State private var loadingMics = false
@@ -176,7 +177,10 @@ private struct MenuContent: View {
let label: String let label: String
let color: Color 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)) let ageText = age(from: Date(timeIntervalSince1970: evt.ts / 1000))
switch evt.status { switch evt.status {
case "sent": case "sent":

View File

@@ -1,7 +1,7 @@
import AppKit import AppKit
import Foundation import Foundation
enum VoiceWakeChime: Codable, Equatable { enum VoiceWakeChime: Codable, Equatable, Sendable {
case none case none
case system(name: String) case system(name: String)
case custom(displayName: String, bookmark: Data) case custom(displayName: String, bookmark: Data)

View File

@@ -119,7 +119,7 @@ actor VoiceWakeRuntime {
} }
} }
private func stop() { private func stop(dismissOverlay: Bool = true) {
self.captureTask?.cancel() self.captureTask?.cancel()
self.captureTask = nil self.captureTask = nil
self.isCapturing = false self.isCapturing = false
@@ -135,6 +135,7 @@ actor VoiceWakeRuntime {
self.currentConfig = nil self.currentConfig = nil
self.logger.debug("voicewake runtime stopped") self.logger.debug("voicewake runtime stopped")
guard dismissOverlay else { return }
Task { @MainActor in Task { @MainActor in
VoiceWakeOverlayController.shared.dismiss() VoiceWakeOverlayController.shared.dismiss()
} }
@@ -298,7 +299,7 @@ actor VoiceWakeRuntime {
private func restartRecognizer() { private func restartRecognizer() {
// Restart the recognizer so we listen for the next trigger with a clean buffer. // Restart the recognizer so we listen for the next trigger with a clean buffer.
let current = self.currentConfig let current = self.currentConfig
self.stop() self.stop(dismissOverlay: false)
if let current { if let current {
Task { await self.start(with: current) } Task { await self.start(with: current) }
} }