diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index 4706b5971..e13f82bf3 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -138,7 +138,7 @@ final class AppState: ObservableObject { init() { self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) - self.launchAtLogin = LaunchAgentManager.status() + self.launchAtLogin = false self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen") self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdis.debugPaneEnabled") let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey) @@ -189,6 +189,11 @@ final class AppState: ObservableObject { let storedPort = UserDefaults.standard.integer(forKey: webChatPortKey) self.webChatPort = storedPort > 0 ? storedPort : 18788 + Task.detached(priority: .utility) { [weak self] in + let current = await LaunchAgentManager.status() + await MainActor.run { [weak self] in self?.launchAtLogin = current } + } + if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() { self.swabbleEnabled = false } @@ -248,7 +253,9 @@ enum AppStateStore { static var isPausedFlag: Bool { UserDefaults.standard.bool(forKey: pauseDefaultsKey) } static func updateLaunchAtLogin(enabled: Bool) { - LaunchAgentManager.set(enabled: enabled, bundlePath: Bundle.main.bundlePath) + Task.detached(priority: .utility) { + await LaunchAgentManager.set(enabled: enabled, bundlePath: Bundle.main.bundlePath) + } } static var webChatEnabled: Bool { diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index 03af58a23..403a5768e 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -26,18 +26,18 @@ enum LaunchAgentManager { .appendingPathComponent("Library/LaunchAgents/com.steipete.clawdis.plist") } - static func status() -> Bool { + static func status() async -> Bool { guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false } - let result = self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"]) + let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"]) return result == 0 } - static func set(enabled: Bool, bundlePath: String) { + static func set(enabled: Bool, bundlePath: String) async { if enabled { self.writePlist(bundlePath: bundlePath) - _ = self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) - _ = self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) - _ = self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"]) + _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) + _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) + _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"]) } else { // Disable autostart going forward but leave the current app running. // bootout would terminate the launchd job immediately (and crash the app if launched via agent). @@ -84,15 +84,21 @@ enum LaunchAgentManager { } @discardableResult - private static func runLaunchctl(_ args: [String]) -> Int32 { - let process = Process() - process.launchPath = "/bin/launchctl" - process.arguments = args - process.standardOutput = Pipe() - process.standardError = Pipe() - try? process.run() - process.waitUntilExit() - return process.terminationStatus + private static func runLaunchctl(_ args: [String]) async -> Int32 { + await Task.detached(priority: .utility) { () -> Int32 in + let process = Process() + process.launchPath = "/bin/launchctl" + process.arguments = args + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus + } catch { + return -1 + } + }.value } } diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index a2753ee67..2dc647c2b 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -217,7 +217,7 @@ final class WebChatTunnel { throw NSError(domain: "WebChat", code: 3, userInfo: [NSLocalizedDescriptionKey: "remote not configured"]) } - let localPort = try Self.findPort(preferred: preferredLocalPort) + let localPort = try await Self.findPort(preferred: preferredLocalPort) var args: [String] = [ "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", @@ -250,19 +250,35 @@ final class WebChatTunnel { return WebChatTunnel(process: process, localPort: localPort) } - private static func findPort(preferred: UInt16?) throws -> UInt16 { - if let preferred { - if Self.portIsFree(preferred) { return preferred } + private static func findPort(preferred: UInt16?) async throws -> UInt16 { + if let preferred, Self.portIsFree(preferred) { return preferred } + + return try await withCheckedThrowingContinuation { cont in + let queue = DispatchQueue(label: "com.steipete.clawdis.webchat.port", qos: .utility) + do { + let listener = try NWListener(using: .tcp, on: .any) + listener.newConnectionHandler = { connection in connection.cancel() } + listener.stateUpdateHandler = { state in + switch state { + case .ready: + if let port = listener.port?.rawValue { + listener.stateUpdateHandler = nil + listener.cancel() + cont.resume(returning: port) + } + case let .failed(error): + listener.stateUpdateHandler = nil + listener.cancel() + cont.resume(throwing: error) + default: + break + } + } + listener.start(queue: queue) + } catch { + cont.resume(throwing: error) + } } - let listener = try NWListener(using: .tcp, on: .any) - listener.start(queue: .main) - while listener.port == nil { - RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) - } - let port = listener.port?.rawValue - listener.cancel() - guard let port else { throw NSError(domain: "WebChat", code: 4, userInfo: [NSLocalizedDescriptionKey: "no port"])} - return port } private static func portIsFree(_ port: UInt16) -> Bool {