149 lines
6.3 KiB
Swift
149 lines
6.3 KiB
Swift
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 ShellRunner.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)
|
|
}
|
|
}
|