From 3b72ed6e1ace98d17d2094eead0ac6dd0ba1ee2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 12 Dec 2025 23:22:40 +0000 Subject: [PATCH] feat(macos): add clawdis://agent deep link --- apps/macos/Sources/Clawdis/Constants.swift | 2 + .../macos/Sources/Clawdis/DebugSettings.swift | 28 +++ apps/macos/Sources/Clawdis/DeepLinks.swift | 171 ++++++++++++++++++ apps/macos/Sources/Clawdis/MenuBar.swift | 8 + docs/architecture.md | 1 + docs/clawdis-mac.md | 36 ++++ docs/mac/canvas.md | 3 + scripts/package-mac-app.sh | 11 ++ 8 files changed, 260 insertions(+) create mode 100644 apps/macos/Sources/Clawdis/DeepLinks.swift diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index 63cfcef6b..bcefc6b8e 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -24,6 +24,8 @@ let webChatEnabledKey = "clawdis.webChatEnabled" let webChatSwiftUIEnabledKey = "clawdis.webChatSwiftUIEnabled" let webChatPortKey = "clawdis.webChatPort" let canvasEnabledKey = "clawdis.canvasEnabled" +let deepLinkAgentEnabledKey = "clawdis.deepLinkAgentEnabled" +let deepLinkKeyKey = "clawdis.deepLinkKey" let modelCatalogPathKey = "clawdis.modelCatalogPath" let modelCatalogReloadKey = "clawdis.modelCatalogReload" let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly" diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index 72d89ca2e..7b9fc823d 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -8,6 +8,7 @@ struct DebugSettings: View { @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 @AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue @AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true + @AppStorage(deepLinkAgentEnabledKey) private var deepLinkAgentEnabled: Bool = false @State private var modelsCount: Int? @State private var modelsLoading = false @State private var modelsError: String? @@ -93,6 +94,33 @@ struct DebugSettings: View { .toggleStyle(.switch) .help( "When enabled in local mode, the mac app will only connect to an already-running gateway and will not start one itself.") + LabeledContent("URL scheme") { + VStack(alignment: .leading, spacing: 8) { + Toggle("Allow URL scheme (agent)", isOn: self.$deepLinkAgentEnabled) + .toggleStyle(.switch) + .help("Enables handling of clawdis://agent?... deep links to trigger an agent run.") + let key = DeepLinkHandler.currentKey() + HStack(spacing: 8) { + Text(key) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + Button("Copy key") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(key, forType: .string) + } + .buttonStyle(.bordered) + } + Button("Copy sample agent URL") { + let msg = "Hello from deep link" + let encoded = msg.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? msg + let url = "clawdis://agent?message=\(encoded)&key=\(key)" + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url, forType: .string) + } + .buttonStyle(.bordered) + } + } VStack(alignment: .leading, spacing: 4) { Text("Gateway stdout/stderr") .font(.caption.weight(.semibold)) diff --git a/apps/macos/Sources/Clawdis/DeepLinks.swift b/apps/macos/Sources/Clawdis/DeepLinks.swift new file mode 100644 index 000000000..7872e90d4 --- /dev/null +++ b/apps/macos/Sources/Clawdis/DeepLinks.swift @@ -0,0 +1,171 @@ +import AppKit +import Foundation +import OSLog +import Security + +private let deepLinkLogger = Logger(subsystem: "com.steipete.clawdis", category: "DeepLink") + +enum DeepLinkRoute: Sendable, Equatable { + case agent(AgentDeepLink) +} + +struct AgentDeepLink: Sendable, Equatable { + let message: String + let sessionKey: String? + let thinking: String? + let deliver: Bool + let to: String? + let channel: String? + let timeoutSeconds: Int? + let key: String? +} + +enum DeepLinkParser { + static func parse(_ url: URL) -> DeepLinkRoute? { + guard url.scheme?.lowercased() == "clawdis" else { return nil } + guard let host = url.host?.lowercased(), !host.isEmpty else { return nil } + + guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } + let query = (comps.queryItems ?? []).reduce(into: [String: String]()) { dict, item in + guard let value = item.value else { return } + dict[item.name] = value + } + + switch host { + case "agent": + guard let message = query["message"], !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + let deliver = (query["deliver"] as NSString?)?.boolValue ?? false + let timeoutSeconds = query["timeoutSeconds"].flatMap { Int($0) }.flatMap { $0 >= 0 ? $0 : nil } + return .agent( + .init( + message: message, + sessionKey: query["sessionKey"], + thinking: query["thinking"], + deliver: deliver, + to: query["to"], + channel: query["channel"], + timeoutSeconds: timeoutSeconds, + key: query["key"])) + default: + return nil + } + } +} + +@MainActor +final class DeepLinkHandler { + static let shared = DeepLinkHandler() + + private var lastPromptAt: Date = .distantPast + + func handle(url: URL) async { + guard let route = DeepLinkParser.parse(url) else { + deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)") + return + } + guard UserDefaults.standard.bool(forKey: deepLinkAgentEnabledKey) else { + self.presentAlert( + title: "Deep links are disabled", + message: "Enable “Allow URL scheme (agent)” in Clawdis Debug Settings to accept clawdis:// links.") + return + } + guard !AppStateStore.shared.isPaused else { + self.presentAlert(title: "Clawdis is paused", message: "Unpause Clawdis to run agent actions.") + return + } + + switch route { + case let .agent(link): + await self.handleAgent(link: link, originalURL: url) + } + } + + private func handleAgent(link: AgentDeepLink, originalURL: URL) async { + let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines) + if messagePreview.count > 20000 { + self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.") + return + } + + let allowUnattended = link.key == Self.expectedKey() + if !allowUnattended { + if Date().timeIntervalSince(self.lastPromptAt) < 1.0 { + deepLinkLogger.debug("throttling deep link prompt") + return + } + self.lastPromptAt = Date() + + let trimmed = messagePreview.count > 240 ? "\(messagePreview.prefix(240))…" : messagePreview + let body = + "Run the agent with this message?\n\n\(trimmed)\n\nURL:\n\(originalURL.absoluteString)" + guard self.confirm(title: "Run Clawdis agent?", message: body) else { return } + } + + if AppStateStore.shared.connectionMode == .local { + GatewayProcessManager.shared.setActive(true) + } + + do { + var params: [String: AnyCodable] = [ + "message": AnyCodable(messagePreview), + "idempotencyKey": AnyCodable(UUID().uuidString), + ] + if let sessionKey = link.sessionKey, !sessionKey.isEmpty { params["sessionKey"] = AnyCodable(sessionKey) } + if let thinking = link.thinking, !thinking.isEmpty { params["thinking"] = AnyCodable(thinking) } + if let to = link.to, !to.isEmpty { params["to"] = AnyCodable(to) } + if let channel = link.channel, !channel.isEmpty { params["channel"] = AnyCodable(channel) } + if let timeout = link.timeoutSeconds { params["timeout"] = AnyCodable(timeout) } + params["deliver"] = AnyCodable(link.deliver) + + _ = try await GatewayConnection.shared.request(method: "agent", params: params) + } catch { + self.presentAlert(title: "Agent request failed", message: error.localizedDescription) + } + } + + // MARK: - Auth + + static func currentKey() -> String { + Self.expectedKey() + } + + private static func expectedKey() -> String { + let defaults = UserDefaults.standard + if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty { + return key + } + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let data = Data(bytes) + let key = data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + defaults.set(key, forKey: deepLinkKeyKey) + return key + } + + // MARK: - UI + + private func confirm(title: String, message: String) -> Bool { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "Run") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + return alert.runModal() == .alertFirstButtonReturn + } + + private func presentAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.alertStyle = .informational + alert.runModal() + } +} diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index fa8b7209a..daf06a162 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -157,6 +157,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let socketServer = ControlSocketServer() let updaterController: UpdaterProviding = makeUpdaterController() + func application(_: NSApplication, open urls: [URL]) { + Task { @MainActor in + for url in urls { + await DeepLinkHandler.shared.handle(url: url) + } + } + } + @MainActor func applicationDidFinishLaunching(_ notification: Notification) { if self.isDuplicateInstance() { diff --git a/docs/architecture.md b/docs/architecture.md index 1315adfd4..0c1a0c7d0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,6 +20,7 @@ Last updated: 2025-12-09 - **Clients (mac app / CLI / web admin)** - One WS connection per client. - Send requests (`health`, `status`, `send`, `agent`, `system-presence`, toggles) and subscribe to events (`tick`, `agent`, `presence`, `shutdown`). + - On macOS, the app can also be invoked via deep links (`clawdis://agent?...`) which translate into the same Gateway `agent` request path (see `docs/clawdis-mac.md`). - **Agent process (Tau/Pi)** - Spawned by the Gateway on demand for `agent` calls; streams events back over the same WS connection. - **WebChat** diff --git a/docs/clawdis-mac.md b/docs/clawdis-mac.md index b98133fa0..e384f081a 100644 --- a/docs/clawdis-mac.md +++ b/docs/clawdis-mac.md @@ -80,6 +80,42 @@ struct Response { ok: Bool; message?: String; payload?: Data } - Use `notify` for desktop toasts; fall back to JS notifier only if CLI missing or platform ≠ macOS. - Use `run` for tasks requiring privileged UI context (screen-recorded terminal runs, etc.). +## Deep links (URL scheme) + +Clawdis (the macOS app) registers a URL scheme for triggering local actions from anywhere (browser, Shortcuts, CLI, etc.). + +Scheme: +- `clawdis://…` + +### `clawdis://agent` + +Triggers a Gateway `agent` request (same machinery as WebChat/agent runs). + +Example: + +```bash +open 'clawdis://agent?message=Hello%20from%20deep%20link' +``` + +Query parameters: +- `message` (required): the agent prompt (URL-encoded). +- `sessionKey` (optional): explicit session key to use. +- `thinking` (optional): `off|minimal|low|medium|high` (or omit for default). +- `deliver` (optional): `true|false` (default: false). +- `to` / `channel` (optional): forwarded to the Gateway `agent` method (only meaningful with `deliver=true`). +- `timeoutSeconds` (optional): timeout hint forwarded to the Gateway. +- `key` (optional): unattended mode key (see below). + +Safety/guardrails: +- Disabled by default; enable in **Clawdis → Settings → Debug** (“Allow URL scheme (agent)”). +- Without `key`, Clawdis prompts with a confirmation dialog before invoking the agent. +- With `key=`, Clawdis runs without prompting (intended for personal automations). + - The current key is shown in Debug Settings and stored locally in UserDefaults. + +Notes: +- In local mode, Clawdis will start the local Gateway if needed before issuing the request. +- In remote mode, Clawdis will use the configured remote tunnel/endpoint. + ## Permissions strategy - All TCC prompts originate from the app bundle; CLI and Node stay headless. - Permission checks are idempotent; onboarding surfaces missing grants and provides one-click request buttons. diff --git a/docs/mac/canvas.md b/docs/mac/canvas.md index aa3b87e97..85a0ef789 100644 --- a/docs/mac/canvas.md +++ b/docs/mac/canvas.md @@ -79,6 +79,9 @@ Expose Canvas via the existing `clawdis-mac` → XPC → app routing so the agen This should be modeled after `WebChatManager`/`WebChatWindowController` but targeting `clawdis-canvas://…` URLs. +Related: +- For “invoke the agent again from UI” flows, prefer the macOS deep link scheme (`clawdis://agent?...`) so *any* UI surface (Canvas, WebChat, native views) can trigger a new agent run. See `docs/clawdis-mac.md`. + ## Security / guardrails Recommended defaults: diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 502dfd8a6..a3bd8b56d 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -73,6 +73,17 @@ cat > "$APP_ROOT/Contents/Info.plist" <15.0 LSUIElement + CFBundleURLTypes + + + CFBundleURLName + com.steipete.clawdis.deeplink + CFBundleURLSchemes + + clawdis + + + ClawdisBuildTimestamp ${BUILD_TS} ClawdisGitCommit