feat(macos): add clawdis://agent deep link
This commit is contained in:
@@ -24,6 +24,8 @@ let webChatEnabledKey = "clawdis.webChatEnabled"
|
|||||||
let webChatSwiftUIEnabledKey = "clawdis.webChatSwiftUIEnabled"
|
let webChatSwiftUIEnabledKey = "clawdis.webChatSwiftUIEnabled"
|
||||||
let webChatPortKey = "clawdis.webChatPort"
|
let webChatPortKey = "clawdis.webChatPort"
|
||||||
let canvasEnabledKey = "clawdis.canvasEnabled"
|
let canvasEnabledKey = "clawdis.canvasEnabled"
|
||||||
|
let deepLinkAgentEnabledKey = "clawdis.deepLinkAgentEnabled"
|
||||||
|
let deepLinkKeyKey = "clawdis.deepLinkKey"
|
||||||
let modelCatalogPathKey = "clawdis.modelCatalogPath"
|
let modelCatalogPathKey = "clawdis.modelCatalogPath"
|
||||||
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
|
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
|
||||||
let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly"
|
let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ struct DebugSettings: View {
|
|||||||
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||||||
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
|
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
|
||||||
@AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true
|
@AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true
|
||||||
|
@AppStorage(deepLinkAgentEnabledKey) private var deepLinkAgentEnabled: Bool = false
|
||||||
@State private var modelsCount: Int?
|
@State private var modelsCount: Int?
|
||||||
@State private var modelsLoading = false
|
@State private var modelsLoading = false
|
||||||
@State private var modelsError: String?
|
@State private var modelsError: String?
|
||||||
@@ -93,6 +94,33 @@ struct DebugSettings: View {
|
|||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
.help(
|
.help(
|
||||||
"When enabled in local mode, the mac app will only connect to an already-running gateway and will not start one itself.")
|
"When enabled in local mode, the mac app will only connect to an already-running gateway and will not start one itself.")
|
||||||
|
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) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Gateway stdout/stderr")
|
Text("Gateway stdout/stderr")
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
|
|||||||
171
apps/macos/Sources/Clawdis/DeepLinks.swift
Normal file
171
apps/macos/Sources/Clawdis/DeepLinks.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,6 +157,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private let socketServer = ControlSocketServer()
|
private let socketServer = ControlSocketServer()
|
||||||
let updaterController: UpdaterProviding = makeUpdaterController()
|
let updaterController: UpdaterProviding = makeUpdaterController()
|
||||||
|
|
||||||
|
func application(_: NSApplication, open urls: [URL]) {
|
||||||
|
Task { @MainActor in
|
||||||
|
for url in urls {
|
||||||
|
await DeepLinkHandler.shared.handle(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
if self.isDuplicateInstance() {
|
if self.isDuplicateInstance() {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Last updated: 2025-12-09
|
|||||||
- **Clients (mac app / CLI / web admin)**
|
- **Clients (mac app / CLI / web admin)**
|
||||||
- One WS connection per client.
|
- One WS connection per client.
|
||||||
- Send requests (`health`, `status`, `send`, `agent`, `system-presence`, toggles) and subscribe to events (`tick`, `agent`, `presence`, `shutdown`).
|
- 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)**
|
- **Agent process (Tau/Pi)**
|
||||||
- Spawned by the Gateway on demand for `agent` calls; streams events back over the same WS connection.
|
- Spawned by the Gateway on demand for `agent` calls; streams events back over the same WS connection.
|
||||||
- **WebChat**
|
- **WebChat**
|
||||||
|
|||||||
@@ -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 `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.).
|
- 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=<value>`, 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
|
## Permissions strategy
|
||||||
- All TCC prompts originate from the app bundle; CLI and Node stay headless.
|
- 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.
|
- Permission checks are idempotent; onboarding surfaces missing grants and provides one-click request buttons.
|
||||||
|
|||||||
@@ -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.
|
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
|
## Security / guardrails
|
||||||
|
|
||||||
Recommended defaults:
|
Recommended defaults:
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ cat > "$APP_ROOT/Contents/Info.plist" <<PLIST
|
|||||||
<string>15.0</string>
|
<string>15.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.steipete.clawdis.deeplink</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>clawdis</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>ClawdisBuildTimestamp</key>
|
<key>ClawdisBuildTimestamp</key>
|
||||||
<string>${BUILD_TS}</string>
|
<string>${BUILD_TS}</string>
|
||||||
<key>ClawdisGitCommit</key>
|
<key>ClawdisGitCommit</key>
|
||||||
|
|||||||
Reference in New Issue
Block a user