chore(instances): log empty payloads and add local fallback
parent
6b8011228e
commit
9dee4c158d
|
|
@ -350,11 +350,13 @@ actor AgentRPC {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if parsed.ok {
|
if parsed.ok {
|
||||||
let payloadData: Data = if let payload = parsed.payload {
|
let payloadData: Data = {
|
||||||
(try? JSONEncoder().encode(payload)) ?? Data()
|
if let payload = parsed.payload {
|
||||||
} else {
|
return (try? JSONEncoder().encode(payload)) ?? Data()
|
||||||
Data()
|
}
|
||||||
}
|
// Use an empty JSON array to keep callers happy when payload is missing.
|
||||||
|
return Data("[]".utf8)
|
||||||
|
}()
|
||||||
waiter.resume(returning: payloadData)
|
waiter.resume(returning: payloadData)
|
||||||
} else {
|
} else {
|
||||||
waiter.resume(throwing: RpcError(message: parsed.error ?? "control error"))
|
waiter.resume(throwing: RpcError(message: parsed.error ?? "control error"))
|
||||||
|
|
|
||||||
|
|
@ -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 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 config = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
|
||||||
let trimmedTarget = config.target.trimmingCharacters(in: .whitespacesAndNewlines)
|
let shouldForward = config.enabled
|
||||||
let shouldForward = config.enabled && !trimmedTarget.isEmpty
|
|
||||||
|
|
||||||
if shouldForward {
|
if shouldForward {
|
||||||
let result = await VoiceWakeForwarder.forward(transcript: message, config: config)
|
let result = await VoiceWakeForwarder.forward(transcript: message, config: config)
|
||||||
|
|
@ -300,7 +299,7 @@ struct DebugSettings: View {
|
||||||
self.debugSendInFlight = false
|
self.debugSendInFlight = false
|
||||||
switch result {
|
switch result {
|
||||||
case .success:
|
case .success:
|
||||||
self.debugSendStatus = "Forwarded via \(trimmedTarget). Await reply."
|
self.debugSendStatus = "Forwarded. Await reply."
|
||||||
self.debugSendError = nil
|
self.debugSendError = nil
|
||||||
case let .failure(error):
|
case let .failure(error):
|
||||||
let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Cocoa
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
|
|
@ -58,6 +59,13 @@ final class InstancesStore: ObservableObject {
|
||||||
defer { self.isLoading = false }
|
defer { self.isLoading = false }
|
||||||
do {
|
do {
|
||||||
let data = try await ControlChannel.shared.request(method: "system-presence")
|
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 decoded = try JSONDecoder().decode([InstanceInfo].self, from: data)
|
||||||
let withIDs = decoded.map { entry -> InstanceInfo in
|
let withIDs = decoded.map { entry -> InstanceInfo in
|
||||||
let key = entry.host ?? entry.ip ?? entry.text
|
let key = entry.host ?? entry.ip ?? entry.text
|
||||||
|
|
@ -72,11 +80,93 @@ final class InstancesStore: ObservableObject {
|
||||||
text: entry.text,
|
text: entry.text,
|
||||||
ts: entry.ts)
|
ts: entry.ts)
|
||||||
}
|
}
|
||||||
self.instances = withIDs
|
if withIDs.isEmpty {
|
||||||
self.lastError = nil
|
self.instances = [self.localFallbackInstance()]
|
||||||
|
self.lastError = nil
|
||||||
|
} else {
|
||||||
|
self.instances = withIDs
|
||||||
|
self.lastError = nil
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("instances fetch failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error(
|
||||||
self.lastError = error.localizedDescription
|
"""
|
||||||
|
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<ifaddrs>? = 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 "<none>" }
|
||||||
|
if data.isEmpty { return "<empty>" }
|
||||||
|
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>"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -379,7 +379,7 @@ enum CommandResolver {
|
||||||
|
|
||||||
private static func sshNodeCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? {
|
private static func sshNodeCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? {
|
||||||
guard !settings.target.isEmpty else { return nil }
|
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"]
|
var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"]
|
||||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
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]? {
|
private static func sshMacHelperCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? {
|
||||||
guard !settings.target.isEmpty else { return nil }
|
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"]
|
var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"]
|
||||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||||
|
|
@ -508,6 +508,39 @@ enum CommandResolver {
|
||||||
return trimmed
|
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[..<atRange.lowerBound])
|
||||||
|
userHostPort = String(trimmed[atRange.upperBound...])
|
||||||
|
} else {
|
||||||
|
user = nil
|
||||||
|
userHostPort = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
let host: String
|
||||||
|
let port: Int
|
||||||
|
if let colon = userHostPort.lastIndex(of: ":"), colon != userHostPort.startIndex {
|
||||||
|
host = String(userHostPort[..<colon])
|
||||||
|
let portStr = String(userHostPort[userHostPort.index(after: colon)...])
|
||||||
|
port = Int(portStr) ?? 22
|
||||||
|
} else {
|
||||||
|
host = userHostPort
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
|
||||||
|
return SSHParsedTarget(user: user, host: host, port: port)
|
||||||
|
}
|
||||||
|
|
||||||
private static func shellQuote(_ text: String) -> String {
|
private static func shellQuote(_ text: String) -> String {
|
||||||
if text.isEmpty { return "''" }
|
if text.isEmpty { return "''" }
|
||||||
let escaped = text.replacingOccurrences(of: "'", with: "'\\''")
|
let escaped = text.replacingOccurrences(of: "'", with: "'\\''")
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.overlay")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.overlay")
|
||||||
|
|
||||||
|
enum Source: String { case wakeWord, pushToTalk }
|
||||||
|
|
||||||
@Published private(set) var model = Model()
|
@Published private(set) var model = Model()
|
||||||
|
|
||||||
struct Model {
|
struct Model {
|
||||||
|
|
@ -27,8 +29,10 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
private var window: NSPanel?
|
private var window: NSPanel?
|
||||||
private var hostingView: NSHostingView<VoiceWakeOverlayView>?
|
private var hostingView: NSHostingView<VoiceWakeOverlayView>?
|
||||||
private var autoSendTask: Task<Void, Never>?
|
private var autoSendTask: Task<Void, Never>?
|
||||||
private var safetyDismissTask: Task<Void, Never>?
|
private var autoSendToken: UUID?
|
||||||
private var forwardConfig: VoiceWakeForwardConfig?
|
private var forwardConfig: VoiceWakeForwardConfig?
|
||||||
|
private var activeToken: UUID?
|
||||||
|
private var activeSource: Source?
|
||||||
|
|
||||||
private let width: CGFloat = 360
|
private let width: CGFloat = 360
|
||||||
private let padding: CGFloat = 10
|
private let padding: CGFloat = 10
|
||||||
|
|
@ -39,10 +43,41 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
private let minHeight: CGFloat = 48
|
private let minHeight: CGFloat = 48
|
||||||
let closeOverflow: CGFloat = 10
|
let closeOverflow: CGFloat = 10
|
||||||
|
|
||||||
func showPartial(transcript: String, attributed: NSAttributedString? = nil) {
|
@discardableResult
|
||||||
self.logger.log(level: .info, "overlay showPartial len=\(transcript.count, privacy: .public) visible=\(self.model.isVisible, privacy: .public) isFinal=false")
|
func startSession(
|
||||||
self.autoSendTask?.cancel()
|
source: Source,
|
||||||
self.safetyDismissTask?.cancel()
|
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.forwardConfig = nil
|
||||||
self.model.text = transcript
|
self.model.text = transcript
|
||||||
self.model.isFinal = false
|
self.model.isFinal = false
|
||||||
|
|
@ -56,15 +91,17 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentFinal(
|
func presentFinal(
|
||||||
|
token: UUID,
|
||||||
transcript: String,
|
transcript: String,
|
||||||
forwardConfig: VoiceWakeForwardConfig,
|
forwardConfig: VoiceWakeForwardConfig,
|
||||||
autoSendAfter delay: TimeInterval?,
|
autoSendAfter delay: TimeInterval?,
|
||||||
sendChime: VoiceWakeChime = .none,
|
sendChime: VoiceWakeChime = .none,
|
||||||
attributed: NSAttributedString? = nil)
|
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.autoSendTask?.cancel()
|
||||||
self.safetyDismissTask?.cancel()
|
self.autoSendToken = token
|
||||||
self.forwardConfig = forwardConfig
|
self.forwardConfig = forwardConfig
|
||||||
self.model.text = transcript
|
self.model.text = transcript
|
||||||
self.model.isFinal = true
|
self.model.isFinal = true
|
||||||
|
|
@ -75,10 +112,8 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
self.model.level = 0
|
self.model.level = 0
|
||||||
self.present()
|
self.present()
|
||||||
if let delay {
|
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() {
|
func userBeganEditing() {
|
||||||
|
|
@ -105,11 +140,10 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
self.updateWindowFrame(animate: true)
|
self.updateWindowFrame(animate: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendNow(sendChime: VoiceWakeChime = .none) {
|
func sendNow(token: UUID? = nil, 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)")
|
guard self.guardToken(token, context: "send") else { return }
|
||||||
self.autoSendTask?.cancel()
|
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 = nil
|
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
||||||
self.safetyDismissTask?.cancel()
|
|
||||||
if self.model.isSending { return }
|
if self.model.isSending { return }
|
||||||
self.model.isEditing = false
|
self.model.isEditing = false
|
||||||
guard let forwardConfig, forwardConfig.enabled else {
|
guard let forwardConfig, forwardConfig.enabled else {
|
||||||
|
|
@ -126,7 +160,7 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
|
|
||||||
if sendChime != .none {
|
if sendChime != .none {
|
||||||
self.logger.log(level: .info, "overlay sendNow playing sendChime=\(String(describing: sendChime), privacy: .public)")
|
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
|
self.model.isSending = true
|
||||||
|
|
@ -136,14 +170,14 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
await VoiceWakeForwarder.forward(transcript: payload, config: forwardConfig)
|
await VoiceWakeForwarder.forward(transcript: payload, config: forwardConfig)
|
||||||
}
|
}
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) {
|
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) {
|
func dismiss(token: UUID? = nil, 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)")
|
guard self.guardToken(token, context: "dismiss") else { return }
|
||||||
self.autoSendTask?.cancel()
|
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.safetyDismissTask?.cancel()
|
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
||||||
self.model.isSending = false
|
self.model.isSending = false
|
||||||
self.model.isEditing = false
|
self.model.isEditing = false
|
||||||
guard let window else { return }
|
guard let window else { return }
|
||||||
|
|
@ -160,6 +194,9 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
window.orderOut(nil)
|
window.orderOut(nil)
|
||||||
self.model.isVisible = false
|
self.model.isVisible = false
|
||||||
self.model.level = 0
|
self.model.level = 0
|
||||||
|
self.activeToken = nil
|
||||||
|
self.activeSource = nil
|
||||||
|
self.forwardConfig = nil
|
||||||
if outcome == .empty {
|
if outcome == .empty {
|
||||||
AppStateStore.shared.blinkOnce()
|
AppStateStore.shared.blinkOnce()
|
||||||
} else if outcome == .sent {
|
} 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))
|
self.model.level = max(0, min(1, level))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,6 +217,18 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
|
|
||||||
// MARK: - Private
|
// 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() {
|
private func present() {
|
||||||
self.ensureWindow()
|
self.ensureWindow()
|
||||||
self.hostingView?.rootView = VoiceWakeOverlayView(controller: self)
|
self.hostingView?.rootView = VoiceWakeOverlayView(controller: self)
|
||||||
|
|
@ -299,36 +349,24 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleAutoSend(after delay: TimeInterval, sendChime: VoiceWakeChime) {
|
private func scheduleAutoSend(token: UUID, after delay: TimeInterval, sendChime: VoiceWakeChime) {
|
||||||
self.logger.log(level: .info, "overlay scheduleAutoSend after=\(delay, privacy: .public) sendChime=\(String(describing: sendChime), privacy: .public)")
|
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?.cancel()
|
||||||
self.autoSendTask = Task<Void, Never> { [weak self, sendChime] in
|
self.autoSendToken = token
|
||||||
|
self.autoSendTask = Task<Void, Never> { [weak self, sendChime, token] in
|
||||||
let nanos = UInt64(max(0, delay) * 1_000_000_000)
|
let nanos = UInt64(max(0, delay) * 1_000_000_000)
|
||||||
try? await Task.sleep(nanoseconds: nanos)
|
try? await Task.sleep(nanoseconds: nanos)
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.logger.log(level: .info, "overlay autoSend firing")
|
guard self.guardToken(token, context: "autoSend") else { return }
|
||||||
self.sendNow(sendChime: sendChime)
|
self.logger.log(level: .info, "overlay autoSend firing token=\(token.uuidString, privacy: .public)")
|
||||||
|
self.sendNow(token: token, sendChime: sendChime)
|
||||||
self.autoSendTask = nil
|
self.autoSendTask = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleSafetyDismiss() {
|
|
||||||
self.safetyDismissTask?.cancel()
|
|
||||||
self.safetyDismissTask = Task<Void, Never> { [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 {
|
private func makeAttributed(from text: String) -> NSAttributedString {
|
||||||
NSAttributedString(
|
NSAttributedString(
|
||||||
string: text,
|
string: text,
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@ final class WebChatTunnel {
|
||||||
|
|
||||||
static func create(remotePort: Int, preferredLocalPort: UInt16? = nil) async throws -> WebChatTunnel {
|
static func create(remotePort: Int, preferredLocalPort: UInt16? = nil) async throws -> WebChatTunnel {
|
||||||
let settings = CommandResolver.connectionSettings()
|
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"])
|
throw NSError(domain: "WebChat", code: 3, userInfo: [NSLocalizedDescriptionKey: "remote not configured"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue