chore(mac): apply swiftformat and lint fixes
parent
b9cc914729
commit
51aed3ca0a
|
|
@ -9,8 +9,8 @@ final class AgentEventStore: ObservableObject {
|
||||||
|
|
||||||
func append(_ event: ControlAgentEvent) {
|
func append(_ event: ControlAgentEvent) {
|
||||||
self.events.append(event)
|
self.events.append(event)
|
||||||
if self.events.count > maxEvents {
|
if self.events.count > self.maxEvents {
|
||||||
self.events.removeFirst(self.events.count - maxEvents)
|
self.events.removeFirst(self.events.count - self.maxEvents)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,14 @@ struct AgentEventsWindow: View {
|
||||||
Text("Agent Events")
|
Text("Agent Events")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Clear") { store.clear() }
|
Button("Clear") { self.store.clear() }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(store.events.reversed(), id: \.seq) { evt in
|
ForEach(self.store.events.reversed(), id: \.seq) { evt in
|
||||||
EventRow(event: evt)
|
EventRow(event: evt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -34,14 +34,14 @@ private struct EventRow: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(event.stream.uppercased())
|
Text(self.event.stream.uppercased())
|
||||||
.font(.caption2.weight(.bold))
|
.font(.caption2.weight(.bold))
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.background(self.tint)
|
.background(self.tint)
|
||||||
.foregroundStyle(Color.white)
|
.foregroundStyle(Color.white)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
|
||||||
Text("run " + event.runId)
|
Text("run " + self.event.runId)
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
@ -61,16 +61,15 @@ private struct EventRow: View {
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
.fill(Color.primary.opacity(0.04))
|
.fill(Color.primary.opacity(0.04)))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var tint: Color {
|
private var tint: Color {
|
||||||
switch event.stream {
|
switch self.event.stream {
|
||||||
case "job": return .blue
|
case "job": .blue
|
||||||
case "tool": return .orange
|
case "tool": .orange
|
||||||
case "assistant": return .green
|
case "assistant": .green
|
||||||
default: return .gray
|
default: .gray
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Foundation
|
|
||||||
import Darwin
|
import Darwin
|
||||||
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
struct ControlRequestParams: @unchecked Sendable {
|
struct ControlRequestParams: @unchecked Sendable {
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,8 @@ final class AppState: ObservableObject {
|
||||||
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
|
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
|
||||||
}
|
}
|
||||||
if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey),
|
if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey),
|
||||||
let selection = IconOverrideSelection(rawValue: storedOverride) {
|
let selection = IconOverrideSelection(rawValue: storedOverride)
|
||||||
|
{
|
||||||
self.iconOverride = selection
|
self.iconOverride = selection
|
||||||
} else {
|
} else {
|
||||||
self.iconOverride = .system
|
self.iconOverride = .system
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,11 @@ struct ConfigSettings: View {
|
||||||
.frame(width: 100)
|
.frame(width: 100)
|
||||||
.disabled(!self.webChatEnabled)
|
.disabled(!self.webChatEnabled)
|
||||||
}
|
}
|
||||||
Text("Mac app connects to the relay’s loopback web chat on this port. Remote mode uses SSH -L to forward it.")
|
Text(
|
||||||
|
"""
|
||||||
|
Mac app connects to the relay’s loopback web chat on this port.
|
||||||
|
Remote mode uses SSH -L to forward it.
|
||||||
|
""")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(maxWidth: 480, alignment: .leading)
|
.frame(maxWidth: 480, alignment: .leading)
|
||||||
|
|
|
||||||
|
|
@ -113,8 +113,7 @@ struct CritterStatusLabel: View {
|
||||||
.padding(3)
|
.padding(3)
|
||||||
.background(
|
.background(
|
||||||
Circle()
|
Circle()
|
||||||
.fill(self.iconState.tint.opacity(0.9))
|
.fill(self.iconState.tint.opacity(0.9)))
|
||||||
)
|
|
||||||
.foregroundStyle(Color.white)
|
.foregroundStyle(Color.white)
|
||||||
.offset(x: -4, y: -2)
|
.offset(x: -4, y: -2)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,10 @@ struct DebugSettings: View {
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Uses the Voice Wake path: forwards over SSH when configured, otherwise runs locally via rpc.")
|
"""
|
||||||
|
Uses the Voice Wake path: forwards over SSH when configured,
|
||||||
|
otherwise runs locally via rpc.
|
||||||
|
""")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
@ -289,7 +292,10 @@ struct DebugSettings: View {
|
||||||
self.debugSendStatus = nil
|
self.debugSendStatus = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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 shouldForward = config.enabled
|
let shouldForward = config.enabled
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -184,10 +184,10 @@ struct GeneralSettings: View {
|
||||||
|
|
||||||
private var controlStatusLine: String {
|
private var controlStatusLine: String {
|
||||||
switch ControlChannel.shared.state {
|
switch ControlChannel.shared.state {
|
||||||
case .connected: return "Connected"
|
case .connected: "Connected"
|
||||||
case .connecting: return "Connecting…"
|
case .connecting: "Connecting…"
|
||||||
case .disconnected: return "Disconnected"
|
case .disconnected: "Disconnected"
|
||||||
case let .degraded(msg): return "Degraded: \(msg)"
|
case let .degraded(msg): "Degraded: \(msg)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,8 +276,10 @@ struct GeneralSettings: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if let recent = snap.sessions.recent.first {
|
if let recent = snap.sessions.recent.first {
|
||||||
Text(
|
let lastActivity = recent.updatedAt != nil
|
||||||
"Last activity: \(recent.key) \(recent.updatedAt != nil ? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000)) : "unknown")")
|
? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000))
|
||||||
|
: "unknown"
|
||||||
|
Text("Last activity: \(recent.key) \(lastActivity)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
@ -386,7 +388,9 @@ extension GeneralSettings {
|
||||||
// Step 2: control channel health over tunnel
|
// Step 2: control channel health over tunnel
|
||||||
let originalMode = AppStateStore.shared.connectionMode
|
let originalMode = AppStateStore.shared.connectionMode
|
||||||
do {
|
do {
|
||||||
try await ControlChannel.shared.configure(mode: .remote(target: settings.target, identity: settings.identity))
|
try await ControlChannel.shared.configure(mode: .remote(
|
||||||
|
target: settings.target,
|
||||||
|
identity: settings.identity))
|
||||||
let data = try await ControlChannel.shared.health(timeout: 10)
|
let data = try await ControlChannel.shared.health(timeout: 10)
|
||||||
if decodeHealthSnapshot(from: data) != nil {
|
if decodeHealthSnapshot(from: data) != nil {
|
||||||
self.remoteStatus = .ok
|
self.remoteStatus = .ok
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Network
|
||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Network
|
|
||||||
|
|
||||||
struct HealthSnapshot: Codable, Sendable {
|
struct HealthSnapshot: Codable, Sendable {
|
||||||
struct Web: Codable, Sendable {
|
struct Web: Codable, Sendable {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ final class HeartbeatStore: ObservableObject {
|
||||||
self.observer = NotificationCenter.default.addObserver(
|
self.observer = NotificationCenter.default.addObserver(
|
||||||
forName: .controlHeartbeat,
|
forName: .controlHeartbeat,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: .main) { [weak self] note in
|
queue: .main)
|
||||||
|
{ [weak self] note in
|
||||||
guard let data = note.object as? Data else { return }
|
guard let data = note.object as? Data else { return }
|
||||||
if let decoded = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) {
|
if let decoded = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) {
|
||||||
Task { @MainActor in self?.lastEvent = decoded }
|
Task { @MainActor in self?.lastEvent = decoded }
|
||||||
|
|
|
||||||
|
|
@ -23,28 +23,28 @@ enum IconState: Equatable {
|
||||||
|
|
||||||
var glyph: String {
|
var glyph: String {
|
||||||
switch self.activity {
|
switch self.activity {
|
||||||
case .tool(.bash): return "💻"
|
case .tool(.bash): "💻"
|
||||||
case .tool(.read): return "📄"
|
case .tool(.read): "📄"
|
||||||
case .tool(.write): return "✍️"
|
case .tool(.write): "✍️"
|
||||||
case .tool(.edit): return "📝"
|
case .tool(.edit): "📝"
|
||||||
case .tool(.attach): return "📎"
|
case .tool(.attach): "📎"
|
||||||
case .tool(.other), .job: return "🛠️"
|
case .tool(.other), .job: "🛠️"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var tint: Color {
|
var tint: Color {
|
||||||
switch self {
|
switch self {
|
||||||
case .workingMain: return .accentColor
|
case .workingMain: .accentColor
|
||||||
case .workingOther: return .gray
|
case .workingOther: .gray
|
||||||
case .overridden: return .orange
|
case .overridden: .orange
|
||||||
case .idle: return .clear
|
case .idle: .clear
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isWorking: Bool {
|
var isWorking: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .idle: return false
|
case .idle: false
|
||||||
default: return true
|
default: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,9 +53,9 @@ enum IconState: Equatable {
|
||||||
case let .workingMain(kind),
|
case let .workingMain(kind),
|
||||||
let .workingOther(kind),
|
let .workingOther(kind),
|
||||||
let .overridden(kind):
|
let .overridden(kind):
|
||||||
return kind
|
kind
|
||||||
case .idle:
|
case .idle:
|
||||||
return .job
|
.job
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,18 +70,18 @@ enum IconOverrideSelection: String, CaseIterable, Identifiable {
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .system: return "System (auto)"
|
case .system: "System (auto)"
|
||||||
case .idle: return "Idle"
|
case .idle: "Idle"
|
||||||
case .mainBash: return "Working main – bash"
|
case .mainBash: "Working main – bash"
|
||||||
case .mainRead: return "Working main – read"
|
case .mainRead: "Working main – read"
|
||||||
case .mainWrite: return "Working main – write"
|
case .mainWrite: "Working main – write"
|
||||||
case .mainEdit: return "Working main – edit"
|
case .mainEdit: "Working main – edit"
|
||||||
case .mainOther: return "Working main – other"
|
case .mainOther: "Working main – other"
|
||||||
case .otherBash: return "Working other – bash"
|
case .otherBash: "Working other – bash"
|
||||||
case .otherRead: return "Working other – read"
|
case .otherRead: "Working other – read"
|
||||||
case .otherWrite: return "Working other – write"
|
case .otherWrite: "Working other – write"
|
||||||
case .otherEdit: return "Working other – edit"
|
case .otherEdit: "Working other – edit"
|
||||||
case .otherOther: return "Working other – other"
|
case .otherOther: "Working other – other"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,24 @@ struct InstancesSettings: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
header
|
self.header
|
||||||
if let err = store.lastError {
|
if let err = store.lastError {
|
||||||
Text("Error: \(err)")
|
Text("Error: \(err)")
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
if store.instances.isEmpty {
|
if self.store.instances.isEmpty {
|
||||||
Text("No instances reported yet.")
|
Text("No instances reported yet.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
List(store.instances) { inst in
|
List(self.store.instances) { inst in
|
||||||
instanceRow(inst)
|
self.instanceRow(inst)
|
||||||
}
|
}
|
||||||
.listStyle(.inset)
|
.listStyle(.inset)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.onAppear { store.start() }
|
.onAppear { self.store.start() }
|
||||||
.onDisappear { store.stop() }
|
.onDisappear { self.store.stop() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
|
|
@ -35,10 +35,10 @@ struct InstancesSettings: View {
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if store.isLoading {
|
if self.store.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
} else {
|
} else {
|
||||||
Button("Refresh") { Task { await store.refresh() } }
|
Button("Refresh") { Task { await self.store.refresh() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -52,12 +52,12 @@ struct InstancesSettings: View {
|
||||||
}
|
}
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if let version = inst.version {
|
if let version = inst.version {
|
||||||
label(icon: "shippingbox", text: version)
|
self.label(icon: "shippingbox", text: version)
|
||||||
}
|
}
|
||||||
label(icon: "clock", text: inst.lastInputDescription)
|
self.label(icon: "clock", text: inst.lastInputDescription)
|
||||||
if let mode = inst.mode { label(icon: "network", text: mode) }
|
if let mode = inst.mode { self.label(icon: "network", text: mode) }
|
||||||
if let reason = inst.reason, !reason.isEmpty {
|
if let reason = inst.reason, !reason.isEmpty {
|
||||||
label(icon: "info.circle", text: reason)
|
self.label(icon: "info.circle", text: reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(inst.text)
|
Text(inst.text)
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ final class InstancesStore: ObservableObject {
|
||||||
self.logger.error("instances fetch returned empty payload")
|
self.logger.error("instances fetch returned empty payload")
|
||||||
self.instances = [self.localFallbackInstance()]
|
self.instances = [self.localFallbackInstance()]
|
||||||
self.lastError = "No presence data returned from relay yet."
|
self.lastError = "No presence data returned from relay yet."
|
||||||
|
await self.probeHealthIfNeeded()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data)
|
let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data)
|
||||||
|
|
@ -83,6 +84,7 @@ final class InstancesStore: ObservableObject {
|
||||||
if withIDs.isEmpty {
|
if withIDs.isEmpty {
|
||||||
self.instances = [self.localFallbackInstance()]
|
self.instances = [self.localFallbackInstance()]
|
||||||
self.lastError = nil
|
self.lastError = nil
|
||||||
|
await self.probeHealthIfNeeded()
|
||||||
} else {
|
} else {
|
||||||
self.instances = withIDs
|
self.instances = withIDs
|
||||||
self.lastError = nil
|
self.lastError = nil
|
||||||
|
|
@ -93,10 +95,10 @@ final class InstancesStore: ObservableObject {
|
||||||
instances fetch failed: \(error.localizedDescription, privacy: .public) \
|
instances fetch failed: \(error.localizedDescription, privacy: .public) \
|
||||||
len=\(self.lastPayload?.count ?? 0, privacy: .public) \
|
len=\(self.lastPayload?.count ?? 0, privacy: .public) \
|
||||||
utf8=\(self.snippet(self.lastPayload), privacy: .public)
|
utf8=\(self.snippet(self.lastPayload), privacy: .public)
|
||||||
"""
|
""")
|
||||||
)
|
|
||||||
self.instances = [self.localFallbackInstance()]
|
self.instances = [self.localFallbackInstance()]
|
||||||
self.lastError = "Decode failed: \(error.localizedDescription)"
|
self.lastError = "Decode failed: \(error.localizedDescription)"
|
||||||
|
await self.probeHealthIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +128,7 @@ final class InstancesStore: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func primaryIPv4Address() -> String? {
|
private static func primaryIPv4Address() -> String? {
|
||||||
var addrList: UnsafeMutablePointer<ifaddrs>? = nil
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
defer { freeifaddrs(addrList) }
|
defer { freeifaddrs(addrList) }
|
||||||
|
|
||||||
|
|
@ -143,10 +145,18 @@ final class InstancesStore: ObservableObject {
|
||||||
|
|
||||||
var addr = ptr.pointee.ifa_addr.pointee
|
var addr = ptr.pointee.ifa_addr.pointee
|
||||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
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)
|
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 }
|
guard result == 0 else { continue }
|
||||||
let len = buffer.prefix { $0 != 0 }
|
let len = buffer.prefix { $0 != 0 }
|
||||||
let ip = String(decoding: len.map { UInt8(bitPattern: $0) }, as: UTF8.self)
|
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||||
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||||
|
|
||||||
if name == "en0" { en0 = ip; break }
|
if name == "en0" { en0 = ip; break }
|
||||||
if fallback == nil { fallback = ip }
|
if fallback == nil { fallback = ip }
|
||||||
|
|
@ -169,4 +179,27 @@ final class InstancesStore: ObservableObject {
|
||||||
}
|
}
|
||||||
return "<\(data.count) bytes non-utf8>"
|
return "<\(data.count) bytes non-utf8>"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func probeHealthIfNeeded() async {
|
||||||
|
do {
|
||||||
|
let data = try await ControlChannel.shared.health(timeout: 8)
|
||||||
|
guard let snap = decodeHealthSnapshot(from: data) else { return }
|
||||||
|
let entry = InstanceInfo(
|
||||||
|
id: "health-\(snap.ts)",
|
||||||
|
host: "relay (health)",
|
||||||
|
ip: nil,
|
||||||
|
version: nil,
|
||||||
|
lastInputSeconds: nil,
|
||||||
|
mode: "health",
|
||||||
|
reason: "health probe",
|
||||||
|
text: "Health ok · linked=\(snap.web.linked) · ipc.exists=\(snap.ipc.exists)",
|
||||||
|
ts: snap.ts)
|
||||||
|
if !self.instances.contains(where: { $0.id == entry.id }) {
|
||||||
|
self.instances.insert(entry, at: 0)
|
||||||
|
}
|
||||||
|
self.lastError = nil
|
||||||
|
} catch {
|
||||||
|
self.logger.error("instances health probe failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,10 @@ struct OnboardingView: View {
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
||||||
Text(
|
Text(
|
||||||
"We install into /usr/local/bin and /opt/homebrew/bin. Rerun anytime if you move the build output.")
|
"""
|
||||||
|
We install into /usr/local/bin and /opt/homebrew/bin.
|
||||||
|
Rerun anytime if you move the build output.
|
||||||
|
""")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
@ -259,7 +262,10 @@ struct OnboardingView: View {
|
||||||
Text("Link WhatsApp")
|
Text("Link WhatsApp")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text(
|
Text(
|
||||||
"Run `clawdis login` where the relay runs (local if local mode, remote if remote). Scan the QR to pair your account.")
|
"""
|
||||||
|
Run `clawdis login` where the relay runs (local if local mode, remote if remote).
|
||||||
|
Scan the QR to pair your account.
|
||||||
|
""")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
@ -273,11 +279,17 @@ struct OnboardingView: View {
|
||||||
systemImage: "terminal")
|
systemImage: "terminal")
|
||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Run `clawdis login --verbose`",
|
title: "Run `clawdis login --verbose`",
|
||||||
subtitle: "Scan the QR code with WhatsApp on your phone. We only use your personal session; no cloud relay involved.",
|
subtitle: """
|
||||||
|
Scan the QR code with WhatsApp on your phone.
|
||||||
|
We only use your personal session; no cloud relay involved.
|
||||||
|
""",
|
||||||
systemImage: "qrcode.viewfinder")
|
systemImage: "qrcode.viewfinder")
|
||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Re-link after timeouts",
|
title: "Re-link after timeouts",
|
||||||
subtitle: "If Baileys auth expires, re-run login on that host. Settings → General shows remote/local mode so you know where to run it.",
|
subtitle: """
|
||||||
|
If Baileys auth expires, re-run login on that host.
|
||||||
|
Settings → General shows remote/local mode so you know where to run it.
|
||||||
|
""",
|
||||||
systemImage: "clock.arrow.circlepath")
|
systemImage: "clock.arrow.circlepath")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -290,8 +302,10 @@ struct OnboardingView: View {
|
||||||
self.onboardingCard {
|
self.onboardingCard {
|
||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Run the dashboard",
|
title: "Run the dashboard",
|
||||||
subtitle: "Use the CLI helper from your scripts, and reopen onboarding from "
|
subtitle: """
|
||||||
+ "Settings if you add a new user.",
|
Use the CLI helper from your scripts, and reopen onboarding from Settings
|
||||||
|
if you add a new user.
|
||||||
|
""",
|
||||||
systemImage: "checkmark.seal")
|
systemImage: "checkmark.seal")
|
||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Test a notification",
|
title: "Test a notification",
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ final class PresenceReporter {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func primaryIPv4Address() -> String? {
|
private static func primaryIPv4Address() -> String? {
|
||||||
var addrList: UnsafeMutablePointer<ifaddrs>? = nil
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
defer { freeifaddrs(addrList) }
|
defer { freeifaddrs(addrList) }
|
||||||
|
|
||||||
|
|
@ -86,10 +86,18 @@ final class PresenceReporter {
|
||||||
|
|
||||||
var addr = ptr.pointee.ifa_addr.pointee
|
var addr = ptr.pointee.ifa_addr.pointee
|
||||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
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)
|
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 }
|
guard result == 0 else { continue }
|
||||||
let len = buffer.prefix { $0 != 0 }
|
let len = buffer.prefix { $0 != 0 }
|
||||||
let ip = String(decoding: len.map { UInt8(bitPattern: $0) }, as: UTF8.self)
|
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||||
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||||
|
|
||||||
if name == "en0" { en0 = ip; break }
|
if name == "en0" { en0 = ip; break }
|
||||||
if fallback == nil { fallback = ip }
|
if fallback == nil { fallback = ip }
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ struct RuntimeVersion: Comparable, CustomStringConvertible {
|
||||||
let minor: Int
|
let minor: Int
|
||||||
let patch: Int
|
let patch: Int
|
||||||
|
|
||||||
var description: String { "\(major).\(minor).\(patch)" }
|
var description: String { "\(self.major).\(self.minor).\(self.patch)" }
|
||||||
|
|
||||||
static func < (lhs: RuntimeVersion, rhs: RuntimeVersion) -> Bool {
|
static func < (lhs: RuntimeVersion, rhs: RuntimeVersion) -> Bool {
|
||||||
if lhs.major != rhs.major { return lhs.major < rhs.major }
|
if lhs.major != rhs.major { return lhs.major < rhs.major }
|
||||||
|
|
@ -41,7 +41,12 @@ struct RuntimeResolution {
|
||||||
|
|
||||||
enum RuntimeResolutionError: Error {
|
enum RuntimeResolutionError: Error {
|
||||||
case notFound(searchPaths: [String], preferred: String?)
|
case notFound(searchPaths: [String], preferred: String?)
|
||||||
case unsupported(kind: RuntimeKind, found: RuntimeVersion, required: RuntimeVersion, path: String, searchPaths: [String])
|
case unsupported(
|
||||||
|
kind: RuntimeKind,
|
||||||
|
found: RuntimeVersion,
|
||||||
|
required: RuntimeVersion,
|
||||||
|
path: String,
|
||||||
|
searchPaths: [String])
|
||||||
case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String])
|
case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,22 +56,31 @@ enum RuntimeLocator {
|
||||||
|
|
||||||
static func resolve(
|
static func resolve(
|
||||||
preferred: String? = ProcessInfo.processInfo.environment["CLAWDIS_RUNTIME"],
|
preferred: String? = ProcessInfo.processInfo.environment["CLAWDIS_RUNTIME"],
|
||||||
searchPaths: [String] = CommandResolver.preferredPaths()
|
searchPaths: [String] = CommandResolver.preferredPaths()) -> Result<RuntimeResolution, RuntimeResolutionError>
|
||||||
) -> Result<RuntimeResolution, RuntimeResolutionError> {
|
{
|
||||||
let order = runtimeOrder(preferred: preferred)
|
let order = self.runtimeOrder(preferred: preferred)
|
||||||
let pathEnv = searchPaths.joined(separator: ":")
|
let pathEnv = searchPaths.joined(separator: ":")
|
||||||
|
|
||||||
for runtime in order {
|
for runtime in order {
|
||||||
guard let binary = findExecutable(named: runtime.binaryName, searchPaths: searchPaths) else { continue }
|
guard let binary = findExecutable(named: runtime.binaryName, searchPaths: searchPaths) else { continue }
|
||||||
guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else {
|
guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else {
|
||||||
return .failure(.versionParse(kind: runtime, raw: "(unreadable)", path: binary, searchPaths: searchPaths))
|
return .failure(.versionParse(
|
||||||
|
kind: runtime,
|
||||||
|
raw: "(unreadable)",
|
||||||
|
path: binary,
|
||||||
|
searchPaths: searchPaths))
|
||||||
}
|
}
|
||||||
guard let parsed = RuntimeVersion.from(string: rawVersion) else {
|
guard let parsed = RuntimeVersion.from(string: rawVersion) else {
|
||||||
return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths))
|
return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths))
|
||||||
}
|
}
|
||||||
let minimum = runtime == .bun ? minBun : minNode
|
let minimum = runtime == .bun ? self.minBun : self.minNode
|
||||||
guard parsed >= minimum else {
|
guard parsed >= minimum else {
|
||||||
return .failure(.unsupported(kind: runtime, found: parsed, required: minimum, path: binary, searchPaths: searchPaths))
|
return .failure(.unsupported(
|
||||||
|
kind: runtime,
|
||||||
|
found: parsed,
|
||||||
|
required: minimum,
|
||||||
|
path: binary,
|
||||||
|
searchPaths: searchPaths))
|
||||||
}
|
}
|
||||||
return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed))
|
return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed))
|
||||||
}
|
}
|
||||||
|
|
@ -86,10 +100,11 @@ enum RuntimeLocator {
|
||||||
"Install Bun: https://bun.sh/docs/installation",
|
"Install Bun: https://bun.sh/docs/installation",
|
||||||
].joined(separator: "\n")
|
].joined(separator: "\n")
|
||||||
case let .unsupported(kind, found, required, path, searchPaths):
|
case let .unsupported(kind, found, required, path, searchPaths):
|
||||||
|
let fallbackRuntime = kind == .bun ? "node" : "bun"
|
||||||
return [
|
return [
|
||||||
"Found \(kind.rawValue) \(found) at \(path) but need >= \(required).",
|
"Found \(kind.rawValue) \(found) at \(path) but need >= \(required).",
|
||||||
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||||
"Upgrade \(kind.rawValue) or set CLAWDIS_RUNTIME=\(kind == .bun ? "node" : "bun") to try the other runtime.",
|
"Upgrade \(kind.rawValue) or set CLAWDIS_RUNTIME=\(fallbackRuntime) to try the other runtime.",
|
||||||
].joined(separator: "\n")
|
].joined(separator: "\n")
|
||||||
case let .versionParse(kind, raw, path, searchPaths):
|
case let .versionParse(kind, raw, path, searchPaths):
|
||||||
return [
|
return [
|
||||||
|
|
@ -143,7 +158,6 @@ enum RuntimeLocator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension RuntimeKind {
|
extension RuntimeKind {
|
||||||
var binaryName: String { self == .bun ? "bun" : "node" }
|
fileprivate var binaryName: String { self == .bun ? "bun" : "node" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import AppKit
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct SoundEffectCatalog {
|
enum SoundEffectCatalog {
|
||||||
/// All discoverable system sound names, with "Glass" pinned first.
|
/// All discoverable system sound names, with "Glass" pinned first.
|
||||||
static var systemOptions: [String] {
|
static var systemOptions: [String] {
|
||||||
var names = Set(Self.discoveredSoundMap.keys).union(Self.fallbackNames)
|
var names = Set(Self.discoveredSoundMap.keys).union(Self.fallbackNames)
|
||||||
|
|
@ -13,7 +13,7 @@ struct SoundEffectCatalog {
|
||||||
static func displayName(for raw: String) -> String { raw }
|
static func displayName(for raw: String) -> String { raw }
|
||||||
|
|
||||||
static func url(for name: String) -> URL? {
|
static func url(for name: String) -> URL? {
|
||||||
Self.discoveredSoundMap[name]
|
self.discoveredSoundMap[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Internals
|
// MARK: - Internals
|
||||||
|
|
|
||||||
|
|
@ -225,8 +225,8 @@ enum CommandResolver {
|
||||||
runtime: RuntimeResolution,
|
runtime: RuntimeResolution,
|
||||||
entrypoint: String,
|
entrypoint: String,
|
||||||
subcommand: String,
|
subcommand: String,
|
||||||
extraArgs: [String]
|
extraArgs: [String]) -> [String]
|
||||||
) -> [String] {
|
{
|
||||||
[runtime.path, entrypoint, subcommand] + extraArgs
|
[runtime.path, entrypoint, subcommand] + extraArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,11 +332,19 @@ enum CommandResolver {
|
||||||
if let relay = self.bundledRelayRoot(),
|
if let relay = self.bundledRelayRoot(),
|
||||||
let entry = self.relayEntrypoint(in: relay)
|
let entry = self.relayEntrypoint(in: relay)
|
||||||
{
|
{
|
||||||
return self.makeRuntimeCommand(runtime: runtime, entrypoint: entry, subcommand: subcommand, extraArgs: extraArgs)
|
return self.makeRuntimeCommand(
|
||||||
|
runtime: runtime,
|
||||||
|
entrypoint: entry,
|
||||||
|
subcommand: subcommand,
|
||||||
|
extraArgs: extraArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let entry = self.relayEntrypoint(in: self.projectRoot()) {
|
if let entry = self.relayEntrypoint(in: self.projectRoot()) {
|
||||||
return self.makeRuntimeCommand(runtime: runtime, entrypoint: entry, subcommand: subcommand, extraArgs: extraArgs)
|
return self.makeRuntimeCommand(
|
||||||
|
runtime: runtime,
|
||||||
|
entrypoint: entry,
|
||||||
|
subcommand: subcommand,
|
||||||
|
extraArgs: extraArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let clawdisPath = self.clawdisExecutable() {
|
if let clawdisPath = self.clawdisExecutable() {
|
||||||
|
|
@ -347,7 +355,9 @@ enum CommandResolver {
|
||||||
return [pnpm, "--silent", "clawdis", subcommand] + extraArgs
|
return [pnpm, "--silent", "clawdis", subcommand] + extraArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
let missingEntry = "clawdis entrypoint missing (looked for dist/index.js or bin/clawdis.js); run pnpm build."
|
let missingEntry = """
|
||||||
|
clawdis entrypoint missing (looked for dist/index.js or bin/clawdis.js); run pnpm build.
|
||||||
|
"""
|
||||||
return self.errorCommand(with: missingEntry)
|
return self.errorCommand(with: missingEntry)
|
||||||
|
|
||||||
case let .failure(error):
|
case let .failure(error):
|
||||||
|
|
@ -390,13 +400,21 @@ enum CommandResolver {
|
||||||
args.append(userHost)
|
args.append(userHost)
|
||||||
|
|
||||||
// Run the real clawdis CLI on the remote host; do not fall back to clawdis-mac.
|
// Run the real clawdis CLI on the remote host; do not fall back to clawdis-mac.
|
||||||
let exportedPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm:$PATH"
|
let exportedPath = [
|
||||||
|
"/opt/homebrew/bin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
"/usr/bin",
|
||||||
|
"/bin",
|
||||||
|
"/usr/sbin",
|
||||||
|
"/sbin",
|
||||||
|
"/Users/steipete/Library/pnpm",
|
||||||
|
"$PATH",
|
||||||
|
].joined(separator: ":")
|
||||||
let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
|
let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
|
||||||
let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines)
|
let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
let projectSection: String
|
let projectSection = if userPRJ.isEmpty {
|
||||||
if userPRJ.isEmpty {
|
"""
|
||||||
projectSection = """
|
|
||||||
DEFAULT_PRJ="$HOME/Projects/clawdis"
|
DEFAULT_PRJ="$HOME/Projects/clawdis"
|
||||||
if [ -d "$DEFAULT_PRJ" ]; then
|
if [ -d "$DEFAULT_PRJ" ]; then
|
||||||
PRJ="$DEFAULT_PRJ"
|
PRJ="$DEFAULT_PRJ"
|
||||||
|
|
@ -404,7 +422,7 @@ enum CommandResolver {
|
||||||
fi
|
fi
|
||||||
"""
|
"""
|
||||||
} else {
|
} else {
|
||||||
projectSection = """
|
"""
|
||||||
PRJ=\(self.shellQuote(userPRJ))
|
PRJ=\(self.shellQuote(userPRJ))
|
||||||
cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
|
cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
|
||||||
"""
|
"""
|
||||||
|
|
@ -448,7 +466,11 @@ enum CommandResolver {
|
||||||
return ["/usr/bin/ssh"] + args
|
return ["/usr/bin/ssh"] + args
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = self.parseSSHTarget(settings.target) else { return nil }
|
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,14 +55,14 @@ final class VoicePushToTalkHotkey {
|
||||||
}
|
}
|
||||||
|
|
||||||
let chordActive = self.optionDown
|
let chordActive = self.optionDown
|
||||||
if chordActive && !self.active {
|
if chordActive, !self.active {
|
||||||
self.active = true
|
self.active = true
|
||||||
Task {
|
Task {
|
||||||
Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt")
|
Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt")
|
||||||
.info("ptt hotkey down")
|
.info("ptt hotkey down")
|
||||||
await VoicePushToTalk.shared.begin()
|
await VoicePushToTalk.shared.begin()
|
||||||
}
|
}
|
||||||
} else if !chordActive && self.active {
|
} else if !chordActive, self.active {
|
||||||
self.active = false
|
self.active = false
|
||||||
Task {
|
Task {
|
||||||
Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt")
|
Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt")
|
||||||
|
|
@ -126,7 +126,10 @@ actor VoicePushToTalk {
|
||||||
// Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap.
|
// Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap.
|
||||||
await VoiceWakeRuntime.shared.pauseForPushToTalk()
|
await VoiceWakeRuntime.shared.pauseForPushToTalk()
|
||||||
let adoptedPrefix = self.adoptedPrefix
|
let adoptedPrefix = self.adoptedPrefix
|
||||||
let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : Self.makeAttributed(committed: adoptedPrefix, volatile: "", isFinal: false)
|
let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : Self.makeAttributed(
|
||||||
|
committed: adoptedPrefix,
|
||||||
|
volatile: "",
|
||||||
|
isFinal: false)
|
||||||
self.overlayToken = await MainActor.run {
|
self.overlayToken = await MainActor.run {
|
||||||
VoiceWakeOverlayController.shared.startSession(
|
VoiceWakeOverlayController.shared.startSession(
|
||||||
source: .pushToTalk,
|
source: .pushToTalk,
|
||||||
|
|
@ -166,7 +169,10 @@ actor VoicePushToTalk {
|
||||||
let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier)
|
let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier)
|
||||||
self.recognizer = SFSpeechRecognizer(locale: locale)
|
self.recognizer = SFSpeechRecognizer(locale: locale)
|
||||||
guard let recognizer, recognizer.isAvailable else {
|
guard let recognizer, recognizer.isAvailable else {
|
||||||
throw NSError(domain: "VoicePushToTalk", code: 1, userInfo: [NSLocalizedDescriptionKey: "Recognizer unavailable"])
|
throw NSError(
|
||||||
|
domain: "VoicePushToTalk",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Recognizer unavailable"])
|
||||||
}
|
}
|
||||||
|
|
||||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||||
|
|
@ -216,7 +222,10 @@ actor VoicePushToTalk {
|
||||||
let attributed = Self.makeAttributed(committed: committedWithPrefix, volatile: self.volatile, isFinal: isFinal)
|
let attributed = Self.makeAttributed(committed: committedWithPrefix, volatile: self.volatile, isFinal: isFinal)
|
||||||
if let token = self.overlayToken {
|
if let token = self.overlayToken {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
VoiceWakeOverlayController.shared.updatePartial(token: token, transcript: snapshot, attributed: attributed)
|
VoiceWakeOverlayController.shared.updatePartial(
|
||||||
|
token: token,
|
||||||
|
transcript: snapshot,
|
||||||
|
attributed: attributed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -238,11 +247,10 @@ actor VoicePushToTalk {
|
||||||
committed: Self.join(self.adoptedPrefix, self.committed),
|
committed: Self.join(self.adoptedPrefix, self.committed),
|
||||||
volatile: self.volatile,
|
volatile: self.volatile,
|
||||||
isFinal: true)
|
isFinal: true)
|
||||||
let forward: VoiceWakeForwardConfig
|
let forward: VoiceWakeForwardConfig = if let cached = self.activeConfig?.forwardConfig {
|
||||||
if let cached = self.activeConfig?.forwardConfig {
|
cached
|
||||||
forward = cached
|
|
||||||
} else {
|
} else {
|
||||||
forward = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
|
await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
|
||||||
}
|
}
|
||||||
let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none)
|
let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,16 @@ enum VoiceWakeChime: Codable, Equatable, Sendable {
|
||||||
var displayLabel: String {
|
var displayLabel: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none:
|
case .none:
|
||||||
return "No Sound"
|
"No Sound"
|
||||||
case let .system(name):
|
case let .system(name):
|
||||||
return VoiceWakeChimeCatalog.displayName(for: name)
|
VoiceWakeChimeCatalog.displayName(for: name)
|
||||||
case let .custom(displayName, _):
|
case let .custom(displayName, _):
|
||||||
return displayName
|
displayName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VoiceWakeChimeCatalog {
|
enum VoiceWakeChimeCatalog {
|
||||||
/// Options shown in the picker.
|
/// Options shown in the picker.
|
||||||
static var systemOptions: [String] { SoundEffectCatalog.systemOptions }
|
static var systemOptions: [String] { SoundEffectCatalog.systemOptions }
|
||||||
|
|
||||||
|
|
@ -57,12 +57,13 @@ enum VoiceWakeChimePlayer {
|
||||||
private static func sound(for chime: VoiceWakeChime) -> NSSound? {
|
private static func sound(for chime: VoiceWakeChime) -> NSSound? {
|
||||||
switch chime {
|
switch chime {
|
||||||
case .none:
|
case .none:
|
||||||
return nil
|
nil
|
||||||
|
|
||||||
case let .system(name):
|
case let .system(name):
|
||||||
return SoundEffectPlayer.sound(named: name)
|
SoundEffectPlayer.sound(named: name)
|
||||||
|
|
||||||
case let .custom(_, bookmark):
|
case let .custom(_, bookmark):
|
||||||
return SoundEffectPlayer.sound(from: bookmark)
|
SoundEffectPlayer.sound(from: bookmark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,12 @@ enum VoiceWakeForwarder {
|
||||||
?? ProcessInfo.processInfo.hostName
|
?? ProcessInfo.processInfo.hostName
|
||||||
|
|
||||||
let safeMachine = resolvedMachine.isEmpty ? "this Mac" : resolvedMachine
|
let safeMachine = resolvedMachine.isEmpty ? "this Mac" : resolvedMachine
|
||||||
return "User talked via voice recognition on \(safeMachine) - repeat prompt first + remember some words might be incorrectly transcribed.\n\n\(transcript)"
|
return """
|
||||||
|
User talked via voice recognition on \(safeMachine) - repeat prompt first \
|
||||||
|
+ remember some words might be incorrectly transcribed.
|
||||||
|
|
||||||
|
\(transcript)
|
||||||
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
static func clearCliCache() {
|
static func clearCliCache() {
|
||||||
|
|
@ -33,8 +38,8 @@ enum VoiceWakeForwarder {
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .rpcFailed(message): return message
|
case let .rpcFailed(message): message
|
||||||
case .disabled: return "Voice wake forwarding disabled"
|
case .disabled: "Voice wake forwarding disabled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -149,7 +154,7 @@ enum VoiceWakeForwarder {
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ch.isWhitespace && quote == nil {
|
if ch.isWhitespace, quote == nil {
|
||||||
flush()
|
flush()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
var isVisible: Bool = false
|
var isVisible: Bool = false
|
||||||
var forwardEnabled: Bool = false
|
var forwardEnabled: Bool = false
|
||||||
var isSending: Bool = false
|
var isSending: Bool = false
|
||||||
var attributed: NSAttributedString = NSAttributedString(string: "")
|
var attributed: NSAttributedString = .init(string: "")
|
||||||
var isOverflowing: Bool = false
|
var isOverflowing: Bool = false
|
||||||
var isEditing: Bool = false
|
var isEditing: Bool = false
|
||||||
var level: Double = 0 // normalized 0...1 speech level for UI
|
var level: Double = 0 // normalized 0...1 speech level for UI
|
||||||
|
|
@ -52,7 +52,11 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
isFinal: Bool = false) -> UUID
|
isFinal: Bool = false) -> UUID
|
||||||
{
|
{
|
||||||
let token = UUID()
|
let token = UUID()
|
||||||
self.logger.log(level: .info, "overlay session_start source=\(source.rawValue, privacy: .public) len=\(transcript.count, privacy: .public)")
|
let message = """
|
||||||
|
overlay session_start source=\(source.rawValue, privacy: .public) \
|
||||||
|
len=\(transcript.count, privacy: .public)
|
||||||
|
"""
|
||||||
|
self.logger.log(level: .info, "\(message)")
|
||||||
self.activeToken = token
|
self.activeToken = token
|
||||||
self.activeSource = source
|
self.activeSource = source
|
||||||
self.forwardConfig = nil
|
self.forwardConfig = nil
|
||||||
|
|
@ -76,7 +80,11 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
func updatePartial(token: UUID, transcript: String, attributed: NSAttributedString? = nil) {
|
func updatePartial(token: UUID, transcript: String, attributed: NSAttributedString? = nil) {
|
||||||
guard self.guardToken(token, context: "partial") else { return }
|
guard self.guardToken(token, context: "partial") else { return }
|
||||||
guard !self.model.isFinal 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)")
|
let message = """
|
||||||
|
overlay partial token=\(token.uuidString, privacy: .public) \
|
||||||
|
len=\(transcript.count, privacy: .public)
|
||||||
|
"""
|
||||||
|
self.logger.log(level: .info, "\(message)")
|
||||||
self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil
|
self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil
|
||||||
self.forwardConfig = nil
|
self.forwardConfig = nil
|
||||||
self.model.text = transcript
|
self.model.text = transcript
|
||||||
|
|
@ -99,7 +107,13 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
attributed: NSAttributedString? = nil)
|
attributed: NSAttributedString? = nil)
|
||||||
{
|
{
|
||||||
guard self.guardToken(token, context: "final") else { return }
|
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)")
|
let message = """
|
||||||
|
overlay presentFinal token=\(token.uuidString, privacy: .public) \
|
||||||
|
len=\(transcript.count, privacy: .public) \
|
||||||
|
autoSendAfter=\(delay ?? -1, privacy: .public) \
|
||||||
|
forwardEnabled=\(forwardConfig.enabled, privacy: .public)
|
||||||
|
"""
|
||||||
|
self.logger.log(level: .info, "\(message)")
|
||||||
self.autoSendTask?.cancel()
|
self.autoSendTask?.cancel()
|
||||||
self.autoSendToken = token
|
self.autoSendToken = token
|
||||||
self.forwardConfig = forwardConfig
|
self.forwardConfig = forwardConfig
|
||||||
|
|
@ -142,7 +156,13 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
|
|
||||||
func sendNow(token: UUID? = nil, sendChime: VoiceWakeChime = .none) {
|
func sendNow(token: UUID? = nil, sendChime: VoiceWakeChime = .none) {
|
||||||
guard self.guardToken(token, context: "send") else { return }
|
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)")
|
let message = """
|
||||||
|
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.logger.log(level: .info, "\(message)")
|
||||||
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
||||||
if self.model.isSending { return }
|
if self.model.isSending { return }
|
||||||
self.model.isEditing = false
|
self.model.isEditing = false
|
||||||
|
|
@ -159,7 +179,8 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
if sendChime != .none {
|
if sendChime != .none {
|
||||||
self.logger.log(level: .info, "overlay sendNow playing sendChime=\(String(describing: sendChime), privacy: .public)")
|
let message = "overlay sendNow playing sendChime=\(String(describing: sendChime), privacy: .public)"
|
||||||
|
self.logger.log(level: .info, "\(message)")
|
||||||
VoiceWakeChimePlayer.play(sendChime, reason: "overlay.send")
|
VoiceWakeChimePlayer.play(sendChime, reason: "overlay.send")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,7 +197,14 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
|
|
||||||
func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) {
|
func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) {
|
||||||
guard self.guardToken(token, context: "dismiss") else { return }
|
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)")
|
let message = """
|
||||||
|
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.logger.log(level: .info, "\(message)")
|
||||||
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
||||||
self.model.isSending = false
|
self.model.isSending = false
|
||||||
self.model.isEditing = false
|
self.model.isEditing = false
|
||||||
|
|
@ -237,7 +265,9 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
guard let window else { return }
|
guard let window else { return }
|
||||||
if !self.model.isVisible {
|
if !self.model.isVisible {
|
||||||
self.model.isVisible = true
|
self.model.isVisible = true
|
||||||
self.logger.log(level: .info, "overlay present windowShown textLen=\(self.model.text.count, privacy: .public)")
|
self.logger.log(
|
||||||
|
level: .info,
|
||||||
|
"overlay present windowShown textLen=\(self.model.text.count, privacy: .public)")
|
||||||
// Keep the status item in “listening” mode until we explicitly dismiss the overlay.
|
// Keep the status item in “listening” mode until we explicitly dismiss the overlay.
|
||||||
AppStateStore.shared.triggerVoiceEars(ttl: nil)
|
AppStateStore.shared.triggerVoiceEars(ttl: nil)
|
||||||
let start = target.offsetBy(dx: 0, dy: -6)
|
let start = target.offsetBy(dx: 0, dy: -6)
|
||||||
|
|
@ -309,7 +339,8 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func measuredHeight() -> CGFloat {
|
private func measuredHeight() -> CGFloat {
|
||||||
let attributed = self.model.attributed.length > 0 ? self.model.attributed : self.makeAttributed(from: self.model.text)
|
let attributed = self.model.attributed.length > 0 ? self.model.attributed : self
|
||||||
|
.makeAttributed(from: self.model.text)
|
||||||
let maxWidth = self.width - (self.padding * 2) - self.spacing - self.buttonWidth
|
let maxWidth = self.width - (self.padding * 2) - self.spacing - self.buttonWidth
|
||||||
|
|
||||||
let textInset = NSSize(width: 2, height: 6)
|
let textInset = NSSize(width: 2, height: 6)
|
||||||
|
|
@ -350,7 +381,13 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleAutoSend(token: UUID, after delay: TimeInterval, sendChime: VoiceWakeChime) {
|
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.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.autoSendToken = token
|
self.autoSendToken = token
|
||||||
self.autoSendTask = Task<Void, Never> { [weak self, sendChime, token] in
|
self.autoSendTask = Task<Void, Never> { [weak self, sendChime, token] in
|
||||||
|
|
@ -360,7 +397,9 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
guard self.guardToken(token, context: "autoSend") else { return }
|
guard self.guardToken(token, context: "autoSend") else { return }
|
||||||
self.logger.log(level: .info, "overlay autoSend firing token=\(token.uuidString, privacy: .public)")
|
self.logger.log(
|
||||||
|
level: .info,
|
||||||
|
"overlay autoSend firing token=\(token.uuidString, privacy: .public)")
|
||||||
self.sendNow(token: token, sendChime: sendChime)
|
self.sendNow(token: token, sendChime: sendChime)
|
||||||
self.autoSendTask = nil
|
self.autoSendTask = nil
|
||||||
}
|
}
|
||||||
|
|
@ -376,6 +415,7 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct VoiceWakeOverlayView: View {
|
private struct VoiceWakeOverlayView: View {
|
||||||
@ObservedObject var controller: VoiceWakeOverlayController
|
@ObservedObject var controller: VoiceWakeOverlayController
|
||||||
@FocusState private var textFocused: Bool
|
@FocusState private var textFocused: Bool
|
||||||
|
|
@ -469,9 +509,8 @@ private struct VoiceWakeOverlayView: View {
|
||||||
// Close button rendered above and outside the clipped bubble
|
// Close button rendered above and outside the clipped bubble
|
||||||
CloseButtonOverlay(
|
CloseButtonOverlay(
|
||||||
isVisible: self.controller.model.isEditing || self.isHovering || self.closeHovering,
|
isVisible: self.controller.model.isEditing || self.isHovering || self.closeHovering,
|
||||||
onHover: { self.closeHovering = $0 }) {
|
onHover: { self.closeHovering = $0 },
|
||||||
self.controller.cancelEditingAndDismiss()
|
onClose: { self.controller.cancelEditingAndDismiss() })
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.top, self.controller.closeOverflow)
|
.padding(.top, self.controller.closeOverflow)
|
||||||
.padding(.leading, self.controller.closeOverflow)
|
.padding(.leading, self.controller.closeOverflow)
|
||||||
|
|
@ -629,7 +668,6 @@ private struct VibrantLabelView: NSViewRepresentable {
|
||||||
label.attributedStringValue = self.attributed.strippingForegroundColor()
|
label.attributedStringValue = self.attributed.strippingForegroundColor()
|
||||||
label.textColor = .labelColor
|
label.textColor = .labelColor
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class ClickCatcher: NSView {
|
private final class ClickCatcher: NSView {
|
||||||
|
|
@ -675,8 +713,8 @@ private struct CloseButtonOverlay: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if isVisible {
|
if self.isVisible {
|
||||||
Button(action: onClose) {
|
Button(action: self.onClose) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 12, weight: .bold))
|
.font(.system(size: 12, weight: .bold))
|
||||||
.foregroundColor(Color.white.opacity(0.9))
|
.foregroundColor(Color.white.opacity(0.9))
|
||||||
|
|
@ -695,7 +733,7 @@ private struct CloseButtonOverlay: View {
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.allowsHitTesting(isVisible)
|
.allowsHitTesting(self.isVisible)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -723,7 +761,7 @@ private final class TranscriptNSTextView: NSTextView {
|
||||||
self.onEscape?()
|
self.onEscape?()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if isReturn && event.modifierFlags.contains(.command) {
|
if isReturn, event.modifierFlags.contains(.command) {
|
||||||
self.onSend?()
|
self.onSend?()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,12 @@ actor VoiceWakeRuntime {
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let transcript = result?.bestTranscription.formattedString
|
let transcript = result?.bestTranscription.formattedString
|
||||||
let isFinal = result?.isFinal ?? false
|
let isFinal = result?.isFinal ?? false
|
||||||
Task { await self.handleRecognition(transcript: transcript, isFinal: isFinal, error: error, config: config, generation: generation) }
|
Task { await self.handleRecognition(
|
||||||
|
transcript: transcript,
|
||||||
|
isFinal: isFinal,
|
||||||
|
error: error,
|
||||||
|
config: config,
|
||||||
|
generation: generation) }
|
||||||
}
|
}
|
||||||
|
|
||||||
self.logger.info("voicewake runtime started")
|
self.logger.info("voicewake runtime started")
|
||||||
|
|
@ -213,7 +218,10 @@ actor VoiceWakeRuntime {
|
||||||
let snapshot = self.committedTranscript + self.volatileTranscript
|
let snapshot = self.committedTranscript + self.volatileTranscript
|
||||||
if let token = self.overlayToken {
|
if let token = self.overlayToken {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
VoiceWakeOverlayController.shared.updatePartial(token: token, transcript: snapshot, attributed: attributed)
|
VoiceWakeOverlayController.shared.updatePartial(
|
||||||
|
token: token,
|
||||||
|
transcript: snapshot,
|
||||||
|
attributed: attributed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,10 @@ struct VoiceWakeSettings: View {
|
||||||
|
|
||||||
SettingsToggleRow(
|
SettingsToggleRow(
|
||||||
title: "Hold Right Option to talk",
|
title: "Hold Right Option to talk",
|
||||||
subtitle: "Push-to-talk mode that starts listening while you hold the key and shows the preview overlay.",
|
subtitle: """
|
||||||
|
Push-to-talk mode that starts listening while you hold the key
|
||||||
|
and shows the preview overlay.
|
||||||
|
""",
|
||||||
binding: self.$state.voicePushToTalkEnabled)
|
binding: self.$state.voicePushToTalkEnabled)
|
||||||
.disabled(!voiceWakeSupported)
|
.disabled(!voiceWakeSupported)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,10 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||||
private func bootstrap() async {
|
private func bootstrap() async {
|
||||||
do {
|
do {
|
||||||
guard AppStateStore.webChatEnabled else {
|
guard AppStateStore.webChatEnabled else {
|
||||||
throw NSError(domain: "WebChat", code: 5, userInfo: [NSLocalizedDescriptionKey: "Web chat disabled in settings"])
|
throw NSError(
|
||||||
|
domain: "WebChat",
|
||||||
|
code: 5,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Web chat disabled in settings"])
|
||||||
}
|
}
|
||||||
let endpoint = try await self.prepareEndpoint(remotePort: self.remotePort)
|
let endpoint = try await self.prepareEndpoint(remotePort: self.remotePort)
|
||||||
self.baseEndpoint = endpoint
|
self.baseEndpoint = endpoint
|
||||||
|
|
@ -90,9 +93,9 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||||
|
|
||||||
private func prepareEndpoint(remotePort: Int) async throws -> URL {
|
private func prepareEndpoint(remotePort: Int) async throws -> URL {
|
||||||
if CommandResolver.connectionModeIsRemote() {
|
if CommandResolver.connectionModeIsRemote() {
|
||||||
return try await self.startOrRestartTunnel()
|
try await self.startOrRestartTunnel()
|
||||||
} else {
|
} else {
|
||||||
return URL(string: "http://127.0.0.1:\(remotePort)/")!
|
URL(string: "http://127.0.0.1:\(remotePort)/")!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,11 +119,17 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||||
let (_, response) = try await session.data(for: request)
|
let (_, response) = try await session.data(for: request)
|
||||||
if let http = response as? HTTPURLResponse {
|
if let http = response as? HTTPURLResponse {
|
||||||
guard (200..<500).contains(http.statusCode) else {
|
guard (200..<500).contains(http.statusCode) else {
|
||||||
throw NSError(domain: "WebChat", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: "webchat returned HTTP \(http.statusCode)"])
|
throw NSError(
|
||||||
|
domain: "WebChat",
|
||||||
|
code: http.statusCode,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "webchat returned HTTP \(http.statusCode)"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
throw NSError(domain: "WebChat", code: 7, userInfo: [NSLocalizedDescriptionKey: "webchat unreachable: \(error.localizedDescription)"])
|
throw NSError(
|
||||||
|
domain: "WebChat",
|
||||||
|
code: 7,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "webchat unreachable: \(error.localizedDescription)"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,7 +137,7 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||||
// Kill existing tunnel if any
|
// Kill existing tunnel if any
|
||||||
self.stopTunnel(allowRestart: false)
|
self.stopTunnel(allowRestart: false)
|
||||||
|
|
||||||
let tunnel = try await WebChatTunnel.create(remotePort: self.remotePort, preferredLocalPort: 18_788)
|
let tunnel = try await WebChatTunnel.create(remotePort: self.remotePort, preferredLocalPort: 18788)
|
||||||
self.tunnel = tunnel
|
self.tunnel = tunnel
|
||||||
self.tunnelRestartEnabled = true
|
self.tunnelRestartEnabled = true
|
||||||
|
|
||||||
|
|
@ -162,7 +171,8 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||||
|
|
||||||
private func showError(_ text: String) {
|
private func showError(_ text: String) {
|
||||||
let html = """
|
let html = """
|
||||||
<html><body style='font-family:-apple-system;padding:24px;color:#c00'>Web chat failed to connect.<br><br>\(text)</body></html>
|
<html><body style='font-family:-apple-system;padding:24px;color:#c00'>Web chat failed to connect.<br><br>\(
|
||||||
|
text)</body></html>
|
||||||
"""
|
"""
|
||||||
self.webView.loadHTMLString(html, baseURL: nil)
|
self.webView.loadHTMLString(html, baseURL: nil)
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +257,7 @@ final class WebChatTunnel {
|
||||||
"-o", "ServerAliveCountMax=3",
|
"-o", "ServerAliveCountMax=3",
|
||||||
"-o", "TCPKeepAlive=yes",
|
"-o", "TCPKeepAlive=yes",
|
||||||
"-N",
|
"-N",
|
||||||
"-L", "\(localPort):127.0.0.1:\(remotePort)"
|
"-L", "\(localPort):127.0.0.1:\(remotePort)",
|
||||||
]
|
]
|
||||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
@ -263,7 +273,9 @@ final class WebChatTunnel {
|
||||||
// Consume stderr so ssh cannot block if it logs
|
// Consume stderr so ssh cannot block if it logs
|
||||||
pipe.fileHandleForReading.readabilityHandler = { handle in
|
pipe.fileHandleForReading.readabilityHandler = { handle in
|
||||||
let data = handle.availableData
|
let data = handle.availableData
|
||||||
guard !data.isEmpty, let line = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !line.isEmpty else { return }
|
guard !data.isEmpty,
|
||||||
|
let line = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!line.isEmpty else { return }
|
||||||
webChatLogger.error("webchat tunnel stderr: \(line, privacy: .public)")
|
webChatLogger.error("webchat tunnel stderr: \(line, privacy: .public)")
|
||||||
}
|
}
|
||||||
try process.run()
|
try process.run()
|
||||||
|
|
@ -272,7 +284,7 @@ final class WebChatTunnel {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func findPort(preferred: UInt16?) async throws -> UInt16 {
|
private static func findPort(preferred: UInt16?) async throws -> UInt16 {
|
||||||
if let preferred, Self.portIsFree(preferred) { return preferred }
|
if let preferred, portIsFree(preferred) { return preferred }
|
||||||
|
|
||||||
return try await withCheckedThrowingContinuation { cont in
|
return try await withCheckedThrowingContinuation { cont in
|
||||||
let queue = DispatchQueue(label: "com.steipete.clawdis.webchat.port", qos: .utility)
|
let queue = DispatchQueue(label: "com.steipete.clawdis.webchat.port", qos: .utility)
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,8 @@ final class WorkActivityStore: ObservableObject {
|
||||||
phase: String,
|
phase: String,
|
||||||
name: String?,
|
name: String?,
|
||||||
meta: String?,
|
meta: String?,
|
||||||
args: [String: AnyCodable]?
|
args: [String: AnyCodable]?)
|
||||||
) {
|
{
|
||||||
let toolKind = Self.mapToolKind(name)
|
let toolKind = Self.mapToolKind(name)
|
||||||
let label = Self.buildLabel(kind: toolKind, meta: meta, args: args)
|
let label = Self.buildLabel(kind: toolKind, meta: meta, args: args)
|
||||||
if phase.lowercased() == "start" {
|
if phase.lowercased() == "start" {
|
||||||
|
|
@ -124,7 +124,7 @@ final class WorkActivityStore: ObservableObject {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Otherwise, pick most recent by lastUpdate.
|
// Otherwise, pick most recent by lastUpdate.
|
||||||
if let next = self.active.values.sorted(by: { $0.lastUpdate > $1.lastUpdate }).first {
|
if let next = self.active.values.max(by: { $0.lastUpdate < $1.lastUpdate }) {
|
||||||
self.currentSessionKey = next.sessionKey
|
self.currentSessionKey = next.sessionKey
|
||||||
} else {
|
} else {
|
||||||
self.currentSessionKey = nil
|
self.currentSessionKey = nil
|
||||||
|
|
@ -145,20 +145,20 @@ final class WorkActivityStore: ObservableObject {
|
||||||
|
|
||||||
private static func mapToolKind(_ name: String?) -> ToolKind {
|
private static func mapToolKind(_ name: String?) -> ToolKind {
|
||||||
switch name?.lowercased() {
|
switch name?.lowercased() {
|
||||||
case "bash", "shell": return .bash
|
case "bash", "shell": .bash
|
||||||
case "read": return .read
|
case "read": .read
|
||||||
case "write": return .write
|
case "write": .write
|
||||||
case "edit": return .edit
|
case "edit": .edit
|
||||||
case "attach": return .attach
|
case "attach": .attach
|
||||||
default: return .other
|
default: .other
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func buildLabel(
|
private static func buildLabel(
|
||||||
kind: ToolKind,
|
kind: ToolKind,
|
||||||
meta: String?,
|
meta: String?,
|
||||||
args: [String: AnyCodable]?
|
args: [String: AnyCodable]?) -> String
|
||||||
) -> String {
|
{
|
||||||
switch kind {
|
switch kind {
|
||||||
case .bash:
|
case .bash:
|
||||||
if let cmd = args?["command"]?.value as? String {
|
if let cmd = args?["command"]?.value as? String {
|
||||||
|
|
@ -166,7 +166,7 @@ final class WorkActivityStore: ObservableObject {
|
||||||
}
|
}
|
||||||
return "bash"
|
return "bash"
|
||||||
case .read, .write, .edit, .attach:
|
case .read, .write, .edit, .attach:
|
||||||
if let path = Self.extractPath(args: args, meta: meta) {
|
if let path = extractPath(args: args, meta: meta) {
|
||||||
return "\(kind.rawValue): \(path)"
|
return "\(kind.rawValue): \(path)"
|
||||||
}
|
}
|
||||||
return kind.rawValue
|
return kind.rawValue
|
||||||
|
|
@ -179,9 +179,9 @@ final class WorkActivityStore: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func extractPath(args: [String: AnyCodable]?, meta: String?) -> String? {
|
private static func extractPath(args: [String: AnyCodable]?, meta: String?) -> String? {
|
||||||
if let p = args?["path"]?.value as? String { return shortenHome(path: p) }
|
if let p = args?["path"]?.value as? String { return self.shortenHome(path: p) }
|
||||||
if let p = args?["file_path"]?.value as? String { return shortenHome(path: p) }
|
if let p = args?["file_path"]?.value as? String { return self.shortenHome(path: p) }
|
||||||
if let meta { return shortenHome(path: meta) }
|
if let meta { return self.shortenHome(path: meta) }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,8 @@ struct ClawdisCLI {
|
||||||
clawdis-mac run [--cwd <path>] [--env KEY=VAL] [--timeout <sec>] [--needs-screen-recording] <command ...>
|
clawdis-mac run [--cwd <path>] [--env KEY=VAL] [--timeout <sec>] [--needs-screen-recording] <command ...>
|
||||||
clawdis-mac status
|
clawdis-mac status
|
||||||
clawdis-mac rpc-status
|
clawdis-mac rpc-status
|
||||||
clawdis-mac agent --message <text> [--thinking <low|default|high>] [--session <key>] [--deliver] [--to <E.164>]
|
clawdis-mac agent --message <text> [--thinking <low|default|high>]
|
||||||
|
[--session <key>] [--deliver] [--to <E.164>]
|
||||||
clawdis-mac --help
|
clawdis-mac --help
|
||||||
|
|
||||||
Returns JSON to stdout:
|
Returns JSON to stdout:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
@testable import Clawdis
|
|
||||||
import Testing
|
import Testing
|
||||||
|
@testable import Clawdis
|
||||||
@testable import ClawdisIPC
|
@testable import ClawdisIPC
|
||||||
|
|
||||||
@Suite(.serialized) struct AgentRPCTests {
|
@Suite(.serialized) struct AgentRPCTests {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ import Testing
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeExec(at path: URL) throws {
|
private func makeExec(at path: URL) throws {
|
||||||
try FileManager.default.createDirectory(at: path.deletingLastPathComponent(),
|
try FileManager.default.createDirectory(
|
||||||
|
at: path.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
|
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
||||||
|
|
@ -23,7 +24,7 @@ import Testing
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
|
|
||||||
let clawdisPath = tmp.appendingPathComponent("node_modules/.bin/clawdis")
|
let clawdisPath = tmp.appendingPathComponent("node_modules/.bin/clawdis")
|
||||||
try makeExec(at: clawdisPath)
|
try self.makeExec(at: clawdisPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdisCommand(subcommand: "relay")
|
let cmd = CommandResolver.clawdisCommand(subcommand: "relay")
|
||||||
#expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "relay"]))
|
#expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "relay"]))
|
||||||
|
|
@ -35,10 +36,10 @@ import Testing
|
||||||
|
|
||||||
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
|
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
|
||||||
let scriptPath = tmp.appendingPathComponent("bin/clawdis.js")
|
let scriptPath = tmp.appendingPathComponent("bin/clawdis.js")
|
||||||
try makeExec(at: nodePath)
|
try self.makeExec(at: nodePath)
|
||||||
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
||||||
try makeExec(at: scriptPath)
|
try self.makeExec(at: scriptPath)
|
||||||
|
|
||||||
let previous = getenv("CLAWDIS_RUNTIME").flatMap { String(validatingCString: $0) }
|
let previous = getenv("CLAWDIS_RUNTIME").flatMap { String(validatingCString: $0) }
|
||||||
setenv("CLAWDIS_RUNTIME", "node", 1)
|
setenv("CLAWDIS_RUNTIME", "node", 1)
|
||||||
|
|
@ -63,7 +64,7 @@ import Testing
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
|
|
||||||
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
||||||
try makeExec(at: pnpmPath)
|
try self.makeExec(at: pnpmPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdisCommand(subcommand: "rpc")
|
let cmd = CommandResolver.clawdisCommand(subcommand: "rpc")
|
||||||
|
|
||||||
|
|
@ -75,7 +76,7 @@ import Testing
|
||||||
CommandResolver.setProjectRoot(tmp.path)
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
|
|
||||||
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
||||||
try makeExec(at: pnpmPath)
|
try self.makeExec(at: pnpmPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdisCommand(subcommand: "health", extraArgs: ["--json", "--timeout", "5"])
|
let cmd = CommandResolver.clawdisCommand(subcommand: "health", extraArgs: ["--json", "--timeout", "5"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,10 @@ import Testing
|
||||||
@testable import Clawdis
|
@testable import Clawdis
|
||||||
|
|
||||||
@Suite struct HealthDecodeTests {
|
@Suite struct HealthDecodeTests {
|
||||||
private let sampleJSON: String = {
|
private let sampleJSON: String = // minimal but complete payload
|
||||||
// minimal but complete payload
|
|
||||||
"""
|
"""
|
||||||
{"ts":1733622000,"durationMs":420,"web":{"linked":true,"authAgeMs":120000,"connect":{"ok":true,"status":200,"error":null,"elapsedMs":800}},"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]},"ipc":{"path":"/tmp/ipc.sock","exists":true}}
|
{"ts":1733622000,"durationMs":420,"web":{"linked":true,"authAgeMs":120000,"connect":{"ok":true,"status":200,"error":null,"elapsedMs":800}},"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]},"ipc":{"path":"/tmp/ipc.sock","exists":true}}
|
||||||
"""
|
"""
|
||||||
}()
|
|
||||||
|
|
||||||
@Test func decodesCleanJSON() async throws {
|
@Test func decodesCleanJSON() async throws {
|
||||||
let data = Data(sampleJSON.utf8)
|
let data = Data(sampleJSON.utf8)
|
||||||
|
|
@ -20,7 +18,7 @@ import Testing
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func decodesWithLeadingNoise() async throws {
|
@Test func decodesWithLeadingNoise() async throws {
|
||||||
let noisy = "debug: something logged\n" + sampleJSON + "\ntrailer"
|
let noisy = "debug: something logged\n" + self.sampleJSON + "\ntrailer"
|
||||||
let snap = decodeHealthSnapshot(from: Data(noisy.utf8))
|
let snap = decodeHealthSnapshot(from: Data(noisy.utf8))
|
||||||
|
|
||||||
#expect(snap?.web.connect?.status == 200)
|
#expect(snap?.web.connect?.status == 200)
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,3 @@ import Testing
|
||||||
#expect(true)
|
#expect(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ import Testing
|
||||||
@Test func trimsAfterFirstMatchingTrigger() {
|
@Test func trimsAfterFirstMatchingTrigger() {
|
||||||
let triggers = ["buddy", "claude"]
|
let triggers = ["buddy", "claude"]
|
||||||
let text = "hello buddy this is after trigger claude also here"
|
let text = "hello buddy this is after trigger claude also here"
|
||||||
#expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "this is after trigger claude also here")
|
#expect(VoiceWakeRuntime
|
||||||
|
._testTrimmedAfterTrigger(text, triggers: triggers) == "this is after trigger claude also here")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func hasContentAfterTriggerFalseWhenOnlyTrigger() {
|
@Test func hasContentAfterTriggerFalseWhenOnlyTrigger() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue