chore(mac): apply swiftformat and lint fixes
This commit is contained in:
@@ -9,8 +9,8 @@ final class AgentEventStore: ObservableObject {
|
||||
|
||||
func append(_ event: ControlAgentEvent) {
|
||||
self.events.append(event)
|
||||
if self.events.count > maxEvents {
|
||||
self.events.removeFirst(self.events.count - maxEvents)
|
||||
if self.events.count > self.maxEvents {
|
||||
self.events.removeFirst(self.events.count - self.maxEvents)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@ struct AgentEventsWindow: View {
|
||||
Text("Agent Events")
|
||||
.font(.title3.weight(.semibold))
|
||||
Spacer()
|
||||
Button("Clear") { store.clear() }
|
||||
Button("Clear") { self.store.clear() }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
|
||||
ScrollView {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -34,14 +34,14 @@ private struct EventRow: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(event.stream.uppercased())
|
||||
Text(self.event.stream.uppercased())
|
||||
.font(.caption2.weight(.bold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(self.tint)
|
||||
.foregroundStyle(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
|
||||
Text("run " + event.runId)
|
||||
Text("run " + self.event.runId)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
@@ -61,16 +61,15 @@ private struct EventRow: View {
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color.primary.opacity(0.04))
|
||||
)
|
||||
.fill(Color.primary.opacity(0.04)))
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch event.stream {
|
||||
case "job": return .blue
|
||||
case "tool": return .orange
|
||||
case "assistant": return .green
|
||||
default: return .gray
|
||||
switch self.event.stream {
|
||||
case "job": .blue
|
||||
case "tool": .orange
|
||||
case "assistant": .green
|
||||
default: .gray
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
struct ControlRequestParams: @unchecked Sendable {
|
||||
|
||||
@@ -182,7 +182,8 @@ final class AppState: ObservableObject {
|
||||
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
|
||||
}
|
||||
if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey),
|
||||
let selection = IconOverrideSelection(rawValue: storedOverride) {
|
||||
let selection = IconOverrideSelection(rawValue: storedOverride)
|
||||
{
|
||||
self.iconOverride = selection
|
||||
} else {
|
||||
self.iconOverride = .system
|
||||
|
||||
@@ -108,7 +108,11 @@ struct ConfigSettings: View {
|
||||
.frame(width: 100)
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 480, alignment: .leading)
|
||||
|
||||
@@ -113,8 +113,7 @@ struct CritterStatusLabel: View {
|
||||
.padding(3)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(self.iconState.tint.opacity(0.9))
|
||||
)
|
||||
.fill(self.iconState.tint.opacity(0.9)))
|
||||
.foregroundStyle(Color.white)
|
||||
.offset(x: -4, y: -2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
||||
|
||||
@@ -194,7 +194,10 @@ struct DebugSettings: View {
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
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)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -289,7 +292,10 @@ struct DebugSettings: View {
|
||||
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 shouldForward = config.enabled
|
||||
|
||||
|
||||
@@ -137,57 +137,57 @@ struct GeneralSettings: View {
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
switch self.remoteStatus {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .checking:
|
||||
Text("Checking…").font(.caption).foregroundStyle(.secondary)
|
||||
switch self.remoteStatus {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .checking:
|
||||
Text("Checking…").font(.caption).foregroundStyle(.secondary)
|
||||
case .ok:
|
||||
Label("Ready", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
case let .failed(message):
|
||||
Text(message)
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostics
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Control channel")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(self.controlStatusLine)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if let ping = ControlChannel.shared.lastPingMs {
|
||||
Text("Last ping: \(Int(ping)) ms")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let hb = HeartbeatStore.shared.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000))
|
||||
Text("Last heartbeat: \(hb.status) · \(ageText)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostics
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Control channel")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(self.controlStatusLine)
|
||||
.font(.caption)
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if let ping = ControlChannel.shared.lastPingMs {
|
||||
Text("Last ping: \(Int(ping)) ms")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let hb = HeartbeatStore.shared.lastEvent {
|
||||
let ageText = age(from: Date(timeIntervalSince1970: hb.ts / 1000))
|
||||
Text("Last heartbeat: \(hb.status) · \(ageText)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
private var controlStatusLine: String {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected: return "Connected"
|
||||
case .connecting: return "Connecting…"
|
||||
case .disconnected: return "Disconnected"
|
||||
case let .degraded(msg): return "Degraded: \(msg)"
|
||||
case .connected: "Connected"
|
||||
case .connecting: "Connecting…"
|
||||
case .disconnected: "Disconnected"
|
||||
case let .degraded(msg): "Degraded: \(msg)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,8 +276,10 @@ struct GeneralSettings: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let recent = snap.sessions.recent.first {
|
||||
Text(
|
||||
"Last activity: \(recent.key) \(recent.updatedAt != nil ? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000)) : "unknown")")
|
||||
let lastActivity = recent.updatedAt != nil
|
||||
? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000))
|
||||
: "unknown"
|
||||
Text("Last activity: \(recent.key) \(lastActivity)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -386,7 +388,9 @@ extension GeneralSettings {
|
||||
// Step 2: control channel health over tunnel
|
||||
let originalMode = AppStateStore.shared.connectionMode
|
||||
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)
|
||||
if decodeHealthSnapshot(from: data) != nil {
|
||||
self.remoteStatus = .ok
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
import Network
|
||||
|
||||
struct HealthSnapshot: Codable, Sendable {
|
||||
struct Web: Codable, Sendable {
|
||||
|
||||
@@ -13,12 +13,13 @@ final class HeartbeatStore: ObservableObject {
|
||||
self.observer = NotificationCenter.default.addObserver(
|
||||
forName: .controlHeartbeat,
|
||||
object: nil,
|
||||
queue: .main) { [weak self] note in
|
||||
guard let data = note.object as? Data else { return }
|
||||
if let decoded = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) {
|
||||
Task { @MainActor in self?.lastEvent = decoded }
|
||||
}
|
||||
queue: .main)
|
||||
{ [weak self] note in
|
||||
guard let data = note.object as? Data else { return }
|
||||
if let decoded = try? JSONDecoder().decode(ControlHeartbeatEvent.self, from: data) {
|
||||
Task { @MainActor in self?.lastEvent = decoded }
|
||||
}
|
||||
}
|
||||
|
||||
Task {
|
||||
if self.lastEvent == nil {
|
||||
|
||||
@@ -23,28 +23,28 @@ enum IconState: Equatable {
|
||||
|
||||
var glyph: String {
|
||||
switch self.activity {
|
||||
case .tool(.bash): return "💻"
|
||||
case .tool(.read): return "📄"
|
||||
case .tool(.write): return "✍️"
|
||||
case .tool(.edit): return "📝"
|
||||
case .tool(.attach): return "📎"
|
||||
case .tool(.other), .job: return "🛠️"
|
||||
case .tool(.bash): "💻"
|
||||
case .tool(.read): "📄"
|
||||
case .tool(.write): "✍️"
|
||||
case .tool(.edit): "📝"
|
||||
case .tool(.attach): "📎"
|
||||
case .tool(.other), .job: "🛠️"
|
||||
}
|
||||
}
|
||||
|
||||
var tint: Color {
|
||||
switch self {
|
||||
case .workingMain: return .accentColor
|
||||
case .workingOther: return .gray
|
||||
case .overridden: return .orange
|
||||
case .idle: return .clear
|
||||
case .workingMain: .accentColor
|
||||
case .workingOther: .gray
|
||||
case .overridden: .orange
|
||||
case .idle: .clear
|
||||
}
|
||||
}
|
||||
|
||||
var isWorking: Bool {
|
||||
switch self {
|
||||
case .idle: return false
|
||||
default: return true
|
||||
case .idle: false
|
||||
default: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,9 @@ enum IconState: Equatable {
|
||||
case let .workingMain(kind),
|
||||
let .workingOther(kind),
|
||||
let .overridden(kind):
|
||||
return kind
|
||||
kind
|
||||
case .idle:
|
||||
return .job
|
||||
.job
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,18 +70,18 @@ enum IconOverrideSelection: String, CaseIterable, Identifiable {
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .system: return "System (auto)"
|
||||
case .idle: return "Idle"
|
||||
case .mainBash: return "Working main – bash"
|
||||
case .mainRead: return "Working main – read"
|
||||
case .mainWrite: return "Working main – write"
|
||||
case .mainEdit: return "Working main – edit"
|
||||
case .mainOther: return "Working main – other"
|
||||
case .otherBash: return "Working other – bash"
|
||||
case .otherRead: return "Working other – read"
|
||||
case .otherWrite: return "Working other – write"
|
||||
case .otherEdit: return "Working other – edit"
|
||||
case .otherOther: return "Working other – other"
|
||||
case .system: "System (auto)"
|
||||
case .idle: "Idle"
|
||||
case .mainBash: "Working main – bash"
|
||||
case .mainRead: "Working main – read"
|
||||
case .mainWrite: "Working main – write"
|
||||
case .mainEdit: "Working main – edit"
|
||||
case .mainOther: "Working main – other"
|
||||
case .otherBash: "Working other – bash"
|
||||
case .otherRead: "Working other – read"
|
||||
case .otherWrite: "Working other – write"
|
||||
case .otherEdit: "Working other – edit"
|
||||
case .otherOther: "Working other – other"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,24 +5,24 @@ struct InstancesSettings: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
header
|
||||
self.header
|
||||
if let err = store.lastError {
|
||||
Text("Error: \(err)")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if store.instances.isEmpty {
|
||||
if self.store.instances.isEmpty {
|
||||
Text("No instances reported yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
List(store.instances) { inst in
|
||||
instanceRow(inst)
|
||||
List(self.store.instances) { inst in
|
||||
self.instanceRow(inst)
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.onAppear { store.start() }
|
||||
.onDisappear { store.stop() }
|
||||
.onAppear { self.store.start() }
|
||||
.onDisappear { self.store.stop() }
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
@@ -35,10 +35,10 @@ struct InstancesSettings: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if store.isLoading {
|
||||
if self.store.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button("Refresh") { Task { await store.refresh() } }
|
||||
Button("Refresh") { Task { await self.store.refresh() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,12 +52,12 @@ struct InstancesSettings: View {
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
if let version = inst.version {
|
||||
label(icon: "shippingbox", text: version)
|
||||
self.label(icon: "shippingbox", text: version)
|
||||
}
|
||||
label(icon: "clock", text: inst.lastInputDescription)
|
||||
if let mode = inst.mode { label(icon: "network", text: mode) }
|
||||
self.label(icon: "clock", text: inst.lastInputDescription)
|
||||
if let mode = inst.mode { self.label(icon: "network", text: mode) }
|
||||
if let reason = inst.reason, !reason.isEmpty {
|
||||
label(icon: "info.circle", text: reason)
|
||||
self.label(icon: "info.circle", text: reason)
|
||||
}
|
||||
}
|
||||
Text(inst.text)
|
||||
|
||||
@@ -64,6 +64,7 @@ final class InstancesStore: ObservableObject {
|
||||
self.logger.error("instances fetch returned empty payload")
|
||||
self.instances = [self.localFallbackInstance()]
|
||||
self.lastError = "No presence data returned from relay yet."
|
||||
await self.probeHealthIfNeeded()
|
||||
return
|
||||
}
|
||||
let decoded = try JSONDecoder().decode([InstanceInfo].self, from: data)
|
||||
@@ -83,6 +84,7 @@ final class InstancesStore: ObservableObject {
|
||||
if withIDs.isEmpty {
|
||||
self.instances = [self.localFallbackInstance()]
|
||||
self.lastError = nil
|
||||
await self.probeHealthIfNeeded()
|
||||
} else {
|
||||
self.instances = withIDs
|
||||
self.lastError = nil
|
||||
@@ -93,10 +95,10 @@ final class InstancesStore: ObservableObject {
|
||||
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)"
|
||||
await self.probeHealthIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +128,7 @@ final class InstancesStore: ObservableObject {
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>? = nil
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
@@ -143,10 +145,18 @@ final class InstancesStore: ObservableObject {
|
||||
|
||||
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)
|
||||
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)
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
|
||||
if name == "en0" { en0 = ip; break }
|
||||
if fallback == nil { fallback = ip }
|
||||
@@ -169,4 +179,27 @@ final class InstancesStore: ObservableObject {
|
||||
}
|
||||
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)
|
||||
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
||||
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)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -259,7 +262,10 @@ struct OnboardingView: View {
|
||||
Text("Link WhatsApp")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
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)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -273,11 +279,17 @@ struct OnboardingView: View {
|
||||
systemImage: "terminal")
|
||||
self.featureRow(
|
||||
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")
|
||||
self.featureRow(
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -290,8 +302,10 @@ struct OnboardingView: View {
|
||||
self.onboardingCard {
|
||||
self.featureRow(
|
||||
title: "Run the dashboard",
|
||||
subtitle: "Use the CLI helper from your scripts, and reopen onboarding from "
|
||||
+ "Settings if you add a new user.",
|
||||
subtitle: """
|
||||
Use the CLI helper from your scripts, and reopen onboarding from Settings
|
||||
if you add a new user.
|
||||
""",
|
||||
systemImage: "checkmark.seal")
|
||||
self.featureRow(
|
||||
title: "Test a notification",
|
||||
|
||||
@@ -69,7 +69,7 @@ final class PresenceReporter {
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>? = nil
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
@@ -86,10 +86,18 @@ final class PresenceReporter {
|
||||
|
||||
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)
|
||||
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)
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
|
||||
if name == "en0" { en0 = ip; break }
|
||||
if fallback == nil { fallback = ip }
|
||||
|
||||
@@ -10,7 +10,7 @@ struct RuntimeVersion: Comparable, CustomStringConvertible {
|
||||
let minor: 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 {
|
||||
if lhs.major != rhs.major { return lhs.major < rhs.major }
|
||||
@@ -41,7 +41,12 @@ struct RuntimeResolution {
|
||||
|
||||
enum RuntimeResolutionError: Error {
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -51,22 +56,31 @@ enum RuntimeLocator {
|
||||
|
||||
static func resolve(
|
||||
preferred: String? = ProcessInfo.processInfo.environment["CLAWDIS_RUNTIME"],
|
||||
searchPaths: [String] = CommandResolver.preferredPaths()
|
||||
) -> Result<RuntimeResolution, RuntimeResolutionError> {
|
||||
let order = runtimeOrder(preferred: preferred)
|
||||
searchPaths: [String] = CommandResolver.preferredPaths()) -> Result<RuntimeResolution, RuntimeResolutionError>
|
||||
{
|
||||
let order = self.runtimeOrder(preferred: preferred)
|
||||
let pathEnv = searchPaths.joined(separator: ":")
|
||||
|
||||
for runtime in order {
|
||||
guard let binary = findExecutable(named: runtime.binaryName, searchPaths: searchPaths) else { continue }
|
||||
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 {
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
@@ -86,10 +100,11 @@ enum RuntimeLocator {
|
||||
"Install Bun: https://bun.sh/docs/installation",
|
||||
].joined(separator: "\n")
|
||||
case let .unsupported(kind, found, required, path, searchPaths):
|
||||
let fallbackRuntime = kind == .bun ? "node" : "bun"
|
||||
return [
|
||||
"Found \(kind.rawValue) \(found) at \(path) but need >= \(required).",
|
||||
"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")
|
||||
case let .versionParse(kind, raw, path, searchPaths):
|
||||
return [
|
||||
@@ -143,7 +158,6 @@ enum RuntimeLocator {
|
||||
}
|
||||
}
|
||||
|
||||
private extension RuntimeKind {
|
||||
var binaryName: String { self == .bun ? "bun" : "node" }
|
||||
extension RuntimeKind {
|
||||
fileprivate var binaryName: String { self == .bun ? "bun" : "node" }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
struct SoundEffectCatalog {
|
||||
enum SoundEffectCatalog {
|
||||
/// All discoverable system sound names, with "Glass" pinned first.
|
||||
static var systemOptions: [String] {
|
||||
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 url(for name: String) -> URL? {
|
||||
Self.discoveredSoundMap[name]
|
||||
self.discoveredSoundMap[name]
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
@@ -225,8 +225,8 @@ enum CommandResolver {
|
||||
runtime: RuntimeResolution,
|
||||
entrypoint: String,
|
||||
subcommand: String,
|
||||
extraArgs: [String]
|
||||
) -> [String] {
|
||||
extraArgs: [String]) -> [String]
|
||||
{
|
||||
[runtime.path, entrypoint, subcommand] + extraArgs
|
||||
}
|
||||
|
||||
@@ -332,11 +332,19 @@ enum CommandResolver {
|
||||
if let relay = self.bundledRelayRoot(),
|
||||
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()) {
|
||||
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() {
|
||||
@@ -347,7 +355,9 @@ enum CommandResolver {
|
||||
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)
|
||||
|
||||
case let .failure(error):
|
||||
@@ -390,24 +400,32 @@ enum CommandResolver {
|
||||
args.append(userHost)
|
||||
|
||||
// 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 userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let projectSection: String
|
||||
if userPRJ.isEmpty {
|
||||
projectSection = """
|
||||
DEFAULT_PRJ="$HOME/Projects/clawdis"
|
||||
if [ -d "$DEFAULT_PRJ" ]; then
|
||||
PRJ="$DEFAULT_PRJ"
|
||||
cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
|
||||
fi
|
||||
"""
|
||||
let projectSection = if userPRJ.isEmpty {
|
||||
"""
|
||||
DEFAULT_PRJ="$HOME/Projects/clawdis"
|
||||
if [ -d "$DEFAULT_PRJ" ]; then
|
||||
PRJ="$DEFAULT_PRJ"
|
||||
cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
|
||||
fi
|
||||
"""
|
||||
} else {
|
||||
projectSection = """
|
||||
PRJ=\(self.shellQuote(userPRJ))
|
||||
cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
|
||||
"""
|
||||
"""
|
||||
PRJ=\(self.shellQuote(userPRJ))
|
||||
cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
|
||||
"""
|
||||
}
|
||||
|
||||
let scriptBody = """
|
||||
@@ -448,7 +466,11 @@ enum CommandResolver {
|
||||
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 let parsed = self.parseSSHTarget(settings.target) else { return nil }
|
||||
|
||||
|
||||
@@ -55,14 +55,14 @@ final class VoicePushToTalkHotkey {
|
||||
}
|
||||
|
||||
let chordActive = self.optionDown
|
||||
if chordActive && !self.active {
|
||||
if chordActive, !self.active {
|
||||
self.active = true
|
||||
Task {
|
||||
Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt")
|
||||
.info("ptt hotkey down")
|
||||
await VoicePushToTalk.shared.begin()
|
||||
}
|
||||
} else if !chordActive && self.active {
|
||||
} else if !chordActive, self.active {
|
||||
self.active = false
|
||||
Task {
|
||||
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.
|
||||
await VoiceWakeRuntime.shared.pauseForPushToTalk()
|
||||
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 {
|
||||
VoiceWakeOverlayController.shared.startSession(
|
||||
source: .pushToTalk,
|
||||
@@ -166,7 +169,10 @@ actor VoicePushToTalk {
|
||||
let locale = localeID.flatMap { Locale(identifier: $0) } ?? Locale(identifier: Locale.current.identifier)
|
||||
self.recognizer = SFSpeechRecognizer(locale: locale)
|
||||
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()
|
||||
@@ -216,7 +222,10 @@ actor VoicePushToTalk {
|
||||
let attributed = Self.makeAttributed(committed: committedWithPrefix, volatile: self.volatile, isFinal: isFinal)
|
||||
if let token = self.overlayToken {
|
||||
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),
|
||||
volatile: self.volatile,
|
||||
isFinal: true)
|
||||
let forward: VoiceWakeForwardConfig
|
||||
if let cached = self.activeConfig?.forwardConfig {
|
||||
forward = cached
|
||||
let forward: VoiceWakeForwardConfig = if let cached = self.activeConfig?.forwardConfig {
|
||||
cached
|
||||
} else {
|
||||
forward = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
|
||||
await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
|
||||
}
|
||||
let chime = finalText.isEmpty ? .none : (self.activeConfig?.sendChime ?? .none)
|
||||
|
||||
|
||||
@@ -17,16 +17,16 @@ enum VoiceWakeChime: Codable, Equatable, Sendable {
|
||||
var displayLabel: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return "No Sound"
|
||||
"No Sound"
|
||||
case let .system(name):
|
||||
return VoiceWakeChimeCatalog.displayName(for: name)
|
||||
VoiceWakeChimeCatalog.displayName(for: name)
|
||||
case let .custom(displayName, _):
|
||||
return displayName
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceWakeChimeCatalog {
|
||||
enum VoiceWakeChimeCatalog {
|
||||
/// Options shown in the picker.
|
||||
static var systemOptions: [String] { SoundEffectCatalog.systemOptions }
|
||||
|
||||
@@ -57,12 +57,13 @@ enum VoiceWakeChimePlayer {
|
||||
private static func sound(for chime: VoiceWakeChime) -> NSSound? {
|
||||
switch chime {
|
||||
case .none:
|
||||
return nil
|
||||
nil
|
||||
|
||||
case let .system(name):
|
||||
return SoundEffectPlayer.sound(named: name)
|
||||
SoundEffectPlayer.sound(named: name)
|
||||
|
||||
case let .custom(_, bookmark):
|
||||
return SoundEffectPlayer.sound(from: bookmark)
|
||||
SoundEffectPlayer.sound(from: bookmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,12 @@ enum VoiceWakeForwarder {
|
||||
?? ProcessInfo.processInfo.hostName
|
||||
|
||||
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() {
|
||||
@@ -33,8 +38,8 @@ enum VoiceWakeForwarder {
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .rpcFailed(message): return message
|
||||
case .disabled: return "Voice wake forwarding disabled"
|
||||
case let .rpcFailed(message): message
|
||||
case .disabled: "Voice wake forwarding disabled"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,7 +154,7 @@ enum VoiceWakeForwarder {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch.isWhitespace && quote == nil {
|
||||
if ch.isWhitespace, quote == nil {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
var isVisible: Bool = false
|
||||
var forwardEnabled: Bool = false
|
||||
var isSending: Bool = false
|
||||
var attributed: NSAttributedString = NSAttributedString(string: "")
|
||||
var attributed: NSAttributedString = .init(string: "")
|
||||
var isOverflowing: Bool = false
|
||||
var isEditing: Bool = false
|
||||
var level: Double = 0 // normalized 0...1 speech level for UI
|
||||
@@ -52,7 +52,11 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
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)")
|
||||
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.activeSource = source
|
||||
self.forwardConfig = nil
|
||||
@@ -76,7 +80,11 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
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)")
|
||||
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.forwardConfig = nil
|
||||
self.model.text = transcript
|
||||
@@ -99,7 +107,13 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
attributed: NSAttributedString? = nil)
|
||||
{
|
||||
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.autoSendToken = token
|
||||
self.forwardConfig = forwardConfig
|
||||
@@ -142,7 +156,13 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
|
||||
func sendNow(token: UUID? = nil, sendChime: VoiceWakeChime = .none) {
|
||||
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
|
||||
if self.model.isSending { return }
|
||||
self.model.isEditing = false
|
||||
@@ -159,7 +179,8 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -176,7 +197,14 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
|
||||
func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) {
|
||||
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.model.isSending = false
|
||||
self.model.isEditing = false
|
||||
@@ -237,7 +265,9 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
guard let window else { return }
|
||||
if !self.model.isVisible {
|
||||
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.
|
||||
AppStateStore.shared.triggerVoiceEars(ttl: nil)
|
||||
let start = target.offsetBy(dx: 0, dy: -6)
|
||||
@@ -309,7 +339,8 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
}
|
||||
|
||||
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 textInset = NSSize(width: 2, height: 6)
|
||||
@@ -350,7 +381,13 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
}
|
||||
|
||||
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.autoSendToken = token
|
||||
self.autoSendTask = Task<Void, Never> { [weak self, sendChime, token] in
|
||||
@@ -360,7 +397,9 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
await MainActor.run {
|
||||
guard let self 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.autoSendTask = nil
|
||||
}
|
||||
@@ -376,6 +415,7 @@ final class VoiceWakeOverlayController: ObservableObject {
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private struct VoiceWakeOverlayView: View {
|
||||
@ObservedObject var controller: VoiceWakeOverlayController
|
||||
@FocusState private var textFocused: Bool
|
||||
@@ -469,9 +509,8 @@ private struct VoiceWakeOverlayView: View {
|
||||
// Close button rendered above and outside the clipped bubble
|
||||
CloseButtonOverlay(
|
||||
isVisible: self.controller.model.isEditing || self.isHovering || self.closeHovering,
|
||||
onHover: { self.closeHovering = $0 }) {
|
||||
self.controller.cancelEditingAndDismiss()
|
||||
}
|
||||
onHover: { self.closeHovering = $0 },
|
||||
onClose: { self.controller.cancelEditingAndDismiss() })
|
||||
}
|
||||
.padding(.top, self.controller.closeOverflow)
|
||||
.padding(.leading, self.controller.closeOverflow)
|
||||
@@ -629,7 +668,6 @@ private struct VibrantLabelView: NSViewRepresentable {
|
||||
label.attributedStringValue = self.attributed.strippingForegroundColor()
|
||||
label.textColor = .labelColor
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private final class ClickCatcher: NSView {
|
||||
@@ -675,8 +713,8 @@ private struct CloseButtonOverlay: View {
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isVisible {
|
||||
Button(action: onClose) {
|
||||
if self.isVisible {
|
||||
Button(action: self.onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(Color.white.opacity(0.9))
|
||||
@@ -695,7 +733,7 @@ private struct CloseButtonOverlay: View {
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(isVisible)
|
||||
.allowsHitTesting(self.isVisible)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,7 +761,7 @@ private final class TranscriptNSTextView: NSTextView {
|
||||
self.onEscape?()
|
||||
return
|
||||
}
|
||||
if isReturn && event.modifierFlags.contains(.command) {
|
||||
if isReturn, event.modifierFlags.contains(.command) {
|
||||
self.onSend?()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -136,7 +136,12 @@ actor VoiceWakeRuntime {
|
||||
guard let self else { return }
|
||||
let transcript = result?.bestTranscription.formattedString
|
||||
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")
|
||||
@@ -213,7 +218,10 @@ actor VoiceWakeRuntime {
|
||||
let snapshot = self.committedTranscript + self.volatileTranscript
|
||||
if let token = self.overlayToken {
|
||||
await MainActor.run {
|
||||
VoiceWakeOverlayController.shared.updatePartial(token: token, transcript: snapshot, attributed: attributed)
|
||||
VoiceWakeOverlayController.shared.updatePartial(
|
||||
token: token,
|
||||
transcript: snapshot,
|
||||
attributed: attributed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,10 +343,10 @@ actor VoiceWakeRuntime {
|
||||
VoiceWakeOverlayController.shared.presentFinal(
|
||||
token: token,
|
||||
transcript: finalTranscript,
|
||||
forwardConfig: forwardConfig,
|
||||
autoSendAfter: delay,
|
||||
sendChime: sendChime,
|
||||
attributed: finalAttributed)
|
||||
forwardConfig: forwardConfig,
|
||||
autoSendAfter: delay,
|
||||
sendChime: sendChime,
|
||||
attributed: finalAttributed)
|
||||
}
|
||||
} else if forwardConfig.enabled, !finalTranscript.isEmpty {
|
||||
if sendChime != .none {
|
||||
|
||||
@@ -49,7 +49,10 @@ struct VoiceWakeSettings: View {
|
||||
|
||||
SettingsToggleRow(
|
||||
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)
|
||||
.disabled(!voiceWakeSupported)
|
||||
|
||||
|
||||
@@ -67,7 +67,10 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||
private func bootstrap() async {
|
||||
do {
|
||||
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)
|
||||
self.baseEndpoint = endpoint
|
||||
@@ -90,9 +93,9 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||
|
||||
private func prepareEndpoint(remotePort: Int) async throws -> URL {
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return try await self.startOrRestartTunnel()
|
||||
try await self.startOrRestartTunnel()
|
||||
} 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)
|
||||
if let http = response as? HTTPURLResponse {
|
||||
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 {
|
||||
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
|
||||
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.tunnelRestartEnabled = true
|
||||
|
||||
@@ -162,7 +171,8 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||
|
||||
private func showError(_ text: String) {
|
||||
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)
|
||||
}
|
||||
@@ -247,7 +257,7 @@ final class WebChatTunnel {
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-o", "TCPKeepAlive=yes",
|
||||
"-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)]) }
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -263,7 +273,9 @@ final class WebChatTunnel {
|
||||
// Consume stderr so ssh cannot block if it logs
|
||||
pipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
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)")
|
||||
}
|
||||
try process.run()
|
||||
@@ -272,7 +284,7 @@ final class WebChatTunnel {
|
||||
}
|
||||
|
||||
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
|
||||
let queue = DispatchQueue(label: "com.steipete.clawdis.webchat.port", qos: .utility)
|
||||
|
||||
@@ -44,8 +44,8 @@ final class WorkActivityStore: ObservableObject {
|
||||
phase: String,
|
||||
name: String?,
|
||||
meta: String?,
|
||||
args: [String: AnyCodable]?
|
||||
) {
|
||||
args: [String: AnyCodable]?)
|
||||
{
|
||||
let toolKind = Self.mapToolKind(name)
|
||||
let label = Self.buildLabel(kind: toolKind, meta: meta, args: args)
|
||||
if phase.lowercased() == "start" {
|
||||
@@ -124,7 +124,7 @@ final class WorkActivityStore: ObservableObject {
|
||||
return
|
||||
}
|
||||
// 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
|
||||
} else {
|
||||
self.currentSessionKey = nil
|
||||
@@ -145,20 +145,20 @@ final class WorkActivityStore: ObservableObject {
|
||||
|
||||
private static func mapToolKind(_ name: String?) -> ToolKind {
|
||||
switch name?.lowercased() {
|
||||
case "bash", "shell": return .bash
|
||||
case "read": return .read
|
||||
case "write": return .write
|
||||
case "edit": return .edit
|
||||
case "attach": return .attach
|
||||
default: return .other
|
||||
case "bash", "shell": .bash
|
||||
case "read": .read
|
||||
case "write": .write
|
||||
case "edit": .edit
|
||||
case "attach": .attach
|
||||
default: .other
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildLabel(
|
||||
kind: ToolKind,
|
||||
meta: String?,
|
||||
args: [String: AnyCodable]?
|
||||
) -> String {
|
||||
args: [String: AnyCodable]?) -> String
|
||||
{
|
||||
switch kind {
|
||||
case .bash:
|
||||
if let cmd = args?["command"]?.value as? String {
|
||||
@@ -166,7 +166,7 @@ final class WorkActivityStore: ObservableObject {
|
||||
}
|
||||
return "bash"
|
||||
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
|
||||
@@ -179,9 +179,9 @@ final class WorkActivityStore: ObservableObject {
|
||||
}
|
||||
|
||||
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?["file_path"]?.value as? String { return shortenHome(path: p) }
|
||||
if let meta { return shortenHome(path: meta) }
|
||||
if let p = args?["path"]?.value as? String { return self.shortenHome(path: p) }
|
||||
if let p = args?["file_path"]?.value as? String { return self.shortenHome(path: p) }
|
||||
if let meta { return self.shortenHome(path: meta) }
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +183,8 @@ struct ClawdisCLI {
|
||||
clawdis-mac run [--cwd <path>] [--env KEY=VAL] [--timeout <sec>] [--needs-screen-recording] <command ...>
|
||||
clawdis-mac 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
|
||||
|
||||
Returns JSON to stdout:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@testable import Clawdis
|
||||
import Testing
|
||||
@testable import Clawdis
|
||||
@testable import ClawdisIPC
|
||||
|
||||
@Suite(.serialized) struct AgentRPCTests {
|
||||
|
||||
@@ -12,8 +12,9 @@ import Testing
|
||||
}
|
||||
|
||||
private func makeExec(at path: URL) throws {
|
||||
try FileManager.default.createDirectory(at: path.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: path.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
||||
}
|
||||
@@ -23,7 +24,7 @@ import Testing
|
||||
CommandResolver.setProjectRoot(tmp.path)
|
||||
|
||||
let clawdisPath = tmp.appendingPathComponent("node_modules/.bin/clawdis")
|
||||
try makeExec(at: clawdisPath)
|
||||
try self.makeExec(at: clawdisPath)
|
||||
|
||||
let cmd = CommandResolver.clawdisCommand(subcommand: "relay")
|
||||
#expect(cmd.prefix(2).elementsEqual([clawdisPath.path, "relay"]))
|
||||
@@ -35,10 +36,10 @@ import Testing
|
||||
|
||||
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
|
||||
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 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) }
|
||||
setenv("CLAWDIS_RUNTIME", "node", 1)
|
||||
@@ -63,7 +64,7 @@ import Testing
|
||||
CommandResolver.setProjectRoot(tmp.path)
|
||||
|
||||
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
||||
try makeExec(at: pnpmPath)
|
||||
try self.makeExec(at: pnpmPath)
|
||||
|
||||
let cmd = CommandResolver.clawdisCommand(subcommand: "rpc")
|
||||
|
||||
@@ -75,7 +76,7 @@ import Testing
|
||||
CommandResolver.setProjectRoot(tmp.path)
|
||||
|
||||
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"])
|
||||
|
||||
|
||||
@@ -3,12 +3,10 @@ import Testing
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite struct HealthDecodeTests {
|
||||
private let sampleJSON: String = {
|
||||
// minimal but complete payload
|
||||
private let sampleJSON: String = // 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}}
|
||||
"""
|
||||
}()
|
||||
|
||||
@Test func decodesCleanJSON() async throws {
|
||||
let data = Data(sampleJSON.utf8)
|
||||
@@ -20,7 +18,7 @@ import Testing
|
||||
}
|
||||
|
||||
@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))
|
||||
|
||||
#expect(snap?.web.connect?.status == 200)
|
||||
|
||||
@@ -5,4 +5,3 @@ import Testing
|
||||
#expect(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ import Testing
|
||||
@Test func trimsAfterFirstMatchingTrigger() {
|
||||
let triggers = ["buddy", "claude"]
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user