refactor: apply stashed bridge + CLI changes
This commit is contained in:
@@ -9,11 +9,7 @@ actor BridgeClient {
|
|||||||
|
|
||||||
func pairAndHello(
|
func pairAndHello(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
nodeId: String,
|
hello: BridgeHello,
|
||||||
displayName: String?,
|
|
||||||
platform: String,
|
|
||||||
version: String,
|
|
||||||
existingToken: String?,
|
|
||||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||||
{
|
{
|
||||||
self.lineBuffer = Data()
|
self.lineBuffer = Data()
|
||||||
@@ -25,14 +21,7 @@ actor BridgeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onStatus?("Authenticating…")
|
onStatus?("Authenticating…")
|
||||||
try await self.send(
|
try await self.send(hello, over: connection)
|
||||||
BridgeHello(
|
|
||||||
nodeId: nodeId,
|
|
||||||
displayName: displayName,
|
|
||||||
token: existingToken,
|
|
||||||
platform: platform,
|
|
||||||
version: version),
|
|
||||||
over: connection)
|
|
||||||
|
|
||||||
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
|
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
|
||||||
guard let frame = try await self.receiveFrame(over: connection) else {
|
guard let frame = try await self.receiveFrame(over: connection) else {
|
||||||
@@ -46,7 +35,7 @@ actor BridgeClient {
|
|||||||
switch first.base.type {
|
switch first.base.type {
|
||||||
case "hello-ok":
|
case "hello-ok":
|
||||||
// We only return a token if we have one; callers should treat empty as "no token yet".
|
// We only return a token if we have one; callers should treat empty as "no token yet".
|
||||||
return existingToken ?? ""
|
return hello.token ?? ""
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
|
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
|
||||||
@@ -59,10 +48,11 @@ actor BridgeClient {
|
|||||||
onStatus?("Requesting approval…")
|
onStatus?("Requesting approval…")
|
||||||
try await self.send(
|
try await self.send(
|
||||||
BridgePairRequest(
|
BridgePairRequest(
|
||||||
nodeId: nodeId,
|
nodeId: hello.nodeId,
|
||||||
displayName: displayName,
|
displayName: hello.displayName,
|
||||||
platform: platform,
|
platform: hello.platform,
|
||||||
version: version),
|
version: hello.version
|
||||||
|
),
|
||||||
over: connection)
|
over: connection)
|
||||||
|
|
||||||
onStatus?("Waiting for approval…")
|
onStatus?("Waiting for approval…")
|
||||||
@@ -155,7 +145,9 @@ actor BridgeClient {
|
|||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
if self.purpose == "pairing approval" {
|
if self.purpose == "pairing approval" {
|
||||||
return "Timed out waiting for approval (\(self.seconds)s). Approve the node on your gateway and try again."
|
return
|
||||||
|
"Timed out waiting for approval (\(self.seconds)s). " +
|
||||||
|
"Approve the node on your gateway and try again."
|
||||||
}
|
}
|
||||||
return "Timed out during \(self.purpose) (\(self.seconds)s)."
|
return "Timed out during \(self.purpose) (\(self.seconds)s)."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,45 +42,36 @@ final class NodeAppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setVoiceWakeEnabled(_ enabled: Bool) {
|
func setVoiceWakeEnabled(_ enabled: Bool) {
|
||||||
self.voiceWake.setEnabled(enabled)
|
self.voiceWake.setEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func connectToBridge(
|
func connectToBridge(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
token: String,
|
hello: BridgeHello)
|
||||||
nodeId: String,
|
{
|
||||||
displayName: String?,
|
self.bridgeTask?.cancel()
|
||||||
platform: String,
|
self.bridgeStatusText = "Connecting…"
|
||||||
version: String)
|
self.bridgeServerName = nil
|
||||||
{
|
self.bridgeRemoteAddress = nil
|
||||||
self.bridgeTask?.cancel()
|
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
||||||
self.bridgeStatusText = "Connecting…"
|
|
||||||
self.bridgeServerName = nil
|
|
||||||
self.bridgeRemoteAddress = nil
|
|
||||||
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
|
|
||||||
self.bridgeTask = Task {
|
self.bridgeTask = Task {
|
||||||
do {
|
do {
|
||||||
try await self.bridge.connect(
|
try await self.bridge.connect(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
hello: BridgeHello(
|
hello: hello,
|
||||||
nodeId: nodeId,
|
onConnected: { [weak self] serverName in
|
||||||
displayName: displayName,
|
guard let self else { return }
|
||||||
token: token,
|
await MainActor.run {
|
||||||
platform: platform,
|
self.bridgeStatusText = "Connected"
|
||||||
version: version),
|
|
||||||
onConnected: { [weak self] serverName in
|
|
||||||
guard let self else { return }
|
|
||||||
await MainActor.run {
|
|
||||||
self.bridgeStatusText = "Connected"
|
|
||||||
self.bridgeServerName = serverName
|
self.bridgeServerName = serverName
|
||||||
}
|
}
|
||||||
if let addr = await self.bridge.currentRemoteAddress() {
|
if let addr = await self.bridge.currentRemoteAddress() {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.bridgeRemoteAddress = addr
|
self.bridgeRemoteAddress = addr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onInvoke: { [weak self] req in
|
onInvoke: { [weak self] req in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
@@ -119,16 +110,20 @@ final class NodeAppModel: ObservableObject {
|
|||||||
self.connectedBridgeID = nil
|
self.connectedBridgeID = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
||||||
struct Payload: Codable {
|
struct Payload: Codable {
|
||||||
var text: String
|
var text: String
|
||||||
var sessionKey: String?
|
var sessionKey: String?
|
||||||
}
|
}
|
||||||
let payload = Payload(text: text, sessionKey: sessionKey)
|
let payload = Payload(text: text, sessionKey: sessionKey)
|
||||||
let data = try JSONEncoder().encode(payload)
|
let data = try JSONEncoder().encode(payload)
|
||||||
let json = String(decoding: data, as: UTF8.self)
|
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||||
try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json)
|
throw NSError(domain: "NodeAppModel", code: 1, userInfo: [
|
||||||
}
|
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json)
|
||||||
|
}
|
||||||
|
|
||||||
func handleDeepLink(url: URL) async {
|
func handleDeepLink(url: URL) async {
|
||||||
guard let route = DeepLinkParser.parse(url) else { return }
|
guard let route = DeepLinkParser.parse(url) else { return }
|
||||||
@@ -168,12 +163,16 @@ final class NodeAppModel: ObservableObject {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS bridge forwards to the gateway; no local auth prompts here.
|
// iOS bridge forwards to the gateway; no local auth prompts here.
|
||||||
// (Key-based unattended auth is handled on macOS for clawdis:// links.)
|
// (Key-based unattended auth is handled on macOS for clawdis:// links.)
|
||||||
let data = try JSONEncoder().encode(link)
|
let data = try JSONEncoder().encode(link)
|
||||||
let json = String(decoding: data, as: UTF8.self)
|
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||||
try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json)
|
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
|
||||||
}
|
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json)
|
||||||
|
}
|
||||||
|
|
||||||
private func isBridgeConnected() async -> Bool {
|
private func isBridgeConnected() async -> Bool {
|
||||||
if case .connected = await self.bridge.state { return true }
|
if case .connected = await self.bridge.state { return true }
|
||||||
@@ -244,8 +243,13 @@ final class NodeAppModel: ObservableObject {
|
|||||||
return try JSONDecoder().decode(type, from: data)
|
return try JSONDecoder().decode(type, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func encodePayload(_ obj: some Encodable) throws -> String {
|
private static func encodePayload(_ obj: some Encodable) throws -> String {
|
||||||
let data = try JSONEncoder().encode(obj)
|
let data = try JSONEncoder().encode(obj)
|
||||||
return String(decoding: data, as: UTF8.self)
|
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||||
}
|
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,17 +105,19 @@ final class ScreenController: ObservableObject {
|
|||||||
#000;
|
#000;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
body::before {
|
body::before {
|
||||||
content:"";
|
content:"";
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: -20%;
|
inset: -20%;
|
||||||
background:
|
background:
|
||||||
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 48px),
|
repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
|
||||||
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px, transparent 1px, transparent 48px);
|
transparent 1px, transparent 48px),
|
||||||
transform: rotate(-7deg);
|
repeating-linear-gradient(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
|
||||||
opacity: 0.55;
|
transparent 1px, transparent 48px);
|
||||||
pointer-events: none;
|
transform: rotate(-7deg);
|
||||||
}
|
opacity: 0.55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
canvas {
|
canvas {
|
||||||
display:block;
|
display:block;
|
||||||
width:100vw;
|
width:100vw;
|
||||||
|
|||||||
@@ -77,17 +77,20 @@ struct SettingsTab: View {
|
|||||||
guard let existing, !existing.isEmpty else { return }
|
guard let existing, !existing.isEmpty else { return }
|
||||||
guard let target = self.pickAutoConnectBridge(from: newValue) else { return }
|
guard let target = self.pickAutoConnectBridge(from: newValue) else { return }
|
||||||
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
self.preferredBridgeStableID = target.stableID
|
self.preferredBridgeStableID = target.stableID
|
||||||
self.appModel.connectToBridge(
|
self.appModel.connectToBridge(
|
||||||
endpoint: target.endpoint,
|
endpoint: target.endpoint,
|
||||||
token: existing,
|
hello: BridgeHello(
|
||||||
nodeId: self.instanceId,
|
nodeId: self.instanceId,
|
||||||
displayName: self.displayName,
|
displayName: self.displayName,
|
||||||
platform: self.platformString(),
|
token: existing,
|
||||||
version: self.appVersion())
|
platform: self.platformString(),
|
||||||
self.connectStatus = nil
|
version: self.appVersion()
|
||||||
}
|
)
|
||||||
|
)
|
||||||
|
self.connectStatus = nil
|
||||||
|
}
|
||||||
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
.onChange(of: self.appModel.bridgeServerName) { _, _ in
|
||||||
self.connectStatus = nil
|
self.connectStatus = nil
|
||||||
}
|
}
|
||||||
@@ -170,18 +173,22 @@ struct SettingsTab: View {
|
|||||||
existing :
|
existing :
|
||||||
nil
|
nil
|
||||||
|
|
||||||
let token = try await BridgeClient().pairAndHello(
|
let hello = BridgeHello(
|
||||||
endpoint: bridge.endpoint,
|
nodeId: self.instanceId,
|
||||||
nodeId: self.instanceId,
|
displayName: self.displayName,
|
||||||
displayName: self.displayName,
|
token: existingToken,
|
||||||
platform: self.platformString(),
|
platform: self.platformString(),
|
||||||
version: self.appVersion(),
|
version: self.appVersion()
|
||||||
existingToken: existingToken,
|
)
|
||||||
onStatus: { status in
|
let token = try await BridgeClient().pairAndHello(
|
||||||
Task { @MainActor in
|
endpoint: bridge.endpoint,
|
||||||
self.connectStatus = status
|
hello: hello,
|
||||||
}
|
onStatus: { status in
|
||||||
})
|
Task { @MainActor in
|
||||||
|
self.connectStatus = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if !token.isEmpty, token != existingToken {
|
if !token.isEmpty, token != existingToken {
|
||||||
_ = KeychainStore.saveString(
|
_ = KeychainStore.saveString(
|
||||||
@@ -190,13 +197,16 @@ struct SettingsTab: View {
|
|||||||
account: self.keychainAccount())
|
account: self.keychainAccount())
|
||||||
}
|
}
|
||||||
|
|
||||||
self.appModel.connectToBridge(
|
self.appModel.connectToBridge(
|
||||||
endpoint: bridge.endpoint,
|
endpoint: bridge.endpoint,
|
||||||
token: token,
|
hello: BridgeHello(
|
||||||
nodeId: self.instanceId,
|
nodeId: self.instanceId,
|
||||||
displayName: self.displayName,
|
displayName: self.displayName,
|
||||||
platform: self.platformString(),
|
token: token,
|
||||||
version: self.appVersion())
|
platform: self.platformString(),
|
||||||
|
version: self.appVersion()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
self.connectStatus = "Failed: \(error.localizedDescription)"
|
self.connectStatus = "Failed: \(error.localizedDescription)"
|
||||||
|
|||||||
@@ -87,19 +87,22 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
return self.html("Forbidden", title: "Canvas: 403")
|
return self.html("Forbidden", title: "Canvas: 403")
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: standardizedFile)
|
let data = try Data(contentsOf: standardizedFile)
|
||||||
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
|
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
|
||||||
canvasLogger.debug(
|
let servedPath = standardizedFile.path
|
||||||
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(standardizedFile.path, privacy: .public)")
|
canvasLogger.debug(
|
||||||
return CanvasResponse(mime: mime, data: data)
|
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)")
|
||||||
} catch {
|
return CanvasResponse(mime: mime, data: data)
|
||||||
canvasLogger
|
} catch {
|
||||||
.error(
|
let failedPath = standardizedFile.path
|
||||||
"failed reading \(standardizedFile.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
let errorText = error.localizedDescription
|
||||||
return self.html("Failed to read file.", title: "Canvas error")
|
canvasLogger
|
||||||
}
|
.error(
|
||||||
}
|
"failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)")
|
||||||
|
return self.html("Failed to read file.", title: "Canvas error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
|
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
|
|||||||
@@ -204,13 +204,15 @@ struct ConfigSettings: View {
|
|||||||
.disabled(!self.browserEnabled)
|
.disabled(!self.browserEnabled)
|
||||||
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
|
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
|
||||||
.help(
|
.help(
|
||||||
"When enabled, the browser server will only connect if the clawd browser is already running.")
|
"When enabled, the browser server will only connect if the clawd browser is already running."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
GridRow {
|
GridRow {
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: self.labelColumnWidth, height: 1)
|
.frame(width: self.labelColumnWidth, height: 1)
|
||||||
Text(
|
Text(
|
||||||
"Clawd uses a separate Chrome profile and ports (default 18791/18792) so it won’t interfere with your daily browser.")
|
"Clawd uses a separate Chrome profile and ports (default 18791/18792) so it won’t interfere with your daily browser."
|
||||||
|
)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -144,12 +144,15 @@ final class ControlChannel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
|
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
|
||||||
if let urlErr = error as? URLError,
|
if let urlErr = error as? URLError,
|
||||||
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
||||||
{
|
{
|
||||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||||
return "Gateway rejected token; set CLAWDIS_GATEWAY_TOKEN in the mac app environment or clear it on the gateway. Reason: \(reason)"
|
return
|
||||||
}
|
"Gateway rejected token; set CLAWDIS_GATEWAY_TOKEN in the mac app environment " +
|
||||||
|
"or clear it on the gateway. " +
|
||||||
|
"Reason: \(reason)"
|
||||||
|
}
|
||||||
|
|
||||||
// Common misfire: we connected to localhost:18789 but the port is occupied
|
// Common misfire: we connected to localhost:18789 but the port is occupied
|
||||||
// by some other process (e.g. a local dev gateway or a stuck SSH forward).
|
// by some other process (e.g. a local dev gateway or a stuck SSH forward).
|
||||||
|
|||||||
@@ -10,156 +10,254 @@ enum ControlRequestHandler {
|
|||||||
{
|
{
|
||||||
// Keep `status` responsive even if the main actor is busy.
|
// Keep `status` responsive even if the main actor is busy.
|
||||||
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||||
if paused {
|
if paused, request != .status {
|
||||||
switch request {
|
return Response(ok: false, message: "clawdis paused")
|
||||||
case .status:
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
return Response(ok: false, message: "clawdis paused")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch request {
|
switch request {
|
||||||
case let .notify(title, body, sound, priority, delivery):
|
case let .notify(title, body, sound, priority, delivery):
|
||||||
let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let notify = NotifyRequest(
|
||||||
let chosenDelivery = delivery ?? .system
|
title: title,
|
||||||
|
body: body,
|
||||||
|
sound: sound,
|
||||||
|
priority: priority,
|
||||||
|
delivery: delivery
|
||||||
|
)
|
||||||
|
return await self.handleNotify(notify, notifier: notifier)
|
||||||
|
|
||||||
switch chosenDelivery {
|
case let .ensurePermissions(caps, interactive):
|
||||||
case .system:
|
return await self.handleEnsurePermissions(caps: caps, interactive: interactive)
|
||||||
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
|
|
||||||
return ok ? Response(ok: true) : Response(ok: false, message: "notification not authorized")
|
|
||||||
|
|
||||||
case .overlay:
|
case .status:
|
||||||
await MainActor.run {
|
return paused
|
||||||
NotifyOverlayController.shared.present(title: title, body: body)
|
? Response(ok: false, message: "clawdis paused")
|
||||||
}
|
: Response(ok: true, message: "ready")
|
||||||
return Response(ok: true)
|
|
||||||
|
|
||||||
case .auto:
|
case .rpcStatus:
|
||||||
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
|
return await self.handleRPCStatus()
|
||||||
if ok { return Response(ok: true) }
|
|
||||||
await MainActor.run {
|
case let .runShell(command, cwd, env, timeoutSec, needsSR):
|
||||||
NotifyOverlayController.shared.present(title: title, body: body)
|
return await self.handleRunShell(
|
||||||
}
|
command: command,
|
||||||
return Response(ok: true, message: "notification not authorized; used overlay")
|
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 .canvasShow(session, path, placement):
|
||||||
|
return await self.handleCanvasShow(session: session, path: path, placement: placement)
|
||||||
|
|
||||||
|
case let .canvasHide(session):
|
||||||
|
return await self.handleCanvasHide(session: session)
|
||||||
|
|
||||||
|
case let .canvasGoto(session, path, placement):
|
||||||
|
return await self.handleCanvasGoto(session: session, path: path, placement: placement)
|
||||||
|
|
||||||
|
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 .nodeList:
|
||||||
|
return await self.handleNodeList()
|
||||||
|
|
||||||
|
case let .nodeInvoke(nodeId, command, paramsJSON):
|
||||||
|
return await self.handleNodeInvoke(
|
||||||
|
nodeId: nodeId,
|
||||||
|
command: command,
|
||||||
|
paramsJSON: paramsJSON,
|
||||||
|
logger: logger
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 MainActor.run {
|
||||||
|
NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 paused ? Response(ok: false, message: "clawdis paused") : Response(ok: true, message: "ready")
|
|
||||||
|
|
||||||
case .rpcStatus:
|
|
||||||
let result = await AgentRPC.shared.status()
|
|
||||||
return Response(ok: result.ok, message: result.error)
|
|
||||||
|
|
||||||
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,
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
deliver: deliver,
|
|
||||||
to: to,
|
|
||||||
channel: nil)
|
|
||||||
return rpcResult.ok
|
|
||||||
? Response(ok: true, message: rpcResult.text ?? "sent")
|
|
||||||
: Response(ok: false, message: rpcResult.error ?? "failed to send")
|
|
||||||
|
|
||||||
case let .canvasShow(session, path, placement):
|
|
||||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
|
||||||
guard canvasEnabled else {
|
|
||||||
return Response(ok: false, message: "Canvas disabled by user")
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let dir = try await MainActor.run { try CanvasManager.shared.show(
|
|
||||||
sessionKey: session,
|
|
||||||
path: path,
|
|
||||||
placement: placement) }
|
|
||||||
return Response(ok: true, message: dir)
|
|
||||||
} catch {
|
|
||||||
return Response(ok: false, message: error.localizedDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .canvasHide(session):
|
|
||||||
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
|
|
||||||
return Response(ok: true)
|
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 MainActor.run {
|
||||||
|
NotifyOverlayController.shared.present(title: request.title, body: request.body)
|
||||||
|
}
|
||||||
|
return Response(ok: true, message: "notification not authorized; used overlay")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case let .canvasGoto(session, path, placement):
|
private static func handleEnsurePermissions(caps: [Capability], interactive: Bool) async -> Response {
|
||||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
let statuses = await PermissionManager.ensure(caps, interactive: interactive)
|
||||||
guard canvasEnabled else {
|
let missing = statuses.filter { !$0.value }.map(\.key.rawValue)
|
||||||
return Response(ok: false, message: "Canvas disabled by user")
|
let ok = missing.isEmpty
|
||||||
}
|
let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))"
|
||||||
do {
|
return Response(ok: ok, message: msg)
|
||||||
try await MainActor.run { try CanvasManager.shared.goto(
|
}
|
||||||
sessionKey: session,
|
|
||||||
path: path,
|
|
||||||
placement: placement) }
|
|
||||||
return Response(ok: true)
|
|
||||||
} catch {
|
|
||||||
return Response(ok: false, message: error.localizedDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .canvasEval(session, javaScript):
|
private static func handleRPCStatus() async -> Response {
|
||||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
let result = await AgentRPC.shared.status()
|
||||||
guard canvasEnabled else {
|
return Response(ok: result.ok, message: result.error)
|
||||||
return Response(ok: false, message: "Canvas disabled by user")
|
}
|
||||||
}
|
|
||||||
do {
|
|
||||||
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
|
|
||||||
return Response(ok: true, payload: Data(result.utf8))
|
|
||||||
} catch {
|
|
||||||
return Response(ok: false, message: error.localizedDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .canvasSnapshot(session, outPath):
|
private static func handleRunShell(
|
||||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
command: [String],
|
||||||
guard canvasEnabled else {
|
cwd: String?,
|
||||||
return Response(ok: false, message: "Canvas disabled by user")
|
env: [String: String]?,
|
||||||
}
|
timeoutSec: Double?,
|
||||||
do {
|
needsSR: Bool
|
||||||
let path = try await CanvasManager.shared.snapshot(sessionKey: session, outPath: outPath)
|
) async -> Response {
|
||||||
return Response(ok: true, message: path)
|
if needsSR {
|
||||||
} catch {
|
let authorized = await PermissionManager
|
||||||
return Response(ok: false, message: error.localizedDescription)
|
.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 .nodeList:
|
private static func handleAgent(
|
||||||
let ids = await BridgeServer.shared.connectedNodeIds()
|
message: String,
|
||||||
let payload = (try? JSONSerialization.data(
|
thinking: String?,
|
||||||
withJSONObject: ["connectedNodeIds": ids],
|
session: String?,
|
||||||
options: [.prettyPrinted]))
|
deliver: Bool,
|
||||||
.flatMap { String(data: $0, encoding: .utf8) }
|
to: String?
|
||||||
?? "{}"
|
) async -> Response {
|
||||||
return Response(ok: true, payload: Data(payload.utf8))
|
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,
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
deliver: deliver,
|
||||||
|
to: to,
|
||||||
|
channel: nil
|
||||||
|
)
|
||||||
|
return rpcResult.ok
|
||||||
|
? Response(ok: true, message: rpcResult.text ?? "sent")
|
||||||
|
: Response(ok: false, message: rpcResult.error ?? "failed to send")
|
||||||
|
}
|
||||||
|
|
||||||
case let .nodeInvoke(nodeId, command, paramsJSON):
|
private static func canvasEnabled() -> Bool {
|
||||||
do {
|
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||||
let res = try await BridgeServer.shared.invoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON)
|
}
|
||||||
if res.ok {
|
|
||||||
let payload = res.payloadJSON ?? ""
|
private static func handleCanvasShow(
|
||||||
return Response(ok: true, payload: Data(payload.utf8))
|
session: String,
|
||||||
}
|
path: String?,
|
||||||
let errText = res.error?.message ?? "node invoke failed"
|
placement: CanvasPlacement?
|
||||||
return Response(ok: false, message: errText)
|
) async -> Response {
|
||||||
} catch {
|
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||||
return Response(ok: false, message: error.localizedDescription)
|
do {
|
||||||
|
let dir = try await MainActor.run {
|
||||||
|
try CanvasManager.shared.show(sessionKey: session, path: path, placement: placement)
|
||||||
}
|
}
|
||||||
|
return Response(ok: true, message: dir)
|
||||||
|
} catch {
|
||||||
|
return Response(ok: false, message: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleCanvasHide(session: String) async -> Response {
|
||||||
|
await MainActor.run { CanvasManager.shared.hide(sessionKey: session) }
|
||||||
|
return Response(ok: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleCanvasGoto(session: String, path: String, placement: CanvasPlacement?) async -> Response {
|
||||||
|
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||||
|
do {
|
||||||
|
try await MainActor.run {
|
||||||
|
try CanvasManager.shared.goto(sessionKey: session, path: path, placement: placement)
|
||||||
|
}
|
||||||
|
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") }
|
||||||
|
do {
|
||||||
|
let result = try await CanvasManager.shared.eval(sessionKey: session, javaScript: javaScript)
|
||||||
|
return Response(ok: true, payload: Data(result.utf8))
|
||||||
|
} 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") }
|
||||||
|
do {
|
||||||
|
let path = try await CanvasManager.shared.snapshot(sessionKey: session, outPath: outPath)
|
||||||
|
return Response(ok: true, message: path)
|
||||||
|
} catch {
|
||||||
|
return Response(ok: false, message: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleNodeList() async -> Response {
|
||||||
|
let ids = await BridgeServer.shared.connectedNodeIds()
|
||||||
|
let payload = (try? JSONSerialization.data(
|
||||||
|
withJSONObject: ["connectedNodeIds": ids],
|
||||||
|
options: [.prettyPrinted]
|
||||||
|
))
|
||||||
|
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
|
||||||
|
return Response(ok: true, payload: Data(payload.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleNodeInvoke(
|
||||||
|
nodeId: String,
|
||||||
|
command: String,
|
||||||
|
paramsJSON: String?,
|
||||||
|
logger: Logger
|
||||||
|
) async -> Response {
|
||||||
|
do {
|
||||||
|
let res = try await BridgeServer.shared.invoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON)
|
||||||
|
if res.ok {
|
||||||
|
let payload = res.payloadJSON ?? ""
|
||||||
|
return Response(ok: true, payload: Data(payload.utf8))
|
||||||
|
}
|
||||||
|
let errText = res.error?.message ?? "node invoke failed"
|
||||||
|
return Response(ok: false, message: errText)
|
||||||
|
} catch {
|
||||||
|
logger.error("node invoke failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return Response(ok: false, message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,12 +234,12 @@ final actor ControlSocketServer {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Debug-only escape hatch: allow unsigned/same-UID clients when explicitly opted in.
|
// 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).
|
// This keeps local development workable (e.g. a SwiftPM-built `clawdis-mac` binary).
|
||||||
let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"]
|
let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"]
|
||||||
if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() {
|
if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() {
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"allowing unsigned same-UID socket client pid=\(pid, privacy: .public) due to CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1")
|
"allowing unsigned same-UID socket client pid=\(pid) (CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1)")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if let callerUID = self.uid(for: pid) {
|
if let callerUID = self.uid(for: pid) {
|
||||||
|
|||||||
@@ -69,11 +69,13 @@ struct CronSettings: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` and the Gateway restarts.")
|
"Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " +
|
||||||
.font(.footnote)
|
"and the Gateway restarts."
|
||||||
.foregroundStyle(.secondary)
|
)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
|
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
|
||||||
Text(storePath)
|
Text(storePath)
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
@@ -526,7 +528,8 @@ private struct CronJobEditor: View {
|
|||||||
Text(self.job == nil ? "New cron job" : "Edit cron job")
|
Text(self.job == nil ? "New cron job" : "Edit cron job")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
Text(
|
Text(
|
||||||
"Create a schedule that wakes clawd via the Gateway. Use an isolated session for agent turns so your main chat stays clean.")
|
"Create a schedule that wakes clawd via the Gateway. Use an isolated session for agent turns so your main chat stays clean."
|
||||||
|
)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -572,7 +575,8 @@ private struct CronJobEditor: View {
|
|||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: self.labelColumnWidth, height: 1)
|
.frame(width: self.labelColumnWidth, height: 1)
|
||||||
Text(
|
Text(
|
||||||
"Main jobs post a system event into the current main session. Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/etc).")
|
"Main jobs post a system event into the current main session. Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/etc)."
|
||||||
|
)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -597,7 +601,8 @@ private struct CronJobEditor: View {
|
|||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: self.labelColumnWidth, height: 1)
|
.frame(width: self.labelColumnWidth, height: 1)
|
||||||
Text(
|
Text(
|
||||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression.")
|
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||||
|
)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -642,7 +647,8 @@ private struct CronJobEditor: View {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
if self.sessionTarget == .isolated {
|
if self.sessionTarget == .isolated {
|
||||||
Text(
|
Text(
|
||||||
"Isolated jobs always run an agent turn. The result can be delivered to a surface, and a short summary is posted back to your main chat.")
|
"Isolated jobs always run an agent turn. The result can be delivered to a surface, and a short summary is posted back to your main chat."
|
||||||
|
)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -663,7 +669,8 @@ private struct CronJobEditor: View {
|
|||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: self.labelColumnWidth, height: 1)
|
.frame(width: self.labelColumnWidth, height: 1)
|
||||||
Text(
|
Text(
|
||||||
"System events are injected into the current main session. Agent turns require an isolated session target.")
|
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||||
|
)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -696,7 +703,8 @@ private struct CronJobEditor: View {
|
|||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: self.labelColumnWidth, height: 1)
|
.frame(width: self.labelColumnWidth, height: 1)
|
||||||
Text(
|
Text(
|
||||||
"Controls the label used when posting the completion summary back to the main session.")
|
"Controls the label used when posting the completion summary back to the main session."
|
||||||
|
)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -906,13 +914,14 @@ private struct CronJobEditor: View {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
|
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "Cron",
|
domain: "Cron",
|
||||||
code: 0,
|
code: 0,
|
||||||
userInfo: [
|
userInfo: [
|
||||||
NSLocalizedDescriptionKey: "Main session jobs require systemEvent payloads (switch Session target to isolated).",
|
NSLocalizedDescriptionKey:
|
||||||
])
|
"Main session jobs require systemEvent payloads (switch Session target to isolated).",
|
||||||
}
|
])
|
||||||
|
}
|
||||||
|
|
||||||
if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" {
|
if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
|
|||||||
@@ -141,14 +141,17 @@ struct DebugSettings: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Attach only")
|
self.gridLabel("Attach only")
|
||||||
Toggle("", isOn: self.$attachExistingGatewayOnly)
|
Toggle("", isOn: self.$attachExistingGatewayOnly)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.toggleStyle(.checkbox)
|
.toggleStyle(.checkbox)
|
||||||
.help(
|
.help(
|
||||||
"When enabled in local mode, the mac app will only connect to an already-running gateway and will not start one itself.")
|
"When enabled in local mode, the mac app will only connect " +
|
||||||
}
|
"to an already-running gateway " +
|
||||||
|
"and will not start one itself."
|
||||||
|
)
|
||||||
|
}
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Deep links")
|
self.gridLabel("Deep links")
|
||||||
Toggle("", isOn: self.$deepLinkAgentEnabled)
|
Toggle("", isOn: self.$deepLinkAgentEnabled)
|
||||||
@@ -229,15 +232,17 @@ struct DebugSettings: View {
|
|||||||
|
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Diagnostics")
|
self.gridLabel("Diagnostics")
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
|
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
|
||||||
.toggleStyle(.checkbox)
|
.toggleStyle(.checkbox)
|
||||||
.help(
|
.help(
|
||||||
"Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. Enable only while actively debugging.")
|
"Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. " +
|
||||||
HStack(spacing: 8) {
|
"Enable only while actively debugging."
|
||||||
Button("Open folder") {
|
)
|
||||||
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
|
HStack(spacing: 8) {
|
||||||
}
|
Button("Open folder") {
|
||||||
|
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
|
||||||
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
Button("Clear") {
|
Button("Clear") {
|
||||||
Task { try? await DiagnosticsFileLog.shared.clear() }
|
Task { try? await DiagnosticsFileLog.shared.clear() }
|
||||||
@@ -480,11 +485,13 @@ struct DebugSettings: View {
|
|||||||
|
|
||||||
private var canvasSection: some View {
|
private var canvasSection: some View {
|
||||||
GroupBox("Canvas") {
|
GroupBox("Canvas") {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
|
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
|
||||||
.toggleStyle(.checkbox)
|
.toggleStyle(.checkbox)
|
||||||
.help(
|
.help(
|
||||||
"When off, agent Canvas requests return “Canvas disabled by user”. Manual debug actions still work.")
|
"When off, agent Canvas requests return “Canvas disabled by user”. " +
|
||||||
|
"Manual debug actions still work."
|
||||||
|
)
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
TextField("Session", text: self.$canvasSessionKey)
|
TextField("Session", text: self.$canvasSessionKey)
|
||||||
@@ -580,28 +587,18 @@ struct DebugSettings: View {
|
|||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.frame(maxWidth: 280, alignment: .leading)
|
.frame(maxWidth: 280, alignment: .leading)
|
||||||
}
|
}
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Web chat")
|
self.gridLabel("Web chat")
|
||||||
Toggle("Use SwiftUI web chat (glass)", isOn: self.$webChatSwiftUIEnabled)
|
Toggle("Use SwiftUI web chat (glass)", isOn: self.$webChatSwiftUIEnabled)
|
||||||
.toggleStyle(.checkbox)
|
.toggleStyle(.checkbox)
|
||||||
.help(
|
.help(
|
||||||
"When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the bundled WKWebView.")
|
"When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the " +
|
||||||
}
|
"bundled WKWebView."
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
}
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
configuration.label
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
configuration.content
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func runPortCheck() async {
|
private func runPortCheck() async {
|
||||||
@@ -755,12 +752,14 @@ struct DebugSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configURL() -> URL {
|
private func configURL() -> URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdis")
|
.appendingPathComponent(".clawdis")
|
||||||
.appendingPathComponent("clawdis.json")
|
.appendingPathComponent("clawdis.json")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DebugSettings {
|
||||||
// MARK: - Canvas debug actions
|
// MARK: - Canvas debug actions
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -796,12 +795,17 @@ struct DebugSettings: View {
|
|||||||
body { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
body { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
.wrap { padding:16px; }
|
.wrap { padding:16px; }
|
||||||
.row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
|
.row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
|
||||||
.pill { padding:6px 10px; border-radius:999px; background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.12); }
|
.pill { padding:6px 10px; border-radius:999px; background:rgba(255,255,255,.08);
|
||||||
button { background:#22c55e; color:#04110a; border:0; border-radius:10px; padding:8px 10px; font-weight:700; cursor:pointer; }
|
border:1px solid rgba(255,255,255,.12); }
|
||||||
|
button { background:#22c55e; color:#04110a; border:0; border-radius:10px;
|
||||||
|
padding:8px 10px; font-weight:700; cursor:pointer; }
|
||||||
button:active { transform: translateY(1px); }
|
button:active { transform: translateY(1px); }
|
||||||
.panel { margin-top:14px; padding:14px; border-radius:14px; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.1); }
|
.panel { margin-top:14px; padding:14px; border-radius:14px; background:rgba(255,255,255,.06);
|
||||||
|
border:1px solid rgba(255,255,255,.1); }
|
||||||
.grid { display:grid; grid-template-columns: repeat(12, 1fr); gap:10px; margin-top:12px; }
|
.grid { display:grid; grid-template-columns: repeat(12, 1fr); gap:10px; margin-top:12px; }
|
||||||
.box { grid-column: span 4; height:80px; border-radius:14px; background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(168,85,247,.25)); border:1px solid rgba(255,255,255,.12); }
|
.box { grid-column: span 4; height:80px; border-radius:14px;
|
||||||
|
background: linear-gradient(135deg, rgba(59,130,246,.35), rgba(168,85,247,.25));
|
||||||
|
border:1px solid rgba(255,255,255,.12); }
|
||||||
.muted { color: rgba(229,231,235,.7); }
|
.muted { color: rgba(229,231,235,.7); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -850,7 +854,8 @@ struct DebugSettings: View {
|
|||||||
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let result = try await CanvasManager.shared.eval(
|
let result = try await CanvasManager.shared.eval(
|
||||||
sessionKey: session.isEmpty ? "main" : session,
|
sessionKey: session.isEmpty ? "main" : session,
|
||||||
javaScript: self.canvasEvalJS)
|
javaScript: self.canvasEvalJS
|
||||||
|
)
|
||||||
self.canvasEvalResult = result
|
self.canvasEvalResult = result
|
||||||
} catch {
|
} catch {
|
||||||
self.canvasError = error.localizedDescription
|
self.canvasError = error.localizedDescription
|
||||||
@@ -865,7 +870,8 @@ struct DebugSettings: View {
|
|||||||
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let path = try await CanvasManager.shared.snapshot(
|
let path = try await CanvasManager.shared.snapshot(
|
||||||
sessionKey: session.isEmpty ? "main" : session,
|
sessionKey: session.isEmpty ? "main" : session,
|
||||||
outPath: nil)
|
outPath: nil
|
||||||
|
)
|
||||||
self.canvasSnapshotPath = path
|
self.canvasSnapshotPath = path
|
||||||
} catch {
|
} catch {
|
||||||
self.canvasError = error.localizedDescription
|
self.canvasError = error.localizedDescription
|
||||||
@@ -873,10 +879,22 @@ struct DebugSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||||
struct DebugSettings_Previews: PreviewProvider {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
static var previews: some View {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
DebugSettings()
|
configuration.label
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
configuration.content
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
struct DebugSettings_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
DebugSettings()
|
||||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,15 +125,18 @@ actor GatewayEndpointStore {
|
|||||||
for (_, continuation) in self.subscribers {
|
for (_, continuation) in self.subscribers {
|
||||||
continuation.yield(next)
|
continuation.yield(next)
|
||||||
}
|
}
|
||||||
switch next {
|
switch next {
|
||||||
case let .ready(mode, url, _):
|
case let .ready(mode, url, _):
|
||||||
self.logger
|
let modeDesc = String(describing: mode)
|
||||||
.debug(
|
let urlDesc = url.absoluteString
|
||||||
"resolved endpoint mode=\(String(describing: mode), privacy: .public) url=\(url.absoluteString, privacy: .public)")
|
self.logger
|
||||||
case let .unavailable(mode, reason):
|
.debug(
|
||||||
self.logger
|
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
|
||||||
.debug(
|
case let .unavailable(mode, reason):
|
||||||
"endpoint unavailable mode=\(String(describing: mode), privacy: .public) reason=\(reason, privacy: .public)")
|
let modeDesc = String(describing: mode)
|
||||||
}
|
self.logger
|
||||||
}
|
.debug(
|
||||||
|
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct GeneralSettings: View {
|
struct GeneralSettings: View {
|
||||||
@ObservedObject var state: AppState
|
@ObservedObject var state: AppState
|
||||||
@ObservedObject private var healthStore = HealthStore.shared
|
@ObservedObject private var healthStore = HealthStore.shared
|
||||||
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
|
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
|
||||||
@StateObject private var masterDiscovery = MasterDiscoveryModel()
|
// swiftlint:disable:next inclusive_language
|
||||||
@State private var isInstallingCLI = false
|
@StateObject private var masterDiscovery = MasterDiscoveryModel()
|
||||||
@State private var cliStatus: String?
|
@State private var isInstallingCLI = false
|
||||||
@State private var cliInstalled = false
|
@State private var cliStatus: String?
|
||||||
|
@State private var cliInstalled = false
|
||||||
@State private var cliInstallLocation: String?
|
@State private var cliInstallLocation: String?
|
||||||
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
||||||
@State private var gatewayInstallMessage: String?
|
@State private var gatewayInstallMessage: String?
|
||||||
@@ -576,11 +577,12 @@ extension GeneralSettings {
|
|||||||
alert.runModal()
|
alert.runModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
|
// swiftlint:disable:next inclusive_language
|
||||||
let host = master.tailnetDns ?? master.lanHost
|
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
|
||||||
guard let host else { return }
|
let host = master.tailnetDns ?? master.lanHost
|
||||||
let user = NSUserName()
|
guard let host else { return }
|
||||||
var target = "\(user)@\(host)"
|
let user = NSUserName()
|
||||||
|
var target = "\(user)@\(host)"
|
||||||
if master.sshPort != 22 {
|
if master.sshPort != 22 {
|
||||||
target += ":\(master.sshPort)"
|
target += ":\(master.sshPort)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// “master” is part of the discovery protocol naming; keep UI components consistent.
|
||||||
|
// swiftlint:disable:next inclusive_language
|
||||||
struct MasterDiscoveryInlineList: View {
|
struct MasterDiscoveryInlineList: View {
|
||||||
@ObservedObject var discovery: MasterDiscoveryModel
|
@ObservedObject var discovery: MasterDiscoveryModel
|
||||||
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
||||||
@@ -50,6 +52,7 @@ struct MasterDiscoveryInlineList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable:next inclusive_language
|
||||||
struct MasterDiscoveryMenu: View {
|
struct MasterDiscoveryMenu: View {
|
||||||
@ObservedObject var discovery: MasterDiscoveryModel
|
@ObservedObject var discovery: MasterDiscoveryModel
|
||||||
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
|
|
||||||
|
// We use “master” as the on-the-wire service name; keep the model aligned with the protocol/docs.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
// swiftlint:disable:next inclusive_language
|
||||||
final class MasterDiscoveryModel: ObservableObject {
|
final class MasterDiscoveryModel: ObservableObject {
|
||||||
|
// swiftlint:disable:next inclusive_language
|
||||||
struct DiscoveredMaster: Identifiable, Equatable {
|
struct DiscoveredMaster: Identifiable, Equatable {
|
||||||
var id: String { self.debugID }
|
var id: String { self.debugID }
|
||||||
var displayName: String
|
var displayName: String
|
||||||
@@ -12,6 +15,7 @@ final class MasterDiscoveryModel: ObservableObject {
|
|||||||
var debugID: String
|
var debugID: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable:next inclusive_language
|
||||||
@Published var masters: [DiscoveredMaster] = []
|
@Published var masters: [DiscoveredMaster] = []
|
||||||
@Published var statusText: String = "Idle"
|
@Published var statusText: String = "Idle"
|
||||||
|
|
||||||
|
|||||||
@@ -110,9 +110,8 @@ struct MenuContent: View {
|
|||||||
await self.reloadSessionMenu()
|
await self.reloadSessionMenu()
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
let checkmark = row.thinkingLevel == normalized ? "checkmark" : ""
|
||||||
level.capitalized,
|
Label(level.capitalized, systemImage: checkmark)
|
||||||
systemImage: row.thinkingLevel == normalized ? "checkmark" : "")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,9 +127,8 @@ struct MenuContent: View {
|
|||||||
await self.reloadSessionMenu()
|
await self.reloadSessionMenu()
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
let checkmark = row.verboseLevel == normalized ? "checkmark" : ""
|
||||||
level.capitalized,
|
Label(level.capitalized, systemImage: checkmark)
|
||||||
systemImage: row.verboseLevel == normalized ? "checkmark" : "")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,15 +45,16 @@ struct OnboardingView: View {
|
|||||||
@State private var cliStatus: String?
|
@State private var cliStatus: String?
|
||||||
@State private var copied = false
|
@State private var copied = false
|
||||||
@State private var monitoringPermissions = false
|
@State private var monitoringPermissions = false
|
||||||
@State private var monitoringDiscovery = false
|
@State private var monitoringDiscovery = false
|
||||||
@State private var cliInstalled = false
|
@State private var cliInstalled = false
|
||||||
@State private var cliInstallLocation: String?
|
@State private var cliInstallLocation: String?
|
||||||
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
||||||
@State private var gatewayInstalling = false
|
@State private var gatewayInstalling = false
|
||||||
@State private var gatewayInstallMessage: String?
|
@State private var gatewayInstallMessage: String?
|
||||||
@StateObject private var masterDiscovery = MasterDiscoveryModel()
|
// swiftlint:disable:next inclusive_language
|
||||||
@ObservedObject private var state = AppStateStore.shared
|
@StateObject private var masterDiscovery = MasterDiscoveryModel()
|
||||||
@ObservedObject private var permissionMonitor = PermissionMonitor.shared
|
@ObservedObject private var state = AppStateStore.shared
|
||||||
|
@ObservedObject private var permissionMonitor = PermissionMonitor.shared
|
||||||
|
|
||||||
private let pageWidth: CGFloat = 680
|
private let pageWidth: CGFloat = 680
|
||||||
private let contentHeight: CGFloat = 520
|
private let contentHeight: CGFloat = 520
|
||||||
@@ -115,15 +116,17 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func welcomePage() -> some View {
|
private func welcomePage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Welcome to Clawdis")
|
Text("Welcome to Clawdis")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text(
|
Text(
|
||||||
"Your macOS menu bar companion for notifications, screenshots, and agent automation — setup takes just a few minutes.")
|
"Your macOS menu bar companion for notifications, screenshots, and agent automation — " +
|
||||||
.font(.body)
|
"setup takes just a few minutes."
|
||||||
.foregroundStyle(.secondary)
|
)
|
||||||
.multilineTextAlignment(.center)
|
.font(.body)
|
||||||
.lineLimit(2)
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(2)
|
||||||
.frame(maxWidth: 560)
|
.frame(maxWidth: 560)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
@@ -138,12 +141,16 @@ struct OnboardingView: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Security notice")
|
Text("Security notice")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(
|
Text(
|
||||||
"""
|
"""
|
||||||
The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, including running commands, reading/writing files, and capturing screenshots — depending on the permissions you grant.
|
The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac,
|
||||||
|
including running
|
||||||
|
commands, reading/writing files, and capturing screenshots — depending on the
|
||||||
|
permissions you grant.
|
||||||
|
|
||||||
Only enable Clawdis if you understand the risks and trust the prompts and integrations you use.
|
Only enable Clawdis if you understand the risks and trust the prompts
|
||||||
""")
|
and integrations you use.
|
||||||
|
""")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -155,15 +162,17 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func connectionPage() -> some View {
|
private func connectionPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Where Clawdis runs")
|
Text("Where Clawdis runs")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text(
|
Text(
|
||||||
"Clawdis has one primary Gateway (“master”) that runs continuously. Connect locally or over SSH/Tailscale so the agent can work on any Mac.")
|
"Clawdis has one primary Gateway (“master”) that runs continuously. " +
|
||||||
.font(.body)
|
"Connect locally or over SSH/Tailscale so the agent can work on any Mac."
|
||||||
.foregroundStyle(.secondary)
|
)
|
||||||
.multilineTextAlignment(.center)
|
.font(.body)
|
||||||
.lineLimit(2)
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(2)
|
||||||
.frame(maxWidth: 520)
|
.frame(maxWidth: 520)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
@@ -291,23 +300,26 @@ struct OnboardingView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Uses \"npm install -g clawdis@<version>\" on your PATH. We keep the gateway on port 18789.")
|
"Uses \"npm install -g clawdis@<version>\" on your PATH. " +
|
||||||
.font(.caption)
|
"We keep the gateway on port 18789."
|
||||||
.foregroundStyle(.secondary)
|
)
|
||||||
.lineLimit(2)
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
|
// swiftlint:disable:next inclusive_language
|
||||||
let host = master.tailnetDns ?? master.lanHost
|
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
|
||||||
guard let host else { return }
|
let host = master.tailnetDns ?? master.lanHost
|
||||||
let user = NSUserName()
|
guard let host else { return }
|
||||||
var target = "\(user)@\(host)"
|
let user = NSUserName()
|
||||||
|
var target = "\(user)@\(host)"
|
||||||
if master.sshPort != 22 {
|
if master.sshPort != 22 {
|
||||||
target += ":\(master.sshPort)"
|
target += ":\(master.sshPort)"
|
||||||
}
|
}
|
||||||
@@ -448,12 +460,13 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
Text("Telegram")
|
Text("Telegram")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Set `TELEGRAM_BOT_TOKEN`",
|
title: "Set `TELEGRAM_BOT_TOKEN`",
|
||||||
subtitle: """
|
subtitle: """
|
||||||
Create a bot with @BotFather and set the token as an env var (or `telegram.botToken` in `~/.clawdis/clawdis.json`).
|
Create a bot with @BotFather and set the token as an env var
|
||||||
""",
|
(or `telegram.botToken` in `~/.clawdis/clawdis.json`).
|
||||||
systemImage: "key")
|
""",
|
||||||
|
systemImage: "key")
|
||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Verify with `clawdis status --deep`",
|
title: "Verify with `clawdis status --deep`",
|
||||||
subtitle: "This probes both WhatsApp and the Telegram API and prints what’s configured.",
|
subtitle: "This probes both WhatsApp and the Telegram API and prints what’s configured.",
|
||||||
@@ -478,10 +491,11 @@ struct OnboardingView: View {
|
|||||||
title: "Try Voice Wake",
|
title: "Try Voice Wake",
|
||||||
subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
|
subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
|
||||||
systemImage: "waveform.circle")
|
systemImage: "waveform.circle")
|
||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Use the panel + Canvas",
|
title: "Use the panel + Canvas",
|
||||||
subtitle: "Open the menu bar panel for quick chat; the agent can show previews and richer visuals in Canvas.",
|
subtitle: "Open the menu bar panel for quick chat; the agent can show previews " +
|
||||||
systemImage: "rectangle.inset.filled.and.person.filled")
|
"and richer visuals in Canvas.",
|
||||||
|
systemImage: "rectangle.inset.filled.and.person.filled")
|
||||||
self.featureRow(
|
self.featureRow(
|
||||||
title: "Test a notification",
|
title: "Test a notification",
|
||||||
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
|
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",
|
||||||
|
|||||||
@@ -9,101 +9,109 @@ import Speech
|
|||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
enum PermissionManager {
|
enum PermissionManager {
|
||||||
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
|
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
|
||||||
var results: [Capability: Bool] = [:]
|
var results: [Capability: Bool] = [:]
|
||||||
for cap in caps {
|
for cap in caps {
|
||||||
switch cap {
|
results[cap] = await self.ensureCapability(cap, interactive: interactive)
|
||||||
case .notifications:
|
}
|
||||||
let center = UNUserNotificationCenter.current()
|
return results
|
||||||
let settings = await center.notificationSettings()
|
}
|
||||||
|
|
||||||
switch settings.authorizationStatus {
|
private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool {
|
||||||
case .authorized, .provisional, .ephemeral:
|
switch cap {
|
||||||
results[cap] = true
|
case .notifications:
|
||||||
|
return await self.ensureNotifications(interactive: interactive)
|
||||||
|
case .appleScript:
|
||||||
|
return await self.ensureAppleScript(interactive: interactive)
|
||||||
|
case .accessibility:
|
||||||
|
return await self.ensureAccessibility(interactive: interactive)
|
||||||
|
case .screenRecording:
|
||||||
|
return await self.ensureScreenRecording(interactive: interactive)
|
||||||
|
case .microphone:
|
||||||
|
return await self.ensureMicrophone(interactive: interactive)
|
||||||
|
case .speechRecognition:
|
||||||
|
return await self.ensureSpeechRecognition(interactive: interactive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case .notDetermined:
|
private static func ensureNotifications(interactive: Bool) async -> Bool {
|
||||||
if interactive {
|
let center = UNUserNotificationCenter.current()
|
||||||
let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ??
|
let settings = await center.notificationSettings()
|
||||||
false
|
|
||||||
let updated = await center.notificationSettings()
|
|
||||||
results[cap] = granted && (updated.authorizationStatus == .authorized || updated
|
|
||||||
.authorizationStatus == .provisional)
|
|
||||||
} else {
|
|
||||||
results[cap] = false
|
|
||||||
}
|
|
||||||
|
|
||||||
case .denied:
|
switch settings.authorizationStatus {
|
||||||
results[cap] = false
|
case .authorized, .provisional, .ephemeral:
|
||||||
if interactive {
|
return true
|
||||||
NotificationPermissionHelper.openSettings()
|
case .notDetermined:
|
||||||
}
|
guard interactive else { return false }
|
||||||
|
let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||||
|
let updated = await center.notificationSettings()
|
||||||
|
return granted && (updated.authorizationStatus == .authorized || updated.authorizationStatus == .provisional)
|
||||||
|
case .denied:
|
||||||
|
if interactive {
|
||||||
|
NotificationPermissionHelper.openSettings()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@unknown default:
|
private static func ensureAppleScript(interactive: Bool) async -> Bool {
|
||||||
results[cap] = false
|
let granted = await MainActor.run { AppleScriptPermission.isAuthorized() }
|
||||||
}
|
if interactive, !granted {
|
||||||
|
await AppleScriptPermission.requestAuthorization()
|
||||||
|
}
|
||||||
|
return await MainActor.run { AppleScriptPermission.isAuthorized() }
|
||||||
|
}
|
||||||
|
|
||||||
case .appleScript:
|
private static func ensureAccessibility(interactive: Bool) async -> Bool {
|
||||||
let granted = await MainActor.run { AppleScriptPermission.isAuthorized() }
|
let trusted = await MainActor.run { AXIsProcessTrusted() }
|
||||||
if interactive, !granted {
|
if interactive, !trusted {
|
||||||
await AppleScriptPermission.requestAuthorization()
|
await MainActor.run {
|
||||||
}
|
let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true]
|
||||||
results[cap] = await MainActor.run { AppleScriptPermission.isAuthorized() }
|
_ = AXIsProcessTrustedWithOptions(opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await MainActor.run { AXIsProcessTrusted() }
|
||||||
|
}
|
||||||
|
|
||||||
case .accessibility:
|
private static func ensureScreenRecording(interactive: Bool) async -> Bool {
|
||||||
let trusted = await MainActor.run { AXIsProcessTrusted() }
|
let granted = ScreenRecordingProbe.isAuthorized()
|
||||||
results[cap] = trusted
|
if interactive, !granted {
|
||||||
if interactive, !trusted {
|
await ScreenRecordingProbe.requestAuthorization()
|
||||||
await MainActor.run {
|
}
|
||||||
let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true]
|
return ScreenRecordingProbe.isAuthorized()
|
||||||
_ = AXIsProcessTrustedWithOptions(opts)
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .screenRecording:
|
private static func ensureMicrophone(interactive: Bool) async -> Bool {
|
||||||
let granted = ScreenRecordingProbe.isAuthorized()
|
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||||
if interactive, !granted {
|
switch status {
|
||||||
await ScreenRecordingProbe.requestAuthorization()
|
case .authorized:
|
||||||
}
|
return true
|
||||||
results[cap] = ScreenRecordingProbe.isAuthorized()
|
case .notDetermined:
|
||||||
|
guard interactive else { return false }
|
||||||
|
return await AVCaptureDevice.requestAccess(for: .audio)
|
||||||
|
case .denied, .restricted:
|
||||||
|
if interactive {
|
||||||
|
MicrophonePermissionHelper.openSettings()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case .microphone:
|
private static func ensureSpeechRecognition(interactive: Bool) async -> Bool {
|
||||||
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
let status = SFSpeechRecognizer.authorizationStatus()
|
||||||
switch status {
|
if status == .notDetermined, interactive {
|
||||||
case .authorized:
|
await withUnsafeContinuation { (cont: UnsafeContinuation<Void, Never>) in
|
||||||
results[cap] = true
|
SFSpeechRecognizer.requestAuthorization { _ in
|
||||||
|
DispatchQueue.main.async { cont.resume() }
|
||||||
case .notDetermined:
|
}
|
||||||
if interactive {
|
}
|
||||||
let ok = await AVCaptureDevice.requestAccess(for: .audio)
|
}
|
||||||
results[cap] = ok
|
return SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||||
} else {
|
}
|
||||||
results[cap] = false
|
|
||||||
}
|
|
||||||
|
|
||||||
case .denied, .restricted:
|
|
||||||
results[cap] = false
|
|
||||||
if interactive {
|
|
||||||
MicrophonePermissionHelper.openSettings()
|
|
||||||
}
|
|
||||||
|
|
||||||
@unknown default:
|
|
||||||
results[cap] = false
|
|
||||||
}
|
|
||||||
|
|
||||||
case .speechRecognition:
|
|
||||||
let status = SFSpeechRecognizer.authorizationStatus()
|
|
||||||
if status == .notDetermined, interactive {
|
|
||||||
await withUnsafeContinuation { (cont: UnsafeContinuation<Void, Never>) in
|
|
||||||
SFSpeechRecognizer.requestAuthorization { _ in
|
|
||||||
DispatchQueue.main.async { cont.resume() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
static func voiceWakePermissionsGranted() -> Bool {
|
static func voiceWakePermissionsGranted() -> Bool {
|
||||||
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||||
|
|||||||
@@ -186,39 +186,37 @@ final class WebChatServer: @unchecked Sendable {
|
|||||||
over: connection)
|
over: connection)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = try? Data(contentsOf: fileURL) else {
|
guard let data = try? Data(contentsOf: fileURL) else {
|
||||||
webChatServerLogger.error("WebChatServer 404 missing \(fileURL.lastPathComponent, privacy: .public)")
|
webChatServerLogger.error("WebChatServer 404 missing \(fileURL.lastPathComponent, privacy: .public)")
|
||||||
self.send(
|
self.send(
|
||||||
status: 404,
|
status: 404,
|
||||||
mime: "text/plain",
|
mime: "text/plain",
|
||||||
body: Data("Not Found".utf8),
|
body: Data("Not Found".utf8),
|
||||||
contentLength: "Not Found".utf8.count,
|
includeBody: includeBody,
|
||||||
includeBody: includeBody,
|
over: connection)
|
||||||
over: connection)
|
return
|
||||||
return
|
}
|
||||||
}
|
let mime = self.mimeType(forExtension: fileURL.pathExtension)
|
||||||
let mime = self.mimeType(forExtension: fileURL.pathExtension)
|
self.send(
|
||||||
self.send(
|
status: 200,
|
||||||
status: 200,
|
mime: mime,
|
||||||
mime: mime,
|
body: data,
|
||||||
body: data,
|
includeBody: includeBody,
|
||||||
contentLength: data.count,
|
over: connection)
|
||||||
includeBody: includeBody,
|
}
|
||||||
over: connection)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func send(
|
private func send(
|
||||||
status: Int,
|
status: Int,
|
||||||
mime: String,
|
mime: String,
|
||||||
body: Data,
|
body: Data,
|
||||||
contentLength: Int,
|
includeBody: Bool,
|
||||||
includeBody: Bool,
|
over connection: NWConnection)
|
||||||
over connection: NWConnection)
|
{
|
||||||
{
|
let contentLength = body.count
|
||||||
let headers = "HTTP/1.1 \(status) \(statusText(status))\r\n" +
|
let headers = "HTTP/1.1 \(status) \(statusText(status))\r\n" +
|
||||||
"Content-Length: \(contentLength)\r\n" +
|
"Content-Length: \(contentLength)\r\n" +
|
||||||
"Content-Type: \(mime)\r\n" +
|
"Content-Type: \(mime)\r\n" +
|
||||||
"Connection: close\r\n\r\n"
|
"Connection: close\r\n\r\n"
|
||||||
var response = Data(headers.utf8)
|
var response = Data(headers.utf8)
|
||||||
if includeBody {
|
if includeBody {
|
||||||
response.append(body)
|
response.append(body)
|
||||||
|
|||||||
@@ -163,8 +163,9 @@ final class WebChatViewModel: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let data = try await Task.detached { try Data(contentsOf: url) }.value
|
let data = try await Task.detached { try Data(contentsOf: url) }.value
|
||||||
guard data.count <= 5_000_000 else {
|
guard data.count <= 5_000_000 else {
|
||||||
await MainActor
|
await MainActor.run {
|
||||||
.run { self.errorText = "Attachment \(url.lastPathComponent) exceeds 5 MB limit" }
|
self.errorText = "Attachment \(url.lastPathComponent) exceeds 5 MB limit"
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let uti = UTType(filenameExtension: url.pathExtension) ?? .data
|
let uti = UTType(filenameExtension: url.pathExtension) ?? .data
|
||||||
@@ -447,8 +448,11 @@ struct WebChatView: View {
|
|||||||
.foregroundStyle(Color.accentColor.opacity(0.9))
|
.foregroundStyle(Color.accentColor.opacity(0.9))
|
||||||
Text("Say hi to Clawd")
|
Text("Say hi to Clawd")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(self.viewModel
|
Text(
|
||||||
.healthOK ? "This is the native SwiftUI debug chat." : "Connecting to the gateway…")
|
self.viewModel.healthOK
|
||||||
|
? "This is the native SwiftUI debug chat."
|
||||||
|
: "Connecting to the gateway…"
|
||||||
|
)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -460,10 +464,9 @@ struct WebChatView: View {
|
|||||||
.padding(.vertical, 34)
|
.padding(.vertical, 34)
|
||||||
} else {
|
} else {
|
||||||
ForEach(self.viewModel.messages) { msg in
|
ForEach(self.viewModel.messages) { msg in
|
||||||
|
let alignment: Alignment = msg.role.lowercased() == "user" ? .trailing : .leading
|
||||||
MessageBubble(message: msg)
|
MessageBubble(message: msg)
|
||||||
.frame(
|
.frame(maxWidth: .infinity, alignment: alignment)
|
||||||
maxWidth: .infinity,
|
|
||||||
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,62 +6,17 @@ enum BrowserCLI {
|
|||||||
|
|
||||||
static func run(args: [String], jsonOutput: Bool) async throws -> Int32 {
|
static func run(args: [String], jsonOutput: Bool) async throws -> Int32 {
|
||||||
var args = args
|
var args = args
|
||||||
guard let sub = args.first else {
|
guard let sub = args.popFirst() else {
|
||||||
self.printHelp()
|
self.printHelp()
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
args = Array(args.dropFirst())
|
|
||||||
|
|
||||||
if sub == "--help" || sub == "-h" || sub == "help" {
|
if sub == "--help" || sub == "-h" || sub == "help" {
|
||||||
self.printHelp()
|
self.printHelp()
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
var overrideURL: String?
|
let options = self.parseOptions(args: args)
|
||||||
var fullPage = false
|
|
||||||
var targetId: String?
|
|
||||||
var awaitPromise = false
|
|
||||||
var js: String?
|
|
||||||
var jsFile: String?
|
|
||||||
var jsStdin = false
|
|
||||||
var selector: String?
|
|
||||||
var format: String?
|
|
||||||
var limit: Int?
|
|
||||||
var maxChars: Int?
|
|
||||||
var outPath: String?
|
|
||||||
var rest: [String] = []
|
|
||||||
|
|
||||||
while !args.isEmpty {
|
|
||||||
let arg = args.removeFirst()
|
|
||||||
switch arg {
|
|
||||||
case "--url":
|
|
||||||
overrideURL = args.popFirst()
|
|
||||||
case "--full-page":
|
|
||||||
fullPage = true
|
|
||||||
case "--target-id":
|
|
||||||
targetId = args.popFirst()
|
|
||||||
case "--await":
|
|
||||||
awaitPromise = true
|
|
||||||
case "--js":
|
|
||||||
js = args.popFirst()
|
|
||||||
case "--js-file":
|
|
||||||
jsFile = args.popFirst()
|
|
||||||
case "--js-stdin":
|
|
||||||
jsStdin = true
|
|
||||||
case "--selector":
|
|
||||||
selector = args.popFirst()
|
|
||||||
case "--format":
|
|
||||||
format = args.popFirst()
|
|
||||||
case "--limit":
|
|
||||||
limit = args.popFirst().flatMap(Int.init)
|
|
||||||
case "--max-chars":
|
|
||||||
maxChars = args.popFirst().flatMap(Int.init)
|
|
||||||
case "--out":
|
|
||||||
outPath = args.popFirst()
|
|
||||||
default:
|
|
||||||
rest.append(arg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let cfg = self.loadBrowserConfig()
|
let cfg = self.loadBrowserConfig()
|
||||||
guard cfg.enabled else {
|
guard cfg.enabled else {
|
||||||
@@ -73,7 +28,7 @@ enum BrowserCLI {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
let base = (overrideURL ?? cfg.controlUrl).trimmingCharacters(in: .whitespacesAndNewlines)
|
let base = (options.overrideURL ?? cfg.controlUrl).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard let baseURL = URL(string: base) else {
|
guard let baseURL = URL(string: base) else {
|
||||||
throw NSError(domain: "BrowserCLI", code: 1, userInfo: [
|
throw NSError(domain: "BrowserCLI", code: 1, userInfo: [
|
||||||
NSLocalizedDescriptionKey: "Invalid browser control URL: \(base)",
|
NSLocalizedDescriptionKey: "Invalid browser control URL: \(base)",
|
||||||
@@ -81,237 +36,7 @@ enum BrowserCLI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
switch sub {
|
return try await self.runCommand(sub: sub, options: options, baseURL: baseURL, jsonOutput: jsonOutput)
|
||||||
case "status":
|
|
||||||
try await self.printResult(
|
|
||||||
jsonOutput: jsonOutput,
|
|
||||||
res: self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/")))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
case "start":
|
|
||||||
try await self.printResult(
|
|
||||||
jsonOutput: jsonOutput,
|
|
||||||
res: self.httpJSON(
|
|
||||||
method: "POST",
|
|
||||||
url: baseURL.appendingPathComponent("/start"),
|
|
||||||
timeoutInterval: 15.0))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
case "stop":
|
|
||||||
try await self.printResult(
|
|
||||||
jsonOutput: jsonOutput,
|
|
||||||
res: self.httpJSON(
|
|
||||||
method: "POST",
|
|
||||||
url: baseURL.appendingPathComponent("/stop"),
|
|
||||||
timeoutInterval: 15.0))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
case "tabs":
|
|
||||||
let res = try await self.httpJSON(
|
|
||||||
method: "GET",
|
|
||||||
url: baseURL.appendingPathComponent("/tabs"),
|
|
||||||
timeoutInterval: 3.0)
|
|
||||||
if jsonOutput {
|
|
||||||
self.printJSON(ok: true, result: res)
|
|
||||||
} else {
|
|
||||||
self.printTabs(res: res)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
|
|
||||||
case "open":
|
|
||||||
guard let url = rest.first, !url.isEmpty else {
|
|
||||||
self.printHelp()
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
try await self.printResult(
|
|
||||||
jsonOutput: jsonOutput,
|
|
||||||
res: self.httpJSON(
|
|
||||||
method: "POST",
|
|
||||||
url: baseURL.appendingPathComponent("/tabs/open"),
|
|
||||||
body: ["url": url],
|
|
||||||
timeoutInterval: 15.0))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
case "focus":
|
|
||||||
guard let id = rest.first, !id.isEmpty else {
|
|
||||||
self.printHelp()
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
try await self.printResult(
|
|
||||||
jsonOutput: jsonOutput,
|
|
||||||
res: self.httpJSON(
|
|
||||||
method: "POST",
|
|
||||||
url: baseURL.appendingPathComponent("/tabs/focus"),
|
|
||||||
body: ["targetId": id],
|
|
||||||
timeoutInterval: 5.0))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
case "close":
|
|
||||||
guard let id = rest.first, !id.isEmpty else {
|
|
||||||
self.printHelp()
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
try await self.printResult(
|
|
||||||
jsonOutput: jsonOutput,
|
|
||||||
res: self.httpJSON(
|
|
||||||
method: "DELETE",
|
|
||||||
url: baseURL.appendingPathComponent("/tabs/\(id)"),
|
|
||||||
timeoutInterval: 5.0))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
case "screenshot":
|
|
||||||
var url = baseURL.appendingPathComponent("/screenshot")
|
|
||||||
var items: [URLQueryItem] = []
|
|
||||||
if let targetId, !targetId.isEmpty {
|
|
||||||
items.append(URLQueryItem(name: "targetId", value: targetId))
|
|
||||||
}
|
|
||||||
if fullPage {
|
|
||||||
items.append(URLQueryItem(name: "fullPage", value: "1"))
|
|
||||||
}
|
|
||||||
if !items.isEmpty {
|
|
||||||
url = self.withQuery(url, items: items)
|
|
||||||
}
|
|
||||||
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
|
|
||||||
if jsonOutput {
|
|
||||||
self.printJSON(ok: true, result: res)
|
|
||||||
} else if let path = res["path"] as? String, !path.isEmpty {
|
|
||||||
print("MEDIA:\(path)")
|
|
||||||
} else {
|
|
||||||
self.printResult(jsonOutput: false, res: res)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
|
|
||||||
case "eval":
|
|
||||||
if jsStdin, jsFile != nil {
|
|
||||||
self.printHelp()
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
let code: String = try {
|
|
||||||
if let jsFile, !jsFile.isEmpty {
|
|
||||||
return try String(contentsOfFile: jsFile, encoding: .utf8)
|
|
||||||
}
|
|
||||||
if jsStdin {
|
|
||||||
let data = FileHandle.standardInput.readDataToEndOfFile()
|
|
||||||
return String(data: data, encoding: .utf8) ?? ""
|
|
||||||
}
|
|
||||||
if let js, !js.isEmpty { return js }
|
|
||||||
if !rest.isEmpty { return rest.joined(separator: " ") }
|
|
||||||
return ""
|
|
||||||
}()
|
|
||||||
|
|
||||||
if code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
self.printHelp()
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
let res = try await self.httpJSON(
|
|
||||||
method: "POST",
|
|
||||||
url: baseURL.appendingPathComponent("/eval"),
|
|
||||||
body: [
|
|
||||||
"js": code,
|
|
||||||
"targetId": targetId ?? "",
|
|
||||||
"await": awaitPromise,
|
|
||||||
],
|
|
||||||
timeoutInterval: 15.0)
|
|
||||||
|
|
||||||
if jsonOutput {
|
|
||||||
self.printJSON(ok: true, result: res)
|
|
||||||
} else {
|
|
||||||
self.printEval(res: res)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
|
|
||||||
case "query":
|
|
||||||
let sel = (selector ?? rest.first ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if sel.isEmpty {
|
|
||||||
self.printHelp()
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
var url = baseURL.appendingPathComponent("/query")
|
|
||||||
var items: [URLQueryItem] = [URLQueryItem(name: "selector", value: sel)]
|
|
||||||
if let targetId, !targetId.isEmpty {
|
|
||||||
items.append(URLQueryItem(name: "targetId", value: targetId))
|
|
||||||
}
|
|
||||||
if let limit, limit > 0 {
|
|
||||||
items.append(URLQueryItem(name: "limit", value: String(limit)))
|
|
||||||
}
|
|
||||||
url = self.withQuery(url, items: items)
|
|
||||||
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 15.0)
|
|
||||||
if jsonOutput || format == "json" {
|
|
||||||
self.printJSON(ok: true, result: res)
|
|
||||||
} else {
|
|
||||||
self.printQuery(res: res)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
|
|
||||||
case "dom":
|
|
||||||
let fmt = (format == "text") ? "text" : "html"
|
|
||||||
var url = baseURL.appendingPathComponent("/dom")
|
|
||||||
var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)]
|
|
||||||
if let targetId, !targetId.isEmpty {
|
|
||||||
items.append(URLQueryItem(name: "targetId", value: targetId))
|
|
||||||
}
|
|
||||||
if let selector = selector?.trimmingCharacters(in: .whitespacesAndNewlines), !selector.isEmpty {
|
|
||||||
items.append(URLQueryItem(name: "selector", value: selector))
|
|
||||||
}
|
|
||||||
if let maxChars, maxChars > 0 {
|
|
||||||
items.append(URLQueryItem(name: "maxChars", value: String(maxChars)))
|
|
||||||
}
|
|
||||||
url = self.withQuery(url, items: items)
|
|
||||||
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
|
|
||||||
let text = (res["text"] as? String) ?? ""
|
|
||||||
if let out = outPath, !out.isEmpty {
|
|
||||||
try Data(text.utf8).write(to: URL(fileURLWithPath: out))
|
|
||||||
if jsonOutput {
|
|
||||||
self.printJSON(ok: true, result: ["ok": true, "out": out])
|
|
||||||
} else {
|
|
||||||
print(out)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if jsonOutput {
|
|
||||||
self.printJSON(ok: true, result: res)
|
|
||||||
} else {
|
|
||||||
print(text)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
|
|
||||||
case "snapshot":
|
|
||||||
let fmt = (format == "domSnapshot") ? "domSnapshot" : "aria"
|
|
||||||
var url = baseURL.appendingPathComponent("/snapshot")
|
|
||||||
var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)]
|
|
||||||
if let targetId, !targetId.isEmpty {
|
|
||||||
items.append(URLQueryItem(name: "targetId", value: targetId))
|
|
||||||
}
|
|
||||||
if let limit, limit > 0 {
|
|
||||||
items.append(URLQueryItem(name: "limit", value: String(limit)))
|
|
||||||
}
|
|
||||||
url = self.withQuery(url, items: items)
|
|
||||||
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
|
|
||||||
|
|
||||||
if let out = outPath, !out.isEmpty {
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: res, options: [.prettyPrinted])
|
|
||||||
try data.write(to: URL(fileURLWithPath: out))
|
|
||||||
if jsonOutput {
|
|
||||||
self.printJSON(ok: true, result: ["ok": true, "out": out])
|
|
||||||
} else {
|
|
||||||
print(out)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if jsonOutput || fmt == "domSnapshot" {
|
|
||||||
self.printJSON(ok: true, result: res)
|
|
||||||
} else {
|
|
||||||
self.printSnapshotAria(res: res)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
|
|
||||||
default:
|
|
||||||
self.printHelp()
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
let msg = self.describeError(error, baseURL: baseURL)
|
let msg = self.describeError(error, baseURL: baseURL)
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
@@ -323,6 +48,329 @@ enum BrowserCLI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct RunOptions {
|
||||||
|
var overrideURL: String?
|
||||||
|
var fullPage: Bool = false
|
||||||
|
var targetId: String?
|
||||||
|
var awaitPromise: Bool = false
|
||||||
|
var js: String?
|
||||||
|
var jsFile: String?
|
||||||
|
var jsStdin: Bool = false
|
||||||
|
var selector: String?
|
||||||
|
var format: String?
|
||||||
|
var limit: Int?
|
||||||
|
var maxChars: Int?
|
||||||
|
var outPath: String?
|
||||||
|
var rest: [String] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseOptions(args: [String]) -> RunOptions {
|
||||||
|
var args = args
|
||||||
|
var options = RunOptions()
|
||||||
|
while !args.isEmpty {
|
||||||
|
let arg = args.removeFirst()
|
||||||
|
switch arg {
|
||||||
|
case "--url":
|
||||||
|
options.overrideURL = args.popFirst()
|
||||||
|
case "--full-page":
|
||||||
|
options.fullPage = true
|
||||||
|
case "--target-id":
|
||||||
|
options.targetId = args.popFirst()
|
||||||
|
case "--await":
|
||||||
|
options.awaitPromise = true
|
||||||
|
case "--js":
|
||||||
|
options.js = args.popFirst()
|
||||||
|
case "--js-file":
|
||||||
|
options.jsFile = args.popFirst()
|
||||||
|
case "--js-stdin":
|
||||||
|
options.jsStdin = true
|
||||||
|
case "--selector":
|
||||||
|
options.selector = args.popFirst()
|
||||||
|
case "--format":
|
||||||
|
options.format = args.popFirst()
|
||||||
|
case "--limit":
|
||||||
|
options.limit = args.popFirst().flatMap(Int.init)
|
||||||
|
case "--max-chars":
|
||||||
|
options.maxChars = args.popFirst().flatMap(Int.init)
|
||||||
|
case "--out":
|
||||||
|
options.outPath = args.popFirst()
|
||||||
|
default:
|
||||||
|
options.rest.append(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runCommand(
|
||||||
|
sub: String,
|
||||||
|
options: RunOptions,
|
||||||
|
baseURL: URL,
|
||||||
|
jsonOutput: Bool
|
||||||
|
) async throws -> Int32 {
|
||||||
|
switch sub {
|
||||||
|
case "status":
|
||||||
|
return try await self.handleStatus(baseURL: baseURL, jsonOutput: jsonOutput)
|
||||||
|
case "start":
|
||||||
|
return try await self.handleStartStop(action: "start", baseURL: baseURL, jsonOutput: jsonOutput)
|
||||||
|
case "stop":
|
||||||
|
return try await self.handleStartStop(action: "stop", baseURL: baseURL, jsonOutput: jsonOutput)
|
||||||
|
case "tabs":
|
||||||
|
return try await self.handleTabs(baseURL: baseURL, jsonOutput: jsonOutput)
|
||||||
|
case "open":
|
||||||
|
return try await self.handleOpen(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||||
|
case "focus":
|
||||||
|
return try await self.handleFocus(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||||
|
case "close":
|
||||||
|
return try await self.handleClose(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||||
|
case "screenshot":
|
||||||
|
return try await self.handleScreenshot(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||||
|
case "eval":
|
||||||
|
return try await self.handleEval(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||||
|
case "query":
|
||||||
|
return try await self.handleQuery(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||||
|
case "dom":
|
||||||
|
return try await self.handleDOM(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||||
|
case "snapshot":
|
||||||
|
return try await self.handleSnapshot(baseURL: baseURL, jsonOutput: jsonOutput, options: options)
|
||||||
|
default:
|
||||||
|
self.printHelp()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleStatus(baseURL: URL, jsonOutput: Bool) async throws -> Int32 {
|
||||||
|
let res = try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/"))
|
||||||
|
self.printResult(jsonOutput: jsonOutput, res: res)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleStartStop(action: String, baseURL: URL, jsonOutput: Bool) async throws -> Int32 {
|
||||||
|
let url = baseURL.appendingPathComponent("/\(action)")
|
||||||
|
let res = try await self.httpJSON(method: "POST", url: url, timeoutInterval: 15.0)
|
||||||
|
self.printResult(jsonOutput: jsonOutput, res: res)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleTabs(baseURL: URL, jsonOutput: Bool) async throws -> Int32 {
|
||||||
|
let url = baseURL.appendingPathComponent("/tabs")
|
||||||
|
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 3.0)
|
||||||
|
if jsonOutput {
|
||||||
|
self.printJSON(ok: true, result: res)
|
||||||
|
} else {
|
||||||
|
self.printTabs(res: res)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleOpen(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||||
|
guard let urlString = options.rest.first, !urlString.isEmpty else {
|
||||||
|
self.printHelp()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
let url = baseURL.appendingPathComponent("/tabs/open")
|
||||||
|
let res = try await self.httpJSON(
|
||||||
|
method: "POST",
|
||||||
|
url: url,
|
||||||
|
body: ["url": urlString],
|
||||||
|
timeoutInterval: 15.0
|
||||||
|
)
|
||||||
|
self.printResult(jsonOutput: jsonOutput, res: res)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleFocus(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||||
|
guard let id = options.rest.first, !id.isEmpty else {
|
||||||
|
self.printHelp()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
let url = baseURL.appendingPathComponent("/tabs/focus")
|
||||||
|
let res = try await self.httpJSON(
|
||||||
|
method: "POST",
|
||||||
|
url: url,
|
||||||
|
body: ["targetId": id],
|
||||||
|
timeoutInterval: 5.0
|
||||||
|
)
|
||||||
|
self.printResult(jsonOutput: jsonOutput, res: res)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleClose(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||||
|
guard let id = options.rest.first, !id.isEmpty else {
|
||||||
|
self.printHelp()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
let url = baseURL.appendingPathComponent("/tabs/\(id)")
|
||||||
|
let res = try await self.httpJSON(method: "DELETE", url: url, timeoutInterval: 5.0)
|
||||||
|
self.printResult(jsonOutput: jsonOutput, res: res)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleScreenshot(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||||
|
var url = baseURL.appendingPathComponent("/screenshot")
|
||||||
|
var items: [URLQueryItem] = []
|
||||||
|
if let targetId = options.targetId, !targetId.isEmpty {
|
||||||
|
items.append(URLQueryItem(name: "targetId", value: targetId))
|
||||||
|
}
|
||||||
|
if options.fullPage {
|
||||||
|
items.append(URLQueryItem(name: "fullPage", value: "1"))
|
||||||
|
}
|
||||||
|
if !items.isEmpty {
|
||||||
|
url = self.withQuery(url, items: items)
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
|
||||||
|
if jsonOutput {
|
||||||
|
self.printJSON(ok: true, result: res)
|
||||||
|
} else if let path = res["path"] as? String, !path.isEmpty {
|
||||||
|
print("MEDIA:\(path)")
|
||||||
|
} else {
|
||||||
|
self.printResult(jsonOutput: false, res: res)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleEval(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||||
|
if options.jsStdin, options.jsFile != nil {
|
||||||
|
self.printHelp()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = try self.resolveEvalCode(options: options)
|
||||||
|
if code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
self.printHelp()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = baseURL.appendingPathComponent("/eval")
|
||||||
|
let res = try await self.httpJSON(
|
||||||
|
method: "POST",
|
||||||
|
url: url,
|
||||||
|
body: [
|
||||||
|
"js": code,
|
||||||
|
"targetId": options.targetId ?? "",
|
||||||
|
"await": options.awaitPromise,
|
||||||
|
],
|
||||||
|
timeoutInterval: 15.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
self.printJSON(ok: true, result: res)
|
||||||
|
} else {
|
||||||
|
self.printEval(res: res)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveEvalCode(options: RunOptions) throws -> String {
|
||||||
|
if let jsFile = options.jsFile, !jsFile.isEmpty {
|
||||||
|
return try String(contentsOfFile: jsFile, encoding: .utf8)
|
||||||
|
}
|
||||||
|
if options.jsStdin {
|
||||||
|
let data = FileHandle.standardInput.readDataToEndOfFile()
|
||||||
|
return String(data: data, encoding: .utf8) ?? ""
|
||||||
|
}
|
||||||
|
if let js = options.js, !js.isEmpty {
|
||||||
|
return js
|
||||||
|
}
|
||||||
|
if !options.rest.isEmpty {
|
||||||
|
return options.rest.joined(separator: " ")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleQuery(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||||
|
let sel = (options.selector ?? options.rest.first ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if sel.isEmpty {
|
||||||
|
self.printHelp()
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = baseURL.appendingPathComponent("/query")
|
||||||
|
var items: [URLQueryItem] = [URLQueryItem(name: "selector", value: sel)]
|
||||||
|
if let targetId = options.targetId, !targetId.isEmpty {
|
||||||
|
items.append(URLQueryItem(name: "targetId", value: targetId))
|
||||||
|
}
|
||||||
|
if let limit = options.limit, limit > 0 {
|
||||||
|
items.append(URLQueryItem(name: "limit", value: String(limit)))
|
||||||
|
}
|
||||||
|
url = self.withQuery(url, items: items)
|
||||||
|
|
||||||
|
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 15.0)
|
||||||
|
if jsonOutput || options.format == "json" {
|
||||||
|
self.printJSON(ok: true, result: res)
|
||||||
|
} else {
|
||||||
|
self.printQuery(res: res)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleDOM(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||||
|
let fmt = (options.format == "text") ? "text" : "html"
|
||||||
|
var url = baseURL.appendingPathComponent("/dom")
|
||||||
|
var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)]
|
||||||
|
if let targetId = options.targetId, !targetId.isEmpty {
|
||||||
|
items.append(URLQueryItem(name: "targetId", value: targetId))
|
||||||
|
}
|
||||||
|
if let selector = options.selector?.trimmingCharacters(in: .whitespacesAndNewlines), !selector.isEmpty {
|
||||||
|
items.append(URLQueryItem(name: "selector", value: selector))
|
||||||
|
}
|
||||||
|
if let maxChars = options.maxChars, maxChars > 0 {
|
||||||
|
items.append(URLQueryItem(name: "maxChars", value: String(maxChars)))
|
||||||
|
}
|
||||||
|
url = self.withQuery(url, items: items)
|
||||||
|
|
||||||
|
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
|
||||||
|
let text = (res["text"] as? String) ?? ""
|
||||||
|
if let out = options.outPath, !out.isEmpty {
|
||||||
|
try Data(text.utf8).write(to: URL(fileURLWithPath: out))
|
||||||
|
if jsonOutput {
|
||||||
|
self.printJSON(ok: true, result: ["ok": true, "out": out])
|
||||||
|
} else {
|
||||||
|
print(out)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
self.printJSON(ok: true, result: res)
|
||||||
|
} else {
|
||||||
|
print(text)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleSnapshot(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 {
|
||||||
|
let fmt = (options.format == "domSnapshot") ? "domSnapshot" : "aria"
|
||||||
|
var url = baseURL.appendingPathComponent("/snapshot")
|
||||||
|
var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)]
|
||||||
|
if let targetId = options.targetId, !targetId.isEmpty {
|
||||||
|
items.append(URLQueryItem(name: "targetId", value: targetId))
|
||||||
|
}
|
||||||
|
if let limit = options.limit, limit > 0 {
|
||||||
|
items.append(URLQueryItem(name: "limit", value: String(limit)))
|
||||||
|
}
|
||||||
|
url = self.withQuery(url, items: items)
|
||||||
|
|
||||||
|
let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0)
|
||||||
|
if let out = options.outPath, !out.isEmpty {
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: res, options: [.prettyPrinted])
|
||||||
|
try data.write(to: URL(fileURLWithPath: out))
|
||||||
|
if jsonOutput {
|
||||||
|
self.printJSON(ok: true, result: ["ok": true, "out": out])
|
||||||
|
} else {
|
||||||
|
print(out)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if jsonOutput || fmt == "domSnapshot" {
|
||||||
|
self.printJSON(ok: true, result: res)
|
||||||
|
} else {
|
||||||
|
self.printSnapshotAria(res: res)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
private struct BrowserConfig {
|
private struct BrowserConfig {
|
||||||
let enabled: Bool
|
let enabled: Bool
|
||||||
let controlUrl: String
|
let controlUrl: String
|
||||||
|
|||||||
@@ -58,261 +58,276 @@ struct ClawdisCLI {
|
|||||||
enum Kind {
|
enum Kind {
|
||||||
case generic
|
case generic
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// swiftlint:disable cyclomatic_complexity
|
private static func parseCommandLine(args: [String]) throws -> ParsedCLIRequest {
|
||||||
private static func parseCommandLine(args: [String]) throws -> ParsedCLIRequest {
|
var args = args
|
||||||
var args = args
|
guard !args.isEmpty else { throw CLIError.help }
|
||||||
guard let command = args.first else { throw CLIError.help }
|
let command = args.removeFirst()
|
||||||
args = Array(args.dropFirst())
|
|
||||||
|
|
||||||
switch command {
|
switch command {
|
||||||
case "--help", "-h", "help":
|
case "--help", "-h", "help":
|
||||||
throw CLIError.help
|
throw CLIError.help
|
||||||
|
|
||||||
case "--version", "-V", "version":
|
case "--version", "-V", "version":
|
||||||
throw CLIError.version
|
throw CLIError.version
|
||||||
|
|
||||||
case "notify":
|
case "notify":
|
||||||
var title: String?
|
return try self.parseNotify(args: &args)
|
||||||
var body: String?
|
|
||||||
var sound: String?
|
|
||||||
var priority: NotificationPriority?
|
|
||||||
var delivery: NotificationDelivery?
|
|
||||||
while !args.isEmpty {
|
|
||||||
let arg = args.removeFirst()
|
|
||||||
switch arg {
|
|
||||||
case "--title": title = args.popFirst()
|
|
||||||
case "--body": body = args.popFirst()
|
|
||||||
case "--sound": sound = args.popFirst()
|
|
||||||
case "--priority":
|
|
||||||
if let val = args.popFirst(), let p = NotificationPriority(rawValue: val) { priority = p }
|
|
||||||
case "--delivery":
|
|
||||||
if let val = args.popFirst(), let d = NotificationDelivery(rawValue: val) { delivery = d }
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard let t = title, let b = body else { throw CLIError.help }
|
|
||||||
return ParsedCLIRequest(
|
|
||||||
request: .notify(title: t, body: b, sound: sound, priority: priority, delivery: delivery),
|
|
||||||
kind: .generic)
|
|
||||||
|
|
||||||
case "ensure-permissions":
|
case "ensure-permissions":
|
||||||
var caps: [Capability] = []
|
return self.parseEnsurePermissions(args: &args)
|
||||||
var interactive = false
|
|
||||||
while !args.isEmpty {
|
|
||||||
let arg = args.removeFirst()
|
|
||||||
switch arg {
|
|
||||||
case "--cap":
|
|
||||||
if let val = args.popFirst(), let cap = Capability(rawValue: val) { caps.append(cap) }
|
|
||||||
case "--interactive": interactive = true
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if caps.isEmpty { caps = Capability.allCases }
|
|
||||||
return ParsedCLIRequest(request: .ensurePermissions(caps, interactive: interactive), kind: .generic)
|
|
||||||
|
|
||||||
case "run":
|
case "run":
|
||||||
var cwd: String?
|
return self.parseRunShell(args: &args)
|
||||||
var env: [String: String] = [:]
|
|
||||||
var timeout: Double?
|
|
||||||
var needsSR = false
|
|
||||||
var cmd: [String] = []
|
|
||||||
while !args.isEmpty {
|
|
||||||
let arg = args.removeFirst()
|
|
||||||
switch arg {
|
|
||||||
case "--cwd": cwd = args.popFirst()
|
|
||||||
|
|
||||||
case "--env":
|
case "status":
|
||||||
if let pair = args.popFirst(), let eq = pair.firstIndex(of: "=") {
|
return ParsedCLIRequest(request: .status, kind: .generic)
|
||||||
let k = String(pair[..<eq]); let v = String(pair[pair.index(after: eq)...]); env[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
case "--timeout": if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl }
|
case "rpc-status":
|
||||||
|
return ParsedCLIRequest(request: .rpcStatus, kind: .generic)
|
||||||
|
|
||||||
case "--needs-screen-recording": needsSR = true
|
case "agent":
|
||||||
|
return try self.parseAgent(args: &args)
|
||||||
|
|
||||||
default:
|
case "node":
|
||||||
cmd.append(arg)
|
return try self.parseNode(args: &args)
|
||||||
}
|
|
||||||
}
|
|
||||||
return ParsedCLIRequest(request: .runShell(
|
|
||||||
command: cmd,
|
|
||||||
cwd: cwd,
|
|
||||||
env: env.isEmpty ? nil : env,
|
|
||||||
timeoutSec: timeout,
|
|
||||||
needsScreenRecording: needsSR), kind: .generic)
|
|
||||||
|
|
||||||
case "status":
|
case "canvas":
|
||||||
return ParsedCLIRequest(request: .status, kind: .generic)
|
return try self.parseCanvas(args: &args)
|
||||||
|
|
||||||
case "rpc-status":
|
default:
|
||||||
return ParsedCLIRequest(request: .rpcStatus, kind: .generic)
|
throw CLIError.help
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "agent":
|
private static func parseNotify(args: inout [String]) throws -> ParsedCLIRequest {
|
||||||
var message: String?
|
var title: String?
|
||||||
var thinking: String?
|
var body: String?
|
||||||
var session: String?
|
var sound: String?
|
||||||
var deliver = false
|
var priority: NotificationPriority?
|
||||||
var to: String?
|
var delivery: NotificationDelivery?
|
||||||
|
while !args.isEmpty {
|
||||||
|
let arg = args.removeFirst()
|
||||||
|
switch arg {
|
||||||
|
case "--title": title = args.popFirst()
|
||||||
|
case "--body": body = args.popFirst()
|
||||||
|
case "--sound": sound = args.popFirst()
|
||||||
|
case "--priority":
|
||||||
|
if let val = args.popFirst(), let p = NotificationPriority(rawValue: val) { priority = p }
|
||||||
|
case "--delivery":
|
||||||
|
if let val = args.popFirst(), let d = NotificationDelivery(rawValue: val) { delivery = d }
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let t = title, let b = body else { throw CLIError.help }
|
||||||
|
return ParsedCLIRequest(
|
||||||
|
request: .notify(title: t, body: b, sound: sound, priority: priority, delivery: delivery),
|
||||||
|
kind: .generic
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
while !args.isEmpty {
|
private static func parseEnsurePermissions(args: inout [String]) -> ParsedCLIRequest {
|
||||||
let arg = args.removeFirst()
|
var caps: [Capability] = []
|
||||||
switch arg {
|
var interactive = false
|
||||||
case "--message": message = args.popFirst()
|
while !args.isEmpty {
|
||||||
case "--thinking": thinking = args.popFirst()
|
let arg = args.removeFirst()
|
||||||
case "--session": session = args.popFirst()
|
switch arg {
|
||||||
case "--deliver": deliver = true
|
case "--cap":
|
||||||
case "--to": to = args.popFirst()
|
if let val = args.popFirst(), let cap = Capability(rawValue: val) { caps.append(cap) }
|
||||||
default:
|
case "--interactive":
|
||||||
// Support bare message as last argument
|
interactive = true
|
||||||
if message == nil {
|
default:
|
||||||
message = arg
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if caps.isEmpty { caps = Capability.allCases }
|
||||||
|
return ParsedCLIRequest(request: .ensurePermissions(caps, interactive: interactive), kind: .generic)
|
||||||
|
}
|
||||||
|
|
||||||
guard let message else { throw CLIError.help }
|
private static func parseRunShell(args: inout [String]) -> ParsedCLIRequest {
|
||||||
return ParsedCLIRequest(
|
var cwd: String?
|
||||||
request: .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to),
|
var env: [String: String] = [:]
|
||||||
kind: .generic)
|
var timeout: Double?
|
||||||
|
var needsSR = false
|
||||||
|
var cmd: [String] = []
|
||||||
|
while !args.isEmpty {
|
||||||
|
let arg = args.removeFirst()
|
||||||
|
switch arg {
|
||||||
|
case "--cwd":
|
||||||
|
cwd = args.popFirst()
|
||||||
|
case "--env":
|
||||||
|
if let pair = args.popFirst() {
|
||||||
|
self.parseEnvPair(pair, into: &env)
|
||||||
|
}
|
||||||
|
case "--timeout":
|
||||||
|
if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl }
|
||||||
|
case "--needs-screen-recording":
|
||||||
|
needsSR = true
|
||||||
|
default:
|
||||||
|
cmd.append(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ParsedCLIRequest(
|
||||||
|
request: .runShell(
|
||||||
|
command: cmd,
|
||||||
|
cwd: cwd,
|
||||||
|
env: env.isEmpty ? nil : env,
|
||||||
|
timeoutSec: timeout,
|
||||||
|
needsScreenRecording: needsSR
|
||||||
|
),
|
||||||
|
kind: .generic
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case "node":
|
private static func parseEnvPair(_ pair: String, into env: inout [String: String]) {
|
||||||
guard let sub = args.first else { throw CLIError.help }
|
guard let eq = pair.firstIndex(of: "=") else { return }
|
||||||
args = Array(args.dropFirst())
|
let key = String(pair[..<eq])
|
||||||
|
let value = String(pair[pair.index(after: eq)...])
|
||||||
|
env[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
switch sub {
|
private static func parseAgent(args: inout [String]) throws -> ParsedCLIRequest {
|
||||||
case "list":
|
var message: String?
|
||||||
return ParsedCLIRequest(request: .nodeList, kind: .generic)
|
var thinking: String?
|
||||||
|
var session: String?
|
||||||
|
var deliver = false
|
||||||
|
var to: String?
|
||||||
|
|
||||||
case "invoke":
|
while !args.isEmpty {
|
||||||
var nodeId: String?
|
let arg = args.removeFirst()
|
||||||
var command: String?
|
switch arg {
|
||||||
var paramsJSON: String?
|
case "--message": message = args.popFirst()
|
||||||
while !args.isEmpty {
|
case "--thinking": thinking = args.popFirst()
|
||||||
let arg = args.removeFirst()
|
case "--session": session = args.popFirst()
|
||||||
switch arg {
|
case "--deliver": deliver = true
|
||||||
case "--node": nodeId = args.popFirst()
|
case "--to": to = args.popFirst()
|
||||||
case "--command": command = args.popFirst()
|
default:
|
||||||
case "--params-json": paramsJSON = args.popFirst()
|
if message == nil {
|
||||||
default: break
|
message = arg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard let nodeId, let command else { throw CLIError.help }
|
}
|
||||||
return ParsedCLIRequest(
|
|
||||||
request: .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON),
|
|
||||||
kind: .generic)
|
|
||||||
|
|
||||||
default:
|
guard let message else { throw CLIError.help }
|
||||||
throw CLIError.help
|
return ParsedCLIRequest(
|
||||||
}
|
request: .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to),
|
||||||
|
kind: .generic
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case "canvas":
|
private static func parseNode(args: inout [String]) throws -> ParsedCLIRequest {
|
||||||
guard let sub = args.first else { throw CLIError.help }
|
guard let sub = args.popFirst() else { throw CLIError.help }
|
||||||
args = Array(args.dropFirst())
|
switch sub {
|
||||||
|
case "list":
|
||||||
|
return ParsedCLIRequest(request: .nodeList, kind: .generic)
|
||||||
|
case "invoke":
|
||||||
|
var nodeId: String?
|
||||||
|
var command: String?
|
||||||
|
var paramsJSON: String?
|
||||||
|
while !args.isEmpty {
|
||||||
|
let arg = args.removeFirst()
|
||||||
|
switch arg {
|
||||||
|
case "--node": nodeId = args.popFirst()
|
||||||
|
case "--command": command = args.popFirst()
|
||||||
|
case "--params-json": paramsJSON = args.popFirst()
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let nodeId, let command else { throw CLIError.help }
|
||||||
|
return ParsedCLIRequest(
|
||||||
|
request: .nodeInvoke(nodeId: nodeId, command: command, paramsJSON: paramsJSON),
|
||||||
|
kind: .generic
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
throw CLIError.help
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch sub {
|
private static func parseCanvas(args: inout [String]) throws -> ParsedCLIRequest {
|
||||||
case "show":
|
guard let sub = args.popFirst() else { throw CLIError.help }
|
||||||
var session = "main"
|
switch sub {
|
||||||
var path: String?
|
case "show":
|
||||||
var x: Double?
|
var session = "main"
|
||||||
var y: Double?
|
var path: String?
|
||||||
var width: Double?
|
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
|
||||||
var height: Double?
|
return ParsedCLIRequest(
|
||||||
while !args.isEmpty {
|
request: .canvasShow(session: session, path: path, placement: placement),
|
||||||
let arg = args.removeFirst()
|
kind: .generic
|
||||||
switch arg {
|
)
|
||||||
case "--session": session = args.popFirst() ?? session
|
case "hide":
|
||||||
case "--path": path = args.popFirst()
|
var session = "main"
|
||||||
case "--x": x = args.popFirst().flatMap(Double.init)
|
while !args.isEmpty {
|
||||||
case "--y": y = args.popFirst().flatMap(Double.init)
|
let arg = args.removeFirst()
|
||||||
case "--width": width = args.popFirst().flatMap(Double.init)
|
switch arg {
|
||||||
case "--height": height = args.popFirst().flatMap(Double.init)
|
case "--session": session = args.popFirst() ?? session
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let placement = (x != nil || y != nil || width != nil || height != nil)
|
return ParsedCLIRequest(request: .canvasHide(session: session), kind: .generic)
|
||||||
? CanvasPlacement(x: x, y: y, width: width, height: height)
|
case "goto":
|
||||||
: nil
|
var session = "main"
|
||||||
return ParsedCLIRequest(
|
var path: String?
|
||||||
request: .canvasShow(session: session, path: path, placement: placement),
|
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
|
||||||
kind: .generic)
|
guard let path else { throw CLIError.help }
|
||||||
|
return ParsedCLIRequest(
|
||||||
|
request: .canvasGoto(session: session, path: path, placement: placement),
|
||||||
|
kind: .generic
|
||||||
|
)
|
||||||
|
case "eval":
|
||||||
|
var session = "main"
|
||||||
|
var js: String?
|
||||||
|
while !args.isEmpty {
|
||||||
|
let arg = args.removeFirst()
|
||||||
|
switch arg {
|
||||||
|
case "--session": session = args.popFirst() ?? session
|
||||||
|
case "--js": js = args.popFirst()
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let js else { throw CLIError.help }
|
||||||
|
return ParsedCLIRequest(request: .canvasEval(session: session, javaScript: js), kind: .generic)
|
||||||
|
case "snapshot":
|
||||||
|
var session = "main"
|
||||||
|
var outPath: String?
|
||||||
|
while !args.isEmpty {
|
||||||
|
let arg = args.removeFirst()
|
||||||
|
switch arg {
|
||||||
|
case "--session": session = args.popFirst() ?? session
|
||||||
|
case "--out": outPath = args.popFirst()
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ParsedCLIRequest(request: .canvasSnapshot(session: session, outPath: outPath), kind: .generic)
|
||||||
|
default:
|
||||||
|
throw CLIError.help
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "hide":
|
private static func parseCanvasPlacement(
|
||||||
var session = "main"
|
args: inout [String],
|
||||||
while !args.isEmpty {
|
session: inout String,
|
||||||
let arg = args.removeFirst()
|
path: inout String?
|
||||||
switch arg {
|
) -> CanvasPlacement? {
|
||||||
case "--session": session = args.popFirst() ?? session
|
var x: Double?
|
||||||
default: break
|
var y: Double?
|
||||||
}
|
var width: Double?
|
||||||
}
|
var height: Double?
|
||||||
return ParsedCLIRequest(request: .canvasHide(session: session), kind: .generic)
|
while !args.isEmpty {
|
||||||
|
let arg = args.removeFirst()
|
||||||
case "goto":
|
switch arg {
|
||||||
var session = "main"
|
case "--session": session = args.popFirst() ?? session
|
||||||
var path: String?
|
case "--path": path = args.popFirst()
|
||||||
var x: Double?
|
case "--x": x = args.popFirst().flatMap(Double.init)
|
||||||
var y: Double?
|
case "--y": y = args.popFirst().flatMap(Double.init)
|
||||||
var width: Double?
|
case "--width": width = args.popFirst().flatMap(Double.init)
|
||||||
var height: Double?
|
case "--height": height = args.popFirst().flatMap(Double.init)
|
||||||
while !args.isEmpty {
|
default: break
|
||||||
let arg = args.removeFirst()
|
}
|
||||||
switch arg {
|
}
|
||||||
case "--session": session = args.popFirst() ?? session
|
if x == nil, y == nil, width == nil, height == nil { return nil }
|
||||||
case "--path": path = args.popFirst()
|
return CanvasPlacement(x: x, y: y, width: width, height: height)
|
||||||
case "--x": x = args.popFirst().flatMap(Double.init)
|
}
|
||||||
case "--y": y = args.popFirst().flatMap(Double.init)
|
|
||||||
case "--width": width = args.popFirst().flatMap(Double.init)
|
|
||||||
case "--height": height = args.popFirst().flatMap(Double.init)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard let path else { throw CLIError.help }
|
|
||||||
let placement = (x != nil || y != nil || width != nil || height != nil)
|
|
||||||
? CanvasPlacement(x: x, y: y, width: width, height: height)
|
|
||||||
: nil
|
|
||||||
return ParsedCLIRequest(
|
|
||||||
request: .canvasGoto(session: session, path: path, placement: placement),
|
|
||||||
kind: .generic)
|
|
||||||
|
|
||||||
case "eval":
|
|
||||||
var session = "main"
|
|
||||||
var js: String?
|
|
||||||
while !args.isEmpty {
|
|
||||||
let arg = args.removeFirst()
|
|
||||||
switch arg {
|
|
||||||
case "--session": session = args.popFirst() ?? session
|
|
||||||
case "--js": js = args.popFirst()
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard let js else { throw CLIError.help }
|
|
||||||
return ParsedCLIRequest(request: .canvasEval(session: session, javaScript: js), kind: .generic)
|
|
||||||
|
|
||||||
case "snapshot":
|
|
||||||
var session = "main"
|
|
||||||
var outPath: String?
|
|
||||||
while !args.isEmpty {
|
|
||||||
let arg = args.removeFirst()
|
|
||||||
switch arg {
|
|
||||||
case "--session": session = args.popFirst() ?? session
|
|
||||||
case "--out": outPath = args.popFirst()
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ParsedCLIRequest(request: .canvasSnapshot(session: session, outPath: outPath), kind: .generic)
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw CLIError.help
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw CLIError.help
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// swiftlint:enable cyclomatic_complexity
|
|
||||||
|
|
||||||
private static func printText(parsed: ParsedCLIRequest, response: Response) throws {
|
private static func printText(parsed: ParsedCLIRequest, response: Response) throws {
|
||||||
guard response.ok else {
|
guard response.ok else {
|
||||||
@@ -491,13 +506,13 @@ struct ClawdisCLI {
|
|||||||
_NSGetExecutablePath(ptr.baseAddress, &size)
|
_NSGetExecutablePath(ptr.baseAddress, &size)
|
||||||
}
|
}
|
||||||
guard result2 == 0 else { return nil }
|
guard result2 == 0 else { return nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
let nulIndex = buffer.firstIndex(of: 0) ?? buffer.count
|
let nulIndex = buffer.firstIndex(of: 0) ?? buffer.count
|
||||||
let bytes = buffer.prefix(nulIndex).map { UInt8(bitPattern: $0) }
|
let bytes = buffer.prefix(nulIndex).map { UInt8(bitPattern: $0) }
|
||||||
let path = String(decoding: bytes, as: UTF8.self)
|
guard let path = String(bytes: bytes, encoding: .utf8) else { return nil }
|
||||||
return URL(fileURLWithPath: path).resolvingSymlinksInPath()
|
return URL(fileURLWithPath: path).resolvingSymlinksInPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func loadPackageJSONVersion() -> String? {
|
private static func loadPackageJSONVersion() -> String? {
|
||||||
guard let exeURL = self.resolveExecutableURL() else { return nil }
|
guard let exeURL = self.resolveExecutableURL() else { return nil }
|
||||||
|
|||||||
@@ -323,16 +323,17 @@ enum UICLI {
|
|||||||
"screenshotPath": screenshotPath,
|
"screenshotPath": screenshotPath,
|
||||||
"result": self.toJSONObject(detection),
|
"result": self.toJSONObject(detection),
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
FileHandle.standardOutput.write(Data((screenshotPath + "\n").utf8))
|
FileHandle.standardOutput.write(Data((screenshotPath + "\n").utf8))
|
||||||
for el in detection.elements.all {
|
for el in detection.elements.all {
|
||||||
let b = el.bounds
|
let b = el.bounds
|
||||||
let label = (el.label ?? el.value ?? "").replacingOccurrences(of: "\n", with: " ")
|
let label = (el.label ?? el.value ?? "").replacingOccurrences(of: "\n", with: " ")
|
||||||
let line =
|
let coords = "\(Int(b.origin.x)),\(Int(b.origin.y))"
|
||||||
"\(el.id)\t\(el.type)\t\(Int(b.origin.x)),\(Int(b.origin.y)) \(Int(b.size.width))x\(Int(b.size.height))\t\(label)\n"
|
let size = "\(Int(b.size.width))x\(Int(b.size.height))"
|
||||||
FileHandle.standardOutput.write(Data(line.utf8))
|
let line = "\(el.id)\t\(el.type)\t\(coords) \(size)\t\(label)\n"
|
||||||
}
|
FileHandle.standardOutput.write(Data(line.utf8))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,14 +522,16 @@ enum UICLI {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
return try await client.getMostRecentSnapshot(applicationBundleId: resolvedBundle)
|
return try await client.getMostRecentSnapshot(applicationBundleId: resolvedBundle)
|
||||||
} catch {
|
} catch {
|
||||||
throw NSError(domain: "clawdis.ui", code: 6, userInfo: [
|
let command = "clawdis-mac ui see --bundle-id \(resolvedBundle)"
|
||||||
NSLocalizedDescriptionKey: "No recent snapshot for \(resolvedBundle). Run `clawdis-mac ui see --bundle-id \(resolvedBundle)` first.",
|
let help = "No recent snapshot for \(resolvedBundle). Run `\(command)` first."
|
||||||
])
|
throw NSError(domain: "clawdis.ui", code: 6, userInfo: [
|
||||||
}
|
NSLocalizedDescriptionKey: help,
|
||||||
}
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - IO helpers
|
// MARK: - IO helpers
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user