mac: replace xpc with unix socket control channel
This commit is contained in:
@@ -1,15 +1,6 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "9d6819a603c065346890e6bfc47d0239e92e1b6510e22766b85e6bdf4f891831",
|
"originHash" : "ee7127ff91914397f9991e22a0b06ab0bca0d83582adeed6011198c49167631b",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
|
||||||
"identity" : "asyncxpcconnection",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/ChimeHQ/AsyncXPCConnection",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "da31dbcaa1b57949e46dcc19360b17d1a8de06bd",
|
|
||||||
"version" : "1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "menubarextraaccess",
|
"identity" : "menubarextraaccess",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ let package = Package(
|
|||||||
.executable(name: "ClawdisCLI", targets: ["ClawdisCLI"]),
|
.executable(name: "ClawdisCLI", targets: ["ClawdisCLI"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
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/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
.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"),
|
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
||||||
@@ -38,7 +37,6 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
"ClawdisIPC",
|
"ClawdisIPC",
|
||||||
"ClawdisProtocol",
|
"ClawdisProtocol",
|
||||||
.product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"),
|
|
||||||
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
||||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||||
.product(name: "Sparkle", package: "Sparkle"),
|
.product(name: "Sparkle", package: "Sparkle"),
|
||||||
@@ -55,7 +53,6 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
"ClawdisIPC",
|
"ClawdisIPC",
|
||||||
"ClawdisProtocol",
|
"ClawdisProtocol",
|
||||||
.product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"),
|
|
||||||
],
|
],
|
||||||
swiftSettings: [
|
swiftSettings: [
|
||||||
.enableUpcomingFeature("StrictConcurrency"),
|
.enableUpcomingFeature("StrictConcurrency"),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
let serviceName = "com.steipete.clawdis.xpc"
|
|
||||||
let launchdLabel = "com.steipete.clawdis"
|
let launchdLabel = "com.steipete.clawdis"
|
||||||
let onboardingVersionKey = "clawdis.onboardingVersion"
|
let onboardingVersionKey = "clawdis.onboardingVersion"
|
||||||
let currentOnboardingVersion = 3
|
let currentOnboardingVersion = 3
|
||||||
|
|||||||
68
apps/macos/Sources/Clawdis/ControlRequestHandler.swift
Normal file
68
apps/macos/Sources/Clawdis/ControlRequestHandler.swift
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
apps/macos/Sources/Clawdis/ControlSocketServer.swift
Normal file
118
apps/macos/Sources/Clawdis/ControlSocketServer.swift
Normal file
@@ -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<sockaddr>(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<sockaddr>.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,13 +150,10 @@ private final class StatusItemMouseHandlerView: NSView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
private var listener: NSXPCListener?
|
|
||||||
private var state: AppState?
|
private var state: AppState?
|
||||||
private let xpcLogger = Logger(subsystem: "com.steipete.clawdis", category: "xpc")
|
|
||||||
private let webChatAutoLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat")
|
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 socketServer = ControlSocketServer()
|
||||||
private let allowedTeamIDs: Set<String> = ["Y5PE65HELJ"]
|
|
||||||
let updaterController: UpdaterProviding = makeUpdaterController()
|
let updaterController: UpdaterProviding = makeUpdaterController()
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -173,7 +170,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
|||||||
Task { PresenceReporter.shared.start() }
|
Task { PresenceReporter.shared.start() }
|
||||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||||
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
||||||
self.startListener()
|
Task { await self.socketServer.start() }
|
||||||
self.scheduleFirstRunOnboardingIfNeeded()
|
self.scheduleFirstRunOnboardingIfNeeded()
|
||||||
|
|
||||||
// Developer/testing helper: auto-open WebChat when launched with --webchat
|
// Developer/testing helper: auto-open WebChat when launched with --webchat
|
||||||
@@ -190,15 +187,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
|
|||||||
WebChatManager.shared.resetTunnels()
|
WebChatManager.shared.resetTunnels()
|
||||||
Task { await RemoteTunnelManager.shared.stopAll() }
|
Task { await RemoteTunnelManager.shared.stopAll() }
|
||||||
Task { await AgentRPC.shared.shutdown() }
|
Task { await AgentRPC.shared.shutdown() }
|
||||||
}
|
Task { await self.socketServer.stop() }
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func startListener() {
|
|
||||||
guard self.state != nil else { return }
|
|
||||||
let listener = NSXPCListener(machServiceName: serviceName)
|
|
||||||
listener.delegate = self
|
|
||||||
listener.resume()
|
|
||||||
self.listener = listener
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@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 {
|
private func isDuplicateInstance() -> Bool {
|
||||||
guard let bundleID = Bundle.main.bundleIdentifier else { return false }
|
guard let bundleID = Bundle.main.bundleIdentifier else { return false }
|
||||||
let running = NSWorkspace.shared.runningApplications.filter { $0.bundleIdentifier == bundleID }
|
let running = NSWorkspace.shared.runningApplications.filter { $0.bundleIdentifier == bundleID }
|
||||||
return running.count > 1
|
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)
|
// MARK: - Sparkle updater (disabled for unsigned/dev builds)
|
||||||
|
|||||||
@@ -74,11 +74,6 @@ enum LaunchAgentManager {
|
|||||||
<key>PATH</key>
|
<key>PATH</key>
|
||||||
<string>\(CommandResolver.preferredPaths().joined(separator: ":"))</string>
|
<string>\(CommandResolver.preferredPaths().joined(separator: ":"))</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>MachServices</key>
|
|
||||||
<dict>
|
|
||||||
<key>com.steipete.clawdis.xpc</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>StandardOutPath</key>
|
<key>StandardOutPath</key>
|
||||||
<string>\(LogLocator.launchdLogPath)</string>
|
<string>\(LogLocator.launchdLogPath)</string>
|
||||||
<key>StandardErrorPath</key>
|
<key>StandardErrorPath</key>
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
import AsyncXPCConnection
|
|
||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Darwin
|
||||||
private let serviceName = "com.steipete.clawdis.xpc"
|
|
||||||
|
|
||||||
@objc protocol ClawdisXPCProtocol {
|
|
||||||
func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void)
|
|
||||||
}
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct ClawdisCLI {
|
struct ClawdisCLI {
|
||||||
@@ -222,30 +216,51 @@ struct ClawdisCLI {
|
|||||||
private static func send(request: Request) async throws -> Response {
|
private static func send(request: Request) async throws -> Response {
|
||||||
try await self.ensureAppRunning()
|
try await self.ensureAppRunning()
|
||||||
|
|
||||||
var lastError: Error?
|
return try await self.sendViaSocket(request: request)
|
||||||
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)
|
/// Attempt a direct UNIX socket call; falls back to XPC if unavailable.
|
||||||
do {
|
private static func sendViaSocket(request: Request) async throws -> Response {
|
||||||
let service = AsyncXPCConnection.RemoteXPCService<ClawdisXPCProtocol>(connection: conn)
|
let path = controlSocketPath
|
||||||
let raw: Data = try await service.withValueErrorCompletion { proxy, completion in
|
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||||
struct CompletionBox: @unchecked Sendable { let handler: (Data?, Error?) -> Void }
|
guard fd >= 0 else { throw POSIXError(.ECONNREFUSED) }
|
||||||
let box = CompletionBox(handler: completion)
|
defer { close(fd) }
|
||||||
proxy.handle(data, withReply: { data, error in box.handler(data, error) })
|
|
||||||
}
|
var addr = sockaddr_un()
|
||||||
conn.invalidate()
|
addr.sun_family = sa_family_t(AF_UNIX)
|
||||||
return try JSONDecoder().decode(Response.self, from: raw)
|
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
|
||||||
} catch {
|
let copied = path.withCString { cstr -> Int in
|
||||||
lastError = error
|
strlcpy(&addr.sun_path.0, cstr, capacity)
|
||||||
conn.invalidate()
|
}
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
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 {
|
private static func ensureAppRunning() async throws {
|
||||||
|
|||||||
@@ -156,3 +156,10 @@ extension Request: Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared transport settings
|
||||||
|
public let controlSocketPath =
|
||||||
|
FileManager.default
|
||||||
|
.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent("Library/Application Support/clawdis/control.sock")
|
||||||
|
.path
|
||||||
|
|||||||
Reference in New Issue
Block a user