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 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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
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()
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user