From 5674c9f4c2a4d93a6cb99d1a419a56958c92835e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Dec 2025 00:08:19 +0100 Subject: [PATCH] Mac: clarify runtime comments --- apps/macos/Sources/Clawdis/AgentRPC.swift | 3 ++- apps/macos/Sources/Clawdis/ControlChannel.swift | 5 ++++- apps/macos/Sources/Clawdis/MenuBar.swift | 1 + apps/macos/Sources/Clawdis/RelayProcessManager.swift | 2 ++ apps/macos/Sources/Clawdis/ShellRunner.swift | 1 + apps/macos/Sources/Clawdis/Utilities.swift | 1 + apps/macos/Sources/Clawdis/VoicePushToTalk.swift | 2 ++ apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift | 1 + apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift | 2 +- apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift | 5 ++++- apps/macos/Sources/Clawdis/WebChatServer.swift | 1 + apps/macos/Sources/Clawdis/WebChatWindow.swift | 1 + 12 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/macos/Sources/Clawdis/AgentRPC.swift b/apps/macos/Sources/Clawdis/AgentRPC.swift index a7ea517f4..30df9da16 100644 --- a/apps/macos/Sources/Clawdis/AgentRPC.swift +++ b/apps/macos/Sources/Clawdis/AgentRPC.swift @@ -162,6 +162,7 @@ actor AgentRPC { } Task.detached { [weak self] in + // Ensure all waiters are failed if the worker dies (e.g., crash or SIGTERM). process.waitUntilExit() await self?.stop() } @@ -188,7 +189,7 @@ actor AgentRPC { self.buffer.removeSubrange(self.buffer.startIndex...range.lowerBound) guard let line = String(data: lineData, encoding: .utf8) else { continue } - // Handle event envelopes (unsolicited) + // Event frames are pushed without request/response pairing (e.g., heartbeats). if let event = self.parseHeartbeatEvent(from: line) { DispatchQueue.main.async { NotificationCenter.default.post(name: Self.heartbeatNotification, object: event) diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 3e55a796c..b24043c36 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -20,6 +20,8 @@ actor ConnectionWaiter { private var pendingResult: Result? func wait() async throws { + // Acts like a one-shot Future; if the connection resolves before wait() is called, + // stash the result so the waiter resumes immediately. try await withCheckedThrowingContinuation { (c: CheckedContinuation) in if let pending = pendingResult { pendingResult = nil @@ -292,7 +294,7 @@ final class ControlChannel: ObservableObject { proc.standardOutput = outPipe proc.standardError = errPipe try proc.run() - // Give ssh a brief moment; if it exits immediately, surface the error. + // Give ssh a brief moment; if it exits immediately we surface stderr instead of silently failing. Thread.sleep(forTimeInterval: 0.2) // 200ms if !proc.isRunning { let err = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -381,6 +383,7 @@ final class ControlChannel: ObservableObject { getsockname(socket, withUnsafeMutablePointer(to: &addr) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { $0 } }, &len) + // Asking the kernel for port 0 yields an ephemeral free port; reuse it for the SSH tunnel. port = UInt16(bigEndian: addr.sin_port) return port } diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index b66c905bb..3e11890b4 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -654,6 +654,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate private var state: AppState? private let xpcLogger = Logger(subsystem: "com.steipete.clawdis", category: "xpc") private let webChatAutoLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat") + // Only clients signed with this team ID may talk to the XPC service (hard-fails if mismatched). private let allowedTeamIDs: Set = ["Y5PE65HELJ"] let updaterController: UpdaterProviding = makeUpdaterController() diff --git a/apps/macos/Sources/Clawdis/RelayProcessManager.swift b/apps/macos/Sources/Clawdis/RelayProcessManager.swift index 79d300c3c..c57235b03 100644 --- a/apps/macos/Sources/Clawdis/RelayProcessManager.swift +++ b/apps/macos/Sources/Clawdis/RelayProcessManager.swift @@ -97,6 +97,7 @@ final class RelayProcessManager: ObservableObject { workingDirectory: FilePath(cwd)) { execution, stdin, stdout, stderr in self.didStart(execution) + // Consume stdout/stderr eagerly so the relay can't block on full pipes. async let out: Void = self.stream(output: stdout, label: "stdout") async let err: Void = self.stream(output: stderr, label: "stderr") try await stdin.finish() @@ -143,6 +144,7 @@ final class RelayProcessManager: ObservableObject { self.status = .restarting self.logger.warning("relay crashed (code \(code)); restarting") + // Slight backoff to avoid hammering the system in case of immediate crash-on-start. try? await Task.sleep(nanoseconds: 750_000_000) self.startIfNeeded() } diff --git a/apps/macos/Sources/Clawdis/ShellRunner.swift b/apps/macos/Sources/Clawdis/ShellRunner.swift index bfeccd383..ff16b2321 100644 --- a/apps/macos/Sources/Clawdis/ShellRunner.swift +++ b/apps/macos/Sources/Clawdis/ShellRunner.swift @@ -41,6 +41,7 @@ enum ShellRunner { _ = await waitTask.value // drain pipes after termination return Response(ok: false, message: "timeout") } + // Whichever completes first (process exit or timeout) wins; cancel the other branch. let first = await group.next()! group.cancelAll() return first diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index 63e6ee9f9..7c2a52e99 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -257,6 +257,7 @@ enum CommandResolver { extras.insert(relay.appendingPathComponent("node_modules/.bin").path, at: 0) } var seen = Set() + // Preserve order while stripping duplicates so PATH lookups remain deterministic. return (extras + current).filter { seen.insert($0).inserted } } diff --git a/apps/macos/Sources/Clawdis/VoicePushToTalk.swift b/apps/macos/Sources/Clawdis/VoicePushToTalk.swift index dab1a602b..fd503004b 100644 --- a/apps/macos/Sources/Clawdis/VoicePushToTalk.swift +++ b/apps/macos/Sources/Clawdis/VoicePushToTalk.swift @@ -98,6 +98,7 @@ actor VoicePushToTalk { self.triggerChimePlayed = true await MainActor.run { VoiceWakeChimePlayer.play(config.triggerChime) } } + // Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap. await VoiceWakeRuntime.shared.pauseForPushToTalk() await MainActor.run { VoiceWakeOverlayController.shared.showPartial(transcript: "") @@ -189,6 +190,7 @@ actor VoicePushToTalk { } let transcript = result?.bestTranscription.formattedString let isFinal = result?.isFinal ?? false + // Hop to a Task so UI updates stay off the Speech callback thread. Task.detached { [weak self, transcript, isFinal] in guard let self else { return } await self.handle(transcript: transcript, isFinal: isFinal) diff --git a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift index dbff1200b..d49f42067 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeForwarder.swift @@ -290,6 +290,7 @@ enum VoiceWakeForwarder { let nanos = UInt64(max(timeout, 0.1) * 1_000_000_000) try? await Task.sleep(nanoseconds: nanos) if process.isRunning { + // SIGTERM is enough to stop ssh; keeps stdout/stderr readable for diagnostics. process.terminate() } } diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift index da94791fe..7c0608d3d 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift @@ -165,6 +165,7 @@ final class VoiceWakeOverlayController: ObservableObject { guard let window else { return } if !self.model.isVisible { self.model.isVisible = true + // Keep the status item in “listening” mode until we explicitly dismiss the overlay. AppStateStore.shared.triggerVoiceEars(ttl: nil) let start = target.offsetBy(dx: 0, dy: -6) window.setFrame(start, display: true) @@ -276,7 +277,6 @@ final class VoiceWakeOverlayController: ObservableObject { } private func scheduleAutoSend(after delay: TimeInterval, sendChime: VoiceWakeChime) { - guard let forwardConfig, forwardConfig.enabled else { return } self.autoSendTask?.cancel() self.autoSendTask = Task { [weak self, sendChime] in let nanos = UInt64(max(0, delay) * 1_000_000_000) diff --git a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift index f8ac48cdb..fb2cd4a33 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift @@ -125,7 +125,7 @@ actor VoiceWakeRuntime { self.currentConfig = config self.lastHeard = Date() - self.cooldownUntil = nil + // Preserve any existing cooldownUntil so the debounce after send isn't wiped by a restart. self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in guard let self else { return } @@ -253,6 +253,7 @@ actor VoiceWakeRuntime { VoiceWakeOverlayController.shared.showPartial(transcript: snapshot, attributed: attributed) } + // Keep the "ears" boosted for the capture window so the status icon animates while recording. await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } self.captureTask?.cancel() @@ -269,6 +270,7 @@ actor VoiceWakeRuntime { while self.isCapturing { let now = Date() if now >= hardStop { + // Hard-stop after a maximum duration so we never leave the recognizer pinned open. await self.finalizeCapture(config: config) return } @@ -337,6 +339,7 @@ actor VoiceWakeRuntime { self.lastHeard = Date() } + // Normalize against the adaptive threshold so the UI meter stays roughly 0...1 across devices. let clamped = min(1.0, max(0.0, rms / max(self.minSpeechRMS, threshold))) Task { @MainActor in VoiceWakeOverlayController.shared.updateLevel(clamped) diff --git a/apps/macos/Sources/Clawdis/WebChatServer.swift b/apps/macos/Sources/Clawdis/WebChatServer.swift index c4113bb09..c86d25a5c 100644 --- a/apps/macos/Sources/Clawdis/WebChatServer.swift +++ b/apps/macos/Sources/Clawdis/WebChatServer.swift @@ -135,6 +135,7 @@ final class WebChatServer: @unchecked Sendable { } let fileURL = root.appendingPathComponent(path) webChatServerLogger.debug("WebChatServer resolved file=\(fileURL.path, privacy: .public)") + // Simple directory traversal guard: served files must live under the bundled web root. guard fileURL.path.hasPrefix(root.path) else { self.send(status: 403, mime: "text/plain", body: Data("Forbidden".utf8), over: connection) return diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index 2dc647c2b..c1a61037c 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -137,6 +137,7 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate { Task { @MainActor [weak self] in guard let self else { return } do { + // Recreate the tunnel silently so the window keeps working without user intervention. let base = try await self.startOrRestartTunnel() self.loadPage(baseURL: base) } catch {