From 958c13e02da5da47dcec3db1abb603dba9edef8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 11 Dec 2025 16:30:35 +0100 Subject: [PATCH] mac: replace xpc with unix socket control channel --- apps/macos/Package.resolved | 11 +- apps/macos/Package.swift | 3 - apps/macos/Sources/Clawdis/Constants.swift | 1 - .../Clawdis/ControlRequestHandler.swift | 68 ++++++++ .../Sources/Clawdis/ControlSocketServer.swift | 118 ++++++++++++++ apps/macos/Sources/Clawdis/MenuBar.swift | 81 +--------- apps/macos/Sources/Clawdis/Utilities.swift | 5 - apps/macos/Sources/Clawdis/XPCService.swift | 148 ------------------ apps/macos/Sources/ClawdisCLI/main.swift | 71 +++++---- apps/macos/Sources/ClawdisIPC/IPC.swift | 7 + 10 files changed, 241 insertions(+), 272 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/ControlRequestHandler.swift create mode 100644 apps/macos/Sources/Clawdis/ControlSocketServer.swift delete mode 100644 apps/macos/Sources/Clawdis/XPCService.swift diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index 885057fef..9d633d7c8 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "9d6819a603c065346890e6bfc47d0239e92e1b6510e22766b85e6bdf4f891831", + "originHash" : "ee7127ff91914397f9991e22a0b06ab0bca0d83582adeed6011198c49167631b", "pins" : [ - { - "identity" : "asyncxpcconnection", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ChimeHQ/AsyncXPCConnection", - "state" : { - "revision" : "da31dbcaa1b57949e46dcc19360b17d1a8de06bd", - "version" : "1.3.0" - } - }, { "identity" : "menubarextraaccess", "kind" : "remoteSourceControl", diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index ee9aef66f..9f1d4e788 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -14,7 +14,6 @@ let package = Package( .executable(name: "ClawdisCLI", targets: ["ClawdisCLI"]), ], dependencies: [ - .package(url: "https://github.com/ChimeHQ/AsyncXPCConnection", from: "1.3.0"), .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), @@ -38,7 +37,6 @@ let package = Package( dependencies: [ "ClawdisIPC", "ClawdisProtocol", - .product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"), .product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"), .product(name: "Subprocess", package: "swift-subprocess"), .product(name: "Sparkle", package: "Sparkle"), @@ -55,7 +53,6 @@ let package = Package( dependencies: [ "ClawdisIPC", "ClawdisProtocol", - .product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"), ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index 140e953c6..c94dc6136 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -1,6 +1,5 @@ import Foundation -let serviceName = "com.steipete.clawdis.xpc" let launchdLabel = "com.steipete.clawdis" let onboardingVersionKey = "clawdis.onboardingVersion" let currentOnboardingVersion = 3 diff --git a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift new file mode 100644 index 000000000..43da27283 --- /dev/null +++ b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift @@ -0,0 +1,68 @@ +import ClawdisIPC +import Foundation +import OSLog + +enum ControlRequestHandler { + static func process( + request: Request, + notifier: NotificationManager = NotificationManager(), + logger: Logger = Logger(subsystem: "com.steipete.clawdis", category: "control")) async throws -> Response + { + let paused = await MainActor.run { AppStateStore.isPausedFlag } + if paused { + return Response(ok: false, message: "clawdis paused") + } + + switch request { + case let .notify(title, body, sound): + let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines) + let ok = await notifier.send(title: title, body: body, sound: chosenSound) + return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized") + + case let .ensurePermissions(caps, interactive): + let statuses = await PermissionManager.ensure(caps, interactive: interactive) + let missing = statuses.filter { !$0.value }.map(\.key.rawValue) + let ok = missing.isEmpty + let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))" + return Response(ok: ok, message: msg) + + case .status: + return Response(ok: true, message: "ready") + + case .rpcStatus: + let result = await AgentRPC.shared.status() + return Response(ok: result.ok, message: result.error) + + case let .screenshot(displayID, windowID, _): + let authorized = await PermissionManager + .ensure([.screenRecording], interactive: false)[.screenRecording] ?? false + guard authorized else { return Response(ok: false, message: "screen recording permission missing") } + if let data = await Screenshotter.capture(displayID: displayID, windowID: windowID) { + return Response(ok: true, payload: data) + } + return Response(ok: false, message: "screenshot failed") + + case let .runShell(command, cwd, env, timeoutSec, needsSR): + if needsSR { + let authorized = await PermissionManager + .ensure([.screenRecording], interactive: false)[.screenRecording] ?? false + guard authorized else { return Response(ok: false, message: "screen recording permission missing") } + } + return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec) + + case let .agent(message, thinking, session, deliver, to): + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") } + let sessionKey = session ?? "main" + let rpcResult = await AgentRPC.shared.send( + text: trimmed, + thinking: thinking, + session: sessionKey, + deliver: deliver, + to: to) + return rpcResult.ok + ? Response(ok: true, message: rpcResult.text ?? "sent") + : Response(ok: false, message: rpcResult.error ?? "failed to send") + } + } +} diff --git a/apps/macos/Sources/Clawdis/ControlSocketServer.swift b/apps/macos/Sources/Clawdis/ControlSocketServer.swift new file mode 100644 index 000000000..b2ef98d77 --- /dev/null +++ b/apps/macos/Sources/Clawdis/ControlSocketServer.swift @@ -0,0 +1,118 @@ +import ClawdisIPC +import Foundation +import Darwin + +/// Lightweight UNIX-domain socket server so `clawdis-mac` can talk to the app +/// without a launchd MachService. Listens on `controlSocketPath`. +final actor ControlSocketServer { + private var listenFD: Int32 = -1 + private var source: DispatchSourceRead? + private let maxRequestBytes = 512 * 1024 + + func start() { + // Already running + guard self.listenFD == -1 else { return } + + let path = controlSocketPath + let fm = FileManager.default + // Ensure directory exists + let dir = (path as NSString).deletingLastPathComponent + try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true) + // Remove stale socket + unlink(path) + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let capacity = MemoryLayout.size(ofValue: addr.sun_path) + let copied = path.withCString { cstr -> Int in + strlcpy(&addr.sun_path.0, cstr, capacity) + } + if copied >= capacity { + close(fd) + return + } + addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr)) + let len = socklen_t(MemoryLayout.size(ofValue: addr)) + if bind(fd, withUnsafePointer(to: &addr, { UnsafePointer(OpaquePointer($0)) }), len) != 0 { + close(fd) + return + } + // Restrict permissions: owner rw + chmod(path, S_IRUSR | S_IWUSR) + if listen(fd, SOMAXCONN) != 0 { + close(fd) + return + } + + let src = DispatchSource.makeReadSource(fileDescriptor: fd, queue: .global(qos: .utility)) + src.setEventHandler { [weak self] in + guard let self else { return } + Task { await self.acceptConnection(listenFD: fd) } + } + src.setCancelHandler { close(fd) } + src.resume() + + self.listenFD = fd + self.source = src + } + + func stop() { + self.source?.cancel() + self.source = nil + if self.listenFD != -1 { + close(self.listenFD) + self.listenFD = -1 + } + unlink(controlSocketPath) + } + + private func acceptConnection(listenFD: Int32) { + var addr = sockaddr() + var len: socklen_t = socklen_t(MemoryLayout.size) + let client = accept(listenFD, &addr, &len) + guard client >= 0 else { return } + Task.detached { [weak self] in + defer { close(client) } + guard let self else { return } + await self.handleClient(fd: client) + } + } + + private func handleClient(fd: Int32) async { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 16 * 1024) + let bufSize = buffer.count + while true { + let readCount = buffer.withUnsafeMutableBytes { + read(fd, $0.baseAddress!, bufSize) + } + if readCount > 0 { + data.append(buffer, count: readCount) + if data.count > self.maxRequestBytes { return } + } else { + break + } + } + + guard !data.isEmpty else { return } + + do { + let request = try JSONDecoder().decode(Request.self, from: data) + let response = try await ControlRequestHandler.process(request: request) + let encoded = try JSONEncoder().encode(response) + _ = encoded.withUnsafeBytes { ptr in + write(fd, ptr.baseAddress!, encoded.count) + } + } catch { + let resp = Response(ok: false, message: "socket error: \(error.localizedDescription)") + if let encoded = try? JSONEncoder().encode(resp) { + _ = encoded.withUnsafeBytes { ptr in + write(fd, ptr.baseAddress!, encoded.count) + } + } + } + } +} diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 3791e5b1c..750d0744c 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -150,13 +150,10 @@ private final class StatusItemMouseHandlerView: NSView { } } -final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate { - private var listener: NSXPCListener? +final class AppDelegate: NSObject, NSApplicationDelegate { private var state: AppState? - private let xpcLogger = Logger(subsystem: "com.steipete.clawdis", category: "xpc") private let webChatAutoLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat") - // Only clients signed with this team ID may talk to the XPC service (hard-fails if mismatched). - private let allowedTeamIDs: Set = ["Y5PE65HELJ"] + private let socketServer = ControlSocketServer() let updaterController: UpdaterProviding = makeUpdaterController() @MainActor @@ -173,7 +170,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate Task { PresenceReporter.shared.start() } Task { await HealthStore.shared.refresh(onDemand: true) } Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) } - self.startListener() + Task { await self.socketServer.start() } self.scheduleFirstRunOnboardingIfNeeded() // Developer/testing helper: auto-open WebChat when launched with --webchat @@ -190,15 +187,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate WebChatManager.shared.resetTunnels() Task { await RemoteTunnelManager.shared.stopAll() } Task { await AgentRPC.shared.shutdown() } - } - - @MainActor - private func startListener() { - guard self.state != nil else { return } - let listener = NSXPCListener(machServiceName: serviceName) - listener.delegate = self - listener.resume() - self.listener = listener + Task { await self.socketServer.stop() } } @MainActor @@ -211,73 +200,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate } } - func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { - guard self.isAllowed(connection: connection) else { - self.xpcLogger.error("Rejecting XPC connection: team ID mismatch or invalid audit token") - connection.invalidate() - return false - } - let interface = NSXPCInterface(with: ClawdisXPCProtocol.self) - connection.exportedInterface = interface - connection.exportedObject = ClawdisXPCService() - 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 } - - private func isAllowed(connection: NSXPCConnection) -> Bool { - let pid = connection.processIdentifier - guard pid > 0 else { return false } - - // Same-user shortcut: allow quickly when caller uid == ours. - if let callerUID = self.uid(for: pid), callerUID == getuid() { - return true - } - - let attrs: NSDictionary = [kSecGuestAttributePid: pid] - if self.teamIDMatches(attrs: attrs) { return true } - - return false - } - - private func uid(for pid: pid_t) -> uid_t? { - var info = kinfo_proc() - var size = MemoryLayout.size(ofValue: info) - var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] - let ok = mib.withUnsafeMutableBufferPointer { mibPtr -> Bool in - return sysctl(mibPtr.baseAddress, u_int(mibPtr.count), &info, &size, nil, 0) == 0 - } - return ok ? info.kp_eproc.e_ucred.cr_uid : nil - } - - private func teamIDMatches(attrs: NSDictionary) -> Bool { - var secCode: SecCode? - guard SecCodeCopyGuestWithAttributes(nil, attrs, SecCSFlags(), &secCode) == errSecSuccess, - let code = secCode else { return false } - - var staticCode: SecStaticCode? - guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess, - let sCode = staticCode else { return false } - - var infoCF: CFDictionary? - guard SecCodeCopySigningInformation(sCode, SecCSFlags(), &infoCF) == errSecSuccess, - let info = infoCF as? [String: Any], - let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String - else { - return false - } - - return self.allowedTeamIDs.contains(teamID) - } - - @MainActor - private func writeEndpoint(_ endpoint: NSXPCListenerEndpoint) {} - @MainActor private func writeEndpointIfAvailable() {} } // MARK: - Sparkle updater (disabled for unsigned/dev builds) diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index 0ab27add9..e58aa57bd 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -74,11 +74,6 @@ enum LaunchAgentManager { PATH \(CommandResolver.preferredPaths().joined(separator: ":")) - MachServices - - com.steipete.clawdis.xpc - - StandardOutPath \(LogLocator.launchdLogPath) StandardErrorPath diff --git a/apps/macos/Sources/Clawdis/XPCService.swift b/apps/macos/Sources/Clawdis/XPCService.swift deleted file mode 100644 index 46a30931a..000000000 --- a/apps/macos/Sources/Clawdis/XPCService.swift +++ /dev/null @@ -1,148 +0,0 @@ -import ClawdisIPC -import Foundation -import OSLog - -@objc protocol ClawdisXPCProtocol { - func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void) -} - -final class ClawdisXPCService: NSObject, ClawdisXPCProtocol { - private let logger = Logger(subsystem: "com.steipete.clawdis", category: "xpc") - - func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void) { - let logger = logger - Task.detached { @Sendable in - do { - let request = try JSONDecoder().decode(Request.self, from: data) - let response = try await Self.process(request: request, notifier: NotificationManager(), logger: logger) - let encoded = try JSONEncoder().encode(response) - await MainActor.run { reply(encoded, nil) } - } catch { - logger.error("Failed to handle XPC request: \(error.localizedDescription, privacy: .public)") - let resp = Response(ok: false, message: "decode/handle error: \(error.localizedDescription)") - await MainActor.run { reply(try? JSONEncoder().encode(resp), error) } - } - } - } - - private static func process( - request: Request, - notifier: NotificationManager, - logger: Logger) async throws -> Response - { - let paused = await MainActor.run { AppStateStore.isPausedFlag } - if paused { - return Response(ok: false, message: "clawdis paused") - } - - switch request { - case let .notify(title, body, sound): - let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines) - let ok = await notifier.send(title: title, body: body, sound: chosenSound) - return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized") - - case let .ensurePermissions(caps, interactive): - let statuses = await PermissionManager.ensure(caps, interactive: interactive) - let missing = statuses.filter { !$0.value }.map(\.key.rawValue) - let ok = missing.isEmpty - let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))" - return Response(ok: ok, message: msg) - - case .status: - return Response(ok: true, message: "ready") - - case .rpcStatus: - let result = await AgentRPC.shared.status() - return Response(ok: result.ok, message: result.error) - - case let .screenshot(displayID, windowID, _): - let authorized = await PermissionManager - .ensure([.screenRecording], interactive: false)[.screenRecording] ?? false - guard authorized else { return Response(ok: false, message: "screen recording permission missing") } - if let data = await Screenshotter.capture(displayID: displayID, windowID: windowID) { - return Response(ok: true, payload: data) - } - return Response(ok: false, message: "screenshot failed") - - case let .runShell(command, cwd, env, timeoutSec, needsSR): - if needsSR { - let authorized = await PermissionManager - .ensure([.screenRecording], interactive: false)[.screenRecording] ?? false - guard authorized else { return Response(ok: false, message: "screen recording permission missing") } - } - return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec) - - case let .agent(message, thinking, session, deliver, to): - let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") } - let sessionKey = session ?? "main" - let rpcResult = await AgentRPC.shared.send( - text: trimmed, - thinking: thinking, - session: sessionKey, - deliver: deliver, - to: to) - return rpcResult.ok - ? Response(ok: true, message: rpcResult.text ?? "sent") - : Response(ok: false, message: rpcResult.error ?? "failed to send") - } - } - - private static func runAgentCLI( - message: String, - thinking: String?, - session: String, - deliver: Bool, - to: String?) async -> (ok: Bool, text: String?, error: String?) - { - let projectRoot = CommandResolver.projectRootPath() - var command = CommandResolver.clawdisCommand(subcommand: "agent") - command += ["--message", message, "--json"] - if let to { command += ["--to", to] } - if deliver { command += ["--deliver"] } - if !session.isEmpty { command += ["--session-id", session] } - if let thinking { command += ["--thinking", thinking] } - - let process = Process() - process.executableURL = URL(fileURLWithPath: command.first ?? "/usr/bin/env") - process.arguments = Array(command.dropFirst()) - process.currentDirectoryURL = URL(fileURLWithPath: projectRoot) - - var env = ProcessInfo.processInfo.environment - env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") - process.environment = env - - let outPipe = Pipe() - let errPipe = Pipe() - process.standardOutput = outPipe - process.standardError = errPipe - - do { - try process.run() - } catch { - return (false, nil, "launch failed: \(error.localizedDescription)") - } - - process.waitUntilExit() - let outputData = outPipe.fileHandleForReading.readDataToEndOfFile() - let errorData = errPipe.fileHandleForReading.readDataToEndOfFile() - - guard process.terminationStatus == 0 else { - let errStr = String(data: errorData, encoding: .utf8) ?? "agent failed" - return (false, nil, errStr.trimmingCharacters(in: .whitespacesAndNewlines)) - } - - if - let obj = try? JSONSerialization.jsonObject(with: outputData) as? [String: Any], - let payloads = obj["payloads"] as? [[String: Any]], - let first = payloads.first, - let text = first["text"] as? String - { - return (true, text, nil) - } - - let fallback = String(data: outputData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - return (true, fallback, nil) - } -} diff --git a/apps/macos/Sources/ClawdisCLI/main.swift b/apps/macos/Sources/ClawdisCLI/main.swift index db1865b3e..aef7dfe72 100644 --- a/apps/macos/Sources/ClawdisCLI/main.swift +++ b/apps/macos/Sources/ClawdisCLI/main.swift @@ -1,12 +1,6 @@ -import AsyncXPCConnection import ClawdisIPC import Foundation - -private let serviceName = "com.steipete.clawdis.xpc" - -@objc protocol ClawdisXPCProtocol { - func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void) -} +import Darwin @main struct ClawdisCLI { @@ -222,30 +216,51 @@ struct ClawdisCLI { private static func send(request: Request) async throws -> Response { try await self.ensureAppRunning() - var lastError: Error? - for _ in 0..<10 { - let conn = NSXPCConnection(machServiceName: serviceName) - let interface = NSXPCInterface(with: ClawdisXPCProtocol.self) - conn.remoteObjectInterface = interface - conn.resume() + return try await self.sendViaSocket(request: request) + } - 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) + /// Attempt a direct UNIX socket call; falls back to XPC if unavailable. + private static func sendViaSocket(request: Request) async throws -> Response { + let path = controlSocketPath + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw POSIXError(.ECONNREFUSED) } + defer { close(fd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let capacity = MemoryLayout.size(ofValue: addr.sun_path) + let copied = path.withCString { cstr -> Int in + strlcpy(&addr.sun_path.0, cstr, capacity) + } + guard copied < capacity else { throw POSIXError(.ENAMETOOLONG) } + addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr)) + let len = socklen_t(MemoryLayout.size(ofValue: addr)) + let result = withUnsafePointer(to: &addr) { ptr -> Int32 in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + connect(fd, sockPtr, len) } } - throw lastError ?? CLIError.help + guard result == 0 else { throw POSIXError(.ECONNREFUSED) } + + let payload = try JSONEncoder().encode(request) + _ = payload.withUnsafeBytes { buf in + write(fd, buf.baseAddress!, payload.count) + } + shutdown(fd, SHUT_WR) + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 8192) + let bufSize = buffer.count + while true { + let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, bufSize) } + if n > 0 { + data.append(buffer, count: n) + } else { + break + } + } + guard !data.isEmpty else { throw POSIXError(.ECONNRESET) } + return try JSONDecoder().decode(Response.self, from: data) } private static func ensureAppRunning() async throws { diff --git a/apps/macos/Sources/ClawdisIPC/IPC.swift b/apps/macos/Sources/ClawdisIPC/IPC.swift index 156aca590..9bdfe8a47 100644 --- a/apps/macos/Sources/ClawdisIPC/IPC.swift +++ b/apps/macos/Sources/ClawdisIPC/IPC.swift @@ -156,3 +156,10 @@ extension Request: Codable { } } } + +// Shared transport settings +public let controlSocketPath = + FileManager.default + .homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/clawdis/control.sock") + .path