feat(deeplink): forward agent links via bridge
parent
a56daa6c06
commit
378e5acd23
|
|
@ -10,6 +10,9 @@ struct ClawdisNodeApp: App {
|
||||||
RootCanvas()
|
RootCanvas()
|
||||||
.environmentObject(self.appModel)
|
.environmentObject(self.appModel)
|
||||||
.environmentObject(self.appModel.voiceWake)
|
.environmentObject(self.appModel.voiceWake)
|
||||||
|
.onOpenURL { url in
|
||||||
|
Task { await self.appModel.handleDeepLink(url: url) }
|
||||||
|
}
|
||||||
.onChange(of: self.scenePhase) { _, newValue in
|
.onChange(of: self.scenePhase) { _, newValue in
|
||||||
self.appModel.setScenePhase(newValue)
|
self.appModel.setScenePhase(newValue)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,56 @@ final class NodeAppModel: ObservableObject {
|
||||||
try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json)
|
try await self.bridge.sendEvent(event: "voice.transcript", payloadJSON: json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleDeepLink(url: URL) async {
|
||||||
|
guard let route = DeepLinkParser.parse(url) else { return }
|
||||||
|
|
||||||
|
switch route {
|
||||||
|
case let .agent(link):
|
||||||
|
await self.handleAgentDeepLink(link, originalURL: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async {
|
||||||
|
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !message.isEmpty else { return }
|
||||||
|
|
||||||
|
if message.count > 20000 {
|
||||||
|
self.screen.errorText = "Deep link too large (message exceeds 20,000 characters)."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard await self.isBridgeConnected() else {
|
||||||
|
self.screen.errorText = "Bridge not connected (cannot forward deep link)."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await self.sendAgentRequest(link: link)
|
||||||
|
self.screen.errorText = nil
|
||||||
|
} catch {
|
||||||
|
self.screen.errorText = "Agent request failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendAgentRequest(link: AgentDeepLink) async throws {
|
||||||
|
if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
throw NSError(domain: "DeepLink", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "invalid agent message",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS bridge forwards to the gateway; no local auth prompts here.
|
||||||
|
// (Key-based unattended auth is handled on macOS for clawdis:// links.)
|
||||||
|
let data = try JSONEncoder().encode(link)
|
||||||
|
let json = String(decoding: data, as: UTF8.self)
|
||||||
|
try await self.bridge.sendEvent(event: "agent.request", payloadJSON: json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isBridgeConnected() async -> Bool {
|
||||||
|
if case .connected = await self.bridge.state { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||||
if req.command.hasPrefix("screen."), self.isBackgrounded {
|
if req.command.hasPrefix("screen."), self.isBackgrounded {
|
||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
|
|
|
||||||
|
|
@ -123,17 +123,24 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
||||||
self.audioEngine.prepare()
|
self.audioEngine.prepare()
|
||||||
try self.audioEngine.start()
|
try self.audioEngine.start()
|
||||||
|
|
||||||
self.recognitionTask = self.speechRecognizer?
|
let handler = self.makeRecognitionResultHandler()
|
||||||
.recognitionTask(with: request) { [weak manager = self] result, error in
|
self.recognitionTask = self.speechRecognizer?.recognitionTask(with: request, resultHandler: handler)
|
||||||
Task { @MainActor in
|
|
||||||
manager?.handleRecognitionCallback(result: result, error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleRecognitionCallback(result: SFSpeechRecognitionResult?, error: Error?) {
|
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
|
||||||
if let error {
|
{ [weak self] result, error in
|
||||||
self.statusText = "Recognizer error: \(error.localizedDescription)"
|
let transcript = result?.bestTranscription.formattedString
|
||||||
|
let errorText = error?.localizedDescription
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.handleRecognitionCallback(transcript: transcript, errorText: errorText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRecognitionCallback(transcript: String?, errorText: String?) {
|
||||||
|
if let errorText {
|
||||||
|
self.statusText = "Recognizer error: \(errorText)"
|
||||||
self.isListening = false
|
self.isListening = false
|
||||||
|
|
||||||
let shouldRestart = self.isEnabled
|
let shouldRestart = self.isEnabled
|
||||||
|
|
@ -146,8 +153,7 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let result else { return }
|
guard let transcript else { return }
|
||||||
let transcript = result.bestTranscription.formattedString
|
|
||||||
guard let cmd = self.extractCommand(from: transcript) else { return }
|
guard let cmd = self.extractCommand(from: transcript) else { return }
|
||||||
|
|
||||||
if cmd == self.lastDispatched { return }
|
if cmd == self.lastDispatched { return }
|
||||||
|
|
@ -189,17 +195,21 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||||
await withCheckedContinuation(isolation: nil) { cont in
|
await withCheckedContinuation { cont in
|
||||||
AVAudioApplication.requestRecordPermission { ok in
|
AVAudioApplication.requestRecordPermission { ok in
|
||||||
cont.resume(returning: ok)
|
Task { @MainActor in
|
||||||
|
cont.resume(returning: ok)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func requestSpeechPermission() async -> Bool {
|
private nonisolated static func requestSpeechPermission() async -> Bool {
|
||||||
await withCheckedContinuation(isolation: nil) { cont in
|
await withCheckedContinuation { cont in
|
||||||
SFSpeechRecognizer.requestAuthorization { status in
|
SFSpeechRecognizer.requestAuthorization { status in
|
||||||
cont.resume(returning: status == .authorized)
|
Task { @MainActor in
|
||||||
|
cont.resume(returning: status == .authorized)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,31 @@ actor BridgeServer {
|
||||||
deliver: false,
|
deliver: false,
|
||||||
to: nil,
|
to: nil,
|
||||||
channel: "last")
|
channel: "last")
|
||||||
|
case "agent.request":
|
||||||
|
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let link = try? JSONDecoder().decode(AgentDeepLink.self, from: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !message.isEmpty else { return }
|
||||||
|
guard message.count <= 20000 else { return }
|
||||||
|
|
||||||
|
let sessionKey = link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
|
?? "node-\(nodeId)"
|
||||||
|
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
|
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
|
let channel = link.channel?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
|
|
||||||
|
_ = await AgentRPC.shared.send(
|
||||||
|
text: message,
|
||||||
|
thinking: thinking,
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
deliver: link.deliver,
|
||||||
|
to: to,
|
||||||
|
channel: channel ?? "last")
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,11 @@
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import ClawdisNodeKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
private let deepLinkLogger = Logger(subsystem: "com.steipete.clawdis", category: "DeepLink")
|
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
|
@MainActor
|
||||||
final class DeepLinkHandler {
|
final class DeepLinkHandler {
|
||||||
static let shared = DeepLinkHandler()
|
static let shared = DeepLinkHandler()
|
||||||
|
|
@ -128,7 +80,7 @@ final class DeepLinkHandler {
|
||||||
// MARK: - Auth
|
// MARK: - Auth
|
||||||
|
|
||||||
static func currentKey() -> String {
|
static func currentKey() -> String {
|
||||||
Self.expectedKey()
|
self.expectedKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func expectedKey() -> String {
|
private static func expectedKey() -> String {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum DeepLinkRoute: Sendable, Equatable {
|
||||||
|
case agent(AgentDeepLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AgentDeepLink: Codable, Sendable, Equatable {
|
||||||
|
public let message: String
|
||||||
|
public let sessionKey: String?
|
||||||
|
public let thinking: String?
|
||||||
|
public let deliver: Bool
|
||||||
|
public let to: String?
|
||||||
|
public let channel: String?
|
||||||
|
public let timeoutSeconds: Int?
|
||||||
|
public let key: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
message: String,
|
||||||
|
sessionKey: String?,
|
||||||
|
thinking: String?,
|
||||||
|
deliver: Bool,
|
||||||
|
to: String?,
|
||||||
|
channel: String?,
|
||||||
|
timeoutSeconds: Int?,
|
||||||
|
key: String?)
|
||||||
|
{
|
||||||
|
self.message = message
|
||||||
|
self.sessionKey = sessionKey
|
||||||
|
self.thinking = thinking
|
||||||
|
self.deliver = deliver
|
||||||
|
self.to = to
|
||||||
|
self.channel = channel
|
||||||
|
self.timeoutSeconds = timeoutSeconds
|
||||||
|
self.key = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DeepLinkParser {
|
||||||
|
public 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue