Mac: stabilize XPC and voice wake handling
This commit is contained in:
@@ -389,6 +389,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
if self.isDuplicateInstance() {
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
self.state = AppStateStore.shared
|
self.state = AppStateStore.shared
|
||||||
AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false)
|
AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false)
|
||||||
if let state {
|
if let state {
|
||||||
@@ -428,4 +432,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
|||||||
connection.resume()
|
connection.resume()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isDuplicateInstance() -> Bool {
|
||||||
|
guard let bundleID = Bundle.main.bundleIdentifier else { return false }
|
||||||
|
let running = NSWorkspace.shared.runningApplications.filter { $0.bundleIdentifier == bundleID }
|
||||||
|
return running.count > 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,13 +78,13 @@ enum PermissionManager {
|
|||||||
case .speechRecognition:
|
case .speechRecognition:
|
||||||
let status = SFSpeechRecognizer.authorizationStatus()
|
let status = SFSpeechRecognizer.authorizationStatus()
|
||||||
if status == .notDetermined, interactive {
|
if status == .notDetermined, interactive {
|
||||||
let ok = await withCheckedContinuation { cont in
|
await withUnsafeContinuation { (cont: UnsafeContinuation<Void, Never>) in
|
||||||
SFSpeechRecognizer.requestAuthorization { auth in cont.resume(returning: auth == .authorized) }
|
SFSpeechRecognizer.requestAuthorization { _ in
|
||||||
|
DispatchQueue.main.async { cont.resume() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
results[cap] = ok
|
|
||||||
} else {
|
|
||||||
results[cap] = status == .authorized
|
|
||||||
}
|
}
|
||||||
|
results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -155,13 +155,15 @@ final class VoiceWakeTester {
|
|||||||
guard let request = recognitionRequest else { return }
|
guard let request = recognitionRequest else { return }
|
||||||
|
|
||||||
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, !self.isStopping else { return }
|
||||||
let text = result?.bestTranscription.formattedString ?? ""
|
let text = result?.bestTranscription.formattedString ?? ""
|
||||||
let matched = Self.matches(text: text, triggers: triggers)
|
let matched = Self.matches(text: text, triggers: triggers)
|
||||||
let isFinal = result?.isFinal ?? false
|
let isFinal = result?.isFinal ?? false
|
||||||
let errorMessage = error?.localizedDescription
|
let errorMessage = error?.localizedDescription
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
|
Task { [weak self] in
|
||||||
guard let self, !self.isStopping else { return }
|
guard let self, !self.isStopping else { return }
|
||||||
self.handleResult(
|
await self.handleResult(
|
||||||
matched: matched,
|
matched: matched,
|
||||||
text: text,
|
text: text,
|
||||||
isFinal: isFinal,
|
isFinal: isFinal,
|
||||||
@@ -181,14 +183,12 @@ final class VoiceWakeTester {
|
|||||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleResult(
|
private func handleResult(
|
||||||
matched: Bool,
|
matched: Bool,
|
||||||
text: String,
|
text: String,
|
||||||
isFinal: Bool,
|
isFinal: Bool,
|
||||||
errorMessage: String?,
|
errorMessage: String?,
|
||||||
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void)
|
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async {
|
||||||
{
|
|
||||||
if !text.isEmpty {
|
if !text.isEmpty {
|
||||||
self.lastHeard = Date()
|
self.lastHeard = Date()
|
||||||
}
|
}
|
||||||
@@ -196,30 +196,34 @@ final class VoiceWakeTester {
|
|||||||
self.holdingAfterDetect = true
|
self.holdingAfterDetect = true
|
||||||
self.detectedText = text
|
self.detectedText = text
|
||||||
self.logger.info("voice wake detected; forwarding (len=\(text.count))")
|
self.logger.info("voice wake detected; forwarding (len=\(text.count))")
|
||||||
AppStateStore.shared.triggerVoiceEars()
|
await MainActor.run { AppStateStore.shared.triggerVoiceEars() }
|
||||||
let config = AppStateStore.shared.voiceWakeForwardConfig
|
let config = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig }
|
||||||
Task.detached {
|
Task.detached {
|
||||||
await VoiceWakeForwarder.forward(transcript: text, config: config)
|
await VoiceWakeForwarder.forward(transcript: text, config: config)
|
||||||
}
|
}
|
||||||
onUpdate(.detected(text))
|
Task { @MainActor in onUpdate(.detected(text)) }
|
||||||
self.holdUntilSilence(onUpdate: onUpdate)
|
self.holdUntilSilence(onUpdate: onUpdate)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let errorMessage {
|
if let errorMessage {
|
||||||
self.stop()
|
self.stop()
|
||||||
onUpdate(.failed(errorMessage))
|
Task { @MainActor in onUpdate(.failed(errorMessage)) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if isFinal {
|
if isFinal {
|
||||||
self.stop()
|
self.stop()
|
||||||
onUpdate(text.isEmpty ? .failed("No speech detected") : .failed("No trigger heard: “\(text)”"))
|
let state: VoiceWakeTestState = text.isEmpty
|
||||||
|
? .failed("No speech detected")
|
||||||
|
: .failed("No trigger heard: “\(text)”")
|
||||||
|
Task { @MainActor in onUpdate(state) }
|
||||||
} else {
|
} else {
|
||||||
onUpdate(text.isEmpty ? .listening : .hearing(text))
|
let state: VoiceWakeTestState = text.isEmpty ? .listening : .hearing(text)
|
||||||
|
Task { @MainActor in onUpdate(state) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
|
private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
|
||||||
Task { @MainActor [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let start = self.detectionStart ?? Date()
|
let start = self.detectionStart ?? Date()
|
||||||
let deadline = start.addingTimeInterval(10)
|
let deadline = start.addingTimeInterval(10)
|
||||||
@@ -235,7 +239,7 @@ final class VoiceWakeTester {
|
|||||||
self.stop()
|
self.stop()
|
||||||
if let detectedText {
|
if let detectedText {
|
||||||
self.logger.info("voice wake hold finished; len=\(detectedText.count)")
|
self.logger.info("voice wake hold finished; len=\(detectedText.count)")
|
||||||
onUpdate(.detected(detectedText))
|
Task { @MainActor in onUpdate(.detected(detectedText)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,44 +171,59 @@ struct ClawdisCLI {
|
|||||||
|
|
||||||
private static func loadInfo() -> [String: Any] {
|
private static func loadInfo() -> [String: Any] {
|
||||||
if let dict = Bundle.main.infoDictionary, !dict.isEmpty { return dict }
|
if let dict = Bundle.main.infoDictionary, !dict.isEmpty { return dict }
|
||||||
guard let exePath = executablePath() else { return [:] }
|
guard let exe = CommandLine.arguments.first else { return [:] }
|
||||||
let infoURL = exePath
|
let url = URL(fileURLWithPath: exe)
|
||||||
|
.resolvingSymlinksInPath()
|
||||||
.deletingLastPathComponent() // MacOS
|
.deletingLastPathComponent() // MacOS
|
||||||
.deletingLastPathComponent() // Contents
|
.deletingLastPathComponent() // Contents
|
||||||
.appendingPathComponent("Info.plist")
|
.appendingPathComponent("Info.plist")
|
||||||
if let data = try? Data(contentsOf: infoURL),
|
if let data = try? Data(contentsOf: url),
|
||||||
let dict = (try? PropertyListSerialization.propertyList(
|
let dict = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any]
|
||||||
from: data,
|
{
|
||||||
options: [],
|
|
||||||
format: nil)) as? [String: Any] {
|
|
||||||
return dict
|
return dict
|
||||||
}
|
}
|
||||||
return [:]
|
return [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func executablePath() -> URL? {
|
private static func send(request: Request) async throws -> Response {
|
||||||
if let cstr = _dyld_get_image_name(0) {
|
try await ensureAppRunning()
|
||||||
return URL(fileURLWithPath: String(cString: cstr)).resolvingSymlinksInPath()
|
|
||||||
|
var lastError: Error?
|
||||||
|
for _ in 0..<10 {
|
||||||
|
let conn = NSXPCConnection(machServiceName: serviceName)
|
||||||
|
let interface = NSXPCInterface(with: ClawdisXPCProtocol.self)
|
||||||
|
conn.remoteObjectInterface = interface
|
||||||
|
conn.resume()
|
||||||
|
|
||||||
|
let data = try JSONEncoder().encode(request)
|
||||||
|
do {
|
||||||
|
let service = AsyncXPCConnection.RemoteXPCService<ClawdisXPCProtocol>(connection: conn)
|
||||||
|
let raw: Data = try await service.withValueErrorCompletion { proxy, completion in
|
||||||
|
struct CompletionBox: @unchecked Sendable { let handler: (Data?, Error?) -> Void }
|
||||||
|
let box = CompletionBox(handler: completion)
|
||||||
|
proxy.handle(data, withReply: { data, error in box.handler(data, error) })
|
||||||
|
}
|
||||||
|
conn.invalidate()
|
||||||
|
return try JSONDecoder().decode(Response.self, from: raw)
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
conn.invalidate()
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
throw lastError ?? CLIError.help
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func send(request: Request) async throws -> Response {
|
private static func ensureAppRunning() async throws {
|
||||||
let conn = NSXPCConnection(machServiceName: serviceName)
|
let appURL = URL(fileURLWithPath: (CommandLine.arguments.first ?? ""))
|
||||||
let interface = NSXPCInterface(with: ClawdisXPCProtocol.self)
|
.resolvingSymlinksInPath()
|
||||||
conn.remoteObjectInterface = interface
|
.deletingLastPathComponent() // MacOS
|
||||||
conn.resume()
|
.deletingLastPathComponent() // Contents
|
||||||
defer { conn.invalidate() }
|
let proc = Process()
|
||||||
|
proc.launchPath = "/usr/bin/open"
|
||||||
let data = try JSONEncoder().encode(request)
|
proc.arguments = ["-n", appURL.path]
|
||||||
|
try proc.run()
|
||||||
let service = AsyncXPCConnection.RemoteXPCService<ClawdisXPCProtocol>(connection: conn)
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
let raw: Data = try await service.withValueErrorCompletion { proxy, completion in
|
|
||||||
struct CompletionBox: @unchecked Sendable { let handler: (Data?, Error?) -> Void }
|
|
||||||
let box = CompletionBox(handler: completion)
|
|
||||||
proxy.handle(data, withReply: { data, error in box.handler(data, error) })
|
|
||||||
}
|
|
||||||
return try JSONDecoder().decode(Response.self, from: raw)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,38 +22,6 @@ run_step() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
write_launch_agent() {
|
|
||||||
cat > "${LAUNCH_AGENT}" <<PLIST
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>com.steipete.clawdis</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>${APP_BUNDLE}/Contents/MacOS/Clawdis</string>
|
|
||||||
</array>
|
|
||||||
<key>WorkingDirectory</key>
|
|
||||||
<string>${ROOT_DIR}</string>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<true/>
|
|
||||||
<key>MachServices</key>
|
|
||||||
<dict>
|
|
||||||
<key>com.steipete.clawdis.xpc</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>StandardOutPath</key>
|
|
||||||
<string>/tmp/clawdis.log</string>
|
|
||||||
<key>StandardErrorPath</key>
|
|
||||||
<string>/tmp/clawdis.log</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
PLIST
|
|
||||||
}
|
|
||||||
|
|
||||||
kill_all_clawdis() {
|
kill_all_clawdis() {
|
||||||
for _ in {1..10}; do
|
for _ in {1..10}; do
|
||||||
pkill -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true
|
pkill -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true
|
||||||
@@ -72,9 +40,14 @@ kill_all_clawdis() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stop_launch_agent() {
|
||||||
|
launchctl bootout gui/"$UID"/com.steipete.clawdis 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
# 1) Kill all running instances first.
|
# 1) Kill all running instances first.
|
||||||
log "==> Killing existing Clawdis instances"
|
log "==> Killing existing Clawdis instances"
|
||||||
kill_all_clawdis
|
kill_all_clawdis
|
||||||
|
stop_launch_agent
|
||||||
|
|
||||||
# 2) Rebuild into the same path the packager consumes (.build).
|
# 2) Rebuild into the same path the packager consumes (.build).
|
||||||
run_step "clean build cache" bash -lc "cd '${ROOT_DIR}/apps/macos' && rm -rf .build .build-swift .swiftpm 2>/dev/null || true"
|
run_step "clean build cache" bash -lc "cd '${ROOT_DIR}/apps/macos' && rm -rf .build .build-swift .swiftpm 2>/dev/null || true"
|
||||||
@@ -83,16 +56,10 @@ run_step "swift build" bash -lc "cd '${ROOT_DIR}/apps/macos' && swift build -q -
|
|||||||
# 3) Package + relaunch the app (script also stops any stragglers).
|
# 3) Package + relaunch the app (script also stops any stragglers).
|
||||||
run_step "package app" "${ROOT_DIR}/scripts/package-mac-app.sh"
|
run_step "package app" "${ROOT_DIR}/scripts/package-mac-app.sh"
|
||||||
|
|
||||||
# 4) Install launch agent with Mach service and bootstrap it.
|
# 4) Verify the packaged app is alive.
|
||||||
write_launch_agent
|
|
||||||
launchctl bootout gui/"$UID"/com.steipete.clawdis 2>/dev/null || true
|
|
||||||
run_step "bootstrap launch agent" launchctl bootstrap gui/"$UID" "${LAUNCH_AGENT}"
|
|
||||||
run_step "kickstart" launchctl kickstart -k gui/"$UID"/com.steipete.clawdis
|
|
||||||
|
|
||||||
# 5) Verify the packaged app is alive.
|
|
||||||
sleep 1
|
sleep 1
|
||||||
if pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1; then
|
if pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1; then
|
||||||
log "OK: Clawdis is running (launchd)."
|
log "OK: Clawdis is running."
|
||||||
else
|
else
|
||||||
fail "App exited immediately. Check /tmp/clawdis.log or Console.app (User Reports)."
|
fail "App exited immediately. Check /tmp/clawdis.log or Console.app (User Reports)."
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user