diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index a4f95e370..20290aaec 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -389,6 +389,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate @MainActor func applicationDidFinishLaunching(_ notification: Notification) { + if self.isDuplicateInstance() { + NSApp.terminate(nil) + return + } self.state = AppStateStore.shared AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false) if let state { @@ -428,4 +432,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate connection.resume() 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 + } } diff --git a/apps/macos/Sources/Clawdis/PermissionManager.swift b/apps/macos/Sources/Clawdis/PermissionManager.swift index 38b66c77d..c02082134 100644 --- a/apps/macos/Sources/Clawdis/PermissionManager.swift +++ b/apps/macos/Sources/Clawdis/PermissionManager.swift @@ -78,13 +78,13 @@ enum PermissionManager { case .speechRecognition: let status = SFSpeechRecognizer.authorizationStatus() if status == .notDetermined, interactive { - let ok = await withCheckedContinuation { cont in - SFSpeechRecognizer.requestAuthorization { auth in cont.resume(returning: auth == .authorized) } + await withUnsafeContinuation { (cont: UnsafeContinuation) in + SFSpeechRecognizer.requestAuthorization { _ in + DispatchQueue.main.async { cont.resume() } + } } - results[cap] = ok - } else { - results[cap] = status == .authorized } + results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized } } return results diff --git a/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift b/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift index c00cd0acc..f27950123 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeSettings.swift @@ -155,13 +155,15 @@ final class VoiceWakeTester { guard let request = recognitionRequest else { return } self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self, !self.isStopping else { return } let text = result?.bestTranscription.formattedString ?? "" let matched = Self.matches(text: text, triggers: triggers) let isFinal = result?.isFinal ?? false let errorMessage = error?.localizedDescription - Task { @MainActor [weak self] in + + Task { [weak self] in guard let self, !self.isStopping else { return } - self.handleResult( + await self.handleResult( matched: matched, text: text, isFinal: isFinal, @@ -181,14 +183,12 @@ final class VoiceWakeTester { self.audioEngine.inputNode.removeTap(onBus: 0) } - @MainActor private func handleResult( matched: Bool, text: String, isFinal: Bool, errorMessage: String?, - onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) - { + onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async { if !text.isEmpty { self.lastHeard = Date() } @@ -196,30 +196,34 @@ final class VoiceWakeTester { self.holdingAfterDetect = true self.detectedText = text self.logger.info("voice wake detected; forwarding (len=\(text.count))") - AppStateStore.shared.triggerVoiceEars() - let config = AppStateStore.shared.voiceWakeForwardConfig + await MainActor.run { AppStateStore.shared.triggerVoiceEars() } + let config = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } Task.detached { await VoiceWakeForwarder.forward(transcript: text, config: config) } - onUpdate(.detected(text)) + Task { @MainActor in onUpdate(.detected(text)) } self.holdUntilSilence(onUpdate: onUpdate) return } if let errorMessage { self.stop() - onUpdate(.failed(errorMessage)) + Task { @MainActor in onUpdate(.failed(errorMessage)) } return } if isFinal { 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 { - 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) { - Task { @MainActor [weak self] in + Task { [weak self] in guard let self else { return } let start = self.detectionStart ?? Date() let deadline = start.addingTimeInterval(10) @@ -235,7 +239,7 @@ final class VoiceWakeTester { self.stop() if let detectedText { self.logger.info("voice wake hold finished; len=\(detectedText.count)") - onUpdate(.detected(detectedText)) + Task { @MainActor in onUpdate(.detected(detectedText)) } } } } diff --git a/apps/macos/Sources/ClawdisCLI/main.swift b/apps/macos/Sources/ClawdisCLI/main.swift index 28241d8eb..310e0f7c6 100644 --- a/apps/macos/Sources/ClawdisCLI/main.swift +++ b/apps/macos/Sources/ClawdisCLI/main.swift @@ -171,44 +171,59 @@ struct ClawdisCLI { private static func loadInfo() -> [String: Any] { if let dict = Bundle.main.infoDictionary, !dict.isEmpty { return dict } - guard let exePath = executablePath() else { return [:] } - let infoURL = exePath + guard let exe = CommandLine.arguments.first else { return [:] } + let url = URL(fileURLWithPath: exe) + .resolvingSymlinksInPath() .deletingLastPathComponent() // MacOS .deletingLastPathComponent() // Contents .appendingPathComponent("Info.plist") - if let data = try? Data(contentsOf: infoURL), - let dict = (try? PropertyListSerialization.propertyList( - from: data, - options: [], - format: nil)) as? [String: Any] { + if let data = try? Data(contentsOf: url), + let dict = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any] + { return dict } return [:] } - private static func executablePath() -> URL? { - if let cstr = _dyld_get_image_name(0) { - return URL(fileURLWithPath: String(cString: cstr)).resolvingSymlinksInPath() + private static func send(request: Request) async throws -> Response { + try await ensureAppRunning() + + 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(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 { - let conn = NSXPCConnection(machServiceName: serviceName) - let interface = NSXPCInterface(with: ClawdisXPCProtocol.self) - conn.remoteObjectInterface = interface - conn.resume() - defer { conn.invalidate() } - - let data = try JSONEncoder().encode(request) - - let service = AsyncXPCConnection.RemoteXPCService(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) }) - } - return try JSONDecoder().decode(Response.self, from: raw) + private static func ensureAppRunning() async throws { + let appURL = URL(fileURLWithPath: (CommandLine.arguments.first ?? "")) + .resolvingSymlinksInPath() + .deletingLastPathComponent() // MacOS + .deletingLastPathComponent() // Contents + let proc = Process() + proc.launchPath = "/usr/bin/open" + proc.arguments = ["-n", appURL.path] + try proc.run() + try? await Task.sleep(nanoseconds: 100_000_000) } } diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index 0ad950107..ceb872709 100755 --- a/scripts/restart-mac.sh +++ b/scripts/restart-mac.sh @@ -22,38 +22,6 @@ run_step() { fi } -write_launch_agent() { - cat > "${LAUNCH_AGENT}" < - - - - Label - com.steipete.clawdis - ProgramArguments - - ${APP_BUNDLE}/Contents/MacOS/Clawdis - - WorkingDirectory - ${ROOT_DIR} - RunAtLoad - - KeepAlive - - MachServices - - com.steipete.clawdis.xpc - - - StandardOutPath - /tmp/clawdis.log - StandardErrorPath - /tmp/clawdis.log - - -PLIST -} - kill_all_clawdis() { for _ in {1..10}; do pkill -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true @@ -72,9 +40,14 @@ kill_all_clawdis() { done } +stop_launch_agent() { + launchctl bootout gui/"$UID"/com.steipete.clawdis 2>/dev/null || true +} + # 1) Kill all running instances first. log "==> Killing existing Clawdis instances" kill_all_clawdis +stop_launch_agent # 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" @@ -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). run_step "package app" "${ROOT_DIR}/scripts/package-mac-app.sh" -# 4) Install launch agent with Mach service and bootstrap it. -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. +# 4) Verify the packaged app is alive. sleep 1 if pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1; then - log "OK: Clawdis is running (launchd)." + log "OK: Clawdis is running." else fail "App exited immediately. Check /tmp/clawdis.log or Console.app (User Reports)." fi