refactor: apply stashed bridge + CLI changes
This commit is contained in:
@@ -87,19 +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)
|
||||
canvasLogger.debug(
|
||||
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(standardizedFile.path, privacy: .public)")
|
||||
return CanvasResponse(mime: mime, data: data)
|
||||
} catch {
|
||||
canvasLogger
|
||||
.error(
|
||||
"failed reading \(standardizedFile.path, privacy: .public): \(error.localizedDescription, 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
|
||||
|
||||
@@ -204,13 +204,15 @@ struct ConfigSettings: View {
|
||||
.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.")
|
||||
"When enabled, the browser server will only connect if the clawd browser is already running."
|
||||
)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
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)
|
||||
.foregroundStyle(.secondary)
|
||||
.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 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).
|
||||
|
||||
@@ -10,156 +10,254 @@ enum ControlRequestHandler {
|
||||
{
|
||||
// Keep `status` responsive even if the main actor is busy.
|
||||
let paused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
if paused {
|
||||
switch request {
|
||||
case .status:
|
||||
break
|
||||
default:
|
||||
return Response(ok: false, message: "clawdis paused")
|
||||
}
|
||||
if paused, request != .status {
|
||||
return Response(ok: false, message: "clawdis paused")
|
||||
}
|
||||
|
||||
switch request {
|
||||
case let .notify(title, body, sound, priority, delivery):
|
||||
let chosenSound = sound?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let chosenDelivery = delivery ?? .system
|
||||
switch request {
|
||||
case let .notify(title, body, sound, priority, delivery):
|
||||
let notify = NotifyRequest(
|
||||
title: title,
|
||||
body: body,
|
||||
sound: sound,
|
||||
priority: priority,
|
||||
delivery: delivery
|
||||
)
|
||||
return await self.handleNotify(notify, notifier: notifier)
|
||||
|
||||
switch chosenDelivery {
|
||||
case .system:
|
||||
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 let .ensurePermissions(caps, interactive):
|
||||
return await self.handleEnsurePermissions(caps: caps, interactive: interactive)
|
||||
|
||||
case .overlay:
|
||||
await MainActor.run {
|
||||
NotifyOverlayController.shared.present(title: title, body: body)
|
||||
}
|
||||
return Response(ok: true)
|
||||
case .status:
|
||||
return paused
|
||||
? Response(ok: false, message: "clawdis paused")
|
||||
: Response(ok: true, message: "ready")
|
||||
|
||||
case .auto:
|
||||
let ok = await notifier.send(title: title, body: body, sound: chosenSound, priority: priority)
|
||||
if ok { return Response(ok: true) }
|
||||
await MainActor.run {
|
||||
NotifyOverlayController.shared.present(title: title, body: body)
|
||||
}
|
||||
return Response(ok: true, message: "notification not authorized; used overlay")
|
||||
case .rpcStatus:
|
||||
return await self.handleRPCStatus()
|
||||
|
||||
case let .runShell(command, cwd, env, timeoutSec, needsSR):
|
||||
return await self.handleRunShell(
|
||||
command: command,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
timeoutSec: timeoutSec,
|
||||
needsSR: needsSR
|
||||
)
|
||||
|
||||
case let .agent(message, thinking, session, deliver, to):
|
||||
return await self.handleAgent(
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
session: session,
|
||||
deliver: deliver,
|
||||
to: to
|
||||
)
|
||||
|
||||
case let .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)
|
||||
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):
|
||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
guard 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 handleEnsurePermissions(caps: [Capability], interactive: Bool) async -> Response {
|
||||
let statuses = await PermissionManager.ensure(caps, interactive: interactive)
|
||||
let missing = statuses.filter { !$0.value }.map(\.key.rawValue)
|
||||
let ok = missing.isEmpty
|
||||
let msg = ok ? "all granted" : "missing: \(missing.joined(separator: ","))"
|
||||
return Response(ok: ok, message: msg)
|
||||
}
|
||||
|
||||
case let .canvasEval(session, javaScript):
|
||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
guard 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 handleRPCStatus() async -> Response {
|
||||
let result = await AgentRPC.shared.status()
|
||||
return Response(ok: result.ok, message: result.error)
|
||||
}
|
||||
|
||||
case let .canvasSnapshot(session, outPath):
|
||||
let canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
guard 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 handleRunShell(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?,
|
||||
timeoutSec: Double?,
|
||||
needsSR: Bool
|
||||
) async -> Response {
|
||||
if needsSR {
|
||||
let authorized = await PermissionManager
|
||||
.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
|
||||
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
|
||||
}
|
||||
return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec)
|
||||
}
|
||||
|
||||
case .nodeList:
|
||||
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 handleAgent(
|
||||
message: String,
|
||||
thinking: String?,
|
||||
session: String?,
|
||||
deliver: Bool,
|
||||
to: String?
|
||||
) async -> Response {
|
||||
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return Response(ok: false, message: "message empty") }
|
||||
let sessionKey = session ?? "main"
|
||||
let 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):
|
||||
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 {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
private static func canvasEnabled() -> Bool {
|
||||
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
private static func handleCanvasShow(
|
||||
session: String,
|
||||
path: String?,
|
||||
placement: CanvasPlacement?
|
||||
) async -> Response {
|
||||
guard self.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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// 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, privacy: .public) due to 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) {
|
||||
|
||||
@@ -69,11 +69,13 @@ 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())
|
||||
@@ -526,7 +528,8 @@ private struct CronJobEditor: View {
|
||||
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.")
|
||||
"Create a schedule that wakes clawd via the Gateway. Use an isolated session for agent turns so your main chat stays clean."
|
||||
)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -572,7 +575,8 @@ 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).")
|
||||
"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)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -597,7 +601,8 @@ 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.")
|
||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -642,7 +647,8 @@ private struct CronJobEditor: View {
|
||||
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.")
|
||||
"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)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -663,7 +669,8 @@ 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.")
|
||||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -696,7 +703,8 @@ 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.")
|
||||
"Controls the label used when posting the completion summary back to the main session."
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -906,13 +914,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(
|
||||
|
||||
@@ -141,14 +141,17 @@ 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)
|
||||
@@ -229,15 +232,17 @@ 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() }
|
||||
@@ -480,11 +485,13 @@ 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)
|
||||
@@ -580,28 +587,18 @@ 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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -755,12 +752,14 @@ 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
|
||||
|
||||
@MainActor
|
||||
@@ -796,12 +795,17 @@ struct DebugSettings: View {
|
||||
body { font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.wrap { padding:16px; }
|
||||
.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); }
|
||||
button { background:#22c55e; color:#04110a; border:0; border-radius:10px; padding:8px 10px; font-weight:700; cursor:pointer; }
|
||||
.pill { padding:6px 10px; border-radius:999px; background:rgba(255,255,255,.08);
|
||||
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); }
|
||||
.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; }
|
||||
.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); }
|
||||
</style>
|
||||
</head>
|
||||
@@ -850,7 +854,8 @@ struct DebugSettings: View {
|
||||
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
|
||||
@@ -865,7 +870,8 @@ struct DebugSettings: View {
|
||||
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
|
||||
@@ -873,10 +879,22 @@ struct DebugSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct DebugSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct DebugSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
DebugSettings()
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,15 +125,18 @@ actor GatewayEndpointStore {
|
||||
for (_, continuation) in self.subscribers {
|
||||
continuation.yield(next)
|
||||
}
|
||||
switch next {
|
||||
case let .ready(mode, url, _):
|
||||
self.logger
|
||||
.debug(
|
||||
"resolved endpoint mode=\(String(describing: mode), privacy: .public) url=\(url.absoluteString, privacy: .public)")
|
||||
case let .unavailable(mode, reason):
|
||||
self.logger
|
||||
.debug(
|
||||
"endpoint unavailable mode=\(String(describing: mode), 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +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
|
||||
@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?
|
||||
@@ -576,11 +577,12 @@ extension GeneralSettings {
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
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)"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
// “master” is part of the discovery protocol naming; keep UI components consistent.
|
||||
// swiftlint:disable:next inclusive_language
|
||||
struct MasterDiscoveryInlineList: View {
|
||||
@ObservedObject var discovery: MasterDiscoveryModel
|
||||
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
||||
@@ -50,6 +52,7 @@ struct MasterDiscoveryInlineList: View {
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next inclusive_language
|
||||
struct MasterDiscoveryMenu: View {
|
||||
@ObservedObject var discovery: MasterDiscoveryModel
|
||||
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
// We use “master” as the on-the-wire service name; keep the model aligned with the protocol/docs.
|
||||
@MainActor
|
||||
// swiftlint:disable:next inclusive_language
|
||||
final class MasterDiscoveryModel: ObservableObject {
|
||||
// swiftlint:disable:next inclusive_language
|
||||
struct DiscoveredMaster: Identifiable, Equatable {
|
||||
var id: String { self.debugID }
|
||||
var displayName: String
|
||||
@@ -12,6 +15,7 @@ final class MasterDiscoveryModel: ObservableObject {
|
||||
var debugID: String
|
||||
}
|
||||
|
||||
// swiftlint:disable:next inclusive_language
|
||||
@Published var masters: [DiscoveredMaster] = []
|
||||
@Published var statusText: String = "Idle"
|
||||
|
||||
|
||||
@@ -110,9 +110,8 @@ struct MenuContent: View {
|
||||
await self.reloadSessionMenu()
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
level.capitalized,
|
||||
systemImage: row.thinkingLevel == normalized ? "checkmark" : "")
|
||||
let checkmark = row.thinkingLevel == normalized ? "checkmark" : ""
|
||||
Label(level.capitalized, systemImage: checkmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,9 +127,8 @@ struct MenuContent: View {
|
||||
await self.reloadSessionMenu()
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
level.capitalized,
|
||||
systemImage: row.verboseLevel == normalized ? "checkmark" : "")
|
||||
let checkmark = row.verboseLevel == normalized ? "checkmark" : ""
|
||||
Label(level.capitalized, systemImage: checkmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,15 +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?
|
||||
@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
|
||||
@@ -115,15 +116,17 @@ 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)
|
||||
|
||||
@@ -138,12 +141,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)
|
||||
@@ -155,15 +162,17 @@ 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)
|
||||
|
||||
@@ -291,23 +300,26 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)"
|
||||
}
|
||||
@@ -448,12 +460,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 what’s configured.",
|
||||
@@ -478,10 +491,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.",
|
||||
|
||||
@@ -9,101 +9,109 @@ import Speech
|
||||
import UserNotifications
|
||||
|
||||
enum PermissionManager {
|
||||
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
|
||||
var results: [Capability: Bool] = [:]
|
||||
for cap in caps {
|
||||
switch cap {
|
||||
case .notifications:
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let settings = await center.notificationSettings()
|
||||
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
|
||||
}
|
||||
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
results[cap] = true
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
case .notDetermined:
|
||||
if interactive {
|
||||
let granted = await (try? center.requestAuthorization(options: [.alert, .sound, .badge])) ??
|
||||
false
|
||||
let updated = await center.notificationSettings()
|
||||
results[cap] = granted && (updated.authorizationStatus == .authorized || updated
|
||||
.authorizationStatus == .provisional)
|
||||
} else {
|
||||
results[cap] = false
|
||||
}
|
||||
private static func ensureNotifications(interactive: Bool) async -> Bool {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let settings = await center.notificationSettings()
|
||||
|
||||
case .denied:
|
||||
results[cap] = false
|
||||
if interactive {
|
||||
NotificationPermissionHelper.openSettings()
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@unknown default:
|
||||
results[cap] = 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() }
|
||||
}
|
||||
|
||||
case .appleScript:
|
||||
let granted = await MainActor.run { AppleScriptPermission.isAuthorized() }
|
||||
if interactive, !granted {
|
||||
await AppleScriptPermission.requestAuthorization()
|
||||
}
|
||||
results[cap] = 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() }
|
||||
}
|
||||
|
||||
case .accessibility:
|
||||
let trusted = await MainActor.run { AXIsProcessTrusted() }
|
||||
results[cap] = trusted
|
||||
if interactive, !trusted {
|
||||
await MainActor.run {
|
||||
let opts: NSDictionary = ["AXTrustedCheckOptionPrompt": true]
|
||||
_ = AXIsProcessTrustedWithOptions(opts)
|
||||
}
|
||||
}
|
||||
private static func ensureScreenRecording(interactive: Bool) async -> Bool {
|
||||
let granted = ScreenRecordingProbe.isAuthorized()
|
||||
if interactive, !granted {
|
||||
await ScreenRecordingProbe.requestAuthorization()
|
||||
}
|
||||
return ScreenRecordingProbe.isAuthorized()
|
||||
}
|
||||
|
||||
case .screenRecording:
|
||||
let granted = ScreenRecordingProbe.isAuthorized()
|
||||
if interactive, !granted {
|
||||
await ScreenRecordingProbe.requestAuthorization()
|
||||
}
|
||||
results[cap] = 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
|
||||
}
|
||||
}
|
||||
|
||||
case .microphone:
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
switch status {
|
||||
case .authorized:
|
||||
results[cap] = true
|
||||
|
||||
case .notDetermined:
|
||||
if interactive {
|
||||
let ok = await AVCaptureDevice.requestAccess(for: .audio)
|
||||
results[cap] = ok
|
||||
} 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
|
||||
}
|
||||
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
|
||||
|
||||
@@ -186,39 +186,37 @@ final class WebChatServer: @unchecked Sendable {
|
||||
over: connection)
|
||||
return
|
||||
}
|
||||
guard let data = try? Data(contentsOf: fileURL) else {
|
||||
webChatServerLogger.error("WebChatServer 404 missing \(fileURL.lastPathComponent, privacy: .public)")
|
||||
self.send(
|
||||
status: 404,
|
||||
mime: "text/plain",
|
||||
body: Data("Not Found".utf8),
|
||||
contentLength: "Not Found".utf8.count,
|
||||
includeBody: includeBody,
|
||||
over: connection)
|
||||
return
|
||||
}
|
||||
let mime = self.mimeType(forExtension: fileURL.pathExtension)
|
||||
self.send(
|
||||
status: 200,
|
||||
mime: mime,
|
||||
body: data,
|
||||
contentLength: data.count,
|
||||
includeBody: includeBody,
|
||||
over: connection)
|
||||
}
|
||||
guard let data = try? Data(contentsOf: fileURL) else {
|
||||
webChatServerLogger.error("WebChatServer 404 missing \(fileURL.lastPathComponent, privacy: .public)")
|
||||
self.send(
|
||||
status: 404,
|
||||
mime: "text/plain",
|
||||
body: Data("Not Found".utf8),
|
||||
includeBody: includeBody,
|
||||
over: connection)
|
||||
return
|
||||
}
|
||||
let mime = self.mimeType(forExtension: fileURL.pathExtension)
|
||||
self.send(
|
||||
status: 200,
|
||||
mime: mime,
|
||||
body: data,
|
||||
includeBody: includeBody,
|
||||
over: connection)
|
||||
}
|
||||
|
||||
private func send(
|
||||
status: Int,
|
||||
mime: String,
|
||||
body: Data,
|
||||
contentLength: Int,
|
||||
includeBody: Bool,
|
||||
over connection: NWConnection)
|
||||
{
|
||||
let headers = "HTTP/1.1 \(status) \(statusText(status))\r\n" +
|
||||
"Content-Length: \(contentLength)\r\n" +
|
||||
"Content-Type: \(mime)\r\n" +
|
||||
"Connection: close\r\n\r\n"
|
||||
private func send(
|
||||
status: Int,
|
||||
mime: String,
|
||||
body: Data,
|
||||
includeBody: Bool,
|
||||
over connection: NWConnection)
|
||||
{
|
||||
let contentLength = body.count
|
||||
let headers = "HTTP/1.1 \(status) \(statusText(status))\r\n" +
|
||||
"Content-Length: \(contentLength)\r\n" +
|
||||
"Content-Type: \(mime)\r\n" +
|
||||
"Connection: close\r\n\r\n"
|
||||
var response = Data(headers.utf8)
|
||||
if includeBody {
|
||||
response.append(body)
|
||||
|
||||
@@ -163,8 +163,9 @@ final class WebChatViewModel: ObservableObject {
|
||||
do {
|
||||
let data = try await Task.detached { try Data(contentsOf: url) }.value
|
||||
guard data.count <= 5_000_000 else {
|
||||
await MainActor
|
||||
.run { self.errorText = "Attachment \(url.lastPathComponent) exceeds 5 MB limit" }
|
||||
await MainActor.run {
|
||||
self.errorText = "Attachment \(url.lastPathComponent) exceeds 5 MB limit"
|
||||
}
|
||||
continue
|
||||
}
|
||||
let uti = UTType(filenameExtension: url.pathExtension) ?? .data
|
||||
@@ -447,8 +448,11 @@ struct WebChatView: View {
|
||||
.foregroundStyle(Color.accentColor.opacity(0.9))
|
||||
Text("Say hi to Clawd")
|
||||
.font(.headline)
|
||||
Text(self.viewModel
|
||||
.healthOK ? "This is the native SwiftUI debug chat." : "Connecting to the gateway…")
|
||||
Text(
|
||||
self.viewModel.healthOK
|
||||
? "This is the native SwiftUI debug chat."
|
||||
: "Connecting to the gateway…"
|
||||
)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -460,10 +464,9 @@ struct WebChatView: View {
|
||||
.padding(.vertical, 34)
|
||||
} else {
|
||||
ForEach(self.viewModel.messages) { msg in
|
||||
let alignment: Alignment = msg.role.lowercased() == "user" ? .trailing : .leading
|
||||
MessageBubble(message: msg)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
||||
.frame(maxWidth: .infinity, alignment: alignment)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user