chore(swift): run swiftformat and clear swiftlint

This commit is contained in:
Peter Steinberger
2025-12-13 19:53:17 +00:00
parent 39c232548c
commit 6143338116
18 changed files with 713 additions and 723 deletions

View File

@@ -51,8 +51,7 @@ actor BridgeClient {
nodeId: hello.nodeId,
displayName: hello.displayName,
platform: hello.platform,
version: hello.version
),
version: hello.version),
over: connection)
onStatus?("Waiting for approval…")

View File

@@ -42,36 +42,36 @@ final class NodeAppModel: ObservableObject {
}
}
func setVoiceWakeEnabled(_ enabled: Bool) {
self.voiceWake.setEnabled(enabled)
}
func setVoiceWakeEnabled(_ enabled: Bool) {
self.voiceWake.setEnabled(enabled)
}
func connectToBridge(
endpoint: NWEndpoint,
hello: BridgeHello)
{
self.bridgeTask?.cancel()
self.bridgeStatusText = "Connecting…"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
func connectToBridge(
endpoint: NWEndpoint,
hello: BridgeHello)
{
self.bridgeTask?.cancel()
self.bridgeStatusText = "Connecting…"
self.bridgeServerName = nil
self.bridgeRemoteAddress = nil
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
self.bridgeTask = Task {
do {
try await self.bridge.connect(
endpoint: endpoint,
hello: hello,
onConnected: { [weak self] serverName in
guard let self else { return }
await MainActor.run {
self.bridgeStatusText = "Connected"
do {
try await self.bridge.connect(
endpoint: endpoint,
hello: hello,
onConnected: { [weak self] serverName in
guard let self else { return }
await MainActor.run {
self.bridgeStatusText = "Connected"
self.bridgeServerName = serverName
}
if let addr = await self.bridge.currentRemoteAddress() {
await MainActor.run {
self.bridgeRemoteAddress = addr
}
}
}
}
},
onInvoke: { [weak self] req in
guard let self else {
@@ -110,20 +110,20 @@ final class NodeAppModel: ObservableObject {
self.connectedBridgeID = nil
}
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
struct Payload: Codable {
var text: String
var sessionKey: String?
}
let payload = Payload(text: text, sessionKey: sessionKey)
let data = try JSONEncoder().encode(payload)
guard let json = String(bytes: data, encoding: .utf8) else {
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 sendVoiceTranscript(text: String, sessionKey: String?) async throws {
struct Payload: Codable {
var text: String
var sessionKey: String?
}
let payload = Payload(text: text, sessionKey: sessionKey)
let data = try JSONEncoder().encode(payload)
guard let json = String(bytes: data, encoding: .utf8) else {
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 {
guard let route = DeepLinkParser.parse(url) else { return }
@@ -163,16 +163,16 @@ final class NodeAppModel: ObservableObject {
])
}
// iOS bridge forwards to the gateway; no local auth prompts here.
// (Key-based unattended auth is handled on macOS for clawdis:// links.)
let data = try JSONEncoder().encode(link)
guard let json = String(bytes: data, encoding: .utf8) else {
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)
}
// iOS bridge forwards to the gateway; no local auth prompts here.
// (Key-based unattended auth is handled on macOS for clawdis:// links.)
let data = try JSONEncoder().encode(link)
guard let json = String(bytes: data, encoding: .utf8) else {
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 {
if case .connected = await self.bridge.state { return true }
@@ -243,13 +243,13 @@ final class NodeAppModel: ObservableObject {
return try JSONDecoder().decode(type, from: data)
}
private static func encodePayload(_ obj: some Encodable) throws -> String {
let data = try JSONEncoder().encode(obj)
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
}
private static func encodePayload(_ obj: some Encodable) throws -> String {
let data = try JSONEncoder().encode(obj)
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
}
}

View File

@@ -105,19 +105,19 @@ final class ScreenController: ObservableObject {
#000;
overflow: hidden;
}
body::before {
content:"";
position: fixed;
inset: -20%;
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(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
transparent 1px, transparent 48px);
transform: rotate(-7deg);
opacity: 0.55;
pointer-events: none;
}
body::before {
content:"";
position: fixed;
inset: -20%;
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(90deg, rgba(255,255,255,0.02) 0, rgba(255,255,255,0.02) 1px,
transparent 1px, transparent 48px);
transform: rotate(-7deg);
opacity: 0.55;
pointer-events: none;
}
canvas {
display:block;
width:100vw;

View File

@@ -1,5 +1,13 @@
import ClawdisKit
import SwiftUI
@MainActor
private final class ConnectStatusStore: ObservableObject {
@Published var text: String?
}
extension ConnectStatusStore: @unchecked Sendable {}
struct SettingsTab: View {
@EnvironmentObject private var appModel: NodeAppModel
@Environment(\.dismiss) private var dismiss
@@ -8,7 +16,7 @@ struct SettingsTab: View {
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
@StateObject private var discovery = BridgeDiscoveryModel()
@State private var connectStatus: String?
@StateObject private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String?
@State private var didAutoConnect = false
@@ -47,8 +55,8 @@ struct SettingsTab: View {
self.bridgeList(showing: .all)
}
if let connectStatus {
Text(connectStatus)
if let text = self.connectStatus.text {
Text(text)
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -77,22 +85,20 @@ struct SettingsTab: View {
guard let existing, !existing.isEmpty else { return }
guard let target = self.pickAutoConnectBridge(from: newValue) else { return }
self.didAutoConnect = true
self.preferredBridgeStableID = target.stableID
self.appModel.connectToBridge(
endpoint: target.endpoint,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existing,
platform: self.platformString(),
version: self.appVersion()
)
)
self.connectStatus = nil
}
self.didAutoConnect = true
self.preferredBridgeStableID = target.stableID
self.appModel.connectToBridge(
endpoint: target.endpoint,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existing,
platform: self.platformString(),
version: self.appVersion()))
self.connectStatus.text = nil
}
.onChange(of: self.appModel.bridgeServerName) { _, _ in
self.connectStatus = nil
self.connectStatus.text = nil
}
}
}
@@ -173,22 +179,21 @@ struct SettingsTab: View {
existing :
nil
let hello = BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existingToken,
platform: self.platformString(),
version: self.appVersion()
)
let token = try await BridgeClient().pairAndHello(
endpoint: bridge.endpoint,
hello: hello,
onStatus: { status in
Task { @MainActor in
self.connectStatus = status
}
}
)
let hello = BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: existingToken,
platform: self.platformString(),
version: self.appVersion())
let token = try await BridgeClient().pairAndHello(
endpoint: bridge.endpoint,
hello: hello,
onStatus: { status in
let store = self.connectStatus
Task { @MainActor in
store.text = status
}
})
if !token.isEmpty, token != existingToken {
_ = KeychainStore.saveString(
@@ -197,19 +202,17 @@ struct SettingsTab: View {
account: self.keychainAccount())
}
self.appModel.connectToBridge(
endpoint: bridge.endpoint,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: token,
platform: self.platformString(),
version: self.appVersion()
)
)
self.appModel.connectToBridge(
endpoint: bridge.endpoint,
hello: BridgeHello(
nodeId: self.instanceId,
displayName: self.displayName,
token: token,
platform: self.platformString(),
version: self.appVersion()))
} catch {
self.connectStatus = "Failed: \(error.localizedDescription)"
self.connectStatus.text = "Failed: \(error.localizedDescription)"
}
}

View File

@@ -87,22 +87,22 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
return self.html("Forbidden", title: "Canvas: 403")
}
do {
let data = try Data(contentsOf: standardizedFile)
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
let servedPath = standardizedFile.path
canvasLogger.debug(
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)")
return CanvasResponse(mime: mime, data: data)
} catch {
let failedPath = standardizedFile.path
let errorText = error.localizedDescription
canvasLogger
.error(
"failed reading \(failedPath, privacy: .public): \(errorText, privacy: .public)")
return self.html("Failed to read file.", title: "Canvas error")
}
}
do {
let data = try Data(contentsOf: standardizedFile)
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
let servedPath = standardizedFile.path
canvasLogger.debug(
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)")
return CanvasResponse(mime: mime, data: data)
} catch {
let failedPath = standardizedFile.path
let errorText = error.localizedDescription
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? {
let fm = FileManager.default

View File

@@ -4,6 +4,11 @@ import SwiftUI
struct ConfigSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview
private let labelColumnWidth: CGFloat = 120
private static let browserAttachOnlyHelp =
"When enabled, the browser server will only connect if the clawd browser is already running."
private static let browserProfileNote =
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
+ "so it wont interfere with your daily browser."
@State private var configModel: String = ""
@State private var customModel: String = ""
@State private var configSaving = false
@@ -203,16 +208,12 @@ struct ConfigSettings: View {
.toggleStyle(.checkbox)
.disabled(!self.browserEnabled)
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
.help(
"When enabled, the browser server will only connect if the clawd browser is already running."
)
.help(Self.browserAttachOnlyHelp)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
"Clawd uses a separate Chrome profile and ports (default 18791/18792) so it wont interfere with your daily browser."
)
Text(Self.browserProfileNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)

View File

@@ -144,15 +144,15 @@ final class ControlChannel: ObservableObject {
}
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
if let urlErr = error as? URLError,
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
{
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)"
}
if let urlErr = error as? URLError,
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
{
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)"
}
// 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).

View File

@@ -234,12 +234,12 @@ final actor ControlSocketServer {
#if DEBUG
// Debug-only escape hatch: allow unsigned/same-UID clients when explicitly opted in.
// This keeps local development workable (e.g. a SwiftPM-built `clawdis-mac` binary).
let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"]
if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() {
self.logger.warning(
"allowing unsigned same-UID socket client pid=\(pid) (CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1)")
return true
}
let env = ProcessInfo.processInfo.environment["CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS"]
if env == "1", let callerUID = self.uid(for: pid), callerUID == getuid() {
self.logger.warning(
"allowing unsigned same-UID socket client pid=\(pid) (CLAWDIS_ALLOW_UNSIGNED_SOCKET_CLIENTS=1)")
return true
}
#endif
if let callerUID = self.uid(for: pid) {

View File

@@ -69,13 +69,12 @@ struct CronSettings: View {
.font(.headline)
Spacer()
}
Text(
"Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " +
"and the Gateway restarts."
)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(
"Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " +
"and the Gateway restarts.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
Text(storePath)
.font(.caption.monospaced())
@@ -497,6 +496,21 @@ private struct CronJobEditor: View {
let onSave: ([String: Any]) -> Void
private let labelColumnWidth: CGFloat = 160
private static let introText =
"Create a schedule that wakes clawd via the Gateway. "
+ "Use an isolated session for agent turns so your main chat stays clean."
private static let sessionTargetNote =
"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)."
private static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
private static let isolatedPayloadNote =
"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."
private static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target."
private static let mainSummaryNote =
"Controls the label used when posting the completion summary back to the main session."
@State private var name: String = ""
@State private var enabled: Bool = true
@@ -527,9 +541,7 @@ private struct CronJobEditor: View {
VStack(alignment: .leading, spacing: 6) {
Text(self.job == nil ? "New cron job" : "Edit cron job")
.font(.title3.weight(.semibold))
Text(
"Create a schedule that wakes clawd via the Gateway. Use an isolated session for agent turns so your main chat stays clean."
)
Text(Self.introText)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -575,8 +587,7 @@ private struct CronJobEditor: View {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
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)."
)
Self.sessionTargetNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -601,8 +612,7 @@ private struct CronJobEditor: View {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
)
Self.scheduleKindNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -646,9 +656,7 @@ private struct CronJobEditor: View {
GroupBox("Payload") {
VStack(alignment: .leading, spacing: 10) {
if self.sessionTarget == .isolated {
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."
)
Text(Self.isolatedPayloadNote)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -669,8 +677,7 @@ private struct CronJobEditor: View {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
"System events are injected into the current main session. Agent turns require an isolated session target."
)
Self.mainPayloadNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -703,8 +710,7 @@ private struct CronJobEditor: View {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
"Controls the label used when posting the completion summary back to the main session."
)
Self.mainSummaryNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -914,14 +920,14 @@ private struct CronJobEditor: View {
}()
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [
NSLocalizedDescriptionKey:
"Main session jobs require systemEvent payloads (switch Session target to isolated).",
])
}
throw NSError(
domain: "Cron",
code: 0,
userInfo: [
NSLocalizedDescriptionKey:
"Main session jobs require systemEvent payloads (switch Session target to isolated).",
])
}
if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" {
throw NSError(

View File

@@ -141,17 +141,16 @@ struct DebugSettings: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$attachExistingGatewayOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.help(
"When enabled in local mode, the mac app will only connect " +
"to an already-running gateway " +
"and will not start one itself."
)
}
GridRow {
self.gridLabel("Attach only")
Toggle("", isOn: self.$attachExistingGatewayOnly)
.labelsHidden()
.toggleStyle(.checkbox)
.help(
"When enabled in local mode, the mac app will only connect " +
"to an already-running gateway " +
"and will not start one itself.")
}
GridRow {
self.gridLabel("Deep links")
Toggle("", isOn: self.$deepLinkAgentEnabled)
@@ -232,17 +231,16 @@ struct DebugSettings: View {
GridRow {
self.gridLabel("Diagnostics")
VStack(alignment: .leading, spacing: 6) {
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
.toggleStyle(.checkbox)
.help(
"Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. " +
"Enable only while actively debugging."
)
HStack(spacing: 8) {
Button("Open folder") {
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
}
VStack(alignment: .leading, spacing: 6) {
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
.toggleStyle(.checkbox)
.help(
"Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. " +
"Enable only while actively debugging.")
HStack(spacing: 8) {
Button("Open folder") {
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
}
.buttonStyle(.bordered)
Button("Clear") {
Task { try? await DiagnosticsFileLog.shared.clear() }
@@ -485,13 +483,12 @@ struct DebugSettings: View {
private var canvasSection: some View {
GroupBox("Canvas") {
VStack(alignment: .leading, spacing: 10) {
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
.toggleStyle(.checkbox)
.help(
"When off, agent Canvas requests return “Canvas disabled by user”. " +
"Manual debug actions still work."
)
VStack(alignment: .leading, spacing: 10) {
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
.toggleStyle(.checkbox)
.help(
"When off, agent Canvas requests return “Canvas disabled by user”. " +
"Manual debug actions still work.")
HStack(spacing: 8) {
TextField("Session", text: self.$canvasSessionKey)
@@ -587,18 +584,17 @@ struct DebugSettings: View {
.labelsHidden()
.frame(maxWidth: 280, alignment: .leading)
}
GridRow {
self.gridLabel("Web chat")
Toggle("Use SwiftUI web chat (glass)", isOn: self.$webChatSwiftUIEnabled)
.toggleStyle(.checkbox)
.help(
"When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the " +
"bundled WKWebView."
)
}
}
}
}
GridRow {
self.gridLabel("Web chat")
Toggle("Use SwiftUI web chat (glass)", isOn: self.$webChatSwiftUIEnabled)
.toggleStyle(.checkbox)
.help(
"When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the " +
"bundled WKWebView.")
}
}
}
}
@MainActor
private func runPortCheck() async {
@@ -752,12 +748,12 @@ struct DebugSettings: View {
}
}
private func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis")
.appendingPathComponent("clawdis.json")
}
}
private func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdis")
.appendingPathComponent("clawdis.json")
}
}
extension DebugSettings {
// MARK: - Canvas debug actions
@@ -854,8 +850,7 @@ extension DebugSettings {
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let result = try await CanvasManager.shared.eval(
sessionKey: session.isEmpty ? "main" : session,
javaScript: self.canvasEvalJS
)
javaScript: self.canvasEvalJS)
self.canvasEvalResult = result
} catch {
self.canvasError = error.localizedDescription
@@ -870,8 +865,7 @@ extension DebugSettings {
let session = self.canvasSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let path = try await CanvasManager.shared.snapshot(
sessionKey: session.isEmpty ? "main" : session,
outPath: nil
)
outPath: nil)
self.canvasSnapshotPath = path
} catch {
self.canvasError = error.localizedDescription
@@ -879,22 +873,22 @@ extension DebugSettings {
}
}
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)
}
}
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)
}
}
#if DEBUG
struct DebugSettings_Previews: PreviewProvider {
static var previews: some View {
DebugSettings()
#if DEBUG
struct DebugSettings_Previews: PreviewProvider {
static var previews: some View {
DebugSettings()
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}

View File

@@ -125,18 +125,18 @@ actor GatewayEndpointStore {
for (_, continuation) in self.subscribers {
continuation.yield(next)
}
switch next {
case let .ready(mode, url, _):
let modeDesc = String(describing: mode)
let urlDesc = url.absoluteString
self.logger
.debug(
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
case let .unavailable(mode, reason):
let modeDesc = String(describing: mode)
self.logger
.debug(
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
}
}
switch next {
case let .ready(mode, url, _):
let modeDesc = String(describing: mode)
let urlDesc = url.absoluteString
self.logger
.debug(
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
case let .unavailable(mode, reason):
let modeDesc = String(describing: mode)
self.logger
.debug(
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
}
}
}

View File

@@ -1,15 +1,15 @@
import AppKit
import SwiftUI
struct GeneralSettings: View {
@ObservedObject var state: AppState
@ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
// swiftlint:disable:next inclusive_language
@StateObject private var masterDiscovery = MasterDiscoveryModel()
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@State private var cliInstalled = false
struct GeneralSettings: View {
@ObservedObject var state: AppState
@ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
// swiftlint:disable:next inclusive_language
@StateObject private var masterDiscovery = MasterDiscoveryModel()
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@State private var cliInstalled = false
@State private var cliInstallLocation: String?
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstallMessage: String?
@@ -577,12 +577,12 @@ extension GeneralSettings {
alert.runModal()
}
// swiftlint:disable:next inclusive_language
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost
guard let host else { return }
let user = NSUserName()
var target = "\(user)@\(host)"
// swiftlint:disable:next inclusive_language
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost
guard let host else { return }
let user = NSUserName()
var target = "\(user)@\(host)"
if master.sshPort != 22 {
target += ":\(master.sshPort)"
}

View File

@@ -45,16 +45,16 @@ struct OnboardingView: View {
@State private var cliStatus: String?
@State private var copied = false
@State private var monitoringPermissions = false
@State private var monitoringDiscovery = false
@State private var cliInstalled = false
@State private var cliInstallLocation: String?
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstalling = false
@State private var gatewayInstallMessage: String?
// swiftlint:disable:next inclusive_language
@StateObject private var masterDiscovery = MasterDiscoveryModel()
@ObservedObject private var state = AppStateStore.shared
@ObservedObject private var permissionMonitor = PermissionMonitor.shared
@State private var monitoringDiscovery = false
@State private var cliInstalled = false
@State private var cliInstallLocation: String?
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
@State private var gatewayInstalling = false
@State private var gatewayInstallMessage: String?
// swiftlint:disable:next inclusive_language
@StateObject private var masterDiscovery = MasterDiscoveryModel()
@ObservedObject private var state = AppStateStore.shared
@ObservedObject private var permissionMonitor = PermissionMonitor.shared
private let pageWidth: CGFloat = 680
private let contentHeight: CGFloat = 520
@@ -116,17 +116,16 @@ struct OnboardingView: View {
}
private func welcomePage() -> some View {
self.onboardingPage {
Text("Welcome to Clawdis")
.font(.largeTitle.weight(.semibold))
Text(
"Your macOS menu bar companion for notifications, screenshots, and agent automation — " +
"setup takes just a few minutes."
)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
self.onboardingPage {
Text("Welcome to Clawdis")
.font(.largeTitle.weight(.semibold))
Text(
"Your macOS menu bar companion for notifications, screenshots, and agent automation — " +
"setup takes just a few minutes.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(maxWidth: 560)
.fixedSize(horizontal: false, vertical: true)
@@ -141,16 +140,16 @@ struct OnboardingView: View {
VStack(alignment: .leading, spacing: 6) {
Text("Security notice")
.font(.headline)
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.
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.
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)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -162,17 +161,16 @@ struct OnboardingView: View {
}
private func connectionPage() -> some View {
self.onboardingPage {
Text("Where Clawdis runs")
.font(.largeTitle.weight(.semibold))
Text(
"Clawdis has one primary Gateway (“master”) that runs continuously. " +
"Connect locally or over SSH/Tailscale so the agent can work on any Mac."
)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
self.onboardingPage {
Text("Where Clawdis runs")
.font(.largeTitle.weight(.semibold))
Text(
"Clawdis has one primary Gateway (“master”) that runs continuously. " +
"Connect locally or over SSH/Tailscale so the agent can work on any Mac.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
@@ -300,26 +298,25 @@ struct OnboardingView: View {
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else {
Text(
"Uses \"npm install -g clawdis@<version>\" on your PATH. " +
"We keep the gateway on port 18789."
)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else {
Text(
"Uses \"npm install -g clawdis@<version>\" on your PATH. " +
"We keep the gateway on port 18789.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
}
}
// swiftlint:disable:next inclusive_language
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost
guard let host else { return }
let user = NSUserName()
var target = "\(user)@\(host)"
// swiftlint:disable:next inclusive_language
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost
guard let host else { return }
let user = NSUserName()
var target = "\(user)@\(host)"
if master.sshPort != 22 {
target += ":\(master.sshPort)"
}
@@ -460,13 +457,13 @@ struct OnboardingView: View {
Text("Telegram")
.font(.headline)
self.featureRow(
title: "Set `TELEGRAM_BOT_TOKEN`",
subtitle: """
Create a bot with @BotFather and set the token as an env var
(or `telegram.botToken` in `~/.clawdis/clawdis.json`).
""",
systemImage: "key")
self.featureRow(
title: "Set `TELEGRAM_BOT_TOKEN`",
subtitle: """
Create a bot with @BotFather and set the token as an env var
(or `telegram.botToken` in `~/.clawdis/clawdis.json`).
""",
systemImage: "key")
self.featureRow(
title: "Verify with `clawdis status --deep`",
subtitle: "This probes both WhatsApp and the Telegram API and prints whats configured.",
@@ -491,11 +488,11 @@ struct OnboardingView: View {
title: "Try Voice Wake",
subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
systemImage: "waveform.circle")
self.featureRow(
title: "Use the panel + Canvas",
subtitle: "Open the menu bar panel for quick chat; the agent can show previews " +
"and richer visuals in Canvas.",
systemImage: "rectangle.inset.filled.and.person.filled")
self.featureRow(
title: "Use the panel + Canvas",
subtitle: "Open the menu bar panel for quick chat; the agent can show previews " +
"and richer visuals in Canvas.",
systemImage: "rectangle.inset.filled.and.person.filled")
self.featureRow(
title: "Test a notification",
subtitle: "Send a quick notify via the menu bar to confirm sounds and permissions.",

View File

@@ -9,109 +9,110 @@ import Speech
import UserNotifications
enum PermissionManager {
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
var results: [Capability: Bool] = [:]
for cap in caps {
results[cap] = await self.ensureCapability(cap, interactive: interactive)
}
return results
}
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
var results: [Capability: Bool] = [:]
for cap in caps {
results[cap] = await self.ensureCapability(cap, interactive: interactive)
}
return results
}
private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool {
switch cap {
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)
}
}
private static func ensureCapability(_ cap: Capability, interactive: Bool) async -> Bool {
switch cap {
case .notifications:
await self.ensureNotifications(interactive: interactive)
case .appleScript:
await self.ensureAppleScript(interactive: interactive)
case .accessibility:
await self.ensureAccessibility(interactive: interactive)
case .screenRecording:
await self.ensureScreenRecording(interactive: interactive)
case .microphone:
await self.ensureMicrophone(interactive: interactive)
case .speechRecognition:
await self.ensureSpeechRecognition(interactive: interactive)
}
}
private static func ensureNotifications(interactive: Bool) async -> Bool {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
private static func ensureNotifications(interactive: Bool) async -> Bool {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
return true
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
}
}
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
return true
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
}
}
private static func ensureAppleScript(interactive: Bool) async -> Bool {
let granted = await MainActor.run { AppleScriptPermission.isAuthorized() }
if interactive, !granted {
await AppleScriptPermission.requestAuthorization()
}
return await MainActor.run { AppleScriptPermission.isAuthorized() }
}
private static func ensureAppleScript(interactive: Bool) async -> Bool {
let granted = await MainActor.run { AppleScriptPermission.isAuthorized() }
if interactive, !granted {
await AppleScriptPermission.requestAuthorization()
}
return await MainActor.run { AppleScriptPermission.isAuthorized() }
}
private static func ensureAccessibility(interactive: Bool) async -> Bool {
let trusted = await MainActor.run { AXIsProcessTrusted() }
if interactive, !trusted {
await MainActor.run {
let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true]
_ = AXIsProcessTrustedWithOptions(opts)
}
}
return await MainActor.run { AXIsProcessTrusted() }
}
private static func ensureAccessibility(interactive: Bool) async -> Bool {
let trusted = await MainActor.run { AXIsProcessTrusted() }
if interactive, !trusted {
await MainActor.run {
let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true]
_ = AXIsProcessTrustedWithOptions(opts)
}
}
return await MainActor.run { AXIsProcessTrusted() }
}
private static func ensureScreenRecording(interactive: Bool) async -> Bool {
let granted = ScreenRecordingProbe.isAuthorized()
if interactive, !granted {
await ScreenRecordingProbe.requestAuthorization()
}
return ScreenRecordingProbe.isAuthorized()
}
private static func ensureScreenRecording(interactive: Bool) async -> Bool {
let granted = ScreenRecordingProbe.isAuthorized()
if interactive, !granted {
await ScreenRecordingProbe.requestAuthorization()
}
return ScreenRecordingProbe.isAuthorized()
}
private static func ensureMicrophone(interactive: Bool) async -> Bool {
let status = AVCaptureDevice.authorizationStatus(for: .audio)
switch status {
case .authorized:
return true
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
}
}
private static func ensureMicrophone(interactive: Bool) async -> Bool {
let status = AVCaptureDevice.authorizationStatus(for: .audio)
switch status {
case .authorized:
return true
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
}
}
private static func ensureSpeechRecognition(interactive: Bool) async -> Bool {
let status = SFSpeechRecognizer.authorizationStatus()
if status == .notDetermined, interactive {
await withUnsafeContinuation { (cont: UnsafeContinuation<Void, Never>) in
SFSpeechRecognizer.requestAuthorization { _ in
DispatchQueue.main.async { cont.resume() }
}
}
}
return SFSpeechRecognizer.authorizationStatus() == .authorized
}
private static func ensureSpeechRecognition(interactive: Bool) async -> Bool {
let status = SFSpeechRecognizer.authorizationStatus()
if status == .notDetermined, interactive {
await withUnsafeContinuation { (cont: UnsafeContinuation<Void, Never>) in
SFSpeechRecognizer.requestAuthorization { _ in
DispatchQueue.main.async { cont.resume() }
}
}
}
return SFSpeechRecognizer.authorizationStatus() == .authorized
}
static func voiceWakePermissionsGranted() -> Bool {
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized

View File

@@ -451,8 +451,7 @@ struct WebChatView: View {
Text(
self.viewModel.healthOK
? "This is the native SwiftUI debug chat."
: "Connecting to the gateway…"
)
: "Connecting to the gateway…")
.font(.subheadline)
.foregroundStyle(.secondary)
}

View File

@@ -105,8 +105,8 @@ enum BrowserCLI {
sub: String,
options: RunOptions,
baseURL: URL,
jsonOutput: Bool
) async throws -> Int32 {
jsonOutput: Bool) async throws -> Int32
{
switch sub {
case "status":
return try await self.handleStatus(baseURL: baseURL, jsonOutput: jsonOutput)
@@ -172,8 +172,7 @@ enum BrowserCLI {
method: "POST",
url: url,
body: ["url": urlString],
timeoutInterval: 15.0
)
timeoutInterval: 15.0)
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
@@ -188,8 +187,7 @@ enum BrowserCLI {
method: "POST",
url: url,
body: ["targetId": id],
timeoutInterval: 5.0
)
timeoutInterval: 5.0)
self.printResult(jsonOutput: jsonOutput, res: res)
return 0
}
@@ -250,8 +248,7 @@ enum BrowserCLI {
"targetId": options.targetId ?? "",
"await": options.awaitPromise,
],
timeoutInterval: 15.0
)
timeoutInterval: 15.0)
if jsonOutput {
self.printJSON(ok: true, result: res)

View File

@@ -58,276 +58,269 @@ struct ClawdisCLI {
enum Kind {
case generic
}
}
}
private static func parseCommandLine(args: [String]) throws -> ParsedCLIRequest {
var args = args
guard !args.isEmpty else { throw CLIError.help }
let command = args.removeFirst()
private static func parseCommandLine(args: [String]) throws -> ParsedCLIRequest {
var args = args
guard !args.isEmpty else { throw CLIError.help }
let command = args.removeFirst()
switch command {
case "--help", "-h", "help":
throw CLIError.help
switch command {
case "--help", "-h", "help":
throw CLIError.help
case "--version", "-V", "version":
throw CLIError.version
case "--version", "-V", "version":
throw CLIError.version
case "notify":
return try self.parseNotify(args: &args)
case "notify":
return try self.parseNotify(args: &args)
case "ensure-permissions":
return self.parseEnsurePermissions(args: &args)
case "ensure-permissions":
return self.parseEnsurePermissions(args: &args)
case "run":
return self.parseRunShell(args: &args)
case "run":
return self.parseRunShell(args: &args)
case "status":
return ParsedCLIRequest(request: .status, kind: .generic)
case "status":
return ParsedCLIRequest(request: .status, kind: .generic)
case "rpc-status":
return ParsedCLIRequest(request: .rpcStatus, kind: .generic)
case "rpc-status":
return ParsedCLIRequest(request: .rpcStatus, kind: .generic)
case "agent":
return try self.parseAgent(args: &args)
case "agent":
return try self.parseAgent(args: &args)
case "node":
return try self.parseNode(args: &args)
case "node":
return try self.parseNode(args: &args)
case "canvas":
return try self.parseCanvas(args: &args)
case "canvas":
return try self.parseCanvas(args: &args)
default:
throw CLIError.help
}
}
default:
throw CLIError.help
}
}
private static func parseNotify(args: inout [String]) throws -> ParsedCLIRequest {
var title: String?
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
)
}
private static func parseNotify(args: inout [String]) throws -> ParsedCLIRequest {
var title: String?
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)
}
private static func parseEnsurePermissions(args: inout [String]) -> ParsedCLIRequest {
var caps: [Capability] = []
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)
}
private static func parseEnsurePermissions(args: inout [String]) -> ParsedCLIRequest {
var caps: [Capability] = []
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)
}
private static func parseRunShell(args: inout [String]) -> ParsedCLIRequest {
var cwd: String?
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":
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
)
}
private static func parseRunShell(args: inout [String]) -> ParsedCLIRequest {
var cwd: String?
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":
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)
}
private static func parseEnvPair(_ pair: String, into env: inout [String: String]) {
guard let eq = pair.firstIndex(of: "=") else { return }
let key = String(pair[..<eq])
let value = String(pair[pair.index(after: eq)...])
env[key] = value
}
private static func parseEnvPair(_ pair: String, into env: inout [String: String]) {
guard let eq = pair.firstIndex(of: "=") else { return }
let key = String(pair[..<eq])
let value = String(pair[pair.index(after: eq)...])
env[key] = value
}
private static func parseAgent(args: inout [String]) throws -> ParsedCLIRequest {
var message: String?
var thinking: String?
var session: String?
var deliver = false
var to: String?
private static func parseAgent(args: inout [String]) throws -> ParsedCLIRequest {
var message: String?
var thinking: String?
var session: String?
var deliver = false
var to: String?
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--message": message = args.popFirst()
case "--thinking": thinking = args.popFirst()
case "--session": session = args.popFirst()
case "--deliver": deliver = true
case "--to": to = args.popFirst()
default:
if message == nil {
message = arg
}
}
}
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--message": message = args.popFirst()
case "--thinking": thinking = args.popFirst()
case "--session": session = args.popFirst()
case "--deliver": deliver = true
case "--to": to = args.popFirst()
default:
if message == nil {
message = arg
}
}
}
guard let message else { throw CLIError.help }
return ParsedCLIRequest(
request: .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to),
kind: .generic
)
}
guard let message else { throw CLIError.help }
return ParsedCLIRequest(
request: .agent(message: message, thinking: thinking, session: session, deliver: deliver, to: to),
kind: .generic)
}
private static func parseNode(args: inout [String]) throws -> ParsedCLIRequest {
guard let sub = args.popFirst() else { throw CLIError.help }
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
}
}
private static func parseNode(args: inout [String]) throws -> ParsedCLIRequest {
guard let sub = args.popFirst() else { throw CLIError.help }
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
}
}
private static func parseCanvas(args: inout [String]) throws -> ParsedCLIRequest {
guard let sub = args.popFirst() else { throw CLIError.help }
switch sub {
case "show":
var session = "main"
var path: String?
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
return ParsedCLIRequest(
request: .canvasShow(session: session, path: path, placement: placement),
kind: .generic
)
case "hide":
var session = "main"
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--session": session = args.popFirst() ?? session
default: break
}
}
return ParsedCLIRequest(request: .canvasHide(session: session), kind: .generic)
case "goto":
var session = "main"
var path: String?
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
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
}
}
private static func parseCanvas(args: inout [String]) throws -> ParsedCLIRequest {
guard let sub = args.popFirst() else { throw CLIError.help }
switch sub {
case "show":
var session = "main"
var path: String?
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
return ParsedCLIRequest(
request: .canvasShow(session: session, path: path, placement: placement),
kind: .generic)
case "hide":
var session = "main"
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--session": session = args.popFirst() ?? session
default: break
}
}
return ParsedCLIRequest(request: .canvasHide(session: session), kind: .generic)
case "goto":
var session = "main"
var path: String?
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
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
}
}
private static func parseCanvasPlacement(
args: inout [String],
session: inout String,
path: inout String?
) -> CanvasPlacement? {
var x: Double?
var y: Double?
var width: Double?
var height: Double?
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--session": session = args.popFirst() ?? session
case "--path": path = args.popFirst()
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
}
}
if x == nil, y == nil, width == nil, height == nil { return nil }
return CanvasPlacement(x: x, y: y, width: width, height: height)
}
private static func parseCanvasPlacement(
args: inout [String],
session: inout String,
path: inout String?) -> CanvasPlacement?
{
var x: Double?
var y: Double?
var width: Double?
var height: Double?
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--session": session = args.popFirst() ?? session
case "--path": path = args.popFirst()
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
}
}
if x == nil, y == nil, width == nil, height == nil { return nil }
return CanvasPlacement(x: x, y: y, width: width, height: height)
}
private static func printText(parsed: ParsedCLIRequest, response: Response) throws {
guard response.ok else {
@@ -506,13 +499,13 @@ struct ClawdisCLI {
_NSGetExecutablePath(ptr.baseAddress, &size)
}
guard result2 == 0 else { return nil }
}
}
let nulIndex = buffer.firstIndex(of: 0) ?? buffer.count
let bytes = buffer.prefix(nulIndex).map { UInt8(bitPattern: $0) }
guard let path = String(bytes: bytes, encoding: .utf8) else { return nil }
return URL(fileURLWithPath: path).resolvingSymlinksInPath()
}
let nulIndex = buffer.firstIndex(of: 0) ?? buffer.count
let bytes = buffer.prefix(nulIndex).map { UInt8(bitPattern: $0) }
guard let path = String(bytes: bytes, encoding: .utf8) else { return nil }
return URL(fileURLWithPath: path).resolvingSymlinksInPath()
}
private static func loadPackageJSONVersion() -> String? {
guard let exeURL = self.resolveExecutableURL() else { return nil }

View File

@@ -323,17 +323,17 @@ enum UICLI {
"screenshotPath": screenshotPath,
"result": self.toJSONObject(detection),
])
} else {
FileHandle.standardOutput.write(Data((screenshotPath + "\n").utf8))
for el in detection.elements.all {
let b = el.bounds
let label = (el.label ?? el.value ?? "").replacingOccurrences(of: "\n", with: " ")
let coords = "\(Int(b.origin.x)),\(Int(b.origin.y))"
let size = "\(Int(b.size.width))x\(Int(b.size.height))"
let line = "\(el.id)\t\(el.type)\t\(coords) \(size)\t\(label)\n"
FileHandle.standardOutput.write(Data(line.utf8))
}
}
} else {
FileHandle.standardOutput.write(Data((screenshotPath + "\n").utf8))
for el in detection.elements.all {
let b = el.bounds
let label = (el.label ?? el.value ?? "").replacingOccurrences(of: "\n", with: " ")
let coords = "\(Int(b.origin.x)),\(Int(b.origin.y))"
let size = "\(Int(b.size.width))x\(Int(b.size.height))"
let line = "\(el.id)\t\(el.type)\t\(coords) \(size)\t\(label)\n"
FileHandle.standardOutput.write(Data(line.utf8))
}
}
return 0
}
@@ -522,16 +522,16 @@ enum UICLI {
])
}
do {
return try await client.getMostRecentSnapshot(applicationBundleId: resolvedBundle)
} catch {
let command = "clawdis-mac ui see --bundle-id \(resolvedBundle)"
let help = "No recent snapshot for \(resolvedBundle). Run `\(command)` first."
throw NSError(domain: "clawdis.ui", code: 6, userInfo: [
NSLocalizedDescriptionKey: help,
])
}
}
do {
return try await client.getMostRecentSnapshot(applicationBundleId: resolvedBundle)
} catch {
let command = "clawdis-mac ui see --bundle-id \(resolvedBundle)"
let help = "No recent snapshot for \(resolvedBundle). Run `\(command)` first."
throw NSError(domain: "clawdis.ui", code: 6, userInfo: [
NSLocalizedDescriptionKey: help,
])
}
}
// MARK: - IO helpers