perf(mac): move blocking launchctl/webchat work off main

main
Peter Steinberger 2025-12-08 18:42:13 +01:00
parent a19d4c19d3
commit 73211c900b
3 changed files with 59 additions and 30 deletions

View File

@ -138,7 +138,7 @@ final class AppState: ObservableObject {
init() { init() {
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey) self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
self.launchAtLogin = LaunchAgentManager.status() self.launchAtLogin = false
self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen") self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdis.debugPaneEnabled") self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdis.debugPaneEnabled")
let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey) let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey)
@ -189,6 +189,11 @@ final class AppState: ObservableObject {
let storedPort = UserDefaults.standard.integer(forKey: webChatPortKey) let storedPort = UserDefaults.standard.integer(forKey: webChatPortKey)
self.webChatPort = storedPort > 0 ? storedPort : 18788 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() { if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
self.swabbleEnabled = false self.swabbleEnabled = false
} }
@ -248,7 +253,9 @@ enum AppStateStore {
static var isPausedFlag: Bool { UserDefaults.standard.bool(forKey: pauseDefaultsKey) } static var isPausedFlag: Bool { UserDefaults.standard.bool(forKey: pauseDefaultsKey) }
static func updateLaunchAtLogin(enabled: Bool) { 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 { static var webChatEnabled: Bool {

View File

@ -26,18 +26,18 @@ enum LaunchAgentManager {
.appendingPathComponent("Library/LaunchAgents/com.steipete.clawdis.plist") .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 } 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 return result == 0
} }
static func set(enabled: Bool, bundlePath: String) { static func set(enabled: Bool, bundlePath: String) async {
if enabled { if enabled {
self.writePlist(bundlePath: bundlePath) self.writePlist(bundlePath: bundlePath)
_ = self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
_ = self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
_ = self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"]) _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"])
} else { } else {
// Disable autostart going forward but leave the current app running. // 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). // bootout would terminate the launchd job immediately (and crash the app if launched via agent).
@ -84,15 +84,21 @@ enum LaunchAgentManager {
} }
@discardableResult @discardableResult
private static func runLaunchctl(_ args: [String]) -> Int32 { private static func runLaunchctl(_ args: [String]) async -> Int32 {
await Task.detached(priority: .utility) { () -> Int32 in
let process = Process() let process = Process()
process.launchPath = "/bin/launchctl" process.launchPath = "/bin/launchctl"
process.arguments = args process.arguments = args
process.standardOutput = Pipe() process.standardOutput = Pipe()
process.standardError = Pipe() process.standardError = Pipe()
try? process.run() do {
try process.run()
process.waitUntilExit() process.waitUntilExit()
return process.terminationStatus return process.terminationStatus
} catch {
return -1
}
}.value
} }
} }

View File

@ -217,7 +217,7 @@ final class WebChatTunnel {
throw NSError(domain: "WebChat", code: 3, userInfo: [NSLocalizedDescriptionKey: "remote not configured"]) 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] = [ var args: [String] = [
"-o", "BatchMode=yes", "-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes", "-o", "IdentitiesOnly=yes",
@ -250,19 +250,35 @@ final class WebChatTunnel {
return WebChatTunnel(process: process, localPort: localPort) return WebChatTunnel(process: process, localPort: localPort)
} }
private static func findPort(preferred: UInt16?) throws -> UInt16 { private static func findPort(preferred: UInt16?) async throws -> UInt16 {
if let preferred { if let preferred, Self.portIsFree(preferred) { return preferred }
if 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) let listener = try NWListener(using: .tcp, on: .any)
listener.start(queue: .main) listener.newConnectionHandler = { connection in connection.cancel() }
while listener.port == nil { listener.stateUpdateHandler = { state in
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) switch state {
} case .ready:
let port = listener.port?.rawValue if let port = listener.port?.rawValue {
listener.stateUpdateHandler = nil
listener.cancel() listener.cancel()
guard let port else { throw NSError(domain: "WebChat", code: 4, userInfo: [NSLocalizedDescriptionKey: "no port"])} cont.resume(returning: port)
return 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)
}
}
} }
private static func portIsFree(_ port: UInt16) -> Bool { private static func portIsFree(_ port: UInt16) -> Bool {