chore: format macOS sources
This commit is contained in:
@@ -21,7 +21,7 @@ actor AgentRPC {
|
|||||||
deliver: Bool,
|
deliver: Bool,
|
||||||
to: String?) async -> (ok: Bool, text: String?, error: String?)
|
to: String?) async -> (ok: Bool, text: String?, error: String?)
|
||||||
{
|
{
|
||||||
if process?.isRunning != true {
|
if self.process?.isRunning != true {
|
||||||
do {
|
do {
|
||||||
try await self.start()
|
try await self.start()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -51,7 +51,8 @@ actor AgentRPC {
|
|||||||
if let payloadDict = parsed["payload"] as? [String: Any],
|
if let payloadDict = parsed["payload"] as? [String: Any],
|
||||||
let payloads = payloadDict["payloads"] as? [[String: Any]],
|
let payloads = payloadDict["payloads"] as? [[String: Any]],
|
||||||
let first = payloads.first,
|
let first = payloads.first,
|
||||||
let txt = first["text"] as? String {
|
let txt = first["text"] as? String
|
||||||
|
{
|
||||||
return (true, txt, nil)
|
return (true, txt, nil)
|
||||||
}
|
}
|
||||||
return (true, nil, nil)
|
return (true, nil, nil)
|
||||||
@@ -62,14 +63,14 @@ actor AgentRPC {
|
|||||||
}
|
}
|
||||||
return (false, nil, "rpc returned unexpected response: \(line)")
|
return (false, nil, "rpc returned unexpected response: \(line)")
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("rpc send failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("rpc send failed: \(error.localizedDescription, privacy: .public)")
|
||||||
await stop()
|
await self.stop()
|
||||||
return (false, nil, error.localizedDescription)
|
return (false, nil, error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func status() async -> (ok: Bool, error: String?) {
|
func status() async -> (ok: Bool, error: String?) {
|
||||||
if process?.isRunning != true {
|
if self.process?.isRunning != true {
|
||||||
do {
|
do {
|
||||||
try await self.start()
|
try await self.start()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -88,14 +89,14 @@ actor AgentRPC {
|
|||||||
if let ok = parsed?["ok"] as? Bool, ok { return (true, nil) }
|
if let ok = parsed?["ok"] as? Bool, ok { return (true, nil) }
|
||||||
return (false, parsed?["error"] as? String ?? "rpc status failed: \(line)")
|
return (false, parsed?["error"] as? String ?? "rpc status failed: \(line)")
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("rpc status failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("rpc status failed: \(error.localizedDescription, privacy: .public)")
|
||||||
await stop()
|
await self.stop()
|
||||||
return (false, error.localizedDescription)
|
return (false, error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
|
func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool {
|
||||||
guard process?.isRunning == true else { return false }
|
guard self.process?.isRunning == true else { return false }
|
||||||
do {
|
do {
|
||||||
let payload: [String: Any] = ["type": "set-heartbeats", "enabled": enabled]
|
let payload: [String: Any] = ["type": "set-heartbeats", "enabled": enabled]
|
||||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||||
@@ -108,8 +109,8 @@ actor AgentRPC {
|
|||||||
if let ok = parsed?["ok"] as? Bool, ok { return true }
|
if let ok = parsed?["ok"] as? Bool, ok { return true }
|
||||||
return false
|
return false
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("rpc set-heartbeats failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("rpc set-heartbeats failed: \(error.localizedDescription, privacy: .public)")
|
||||||
await stop()
|
await self.stop()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,12 +156,12 @@ actor AgentRPC {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func stop() async {
|
private func stop() async {
|
||||||
stdoutHandle?.readabilityHandler = nil
|
self.stdoutHandle?.readabilityHandler = nil
|
||||||
process?.terminate()
|
self.process?.terminate()
|
||||||
process = nil
|
self.process = nil
|
||||||
stdinHandle = nil
|
self.stdinHandle = nil
|
||||||
stdoutHandle = nil
|
self.stdoutHandle = nil
|
||||||
buffer.removeAll(keepingCapacity: false)
|
self.buffer.removeAll(keepingCapacity: false)
|
||||||
let waiters = self.waiters
|
let waiters = self.waiters
|
||||||
self.waiters.removeAll()
|
self.waiters.removeAll()
|
||||||
for waiter in waiters {
|
for waiter in waiters {
|
||||||
@@ -169,13 +170,13 @@ actor AgentRPC {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func ingest(data: Data) {
|
private func ingest(data: Data) {
|
||||||
buffer.append(data)
|
self.buffer.append(data)
|
||||||
while let range = buffer.firstRange(of: Data([0x0A])) {
|
while let range = buffer.firstRange(of: Data([0x0A])) {
|
||||||
let lineData = buffer.subdata(in: buffer.startIndex..<range.lowerBound)
|
let lineData = self.buffer.subdata(in: self.buffer.startIndex..<range.lowerBound)
|
||||||
buffer.removeSubrange(buffer.startIndex...range.lowerBound)
|
self.buffer.removeSubrange(self.buffer.startIndex...range.lowerBound)
|
||||||
guard let line = String(data: lineData, encoding: .utf8) else { continue }
|
guard let line = String(data: lineData, encoding: .utf8) else { continue }
|
||||||
if let waiter = waiters.first {
|
if let waiter = waiters.first {
|
||||||
waiters.removeFirst()
|
self.waiters.removeFirst()
|
||||||
waiter.resume(returning: line)
|
waiter.resume(returning: line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,7 +184,7 @@ actor AgentRPC {
|
|||||||
|
|
||||||
private func nextLine() async throws -> String {
|
private func nextLine() async throws -> String {
|
||||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<String, Error>) in
|
||||||
waiters.append(cont)
|
self.waiters.append(cont)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ final class AppState: ObservableObject {
|
|||||||
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
|
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.swabbleEnabled && !PermissionManager.voiceWakePermissionsGranted() {
|
if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
|
||||||
self.swabbleEnabled = false
|
self.swabbleEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ struct DebugSettings: View {
|
|||||||
Button {
|
Button {
|
||||||
Task { await self.reloadModels() }
|
Task { await self.reloadModels() }
|
||||||
} label: {
|
} label: {
|
||||||
Label(self.modelsLoading ? "Reloading…" : "Reload models", systemImage: "arrow.clockwise")
|
Label(
|
||||||
|
self.modelsLoading ? "Reloading…" : "Reload models",
|
||||||
|
systemImage: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.disabled(self.modelsLoading)
|
.disabled(self.modelsLoading)
|
||||||
@@ -129,7 +131,9 @@ struct DebugSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button("Send Test Notification") {
|
Button("Send Test Notification") {
|
||||||
Task { _ = await NotificationManager().send(title: "Clawdis", body: "Test notification", sound: nil) }
|
Task {
|
||||||
|
_ = await NotificationManager().send(title: "Clawdis", body: "Test notification", sound: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
@@ -153,7 +157,8 @@ struct DebugSettings: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
} else {
|
} else {
|
||||||
Text("Uses the Voice Wake path: forwards over SSH when configured, otherwise runs locally via rpc.")
|
Text(
|
||||||
|
"Uses the Voice Wake path: forwards over SSH when configured, otherwise runs locally via rpc.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,7 +172,8 @@ 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("Last activity: \(recent.key) \(recent.updatedAt != nil ? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000)) : "unknown")")
|
Text(
|
||||||
|
"Last activity: \(recent.key) \(recent.updatedAt != nil ? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000)) : "unknown")")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -216,8 +217,8 @@ struct GeneralSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension GeneralSettings {
|
extension GeneralSettings {
|
||||||
func revealLogs() {
|
private func revealLogs() {
|
||||||
let path = URL(fileURLWithPath: "/tmp/clawdis/clawdis.log")
|
let path = URL(fileURLWithPath: "/tmp/clawdis/clawdis.log")
|
||||||
if FileManager.default.fileExists(atPath: path.path) {
|
if FileManager.default.fileExists(atPath: path.path) {
|
||||||
NSWorkspace.shared.selectFile(path.path, inFileViewerRootedAtPath: path.deletingLastPathComponent().path)
|
NSWorkspace.shared.selectFile(path.path, inFileViewerRootedAtPath: path.deletingLastPathComponent().path)
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import MenuBarExtraAccess
|
import MenuBarExtraAccess
|
||||||
import SwiftUI
|
import OSLog
|
||||||
import Security
|
import Security
|
||||||
import OSLog
|
import SwiftUI
|
||||||
import OSLog
|
|
||||||
import Darwin
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct ClawdisApp: App {
|
struct ClawdisApp: App {
|
||||||
@@ -98,11 +97,11 @@ private struct MenuContent: View {
|
|||||||
|
|
||||||
private func relayLabel(_ status: RelayProcessManager.Status) -> String {
|
private func relayLabel(_ status: RelayProcessManager.Status) -> String {
|
||||||
switch status {
|
switch status {
|
||||||
case .running: return "Running"
|
case .running: "Running"
|
||||||
case .starting: return "Starting…"
|
case .starting: "Starting…"
|
||||||
case .restarting: return "Restarting…"
|
case .restarting: "Restarting…"
|
||||||
case let .failed(reason): return "Failed: \(reason)"
|
case let .failed(reason): "Failed: \(reason)"
|
||||||
case .stopped: return "Stopped"
|
case .stopped: "Stopped"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +496,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
|||||||
|
|
||||||
// Developer/testing helper: auto-open WebChat when launched with --webchat
|
// Developer/testing helper: auto-open WebChat when launched with --webchat
|
||||||
if CommandLine.arguments.contains("--webchat") {
|
if CommandLine.arguments.contains("--webchat") {
|
||||||
webChatAutoLogger.debug("Auto-opening web chat via --webchat flag")
|
self.webChatAutoLogger.debug("Auto-opening web chat via --webchat flag")
|
||||||
WebChatManager.shared.show(sessionKey: "main")
|
WebChatManager.shared.show(sessionKey: "main")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -581,7 +580,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
|||||||
var infoCF: CFDictionary?
|
var infoCF: CFDictionary?
|
||||||
guard SecCodeCopySigningInformation(sCode, SecCSFlags(), &infoCF) == errSecSuccess,
|
guard SecCodeCopySigningInformation(sCode, SecCSFlags(), &infoCF) == errSecSuccess,
|
||||||
let info = infoCF as? [String: Any],
|
let info = infoCF as? [String: Any],
|
||||||
let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String else {
|
let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String
|
||||||
|
else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ enum AppleScriptPermission {
|
|||||||
let result = appleScript?.executeAndReturnError(&error)
|
let result = appleScript?.executeAndReturnError(&error)
|
||||||
|
|
||||||
if let error, let code = error["NSAppleScriptErrorNumber"] as? Int {
|
if let error, let code = error["NSAppleScriptErrorNumber"] as? Int {
|
||||||
if code == -1_743 { // errAEEventWouldRequireUserConsent
|
if code == -1743 { // errAEEventWouldRequireUserConsent
|
||||||
Self.logger.debug("AppleScript permission denied (-1743)")
|
Self.logger.debug("AppleScript permission denied (-1743)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -180,12 +180,12 @@ enum AppleScriptPermission {
|
|||||||
/// Triggers the TCC prompt and opens System Settings → Privacy & Security → Automation.
|
/// Triggers the TCC prompt and opens System Settings → Privacy & Security → Automation.
|
||||||
@MainActor
|
@MainActor
|
||||||
static func requestAuthorization() async {
|
static func requestAuthorization() async {
|
||||||
_ = isAuthorized() // first attempt triggers the dialog if not granted
|
_ = self.isAuthorized() // first attempt triggers the dialog if not granted
|
||||||
|
|
||||||
// Open the Automation pane to help the user if the prompt was dismissed.
|
// Open the Automation pane to help the user if the prompt was dismissed.
|
||||||
let urlStrings = [
|
let urlStrings = [
|
||||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Automation",
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_Automation",
|
||||||
"x-apple.systempreferences:com.apple.preference.security"
|
"x-apple.systempreferences:com.apple.preference.security",
|
||||||
]
|
]
|
||||||
|
|
||||||
for candidate in urlStrings {
|
for candidate in urlStrings {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Data models
|
// MARK: - Data models
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ enum CLIInstaller {
|
|||||||
let targetList = targets.map(self.shellEscape).joined(separator: " ")
|
let targetList = targets.map(self.shellEscape).joined(separator: " ")
|
||||||
let cmds = [
|
let cmds = [
|
||||||
"mkdir -p /usr/local/bin /opt/homebrew/bin",
|
"mkdir -p /usr/local/bin /opt/homebrew/bin",
|
||||||
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; ")
|
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
|
||||||
].joined(separator: "; ")
|
].joined(separator: "; ")
|
||||||
|
|
||||||
let script = """
|
let script = """
|
||||||
@@ -180,7 +180,8 @@ enum CommandResolver {
|
|||||||
|
|
||||||
static func projectRoot() -> URL {
|
static func projectRoot() -> URL {
|
||||||
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
|
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
|
||||||
let url = self.expandPath(stored) {
|
let url = self.expandPath(stored)
|
||||||
|
{
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
let fallback = FileManager.default.homeDirectoryForCurrentUser
|
let fallback = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ enum VoiceWakeForwarder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
steps.append("if [ -z \"$CLI\" ]; then CLI=$(command -v clawdis-mac 2>/dev/null || true); fi")
|
steps.append("if [ -z \"$CLI\" ]; then CLI=$(command -v clawdis-mac 2>/dev/null || true); fi")
|
||||||
steps.append("if [ -z \"$CLI\" ]; then for c in \(searchList); do [ -x \"$c\" ] && CLI=\"$c\" && break; done; fi")
|
steps
|
||||||
|
.append(
|
||||||
|
"if [ -z \"$CLI\" ]; then for c in \(searchList); do [ -x \"$c\" ] && CLI=\"$c\" && break; done; fi")
|
||||||
steps.append("if [ -z \"$CLI\" ]; then echo 'clawdis-mac missing'; exit 127; fi")
|
steps.append("if [ -z \"$CLI\" ]; then echo 'clawdis-mac missing'; exit 127; fi")
|
||||||
|
|
||||||
if echoPath {
|
if echoPath {
|
||||||
@@ -61,17 +63,16 @@ enum VoiceWakeForwarder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func commandWithCliPath(_ command: String, target: String, echoCliPath: Bool = false) -> String {
|
static func commandWithCliPath(_ command: String, target: String, echoCliPath: Bool = false) -> String {
|
||||||
let rewritten: String
|
let rewritten: String = if command.contains("clawdis-mac") {
|
||||||
if command.contains("clawdis-mac") {
|
command.replacingOccurrences(of: "clawdis-mac", with: "\"$CLI\"")
|
||||||
rewritten = command.replacingOccurrences(of: "clawdis-mac", with: "\"$CLI\"")
|
|
||||||
} else {
|
} else {
|
||||||
rewritten = "\"$CLI\" \(command)"
|
"\"$CLI\" \(command)"
|
||||||
}
|
}
|
||||||
|
|
||||||
return "\(self.cliLookupPrefix(target: target, echoPath: echoCliPath)); \(rewritten)"
|
return "\(self.cliLookupPrefix(target: target, echoPath: echoCliPath)); \(rewritten)"
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Test-only helpers
|
// Test-only helpers
|
||||||
static func _testSetCliCache(target: String, path: String) {
|
static func _testSetCliCache(target: String, path: String) {
|
||||||
self.cliCache.set((target: target, path: path))
|
self.cliCache.set((target: target, path: path))
|
||||||
@@ -80,7 +81,7 @@ enum VoiceWakeForwarder {
|
|||||||
static func _testGetCliCache() -> (target: String, path: String)? {
|
static func _testGetCliCache() -> (target: String, path: String)? {
|
||||||
self.cliCache.get()
|
self.cliCache.get()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
enum VoiceWakeForwardError: LocalizedError, Equatable {
|
enum VoiceWakeForwardError: LocalizedError, Equatable {
|
||||||
case invalidTarget
|
case invalidTarget
|
||||||
@@ -109,7 +110,10 @@ enum VoiceWakeForwarder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func forward(transcript: String, config: VoiceWakeForwardConfig) async -> Result<Void, VoiceWakeForwardError> {
|
static func forward(
|
||||||
|
transcript: String,
|
||||||
|
config: VoiceWakeForwardConfig) async -> Result<Void, VoiceWakeForwardError>
|
||||||
|
{
|
||||||
guard config.enabled else { return .failure(.disabled) }
|
guard config.enabled else { return .failure(.disabled) }
|
||||||
let destination = config.target.trimmingCharacters(in: .whitespacesAndNewlines)
|
let destination = config.target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard let parsed = self.parse(target: destination) else {
|
guard let parsed = self.parse(target: destination) else {
|
||||||
@@ -236,7 +240,8 @@ enum VoiceWakeForwarder {
|
|||||||
if checkProc.terminationStatus == 0 {
|
if checkProc.terminationStatus == 0 {
|
||||||
if let cliLine = statusOut
|
if let cliLine = statusOut
|
||||||
.split(separator: "\n")
|
.split(separator: "\n")
|
||||||
.last(where: { $0.hasPrefix("__CLI:") }) {
|
.last(where: { $0.hasPrefix("__CLI:") })
|
||||||
|
{
|
||||||
let path = String(cliLine.dropFirst("__CLI:".count))
|
let path = String(cliLine.dropFirst("__CLI:".count))
|
||||||
if !path.isEmpty {
|
if !path.isEmpty {
|
||||||
self.cliCache.set((target: destination, path: path))
|
self.cliCache.set((target: destination, path: path))
|
||||||
|
|||||||
@@ -34,32 +34,32 @@ actor VoiceWakeRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard voiceWakeSupported, snapshot.0 else {
|
guard voiceWakeSupported, snapshot.0 else {
|
||||||
stop()
|
self.stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard PermissionManager.voiceWakePermissionsGranted() else {
|
guard PermissionManager.voiceWakePermissionsGranted() else {
|
||||||
logger.debug("voicewake runtime not starting: permissions missing")
|
self.logger.debug("voicewake runtime not starting: permissions missing")
|
||||||
stop()
|
self.stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = snapshot.1
|
let config = snapshot.1
|
||||||
|
|
||||||
if config == currentConfig, recognitionTask != nil {
|
if config == self.currentConfig, self.recognitionTask != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stop()
|
self.stop()
|
||||||
await start(with: config)
|
await self.start(with: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func start(with config: RuntimeConfig) async {
|
private func start(with config: RuntimeConfig) async {
|
||||||
do {
|
do {
|
||||||
configureSession(localeID: config.localeID)
|
self.configureSession(localeID: config.localeID)
|
||||||
|
|
||||||
guard let recognizer, recognizer.isAvailable else {
|
guard let recognizer, recognizer.isAvailable else {
|
||||||
logger.error("voicewake runtime: speech recognizer unavailable")
|
self.logger.error("voicewake runtime: speech recognizer unavailable")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,19 +67,19 @@ actor VoiceWakeRuntime {
|
|||||||
self.recognitionRequest?.shouldReportPartialResults = true
|
self.recognitionRequest?.shouldReportPartialResults = true
|
||||||
guard let request = self.recognitionRequest else { return }
|
guard let request = self.recognitionRequest else { return }
|
||||||
|
|
||||||
let input = audioEngine.inputNode
|
let input = self.audioEngine.inputNode
|
||||||
let format = input.outputFormat(forBus: 0)
|
let format = input.outputFormat(forBus: 0)
|
||||||
input.removeTap(onBus: 0)
|
input.removeTap(onBus: 0)
|
||||||
input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in
|
input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak request] buffer, _ in
|
||||||
request?.append(buffer)
|
request?.append(buffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
audioEngine.prepare()
|
self.audioEngine.prepare()
|
||||||
try audioEngine.start()
|
try self.audioEngine.start()
|
||||||
|
|
||||||
currentConfig = config
|
self.currentConfig = config
|
||||||
lastHeard = Date()
|
self.lastHeard = Date()
|
||||||
cooldownUntil = nil
|
self.cooldownUntil = nil
|
||||||
|
|
||||||
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -87,22 +87,22 @@ actor VoiceWakeRuntime {
|
|||||||
Task { await self.handleRecognition(transcript: transcript, error: error, config: config) }
|
Task { await self.handleRecognition(transcript: transcript, error: error, config: config) }
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("voicewake runtime started")
|
self.logger.info("voicewake runtime started")
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("voicewake runtime failed to start: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("voicewake runtime failed to start: \(error.localizedDescription, privacy: .public)")
|
||||||
stop()
|
self.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stop() {
|
private func stop() {
|
||||||
recognitionTask?.cancel()
|
self.recognitionTask?.cancel()
|
||||||
recognitionTask = nil
|
self.recognitionTask = nil
|
||||||
recognitionRequest?.endAudio()
|
self.recognitionRequest?.endAudio()
|
||||||
recognitionRequest = nil
|
self.recognitionRequest = nil
|
||||||
audioEngine.inputNode.removeTap(onBus: 0)
|
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||||
audioEngine.stop()
|
self.audioEngine.stop()
|
||||||
currentConfig = nil
|
self.currentConfig = nil
|
||||||
logger.debug("voicewake runtime stopped")
|
self.logger.debug("voicewake runtime stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureSession(localeID: String?) {
|
private func configureSession(localeID: String?) {
|
||||||
@@ -113,20 +113,21 @@ actor VoiceWakeRuntime {
|
|||||||
private func handleRecognition(
|
private func handleRecognition(
|
||||||
transcript: String?,
|
transcript: String?,
|
||||||
error: Error?,
|
error: Error?,
|
||||||
config: RuntimeConfig) async {
|
config: RuntimeConfig) async
|
||||||
|
{
|
||||||
if let error {
|
if let error {
|
||||||
logger.debug("voicewake recognition error: \(error.localizedDescription, privacy: .public)")
|
self.logger.debug("voicewake recognition error: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let transcript else { return }
|
guard let transcript else { return }
|
||||||
if !transcript.isEmpty { lastHeard = Date() }
|
if !transcript.isEmpty { self.lastHeard = Date() }
|
||||||
|
|
||||||
if Self.matches(text: transcript, triggers: config.triggers) {
|
if Self.matches(text: transcript, triggers: config.triggers) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if let cooldown = cooldownUntil, now < cooldown {
|
if let cooldown = cooldownUntil, now < cooldown {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cooldownUntil = now.addingTimeInterval(2.5)
|
self.cooldownUntil = now.addingTimeInterval(2.5)
|
||||||
await MainActor.run { AppStateStore.shared.triggerVoiceEars() }
|
await MainActor.run { AppStateStore.shared.triggerVoiceEars() }
|
||||||
let forwardConfig = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
|
let forwardConfig = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
|
||||||
if forwardConfig.enabled {
|
if forwardConfig.enabled {
|
||||||
@@ -148,9 +149,9 @@ actor VoiceWakeRuntime {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
static func _testMatches(text: String, triggers: [String]) -> Bool {
|
static func _testMatches(text: String, triggers: [String]) -> Bool {
|
||||||
Self.matches(text: text, triggers: triggers)
|
self.matches(text: text, triggers: triggers)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,8 @@ final class VoiceWakeTester {
|
|||||||
text: String,
|
text: String,
|
||||||
isFinal: Bool,
|
isFinal: Bool,
|
||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async {
|
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async
|
||||||
|
{
|
||||||
if !text.isEmpty {
|
if !text.isEmpty {
|
||||||
self.lastHeard = Date()
|
self.lastHeard = Date()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ final class WebChatServer: @unchecked Sendable {
|
|||||||
|
|
||||||
/// Start the local HTTP server if it isn't already running. Safe to call multiple times.
|
/// Start the local HTTP server if it isn't already running. Safe to call multiple times.
|
||||||
func start(root: URL) {
|
func start(root: URL) {
|
||||||
queue.async {
|
self.queue.async {
|
||||||
if self.listener != nil { return }
|
if self.listener != nil { return }
|
||||||
self.root = root
|
self.root = root
|
||||||
let params = NWParameters.tcp
|
let params = NWParameters.tcp
|
||||||
@@ -26,8 +26,9 @@ final class WebChatServer: @unchecked Sendable {
|
|||||||
case .ready:
|
case .ready:
|
||||||
self?.port = listener.port
|
self?.port = listener.port
|
||||||
webChatServerLogger.debug("WebChatServer ready on 127.0.0.1:\(listener.port?.rawValue ?? 0)")
|
webChatServerLogger.debug("WebChatServer ready on 127.0.0.1:\(listener.port?.rawValue ?? 0)")
|
||||||
case .failed(let error):
|
case let .failed(error):
|
||||||
webChatServerLogger.error("WebChatServer failed: \(error.localizedDescription, privacy: .public)")
|
webChatServerLogger
|
||||||
|
.error("WebChatServer failed: \(error.localizedDescription, privacy: .public)")
|
||||||
self?.listener = nil
|
self?.listener = nil
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
@@ -39,7 +40,8 @@ final class WebChatServer: @unchecked Sendable {
|
|||||||
listener.start(queue: self.queue)
|
listener.start(queue: self.queue)
|
||||||
self.listener = listener
|
self.listener = listener
|
||||||
} catch {
|
} catch {
|
||||||
webChatServerLogger.error("WebChatServer could not start: \(error.localizedDescription, privacy: .public)")
|
webChatServerLogger
|
||||||
|
.error("WebChatServer could not start: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,7 +49,7 @@ final class WebChatServer: @unchecked Sendable {
|
|||||||
/// Returns the base URL once the server is ready, otherwise nil.
|
/// Returns the base URL once the server is ready, otherwise nil.
|
||||||
func baseURL() -> URL? {
|
func baseURL() -> URL? {
|
||||||
var url: URL?
|
var url: URL?
|
||||||
queue.sync {
|
self.queue.sync {
|
||||||
if let port {
|
if let port {
|
||||||
url = URL(string: "http://127.0.0.1:\(port.rawValue)/webchat/")
|
url = URL(string: "http://127.0.0.1:\(port.rawValue)/webchat/")
|
||||||
}
|
}
|
||||||
@@ -60,14 +62,15 @@ final class WebChatServer: @unchecked Sendable {
|
|||||||
switch state {
|
switch state {
|
||||||
case .ready:
|
case .ready:
|
||||||
self.receive(on: connection)
|
self.receive(on: connection)
|
||||||
case .failed(let error):
|
case let .failed(error):
|
||||||
webChatServerLogger.error("WebChatServer connection failed: \(error.localizedDescription, privacy: .public)")
|
webChatServerLogger
|
||||||
|
.error("WebChatServer connection failed: \(error.localizedDescription, privacy: .public)")
|
||||||
connection.cancel()
|
connection.cancel()
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.start(queue: queue)
|
connection.start(queue: self.queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func receive(on connection: NWConnection) {
|
private func receive(on connection: NWConnection) {
|
||||||
@@ -109,15 +112,15 @@ final class WebChatServer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
let fileURL = root.appendingPathComponent(path)
|
let fileURL = root.appendingPathComponent(path)
|
||||||
guard fileURL.path.hasPrefix(root.path) else {
|
guard fileURL.path.hasPrefix(root.path) else {
|
||||||
send(status: 403, mime: "text/plain", body: Data("Forbidden".utf8), over: connection)
|
self.send(status: 403, mime: "text/plain", body: Data("Forbidden".utf8), over: connection)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = try? Data(contentsOf: fileURL) else {
|
guard let data = try? Data(contentsOf: fileURL) else {
|
||||||
send(status: 404, mime: "text/plain", body: Data("Not Found".utf8), over: connection)
|
self.send(status: 404, mime: "text/plain", body: Data("Not Found".utf8), over: connection)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let mime = mimeType(forExtension: fileURL.pathExtension)
|
let mime = self.mimeType(forExtension: fileURL.pathExtension)
|
||||||
send(status: 200, mime: mime, body: data, over: connection)
|
self.send(status: 200, mime: mime, body: data, over: connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func send(status: Int, mime: String, body: Data, over connection: NWConnection) {
|
private func send(status: Int, mime: String, body: Data, over connection: NWConnection) {
|
||||||
@@ -134,28 +137,28 @@ final class WebChatServer: @unchecked Sendable {
|
|||||||
|
|
||||||
private func statusText(_ code: Int) -> String {
|
private func statusText(_ code: Int) -> String {
|
||||||
switch code {
|
switch code {
|
||||||
case 200: return "OK"
|
case 200: "OK"
|
||||||
case 403: return "Forbidden"
|
case 403: "Forbidden"
|
||||||
case 404: return "Not Found"
|
case 404: "Not Found"
|
||||||
default: return "Error"
|
default: "Error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mimeType(forExtension ext: String) -> String {
|
private func mimeType(forExtension ext: String) -> String {
|
||||||
switch ext.lowercased() {
|
switch ext.lowercased() {
|
||||||
case "html", "htm": return "text/html; charset=utf-8"
|
case "html", "htm": "text/html; charset=utf-8"
|
||||||
case "js", "mjs": return "application/javascript; charset=utf-8"
|
case "js", "mjs": "application/javascript; charset=utf-8"
|
||||||
case "css": return "text/css; charset=utf-8"
|
case "css": "text/css; charset=utf-8"
|
||||||
case "json": return "application/json; charset=utf-8"
|
case "json": "application/json; charset=utf-8"
|
||||||
case "map": return "application/json; charset=utf-8"
|
case "map": "application/json; charset=utf-8"
|
||||||
case "svg": return "image/svg+xml"
|
case "svg": "image/svg+xml"
|
||||||
case "png": return "image/png"
|
case "png": "image/png"
|
||||||
case "jpg", "jpeg": return "image/jpeg"
|
case "jpg", "jpeg": "image/jpeg"
|
||||||
case "gif": return "image/gif"
|
case "gif": "image/gif"
|
||||||
case "woff2": return "font/woff2"
|
case "woff2": "font/woff2"
|
||||||
case "woff": return "font/woff"
|
case "woff": "font/woff"
|
||||||
case "ttf": return "font/ttf"
|
case "ttf": "font/ttf"
|
||||||
default: return "application/octet-stream"
|
default: "application/octet-stream"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,11 @@ final class WebChatWindowController: NSWindowController, WKScriptMessageHandler,
|
|||||||
webChatLogger.debug("didCommit url=\(webView.url?.absoluteString ?? "nil", privacy: .public)")
|
webChatLogger.debug("didCommit url=\(webView.url?.absoluteString ?? "nil", privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) {
|
func webView(
|
||||||
|
_ webView: WKWebView,
|
||||||
|
didFailProvisionalNavigation navigation: WKNavigation!,
|
||||||
|
withError error: any Error)
|
||||||
|
{
|
||||||
webChatLogger.error("didFailProvisional error=\(error.localizedDescription, privacy: .public)")
|
webChatLogger.error("didFailProvisional error=\(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ struct ClawdisCLI {
|
|||||||
FileHandle.standardOutput.write(Data([0x0A]))
|
FileHandle.standardOutput.write(Data([0x0A]))
|
||||||
exit(response.ok ? 0 : 1)
|
exit(response.ok ? 0 : 1)
|
||||||
} catch CLIError.help {
|
} catch CLIError.help {
|
||||||
printHelp()
|
self.printHelp()
|
||||||
exit(0)
|
exit(0)
|
||||||
} catch CLIError.version {
|
} catch CLIError.version {
|
||||||
printVersion()
|
self.printVersion()
|
||||||
exit(0)
|
exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
fputs("clawdis-mac error: \(error)\n", stderr)
|
fputs("clawdis-mac error: \(error)\n", stderr)
|
||||||
@@ -52,6 +52,7 @@ struct ClawdisCLI {
|
|||||||
switch command {
|
switch command {
|
||||||
case "--help", "-h", "help":
|
case "--help", "-h", "help":
|
||||||
throw CLIError.help
|
throw CLIError.help
|
||||||
|
|
||||||
case "--version", "-V", "version":
|
case "--version", "-V", "version":
|
||||||
throw CLIError.version
|
throw CLIError.version
|
||||||
|
|
||||||
@@ -192,7 +193,7 @@ struct ClawdisCLI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func printVersion() {
|
private static func printVersion() {
|
||||||
let info = loadInfo()
|
let info = self.loadInfo()
|
||||||
let version = info["CFBundleShortVersionString"] as? String ?? "unknown"
|
let version = info["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||||
let build = info["CFBundleVersion"] as? String ?? ""
|
let build = info["CFBundleVersion"] as? String ?? ""
|
||||||
let git = info["ClawdisGitCommit"] as? String ?? "unknown"
|
let git = info["ClawdisGitCommit"] as? String ?? "unknown"
|
||||||
@@ -209,7 +210,8 @@ struct ClawdisCLI {
|
|||||||
.deletingLastPathComponent() // Contents
|
.deletingLastPathComponent() // Contents
|
||||||
.appendingPathComponent("Info.plist")
|
.appendingPathComponent("Info.plist")
|
||||||
if let data = try? Data(contentsOf: url),
|
if let data = try? Data(contentsOf: url),
|
||||||
let dict = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any]
|
let dict = try? PropertyListSerialization
|
||||||
|
.propertyList(from: data, options: [], format: nil) as? [String: Any]
|
||||||
{
|
{
|
||||||
return dict
|
return dict
|
||||||
}
|
}
|
||||||
@@ -217,7 +219,7 @@ struct ClawdisCLI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func send(request: Request) async throws -> Response {
|
private static func send(request: Request) async throws -> Response {
|
||||||
try await ensureAppRunning()
|
try await self.ensureAppRunning()
|
||||||
|
|
||||||
var lastError: Error?
|
var lastError: Error?
|
||||||
for _ in 0..<10 {
|
for _ in 0..<10 {
|
||||||
@@ -246,7 +248,7 @@ struct ClawdisCLI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func ensureAppRunning() async throws {
|
private static func ensureAppRunning() async throws {
|
||||||
let appURL = URL(fileURLWithPath: (CommandLine.arguments.first ?? ""))
|
let appURL = URL(fileURLWithPath: CommandLine.arguments.first ?? "")
|
||||||
.resolvingSymlinksInPath()
|
.resolvingSymlinksInPath()
|
||||||
.deletingLastPathComponent() // MacOS
|
.deletingLastPathComponent() // MacOS
|
||||||
.deletingLastPathComponent() // Contents
|
.deletingLastPathComponent() // Contents
|
||||||
|
|||||||
Reference in New Issue
Block a user