diff --git a/apps/ios/Sources/ClawdisNodeApp.swift b/apps/ios/Sources/ClawdisNodeApp.swift index 529c3e943..a59b824dd 100644 --- a/apps/ios/Sources/ClawdisNodeApp.swift +++ b/apps/ios/Sources/ClawdisNodeApp.swift @@ -10,6 +10,9 @@ struct ClawdisNodeApp: App { RootCanvas() .environmentObject(self.appModel) .environmentObject(self.appModel.voiceWake) + .onOpenURL { url in + Task { await self.appModel.handleDeepLink(url: url) } + } .onChange(of: self.scenePhase) { _, newValue in self.appModel.setScenePhase(newValue) } diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index e1ece7f83..8e284232e 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -114,6 +114,56 @@ final class NodeAppModel: ObservableObject { 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 { if req.command.hasPrefix("screen."), self.isBackgrounded { return BridgeInvokeResponse( diff --git a/apps/ios/Sources/Voice/VoiceWakeManager.swift b/apps/ios/Sources/Voice/VoiceWakeManager.swift index f77a16588..6cacb8955 100644 --- a/apps/ios/Sources/Voice/VoiceWakeManager.swift +++ b/apps/ios/Sources/Voice/VoiceWakeManager.swift @@ -123,17 +123,24 @@ final class VoiceWakeManager: NSObject, ObservableObject { self.audioEngine.prepare() try self.audioEngine.start() - self.recognitionTask = self.speechRecognizer? - .recognitionTask(with: request) { [weak manager = self] result, error in - Task { @MainActor in - manager?.handleRecognitionCallback(result: result, error: error) - } - } + let handler = self.makeRecognitionResultHandler() + self.recognitionTask = self.speechRecognizer?.recognitionTask(with: request, resultHandler: handler) } - private func handleRecognitionCallback(result: SFSpeechRecognitionResult?, error: Error?) { - if let error { - self.statusText = "Recognizer error: \(error.localizedDescription)" + private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void { + { [weak self] result, error in + 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 let shouldRestart = self.isEnabled @@ -146,8 +153,7 @@ final class VoiceWakeManager: NSObject, ObservableObject { return } - guard let result else { return } - let transcript = result.bestTranscription.formattedString + guard let transcript else { return } guard let cmd = self.extractCommand(from: transcript) else { return } if cmd == self.lastDispatched { return } @@ -189,17 +195,21 @@ final class VoiceWakeManager: NSObject, ObservableObject { } private nonisolated static func requestMicrophonePermission() async -> Bool { - await withCheckedContinuation(isolation: nil) { cont in + await withCheckedContinuation { cont in AVAudioApplication.requestRecordPermission { ok in - cont.resume(returning: ok) + Task { @MainActor in + cont.resume(returning: ok) + } } } } private nonisolated static func requestSpeechPermission() async -> Bool { - await withCheckedContinuation(isolation: nil) { cont in + await withCheckedContinuation { cont in SFSpeechRecognizer.requestAuthorization { status in - cont.resume(returning: status == .authorized) + Task { @MainActor in + cont.resume(returning: status == .authorized) + } } } } diff --git a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift index adb609b14..09ab1188f 100644 --- a/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift +++ b/apps/macos/Sources/Clawdis/Bridge/BridgeServer.swift @@ -145,6 +145,31 @@ actor BridgeServer { deliver: false, to: nil, 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: break } diff --git a/apps/macos/Sources/Clawdis/DeepLinks.swift b/apps/macos/Sources/Clawdis/DeepLinks.swift index 7872e90d4..1ff164622 100644 --- a/apps/macos/Sources/Clawdis/DeepLinks.swift +++ b/apps/macos/Sources/Clawdis/DeepLinks.swift @@ -1,59 +1,11 @@ import AppKit +import ClawdisNodeKit 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() @@ -128,7 +80,7 @@ final class DeepLinkHandler { // MARK: - Auth static func currentKey() -> String { - Self.expectedKey() + self.expectedKey() } private static func expectedKey() -> String { diff --git a/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/DeepLinks.swift b/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/DeepLinks.swift new file mode 100644 index 000000000..3d155dc3f --- /dev/null +++ b/apps/shared/ClawdisNodeKit/Sources/ClawdisNodeKit/DeepLinks.swift @@ -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 + } + } +}