refactor(cli): unify on clawdis CLI + node permissions
This commit is contained in:
@@ -1,614 +0,0 @@
|
||||
import ClawdisIPC
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum ControlRequestHandler {
|
||||
struct NodeListNode: Codable {
|
||||
var nodeId: String
|
||||
var displayName: String?
|
||||
var platform: String?
|
||||
var version: String?
|
||||
var deviceFamily: String?
|
||||
var modelIdentifier: String?
|
||||
var remoteAddress: String?
|
||||
var connected: Bool
|
||||
var paired: Bool
|
||||
var capabilities: [String]?
|
||||
var commands: [String]?
|
||||
}
|
||||
|
||||
struct NodeListResult: Codable {
|
||||
var ts: Int
|
||||
var connectedNodeIds: [String]
|
||||
var pairedNodeIds: [String]
|
||||
var nodes: [NodeListNode]
|
||||
}
|
||||
|
||||
struct GatewayNodeListPayload: Decodable {
|
||||
struct Node: Decodable {
|
||||
var nodeId: String
|
||||
var displayName: String?
|
||||
var platform: String?
|
||||
var version: String?
|
||||
var deviceFamily: String?
|
||||
var modelIdentifier: String?
|
||||
var remoteIp: String?
|
||||
var connected: Bool?
|
||||
var paired: Bool?
|
||||
var caps: [String]?
|
||||
var commands: [String]?
|
||||
}
|
||||
|
||||
var ts: Int?
|
||||
var nodes: [Node]
|
||||
}
|
||||
|
||||
static func process(
|
||||
request: Request,
|
||||
notifier: NotificationManager = NotificationManager(),
|
||||
logger: Logger = Logger(subsystem: "com.steipete.clawdis", category: "control")) async throws -> Response
|
||||
{
|
||||
// Keep `status` responsive even if the main actor is busy.
|
||||
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
if paused, case .status = request {
|
||||
// allow status through
|
||||
} else if paused {
|
||||
return Response(ok: false, message: "clawdis paused")
|
||||
}
|
||||
|
||||
switch request {
|
||||
case let .notify(title, body, sound, priority, delivery):
|
||||
let notify = NotifyRequest(
|
||||
title: title,
|
||||
body: body,
|
||||
sound: sound,
|
||||
priority: priority,
|
||||
delivery: delivery)
|
||||
return await self.handleNotify(notify, notifier: notifier)
|
||||
|
||||
case let .ensurePermissions(caps, interactive):
|
||||
return await self.handleEnsurePermissions(caps: caps, interactive: interactive)
|
||||
|
||||
case .status:
|
||||
return paused
|
||||
? Response(ok: false, message: "clawdis paused")
|
||||
: Response(ok: true, message: "ready")
|
||||
|
||||
case .rpcStatus:
|
||||
return await self.handleRPCStatus()
|
||||
|
||||
case let .runShell(command, cwd, env, timeoutSec, needsSR):
|
||||
return await self.handleRunShell(
|
||||
command: command,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
timeoutSec: timeoutSec,
|
||||
needsSR: needsSR)
|
||||
|
||||
case let .agent(message, thinking, session, deliver, to):
|
||||
return await self.handleAgent(
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
session: session,
|
||||
deliver: deliver,
|
||||
to: to)
|
||||
|
||||
case let .canvasPresent(session, path, placement):
|
||||
return await self.handleCanvasPresent(session: session, path: path, placement: placement)
|
||||
|
||||
case let .canvasHide(session):
|
||||
return await self.handleCanvasHide(session: session)
|
||||
|
||||
case let .canvasEval(session, javaScript):
|
||||
return await self.handleCanvasEval(session: session, javaScript: javaScript)
|
||||
|
||||
case let .canvasSnapshot(session, outPath):
|
||||
return await self.handleCanvasSnapshot(session: session, outPath: outPath)
|
||||
|
||||
case let .canvasA2UI(session, command, jsonl):
|
||||
return await self.handleCanvasA2UI(session: session, command: command, jsonl: jsonl)
|
||||
|
||||
case .nodeList:
|
||||
return await self.handleNodeList()
|
||||
|
||||
case let .nodeDescribe(nodeId):
|
||||
return await self.handleNodeDescribe(nodeId: nodeId)
|
||||
|
||||
case let .nodeInvoke(nodeId, command, paramsJSON):
|
||||
return await self.handleNodeInvoke(
|
||||
nodeId: nodeId,
|
||||
command: command,
|
||||
paramsJSON: paramsJSON,
|
||||
logger: logger)
|
||||
|
||||
case let .cameraSnap(facing, maxWidth, quality, outPath):
|
||||
return await self.handleCameraSnap(facing: facing, maxWidth: maxWidth, quality: quality, outPath: outPath)
|
||||
|
||||
case let .cameraClip(facing, durationMs, includeAudio, outPath):
|
||||
return await self.handleCameraClip(
|
||||
facing: facing,
|
||||
durationMs: durationMs,
|
||||
includeAudio: includeAudio,
|
||||
outPath: outPath)
|
||||
|
||||
case let .screenRecord(screenIndex, durationMs, fps, includeAudio, outPath):
|
||||
return await self.handleScreenRecord(
|
||||
screenIndex: screenIndex,
|
||||
durationMs: durationMs,
|
||||
fps: fps,
|
||||
includeAudio: includeAudio,
|
||||
outPath: outPath)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotifyRequest {
|
||||
var title: String
|
||||
var body: String
|
||||
var sound: String?
|
||||
var priority: NotificationPriority?
|
||||
var delivery: NotificationDelivery?
|
||||
}
|
||||
|
||||
private static func handleNotify(_ request: NotifyRequest, notifier: NotificationManager) async -> Response {
|
||||
let chosenSound = request.sound?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let chosenDelivery = request.delivery ?? .system
|
||||
|
||||
switch chosenDelivery {
|
||||
case .system:
|
||||
let ok = await notifier.send(
|
||||
title: request.title,
|
||||
body: request.body,
|
||||
sound: chosenSound,
|
||||
priority: request.priority)
|
||||
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
|
||||
case .overlay:
|
||||
await NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
||||
return Response(ok: true)
|
||||
case .auto:
|
||||
let ok = await notifier.send(
|
||||
title: request.title,
|
||||
body: request.body,
|
||||
sound: chosenSound,
|
||||
priority: request.priority)
|
||||
if ok { return Response(ok: true) }
|
||||
await NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
||||
return Response(ok: true, message: "notification not authorized; used overlay")
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleEnsurePermissions(caps: [Capability], interactive: Bool) async -> Response {
|
||||
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)
|
||||
}
|
||||
|
||||
private static func handleRPCStatus() async -> Response {
|
||||
let result = await GatewayConnection.shared.status()
|
||||
return Response(ok: result.ok, message: result.error)
|
||||
}
|
||||
|
||||
private static func handleRunShell(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?,
|
||||
timeoutSec: Double?,
|
||||
needsSR: Bool) async -> Response
|
||||
{
|
||||
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)
|
||||
}
|
||||
|
||||
private static func handleAgent(
|
||||
message: String,
|
||||
thinking: String?,
|
||||
session: String?,
|
||||
deliver: Bool,
|
||||
to: String?) async -> Response
|
||||
{
|
||||
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
|
||||
let sessionKey = session ?? "main"
|
||||
let invocation = GatewayAgentInvocation(
|
||||
message: trimmed,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
deliver: deliver,
|
||||
to: to,
|
||||
channel: .last)
|
||||
let rpcResult = await GatewayConnection.shared.sendAgent(invocation)
|
||||
return rpcResult.ok ? Response(ok: true, message: "sent") : Response(ok: false, message: rpcResult.error)
|
||||
}
|
||||
|
||||
private static func canvasEnabled() -> Bool {
|
||||
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
private static func cameraEnabled() -> Bool {
|
||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||
}
|
||||
|
||||
private static func handleCanvasPresent(
|
||||
session: String,
|
||||
path: String?,
|
||||
placement: CanvasPlacement?) async -> Response
|
||||
{
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
_ = session
|
||||
do {
|
||||
var params: [String: Any] = [:]
|
||||
if let path, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
params["url"] = path
|
||||
}
|
||||
if let placement {
|
||||
var placementPayload: [String: Any] = [:]
|
||||
if let x = placement.x { placementPayload["x"] = x }
|
||||
if let y = placement.y { placementPayload["y"] = y }
|
||||
if let width = placement.width { placementPayload["width"] = width }
|
||||
if let height = placement.height { placementPayload["height"] = height }
|
||||
if !placementPayload.isEmpty {
|
||||
params["placement"] = placementPayload
|
||||
}
|
||||
}
|
||||
_ = try await self.invokeLocalNode(
|
||||
command: ClawdisCanvasCommand.present.rawValue,
|
||||
params: params.isEmpty ? nil : params,
|
||||
timeoutMs: 20000)
|
||||
return Response(ok: true)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleCanvasHide(session: String) async -> Response {
|
||||
_ = session
|
||||
do {
|
||||
_ = try await self.invokeLocalNode(
|
||||
command: ClawdisCanvasCommand.hide.rawValue,
|
||||
params: nil,
|
||||
timeoutMs: 10000)
|
||||
return Response(ok: true)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleCanvasEval(session: String, javaScript: String) async -> Response {
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
_ = session
|
||||
do {
|
||||
let payload = try await self.invokeLocalNode(
|
||||
command: ClawdisCanvasCommand.evalJS.rawValue,
|
||||
params: ["javaScript": javaScript],
|
||||
timeoutMs: 20000)
|
||||
if let dict = payload as? [String: Any],
|
||||
let result = dict["result"] as? String
|
||||
{
|
||||
return Response(ok: true, payload: Data(result.utf8))
|
||||
}
|
||||
return Response(ok: true)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleCanvasSnapshot(session: String, outPath: String?) async -> Response {
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
_ = session
|
||||
do {
|
||||
let payload = try await self.invokeLocalNode(
|
||||
command: ClawdisCanvasCommand.snapshot.rawValue,
|
||||
params: [:],
|
||||
timeoutMs: 20000)
|
||||
guard let dict = payload as? [String: Any],
|
||||
let format = dict["format"] as? String,
|
||||
let base64 = dict["base64"] as? String,
|
||||
let data = Data(base64Encoded: base64)
|
||||
else {
|
||||
return Response(ok: false, message: "invalid canvas snapshot payload")
|
||||
}
|
||||
let ext = (format.lowercased() == "jpeg" || format.lowercased() == "jpg") ? "jpg" : "png"
|
||||
let url: URL = if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
URL(fileURLWithPath: outPath)
|
||||
} else {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdis-canvas-snapshot-\(UUID().uuidString).\(ext)")
|
||||
}
|
||||
try data.write(to: url, options: [.atomic])
|
||||
return Response(ok: true, message: url.path)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleCanvasA2UI(
|
||||
session: String,
|
||||
command: CanvasA2UICommand,
|
||||
jsonl: String?) async -> Response
|
||||
{
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
_ = session
|
||||
do {
|
||||
switch command {
|
||||
case .reset:
|
||||
let payload = try await self.invokeLocalNode(
|
||||
command: ClawdisCanvasA2UICommand.reset.rawValue,
|
||||
params: nil,
|
||||
timeoutMs: 20000)
|
||||
if let payload {
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
return Response(ok: true, payload: data)
|
||||
}
|
||||
return Response(ok: true)
|
||||
case .pushJSONL:
|
||||
guard let jsonl, !jsonl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return Response(ok: false, message: "missing jsonl")
|
||||
}
|
||||
let payload = try await self.invokeLocalNode(
|
||||
command: ClawdisCanvasA2UICommand.pushJSONL.rawValue,
|
||||
params: ["jsonl": jsonl],
|
||||
timeoutMs: 30000)
|
||||
if let payload {
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
return Response(ok: true, payload: data)
|
||||
}
|
||||
return Response(ok: true)
|
||||
}
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleNodeList() async -> Response {
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: "node.list",
|
||||
params: [:],
|
||||
timeoutMs: 10000)
|
||||
let payload = try JSONDecoder().decode(GatewayNodeListPayload.self, from: data)
|
||||
let result = self.buildNodeListResult(payload: payload)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let json = (try? encoder.encode(result))
|
||||
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
|
||||
return Response(ok: true, payload: Data(json.utf8))
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleNodeDescribe(nodeId: String) async -> Response {
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: "node.describe",
|
||||
params: ["nodeId": AnyCodable(nodeId)],
|
||||
timeoutMs: 10000)
|
||||
return Response(ok: true, payload: data)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
static func buildNodeListResult(payload: GatewayNodeListPayload) -> NodeListResult {
|
||||
let nodes = payload.nodes.map { n -> NodeListNode in
|
||||
NodeListNode(
|
||||
nodeId: n.nodeId,
|
||||
displayName: n.displayName,
|
||||
platform: n.platform,
|
||||
version: n.version,
|
||||
deviceFamily: n.deviceFamily,
|
||||
modelIdentifier: n.modelIdentifier,
|
||||
remoteAddress: n.remoteIp,
|
||||
connected: n.connected == true,
|
||||
paired: n.paired == true,
|
||||
capabilities: n.caps,
|
||||
commands: n.commands)
|
||||
}
|
||||
|
||||
let sorted = nodes.sorted { a, b in
|
||||
(a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId)
|
||||
}
|
||||
|
||||
let pairedNodeIds = sorted.filter(\.paired).map(\.nodeId).sorted()
|
||||
let connectedNodeIds = sorted.filter(\.connected).map(\.nodeId).sorted()
|
||||
|
||||
return NodeListResult(
|
||||
ts: payload.ts ?? Int(Date().timeIntervalSince1970 * 1000),
|
||||
connectedNodeIds: connectedNodeIds,
|
||||
pairedNodeIds: pairedNodeIds,
|
||||
nodes: sorted)
|
||||
}
|
||||
|
||||
private static func handleNodeInvoke(
|
||||
nodeId: String,
|
||||
command: String,
|
||||
paramsJSON: String?,
|
||||
logger: Logger) async -> Response
|
||||
{
|
||||
do {
|
||||
var paramsObj: Any?
|
||||
let raw = (paramsJSON ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !raw.isEmpty {
|
||||
if let data = raw.data(using: .utf8) {
|
||||
paramsObj = try JSONSerialization.jsonObject(with: data)
|
||||
} else {
|
||||
return Response(ok: false, message: "params-json not UTF-8")
|
||||
}
|
||||
}
|
||||
|
||||
var params: [String: AnyCodable] = [
|
||||
"nodeId": AnyCodable(nodeId),
|
||||
"command": AnyCodable(command),
|
||||
"idempotencyKey": AnyCodable(UUID().uuidString),
|
||||
]
|
||||
if let paramsObj {
|
||||
params["params"] = AnyCodable(paramsObj)
|
||||
}
|
||||
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: "node.invoke",
|
||||
params: params,
|
||||
timeoutMs: 30000)
|
||||
return Response(ok: true, payload: data)
|
||||
} catch {
|
||||
logger.error("node invoke failed: \(error.localizedDescription, privacy: .public)")
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleCameraSnap(
|
||||
facing: CameraFacing?,
|
||||
maxWidth: Int?,
|
||||
quality: Double?,
|
||||
outPath: String?) async -> Response
|
||||
{
|
||||
guard self.cameraEnabled() else { return Response(ok: false, message: "Camera disabled by user") }
|
||||
do {
|
||||
var params: [String: Any] = [:]
|
||||
if let facing { params["facing"] = facing.rawValue }
|
||||
if let maxWidth { params["maxWidth"] = maxWidth }
|
||||
if let quality { params["quality"] = quality }
|
||||
params["format"] = "jpg"
|
||||
|
||||
let payload = try await self.invokeLocalNode(
|
||||
command: ClawdisCameraCommand.snap.rawValue,
|
||||
params: params,
|
||||
timeoutMs: 30000)
|
||||
guard let dict = payload as? [String: Any],
|
||||
let format = dict["format"] as? String,
|
||||
let base64 = dict["base64"] as? String,
|
||||
let data = Data(base64Encoded: base64)
|
||||
else {
|
||||
return Response(ok: false, message: "invalid camera snapshot payload")
|
||||
}
|
||||
|
||||
let ext = (format.lowercased() == "jpeg" || format.lowercased() == "jpg") ? "jpg" : format.lowercased()
|
||||
let url: URL = if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
URL(fileURLWithPath: outPath)
|
||||
} else {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdis-camera-snap-\(UUID().uuidString).\(ext)")
|
||||
}
|
||||
|
||||
try data.write(to: url, options: [.atomic])
|
||||
return Response(ok: true, message: url.path)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleCameraClip(
|
||||
facing: CameraFacing?,
|
||||
durationMs: Int?,
|
||||
includeAudio: Bool,
|
||||
outPath: String?) async -> Response
|
||||
{
|
||||
guard self.cameraEnabled() else { return Response(ok: false, message: "Camera disabled by user") }
|
||||
do {
|
||||
var params: [String: Any] = ["includeAudio": includeAudio, "format": "mp4"]
|
||||
if let facing { params["facing"] = facing.rawValue }
|
||||
if let durationMs { params["durationMs"] = durationMs }
|
||||
|
||||
let payload = try await self.invokeLocalNode(
|
||||
command: ClawdisCameraCommand.clip.rawValue,
|
||||
params: params,
|
||||
timeoutMs: 90000)
|
||||
guard let dict = payload as? [String: Any],
|
||||
let format = dict["format"] as? String,
|
||||
let base64 = dict["base64"] as? String,
|
||||
let data = Data(base64Encoded: base64)
|
||||
else {
|
||||
return Response(ok: false, message: "invalid camera clip payload")
|
||||
}
|
||||
|
||||
let ext = format.lowercased()
|
||||
let url: URL = if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
URL(fileURLWithPath: outPath)
|
||||
} else {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdis-camera-clip-\(UUID().uuidString).\(ext)")
|
||||
}
|
||||
try data.write(to: url, options: [.atomic])
|
||||
return Response(ok: true, message: url.path)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleScreenRecord(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool,
|
||||
outPath: String?) async -> Response
|
||||
{
|
||||
do {
|
||||
var params: [String: Any] = ["format": "mp4", "includeAudio": includeAudio]
|
||||
if let screenIndex { params["screenIndex"] = screenIndex }
|
||||
if let durationMs { params["durationMs"] = durationMs }
|
||||
if let fps { params["fps"] = fps }
|
||||
|
||||
let payload = try await self.invokeLocalNode(
|
||||
command: "screen.record",
|
||||
params: params,
|
||||
timeoutMs: 120_000)
|
||||
guard let dict = payload as? [String: Any],
|
||||
let base64 = dict["base64"] as? String,
|
||||
let data = Data(base64Encoded: base64)
|
||||
else {
|
||||
return Response(ok: false, message: "invalid screen record payload")
|
||||
}
|
||||
let url: URL = if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
URL(fileURLWithPath: outPath)
|
||||
} else {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdis-screen-record-\(UUID().uuidString).mp4")
|
||||
}
|
||||
try data.write(to: url, options: [.atomic])
|
||||
return Response(ok: true, message: url.path)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func invokeLocalNode(
|
||||
command: String,
|
||||
params: [String: Any]?,
|
||||
timeoutMs: Double) async throws -> Any?
|
||||
{
|
||||
var gatewayParams: [String: AnyCodable] = [
|
||||
"nodeId": AnyCodable(Self.localNodeId()),
|
||||
"command": AnyCodable(command),
|
||||
"idempotencyKey": AnyCodable(UUID().uuidString),
|
||||
]
|
||||
if let params {
|
||||
gatewayParams["params"] = AnyCodable(params)
|
||||
}
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: "node.invoke",
|
||||
params: gatewayParams,
|
||||
timeoutMs: timeoutMs)
|
||||
return try Self.decodeNodeInvokePayload(data: data)
|
||||
}
|
||||
|
||||
private static func decodeNodeInvokePayload(data: Data) throws -> Any? {
|
||||
let obj = try JSONSerialization.jsonObject(with: data)
|
||||
guard let dict = obj as? [String: Any] else {
|
||||
throw NSError(domain: "Node", code: 30, userInfo: [
|
||||
NSLocalizedDescriptionKey: "invalid node invoke response",
|
||||
])
|
||||
}
|
||||
return dict["payload"]
|
||||
}
|
||||
|
||||
private static func localNodeId() -> String {
|
||||
"mac-\(InstanceIdentity.instanceId)"
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
import ClawdisIPC
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// Lightweight UNIX-domain socket server so `clawdis-mac` can talk to the app
|
||||
/// without a launchd MachService. Listens on `controlSocketPath`.
|
||||
final actor ControlSocketServer {
|
||||
private nonisolated static let logger = Logger(subsystem: "com.steipete.clawdis", category: "control.socket")
|
||||
|
||||
private var listenFD: Int32 = -1
|
||||
private var acceptTask: Task<Void, Never>?
|
||||
|
||||
private let socketPath: String
|
||||
private let maxRequestBytes: Int
|
||||
private let allowedTeamIDs: Set<String>
|
||||
private let requestTimeoutSec: TimeInterval
|
||||
|
||||
init(
|
||||
socketPath: String = controlSocketPath,
|
||||
maxRequestBytes: Int = 512 * 1024,
|
||||
allowedTeamIDs: Set<String> = ["Y5PE65HELJ"],
|
||||
requestTimeoutSec: TimeInterval = 5)
|
||||
{
|
||||
self.socketPath = socketPath
|
||||
self.maxRequestBytes = maxRequestBytes
|
||||
self.allowedTeamIDs = allowedTeamIDs
|
||||
self.requestTimeoutSec = requestTimeoutSec
|
||||
}
|
||||
|
||||
private static func disableSigPipe(fd: Int32) {
|
||||
var one: Int32 = 1
|
||||
_ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size(ofValue: one)))
|
||||
}
|
||||
|
||||
func start() {
|
||||
// Already running
|
||||
guard self.listenFD == -1 else { return }
|
||||
|
||||
let path = self.socketPath
|
||||
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
|
||||
}
|
||||
|
||||
self.listenFD = fd
|
||||
|
||||
let allowedTeamIDs = self.allowedTeamIDs
|
||||
let maxRequestBytes = self.maxRequestBytes
|
||||
let requestTimeoutSec = self.requestTimeoutSec
|
||||
self.acceptTask = Task.detached(priority: .utility) {
|
||||
await Self.acceptLoop(
|
||||
listenFD: fd,
|
||||
allowedTeamIDs: allowedTeamIDs,
|
||||
maxRequestBytes: maxRequestBytes,
|
||||
requestTimeoutSec: requestTimeoutSec)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.acceptTask?.cancel()
|
||||
self.acceptTask = nil
|
||||
if self.listenFD != -1 {
|
||||
close(self.listenFD)
|
||||
self.listenFD = -1
|
||||
}
|
||||
unlink(self.socketPath)
|
||||
}
|
||||
|
||||
private nonisolated static func acceptLoop(
|
||||
listenFD: Int32,
|
||||
allowedTeamIDs: Set<String>,
|
||||
maxRequestBytes: Int,
|
||||
requestTimeoutSec: TimeInterval) async
|
||||
{
|
||||
while !Task.isCancelled {
|
||||
var addr = sockaddr()
|
||||
var len = socklen_t(MemoryLayout<sockaddr>.size)
|
||||
let client = accept(listenFD, &addr, &len)
|
||||
if client < 0 {
|
||||
if errno == EINTR { continue }
|
||||
// Socket was likely closed as part of stop().
|
||||
if errno == EBADF || errno == EINVAL { return }
|
||||
self.logger.error("accept failed: \(errno, privacy: .public)")
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
continue
|
||||
}
|
||||
|
||||
Self.disableSigPipe(fd: client)
|
||||
Task.detached(priority: .utility) {
|
||||
defer { close(client) }
|
||||
await Self.handleClient(
|
||||
fd: client,
|
||||
allowedTeamIDs: allowedTeamIDs,
|
||||
maxRequestBytes: maxRequestBytes,
|
||||
requestTimeoutSec: requestTimeoutSec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func handleClient(
|
||||
fd: Int32,
|
||||
allowedTeamIDs: Set<String>,
|
||||
maxRequestBytes: Int,
|
||||
requestTimeoutSec: TimeInterval) async
|
||||
{
|
||||
guard self.isAllowed(fd: fd, allowedTeamIDs: allowedTeamIDs) else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard let request = try self.readRequest(
|
||||
fd: fd,
|
||||
maxRequestBytes: maxRequestBytes,
|
||||
timeoutSec: requestTimeoutSec)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let response = try await ControlRequestHandler.process(request: request)
|
||||
try self.writeResponse(fd: fd, response: response)
|
||||
} catch {
|
||||
self.logger.error("socket request failed: \(error.localizedDescription, privacy: .public)")
|
||||
let resp = Response(ok: false, message: "socket error: \(error.localizedDescription)")
|
||||
try? self.writeResponse(fd: fd, response: resp)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func readRequest(
|
||||
fd: Int32,
|
||||
maxRequestBytes: Int,
|
||||
timeoutSec: TimeInterval) throws -> Request?
|
||||
{
|
||||
let deadline = Date().addingTimeInterval(timeoutSec)
|
||||
var data = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: 16 * 1024)
|
||||
let bufferSize = buffer.count
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
while true {
|
||||
let remaining = deadline.timeIntervalSinceNow
|
||||
if remaining <= 0 {
|
||||
throw POSIXError(.ETIMEDOUT)
|
||||
}
|
||||
|
||||
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
let sliceMs = max(1.0, min(remaining, 0.25) * 1000.0)
|
||||
let polled = poll(&pfd, 1, Int32(sliceMs))
|
||||
if polled == 0 { continue }
|
||||
if polled < 0 {
|
||||
if errno == EINTR { continue }
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
|
||||
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, bufferSize) }
|
||||
if n > 0 {
|
||||
data.append(buffer, count: n)
|
||||
if data.count > maxRequestBytes {
|
||||
throw POSIXError(.EMSGSIZE)
|
||||
}
|
||||
if let req = try? decoder.decode(Request.self, from: data) {
|
||||
return req
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return data.isEmpty ? nil : try decoder.decode(Request.self, from: data)
|
||||
}
|
||||
|
||||
if errno == EINTR { continue }
|
||||
if errno == EAGAIN { continue }
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func writeResponse(fd: Int32, response: Response) throws {
|
||||
let encoded = try JSONEncoder().encode(response)
|
||||
try encoded.withUnsafeBytes { buf in
|
||||
guard let base = buf.baseAddress else { return }
|
||||
var written = 0
|
||||
while written < encoded.count {
|
||||
let n = write(fd, base.advanced(by: written), encoded.count - written)
|
||||
if n > 0 {
|
||||
written += n
|
||||
continue
|
||||
}
|
||||
if n == -1, errno == EINTR { continue }
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func isAllowed(fd: Int32, allowedTeamIDs: Set<String>) -> Bool {
|
||||
var pid: pid_t = 0
|
||||
var pidSize = socklen_t(MemoryLayout<pid_t>.size)
|
||||
let r = getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, &pid, &pidSize)
|
||||
guard r == 0, pid > 0 else { return false }
|
||||
|
||||
// Always require a valid code signature match (TeamID).
|
||||
// This prevents any same-UID process from driving the app's privileged surface.
|
||||
if self.teamIDMatches(pid: pid, allowedTeamIDs: allowedTeamIDs) {
|
||||
return true
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Debug-only escape hatch: allow unsigned/same-UID clients when explicitly opted in.
|
||||
// This keeps local development workable (e.g. a SwiftPM-built `clawdis-mac` binary).
|
||||
let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"]
|
||||
if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() {
|
||||
self.logger.warning(
|
||||
"allowing unsigned same-UID socket client pid=\(pid) (CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1)")
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
|
||||
if let callerUID = self.uid(for: pid) {
|
||||
self.logger.error(
|
||||
"socket client rejected pid=\(pid, privacy: .public) uid=\(callerUID, privacy: .public)")
|
||||
} else {
|
||||
self.logger.error("socket client rejected pid=\(pid, privacy: .public) (uid unknown)")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private nonisolated static 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 nonisolated static func teamIDMatches(pid: pid_t, allowedTeamIDs: Set<String>) -> Bool {
|
||||
let attrs: NSDictionary = [kSecGuestAttributePid: pid]
|
||||
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?
|
||||
// `kSecCodeInfoTeamIdentifier` is only included when requesting signing information.
|
||||
let flags = SecCSFlags(rawValue: UInt32(kSecCSSigningInformation))
|
||||
guard SecCodeCopySigningInformation(sCode, flags, &infoCF) == errSecSuccess,
|
||||
let info = infoCF as? [String: Any],
|
||||
let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
return allowedTeamIDs.contains(teamID)
|
||||
}
|
||||
}
|
||||
|
||||
#if SWIFT_PACKAGE
|
||||
extension ControlSocketServer {
|
||||
nonisolated static func _testTeamIdentifier(pid: pid_t) -> String? {
|
||||
let attrs: NSDictionary = [kSecGuestAttributePid: pid]
|
||||
var secCode: SecCode?
|
||||
guard SecCodeCopyGuestWithAttributes(nil, attrs, SecCSFlags(), &secCode) == errSecSuccess,
|
||||
let code = secCode else { return nil }
|
||||
|
||||
var staticCode: SecStaticCode?
|
||||
guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess,
|
||||
let sCode = staticCode else { return nil }
|
||||
|
||||
var infoCF: CFDictionary?
|
||||
let flags = SecCSFlags(rawValue: UInt32(kSecCSSigningInformation))
|
||||
guard SecCodeCopySigningInformation(sCode, flags, &infoCF) == errSecSuccess,
|
||||
let info = infoCF as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return info[kSecCodeInfoTeamIdentifier as String] as? String
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -296,7 +296,7 @@ struct GeneralSettings: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text("Symlink \"clawdis-mac\" into /usr/local/bin and /opt/homebrew/bin for scripts.")
|
||||
Text("Symlink \"clawdis\" into /usr/local/bin and /opt/homebrew/bin for scripts.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
@@ -29,7 +29,7 @@ enum InstanceIdentity {
|
||||
{
|
||||
return name
|
||||
}
|
||||
return "clawdis-mac"
|
||||
return "clawdis"
|
||||
}()
|
||||
|
||||
static let modelIdentifier: String? = {
|
||||
|
||||
@@ -202,7 +202,6 @@ private final class StatusItemMouseHandlerView: NSView {
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var state: AppState?
|
||||
private let webChatAutoLogger = Logger(subsystem: "com.steipete.clawdis", category: "Chat")
|
||||
private let socketServer = ControlSocketServer()
|
||||
let updaterController: UpdaterProviding = makeUpdaterController()
|
||||
|
||||
func application(_: NSApplication, open urls: [URL]) {
|
||||
@@ -231,7 +230,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
Task { PresenceReporter.shared.start() }
|
||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
||||
Task { await self.socketServer.start() }
|
||||
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) }
|
||||
self.scheduleFirstRunOnboardingIfNeeded()
|
||||
|
||||
@@ -255,7 +253,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
WebChatManager.shared.resetTunnels()
|
||||
Task { await RemoteTunnelManager.shared.stopAll() }
|
||||
Task { await GatewayConnection.shared.shutdown() }
|
||||
Task { await self.socketServer.stop() }
|
||||
Task { await PeekabooBridgeHostCoordinator.shared.stop() }
|
||||
}
|
||||
|
||||
|
||||
@@ -60,9 +60,10 @@ final class MacNodeModeCoordinator {
|
||||
|
||||
retryDelay = 1_000_000_000
|
||||
do {
|
||||
let hello = await self.makeHello()
|
||||
try await self.session.connect(
|
||||
endpoint: endpoint,
|
||||
hello: self.makeHello(),
|
||||
hello: hello,
|
||||
onConnected: { [weak self] serverName in
|
||||
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
|
||||
},
|
||||
@@ -86,10 +87,11 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeHello() -> BridgeHello {
|
||||
private func makeHello() async -> BridgeHello {
|
||||
let token = MacNodeTokenStore.loadToken()
|
||||
let caps = self.currentCaps()
|
||||
let commands = self.currentCommands(caps: caps)
|
||||
let permissions = await self.currentPermissions()
|
||||
return BridgeHello(
|
||||
nodeId: Self.nodeId(),
|
||||
displayName: InstanceIdentity.displayName,
|
||||
@@ -99,7 +101,8 @@ final class MacNodeModeCoordinator {
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||
caps: caps,
|
||||
commands: commands)
|
||||
commands: commands,
|
||||
permissions: permissions)
|
||||
}
|
||||
|
||||
private func currentCaps() -> [String] {
|
||||
@@ -110,6 +113,11 @@ final class MacNodeModeCoordinator {
|
||||
return caps
|
||||
}
|
||||
|
||||
private func currentPermissions() async -> [String: Bool] {
|
||||
let statuses = await PermissionManager.status()
|
||||
return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) })
|
||||
}
|
||||
|
||||
private func currentCommands(caps: [String]) -> [String] {
|
||||
var commands: [String] = [
|
||||
ClawdisCanvasCommand.present.rawValue,
|
||||
@@ -121,6 +129,8 @@ final class MacNodeModeCoordinator {
|
||||
ClawdisCanvasA2UICommand.pushJSONL.rawValue,
|
||||
ClawdisCanvasA2UICommand.reset.rawValue,
|
||||
MacNodeScreenCommand.record.rawValue,
|
||||
ClawdisSystemCommand.run.rawValue,
|
||||
ClawdisSystemCommand.notify.rawValue,
|
||||
]
|
||||
|
||||
let capsSet = Set(caps)
|
||||
@@ -140,9 +150,10 @@ final class MacNodeModeCoordinator {
|
||||
let shouldSilent = await MainActor.run {
|
||||
AppStateStore.shared.connectionMode == .remote
|
||||
}
|
||||
let hello = await self.makeHello()
|
||||
let token = try await MacNodeBridgePairingClient().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
hello: self.makeHello(),
|
||||
hello: hello,
|
||||
silent: shouldSilent,
|
||||
onStatus: { [weak self] status in
|
||||
self?.logger.info("mac node pairing: \(status, privacy: .public)")
|
||||
|
||||
@@ -185,6 +185,12 @@ actor MacNodeRuntime {
|
||||
hasAudio: res.hasAudio))
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
|
||||
case ClawdisSystemCommand.run.rawValue:
|
||||
return try await self.handleSystemRun(req)
|
||||
|
||||
case ClawdisSystemCommand.notify.rawValue:
|
||||
return try await self.handleSystemNotify(req)
|
||||
|
||||
default:
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
||||
}
|
||||
@@ -249,6 +255,89 @@ actor MacNodeRuntime {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
||||
}
|
||||
|
||||
private func handleSystemRun(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeParams(ClawdisSystemRunParams.self, from: req.paramsJSON)
|
||||
let command = params.command
|
||||
guard !command.isEmpty else {
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||
}
|
||||
|
||||
if params.needsScreenRecording == true {
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
if !authorized {
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "PERMISSION_MISSING: screenRecording")
|
||||
}
|
||||
}
|
||||
|
||||
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||
let result = await ShellExecutor.runDetailed(
|
||||
command: command,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
timeout: timeoutSec)
|
||||
|
||||
struct RunPayload: Encodable {
|
||||
var exitCode: Int?
|
||||
var timedOut: Bool
|
||||
var success: Bool
|
||||
var stdout: String
|
||||
var stderr: String
|
||||
var error: String?
|
||||
}
|
||||
|
||||
let payload = try Self.encodePayload(RunPayload(
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
error: result.errorMessage))
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeParams(ClawdisSystemNotifyParams.self, from: req.paramsJSON)
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if title.isEmpty && body.isEmpty {
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: empty notification")
|
||||
}
|
||||
|
||||
let priority = params.priority.flatMap { NotificationPriority(rawValue: $0.rawValue) }
|
||||
let delivery = params.delivery.flatMap { NotificationDelivery(rawValue: $0.rawValue) } ?? .system
|
||||
let manager = NotificationManager()
|
||||
|
||||
switch delivery {
|
||||
case .system:
|
||||
let ok = await manager.send(
|
||||
title: title,
|
||||
body: body,
|
||||
sound: params.sound,
|
||||
priority: priority)
|
||||
return ok
|
||||
? BridgeInvokeResponse(id: req.id, ok: true)
|
||||
: Self.errorResponse(req, code: .unavailable, message: "NOT_AUTHORIZED: notifications")
|
||||
case .overlay:
|
||||
await NotifyOverlayController.shared.present(title: title, body: body)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
case .auto:
|
||||
let ok = await manager.send(
|
||||
title: title,
|
||||
body: body,
|
||||
sound: params.sound,
|
||||
priority: priority)
|
||||
if ok {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
}
|
||||
await NotifyOverlayController.shared.present(title: title, body: body)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
}
|
||||
}
|
||||
|
||||
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||
guard let json, let data = json.data(using: .utf8) else {
|
||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||
|
||||
@@ -107,7 +107,8 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" }
|
||||
private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac"
|
||||
private let devLinkCommand =
|
||||
"ln -sf /Applications/Clawdis.app/Contents/Resources/Relay/clawdis /usr/local/bin/clawdis"
|
||||
|
||||
init(
|
||||
state: AppState = AppStateStore.shared,
|
||||
@@ -897,7 +898,7 @@ struct OnboardingView: View {
|
||||
self.onboardingPage {
|
||||
Text("Install the helper CLI")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Optional, but recommended: link `clawdis-mac` so scripts can talk to this app.")
|
||||
Text("Optional, but recommended: link `clawdis` so scripts can reach the local gateway.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -912,7 +913,7 @@ struct OnboardingView: View {
|
||||
if self.installingCLI {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(self.cliInstalled ? "Reinstall helper" : "Install helper")
|
||||
Text(self.cliInstalled ? "Reinstall CLI" : "Install CLI")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
@@ -2,8 +2,30 @@ import ClawdisIPC
|
||||
import Foundation
|
||||
|
||||
enum ShellExecutor {
|
||||
static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response {
|
||||
guard !command.isEmpty else { return Response(ok: false, message: "empty command") }
|
||||
struct ShellResult {
|
||||
var stdout: String
|
||||
var stderr: String
|
||||
var exitCode: Int?
|
||||
var timedOut: Bool
|
||||
var success: Bool
|
||||
var errorMessage: String?
|
||||
}
|
||||
|
||||
static func runDetailed(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?,
|
||||
timeout: Double?) async -> ShellResult
|
||||
{
|
||||
guard !command.isEmpty else {
|
||||
return ShellResult(
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: nil,
|
||||
timedOut: false,
|
||||
success: false,
|
||||
errorMessage: "empty command")
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
@@ -19,36 +41,59 @@ enum ShellExecutor {
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return Response(ok: false, message: "failed to start: \(error.localizedDescription)")
|
||||
return ShellResult(
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: nil,
|
||||
timedOut: false,
|
||||
success: false,
|
||||
errorMessage: "failed to start: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let waitTask = Task { () -> Response in
|
||||
let waitTask = Task { () -> ShellResult in
|
||||
process.waitUntilExit()
|
||||
let out = stdoutPipe.fileHandleForReading.readToEndSafely()
|
||||
let err = stderrPipe.fileHandleForReading.readToEndSafely()
|
||||
let status = process.terminationStatus
|
||||
let combined = out.isEmpty ? err : out
|
||||
return Response(ok: status == 0, message: status == 0 ? nil : "exit \(status)", payload: combined)
|
||||
let status = Int(process.terminationStatus)
|
||||
return ShellResult(
|
||||
stdout: String(decoding: out, as: UTF8.self),
|
||||
stderr: String(decoding: err, as: UTF8.self),
|
||||
exitCode: status,
|
||||
timedOut: false,
|
||||
success: status == 0,
|
||||
errorMessage: status == 0 ? nil : "exit \(status)")
|
||||
}
|
||||
|
||||
if let timeout, timeout > 0 {
|
||||
let nanos = UInt64(timeout * 1_000_000_000)
|
||||
let response = await withTaskGroup(of: Response.self) { group in
|
||||
let result = await withTaskGroup(of: ShellResult.self) { group in
|
||||
group.addTask { await waitTask.value }
|
||||
group.addTask {
|
||||
try? await Task.sleep(nanoseconds: nanos)
|
||||
if process.isRunning { process.terminate() }
|
||||
_ = await waitTask.value // drain pipes after termination
|
||||
return Response(ok: false, message: "timeout")
|
||||
return ShellResult(
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: nil,
|
||||
timedOut: true,
|
||||
success: false,
|
||||
errorMessage: "timeout")
|
||||
}
|
||||
// Whichever completes first (process exit or timeout) wins; cancel the other branch.
|
||||
let first = await group.next()!
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
return response
|
||||
return result
|
||||
}
|
||||
|
||||
return await waitTask.value
|
||||
}
|
||||
|
||||
static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response {
|
||||
let result = await self.runDetailed(command: command, cwd: cwd, env: env, timeout: timeout)
|
||||
let combined = result.stdout.isEmpty ? result.stderr : result.stdout
|
||||
let payload = combined.isEmpty ? nil : Data(combined.utf8)
|
||||
return Response(ok: result.success, message: result.errorMessage, payload: payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ enum CLIInstaller {
|
||||
let fm = FileManager.default
|
||||
|
||||
for basePath in cliHelperSearchPaths {
|
||||
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis-mac").path
|
||||
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdis").path
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
guard fm.fileExists(atPath: candidate, isDirectory: &isDirectory), !isDirectory.boolValue else {
|
||||
@@ -157,13 +157,13 @@ enum CLIInstaller {
|
||||
}
|
||||
|
||||
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
||||
let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/ClawdisCLI")
|
||||
let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdis")
|
||||
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
|
||||
await statusHandler("Helper missing in bundle; rebuild via scripts/package-mac-app.sh")
|
||||
return
|
||||
}
|
||||
|
||||
let targets = cliHelperSearchPaths.map { "\($0)/clawdis-mac" }
|
||||
let targets = cliHelperSearchPaths.map { "\($0)/clawdis" }
|
||||
let result = await self.privilegedSymlink(source: helper.path, targets: targets)
|
||||
await statusHandler(result)
|
||||
}
|
||||
@@ -432,25 +432,6 @@ enum CommandResolver {
|
||||
}
|
||||
}
|
||||
|
||||
static func clawdisMacCommand(
|
||||
subcommand: String,
|
||||
extraArgs: [String] = [],
|
||||
defaults: UserDefaults = .standard) -> [String]
|
||||
{
|
||||
let settings = self.connectionSettings(defaults: defaults)
|
||||
if settings.mode == .remote, let ssh = self.sshMacHelperCommand(
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs,
|
||||
settings: settings)
|
||||
{
|
||||
return ssh
|
||||
}
|
||||
if let helper = self.findExecutable(named: "clawdis-mac") {
|
||||
return [helper, subcommand] + extraArgs
|
||||
}
|
||||
return ["/usr/local/bin/clawdis-mac", subcommand] + extraArgs
|
||||
}
|
||||
|
||||
// Existing callers still refer to clawdisCommand; keep it as node alias.
|
||||
static func clawdisCommand(
|
||||
subcommand: String,
|
||||
@@ -474,7 +455,7 @@ enum CommandResolver {
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
|
||||
// Run the real clawdis CLI on the remote host; do not fall back to clawdis-mac.
|
||||
// Run the real clawdis CLI on the remote host.
|
||||
let exportedPath = [
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
@@ -535,38 +516,6 @@ enum CommandResolver {
|
||||
return ["/usr/bin/ssh"] + args
|
||||
}
|
||||
|
||||
private static func sshMacHelperCommand(
|
||||
subcommand: String,
|
||||
extraArgs: [String],
|
||||
settings: RemoteSettings) -> [String]?
|
||||
{
|
||||
guard !settings.target.isEmpty else { return nil }
|
||||
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
|
||||
|
||||
var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes"]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
args.append(contentsOf: ["-i", settings.identity])
|
||||
}
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
|
||||
let exportedPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
|
||||
let userPRJ = settings.projectRoot
|
||||
let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
|
||||
let scriptBody = """
|
||||
PATH=\(exportedPath);
|
||||
PRJ=\(userPRJ.isEmpty ? "" : self.shellQuote(userPRJ))
|
||||
DEFAULT_PRJ="$HOME/Projects/clawdis"
|
||||
if [ -z "${PRJ:-}" ] && [ -d "$DEFAULT_PRJ" ]; then PRJ="$DEFAULT_PRJ"; fi
|
||||
if [ -n "${PRJ:-}" ]; then cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }; fi
|
||||
if ! command -v clawdis-mac >/dev/null 2>&1; then echo "clawdis-mac missing on remote host"; exit 127; fi;
|
||||
clawdis-mac \(quotedArgs)
|
||||
"""
|
||||
args.append(contentsOf: ["/bin/sh", "-c", scriptBody])
|
||||
return ["/usr/bin/ssh"] + args
|
||||
}
|
||||
|
||||
struct RemoteSettings {
|
||||
let mode: AppState.ConnectionMode
|
||||
let target: String
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
extension FileHandle {
|
||||
/// Reads until EOF using the throwing FileHandle API and returns empty `Data` on failure.
|
||||
///
|
||||
/// Important: Avoid legacy, non-throwing FileHandle read APIs (e.g. `readDataToEndOfFile()` and
|
||||
/// `availableData`). They can raise Objective-C exceptions when the handle is closed/invalid, which
|
||||
/// will abort the process.
|
||||
func readToEndSafely() -> Data {
|
||||
do {
|
||||
return try self.readToEnd() ?? Data()
|
||||
} catch {
|
||||
return Data()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads up to `count` bytes using the throwing FileHandle API and returns empty `Data` on failure/EOF.
|
||||
func readSafely(upToCount count: Int) -> Data {
|
||||
do {
|
||||
return try self.read(upToCount: count) ?? Data()
|
||||
} catch {
|
||||
return Data()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user