diff --git a/IOS-PRIORITIES.md b/IOS-PRIORITIES.md deleted file mode 100644 index 043b85fd2e..0000000000 --- a/IOS-PRIORITIES.md +++ /dev/null @@ -1,110 +0,0 @@ -# iOS App Priorities (OpenClaw / Moltbot) - -This report is based on repo code + docs in `/Users/mariano/Coding/openclaw`, with focus on: - -- iOS Swift sources under `apps/ios/Sources` -- Shared Swift packages under `apps/shared/OpenClawKit` -- Gateway protocol + node docs in `docs/` -- macOS node implementation under `apps/macos/Sources/OpenClaw/NodeMode` - -## Current iOS state (what works today) - -**Gateway connectivity + pairing** - -- Uses the unified Gateway WebSocket protocol with device identity + challenge signing (via `GatewayChannel` in OpenClawKit). -- Discovery via Bonjour (`NWBrowser`) for `_openclaw-gw._tcp` plus manual host/port fallback and TLS pinning support (`apps/ios/Sources/Gateway/*`). -- Stores gateway token/password in Keychain (`GatewaySettingsStore.swift`). - -**Node command handling** (implemented in `NodeAppModel.handleInvoke`) - -- Canvas: `canvas.present`, `canvas.hide`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`. -- A2UI: `canvas.a2ui.reset`, `canvas.a2ui.push`, `canvas.a2ui.pushJsonl`. -- Camera: `camera.list`, `camera.snap`, `camera.clip`. -- Screen: `screen.record` (ReplayKit-based screen recording). -- Location: `location.get` (CoreLocation-based). -- Foreground gating: returns `NODE_BACKGROUND_UNAVAILABLE` for canvas/camera/screen when backgrounded. - -**Voice features** - -- Voice Wake: continuous speech recognition with wake-word gating and gateway sync (`VoiceWakeManager.swift`). -- Talk Mode: speech-to-text + chat.send + ElevenLabs streaming TTS + system voice fallback (`TalkModeManager.swift`). - -**Chat UI** - -- Uses shared SwiftUI chat client (`OpenClawChatUI`) and Gateway chat APIs (`IOSGatewayChatTransport.swift`). - -**UI surface** - -- Full-screen canvas with overlay controls for chat, settings, and Talk orb (`RootCanvas.swift`). -- Settings for gateway selection, voice, camera, location, screen prevent-sleep, and debug flags (`SettingsTab.swift`). - -## Protocol requirements the iOS app must honor - -From `docs/gateway/protocol.md` + `docs/nodes/index.md` + OpenClawKit: - -- WebSocket `connect` handshake with `role: "node"`, `caps`, `commands`, and `permissions` claims. -- Device identity + challenge signing on connect; device token persistence. -- Respond to `node.invoke.request` with `node.invoke.result`. -- Emit node events (`node.event`) for voice transcripts and agent requests. -- Use gateway RPCs needed by the iOS UI: `config.get`, `voicewake.get/set`, `chat.*`, `sessions.list`. - -## Gaps / incomplete or mismatched behavior - -**1) Declared commands exceed iOS implementation** -`GatewayConnectionController.currentCommands()` includes: - -- `system.run`, `system.which`, `system.notify`, `system.execApprovals.get`, `system.execApprovals.set` - -…but `NodeAppModel.handleInvoke` does not implement any `system.*` commands and will return `INVALID_REQUEST: unknown command` for them. This is a protocol-level mismatch: the gateway will believe iOS supports system execution + notifications, but the node cannot fulfill those requests. - -**2) Permissions map is always empty** -iOS sends `permissions: [:]` in its connect options, while macOS node reports real permission states via `PermissionManager`. This means the gateway cannot reason about iOS permission availability even though camera/mic/location/screen limitations materially affect command success. - -**3) Canvas parity gaps** - -- `canvas.hide` is currently a no-op on iOS (returns ok but doesn’t change UI). -- `canvas.present` ignores placement params (macOS supports window placement). - -These may be acceptable platform limitations, but they should be explicitly handled/documented so the node surface is consistent and predictable. - -## iOS vs. macOS node feature parity - -macOS node mode (`apps/macos/Sources/OpenClaw/NodeMode/*`) supports: - -- `system.run`, `system.which`, `system.notify`, `system.execApprovals.get/set`. -- Permission reporting in `connect.permissions`. -- Canvas window placement + hide. - -iOS currently implements the shared node surface (canvas/camera/screen/location + voice) but does **not** match macOS on the system/exec side and permission reporting. - -## Prioritized work items (ordered by importance) - -1. **Fix the command/implementation mismatch for `system.*`** - - Either remove `system.*` from iOS `currentCommands()` **or** implement iOS equivalents (at minimum `system.notify` via local notifications) with clear error semantics for unsupported actions. - - This is the highest risk mismatch because it misleads the gateway and any operator about what the iOS node can actually do. - -2. **Report real iOS permission state in `connect.permissions`** - - Mirror macOS behavior by sending camera/microphone/location/screen-recording permission flags. - - This enables the gateway to make better decisions and reduces “it failed because permissions” surprises. - -3. **Clarify/normalize iOS canvas behaviors** - - Decide how `canvas.hide` should behave on iOS (e.g., return to the local scaffold) and implement it. - - Document that `canvas.present` ignores placement on iOS, or add a platform-specific best effort. - -4. **Explicitly document platform deltas vs. macOS node** - - The docs currently describe `system.*` under “Nodes” and cite macOS/headless node support. iOS should be clearly marked as not supporting system exec to avoid incorrect user expectations. - -5. **Release readiness (if the goal is to move beyond internal preview)** - - Docs state the iOS app is “internal preview” (`docs/platforms/ios.md`). - - If public distribution is desired, build out TestFlight/App Store release steps (fastlane exists in `apps/ios/fastlane/`). - -## Files referenced (key evidence) - -- iOS node behavior: `apps/ios/Sources/Model/NodeAppModel.swift` -- iOS command declarations: `apps/ios/Sources/Gateway/GatewayConnectionController.swift` -- iOS discovery + TLS: `apps/ios/Sources/Gateway/*` -- iOS voice: `apps/ios/Sources/Voice/*` -- iOS screen/camera/location: `apps/ios/Sources/Screen/*`, `apps/ios/Sources/Camera/*`, `apps/ios/Sources/Location/*` -- Shared protocol + commands: `apps/shared/OpenClawKit/Sources/OpenClawKit/*` -- macOS node runtime: `apps/macos/Sources/OpenClaw/NodeMode/*` -- Node + protocol docs: `docs/nodes/index.md`, `docs/gateway/protocol.md`, `docs/platforms/ios.md` diff --git a/apps/ios/Sources/Calendar/CalendarService.swift b/apps/ios/Sources/Calendar/CalendarService.swift deleted file mode 100644 index 2ab1cb460e..0000000000 --- a/apps/ios/Sources/Calendar/CalendarService.swift +++ /dev/null @@ -1,173 +0,0 @@ -import EventKit -import Foundation -import OpenClawKit - -final class CalendarService: CalendarServicing { - func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload { - let store = EKEventStore() - let status = EKEventStore.authorizationStatus(for: .event) - let authorized = await Self.ensureAuthorization(store: store, status: status) - guard authorized else { - throw NSError(domain: "Calendar", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission", - ]) - } - - let (start, end) = Self.resolveRange( - startISO: params.startISO, - endISO: params.endISO) - let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil) - let events = store.events(matching: predicate) - let limit = max(1, min(params.limit ?? 50, 500)) - let selected = Array(events.prefix(limit)) - - let formatter = ISO8601DateFormatter() - let payload = selected.map { event in - OpenClawCalendarEventPayload( - identifier: event.eventIdentifier ?? UUID().uuidString, - title: event.title ?? "(untitled)", - startISO: formatter.string(from: event.startDate), - endISO: formatter.string(from: event.endDate), - isAllDay: event.isAllDay, - location: event.location, - calendarTitle: event.calendar.title) - } - - return OpenClawCalendarEventsPayload(events: payload) - } - - func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload { - let store = EKEventStore() - let status = EKEventStore.authorizationStatus(for: .event) - let authorized = await Self.ensureWriteAuthorization(store: store, status: status) - guard authorized else { - throw NSError(domain: "Calendar", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission", - ]) - } - - let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) - guard !title.isEmpty else { - throw NSError(domain: "Calendar", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "CALENDAR_INVALID: title required", - ]) - } - - let formatter = ISO8601DateFormatter() - guard let start = formatter.date(from: params.startISO) else { - throw NSError(domain: "Calendar", code: 4, userInfo: [ - NSLocalizedDescriptionKey: "CALENDAR_INVALID: startISO required", - ]) - } - guard let end = formatter.date(from: params.endISO) else { - throw NSError(domain: "Calendar", code: 5, userInfo: [ - NSLocalizedDescriptionKey: "CALENDAR_INVALID: endISO required", - ]) - } - - let event = EKEvent(eventStore: store) - event.title = title - event.startDate = start - event.endDate = end - event.isAllDay = params.isAllDay ?? false - if let location = params.location?.trimmingCharacters(in: .whitespacesAndNewlines), !location.isEmpty { - event.location = location - } - if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty { - event.notes = notes - } - event.calendar = try Self.resolveCalendar( - store: store, - calendarId: params.calendarId, - calendarTitle: params.calendarTitle) - - try store.save(event, span: .thisEvent) - - let payload = OpenClawCalendarEventPayload( - identifier: event.eventIdentifier ?? UUID().uuidString, - title: event.title ?? title, - startISO: formatter.string(from: event.startDate), - endISO: formatter.string(from: event.endDate), - isAllDay: event.isAllDay, - location: event.location, - calendarTitle: event.calendar.title) - - return OpenClawCalendarAddPayload(event: payload) - } - - private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool { - switch status { - case .authorized: - return true - case .notDetermined: - return await withCheckedContinuation { cont in - store.requestAccess(to: .event) { granted, _ in - cont.resume(returning: granted) - } - } - case .restricted, .denied: - return false - case .fullAccess: - return true - case .writeOnly: - return false - @unknown default: - return false - } - } - - private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool { - switch status { - case .authorized, .fullAccess, .writeOnly: - return true - case .notDetermined: - return await withCheckedContinuation { cont in - store.requestAccess(to: .event) { granted, _ in - cont.resume(returning: granted) - } - } - case .restricted, .denied: - return false - @unknown default: - return false - } - } - - private static func resolveCalendar( - store: EKEventStore, - calendarId: String?, - calendarTitle: String?) throws -> EKCalendar - { - if let id = calendarId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty, - let calendar = store.calendar(withIdentifier: id) - { - return calendar - } - - if let title = calendarTitle?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { - if let calendar = store.calendars(for: .event).first(where: { - $0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame - }) { - return calendar - } - throw NSError(domain: "Calendar", code: 6, userInfo: [ - NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no calendar named \(title)", - ]) - } - - if let fallback = store.defaultCalendarForNewEvents { - return fallback - } - - throw NSError(domain: "Calendar", code: 7, userInfo: [ - NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no default calendar", - ]) - } - - private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) { - let formatter = ISO8601DateFormatter() - let start = startISO.flatMap { formatter.date(from: $0) } ?? Date() - let end = endISO.flatMap { formatter.date(from: $0) } ?? start.addingTimeInterval(7 * 24 * 3600) - return (start, end) - } -} diff --git a/apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift b/apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift deleted file mode 100644 index 6dbdd51eb8..0000000000 --- a/apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation -import OpenClawKit - -@MainActor -final class NodeCapabilityRouter { - enum RouterError: Error { - case unknownCommand - case handlerUnavailable - } - - typealias Handler = (BridgeInvokeRequest) async throws -> BridgeInvokeResponse - - private let handlers: [String: Handler] - - init(handlers: [String: Handler]) { - self.handlers = handlers - } - - func handle(_ request: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - guard let handler = handlers[request.command] else { - throw RouterError.unknownCommand - } - return try await handler(request) - } -} diff --git a/apps/ios/Sources/Contacts/ContactsService.swift b/apps/ios/Sources/Contacts/ContactsService.swift deleted file mode 100644 index 48b9f5066b..0000000000 --- a/apps/ios/Sources/Contacts/ContactsService.swift +++ /dev/null @@ -1,214 +0,0 @@ -import Contacts -import Foundation -import OpenClawKit - -final class ContactsService: ContactsServicing { - private static var payloadKeys: [CNKeyDescriptor] { - [ - CNContactIdentifierKey as CNKeyDescriptor, - CNContactGivenNameKey as CNKeyDescriptor, - CNContactFamilyNameKey as CNKeyDescriptor, - CNContactOrganizationNameKey as CNKeyDescriptor, - CNContactPhoneNumbersKey as CNKeyDescriptor, - CNContactEmailAddressesKey as CNKeyDescriptor, - ] - } - - func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload { - let store = CNContactStore() - let status = CNContactStore.authorizationStatus(for: .contacts) - let authorized = await Self.ensureAuthorization(store: store, status: status) - guard authorized else { - throw NSError(domain: "Contacts", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission", - ]) - } - - let limit = max(1, min(params.limit ?? 25, 200)) - - var contacts: [CNContact] = [] - if let query = params.query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty { - let predicate = CNContact.predicateForContacts(matchingName: query) - contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys) - } else { - let request = CNContactFetchRequest(keysToFetch: Self.payloadKeys) - try store.enumerateContacts(with: request) { contact, stop in - contacts.append(contact) - if contacts.count >= limit { - stop.pointee = true - } - } - } - - let sliced = Array(contacts.prefix(limit)) - let payload = sliced.map { Self.payload(from: $0) } - - return OpenClawContactsSearchPayload(contacts: payload) - } - - func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload { - let store = CNContactStore() - let status = CNContactStore.authorizationStatus(for: .contacts) - let authorized = await Self.ensureAuthorization(store: store, status: status) - guard authorized else { - throw NSError(domain: "Contacts", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission", - ]) - } - - let givenName = params.givenName?.trimmingCharacters(in: .whitespacesAndNewlines) - let familyName = params.familyName?.trimmingCharacters(in: .whitespacesAndNewlines) - let organizationName = params.organizationName?.trimmingCharacters(in: .whitespacesAndNewlines) - let displayName = params.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) - let phoneNumbers = Self.normalizeStrings(params.phoneNumbers) - let emails = Self.normalizeStrings(params.emails, lowercased: true) - - let hasName = !(givenName ?? "").isEmpty || !(familyName ?? "").isEmpty || !(displayName ?? "").isEmpty - let hasOrg = !(organizationName ?? "").isEmpty - let hasDetails = !phoneNumbers.isEmpty || !emails.isEmpty - guard hasName || hasOrg || hasDetails else { - throw NSError(domain: "Contacts", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "CONTACTS_INVALID: include a name, organization, phone, or email", - ]) - } - - if !phoneNumbers.isEmpty || !emails.isEmpty { - if let existing = try Self.findExistingContact( - store: store, - phoneNumbers: phoneNumbers, - emails: emails) - { - return OpenClawContactsAddPayload(contact: Self.payload(from: existing)) - } - } - - let contact = CNMutableContact() - contact.givenName = givenName ?? "" - contact.familyName = familyName ?? "" - contact.organizationName = organizationName ?? "" - if contact.givenName.isEmpty && contact.familyName.isEmpty, let displayName { - contact.givenName = displayName - } - contact.phoneNumbers = phoneNumbers.map { - CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0)) - } - contact.emailAddresses = emails.map { - CNLabeledValue(label: CNLabelHome, value: $0 as NSString) - } - - let save = CNSaveRequest() - save.add(contact, toContainerWithIdentifier: nil) - try store.execute(save) - - let persisted: CNContact - if !contact.identifier.isEmpty { - persisted = try store.unifiedContact( - withIdentifier: contact.identifier, - keysToFetch: Self.payloadKeys) - } else { - persisted = contact - } - - return OpenClawContactsAddPayload(contact: Self.payload(from: persisted)) - } - - private static func ensureAuthorization(store: CNContactStore, status: CNAuthorizationStatus) async -> Bool { - switch status { - case .authorized, .limited: - return true - case .notDetermined: - return await withCheckedContinuation { cont in - store.requestAccess(for: .contacts) { granted, _ in - cont.resume(returning: granted) - } - } - case .restricted, .denied: - return false - @unknown default: - return false - } - } - - private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] { - (values ?? []) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .map { lowercased ? $0.lowercased() : $0 } - } - - private static func findExistingContact( - store: CNContactStore, - phoneNumbers: [String], - emails: [String]) throws -> CNContact? - { - if phoneNumbers.isEmpty && emails.isEmpty { - return nil - } - - var matches: [CNContact] = [] - - for phone in phoneNumbers { - let predicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: phone)) - let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys) - matches.append(contentsOf: contacts) - } - - for email in emails { - let predicate = CNContact.predicateForContacts(matchingEmailAddress: email) - let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys) - matches.append(contentsOf: contacts) - } - - return Self.matchContacts(contacts: matches, phoneNumbers: phoneNumbers, emails: emails) - } - - private static func matchContacts( - contacts: [CNContact], - phoneNumbers: [String], - emails: [String]) -> CNContact? - { - let normalizedPhones = Set(phoneNumbers.map { normalizePhone($0) }.filter { !$0.isEmpty }) - let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty }) - var seen = Set() - - for contact in contacts { - guard seen.insert(contact.identifier).inserted else { continue } - let contactPhones = Set(contact.phoneNumbers.map { normalizePhone($0.value.stringValue) }) - let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() }) - - if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) { - return contact - } - if !normalizedEmails.isEmpty, !contactEmails.isDisjoint(with: normalizedEmails) { - return contact - } - } - - return nil - } - - private static func normalizePhone(_ phone: String) -> String { - let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines) - let digits = trimmed.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) } - let normalized = String(String.UnicodeScalarView(digits)) - return normalized.isEmpty ? trimmed : normalized - } - - private static func payload(from contact: CNContact) -> OpenClawContactPayload { - OpenClawContactPayload( - identifier: contact.identifier, - displayName: CNContactFormatter.string(from: contact, style: .fullName) - ?? "\(contact.givenName) \(contact.familyName)".trimmingCharacters(in: .whitespacesAndNewlines), - givenName: contact.givenName, - familyName: contact.familyName, - organizationName: contact.organizationName, - phoneNumbers: contact.phoneNumbers.map { $0.value.stringValue }, - emails: contact.emailAddresses.map { String($0.value) }) - } - -#if DEBUG - static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool { - matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil - } -#endif -} diff --git a/apps/ios/Sources/Device/DeviceStatusService.swift b/apps/ios/Sources/Device/DeviceStatusService.swift deleted file mode 100644 index fed2716b5b..0000000000 --- a/apps/ios/Sources/Device/DeviceStatusService.swift +++ /dev/null @@ -1,87 +0,0 @@ -import Foundation -import OpenClawKit -import UIKit - -final class DeviceStatusService: DeviceStatusServicing { - private let networkStatus: NetworkStatusService - - init(networkStatus: NetworkStatusService = NetworkStatusService()) { - self.networkStatus = networkStatus - } - - func status() async throws -> OpenClawDeviceStatusPayload { - let battery = self.batteryStatus() - let thermal = self.thermalStatus() - let storage = self.storageStatus() - let network = await self.networkStatus.currentStatus() - let uptime = ProcessInfo.processInfo.systemUptime - - return OpenClawDeviceStatusPayload( - battery: battery, - thermal: thermal, - storage: storage, - network: network, - uptimeSeconds: uptime) - } - - func info() -> OpenClawDeviceInfoPayload { - let device = UIDevice.current - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0" - let locale = Locale.preferredLanguages.first ?? Locale.current.identifier - return OpenClawDeviceInfoPayload( - deviceName: device.name, - modelIdentifier: Self.modelIdentifier(), - systemName: device.systemName, - systemVersion: device.systemVersion, - appVersion: appVersion, - appBuild: appBuild, - locale: locale) - } - - private func batteryStatus() -> OpenClawBatteryStatusPayload { - let device = UIDevice.current - device.isBatteryMonitoringEnabled = true - let level = device.batteryLevel >= 0 ? Double(device.batteryLevel) : nil - let state: OpenClawBatteryState = switch device.batteryState { - case .charging: .charging - case .full: .full - case .unplugged: .unplugged - case .unknown: .unknown - @unknown default: .unknown - } - return OpenClawBatteryStatusPayload( - level: level, - state: state, - lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled) - } - - private func thermalStatus() -> OpenClawThermalStatusPayload { - let state: OpenClawThermalState = switch ProcessInfo.processInfo.thermalState { - case .nominal: .nominal - case .fair: .fair - case .serious: .serious - case .critical: .critical - @unknown default: .nominal - } - return OpenClawThermalStatusPayload(state: state) - } - - private func storageStatus() -> OpenClawStorageStatusPayload { - let attrs = (try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())) ?? [:] - let total = (attrs[.systemSize] as? NSNumber)?.int64Value ?? 0 - let free = (attrs[.systemFreeSize] as? NSNumber)?.int64Value ?? 0 - let used = max(0, total - free) - return OpenClawStorageStatusPayload(totalBytes: total, freeBytes: free, usedBytes: used) - } - - private static func modelIdentifier() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in - String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) - } - let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? "unknown" : trimmed - } -} diff --git a/apps/ios/Sources/Device/NetworkStatusService.swift b/apps/ios/Sources/Device/NetworkStatusService.swift deleted file mode 100644 index 7d92d1cc1c..0000000000 --- a/apps/ios/Sources/Device/NetworkStatusService.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation -import Network -import OpenClawKit - -final class NetworkStatusService: @unchecked Sendable { - func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload { - await withCheckedContinuation { cont in - let monitor = NWPathMonitor() - let queue = DispatchQueue(label: "bot.molt.ios.network-status") - let state = NetworkStatusState() - - monitor.pathUpdateHandler = { path in - guard state.markCompleted() else { return } - monitor.cancel() - cont.resume(returning: Self.payload(from: path)) - } - - monitor.start(queue: queue) - - queue.asyncAfter(deadline: .now() + .milliseconds(timeoutMs)) { - guard state.markCompleted() else { return } - monitor.cancel() - cont.resume(returning: Self.fallbackPayload()) - } - } - } - - private static func payload(from path: NWPath) -> OpenClawNetworkStatusPayload { - let status: OpenClawNetworkPathStatus = switch path.status { - case .satisfied: .satisfied - case .requiresConnection: .requiresConnection - case .unsatisfied: .unsatisfied - @unknown default: .unsatisfied - } - - var interfaces: [OpenClawNetworkInterfaceType] = [] - if path.usesInterfaceType(.wifi) { interfaces.append(.wifi) } - if path.usesInterfaceType(.cellular) { interfaces.append(.cellular) } - if path.usesInterfaceType(.wiredEthernet) { interfaces.append(.wired) } - if interfaces.isEmpty { interfaces.append(.other) } - - return OpenClawNetworkStatusPayload( - status: status, - isExpensive: path.isExpensive, - isConstrained: path.isConstrained, - interfaces: interfaces) - } - - private static func fallbackPayload() -> OpenClawNetworkStatusPayload { - OpenClawNetworkStatusPayload( - status: .unsatisfied, - isExpensive: false, - isConstrained: false, - interfaces: [.other]) - } -} - -private final class NetworkStatusState: @unchecked Sendable { - private let lock = NSLock() - private var completed = false - - func markCompleted() -> Bool { - self.lock.lock() - defer { self.lock.unlock() } - if self.completed { return false } - self.completed = true - return true - } -} diff --git a/apps/ios/Sources/Device/NodeDisplayName.swift b/apps/ios/Sources/Device/NodeDisplayName.swift deleted file mode 100644 index 9ddf38b24a..0000000000 --- a/apps/ios/Sources/Device/NodeDisplayName.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation -import UIKit - -enum NodeDisplayName { - private static let genericNames: Set = ["iOS Node", "iPhone Node", "iPad Node"] - - static func isGeneric(_ name: String) -> Bool { - Self.genericNames.contains(name) - } - - static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String { - switch interfaceIdiom { - case .phone: - return "iPhone Node" - case .pad: - return "iPad Node" - default: - return "iOS Node" - } - } - - static func resolve( - existing: String?, - deviceName: String, - interfaceIdiom: UIUserInterfaceIdiom - ) -> String { - let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) { - return trimmedExisting - } - - let trimmedDevice = deviceName.trimmingCharacters(in: .whitespacesAndNewlines) - if let normalized = Self.normalizedDeviceName(trimmedDevice) { - return normalized - } - - return Self.defaultValue(for: interfaceIdiom) - } - - private static func normalizedDeviceName(_ deviceName: String) -> String? { - guard !deviceName.isEmpty else { return nil } - let lower = deviceName.lowercased() - if lower.contains("iphone") || lower.contains("ipad") || lower.contains("ios") { - return deviceName - } - return nil - } -} diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 973cbc0d57..65d099c010 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -1,15 +1,8 @@ -import AVFoundation -import Contacts -import CoreLocation -import CoreMotion -import EventKit -import Foundation import OpenClawKit +import Darwin +import Foundation import Network import Observation -import Photos -import ReplayKit -import Speech import SwiftUI import UIKit @@ -67,11 +60,6 @@ final class GatewayConnectionController { port: port, useTLS: tlsParams?.required == true) else { return } - GatewaySettingsStore.saveLastGatewayConnection( - host: host, - port: port, - useTLS: tlsParams?.required == true, - stableID: gateway.stableID) self.didAutoConnect = true self.startAutoConnect( url: url, @@ -86,24 +74,13 @@ final class GatewayConnectionController { .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) - let resolvedUseTLS = useTLS || self.shouldForceTLS(host: host) - guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS) - else { return } - let stableID = self.manualStableID(host: host, port: resolvedPort) - let tlsParams = self.resolveManualTLSParams( - stableID: stableID, - tlsEnabled: resolvedUseTLS, - allowTOFUReset: self.shouldForceTLS(host: host)) + let stableID = self.manualStableID(host: host, port: port) + let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS) guard let url = self.buildGatewayURL( host: host, - port: resolvedPort, + port: port, useTLS: tlsParams?.required == true) else { return } - GatewaySettingsStore.saveLastGatewayConnection( - host: host, - port: resolvedPort, - useTLS: tlsParams?.required == true, - stableID: stableID) self.didAutoConnect = true self.startAutoConnect( url: url, @@ -113,38 +90,6 @@ final class GatewayConnectionController { password: password) } - func connectLastKnown() async { - guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return } - let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) - let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) - let resolvedUseTLS = last.useTLS || self.shouldForceTLS(host: last.host) - let tlsParams = self.resolveManualTLSParams( - stableID: last.stableID, - tlsEnabled: resolvedUseTLS, - allowTOFUReset: self.shouldForceTLS(host: last.host)) - guard let url = self.buildGatewayURL( - host: last.host, - port: last.port, - useTLS: tlsParams?.required == true) - else { return } - if resolvedUseTLS != last.useTLS { - GatewaySettingsStore.saveLastGatewayConnection( - host: last.host, - port: last.port, - useTLS: resolvedUseTLS, - stableID: last.stableID) - } - self.didAutoConnect = true - self.startAutoConnect( - url: url, - gatewayStableID: last.stableID, - tls: tlsParams, - token: token, - password: password) - } - private func updateFromDiscovery() { let newGateways = self.discovery.gateways self.gateways = newGateways @@ -189,19 +134,11 @@ final class GatewayConnectionController { guard !manualHost.isEmpty else { return } let manualPort = defaults.integer(forKey: "gateway.manual.port") + let resolvedPort = manualPort > 0 ? manualPort : 18789 let manualTLS = defaults.bool(forKey: "gateway.manual.tls") - let resolvedUseTLS = manualTLS || self.shouldForceTLS(host: manualHost) - guard let resolvedPort = self.resolveManualPort( - host: manualHost, - port: manualPort, - useTLS: resolvedUseTLS) - else { return } let stableID = self.manualStableID(host: manualHost, port: resolvedPort) - let tlsParams = self.resolveManualTLSParams( - stableID: stableID, - tlsEnabled: resolvedUseTLS, - allowTOFUReset: self.shouldForceTLS(host: manualHost)) + let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: manualTLS) guard let url = self.buildGatewayURL( host: manualHost, @@ -219,70 +156,30 @@ final class GatewayConnectionController { return } - if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() { - let resolvedUseTLS = lastKnown.useTLS || self.shouldForceTLS(host: lastKnown.host) - let tlsParams = self.resolveManualTLSParams( - stableID: lastKnown.stableID, - tlsEnabled: resolvedUseTLS, - allowTOFUReset: self.shouldForceTLS(host: lastKnown.host)) - guard let url = self.buildGatewayURL( - host: lastKnown.host, - port: lastKnown.port, - useTLS: tlsParams?.required == true) - else { return } - - self.didAutoConnect = true - self.startAutoConnect( - url: url, - gatewayStableID: lastKnown.stableID, - tls: tlsParams, - token: token, - password: password) - return - } - let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty } - if let targetStableID = candidates.first(where: { id in + guard let targetStableID = candidates.first(where: { id in self.gateways.contains(where: { $0.stableID == id }) - }) { - guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return } - guard let host = self.resolveGatewayHost(target) else { return } - let port = target.gatewayPort ?? 18789 - let tlsParams = self.resolveDiscoveredTLSParams(gateway: target) - guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true) - else { return } + }) else { return } - self.didAutoConnect = true - self.startAutoConnect( - url: url, - gatewayStableID: target.stableID, - tls: tlsParams, - token: token, - password: password) - return - } + guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return } + guard let host = self.resolveGatewayHost(target) else { return } + let port = target.gatewayPort ?? 18789 + let tlsParams = self.resolveDiscoveredTLSParams(gateway: target) + guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true) + else { return } - if self.gateways.count == 1, let gateway = self.gateways.first { - guard let host = self.resolveGatewayHost(gateway) else { return } - let port = gateway.gatewayPort ?? 18789 - let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway) - guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true) - else { return } - - self.didAutoConnect = true - self.startAutoConnect( - url: url, - gatewayStableID: gateway.stableID, - tls: tlsParams, - token: token, - password: password) - return - } + self.didAutoConnect = true + self.startAutoConnect( + url: url, + gatewayStableID: target.stableID, + tls: tlsParams, + token: token, + password: password) } private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) { @@ -308,10 +205,10 @@ final class GatewayConnectionController { password: String?) { guard let appModel else { return } - let connectOptions = self.makeConnectOptions(stableID: gatewayStableID) + let connectOptions = self.makeConnectOptions() - Task { [weak appModel] in - guard let appModel else { return } + Task { [weak self] in + guard let self else { return } await MainActor.run { appModel.gatewayStatusText = "Connecting…" } @@ -340,17 +237,13 @@ final class GatewayConnectionController { return nil } - private func resolveManualTLSParams( - stableID: String, - tlsEnabled: Bool, - allowTOFUReset: Bool = false) -> GatewayTLSParams? - { + private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? { let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) if tlsEnabled || stored != nil { return GatewayTLSParams( required: true, expectedFingerprint: stored, - allowTOFU: stored == nil || allowTOFUReset, + allowTOFU: stored == nil, storeKey: stableID) } @@ -358,12 +251,12 @@ final class GatewayConnectionController { } private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { - if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty { - return tailnet - } if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty { return lanHost } + if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty { + return tailnet + } return nil } @@ -376,69 +269,38 @@ final class GatewayConnectionController { return components.url } - private func shouldForceTLS(host: String) -> Bool { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if trimmed.isEmpty { return false } - return trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") - } - private func manualStableID(host: String, port: Int) -> String { "manual|\(host.lowercased())|\(port)" } - private func makeConnectOptions(stableID: String?) -> GatewayConnectOptions { + private func makeConnectOptions() -> GatewayConnectOptions { let defaults = UserDefaults.standard let displayName = self.resolvedDisplayName(defaults: defaults) - let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID) return GatewayConnectOptions( role: "node", scopes: [], caps: self.currentCaps(), commands: self.currentCommands(), - permissions: self.currentPermissions(), - clientId: resolvedClientId, + permissions: [:], + clientId: "openclaw-ios", clientMode: "node", clientDisplayName: displayName) } - private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String { - if let stableID, - let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) { - return override - } - let manualClientId = defaults.string(forKey: "gateway.manual.clientId")? - .trimmingCharacters(in: .whitespacesAndNewlines) - if manualClientId?.isEmpty == false { - return manualClientId! - } - return "openclaw-ios" - } - - private func resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? { - if port > 0 { - return port <= 65535 ? port : nil - } - let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedHost.isEmpty else { return nil } - if useTLS && self.shouldForceTLS(host: trimmedHost) { - return 443 - } - return 18789 - } - private func resolvedDisplayName(defaults: UserDefaults) -> String { let key = "node.displayName" - let existingRaw = defaults.string(forKey: key) - let resolved = NodeDisplayName.resolve( - existing: existingRaw, - deviceName: UIDevice.current.name, - interfaceIdiom: UIDevice.current.userInterfaceIdiom) - let existing = existingRaw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if existing.isEmpty || NodeDisplayName.isGeneric(existing) { - defaults.set(resolved, forKey: key) + let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !existing.isEmpty, existing != "iOS Node" { return existing } + + let deviceName = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) + let candidate = deviceName.isEmpty ? "iOS Node" : deviceName + + if existing.isEmpty || existing == "iOS Node" { + defaults.set(candidate, forKey: key) } - return resolved + + return candidate } private func currentCaps() -> [String] { @@ -458,15 +320,6 @@ final class GatewayConnectionController { let locationMode = OpenClawLocationMode(rawValue: locationModeRaw) ?? .off if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) } - caps.append(OpenClawCapability.device.rawValue) - caps.append(OpenClawCapability.photos.rawValue) - caps.append(OpenClawCapability.contacts.rawValue) - caps.append(OpenClawCapability.calendar.rawValue) - caps.append(OpenClawCapability.reminders.rawValue) - if Self.motionAvailable() { - caps.append(OpenClawCapability.motion.rawValue) - } - return caps } @@ -482,11 +335,10 @@ final class GatewayConnectionController { OpenClawCanvasA2UICommand.reset.rawValue, OpenClawScreenCommand.record.rawValue, OpenClawSystemCommand.notify.rawValue, - OpenClawChatCommand.push.rawValue, - OpenClawTalkCommand.pttStart.rawValue, - OpenClawTalkCommand.pttStop.rawValue, - OpenClawTalkCommand.pttCancel.rawValue, - OpenClawTalkCommand.pttOnce.rawValue, + OpenClawSystemCommand.which.rawValue, + OpenClawSystemCommand.run.rawValue, + OpenClawSystemCommand.execApprovalsGet.rawValue, + OpenClawSystemCommand.execApprovalsSet.rawValue, ] let caps = Set(self.currentCaps()) @@ -498,76 +350,10 @@ final class GatewayConnectionController { if caps.contains(OpenClawCapability.location.rawValue) { commands.append(OpenClawLocationCommand.get.rawValue) } - if caps.contains(OpenClawCapability.device.rawValue) { - commands.append(OpenClawDeviceCommand.status.rawValue) - commands.append(OpenClawDeviceCommand.info.rawValue) - } - if caps.contains(OpenClawCapability.photos.rawValue) { - commands.append(OpenClawPhotosCommand.latest.rawValue) - } - if caps.contains(OpenClawCapability.contacts.rawValue) { - commands.append(OpenClawContactsCommand.search.rawValue) - commands.append(OpenClawContactsCommand.add.rawValue) - } - if caps.contains(OpenClawCapability.calendar.rawValue) { - commands.append(OpenClawCalendarCommand.events.rawValue) - commands.append(OpenClawCalendarCommand.add.rawValue) - } - if caps.contains(OpenClawCapability.reminders.rawValue) { - commands.append(OpenClawRemindersCommand.list.rawValue) - commands.append(OpenClawRemindersCommand.add.rawValue) - } - if caps.contains(OpenClawCapability.motion.rawValue) { - commands.append(OpenClawMotionCommand.activity.rawValue) - commands.append(OpenClawMotionCommand.pedometer.rawValue) - } return commands } - private func currentPermissions() -> [String: Bool] { - var permissions: [String: Bool] = [:] - permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized - permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - permissions["speechRecognition"] = SFSpeechRecognizer.authorizationStatus() == .authorized - permissions["location"] = Self.isLocationAuthorized( - status: CLLocationManager().authorizationStatus) - && CLLocationManager.locationServicesEnabled() - permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable - - let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) - permissions["photos"] = photoStatus == .authorized || photoStatus == .limited - let contactsStatus = CNContactStore.authorizationStatus(for: .contacts) - permissions["contacts"] = contactsStatus == .authorized || contactsStatus == .limited - - let calendarStatus = EKEventStore.authorizationStatus(for: .event) - permissions["calendar"] = - calendarStatus == .authorized || calendarStatus == .fullAccess || calendarStatus == .writeOnly - let remindersStatus = EKEventStore.authorizationStatus(for: .reminder) - permissions["reminders"] = - remindersStatus == .authorized || remindersStatus == .fullAccess || remindersStatus == .writeOnly - - let motionStatus = CMMotionActivityManager.authorizationStatus() - let pedometerStatus = CMPedometer.authorizationStatus() - permissions["motion"] = - motionStatus == .authorized || pedometerStatus == .authorized - - return permissions - } - - private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool { - switch status { - case .authorizedAlways, .authorizedWhenInUse, .authorized: - return true - default: - return false - } - } - - private static func motionAvailable() -> Bool { - CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable() - } - private func platformString() -> String { let v = ProcessInfo.processInfo.operatingSystemVersion let name = switch UIDevice.current.userInterfaceIdiom { @@ -621,10 +407,6 @@ extension GatewayConnectionController { self.currentCommands() } - func _test_currentPermissions() -> [String: Bool] { - self.currentPermissions() - } - func _test_platformString() -> String { self.platformString() } diff --git a/apps/ios/Sources/Gateway/GatewayHealthMonitor.swift b/apps/ios/Sources/Gateway/GatewayHealthMonitor.swift deleted file mode 100644 index 182df942c9..0000000000 --- a/apps/ios/Sources/Gateway/GatewayHealthMonitor.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Foundation -import OpenClawKit - -@MainActor -final class GatewayHealthMonitor { - struct Config: Sendable { - var intervalSeconds: Double - var timeoutSeconds: Double - var maxFailures: Int - } - - private let config: Config - private let sleep: @Sendable (UInt64) async -> Void - private var task: Task? - - init( - config: Config = Config(intervalSeconds: 15, timeoutSeconds: 5, maxFailures: 3), - sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in - try? await Task.sleep(nanoseconds: nanoseconds) - } - ) { - self.config = config - self.sleep = sleep - } - - func start( - check: @escaping @Sendable () async throws -> Bool, - onFailure: @escaping @Sendable (_ failureCount: Int) async -> Void) - { - self.stop() - let config = self.config - let sleep = self.sleep - self.task = Task { @MainActor in - var failures = 0 - while !Task.isCancelled { - let ok = await Self.runCheck(check: check, timeoutSeconds: config.timeoutSeconds) - if ok { - failures = 0 - } else { - failures += 1 - if failures >= max(1, config.maxFailures) { - await onFailure(failures) - failures = 0 - } - } - - if Task.isCancelled { break } - let interval = max(0.0, config.intervalSeconds) - let nanos = UInt64(interval * 1_000_000_000) - if nanos > 0 { - await sleep(nanos) - } else { - await Task.yield() - } - } - } - } - - func stop() { - self.task?.cancel() - self.task = nil - } - - private static func runCheck( - check: @escaping @Sendable () async throws -> Bool, - timeoutSeconds: Double) async -> Bool - { - let timeout = max(0.0, timeoutSeconds) - if timeout == 0 { - return (try? await check()) ?? false - } - do { - let timeoutError = NSError( - domain: "GatewayHealthMonitor", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "health check timed out"]) - return try await AsyncTimeout.withTimeout( - seconds: timeout, - onTimeout: { timeoutError }, - operation: check) - } catch { - return false - } - } -} diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index 68a3eb0c40..4560dab788 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -11,13 +11,7 @@ enum GatewaySettingsStore { private static let manualHostDefaultsKey = "gateway.manual.host" private static let manualPortDefaultsKey = "gateway.manual.port" private static let manualTlsDefaultsKey = "gateway.manual.tls" - private static let manualPasswordDefaultsKey = "gateway.manual.password" private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs" - private static let lastGatewayHostDefaultsKey = "gateway.last.host" - private static let lastGatewayPortDefaultsKey = "gateway.last.port" - private static let lastGatewayTlsDefaultsKey = "gateway.last.tls" - private static let lastGatewayStableIDDefaultsKey = "gateway.last.stableID" - private static let clientIdOverrideDefaultsPrefix = "gateway.clientIdOverride." private static let instanceIdAccount = "instanceId" private static let preferredGatewayStableIDAccount = "preferredStableID" @@ -27,7 +21,6 @@ enum GatewaySettingsStore { self.ensureStableInstanceID() self.ensurePreferredGatewayStableID() self.ensureLastDiscoveredGatewayStableID() - self.ensureManualGatewayPassword() } static func loadStableInstanceID() -> String? { @@ -114,49 +107,6 @@ enum GatewaySettingsStore { account: self.gatewayPasswordAccount(instanceId: instanceId)) } - static func saveLastGatewayConnection(host: String, port: Int, useTLS: Bool, stableID: String) { - let defaults = UserDefaults.standard - defaults.set(host, forKey: self.lastGatewayHostDefaultsKey) - defaults.set(port, forKey: self.lastGatewayPortDefaultsKey) - defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey) - defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey) - } - - static func loadLastGatewayConnection() -> (host: String, port: Int, useTLS: Bool, stableID: String)? { - let defaults = UserDefaults.standard - let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey) - let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey) - let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - guard !host.isEmpty, port > 0, port <= 65535, !stableID.isEmpty else { return nil } - return (host: host, port: port, useTLS: useTLS, stableID: stableID) - } - - static func loadGatewayClientIdOverride(stableID: String) -> String? { - let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedID.isEmpty else { return nil } - let key = self.clientIdOverrideDefaultsPrefix + trimmedID - let value = UserDefaults.standard.string(forKey: key)? - .trimmingCharacters(in: .whitespacesAndNewlines) - if value?.isEmpty == false { return value } - return nil - } - - static func saveGatewayClientIdOverride(stableID: String, clientId: String?) { - let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedID.isEmpty else { return } - let key = self.clientIdOverrideDefaultsPrefix + trimmedID - let trimmedClientId = clientId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if trimmedClientId.isEmpty { - UserDefaults.standard.removeObject(forKey: key) - } else { - UserDefaults.standard.set(trimmedClientId, forKey: key) - } - } - private static func gatewayTokenAccount(instanceId: String) -> String { "gateway-token.\(instanceId)" } @@ -224,23 +174,4 @@ enum GatewaySettingsStore { } } - private static func ensureManualGatewayPassword() { - let defaults = UserDefaults.standard - let instanceId = defaults.string(forKey: self.instanceIdDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !instanceId.isEmpty else { return } - - let manualPassword = defaults.string(forKey: self.manualPasswordDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !manualPassword.isEmpty else { return } - - if self.loadGatewayPassword(instanceId: instanceId) == nil { - self.saveGatewayPassword(manualPassword, instanceId: instanceId) - } - - if self.loadGatewayPassword(instanceId: instanceId) == manualPassword { - defaults.removeObject(forKey: self.manualPasswordDefaultsKey) - } - } - } diff --git a/apps/ios/Sources/Media/PhotoLibraryService.swift b/apps/ios/Sources/Media/PhotoLibraryService.swift deleted file mode 100644 index c8655c11c2..0000000000 --- a/apps/ios/Sources/Media/PhotoLibraryService.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation -import Photos -import OpenClawKit -import UIKit - -final class PhotoLibraryService: PhotosServicing { - func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload { - let status = await Self.ensureAuthorization() - guard status == .authorized || status == .limited else { - throw NSError(domain: "Photos", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "PHOTOS_PERMISSION_REQUIRED: grant Photos permission", - ]) - } - - let limit = max(1, min(params.limit ?? 1, 20)) - let fetchOptions = PHFetchOptions() - fetchOptions.fetchLimit = limit - fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] - let assets = PHAsset.fetchAssets(with: .image, options: fetchOptions) - - var results: [OpenClawPhotoPayload] = [] - let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600 - let quality = params.quality.map { max(0.1, min(1.0, $0)) } ?? 0.85 - let formatter = ISO8601DateFormatter() - - assets.enumerateObjects { asset, _, stop in - if results.count >= limit { stop.pointee = true; return } - if let payload = try? Self.renderAsset( - asset, - maxWidth: maxWidth, - quality: quality, - formatter: formatter) - { - results.append(payload) - } - } - - return OpenClawPhotosLatestPayload(photos: results) - } - - private static func ensureAuthorization() async -> PHAuthorizationStatus { - let current = PHPhotoLibrary.authorizationStatus(for: .readWrite) - if current == .notDetermined { - return await withCheckedContinuation { cont in - PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in - cont.resume(returning: status) - } - } - } - return current - } - - private static func renderAsset( - _ asset: PHAsset, - maxWidth: Int, - quality: Double, - formatter: ISO8601DateFormatter) throws -> OpenClawPhotoPayload - { - let manager = PHImageManager.default() - let options = PHImageRequestOptions() - options.isSynchronous = true - options.isNetworkAccessAllowed = true - options.deliveryMode = .highQualityFormat - - let targetSize: CGSize = { - guard maxWidth > 0 else { return PHImageManagerMaximumSize } - let aspect = CGFloat(asset.pixelHeight) / CGFloat(max(1, asset.pixelWidth)) - let width = CGFloat(maxWidth) - return CGSize(width: width, height: width * aspect) - }() - - var image: UIImage? - manager.requestImage( - for: asset, - targetSize: targetSize, - contentMode: .aspectFit, - options: options) - { result, _ in - image = result - } - - guard let image else { - throw NSError(domain: "Photos", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "photo load failed", - ]) - } - - let jpeg = image.jpegData(compressionQuality: quality) - guard let data = jpeg else { - throw NSError(domain: "Photos", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "photo encode failed", - ]) - } - - let created = asset.creationDate.map { formatter.string(from: $0) } - return OpenClawPhotoPayload( - format: "jpeg", - base64: data.base64EncodedString(), - width: Int(image.size.width), - height: Int(image.size.height), - createdAt: created) - } -} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 43434c7bde..963318a8a2 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1,41 +1,8 @@ -import OpenClawChatUI import OpenClawKit +import Network import Observation import SwiftUI import UIKit -import UserNotifications - -// Wrap errors without pulling non-Sendable types into async notification paths. -private struct NotificationCallError: Error, Sendable { - let message: String -} - -// Ensures notification requests return promptly even if the system prompt blocks. -private final class NotificationInvokeLatch: @unchecked Sendable { - private let lock = NSLock() - private var continuation: CheckedContinuation, Never>? - private var resumed = false - - func setContinuation(_ continuation: CheckedContinuation, Never>) { - self.lock.lock() - defer { self.lock.unlock() } - self.continuation = continuation - } - - func resume(_ response: Result) { - let cont: CheckedContinuation, Never>? - self.lock.lock() - if self.resumed { - self.lock.unlock() - return - } - self.resumed = true - cont = self.continuation - self.continuation = nil - self.lock.unlock() - cont?.resume(returning: response) - } -} @MainActor @Observable @@ -48,9 +15,9 @@ final class NodeAppModel { } var isBackgrounded: Bool = false - let screen: ScreenController - private let camera: any CameraServicing - private let screenRecorder: any ScreenRecordingServicing + let screen = ScreenController() + let camera = CameraController() + private let screenRecorder = ScreenRecordService() var gatewayStatusText: String = "Offline" var gatewayServerName: String? var gatewayRemoteAddress: String? @@ -62,20 +29,10 @@ final class NodeAppModel { private var gatewayTask: Task? private var voiceWakeSyncTask: Task? @ObservationIgnored private var cameraHUDDismissTask: Task? - @ObservationIgnored private lazy var capabilityRouter: NodeCapabilityRouter = self.buildCapabilityRouter() - private let gatewayHealthMonitor = GatewayHealthMonitor() - private let notificationCenter: NotificationCentering let voiceWake = VoiceWakeManager() - let talkMode: TalkModeManager - private let locationService: any LocationServicing - private let deviceStatusService: any DeviceStatusServicing - private let photosService: any PhotosServicing - private let contactsService: any ContactsServicing - private let calendarService: any CalendarServicing - private let remindersService: any RemindersServicing - private let motionService: any MotionServicing + let talkMode = TalkModeManager() + private let locationService = LocationService() private var lastAutoA2uiURL: String? - private var pttVoiceWakeSuspended = false private var gatewayConnected = false var gatewaySession: GatewayNodeSession { self.gateway } @@ -85,33 +42,7 @@ final class NodeAppModel { var cameraFlashNonce: Int = 0 var screenRecordActive: Bool = false - init( - screen: ScreenController = ScreenController(), - camera: any CameraServicing = CameraController(), - screenRecorder: any ScreenRecordingServicing = ScreenRecordService(), - locationService: any LocationServicing = LocationService(), - notificationCenter: NotificationCentering = LiveNotificationCenter(), - deviceStatusService: any DeviceStatusServicing = DeviceStatusService(), - photosService: any PhotosServicing = PhotoLibraryService(), - contactsService: any ContactsServicing = ContactsService(), - calendarService: any CalendarServicing = CalendarService(), - remindersService: any RemindersServicing = RemindersService(), - motionService: any MotionServicing = MotionService(), - talkMode: TalkModeManager = TalkModeManager()) - { - self.screen = screen - self.camera = camera - self.screenRecorder = screenRecorder - self.locationService = locationService - self.notificationCenter = notificationCenter - self.deviceStatusService = deviceStatusService - self.photosService = photosService - self.contactsService = contactsService - self.calendarService = calendarService - self.remindersService = remindersService - self.motionService = motionService - self.talkMode = talkMode - + init() { self.voiceWake.configure { [weak self] cmd in guard let self else { return } let sessionKey = await MainActor.run { self.mainSessionKey } @@ -176,10 +107,7 @@ final class NodeAppModel { return raw.isEmpty ? "-" : raw }() - let host = NodeDisplayName.resolve( - existing: UserDefaults.standard.string(forKey: "node.displayName"), - deviceName: UIDevice.current.name, - interfaceIdiom: UIDevice.current.userInterfaceIdiom) + let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased() let contextJSON = OpenClawCanvasA2UIAction.compactJSON(userAction["context"]) let sessionKey = self.mainSessionKey @@ -247,12 +175,8 @@ final class NodeAppModel { switch phase { case .background: self.isBackgrounded = true - self.stopGatewayHealthMonitor() case .active, .inactive: self.isBackgrounded = false - if self.gatewayConnected { - self.startGatewayHealthMonitor() - } @unknown default: self.isBackgrounded = false } @@ -288,7 +212,6 @@ final class NodeAppModel { connectOptions: GatewayConnectOptions) { self.gatewayTask?.cancel() - self.gatewayHealthMonitor.stop() self.gatewayServerName = nil self.gatewayRemoteAddress = nil let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) @@ -300,9 +223,6 @@ final class NodeAppModel { self.gatewayTask = Task { var attempt = 0 - var currentOptions = connectOptions - var didFallbackClientId = false - let trimmedStableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) while !Task.isCancelled { await MainActor.run { if attempt == 0 { @@ -319,7 +239,7 @@ final class NodeAppModel { url: url, token: token, password: password, - connectOptions: currentOptions, + connectOptions: connectOptions, sessionBox: sessionBox, onConnected: { [weak self] in guard let self else { return } @@ -327,7 +247,6 @@ final class NodeAppModel { self.gatewayStatusText = "Connected" self.gatewayServerName = url.host ?? "gateway" self.gatewayConnected = true - self.talkMode.updateGatewayConnected(true) } if let addr = await self.gateway.currentRemoteAddress() { await MainActor.run { @@ -336,7 +255,6 @@ final class NodeAppModel { } await self.refreshBrandingFromGateway() await self.startVoiceWakeSync() - await MainActor.run { self.startGatewayHealthMonitor() } await self.showA2UIOnConnectIfNeeded() }, onDisconnected: { [weak self] reason in @@ -345,11 +263,9 @@ final class NodeAppModel { self.gatewayStatusText = "Disconnected" self.gatewayRemoteAddress = nil self.gatewayConnected = false - self.talkMode.updateGatewayConnected(false) self.showLocalCanvasOnDisconnect() self.gatewayStatusText = "Disconnected: \(reason)" } - await MainActor.run { self.stopGatewayHealthMonitor() } }, onInvoke: { [weak self] req in guard let self else { @@ -368,30 +284,12 @@ final class NodeAppModel { try? await Task.sleep(nanoseconds: 1_000_000_000) } catch { if Task.isCancelled { break } - if !didFallbackClientId, - let fallbackClientId = self.legacyClientIdFallback( - currentClientId: currentOptions.clientId, - error: error) - { - didFallbackClientId = true - currentOptions.clientId = fallbackClientId - if !trimmedStableID.isEmpty { - GatewaySettingsStore.saveGatewayClientIdOverride( - stableID: trimmedStableID, - clientId: fallbackClientId) - } - await MainActor.run { - self.gatewayStatusText = "Gateway rejected client id. Retrying…" - } - continue - } attempt += 1 await MainActor.run { self.gatewayStatusText = "Gateway error: \(error.localizedDescription)" self.gatewayServerName = nil self.gatewayRemoteAddress = nil self.gatewayConnected = false - self.talkMode.updateGatewayConnected(false) self.showLocalCanvasOnDisconnect() } let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt))) @@ -405,7 +303,6 @@ final class NodeAppModel { self.gatewayRemoteAddress = nil self.connectedGatewayID = nil self.gatewayConnected = false - self.talkMode.updateGatewayConnected(false) self.seamColorHex = nil if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { self.mainSessionKey = "main" @@ -416,29 +313,17 @@ final class NodeAppModel { } } - private func legacyClientIdFallback(currentClientId: String, error: Error) -> String? { - let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard normalizedClientId == "openclaw-ios" else { return nil } - let message = error.localizedDescription.lowercased() - guard message.contains("invalid connect params"), message.contains("/client/id") else { - return nil - } - return "moltbot-ios" - } - func disconnectGateway() { self.gatewayTask?.cancel() self.gatewayTask = nil self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil - self.gatewayHealthMonitor.stop() Task { await self.gateway.disconnect() } self.gatewayStatusText = "Offline" self.gatewayServerName = nil self.gatewayRemoteAddress = nil self.connectedGatewayID = nil self.gatewayConnected = false - self.talkMode.updateGatewayConnected(false) self.seamColorHex = nil if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { self.mainSessionKey = "main" @@ -533,30 +418,6 @@ final class NodeAppModel { } } - private func startGatewayHealthMonitor() { - self.gatewayHealthMonitor.start( - check: { [weak self] in - guard let self else { return false } - do { - let data = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: 6) - guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else { - return false - } - return decoded.ok ?? false - } catch { - return false - } - }, - onFailure: { [weak self] _ in - guard let self else { return } - await self.gateway.disconnect() - }) - } - - private func stopGatewayHealthMonitor() { - self.gatewayHealthMonitor.stop() - } - private func refreshWakeWordsFromGateway() async { do { let data = try await self.gateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) @@ -662,19 +523,30 @@ final class NodeAppModel { } do { - return try await self.capabilityRouter.handle(req) - } catch let error as NodeCapabilityRouter.RouterError { - switch error { - case .unknownCommand: + switch command { + case OpenClawLocationCommand.get.rawValue: + return try await self.handleLocationInvoke(req) + case OpenClawCanvasCommand.present.rawValue, + OpenClawCanvasCommand.hide.rawValue, + OpenClawCanvasCommand.navigate.rawValue, + OpenClawCanvasCommand.evalJS.rawValue, + OpenClawCanvasCommand.snapshot.rawValue: + return try await self.handleCanvasInvoke(req) + case OpenClawCanvasA2UICommand.reset.rawValue, + OpenClawCanvasA2UICommand.push.rawValue, + OpenClawCanvasA2UICommand.pushJSONL.rawValue: + return try await self.handleCanvasA2UIInvoke(req) + case OpenClawCameraCommand.list.rawValue, + OpenClawCameraCommand.snap.rawValue, + OpenClawCameraCommand.clip.rawValue: + return try await self.handleCameraInvoke(req) + case OpenClawScreenCommand.record.rawValue: + return try await self.handleScreenRecordInvoke(req) + default: return BridgeInvokeResponse( id: req.id, ok: false, error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - case .handlerUnavailable: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .unavailable, message: "node handler unavailable")) } } catch { if command.hasPrefix("camera.") { @@ -689,8 +561,7 @@ final class NodeAppModel { } private func isBackgroundRestricted(_ command: String) -> Bool { - command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.") || - command.hasPrefix("talk.") + command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.") } private func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { @@ -755,7 +626,6 @@ final class NodeAppModel { private func handleCanvasInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { switch req.command { case OpenClawCanvasCommand.present.rawValue: - // iOS ignores placement hints; canvas always fills the screen. let params = (try? Self.decodeParams(OpenClawCanvasPresentParams.self, from: req.paramsJSON)) ?? OpenClawCanvasPresentParams() let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -766,7 +636,6 @@ final class NodeAppModel { } return BridgeInvokeResponse(id: req.id, ok: true) case OpenClawCanvasCommand.hide.rawValue: - self.screen.showDefaultCanvas() return BridgeInvokeResponse(id: req.id, ok: true) case OpenClawCanvasCommand.navigate.rawValue: let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON) @@ -990,427 +859,9 @@ final class NodeAppModel { return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) } - private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = try Self.decodeParams(OpenClawSystemNotifyParams.self, from: req.paramsJSON) - let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) - let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) - if title.isEmpty, body.isEmpty { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty notification")) - } - - let finalStatus = await self.requestNotificationAuthorizationIfNeeded() - guard finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications")) - } - - let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in - let content = UNMutableNotificationContent() - content.title = title - content.body = body - if #available(iOS 15.0, *) { - switch params.priority ?? .active { - case .passive: - content.interruptionLevel = .passive - case .timeSensitive: - content.interruptionLevel = .timeSensitive - case .active: - content.interruptionLevel = .active - } - } - let soundValue = params.sound?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if let soundValue, ["none", "silent", "off", "false", "0"].contains(soundValue) { - content.sound = nil - } else { - content.sound = .default - } - let request = UNNotificationRequest( - identifier: UUID().uuidString, - content: content, - trigger: nil) - try await notificationCenter.add(request) - } - if case let .failure(error) = addResult { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .unavailable, message: "NOTIFICATION_FAILED: \(error.message)")) - } - return BridgeInvokeResponse(id: req.id, ok: true) - } - - private func handleChatPushInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = try Self.decodeParams(OpenClawChatPushParams.self, from: req.paramsJSON) - let text = params.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty chat.push text")) - } - - let finalStatus = await self.requestNotificationAuthorizationIfNeeded() - let messageId = UUID().uuidString - if finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral { - let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in - let content = UNMutableNotificationContent() - content.title = "OpenClaw" - content.body = text - content.sound = .default - content.userInfo = ["messageId": messageId] - let request = UNNotificationRequest( - identifier: messageId, - content: content, - trigger: nil) - try await notificationCenter.add(request) - } - if case let .failure(error) = addResult { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .unavailable, message: "NOTIFICATION_FAILED: \(error.message)")) - } - } - - if params.speak ?? true { - let toSpeak = text - Task { @MainActor in - try? await TalkSystemSpeechSynthesizer.shared.speak(text: toSpeak) - } - } - - let payload = OpenClawChatPushPayload(messageId: messageId) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - } - - private func requestNotificationAuthorizationIfNeeded() async -> NotificationAuthorizationStatus { - let status = await self.notificationAuthorizationStatus() - guard status == .notDetermined else { return status } - - // Avoid hanging invoke requests if the permission prompt is never answered. - _ = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in - _ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) - } - - return await self.notificationAuthorizationStatus() - } - - private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus { - let result = await self.runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in - await notificationCenter.authorizationStatus() - } - switch result { - case let .success(status): - return status - case .failure: - return .denied - } - } - - private func runNotificationCall( - timeoutSeconds: Double, - operation: @escaping @Sendable () async throws -> T - ) async -> Result { - let latch = NotificationInvokeLatch() - var opTask: Task? - var timeoutTask: Task? - defer { - opTask?.cancel() - timeoutTask?.cancel() - } - let clamped = max(0.0, timeoutSeconds) - return await withCheckedContinuation { (cont: CheckedContinuation, Never>) in - latch.setContinuation(cont) - opTask = Task { @MainActor in - do { - let value = try await operation() - latch.resume(.success(value)) - } catch { - latch.resume(.failure(NotificationCallError(message: error.localizedDescription))) - } - } - timeoutTask = Task.detached { - if clamped > 0 { - try? await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000)) - } - latch.resume(.failure(NotificationCallError(message: "notification request timed out"))) - } - } - } - - private func handleDeviceInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawDeviceCommand.status.rawValue: - let payload = try await self.deviceStatusService.status() - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawDeviceCommand.info.rawValue: - let payload = self.deviceStatusService.info() - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - private func handlePhotosInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - let params = (try? Self.decodeParams(OpenClawPhotosLatestParams.self, from: req.paramsJSON)) ?? - OpenClawPhotosLatestParams() - let payload = try await self.photosService.latest(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - } - - private func handleContactsInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawContactsCommand.search.rawValue: - let params = (try? Self.decodeParams(OpenClawContactsSearchParams.self, from: req.paramsJSON)) ?? - OpenClawContactsSearchParams() - let payload = try await self.contactsService.search(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawContactsCommand.add.rawValue: - let params = try Self.decodeParams(OpenClawContactsAddParams.self, from: req.paramsJSON) - let payload = try await self.contactsService.add(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - private func handleCalendarInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawCalendarCommand.events.rawValue: - let params = (try? Self.decodeParams(OpenClawCalendarEventsParams.self, from: req.paramsJSON)) ?? - OpenClawCalendarEventsParams() - let payload = try await self.calendarService.events(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawCalendarCommand.add.rawValue: - let params = try Self.decodeParams(OpenClawCalendarAddParams.self, from: req.paramsJSON) - let payload = try await self.calendarService.add(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - private func handleRemindersInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawRemindersCommand.list.rawValue: - let params = (try? Self.decodeParams(OpenClawRemindersListParams.self, from: req.paramsJSON)) ?? - OpenClawRemindersListParams() - let payload = try await self.remindersService.list(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawRemindersCommand.add.rawValue: - let params = try Self.decodeParams(OpenClawRemindersAddParams.self, from: req.paramsJSON) - let payload = try await self.remindersService.add(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - private func handleMotionInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawMotionCommand.activity.rawValue: - let params = (try? Self.decodeParams(OpenClawMotionActivityParams.self, from: req.paramsJSON)) ?? - OpenClawMotionActivityParams() - let payload = try await self.motionService.activities(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawMotionCommand.pedometer.rawValue: - let params = (try? Self.decodeParams(OpenClawPedometerParams.self, from: req.paramsJSON)) ?? - OpenClawPedometerParams() - let payload = try await self.motionService.pedometer(params: params) - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - - private func handleTalkInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { - switch req.command { - case OpenClawTalkCommand.pttStart.rawValue: - self.pttVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() - let payload = try await self.talkMode.beginPushToTalk() - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawTalkCommand.pttStop.rawValue: - let payload = await self.talkMode.endPushToTalk() - self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) - self.pttVoiceWakeSuspended = false - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawTalkCommand.pttCancel.rawValue: - let payload = await self.talkMode.cancelPushToTalk() - self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) - self.pttVoiceWakeSuspended = false - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - case OpenClawTalkCommand.pttOnce.rawValue: - self.pttVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() - defer { - self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended) - self.pttVoiceWakeSuspended = false - } - let payload = try await self.talkMode.runPushToTalkOnce() - let json = try Self.encodePayload(payload) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) - default: - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) - } - } - } private extension NodeAppModel { - // Central registry for node invoke routing to keep commands in one place. - func buildCapabilityRouter() -> NodeCapabilityRouter { - var handlers: [String: NodeCapabilityRouter.Handler] = [:] - - func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) { - for command in commands { - handlers[command] = handler - } - } - - register([OpenClawLocationCommand.get.rawValue]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleLocationInvoke(req) - } - - register([ - OpenClawCanvasCommand.present.rawValue, - OpenClawCanvasCommand.hide.rawValue, - OpenClawCanvasCommand.navigate.rawValue, - OpenClawCanvasCommand.evalJS.rawValue, - OpenClawCanvasCommand.snapshot.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleCanvasInvoke(req) - } - - register([ - OpenClawCanvasA2UICommand.reset.rawValue, - OpenClawCanvasA2UICommand.push.rawValue, - OpenClawCanvasA2UICommand.pushJSONL.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleCanvasA2UIInvoke(req) - } - - register([ - OpenClawCameraCommand.list.rawValue, - OpenClawCameraCommand.snap.rawValue, - OpenClawCameraCommand.clip.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleCameraInvoke(req) - } - - register([OpenClawScreenCommand.record.rawValue]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleScreenRecordInvoke(req) - } - - register([OpenClawSystemCommand.notify.rawValue]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleSystemNotify(req) - } - - register([OpenClawChatCommand.push.rawValue]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleChatPushInvoke(req) - } - - register([ - OpenClawDeviceCommand.status.rawValue, - OpenClawDeviceCommand.info.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleDeviceInvoke(req) - } - - register([OpenClawPhotosCommand.latest.rawValue]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handlePhotosInvoke(req) - } - - register([ - OpenClawContactsCommand.search.rawValue, - OpenClawContactsCommand.add.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleContactsInvoke(req) - } - - register([ - OpenClawCalendarCommand.events.rawValue, - OpenClawCalendarCommand.add.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleCalendarInvoke(req) - } - - register([ - OpenClawRemindersCommand.list.rawValue, - OpenClawRemindersCommand.add.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleRemindersInvoke(req) - } - - register([ - OpenClawMotionCommand.activity.rawValue, - OpenClawMotionCommand.pedometer.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleMotionInvoke(req) - } - - register([ - OpenClawTalkCommand.pttStart.rawValue, - OpenClawTalkCommand.pttStop.rawValue, - OpenClawTalkCommand.pttCancel.rawValue, - OpenClawTalkCommand.pttOnce.rawValue, - ]) { [weak self] req in - guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } - return try await self.handleTalkInvoke(req) - } - - return NodeCapabilityRouter(handlers: handlers) - } - func locationMode() -> OpenClawLocationMode { let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" return OpenClawLocationMode(rawValue: raw) ?? .off diff --git a/apps/ios/Sources/Motion/MotionService.swift b/apps/ios/Sources/Motion/MotionService.swift deleted file mode 100644 index c5ac7aaf95..0000000000 --- a/apps/ios/Sources/Motion/MotionService.swift +++ /dev/null @@ -1,88 +0,0 @@ -import CoreMotion -import Foundation -import OpenClawKit - -final class MotionService: MotionServicing { - func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload { - guard CMMotionActivityManager.isActivityAvailable() else { - throw NSError(domain: "Motion", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "MOTION_UNAVAILABLE: activity not supported on this device", - ]) - } - - let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO) - let limit = max(1, min(params.limit ?? 200, 1000)) - - let manager = CMMotionActivityManager() - let mapped = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawMotionActivityEntry], Error>) in - manager.queryActivityStarting(from: start, to: end, to: OperationQueue()) { activity, error in - if let error { - cont.resume(throwing: error) - } else { - let formatter = ISO8601DateFormatter() - let sliced = Array((activity ?? []).suffix(limit)) - let entries = sliced.map { entry in - OpenClawMotionActivityEntry( - startISO: formatter.string(from: entry.startDate), - endISO: formatter.string(from: end), - confidence: Self.confidenceString(entry.confidence), - isWalking: entry.walking, - isRunning: entry.running, - isCycling: entry.cycling, - isAutomotive: entry.automotive, - isStationary: entry.stationary, - isUnknown: entry.unknown) - } - cont.resume(returning: entries) - } - } - } - - return OpenClawMotionActivityPayload(activities: mapped) - } - - func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload { - guard CMPedometer.isStepCountingAvailable() else { - throw NSError(domain: "Motion", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "PEDOMETER_UNAVAILABLE: step counting not supported", - ]) - } - - let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO) - let pedometer = CMPedometer() - let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - pedometer.queryPedometerData(from: start, to: end) { data, error in - if let error { - cont.resume(throwing: error) - } else { - let formatter = ISO8601DateFormatter() - let payload = OpenClawPedometerPayload( - startISO: formatter.string(from: start), - endISO: formatter.string(from: end), - steps: data?.numberOfSteps.intValue, - distanceMeters: data?.distance?.doubleValue, - floorsAscended: data?.floorsAscended?.intValue, - floorsDescended: data?.floorsDescended?.intValue) - cont.resume(returning: payload) - } - } - } - return payload - } - - private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) { - let formatter = ISO8601DateFormatter() - let start = startISO.flatMap { formatter.date(from: $0) } ?? Calendar.current.startOfDay(for: Date()) - let end = endISO.flatMap { formatter.date(from: $0) } ?? Date() - return (start, end) - } - - private static func confidenceString(_ confidence: CMMotionActivityConfidence) -> String { - switch confidence { - case .low: "low" - case .medium: "medium" - case .high: "high" - @unknown default: "unknown" - } - } -} diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift deleted file mode 100644 index df1372c740..0000000000 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift +++ /dev/null @@ -1,311 +0,0 @@ -import SwiftUI -import UIKit - -struct GatewayOnboardingView: View { - @Environment(NodeAppModel.self) private var appModel: NodeAppModel - @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController - @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" - @AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = "" - @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false - @AppStorage("gateway.manual.host") private var manualGatewayHost: String = "" - @AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789 - @AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true - @State private var connectStatusText: String? - @State private var connectingGatewayID: String? - @State private var showManualEntry: Bool = false - @State private var manualGatewayPortText: String = "" - - var body: some View { - NavigationStack { - Form { - Section { - Text("Connect to your gateway to get started.") - LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) - LabeledContent("Status", value: self.appModel.gatewayStatusText) - } - - Section("Gateways") { - self.gatewayList() - } - - Section { - DisclosureGroup(isExpanded: self.$showManualEntry) { - TextField("Host", text: self.$manualGatewayHost) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - TextField("Port (optional)", text: self.manualPortBinding) - .keyboardType(.numberPad) - - Toggle("Use TLS", isOn: self.$manualGatewayTLS) - - Button { - Task { await self.connectManual() } - } label: { - if self.connectingGatewayID == "manual" { - HStack(spacing: 8) { - ProgressView() - .progressViewStyle(.circular) - Text("Connecting...") - } - } else { - Text("Connect manual gateway") - } - } - .disabled(self.connectingGatewayID != nil || self.manualGatewayHost - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty || !self.manualPortIsValid) - - Button("Paste gateway URL") { - self.pasteGatewayURL() - } - - Text( - "Use this when discovery is blocked. " - + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.") - .font(.footnote) - .foregroundStyle(.secondary) - } label: { - Text("Manual gateway") - } - } - - if let text = self.connectStatusText { - Section { - Text(text) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - .navigationTitle("Connect Gateway") - .onAppear { - self.syncManualPortText() - } - .onChange(of: self.manualGatewayPort) { _, _ in - self.syncManualPortText() - } - .onChange(of: self.appModel.gatewayServerName) { _, _ in - self.connectStatusText = nil - } - } - } - - @ViewBuilder - private func gatewayList() -> some View { - if self.gatewayController.gateways.isEmpty { - VStack(alignment: .leading, spacing: 10) { - Text("No gateways found yet.") - .foregroundStyle(.secondary) - Text("Make sure you are on the same Wi-Fi as your gateway, or your tailnet DNS is set.") - .font(.footnote) - .foregroundStyle(.secondary) - - if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() { - Button { - Task { await self.connectLastKnown() } - } label: { - self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port) - } - .disabled(self.connectingGatewayID != nil) - .buttonStyle(.borderedProminent) - .tint(self.appModel.seamColor) - } - } - } else { - ForEach(self.gatewayController.gateways) { gateway in - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(gateway.name) - let detailLines = self.gatewayDetailLines(gateway) - ForEach(detailLines, id: \.self) { line in - Text(line) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - Spacer() - - Button { - Task { await self.connect(gateway) } - } label: { - if self.connectingGatewayID == gateway.id { - ProgressView() - .progressViewStyle(.circular) - } else { - Text("Connect") - } - } - .disabled(self.connectingGatewayID != nil) - } - } - } - } - - private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { - self.connectingGatewayID = gateway.id - self.manualGatewayEnabled = false - self.preferredGatewayStableID = gateway.stableID - GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID) - self.lastDiscoveredGatewayStableID = gateway.stableID - GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID) - defer { self.connectingGatewayID = nil } - await self.gatewayController.connect(gateway) - } - - private func connectLastKnown() async { - self.connectingGatewayID = "last-known" - defer { self.connectingGatewayID = nil } - await self.gatewayController.connectLastKnown() - } - - private var manualPortBinding: Binding { - Binding( - get: { self.manualGatewayPortText }, - set: { newValue in - let filtered = newValue.filter(\.isNumber) - if self.manualGatewayPortText != filtered { - self.manualGatewayPortText = filtered - } - if filtered.isEmpty { - if self.manualGatewayPort != 0 { - self.manualGatewayPort = 0 - } - } else if let port = Int(filtered), self.manualGatewayPort != port { - self.manualGatewayPort = port - } - }) - } - - private var manualPortIsValid: Bool { - if self.manualGatewayPortText.isEmpty { return true } - return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535 - } - - private func syncManualPortText() { - if self.manualGatewayPort > 0 { - let next = String(self.manualGatewayPort) - if self.manualGatewayPortText != next { - self.manualGatewayPortText = next - } - } else if !self.manualGatewayPortText.isEmpty { - self.manualGatewayPortText = "" - } - } - - @ViewBuilder - private func lastKnownButtonLabel(host: String, port: Int) -> some View { - if self.connectingGatewayID == "last-known" { - HStack(spacing: 8) { - ProgressView() - .progressViewStyle(.circular) - Text("Connecting...") - } - .frame(maxWidth: .infinity) - } else { - HStack(spacing: 8) { - Image(systemName: "bolt.horizontal.circle.fill") - VStack(alignment: .leading, spacing: 2) { - Text("Connect last known") - Text("\(host):\(port)") - .font(.footnote) - .foregroundStyle(.secondary) - } - Spacer() - } - .frame(maxWidth: .infinity) - } - } - - private func connectManual() async { - let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty else { - self.connectStatusText = "Failed: host required" - return - } - guard self.manualPortIsValid else { - self.connectStatusText = "Failed: invalid port" - return - } - - self.connectingGatewayID = "manual" - self.manualGatewayEnabled = true - defer { self.connectingGatewayID = nil } - - await self.gatewayController.connectManual( - host: host, - port: self.manualGatewayPort, - useTLS: self.manualGatewayTLS) - } - - private func pasteGatewayURL() { - guard let text = UIPasteboard.general.string else { - self.connectStatusText = "Clipboard is empty." - return - } - if self.applyGatewayInput(text) { - self.connectStatusText = nil - self.showManualEntry = true - } else { - self.connectStatusText = "Could not parse gateway URL." - } - } - - private func applyGatewayInput(_ text: String) -> Bool { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - - if let components = URLComponents(string: trimmed), - let host = components.host?.trimmingCharacters(in: .whitespacesAndNewlines), - !host.isEmpty - { - let scheme = components.scheme?.lowercased() - let defaultPort: Int = { - let hostLower = host.lowercased() - if (scheme == "wss" || scheme == "https"), hostLower.hasSuffix(".ts.net") { - return 443 - } - return 18789 - }() - let port = components.port ?? defaultPort - if scheme == "wss" || scheme == "https" { - self.manualGatewayTLS = true - } else if scheme == "ws" || scheme == "http" { - self.manualGatewayTLS = false - } - self.manualGatewayHost = host - self.manualGatewayPort = port - self.manualGatewayPortText = String(port) - return true - } - - if let hostPort = SettingsNetworkingHelpers.parseHostPort(from: trimmed) { - self.manualGatewayHost = hostPort.host - self.manualGatewayPort = hostPort.port - self.manualGatewayPortText = String(hostPort.port) - return true - } - - return false - } - - private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] { - var lines: [String] = [] - if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") } - if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") } - - let gatewayPort = gateway.gatewayPort - let canvasPort = gateway.canvasPort - if gatewayPort != nil || canvasPort != nil { - let gw = gatewayPort.map(String.init) ?? "-" - let canvas = canvasPort.map(String.init) ?? "-" - lines.append("Ports: gateway \(gw) / canvas \(canvas)") - } - - if lines.isEmpty { - lines.append(gateway.debugID) - } - - return lines - } -} diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 489c1ce78a..8ad23ae20a 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -15,7 +15,7 @@ struct OpenClawApp: App { var body: some Scene { WindowGroup { - RootView() + RootCanvas() .environment(self.appModel) .environment(self.appModel.voiceWake) .environment(self.gatewayController) diff --git a/apps/ios/Sources/Reminders/RemindersService.swift b/apps/ios/Sources/Reminders/RemindersService.swift deleted file mode 100644 index dc99b9187d..0000000000 --- a/apps/ios/Sources/Reminders/RemindersService.swift +++ /dev/null @@ -1,171 +0,0 @@ -import EventKit -import Foundation -import OpenClawKit - -final class RemindersService: RemindersServicing { - func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload { - let store = EKEventStore() - let status = EKEventStore.authorizationStatus(for: .reminder) - let authorized = await Self.ensureAuthorization(store: store, status: status) - guard authorized else { - throw NSError(domain: "Reminders", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission", - ]) - } - - let limit = max(1, min(params.limit ?? 50, 500)) - let statusFilter = params.status ?? .incomplete - - let predicate = store.predicateForReminders(in: nil) - let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawReminderPayload], Error>) in - store.fetchReminders(matching: predicate) { items in - let formatter = ISO8601DateFormatter() - let filtered = (items ?? []).filter { reminder in - switch statusFilter { - case .all: - return true - case .completed: - return reminder.isCompleted - case .incomplete: - return !reminder.isCompleted - } - } - let selected = Array(filtered.prefix(limit)) - let payload = selected.map { reminder in - let due = reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) } - return OpenClawReminderPayload( - identifier: reminder.calendarItemIdentifier, - title: reminder.title, - dueISO: due.map { formatter.string(from: $0) }, - completed: reminder.isCompleted, - listName: reminder.calendar.title) - } - cont.resume(returning: payload) - } - } - - return OpenClawRemindersListPayload(reminders: payload) - } - - func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload { - let store = EKEventStore() - let status = EKEventStore.authorizationStatus(for: .reminder) - let authorized = await Self.ensureWriteAuthorization(store: store, status: status) - guard authorized else { - throw NSError(domain: "Reminders", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission", - ]) - } - - let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) - guard !title.isEmpty else { - throw NSError(domain: "Reminders", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "REMINDERS_INVALID: title required", - ]) - } - - let reminder = EKReminder(eventStore: store) - reminder.title = title - if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty { - reminder.notes = notes - } - reminder.calendar = try Self.resolveList( - store: store, - listId: params.listId, - listName: params.listName) - - if let dueISO = params.dueISO?.trimmingCharacters(in: .whitespacesAndNewlines), !dueISO.isEmpty { - let formatter = ISO8601DateFormatter() - guard let dueDate = formatter.date(from: dueISO) else { - throw NSError(domain: "Reminders", code: 4, userInfo: [ - NSLocalizedDescriptionKey: "REMINDERS_INVALID: dueISO must be ISO-8601", - ]) - } - reminder.dueDateComponents = Calendar.current.dateComponents( - [.year, .month, .day, .hour, .minute, .second], - from: dueDate) - } - - try store.save(reminder, commit: true) - - let formatter = ISO8601DateFormatter() - let due = reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) } - let payload = OpenClawReminderPayload( - identifier: reminder.calendarItemIdentifier, - title: reminder.title, - dueISO: due.map { formatter.string(from: $0) }, - completed: reminder.isCompleted, - listName: reminder.calendar.title) - - return OpenClawRemindersAddPayload(reminder: payload) - } - - private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool { - switch status { - case .authorized: - return true - case .notDetermined: - return await withCheckedContinuation { cont in - store.requestAccess(to: .reminder) { granted, _ in - cont.resume(returning: granted) - } - } - case .restricted, .denied: - return false - case .fullAccess: - return true - case .writeOnly: - return false - @unknown default: - return false - } - } - - private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool { - switch status { - case .authorized, .fullAccess, .writeOnly: - return true - case .notDetermined: - return await withCheckedContinuation { cont in - store.requestAccess(to: .reminder) { granted, _ in - cont.resume(returning: granted) - } - } - case .restricted, .denied: - return false - @unknown default: - return false - } - } - - private static func resolveList( - store: EKEventStore, - listId: String?, - listName: String?) throws -> EKCalendar - { - if let id = listId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty, - let calendar = store.calendar(withIdentifier: id) - { - return calendar - } - - if let title = listName?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { - if let calendar = store.calendars(for: .reminder).first(where: { - $0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame - }) { - return calendar - } - throw NSError(domain: "Reminders", code: 5, userInfo: [ - NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no list named \(title)", - ]) - } - - if let fallback = store.defaultCalendarForNewReminders() { - return fallback - } - - throw NSError(domain: "Reminders", code: 6, userInfo: [ - NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no default list", - ]) - } -} diff --git a/apps/ios/Sources/RootView.swift b/apps/ios/Sources/RootView.swift deleted file mode 100644 index 5938e7f227..0000000000 --- a/apps/ios/Sources/RootView.swift +++ /dev/null @@ -1,46 +0,0 @@ -import SwiftUI - -struct RootView: View { - @Environment(NodeAppModel.self) private var appModel: NodeAppModel - @AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false - @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" - @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false - @AppStorage("gateway.manual.host") private var manualGatewayHost: String = "" - - var body: some View { - Group { - if self.shouldShowOnboarding { - GatewayOnboardingView() - } else { - RootCanvas() - } - } - .onAppear { self.bootstrapOnboardingIfNeeded() } - .onChange(of: self.appModel.gatewayServerName) { _, newValue in - if newValue != nil { - self.onboardingComplete = true - } - } - } - - private var shouldShowOnboarding: Bool { - if self.appModel.gatewayServerName != nil { return false } - if self.onboardingComplete { return false } - if self.hasExistingGatewayConfig { return false } - return true - } - - private var hasExistingGatewayConfig: Bool { - if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true } - let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines) - if !preferred.isEmpty { return true } - let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) - return self.manualGatewayEnabled && !manualHost.isEmpty - } - - private func bootstrapOnboardingIfNeeded() { - if !self.onboardingComplete, self.hasExistingGatewayConfig { - self.onboardingComplete = true - } - } -} diff --git a/apps/ios/Sources/Services/NodeServiceProtocols.swift b/apps/ios/Sources/Services/NodeServiceProtocols.swift deleted file mode 100644 index 002c87ad9c..0000000000 --- a/apps/ios/Sources/Services/NodeServiceProtocols.swift +++ /dev/null @@ -1,64 +0,0 @@ -import CoreLocation -import Foundation -import OpenClawKit -import UIKit - -protocol CameraServicing: Sendable { - func listDevices() async -> [CameraController.CameraDeviceInfo] - func snap(params: OpenClawCameraSnapParams) async throws -> (format: String, base64: String, width: Int, height: Int) - func clip(params: OpenClawCameraClipParams) async throws -> (format: String, base64: String, durationMs: Int, hasAudio: Bool) -} - -protocol ScreenRecordingServicing: Sendable { - func record( - screenIndex: Int?, - durationMs: Int?, - fps: Double?, - includeAudio: Bool?, - outPath: String?) async throws -> String -} - -@MainActor -protocol LocationServicing: Sendable { - func authorizationStatus() -> CLAuthorizationStatus - func accuracyAuthorization() -> CLAccuracyAuthorization - func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus - func currentLocation( - params: OpenClawLocationGetParams, - desiredAccuracy: OpenClawLocationAccuracy, - maxAgeMs: Int?, - timeoutMs: Int?) async throws -> CLLocation -} - -protocol DeviceStatusServicing: Sendable { - func status() async throws -> OpenClawDeviceStatusPayload - func info() -> OpenClawDeviceInfoPayload -} - -protocol PhotosServicing: Sendable { - func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload -} - -protocol ContactsServicing: Sendable { - func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload - func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload -} - -protocol CalendarServicing: Sendable { - func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload - func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload -} - -protocol RemindersServicing: Sendable { - func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload - func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload -} - -protocol MotionServicing: Sendable { - func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload - func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload -} - -extension CameraController: CameraServicing {} -extension ScreenRecordService: ScreenRecordingServicing {} -extension LocationService: LocationServicing {} diff --git a/apps/ios/Sources/Services/NotificationService.swift b/apps/ios/Sources/Services/NotificationService.swift deleted file mode 100644 index 348e93edc6..0000000000 --- a/apps/ios/Sources/Services/NotificationService.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation -import UserNotifications - -enum NotificationAuthorizationStatus: Sendable { - case notDetermined - case denied - case authorized - case provisional - case ephemeral -} - -protocol NotificationCentering: Sendable { - func authorizationStatus() async -> NotificationAuthorizationStatus - func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool - func add(_ request: UNNotificationRequest) async throws -} - -struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable { - private let center: UNUserNotificationCenter - - init(center: UNUserNotificationCenter = .current()) { - self.center = center - } - - func authorizationStatus() async -> NotificationAuthorizationStatus { - let settings = await self.center.notificationSettings() - return switch settings.authorizationStatus { - case .authorized: - .authorized - case .provisional: - .provisional - case .ephemeral: - .ephemeral - case .denied: - .denied - case .notDetermined: - .notDetermined - @unknown default: - .denied - } - } - - func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { - try await self.center.requestAuthorization(options: options) - } - - func add(_ request: UNNotificationRequest) async throws { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - self.center.add(request) { error in - if let error { - cont.resume(throwing: error) - } else { - cont.resume(returning: ()) - } - } - } - } -} diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index d136725bcf..c1ee609948 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -17,8 +17,7 @@ struct SettingsTab: View { @Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController @Environment(\.dismiss) private var dismiss - @AppStorage("node.displayName") private var displayName: String = NodeDisplayName.defaultValue( - for: UIDevice.current.userInterfaceIdiom) + @AppStorage("node.displayName") private var displayName: String = "iOS Node" @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false @AppStorage("talk.enabled") private var talkEnabled: Bool = false @@ -41,7 +40,6 @@ struct SettingsTab: View { @State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue @State private var gatewayToken: String = "" @State private var gatewayPassword: String = "" - @State private var manualGatewayPortText: String = "" var body: some View { NavigationStack { @@ -122,7 +120,7 @@ struct SettingsTab: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() - TextField("Port (optional)", text: self.manualPortBinding) + TextField("Port", value: self.$manualGatewayPort, format: .number) .keyboardType(.numberPad) Toggle("Use TLS", isOn: self.$manualGatewayTLS) @@ -142,11 +140,11 @@ struct SettingsTab: View { } .disabled(self.connectingGatewayID != nil || self.manualGatewayHost .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty || !self.manualPortIsValid) + .isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535) Text( "Use this when mDNS/Bonjour discovery is blocked. " - + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.") + + "The gateway WebSocket listens on port 18789 by default.") .font(.footnote) .foregroundStyle(.secondary) @@ -234,7 +232,6 @@ struct SettingsTab: View { .onAppear { self.localIPAddress = Self.primaryIPv4Address() self.lastLocationModeRaw = self.locationEnabledModeRaw - self.syncManualPortText() let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedInstanceId.isEmpty { self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? "" @@ -258,9 +255,6 @@ struct SettingsTab: View { guard !instanceId.isEmpty else { return } GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId) } - .onChange(of: self.manualGatewayPort) { _, _ in - self.syncManualPortText() - } .onChange(of: self.appModel.gatewayServerName) { _, _ in self.connectStatus.text = nil } @@ -284,24 +278,8 @@ struct SettingsTab: View { @ViewBuilder private func gatewayList(showing: GatewayListMode) -> some View { if self.gatewayController.gateways.isEmpty { - VStack(alignment: .leading, spacing: 12) { - Text("No gateways found yet.") - .foregroundStyle(.secondary) - Text("If your gateway is on another network, connect it and ensure DNS is working.") - .font(.footnote) - .foregroundStyle(.secondary) - - if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() { - Button { - Task { await self.connectLastKnown() } - } label: { - self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port) - } - .disabled(self.connectingGatewayID != nil) - .buttonStyle(.borderedProminent) - .tint(self.appModel.seamColor) - } - } + Text("No gateways found yet.") + .foregroundStyle(.secondary) } else { let connectedID = self.appModel.connectedGatewayID let rows = self.gatewayController.gateways.filter { gateway in @@ -399,77 +377,13 @@ struct SettingsTab: View { await self.gatewayController.connect(gateway) } - private func connectLastKnown() async { - self.connectingGatewayID = "last-known" - defer { self.connectingGatewayID = nil } - await self.gatewayController.connectLastKnown() - } - - @ViewBuilder - private func lastKnownButtonLabel(host: String, port: Int) -> some View { - if self.connectingGatewayID == "last-known" { - HStack(spacing: 8) { - ProgressView() - .progressViewStyle(.circular) - Text("Connecting…") - } - .frame(maxWidth: .infinity) - } else { - HStack(spacing: 8) { - Image(systemName: "bolt.horizontal.circle.fill") - VStack(alignment: .leading, spacing: 2) { - Text("Connect last known") - Text("\(host):\(port)") - .font(.footnote) - .foregroundStyle(.secondary) - } - Spacer() - } - .frame(maxWidth: .infinity) - } - } - - private var manualPortBinding: Binding { - Binding( - get: { self.manualGatewayPortText }, - set: { newValue in - let filtered = newValue.filter(\.isNumber) - if self.manualGatewayPortText != filtered { - self.manualGatewayPortText = filtered - } - if filtered.isEmpty { - if self.manualGatewayPort != 0 { - self.manualGatewayPort = 0 - } - } else if let port = Int(filtered), self.manualGatewayPort != port { - self.manualGatewayPort = port - } - }) - } - - private var manualPortIsValid: Bool { - if self.manualGatewayPortText.isEmpty { return true } - return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535 - } - - private func syncManualPortText() { - if self.manualGatewayPort > 0 { - let next = String(self.manualGatewayPort) - if self.manualGatewayPortText != next { - self.manualGatewayPortText = next - } - } else if !self.manualGatewayPortText.isEmpty { - self.manualGatewayPortText = "" - } - } - private func connectManual() async { let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty else { self.connectStatus.text = "Failed: host required" return } - guard self.manualPortIsValid else { + guard self.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else { self.connectStatus.text = "Failed: invalid port" return } diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift index df69835435..cd81c011bb 100644 --- a/apps/ios/Sources/Status/StatusPill.swift +++ b/apps/ios/Sources/Status/StatusPill.swift @@ -72,6 +72,12 @@ struct StatusPill: View { .lineLimit(1) } .transition(.opacity.combined(with: .move(edge: .top))) + } else { + Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary) + .accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled") + .transition(.opacity.combined(with: .move(edge: .top))) } } .padding(.vertical, 8) @@ -104,7 +110,7 @@ struct StatusPill: View { if let activity { return "\(self.gateway.title), \(activity.title)" } - return self.gateway.title + return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")" } private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) { diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 6f9aa82fd7..d3adb49e1b 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -1,5 +1,4 @@ import AVFAudio -import OpenClawChatUI import OpenClawKit import OpenClawProtocol import Foundation @@ -15,26 +14,9 @@ final class TalkModeManager: NSObject { var isEnabled: Bool = false var isListening: Bool = false var isSpeaking: Bool = false - var isPushToTalkActive: Bool = false var statusText: String = "Off" - private enum CaptureMode { - case idle - case continuous - case pushToTalk - } - - private var captureMode: CaptureMode = .idle - private var resumeContinuousAfterPTT: Bool = false - private var activePTTCaptureId: String? - private var pttAutoStopEnabled: Bool = false - private var pttCompletion: CheckedContinuation? - private var pttTimeoutTask: Task? - - private let allowSimulatorCapture: Bool - private let audioEngine = AVAudioEngine() - private var inputTapInstalled = false private var speechRecognizer: SFSpeechRecognizer? private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? private var recognitionTask: SFSpeechRecognitionTask? @@ -62,34 +44,16 @@ final class TalkModeManager: NSObject { var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared private var gateway: GatewayNodeSession? - private var gatewayConnected = false private let silenceWindow: TimeInterval = 0.7 private var chatSubscribedSessionKeys = Set() - private var incrementalSpeechQueue: [String] = [] - private var incrementalSpeechTask: Task? - private var incrementalSpeechActive = false - private var incrementalSpeechUsed = false - private var incrementalSpeechLanguage: String? - private var incrementalSpeechBuffer = IncrementalSpeechBuffer() - private var incrementalSpeechContext: IncrementalSpeechContext? - private var incrementalSpeechDirective: TalkDirective? private let logger = Logger(subsystem: "bot.molt", category: "TalkMode") - init(allowSimulatorCapture: Bool = false) { - self.allowSimulatorCapture = allowSimulatorCapture - super.init() - } - func attachGateway(_ gateway: GatewayNodeSession) { self.gateway = gateway } - func updateGatewayConnected(_ connected: Bool) { - self.gatewayConnected = connected - } - func updateMainSessionKey(_ sessionKey: String?) { let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } @@ -110,7 +74,6 @@ final class TalkModeManager: NSObject { func start() async { guard self.isEnabled else { return } - guard self.captureMode != .pushToTalk else { return } if self.isListening { return } self.logger.info("start") @@ -118,17 +81,13 @@ final class TalkModeManager: NSObject { let micOk = await Self.requestMicrophonePermission() guard micOk else { self.logger.warning("start blocked: microphone permission denied") - self.statusText = Self.permissionMessage( - kind: "Microphone", - status: AVAudioSession.sharedInstance().recordPermission) + self.statusText = "Microphone permission denied" return } let speechOk = await Self.requestSpeechPermission() guard speechOk else { self.logger.warning("start blocked: speech permission denied") - self.statusText = Self.permissionMessage( - kind: "Speech recognition", - status: SFSpeechRecognizer.authorizationStatus()) + self.statusText = "Speech recognition permission denied" return } @@ -137,7 +96,6 @@ final class TalkModeManager: NSObject { try Self.configureAudioSession() try self.startRecognition() self.isListening = true - self.captureMode = .continuous self.statusText = "Listening" self.startSilenceMonitor() await self.subscribeChatIfNeeded(sessionKey: self.mainSessionKey) @@ -152,8 +110,6 @@ final class TalkModeManager: NSObject { func stop() { self.isEnabled = false self.isListening = false - self.isPushToTalkActive = false - self.captureMode = .idle self.statusText = "Off" self.lastTranscript = "" self.lastHeard = nil @@ -162,20 +118,6 @@ final class TalkModeManager: NSObject { self.stopRecognition() self.stopSpeaking() self.lastInterruptedAtSeconds = nil - let pendingPTT = self.pttCompletion != nil - let pendingCaptureId = self.activePTTCaptureId ?? UUID().uuidString - self.pttTimeoutTask?.cancel() - self.pttTimeoutTask = nil - self.pttAutoStopEnabled = false - if pendingPTT { - let payload = OpenClawTalkPTTStopPayload( - captureId: pendingCaptureId, - transcript: nil, - status: "cancelled") - self.finishPTTOnce(payload) - } - self.resumeContinuousAfterPTT = false - self.activePTTCaptureId = nil TalkSystemSpeechSynthesizer.shared.stop() do { try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) @@ -189,210 +131,11 @@ final class TalkModeManager: NSObject { self.stopSpeaking() } - func beginPushToTalk() async throws -> OpenClawTalkPTTStartPayload { - if self.isPushToTalkActive, let captureId = self.activePTTCaptureId { - return OpenClawTalkPTTStartPayload(captureId: captureId) - } - - self.stopSpeaking(storeInterruption: false) - self.pttTimeoutTask?.cancel() - self.pttTimeoutTask = nil - self.pttAutoStopEnabled = false - - self.resumeContinuousAfterPTT = self.isEnabled && self.captureMode == .continuous - self.silenceTask?.cancel() - self.silenceTask = nil - self.stopRecognition() - self.isListening = false - - let captureId = UUID().uuidString - self.activePTTCaptureId = captureId - self.lastTranscript = "" - self.lastHeard = nil - - self.statusText = "Requesting permissions…" - if !self.allowSimulatorCapture { - let micOk = await Self.requestMicrophonePermission() - guard micOk else { - self.statusText = Self.permissionMessage( - kind: "Microphone", - status: AVAudioSession.sharedInstance().recordPermission) - throw NSError(domain: "TalkMode", code: 4, userInfo: [ - NSLocalizedDescriptionKey: "Microphone permission denied", - ]) - } - let speechOk = await Self.requestSpeechPermission() - guard speechOk else { - self.statusText = Self.permissionMessage( - kind: "Speech recognition", - status: SFSpeechRecognizer.authorizationStatus()) - throw NSError(domain: "TalkMode", code: 5, userInfo: [ - NSLocalizedDescriptionKey: "Speech recognition permission denied", - ]) - } - } - - do { - try Self.configureAudioSession() - self.captureMode = .pushToTalk - try self.startRecognition() - self.isListening = true - self.isPushToTalkActive = true - self.statusText = "Listening (PTT)" - } catch { - self.isListening = false - self.isPushToTalkActive = false - self.captureMode = .idle - self.statusText = "Start failed: \(error.localizedDescription)" - throw error - } - - return OpenClawTalkPTTStartPayload(captureId: captureId) - } - - func endPushToTalk() async -> OpenClawTalkPTTStopPayload { - let captureId = self.activePTTCaptureId ?? UUID().uuidString - guard self.isPushToTalkActive else { - let payload = OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: nil, - status: "idle") - self.finishPTTOnce(payload) - return payload - } - - self.isPushToTalkActive = false - self.isListening = false - self.captureMode = .idle - self.stopRecognition() - self.pttTimeoutTask?.cancel() - self.pttTimeoutTask = nil - self.pttAutoStopEnabled = false - - let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) - self.lastTranscript = "" - self.lastHeard = nil - - guard !transcript.isEmpty else { - self.statusText = "Ready" - if self.resumeContinuousAfterPTT { - await self.start() - } - self.resumeContinuousAfterPTT = false - self.activePTTCaptureId = nil - let payload = OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: nil, - status: "empty") - self.finishPTTOnce(payload) - return payload - } - - guard self.gatewayConnected else { - self.statusText = "Gateway not connected" - if self.resumeContinuousAfterPTT { - await self.start() - } - self.resumeContinuousAfterPTT = false - self.activePTTCaptureId = nil - let payload = OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: transcript, - status: "offline") - self.finishPTTOnce(payload) - return payload - } - - self.statusText = "Thinking…" - Task { @MainActor in - await self.processTranscript(transcript, restartAfter: self.resumeContinuousAfterPTT) - } - self.resumeContinuousAfterPTT = false - self.activePTTCaptureId = nil - let payload = OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: transcript, - status: "queued") - self.finishPTTOnce(payload) - return payload - } - - func runPushToTalkOnce(maxDurationSeconds: TimeInterval = 12) async throws -> OpenClawTalkPTTStopPayload { - if self.pttCompletion != nil { - _ = await self.cancelPushToTalk() - } - - if self.isPushToTalkActive { - let captureId = self.activePTTCaptureId ?? UUID().uuidString - return OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: nil, - status: "busy") - } - - _ = try await self.beginPushToTalk() - - return await withCheckedContinuation { cont in - self.pttCompletion = cont - self.pttAutoStopEnabled = true - self.startSilenceMonitor() - self.schedulePTTTimeout(seconds: maxDurationSeconds) - } - } - - func cancelPushToTalk() async -> OpenClawTalkPTTStopPayload { - let captureId = self.activePTTCaptureId ?? UUID().uuidString - guard self.isPushToTalkActive else { - let payload = OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: nil, - status: "idle") - self.finishPTTOnce(payload) - self.pttAutoStopEnabled = false - self.pttTimeoutTask?.cancel() - self.pttTimeoutTask = nil - self.resumeContinuousAfterPTT = false - self.activePTTCaptureId = nil - return payload - } - - let shouldResume = self.resumeContinuousAfterPTT - self.isPushToTalkActive = false - self.isListening = false - self.captureMode = .idle - self.stopRecognition() - self.lastTranscript = "" - self.lastHeard = nil - self.pttAutoStopEnabled = false - self.pttTimeoutTask?.cancel() - self.pttTimeoutTask = nil - self.resumeContinuousAfterPTT = false - self.activePTTCaptureId = nil - self.statusText = "Ready" - - let payload = OpenClawTalkPTTStopPayload( - captureId: captureId, - transcript: nil, - status: "cancelled") - self.finishPTTOnce(payload) - - if shouldResume { - await self.start() - } - return payload - } - private func startRecognition() throws { #if targetEnvironment(simulator) - if !self.allowSimulatorCapture { - throw NSError(domain: "TalkMode", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator", - ]) - } else { - self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest() - self.recognitionRequest?.shouldReportPartialResults = true - return - } + throw NSError(domain: "TalkMode", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator", + ]) #endif self.stopRecognition() @@ -417,7 +160,6 @@ final class TalkModeManager: NSObject { input.removeTap(onBus: 0) let tapBlock = Self.makeAudioTapAppendCallback(request: request) input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock) - self.inputTapInstalled = true self.audioEngine.prepare() try self.audioEngine.start() @@ -443,10 +185,7 @@ final class TalkModeManager: NSObject { self.recognitionTask = nil self.recognitionRequest?.endAudio() self.recognitionRequest = nil - if self.inputTapInstalled { - self.audioEngine.inputNode.removeTap(onBus: 0) - self.inputTapInstalled = false - } + self.audioEngine.inputNode.removeTap(onBus: 0) self.audioEngine.stop() self.speechRecognizer = nil } @@ -459,8 +198,7 @@ final class TalkModeManager: NSObject { private func handleTranscript(transcript: String, isFinal: Bool) async { let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) - let ttsActive = self.isSpeechOutputActive - if ttsActive, self.interruptOnSpeech { + if self.isSpeaking, self.interruptOnSpeech { if self.shouldInterrupt(with: trimmed) { self.stopSpeaking() } @@ -474,14 +212,6 @@ final class TalkModeManager: NSObject { } if isFinal { self.lastTranscript = trimmed - guard !trimmed.isEmpty else { return } - if self.captureMode == .pushToTalk, self.pttAutoStopEnabled, self.isPushToTalkActive { - _ = await self.endPushToTalk() - return - } - if self.captureMode == .continuous, !self.isSpeechOutputActive { - await self.processTranscript(trimmed, restartAfter: true) - } } } @@ -489,7 +219,7 @@ final class TalkModeManager: NSObject { self.silenceTask?.cancel() self.silenceTask = Task { [weak self] in guard let self else { return } - while self.isEnabled || (self.isPushToTalkActive && self.pttAutoStopEnabled) { + while self.isEnabled { try? await Task.sleep(nanoseconds: 200_000_000) await self.checkSilence() } @@ -497,50 +227,16 @@ final class TalkModeManager: NSObject { } private func checkSilence() async { - if self.captureMode == .continuous { - guard self.isListening, !self.isSpeechOutputActive else { return } - let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) - guard !transcript.isEmpty else { return } - guard let lastHeard else { return } - if Date().timeIntervalSince(lastHeard) < self.silenceWindow { return } - await self.processTranscript(transcript, restartAfter: true) - return - } - - guard self.captureMode == .pushToTalk, self.pttAutoStopEnabled else { return } - guard self.isListening, !self.isSpeaking, self.isPushToTalkActive else { return } + guard self.isListening, !self.isSpeaking else { return } let transcript = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) guard !transcript.isEmpty else { return } guard let lastHeard else { return } if Date().timeIntervalSince(lastHeard) < self.silenceWindow { return } - _ = await self.endPushToTalk() + await self.finalizeTranscript(transcript) } - // Guardrail for PTT once so we don't stay open indefinitely. - private func schedulePTTTimeout(seconds: TimeInterval) { - guard seconds > 0 else { return } - let nanos = UInt64(seconds * 1_000_000_000) - self.pttTimeoutTask?.cancel() - self.pttTimeoutTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: nanos) - await self?.handlePTTTimeout() - } - } - - private func handlePTTTimeout() async { - guard self.pttAutoStopEnabled, self.isPushToTalkActive else { return } - _ = await self.endPushToTalk() - } - - private func finishPTTOnce(_ payload: OpenClawTalkPTTStopPayload) { - guard let continuation = self.pttCompletion else { return } - self.pttCompletion = nil - continuation.resume(returning: payload) - } - - private func processTranscript(_ transcript: String, restartAfter: Bool) async { + private func finalizeTranscript(_ transcript: String) async { self.isListening = false - self.captureMode = .idle self.statusText = "Thinking…" self.lastTranscript = "" self.lastHeard = nil @@ -548,12 +244,10 @@ final class TalkModeManager: NSObject { await self.reloadConfig() let prompt = self.buildPrompt(transcript: transcript) - guard self.gatewayConnected, let gateway else { + guard let gateway else { self.statusText = "Gateway not connected" self.logger.warning("finalize: gateway not connected") - if restartAfter { - await self.start() - } + await self.start() return } @@ -565,15 +259,6 @@ final class TalkModeManager: NSObject { "chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)") let runId = try await self.sendChat(prompt, gateway: gateway) self.logger.info("chat.send ok runId=\(runId, privacy: .public)") - let shouldIncremental = self.shouldUseIncrementalTTS() - var streamingTask: Task? - if shouldIncremental { - self.resetIncrementalSpeech() - streamingTask = Task { @MainActor [weak self] in - guard let self else { return } - await self.streamAssistant(runId: runId, gateway: gateway) - } - } let completion = await self.waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120) if completion == .timeout { self.logger.warning( @@ -581,52 +266,33 @@ final class TalkModeManager: NSObject { } else if completion == .aborted { self.statusText = "Aborted" self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)") - streamingTask?.cancel() - await self.finishIncrementalSpeech() await self.start() return } else if completion == .error { self.statusText = "Chat error" self.logger.warning("chat completion error runId=\(runId, privacy: .public)") - streamingTask?.cancel() - await self.finishIncrementalSpeech() await self.start() return } - var assistantText = try await self.waitForAssistantText( + guard let assistantText = try await self.waitForAssistantText( gateway: gateway, since: startedAt, timeoutSeconds: completion == .final ? 12 : 25) - if assistantText == nil, shouldIncremental { - let fallback = self.incrementalSpeechBuffer.latestText - if !fallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - assistantText = fallback - } - } - guard let assistantText else { + else { self.statusText = "No reply" self.logger.warning("assistant text timeout runId=\(runId, privacy: .public)") - streamingTask?.cancel() - await self.finishIncrementalSpeech() await self.start() return } self.logger.info("assistant text ok chars=\(assistantText.count, privacy: .public)") - streamingTask?.cancel() - if shouldIncremental { - await self.handleIncrementalAssistantFinal(text: assistantText) - } else { - await self.playAssistant(text: assistantText) - } + await self.playAssistant(text: assistantText) } catch { self.statusText = "Talk failed: \(error.localizedDescription)" self.logger.error("finalize failed: \(error.localizedDescription, privacy: .public)") } - if restartAfter { - await self.start() - } + await self.start() } private func subscribeChatIfNeeded(sessionKey: String) async { @@ -772,7 +438,24 @@ final class TalkModeManager: NSObject { let directive = parsed.directive let cleaned = parsed.stripped.trimmingCharacters(in: .whitespacesAndNewlines) guard !cleaned.isEmpty else { return } - self.applyDirective(directive) + + let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedVoice = self.resolveVoiceAlias(requestedVoice) + if requestedVoice?.isEmpty == false, resolvedVoice == nil { + self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)") + } + if let voice = resolvedVoice { + if directive?.once != true { + self.currentVoiceId = voice + self.voiceOverrideActive = true + } + } + if let model = directive?.modelId { + if directive?.once != true { + self.currentModelId = model + self.modelOverrideActive = true + } + } self.statusText = "Generating voice…" self.isSpeaking = true @@ -781,11 +464,6 @@ final class TalkModeManager: NSObject { do { let started = Date() let language = ElevenLabsTTSClient.validatedLanguage(directive?.language) - let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedVoice = self.resolveVoiceAlias(requestedVoice) - if requestedVoice?.isEmpty == false, resolvedVoice == nil { - self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)") - } let resolvedKey = (self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil) ?? @@ -904,24 +582,17 @@ final class TalkModeManager: NSObject { } private func stopSpeaking(storeInterruption: Bool = true) { - let hasIncremental = self.incrementalSpeechActive || - self.incrementalSpeechTask != nil || - !self.incrementalSpeechQueue.isEmpty - if self.isSpeaking { - let interruptedAt = self.lastPlaybackWasPCM - ? self.pcmPlayer.stop() - : self.mp3Player.stop() - if storeInterruption { - self.lastInterruptedAtSeconds = interruptedAt - } - _ = self.lastPlaybackWasPCM - ? self.mp3Player.stop() - : self.pcmPlayer.stop() - } else if !hasIncremental { - return + guard self.isSpeaking else { return } + let interruptedAt = self.lastPlaybackWasPCM + ? self.pcmPlayer.stop() + : self.mp3Player.stop() + if storeInterruption { + self.lastInterruptedAtSeconds = interruptedAt } + _ = self.lastPlaybackWasPCM + ? self.mp3Player.stop() + : self.pcmPlayer.stop() TalkSystemSpeechSynthesizer.shared.stop() - self.cancelIncrementalSpeech() self.isSpeaking = false } @@ -934,275 +605,6 @@ final class TalkModeManager: NSObject { return true } - private func shouldUseIncrementalTTS() -> Bool { - true - } - - private var isSpeechOutputActive: Bool { - self.isSpeaking || - self.incrementalSpeechActive || - self.incrementalSpeechTask != nil || - !self.incrementalSpeechQueue.isEmpty - } - - private func applyDirective(_ directive: TalkDirective?) { - let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedVoice = self.resolveVoiceAlias(requestedVoice) - if requestedVoice?.isEmpty == false, resolvedVoice == nil { - self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)") - } - if let voice = resolvedVoice { - if directive?.once != true { - self.currentVoiceId = voice - self.voiceOverrideActive = true - } - } - if let model = directive?.modelId { - if directive?.once != true { - self.currentModelId = model - self.modelOverrideActive = true - } - } - } - - private func resetIncrementalSpeech() { - self.incrementalSpeechQueue.removeAll() - self.incrementalSpeechTask?.cancel() - self.incrementalSpeechTask = nil - self.incrementalSpeechActive = true - self.incrementalSpeechUsed = false - self.incrementalSpeechLanguage = nil - self.incrementalSpeechBuffer = IncrementalSpeechBuffer() - self.incrementalSpeechContext = nil - self.incrementalSpeechDirective = nil - } - - private func cancelIncrementalSpeech() { - self.incrementalSpeechQueue.removeAll() - self.incrementalSpeechTask?.cancel() - self.incrementalSpeechTask = nil - self.incrementalSpeechActive = false - self.incrementalSpeechContext = nil - self.incrementalSpeechDirective = nil - } - - private func enqueueIncrementalSpeech(_ text: String) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - self.incrementalSpeechQueue.append(trimmed) - self.incrementalSpeechUsed = true - if self.incrementalSpeechTask == nil { - self.startIncrementalSpeechTask() - } - } - - private func startIncrementalSpeechTask() { - if self.interruptOnSpeech { - do { - try self.startRecognition() - } catch { - self.logger.warning( - "startRecognition during incremental speak failed: \(error.localizedDescription, privacy: .public)") - } - } - - self.incrementalSpeechTask = Task { @MainActor [weak self] in - guard let self else { return } - while !Task.isCancelled { - guard !self.incrementalSpeechQueue.isEmpty else { break } - let segment = self.incrementalSpeechQueue.removeFirst() - self.statusText = "Speaking…" - self.isSpeaking = true - self.lastSpokenText = segment - await self.speakIncrementalSegment(segment) - } - self.isSpeaking = false - self.stopRecognition() - self.incrementalSpeechTask = nil - } - } - - private func finishIncrementalSpeech() async { - guard self.incrementalSpeechActive else { return } - let leftover = self.incrementalSpeechBuffer.flush() - if let leftover { - self.enqueueIncrementalSpeech(leftover) - } - if let task = self.incrementalSpeechTask { - _ = await task.result - } - self.incrementalSpeechActive = false - } - - private func handleIncrementalAssistantFinal(text: String) async { - let parsed = TalkDirectiveParser.parse(text) - self.applyDirective(parsed.directive) - if let lang = parsed.directive?.language { - self.incrementalSpeechLanguage = ElevenLabsTTSClient.validatedLanguage(lang) - } - await self.updateIncrementalContextIfNeeded() - let segments = self.incrementalSpeechBuffer.ingest(text: text, isFinal: true) - for segment in segments { - self.enqueueIncrementalSpeech(segment) - } - await self.finishIncrementalSpeech() - if !self.incrementalSpeechUsed { - await self.playAssistant(text: text) - } - } - - private func streamAssistant(runId: String, gateway: GatewayNodeSession) async { - let stream = await gateway.subscribeServerEvents(bufferingNewest: 200) - for await evt in stream { - if Task.isCancelled { return } - guard evt.event == "agent", let payload = evt.payload else { continue } - guard let agentEvent = try? GatewayPayloadDecoding.decode(payload, as: OpenClawAgentEventPayload.self) else { - continue - } - guard agentEvent.runId == runId, agentEvent.stream == "assistant" else { continue } - guard let text = agentEvent.data["text"]?.value as? String else { continue } - let segments = self.incrementalSpeechBuffer.ingest(text: text, isFinal: false) - if let lang = self.incrementalSpeechBuffer.directive?.language { - self.incrementalSpeechLanguage = ElevenLabsTTSClient.validatedLanguage(lang) - } - await self.updateIncrementalContextIfNeeded() - for segment in segments { - self.enqueueIncrementalSpeech(segment) - } - } - } - - private func updateIncrementalContextIfNeeded() async { - let directive = self.incrementalSpeechBuffer.directive - if let existing = self.incrementalSpeechContext, directive == self.incrementalSpeechDirective { - if existing.language != self.incrementalSpeechLanguage { - self.incrementalSpeechContext = IncrementalSpeechContext( - apiKey: existing.apiKey, - voiceId: existing.voiceId, - modelId: existing.modelId, - outputFormat: existing.outputFormat, - language: self.incrementalSpeechLanguage, - directive: existing.directive, - canUseElevenLabs: existing.canUseElevenLabs) - } - return - } - let context = await self.buildIncrementalSpeechContext(directive: directive) - self.incrementalSpeechContext = context - self.incrementalSpeechDirective = directive - } - - private func buildIncrementalSpeechContext(directive: TalkDirective?) async -> IncrementalSpeechContext { - let requestedVoice = directive?.voiceId?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedVoice = self.resolveVoiceAlias(requestedVoice) - if requestedVoice?.isEmpty == false, resolvedVoice == nil { - self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)") - } - let preferredVoice = resolvedVoice ?? self.currentVoiceId ?? self.defaultVoiceId - let modelId = directive?.modelId ?? self.currentModelId ?? self.defaultModelId - let desiredOutputFormat = (directive?.outputFormat ?? self.defaultOutputFormat)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let requestedOutputFormat = (desiredOutputFormat?.isEmpty == false) ? desiredOutputFormat : nil - let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(requestedOutputFormat ?? "pcm_44100") - if outputFormat == nil, let requestedOutputFormat { - self.logger.warning( - "talk output_format unsupported for local playback: \(requestedOutputFormat, privacy: .public)") - } - - let resolvedKey = - (self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil) ?? - ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] - let apiKey = resolvedKey?.trimmingCharacters(in: .whitespacesAndNewlines) - let voiceId: String? = if let apiKey, !apiKey.isEmpty { - await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey) - } else { - nil - } - let canUseElevenLabs = (voiceId?.isEmpty == false) && (apiKey?.isEmpty == false) - return IncrementalSpeechContext( - apiKey: apiKey, - voiceId: voiceId, - modelId: modelId, - outputFormat: outputFormat, - language: self.incrementalSpeechLanguage, - directive: directive, - canUseElevenLabs: canUseElevenLabs) - } - - private func speakIncrementalSegment(_ text: String) async { - await self.updateIncrementalContextIfNeeded() - guard let context = self.incrementalSpeechContext else { - try? await TalkSystemSpeechSynthesizer.shared.speak( - text: text, - language: self.incrementalSpeechLanguage) - return - } - - if context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId { - let request = ElevenLabsTTSRequest( - text: text, - modelId: context.modelId, - outputFormat: context.outputFormat, - speed: TalkTTSValidation.resolveSpeed( - speed: context.directive?.speed, - rateWPM: context.directive?.rateWPM), - stability: TalkTTSValidation.validatedStability( - context.directive?.stability, - modelId: context.modelId), - similarity: TalkTTSValidation.validatedUnit(context.directive?.similarity), - style: TalkTTSValidation.validatedUnit(context.directive?.style), - speakerBoost: context.directive?.speakerBoost, - seed: TalkTTSValidation.validatedSeed(context.directive?.seed), - normalize: ElevenLabsTTSClient.validatedNormalize(context.directive?.normalize), - language: context.language, - latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier)) - let client = ElevenLabsTTSClient(apiKey: apiKey) - let stream = client.streamSynthesize(voiceId: voiceId, request: request) - let sampleRate = TalkTTSValidation.pcmSampleRate(from: context.outputFormat) - let result: StreamingPlaybackResult - if let sampleRate { - self.lastPlaybackWasPCM = true - var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate) - if !playback.finished, playback.interruptedAt == nil { - self.logger.warning("pcm playback failed; retrying mp3") - self.lastPlaybackWasPCM = false - let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") - let mp3Stream = client.streamSynthesize( - voiceId: voiceId, - request: ElevenLabsTTSRequest( - text: text, - modelId: context.modelId, - outputFormat: mp3Format, - speed: TalkTTSValidation.resolveSpeed( - speed: context.directive?.speed, - rateWPM: context.directive?.rateWPM), - stability: TalkTTSValidation.validatedStability( - context.directive?.stability, - modelId: context.modelId), - similarity: TalkTTSValidation.validatedUnit(context.directive?.similarity), - style: TalkTTSValidation.validatedUnit(context.directive?.style), - speakerBoost: context.directive?.speakerBoost, - seed: TalkTTSValidation.validatedSeed(context.directive?.seed), - normalize: ElevenLabsTTSClient.validatedNormalize(context.directive?.normalize), - language: context.language, - latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier))) - playback = await self.mp3Player.play(stream: mp3Stream) - } - result = playback - } else { - self.lastPlaybackWasPCM = false - result = await self.mp3Player.play(stream: stream) - } - if !result.finished, let interruptedAt = result.interruptedAt { - self.lastInterruptedAtSeconds = interruptedAt - } - } else { - try? await TalkSystemSpeechSynthesizer.shared.speak( - text: text, - language: self.incrementalSpeechLanguage) - } - } - private func resolveVoiceAlias(_ value: String?) -> String? { let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } @@ -1309,265 +711,19 @@ final class TalkModeManager: NSObject { try session.setActive(true, options: []) } -} - -private struct IncrementalSpeechBuffer { - private(set) var latestText: String = "" - private(set) var directive: TalkDirective? - private var spokenOffset: Int = 0 - private var inCodeBlock = false - private var directiveParsed = false - - mutating func ingest(text: String, isFinal: Bool) -> [String] { - let normalized = text.replacingOccurrences(of: "\r\n", with: "\n") - guard let usable = self.stripDirectiveIfReady(from: normalized) else { return [] } - self.updateText(usable) - return self.extractSegments(isFinal: isFinal) - } - - mutating func flush() -> String? { - guard !self.latestText.isEmpty else { return nil } - let segments = self.extractSegments(isFinal: true) - return segments.first - } - - private mutating func stripDirectiveIfReady(from text: String) -> String? { - guard !self.directiveParsed else { return text } - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if trimmed.hasPrefix("{") { - guard let newlineRange = text.range(of: "\n") else { return nil } - let firstLine = text[.. commonPrefix { - self.spokenOffset = commonPrefix - } - } - if self.spokenOffset > self.latestText.count { - self.spokenOffset = self.latestText.count - } - } - - private static func commonPrefixCount(_ lhs: String, _ rhs: String) -> Int { - let left = Array(lhs) - let right = Array(rhs) - let limit = min(left.count, right.count) - var idx = 0 - while idx < limit, left[idx] == right[idx] { - idx += 1 - } - return idx - } - - private mutating func extractSegments(isFinal: Bool) -> [String] { - let chars = Array(self.latestText) - guard self.spokenOffset < chars.count else { return [] } - var idx = self.spokenOffset - var lastBoundary: Int? - var inCodeBlock = self.inCodeBlock - var buffer = "" - var bufferAtBoundary = "" - var inCodeBlockAtBoundary = inCodeBlock - - while idx < chars.count { - if idx + 2 < chars.count, - chars[idx] == "`", - chars[idx + 1] == "`", - chars[idx + 2] == "`" - { - inCodeBlock.toggle() - idx += 3 - continue - } - - if !inCodeBlock { - buffer.append(chars[idx]) - if Self.isBoundary(chars[idx]) { - lastBoundary = idx + 1 - bufferAtBoundary = buffer - inCodeBlockAtBoundary = inCodeBlock - } - } - - idx += 1 - } - - if let boundary = lastBoundary { - self.spokenOffset = boundary - self.inCodeBlock = inCodeBlockAtBoundary - let trimmed = bufferAtBoundary.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? [] : [trimmed] - } - - guard isFinal else { return [] } - self.spokenOffset = chars.count - self.inCodeBlock = inCodeBlock - let trimmed = buffer.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? [] : [trimmed] - } - - private static func isBoundary(_ ch: Character) -> Bool { - ch == "." || ch == "!" || ch == "?" || ch == "\n" - } -} - -extension TalkModeManager { - nonisolated static func requestMicrophonePermission() async -> Bool { - let session = AVAudioSession.sharedInstance() - switch session.recordPermission { - case .granted: - return true - case .denied: - return false - case .undetermined: - break - @unknown default: - return false - } - - return await self.requestPermissionWithTimeout { completion in - AVAudioSession.sharedInstance().requestRecordPermission { ok in - completion(ok) + private nonisolated static func requestMicrophonePermission() async -> Bool { + await withCheckedContinuation(isolation: nil) { cont in + AVAudioApplication.requestRecordPermission { ok in + cont.resume(returning: ok) } } } - nonisolated static func requestSpeechPermission() async -> Bool { - let status = SFSpeechRecognizer.authorizationStatus() - switch status { - case .authorized: - return true - case .denied, .restricted: - return false - case .notDetermined: - break - @unknown default: - return false - } - - return await self.requestPermissionWithTimeout { completion in - SFSpeechRecognizer.requestAuthorization { authStatus in - completion(authStatus == .authorized) + private nonisolated static func requestSpeechPermission() async -> Bool { + await withCheckedContinuation(isolation: nil) { cont in + SFSpeechRecognizer.requestAuthorization { status in + cont.resume(returning: status == .authorized) } } } - - private nonisolated static func requestPermissionWithTimeout( - _ operation: @escaping @Sendable (@escaping (Bool) -> Void) -> Void) async -> Bool - { - do { - return try await AsyncTimeout.withTimeout( - seconds: 8, - onTimeout: { NSError(domain: "TalkMode", code: 6, userInfo: [ - NSLocalizedDescriptionKey: "permission request timed out", - ]) }, - operation: { - await withCheckedContinuation(isolation: nil) { cont in - Task { @MainActor in - operation { ok in - cont.resume(returning: ok) - } - } - } - }) - } catch { - return false - } - } - - static func permissionMessage( - kind: String, - status: AVAudioSession.RecordPermission) -> String - { - switch status { - case .denied: - return "\(kind) permission denied" - case .undetermined: - return "\(kind) permission not granted" - case .granted: - return "\(kind) permission denied" - @unknown default: - return "\(kind) permission denied" - } - } - - static func permissionMessage( - kind: String, - status: SFSpeechRecognizerAuthorizationStatus) -> String - { - switch status { - case .denied: - return "\(kind) permission denied" - case .restricted: - return "\(kind) permission restricted" - case .notDetermined: - return "\(kind) permission not granted" - case .authorized: - return "\(kind) permission denied" - @unknown default: - return "\(kind) permission denied" - } - } } - -private struct IncrementalSpeechContext { - let apiKey: String? - let voiceId: String? - let modelId: String? - let outputFormat: String? - let language: String? - let directive: TalkDirective? - let canUseElevenLabs: Bool -} - -#if DEBUG -extension TalkModeManager { - func _test_seedTranscript(_ transcript: String) { - self.lastTranscript = transcript - self.lastHeard = Date() - } - - func _test_handleTranscript(_ transcript: String, isFinal: Bool) async { - await self.handleTranscript(transcript: transcript, isFinal: isFinal) - } - - func _test_backdateLastHeard(seconds: TimeInterval) { - self.lastHeard = Date().addingTimeInterval(-seconds) - } - - func _test_runSilenceCheck() async { - await self.checkSilence() - } - - func _test_incrementalReset() { - self.incrementalSpeechBuffer = IncrementalSpeechBuffer() - } - - func _test_incrementalIngest(_ text: String, isFinal: Bool) -> [String] { - self.incrementalSpeechBuffer.ingest(text: text, isFinal: isFinal) - } -} -#endif diff --git a/apps/ios/Sources/Voice/VoiceWakeManager.swift b/apps/ios/Sources/Voice/VoiceWakeManager.swift index d4ed467d97..771b5a77a6 100644 --- a/apps/ios/Sources/Voice/VoiceWakeManager.swift +++ b/apps/ios/Sources/Voice/VoiceWakeManager.swift @@ -1,7 +1,6 @@ import AVFAudio import Foundation import Observation -import OpenClawKit import Speech import SwabbleKit @@ -160,18 +159,14 @@ final class VoiceWakeManager: NSObject { let micOk = await Self.requestMicrophonePermission() guard micOk else { - self.statusText = Self.permissionMessage( - kind: "Microphone", - status: AVAudioSession.sharedInstance().recordPermission) + self.statusText = "Microphone permission denied" self.isListening = false return } let speechOk = await Self.requestSpeechPermission() guard speechOk else { - self.statusText = Self.permissionMessage( - kind: "Speech recognition", - status: SFSpeechRecognizer.authorizationStatus()) + self.statusText = "Speech recognition permission denied" self.isListening = false return } @@ -369,101 +364,20 @@ final class VoiceWakeManager: NSObject { } private nonisolated static func requestMicrophonePermission() async -> Bool { - let session = AVAudioSession.sharedInstance() - switch session.recordPermission { - case .granted: - return true - case .denied: - return false - case .undetermined: - break - @unknown default: - return false - } - - return await self.requestPermissionWithTimeout { completion in - AVAudioSession.sharedInstance().requestRecordPermission { ok in - completion(ok) + await withCheckedContinuation(isolation: nil) { cont in + AVAudioApplication.requestRecordPermission { ok in + cont.resume(returning: ok) } } } private nonisolated static func requestSpeechPermission() async -> Bool { - let status = SFSpeechRecognizer.authorizationStatus() - switch status { - case .authorized: - return true - case .denied, .restricted: - return false - case .notDetermined: - break - @unknown default: - return false - } - - return await self.requestPermissionWithTimeout { completion in - SFSpeechRecognizer.requestAuthorization { authStatus in - completion(authStatus == .authorized) + await withCheckedContinuation(isolation: nil) { cont in + SFSpeechRecognizer.requestAuthorization { status in + cont.resume(returning: status == .authorized) } } } - - private nonisolated static func requestPermissionWithTimeout( - _ operation: @escaping @Sendable (@escaping (Bool) -> Void) -> Void) async -> Bool - { - do { - return try await AsyncTimeout.withTimeout( - seconds: 8, - onTimeout: { NSError(domain: "VoiceWake", code: 6, userInfo: [ - NSLocalizedDescriptionKey: "permission request timed out", - ]) }, - operation: { - await withCheckedContinuation(isolation: nil) { cont in - Task { @MainActor in - operation { ok in - cont.resume(returning: ok) - } - } - } - }) - } catch { - return false - } - } - - private static func permissionMessage( - kind: String, - status: AVAudioSession.RecordPermission) -> String - { - switch status { - case .denied: - return "\(kind) permission denied" - case .undetermined: - return "\(kind) permission not granted" - case .granted: - return "\(kind) permission denied" - @unknown default: - return "\(kind) permission denied" - } - } - - private static func permissionMessage( - kind: String, - status: SFSpeechRecognizerAuthorizationStatus) -> String - { - switch status { - case .denied: - return "\(kind) permission denied" - case .restricted: - return "\(kind) permission restricted" - case .notDetermined: - return "\(kind) permission not granted" - case .authorized: - return "\(kind) permission denied" - @unknown default: - return "\(kind) permission denied" - } - } } #if DEBUG diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 4b94dbe3c8..4952019c77 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -2,32 +2,20 @@ Sources/Gateway/GatewayConnectionController.swift Sources/Gateway/GatewayDiscoveryDebugLogView.swift Sources/Gateway/GatewayDiscoveryModel.swift Sources/Gateway/GatewaySettingsStore.swift -Sources/Gateway/GatewayHealthMonitor.swift Sources/Gateway/KeychainStore.swift -Sources/Capabilities/NodeCapabilityRouter.swift Sources/Camera/CameraController.swift Sources/Chat/ChatSheet.swift Sources/Chat/IOSGatewayChatTransport.swift -Sources/Contacts/ContactsService.swift -Sources/Device/DeviceStatusService.swift -Sources/Device/NodeDisplayName.swift -Sources/Device/NetworkStatusService.swift Sources/OpenClawApp.swift Sources/Location/LocationService.swift -Sources/Media/PhotoLibraryService.swift -Sources/Motion/MotionService.swift Sources/Model/NodeAppModel.swift Sources/RootCanvas.swift Sources/RootTabs.swift -Sources/Services/NodeServiceProtocols.swift -Sources/Services/NotificationService.swift Sources/Screen/ScreenController.swift Sources/Screen/ScreenRecordService.swift Sources/Screen/ScreenTab.swift Sources/Screen/ScreenWebView.swift Sources/SessionKey.swift -Sources/Calendar/CalendarService.swift -Sources/Reminders/RemindersService.swift Sources/Settings/SettingsNetworkingHelpers.swift Sources/Settings/SettingsTab.swift Sources/Settings/VoiceWakeWordsSettingsView.swift @@ -52,7 +40,6 @@ Sources/Voice/VoiceWakePreferences.swift ../shared/OpenClawKit/Sources/OpenClawKit/BonjourEscapes.swift ../shared/OpenClawKit/Sources/OpenClawKit/BonjourTypes.swift ../shared/OpenClawKit/Sources/OpenClawKit/BridgeFrames.swift -../shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift ../shared/OpenClawKit/Sources/OpenClawKit/CameraCommands.swift ../shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UIAction.swift ../shared/OpenClawKit/Sources/OpenClawKit/CanvasA2UICommands.swift @@ -60,20 +47,13 @@ Sources/Voice/VoiceWakePreferences.swift ../shared/OpenClawKit/Sources/OpenClawKit/CanvasCommandParams.swift ../shared/OpenClawKit/Sources/OpenClawKit/CanvasCommands.swift ../shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift -../shared/OpenClawKit/Sources/OpenClawKit/ChatCommands.swift -../shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift -../shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift ../shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift ../shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift ../shared/OpenClawKit/Sources/OpenClawKit/JPEGTranscoder.swift -../shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift ../shared/OpenClawKit/Sources/OpenClawKit/NodeError.swift -../shared/OpenClawKit/Sources/OpenClawKit/PhotosCommands.swift -../shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift ../shared/OpenClawKit/Sources/OpenClawKit/ScreenCommands.swift ../shared/OpenClawKit/Sources/OpenClawKit/StoragePaths.swift ../shared/OpenClawKit/Sources/OpenClawKit/SystemCommands.swift -../shared/OpenClawKit/Sources/OpenClawKit/TalkCommands.swift ../shared/OpenClawKit/Sources/OpenClawKit/TalkDirective.swift ../../Swabble/Sources/SwabbleKit/WakeWordGate.swift Sources/Voice/TalkModeManager.swift diff --git a/apps/ios/Tests/ContactsServiceTests.swift b/apps/ios/Tests/ContactsServiceTests.swift deleted file mode 100644 index beac84bbf8..0000000000 --- a/apps/ios/Tests/ContactsServiceTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Contacts -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct ContactsServiceTests { - @Test func matchesPhoneOrEmailForDedupe() { - let contact = CNMutableContact() - contact.givenName = "Test" - contact.phoneNumbers = [ - CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: "+1 (555) 000-0000")), - ] - contact.emailAddresses = [ - CNLabeledValue(label: CNLabelHome, value: "test@example.com" as NSString), - ] - - #expect(ContactsService._test_matches(contact: contact, phoneNumbers: ["15550000000"], emails: [])) - #expect(ContactsService._test_matches(contact: contact, phoneNumbers: [], emails: ["TEST@example.com"])) - #expect(!ContactsService._test_matches(contact: contact, phoneNumbers: ["999"], emails: ["nope@example.com"])) - } -} diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 6cdb37decc..0d3bdbba0e 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -40,7 +40,6 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> let resolved = controller._test_resolvedDisplayName(defaults: defaults) #expect(!resolved.isEmpty) - #expect(resolved != "iOS Node") #expect(defaults.string(forKey: displayKey) == resolved) } } @@ -62,11 +61,6 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> #expect(caps.contains(OpenClawCapability.camera.rawValue)) #expect(caps.contains(OpenClawCapability.location.rawValue)) #expect(caps.contains(OpenClawCapability.voiceWake.rawValue)) - #expect(caps.contains(OpenClawCapability.device.rawValue)) - #expect(caps.contains(OpenClawCapability.photos.rawValue)) - #expect(caps.contains(OpenClawCapability.contacts.rawValue)) - #expect(caps.contains(OpenClawCapability.calendar.rawValue)) - #expect(caps.contains(OpenClawCapability.reminders.rawValue)) } } @@ -82,48 +76,4 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> #expect(commands.contains(OpenClawLocationCommand.get.rawValue)) } } - - @Test @MainActor func currentCommandsExcludeShellAndIncludeNotifyAndDevice() { - withUserDefaults([ - "node.instanceId": "ios-test", - ]) { - let appModel = NodeAppModel() - let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - let commands = Set(controller._test_currentCommands()) - - #expect(commands.contains(OpenClawSystemCommand.notify.rawValue)) - #expect(commands.contains(OpenClawChatCommand.push.rawValue)) - #expect(!commands.contains(OpenClawSystemCommand.run.rawValue)) - #expect(!commands.contains(OpenClawSystemCommand.which.rawValue)) - #expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue)) - #expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue)) - - #expect(commands.contains(OpenClawDeviceCommand.status.rawValue)) - #expect(commands.contains(OpenClawDeviceCommand.info.rawValue)) - #expect(commands.contains(OpenClawContactsCommand.add.rawValue)) - #expect(commands.contains(OpenClawCalendarCommand.add.rawValue)) - #expect(commands.contains(OpenClawRemindersCommand.add.rawValue)) - #expect(commands.contains(OpenClawTalkCommand.pttStart.rawValue)) - #expect(commands.contains(OpenClawTalkCommand.pttStop.rawValue)) - #expect(commands.contains(OpenClawTalkCommand.pttCancel.rawValue)) - #expect(commands.contains(OpenClawTalkCommand.pttOnce.rawValue)) - } - } - - @Test @MainActor func currentPermissionsIncludeExpectedKeys() { - let appModel = NodeAppModel() - let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) - let permissions = controller._test_currentPermissions() - let keys = Set(permissions.keys) - - #expect(keys.contains("camera")) - #expect(keys.contains("microphone")) - #expect(keys.contains("location")) - #expect(keys.contains("screenRecording")) - #expect(keys.contains("photos")) - #expect(keys.contains("contacts")) - #expect(keys.contains("calendar")) - #expect(keys.contains("reminders")) - #expect(keys.contains("motion")) - } } diff --git a/apps/ios/Tests/GatewayHealthMonitorTests.swift b/apps/ios/Tests/GatewayHealthMonitorTests.swift deleted file mode 100644 index 38b46edc51..0000000000 --- a/apps/ios/Tests/GatewayHealthMonitorTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -private actor Counter { - private var value = 0 - - func increment() { - value += 1 - } - - func get() -> Int { - value - } - - func set(_ newValue: Int) { - value = newValue - } -} - -@Suite struct GatewayHealthMonitorTests { - @Test @MainActor func triggersFailureAfterThreshold() async { - let failureCount = Counter() - let monitor = GatewayHealthMonitor( - config: .init(intervalSeconds: 0.001, timeoutSeconds: 0.0, maxFailures: 2)) - - monitor.start( - check: { false }, - onFailure: { _ in - await failureCount.increment() - await monitor.stop() - }) - - try? await Task.sleep(nanoseconds: 60_000_000) - #expect(await failureCount.get() == 1) - } - - @Test @MainActor func resetsFailuresAfterSuccess() async { - let failureCount = Counter() - let calls = Counter() - let monitor = GatewayHealthMonitor( - config: .init(intervalSeconds: 0.001, timeoutSeconds: 0.0, maxFailures: 2)) - - monitor.start( - check: { - await calls.increment() - let callCount = await calls.get() - if callCount >= 6 { - await monitor.stop() - } - return callCount % 2 == 0 - }, - onFailure: { _ in - await failureCount.increment() - }) - - try? await Task.sleep(nanoseconds: 60_000_000) - #expect(await failureCount.get() == 0) - } -} diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift index 087f1fe248..255c7aac9b 100644 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -7,14 +7,11 @@ private struct KeychainEntry: Hashable { let account: String } -private let gatewayService = "ai.openclaw.gateway" -private let nodeService = "ai.openclaw.node" +private let gatewayService = "bot.molt.gateway" +private let nodeService = "bot.molt.node" private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId") private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID") private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID") -private func gatewayPasswordEntry(instanceId: String) -> KeychainEntry { - KeychainEntry(service: gatewayService, account: "gateway-password.\(instanceId)") -} private func snapshotDefaults(_ keys: [String]) -> [String: Any?] { let defaults = UserDefaults.standard @@ -127,33 +124,4 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) { #expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain") #expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain") } - - @Test func bootstrapCopiesManualPasswordToKeychainWhenMissing() { - let instanceId = "node-test" - let defaultsKeys = [ - "node.instanceId", - "gateway.manual.password", - ] - let passwordEntry = gatewayPasswordEntry(instanceId: instanceId) - let defaultsSnapshot = snapshotDefaults(defaultsKeys) - let keychainSnapshot = snapshotKeychain([passwordEntry, instanceIdEntry]) - defer { - restoreDefaults(defaultsSnapshot) - restoreKeychain(keychainSnapshot) - } - - applyDefaults([ - "node.instanceId": instanceId, - "gateway.manual.password": "manual-secret", - ]) - applyKeychain([ - passwordEntry: nil, - instanceIdEntry: nil, - ]) - - GatewaySettingsStore.bootstrapPersistence() - - #expect(KeychainStore.loadString(service: gatewayService, account: passwordEntry.account) == "manual-secret") - #expect(UserDefaults.standard.string(forKey: "gateway.manual.password") != "manual-secret") - } } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 512faf22cc..124059021d 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -1,9 +1,7 @@ -import CoreLocation -import Foundation import OpenClawKit +import Foundation import Testing import UIKit -import UserNotifications @testable import OpenClaw private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T { @@ -31,210 +29,6 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> return try body() } -private final class TestNotificationCenter: NotificationCentering, @unchecked Sendable { - private(set) var requestAuthorizationCalls = 0 - private(set) var addedRequests: [UNNotificationRequest] = [] - private var status: NotificationAuthorizationStatus - - init(status: NotificationAuthorizationStatus) { - self.status = status - } - - func authorizationStatus() async -> NotificationAuthorizationStatus { - status - } - - func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { - requestAuthorizationCalls += 1 - status = .authorized - return true - } - - func add(_ request: UNNotificationRequest) async throws { - addedRequests.append(request) - } -} - -private struct TestCameraService: CameraServicing { - func listDevices() async -> [CameraController.CameraDeviceInfo] { [] } - func snap(params: OpenClawCameraSnapParams) async throws -> (format: String, base64: String, width: Int, height: Int) { - ("jpeg", "dGVzdA==", 1, 1) - } - func clip(params: OpenClawCameraClipParams) async throws -> (format: String, base64: String, durationMs: Int, hasAudio: Bool) { - ("mp4", "dGVzdA==", 1000, true) - } -} - -private struct TestScreenRecorder: ScreenRecordingServicing { - func record( - screenIndex: Int?, - durationMs: Int?, - fps: Double?, - includeAudio: Bool?, - outPath: String?) async throws -> String - { - let url = FileManager.default.temporaryDirectory.appendingPathComponent("openclaw-screen-test.mp4") - FileManager.default.createFile(atPath: url.path, contents: Data()) - return url.path - } -} - -@MainActor -private struct TestLocationService: LocationServicing { - func authorizationStatus() -> CLAuthorizationStatus { .authorizedWhenInUse } - func accuracyAuthorization() -> CLAccuracyAuthorization { .fullAccuracy } - func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus { .authorizedWhenInUse } - func currentLocation( - params: OpenClawLocationGetParams, - desiredAccuracy: OpenClawLocationAccuracy, - maxAgeMs: Int?, - timeoutMs: Int?) async throws -> CLLocation - { - CLLocation(latitude: 37.3349, longitude: -122.0090) - } -} - -private struct TestDeviceStatusService: DeviceStatusServicing { - let statusPayload: OpenClawDeviceStatusPayload - let infoPayload: OpenClawDeviceInfoPayload - - func status() async throws -> OpenClawDeviceStatusPayload { statusPayload } - func info() -> OpenClawDeviceInfoPayload { infoPayload } -} - -private struct TestPhotosService: PhotosServicing { - let payload: OpenClawPhotosLatestPayload - func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload { payload } -} - -private struct TestContactsService: ContactsServicing { - let searchPayload: OpenClawContactsSearchPayload - let addPayload: OpenClawContactsAddPayload - func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload { searchPayload } - func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload { addPayload } -} - -private struct TestCalendarService: CalendarServicing { - let eventsPayload: OpenClawCalendarEventsPayload - let addPayload: OpenClawCalendarAddPayload - func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload { eventsPayload } - func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload { addPayload } -} - -private struct TestRemindersService: RemindersServicing { - let listPayload: OpenClawRemindersListPayload - let addPayload: OpenClawRemindersAddPayload - func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload { listPayload } - func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload { addPayload } -} - -private struct TestMotionService: MotionServicing { - let activityPayload: OpenClawMotionActivityPayload - let pedometerPayload: OpenClawPedometerPayload - - func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload { - activityPayload - } - - func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload { - pedometerPayload - } -} - -@MainActor -private func makeTestAppModel( - notificationCenter: NotificationCentering = TestNotificationCenter(status: .authorized), - deviceStatusService: DeviceStatusServicing, - photosService: PhotosServicing, - contactsService: ContactsServicing, - calendarService: CalendarServicing, - remindersService: RemindersServicing, - motionService: MotionServicing, - talkMode: TalkModeManager = TalkModeManager(allowSimulatorCapture: true)) -> NodeAppModel -{ - NodeAppModel( - screen: ScreenController(), - camera: TestCameraService(), - screenRecorder: TestScreenRecorder(), - locationService: TestLocationService(), - notificationCenter: notificationCenter, - deviceStatusService: deviceStatusService, - photosService: photosService, - contactsService: contactsService, - calendarService: calendarService, - remindersService: remindersService, - motionService: motionService, - talkMode: talkMode) -} - -@MainActor -private func makeTalkTestAppModel(talkMode: TalkModeManager) -> NodeAppModel { - makeTestAppModel( - deviceStatusService: TestDeviceStatusService( - statusPayload: OpenClawDeviceStatusPayload( - battery: OpenClawBatteryStatusPayload(level: 0.5, state: .unplugged, lowPowerModeEnabled: false), - thermal: OpenClawThermalStatusPayload(state: .nominal), - storage: OpenClawStorageStatusPayload(totalBytes: 10, freeBytes: 5, usedBytes: 5), - network: OpenClawNetworkStatusPayload( - status: .satisfied, - isExpensive: false, - isConstrained: false, - interfaces: [.wifi]), - uptimeSeconds: 1), - infoPayload: OpenClawDeviceInfoPayload( - deviceName: "Test", - modelIdentifier: "Test1,1", - systemName: "iOS", - systemVersion: "1.0", - appVersion: "dev", - appBuild: "0", - locale: "en-US")), - photosService: TestPhotosService(payload: OpenClawPhotosLatestPayload(photos: [])), - contactsService: TestContactsService( - searchPayload: OpenClawContactsSearchPayload(contacts: []), - addPayload: OpenClawContactsAddPayload(contact: OpenClawContactPayload( - identifier: "c0", - displayName: "", - givenName: "", - familyName: "", - organizationName: "", - phoneNumbers: [], - emails: []))), - calendarService: TestCalendarService( - eventsPayload: OpenClawCalendarEventsPayload(events: []), - addPayload: OpenClawCalendarAddPayload(event: OpenClawCalendarEventPayload( - identifier: "e0", - title: "Test", - startISO: "2024-01-01T00:00:00Z", - endISO: "2024-01-01T00:10:00Z", - isAllDay: false, - location: nil, - calendarTitle: nil))), - remindersService: TestRemindersService( - listPayload: OpenClawRemindersListPayload(reminders: []), - addPayload: OpenClawRemindersAddPayload(reminder: OpenClawReminderPayload( - identifier: "r0", - title: "Test", - dueISO: nil, - completed: false, - listName: nil))), - motionService: TestMotionService( - activityPayload: OpenClawMotionActivityPayload(activities: []), - pedometerPayload: OpenClawPedometerPayload( - startISO: "2024-01-01T00:00:00Z", - endISO: "2024-01-01T01:00:00Z", - steps: nil, - distanceMeters: nil, - floorsAscended: nil, - floorsDescended: nil)), - talkMode: talkMode) -} - -private func decodePayload(_ json: String?, as type: T.Type) throws -> T { - let data = try #require(json?.data(using: .utf8)) - return try JSONDecoder().decode(type, from: data) -} - @Suite(.serialized) struct NodeAppModelInvokeTests { @Test @MainActor func decodeParamsFailsWithoutJSON() { #expect(throws: Error.self) { @@ -330,11 +124,6 @@ private func decodePayload(_ json: String?, as type: T.Type) throw let payloadData = try #require(evalRes.payloadJSON?.data(using: .utf8)) let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any] #expect(payload?["result"] as? String == "2") - - let hide = BridgeInvokeRequest(id: "hide", command: OpenClawCanvasCommand.hide.rawValue) - let hideRes = await appModel._test_handleInvoke(hide) - #expect(hideRes.ok == true) - #expect(appModel.screen.urlString.isEmpty) } @Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws { @@ -366,470 +155,6 @@ private func decodePayload(_ json: String?, as type: T.Type) throw #expect(res.error?.code == .invalidRequest) } - @Test @MainActor func handleInvokeSystemNotifyCreatesNotificationRequest() async throws { - let notifier = TestNotificationCenter(status: .notDetermined) - let deviceStatus = TestDeviceStatusService( - statusPayload: OpenClawDeviceStatusPayload( - battery: OpenClawBatteryStatusPayload(level: 0.5, state: .charging, lowPowerModeEnabled: false), - thermal: OpenClawThermalStatusPayload(state: .nominal), - storage: OpenClawStorageStatusPayload(totalBytes: 100, freeBytes: 50, usedBytes: 50), - network: OpenClawNetworkStatusPayload( - status: .satisfied, - isExpensive: false, - isConstrained: false, - interfaces: [.wifi]), - uptimeSeconds: 10), - infoPayload: OpenClawDeviceInfoPayload( - deviceName: "Test", - modelIdentifier: "Test1,1", - systemName: "iOS", - systemVersion: "1.0", - appVersion: "dev", - appBuild: "0", - locale: "en-US")) - let emptyContact = OpenClawContactPayload( - identifier: "c0", - displayName: "", - givenName: "", - familyName: "", - organizationName: "", - phoneNumbers: [], - emails: []) - let emptyEvent = OpenClawCalendarEventPayload( - identifier: "e0", - title: "Test", - startISO: "2024-01-01T00:00:00Z", - endISO: "2024-01-01T00:30:00Z", - isAllDay: false, - location: nil, - calendarTitle: nil) - let emptyReminder = OpenClawReminderPayload( - identifier: "r0", - title: "Test", - dueISO: nil, - completed: false, - listName: nil) - let appModel = makeTestAppModel( - notificationCenter: notifier, - deviceStatusService: deviceStatus, - photosService: TestPhotosService(payload: OpenClawPhotosLatestPayload(photos: [])), - contactsService: TestContactsService( - searchPayload: OpenClawContactsSearchPayload(contacts: []), - addPayload: OpenClawContactsAddPayload(contact: emptyContact)), - calendarService: TestCalendarService( - eventsPayload: OpenClawCalendarEventsPayload(events: []), - addPayload: OpenClawCalendarAddPayload(event: emptyEvent)), - remindersService: TestRemindersService( - listPayload: OpenClawRemindersListPayload(reminders: []), - addPayload: OpenClawRemindersAddPayload(reminder: emptyReminder)), - motionService: TestMotionService( - activityPayload: OpenClawMotionActivityPayload(activities: []), - pedometerPayload: OpenClawPedometerPayload( - startISO: "2024-01-01T00:00:00Z", - endISO: "2024-01-01T01:00:00Z", - steps: nil, - distanceMeters: nil, - floorsAscended: nil, - floorsDescended: nil))) - - let params = OpenClawSystemNotifyParams(title: "Hello", body: "World") - let data = try JSONEncoder().encode(params) - let json = String(decoding: data, as: UTF8.self) - let req = BridgeInvokeRequest( - id: "notify", - command: OpenClawSystemCommand.notify.rawValue, - paramsJSON: json) - let res = await appModel._test_handleInvoke(req) - #expect(res.ok == true) - #expect(notifier.requestAuthorizationCalls == 1) - #expect(notifier.addedRequests.count == 1) - let request = try #require(notifier.addedRequests.first) - #expect(request.content.title == "Hello") - #expect(request.content.body == "World") - } - - @Test @MainActor func handleInvokeChatPushCreatesNotification() async throws { - let notifier = TestNotificationCenter(status: .authorized) - let deviceStatus = TestDeviceStatusService( - statusPayload: OpenClawDeviceStatusPayload( - battery: OpenClawBatteryStatusPayload(level: 0.5, state: .charging, lowPowerModeEnabled: false), - thermal: OpenClawThermalStatusPayload(state: .nominal), - storage: OpenClawStorageStatusPayload(totalBytes: 100, freeBytes: 50, usedBytes: 50), - network: OpenClawNetworkStatusPayload( - status: .satisfied, - isExpensive: false, - isConstrained: false, - interfaces: [.wifi]), - uptimeSeconds: 10), - infoPayload: OpenClawDeviceInfoPayload( - deviceName: "Test", - modelIdentifier: "Test1,1", - systemName: "iOS", - systemVersion: "1.0", - appVersion: "dev", - appBuild: "0", - locale: "en-US")) - let emptyContact = OpenClawContactPayload( - identifier: "c0", - displayName: "", - givenName: "", - familyName: "", - organizationName: "", - phoneNumbers: [], - emails: []) - let emptyEvent = OpenClawCalendarEventPayload( - identifier: "e0", - title: "Test", - startISO: "2024-01-01T00:00:00Z", - endISO: "2024-01-01T00:30:00Z", - isAllDay: false, - location: nil, - calendarTitle: nil) - let emptyReminder = OpenClawReminderPayload( - identifier: "r0", - title: "Test", - dueISO: nil, - completed: false, - listName: nil) - let appModel = makeTestAppModel( - notificationCenter: notifier, - deviceStatusService: deviceStatus, - photosService: TestPhotosService(payload: OpenClawPhotosLatestPayload(photos: [])), - contactsService: TestContactsService( - searchPayload: OpenClawContactsSearchPayload(contacts: []), - addPayload: OpenClawContactsAddPayload(contact: emptyContact)), - calendarService: TestCalendarService( - eventsPayload: OpenClawCalendarEventsPayload(events: []), - addPayload: OpenClawCalendarAddPayload(event: emptyEvent)), - remindersService: TestRemindersService( - listPayload: OpenClawRemindersListPayload(reminders: []), - addPayload: OpenClawRemindersAddPayload(reminder: emptyReminder)), - motionService: TestMotionService( - activityPayload: OpenClawMotionActivityPayload(activities: []), - pedometerPayload: OpenClawPedometerPayload( - startISO: "2024-01-01T00:00:00Z", - endISO: "2024-01-01T01:00:00Z", - steps: nil, - distanceMeters: nil, - floorsAscended: nil, - floorsDescended: nil))) - - let params = OpenClawChatPushParams(text: "Ping", speak: false) - let data = try JSONEncoder().encode(params) - let json = String(decoding: data, as: UTF8.self) - let req = BridgeInvokeRequest( - id: "chat-push", - command: OpenClawChatCommand.push.rawValue, - paramsJSON: json) - let res = await appModel._test_handleInvoke(req) - #expect(res.ok == true) - #expect(notifier.addedRequests.count == 1) - let request = try #require(notifier.addedRequests.first) - #expect(request.content.title == "OpenClaw") - #expect(request.content.body == "Ping") - let payloadJSON = try #require(res.payloadJSON) - let decoded = try JSONDecoder().decode(OpenClawChatPushPayload.self, from: Data(payloadJSON.utf8)) - #expect((decoded.messageId ?? "").isEmpty == false) - #expect(request.identifier == decoded.messageId) - } - - @Test @MainActor func handleInvokeDeviceAndDataCommandsReturnPayloads() async throws { - let deviceStatusPayload = OpenClawDeviceStatusPayload( - battery: OpenClawBatteryStatusPayload(level: 0.25, state: .unplugged, lowPowerModeEnabled: false), - thermal: OpenClawThermalStatusPayload(state: .fair), - storage: OpenClawStorageStatusPayload(totalBytes: 200, freeBytes: 80, usedBytes: 120), - network: OpenClawNetworkStatusPayload( - status: .satisfied, - isExpensive: true, - isConstrained: false, - interfaces: [.cellular]), - uptimeSeconds: 42) - let deviceInfoPayload = OpenClawDeviceInfoPayload( - deviceName: "TestPhone", - modelIdentifier: "Test2,1", - systemName: "iOS", - systemVersion: "2.0", - appVersion: "dev", - appBuild: "1", - locale: "en-US") - let photosPayload = OpenClawPhotosLatestPayload( - photos: [ - OpenClawPhotoPayload(format: "jpeg", base64: "dGVzdA==", width: 1, height: 1, createdAt: nil), - ]) - let contactsPayload = OpenClawContactsSearchPayload( - contacts: [ - OpenClawContactPayload( - identifier: "c1", - displayName: "Jane Doe", - givenName: "Jane", - familyName: "Doe", - organizationName: "", - phoneNumbers: ["+1"], - emails: ["jane@example.com"]), - ]) - let contactsAddPayload = OpenClawContactsAddPayload( - contact: OpenClawContactPayload( - identifier: "c2", - displayName: "Added", - givenName: "Added", - familyName: "", - organizationName: "", - phoneNumbers: ["+2"], - emails: ["add@example.com"])) - let calendarPayload = OpenClawCalendarEventsPayload( - events: [ - OpenClawCalendarEventPayload( - identifier: "e1", - title: "Standup", - startISO: "2024-01-01T00:00:00Z", - endISO: "2024-01-01T00:30:00Z", - isAllDay: false, - location: nil, - calendarTitle: "Work"), - ]) - let calendarAddPayload = OpenClawCalendarAddPayload( - event: OpenClawCalendarEventPayload( - identifier: "e2", - title: "Added Event", - startISO: "2024-01-02T00:00:00Z", - endISO: "2024-01-02T01:00:00Z", - isAllDay: false, - location: "HQ", - calendarTitle: "Work")) - let remindersPayload = OpenClawRemindersListPayload( - reminders: [ - OpenClawReminderPayload( - identifier: "r1", - title: "Ship build", - dueISO: "2024-01-02T00:00:00Z", - completed: false, - listName: "Inbox"), - ]) - let remindersAddPayload = OpenClawRemindersAddPayload( - reminder: OpenClawReminderPayload( - identifier: "r2", - title: "Added Reminder", - dueISO: "2024-01-03T00:00:00Z", - completed: false, - listName: "Inbox")) - let motionPayload = OpenClawMotionActivityPayload( - activities: [ - OpenClawMotionActivityEntry( - startISO: "2024-01-01T00:00:00Z", - endISO: "2024-01-01T00:10:00Z", - confidence: "high", - isWalking: true, - isRunning: false, - isCycling: false, - isAutomotive: false, - isStationary: false, - isUnknown: false), - ]) - let pedometerPayload = OpenClawPedometerPayload( - startISO: "2024-01-01T00:00:00Z", - endISO: "2024-01-01T01:00:00Z", - steps: 123, - distanceMeters: 456, - floorsAscended: 1, - floorsDescended: 2) - - let appModel = makeTestAppModel( - deviceStatusService: TestDeviceStatusService( - statusPayload: deviceStatusPayload, - infoPayload: deviceInfoPayload), - photosService: TestPhotosService(payload: photosPayload), - contactsService: TestContactsService( - searchPayload: contactsPayload, - addPayload: contactsAddPayload), - calendarService: TestCalendarService( - eventsPayload: calendarPayload, - addPayload: calendarAddPayload), - remindersService: TestRemindersService( - listPayload: remindersPayload, - addPayload: remindersAddPayload), - motionService: TestMotionService( - activityPayload: motionPayload, - pedometerPayload: pedometerPayload)) - - let deviceStatusReq = BridgeInvokeRequest(id: "device", command: OpenClawDeviceCommand.status.rawValue) - let deviceStatusRes = await appModel._test_handleInvoke(deviceStatusReq) - #expect(deviceStatusRes.ok == true) - let decodedDeviceStatus = try decodePayload(deviceStatusRes.payloadJSON, as: OpenClawDeviceStatusPayload.self) - #expect(decodedDeviceStatus == deviceStatusPayload) - - let deviceInfoReq = BridgeInvokeRequest(id: "device-info", command: OpenClawDeviceCommand.info.rawValue) - let deviceInfoRes = await appModel._test_handleInvoke(deviceInfoReq) - #expect(deviceInfoRes.ok == true) - let decodedDeviceInfo = try decodePayload(deviceInfoRes.payloadJSON, as: OpenClawDeviceInfoPayload.self) - #expect(decodedDeviceInfo == deviceInfoPayload) - - let photosReq = BridgeInvokeRequest(id: "photos", command: OpenClawPhotosCommand.latest.rawValue) - let photosRes = await appModel._test_handleInvoke(photosReq) - #expect(photosRes.ok == true) - let decodedPhotos = try decodePayload(photosRes.payloadJSON, as: OpenClawPhotosLatestPayload.self) - #expect(decodedPhotos == photosPayload) - - let contactsReq = BridgeInvokeRequest(id: "contacts", command: OpenClawContactsCommand.search.rawValue) - let contactsRes = await appModel._test_handleInvoke(contactsReq) - #expect(contactsRes.ok == true) - let decodedContacts = try decodePayload(contactsRes.payloadJSON, as: OpenClawContactsSearchPayload.self) - #expect(decodedContacts == contactsPayload) - - let contactsAddParams = OpenClawContactsAddParams( - givenName: "Added", - phoneNumbers: ["+2"], - emails: ["add@example.com"]) - let contactsAddData = try JSONEncoder().encode(contactsAddParams) - let contactsAddReq = BridgeInvokeRequest( - id: "contacts-add", - command: OpenClawContactsCommand.add.rawValue, - paramsJSON: String(decoding: contactsAddData, as: UTF8.self)) - let contactsAddRes = await appModel._test_handleInvoke(contactsAddReq) - #expect(contactsAddRes.ok == true) - let decodedContactsAdd = try decodePayload(contactsAddRes.payloadJSON, as: OpenClawContactsAddPayload.self) - #expect(decodedContactsAdd == contactsAddPayload) - - let calendarReq = BridgeInvokeRequest(id: "calendar", command: OpenClawCalendarCommand.events.rawValue) - let calendarRes = await appModel._test_handleInvoke(calendarReq) - #expect(calendarRes.ok == true) - let decodedCalendar = try decodePayload(calendarRes.payloadJSON, as: OpenClawCalendarEventsPayload.self) - #expect(decodedCalendar == calendarPayload) - - let calendarAddParams = OpenClawCalendarAddParams( - title: "Added Event", - startISO: "2024-01-02T00:00:00Z", - endISO: "2024-01-02T01:00:00Z", - location: "HQ", - calendarTitle: "Work") - let calendarAddData = try JSONEncoder().encode(calendarAddParams) - let calendarAddReq = BridgeInvokeRequest( - id: "calendar-add", - command: OpenClawCalendarCommand.add.rawValue, - paramsJSON: String(decoding: calendarAddData, as: UTF8.self)) - let calendarAddRes = await appModel._test_handleInvoke(calendarAddReq) - #expect(calendarAddRes.ok == true) - let decodedCalendarAdd = try decodePayload(calendarAddRes.payloadJSON, as: OpenClawCalendarAddPayload.self) - #expect(decodedCalendarAdd == calendarAddPayload) - - let remindersReq = BridgeInvokeRequest(id: "reminders", command: OpenClawRemindersCommand.list.rawValue) - let remindersRes = await appModel._test_handleInvoke(remindersReq) - #expect(remindersRes.ok == true) - let decodedReminders = try decodePayload(remindersRes.payloadJSON, as: OpenClawRemindersListPayload.self) - #expect(decodedReminders == remindersPayload) - - let remindersAddParams = OpenClawRemindersAddParams( - title: "Added Reminder", - dueISO: "2024-01-03T00:00:00Z", - listName: "Inbox") - let remindersAddData = try JSONEncoder().encode(remindersAddParams) - let remindersAddReq = BridgeInvokeRequest( - id: "reminders-add", - command: OpenClawRemindersCommand.add.rawValue, - paramsJSON: String(decoding: remindersAddData, as: UTF8.self)) - let remindersAddRes = await appModel._test_handleInvoke(remindersAddReq) - #expect(remindersAddRes.ok == true) - let decodedRemindersAdd = try decodePayload(remindersAddRes.payloadJSON, as: OpenClawRemindersAddPayload.self) - #expect(decodedRemindersAdd == remindersAddPayload) - - let motionReq = BridgeInvokeRequest(id: "motion", command: OpenClawMotionCommand.activity.rawValue) - let motionRes = await appModel._test_handleInvoke(motionReq) - #expect(motionRes.ok == true) - let decodedMotion = try decodePayload(motionRes.payloadJSON, as: OpenClawMotionActivityPayload.self) - #expect(decodedMotion == motionPayload) - - let pedometerReq = BridgeInvokeRequest(id: "pedometer", command: OpenClawMotionCommand.pedometer.rawValue) - let pedometerRes = await appModel._test_handleInvoke(pedometerReq) - #expect(pedometerRes.ok == true) - let decodedPedometer = try decodePayload(pedometerRes.payloadJSON, as: OpenClawPedometerPayload.self) - #expect(decodedPedometer == pedometerPayload) - } - - @Test @MainActor func handleInvokePushToTalkReturnsTranscriptStatus() async throws { - let talkMode = TalkModeManager(allowSimulatorCapture: true) - talkMode.updateGatewayConnected(false) - let appModel = makeTalkTestAppModel(talkMode: talkMode) - - let startReq = BridgeInvokeRequest(id: "ptt-start", command: OpenClawTalkCommand.pttStart.rawValue) - let startRes = await appModel._test_handleInvoke(startReq) - #expect(startRes.ok == true) - let startPayload = try decodePayload(startRes.payloadJSON, as: OpenClawTalkPTTStartPayload.self) - #expect(!startPayload.captureId.isEmpty) - - talkMode._test_seedTranscript("Hello from PTT") - - let stopReq = BridgeInvokeRequest(id: "ptt-stop", command: OpenClawTalkCommand.pttStop.rawValue) - let stopRes = await appModel._test_handleInvoke(stopReq) - #expect(stopRes.ok == true) - let stopPayload = try decodePayload(stopRes.payloadJSON, as: OpenClawTalkPTTStopPayload.self) - #expect(stopPayload.captureId == startPayload.captureId) - #expect(stopPayload.transcript == "Hello from PTT") - #expect(stopPayload.status == "offline") - } - - @Test @MainActor func handleInvokePushToTalkCancelStopsSession() async throws { - let talkMode = TalkModeManager(allowSimulatorCapture: true) - talkMode.updateGatewayConnected(false) - let appModel = makeTalkTestAppModel(talkMode: talkMode) - - let startReq = BridgeInvokeRequest(id: "ptt-start", command: OpenClawTalkCommand.pttStart.rawValue) - let startRes = await appModel._test_handleInvoke(startReq) - #expect(startRes.ok == true) - let startPayload = try decodePayload(startRes.payloadJSON, as: OpenClawTalkPTTStartPayload.self) - - let cancelReq = BridgeInvokeRequest(id: "ptt-cancel", command: OpenClawTalkCommand.pttCancel.rawValue) - let cancelRes = await appModel._test_handleInvoke(cancelReq) - #expect(cancelRes.ok == true) - let cancelPayload = try decodePayload(cancelRes.payloadJSON, as: OpenClawTalkPTTStopPayload.self) - #expect(cancelPayload.captureId == startPayload.captureId) - #expect(cancelPayload.status == "cancelled") - } - - @Test @MainActor func handleInvokePushToTalkOnceAutoStopsAfterSilence() async throws { - let talkMode = TalkModeManager(allowSimulatorCapture: true) - talkMode.updateGatewayConnected(false) - let appModel = makeTalkTestAppModel(talkMode: talkMode) - - let onceReq = BridgeInvokeRequest(id: "ptt-once", command: OpenClawTalkCommand.pttOnce.rawValue) - let onceTask = Task { await appModel._test_handleInvoke(onceReq) } - - for _ in 0..<5 where !talkMode.isPushToTalkActive { - await Task.yield() - } - #expect(talkMode.isPushToTalkActive == true) - - talkMode._test_seedTranscript("Hello from PTT once") - talkMode._test_backdateLastHeard(seconds: 1.0) - await talkMode._test_runSilenceCheck() - - let onceRes = await onceTask.value - #expect(onceRes.ok == true) - let oncePayload = try decodePayload(onceRes.payloadJSON, as: OpenClawTalkPTTStopPayload.self) - #expect(oncePayload.transcript == "Hello from PTT once") - #expect(oncePayload.status == "offline") - } - - @Test @MainActor func handleInvokePushToTalkOnceStopsOnFinalTranscript() async throws { - let talkMode = TalkModeManager(allowSimulatorCapture: true) - talkMode.updateGatewayConnected(false) - let appModel = makeTalkTestAppModel(talkMode: talkMode) - - let onceReq = BridgeInvokeRequest(id: "ptt-once-final", command: OpenClawTalkCommand.pttOnce.rawValue) - let onceTask = Task { await appModel._test_handleInvoke(onceReq) } - - for _ in 0..<5 where !talkMode.isPushToTalkActive { - await Task.yield() - } - #expect(talkMode.isPushToTalkActive == true) - - await talkMode._test_handleTranscript("Hello final", isFinal: true) - - let onceRes = await onceTask.value - #expect(onceRes.ok == true) - let oncePayload = try decodePayload(onceRes.payloadJSON, as: OpenClawTalkPTTStopPayload.self) - #expect(oncePayload.transcript == "Hello final") - #expect(oncePayload.status == "offline") - } - @Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async { let appModel = NodeAppModel() let url = URL(string: "openclaw://agent?message=hello")! diff --git a/apps/ios/Tests/NodeDisplayNameTests.swift b/apps/ios/Tests/NodeDisplayNameTests.swift deleted file mode 100644 index 06623b5643..0000000000 --- a/apps/ios/Tests/NodeDisplayNameTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Testing -@testable import OpenClaw - -struct NodeDisplayNameTests { - @Test func keepsCustomName() { - let resolved = NodeDisplayName.resolve( - existing: "Razor Phone", - deviceName: "iPhone", - interfaceIdiom: .phone) - #expect(resolved == "Razor Phone") - } - - @Test func usesDeviceNameWhenMatchesIphone() { - let resolved = NodeDisplayName.resolve( - existing: "iOS Node", - deviceName: "iPhone 17 Pro", - interfaceIdiom: .phone) - #expect(resolved == "iPhone 17 Pro") - } - - @Test func usesDefaultWhenDeviceNameIsGeneric() { - let resolved = NodeDisplayName.resolve( - existing: nil, - deviceName: "Work Phone", - interfaceIdiom: .phone) - #expect(NodeDisplayName.isGeneric(resolved)) - } - - @Test func identifiesGenericValues() { - #expect(NodeDisplayName.isGeneric("iOS Node")) - #expect(NodeDisplayName.isGeneric("iPhone Node")) - #expect(NodeDisplayName.isGeneric("iPad Node")) - } -} diff --git a/apps/ios/Tests/TalkModeIncrementalTests.swift b/apps/ios/Tests/TalkModeIncrementalTests.swift deleted file mode 100644 index 9bd17f07d5..0000000000 --- a/apps/ios/Tests/TalkModeIncrementalTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation -import Testing -@testable import OpenClaw - -@Suite struct TalkModeIncrementalTests { - @Test @MainActor func incrementalSpeechSplitsOnBoundary() { - let talkMode = TalkModeManager(allowSimulatorCapture: true) - talkMode._test_incrementalReset() - let segments = talkMode._test_incrementalIngest("Hello world. Next", isFinal: false) - #expect(segments.count == 1) - #expect(segments.first == "Hello world.") - } - - @Test @MainActor func incrementalSpeechSkipsDirectiveLine() { - let talkMode = TalkModeManager(allowSimulatorCapture: true) - talkMode._test_incrementalReset() - let segments = talkMode._test_incrementalIngest("{\"voice\":\"abc\"}\nHello.", isFinal: false) - #expect(segments.count == 1) - #expect(segments.first == "Hello.") - } - - @Test @MainActor func incrementalSpeechIgnoresCodeBlocks() { - let talkMode = TalkModeManager(allowSimulatorCapture: true) - talkMode._test_incrementalReset() - let text = "Here is code:\n```js\nx=1\n```\nDone." - let segments = talkMode._test_incrementalIngest(text, isFinal: true) - #expect(segments.count == 1) - let value = segments.first ?? "" - #expect(value.contains("x=1") == false) - #expect(value.contains("Here is code") == true) - #expect(value.contains("Done.") == true) - } -} diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index a76fc72f0f..f7509236dc 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -360,12 +360,11 @@ actor GatewayConnection { await client.shutdown() } self.lastSnapshot = nil - let resolvedSessionBox = self.sessionBox ?? Self.buildSessionBox(url: url) self.client = GatewayChannelActor( url: url, token: token, password: password, - session: resolvedSessionBox, + session: self.sessionBox, pushHandler: { [weak self] push in await self?.handle(push: push) }) @@ -381,21 +380,6 @@ actor GatewayConnection { private static func defaultConfigProvider() async throws -> Config { try await GatewayEndpointStore.shared.requireConfig() } - - private static func buildSessionBox(url: URL) -> WebSocketSessionBox? { - guard url.scheme?.lowercased() == "wss" else { return nil } - let host = url.host ?? "gateway" - let port = url.port ?? 443 - let stableID = "\(host):\(port)" - let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) - let params = GatewayTLSParams( - required: true, - expectedFingerprint: stored, - allowTOFU: true, - storeKey: stableID) - let session = GatewayTLSPinningSession(params: params) - return WebSocketSessionBox(session: session) - } } // MARK: - Typed gateway API diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift deleted file mode 100644 index 9935b81ba9..0000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift +++ /dev/null @@ -1,93 +0,0 @@ -import Foundation - -public enum OpenClawCalendarCommand: String, Codable, Sendable { - case events = "calendar.events" - case add = "calendar.add" -} - -public struct OpenClawCalendarEventsParams: Codable, Sendable, Equatable { - public var startISO: String? - public var endISO: String? - public var limit: Int? - - public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) { - self.startISO = startISO - self.endISO = endISO - self.limit = limit - } -} - -public struct OpenClawCalendarAddParams: Codable, Sendable, Equatable { - public var title: String - public var startISO: String - public var endISO: String - public var isAllDay: Bool? - public var location: String? - public var notes: String? - public var calendarId: String? - public var calendarTitle: String? - - public init( - title: String, - startISO: String, - endISO: String, - isAllDay: Bool? = nil, - location: String? = nil, - notes: String? = nil, - calendarId: String? = nil, - calendarTitle: String? = nil) - { - self.title = title - self.startISO = startISO - self.endISO = endISO - self.isAllDay = isAllDay - self.location = location - self.notes = notes - self.calendarId = calendarId - self.calendarTitle = calendarTitle - } -} - -public struct OpenClawCalendarEventPayload: Codable, Sendable, Equatable { - public var identifier: String - public var title: String - public var startISO: String - public var endISO: String - public var isAllDay: Bool - public var location: String? - public var calendarTitle: String? - - public init( - identifier: String, - title: String, - startISO: String, - endISO: String, - isAllDay: Bool, - location: String? = nil, - calendarTitle: String? = nil) - { - self.identifier = identifier - self.title = title - self.startISO = startISO - self.endISO = endISO - self.isAllDay = isAllDay - self.location = location - self.calendarTitle = calendarTitle - } -} - -public struct OpenClawCalendarEventsPayload: Codable, Sendable, Equatable { - public var events: [OpenClawCalendarEventPayload] - - public init(events: [OpenClawCalendarEventPayload]) { - self.events = events - } -} - -public struct OpenClawCalendarAddPayload: Codable, Sendable, Equatable { - public var event: OpenClawCalendarEventPayload - - public init(event: OpenClawCalendarEventPayload) { - self.event = event - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift index d5c5e3c439..1cb820e732 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift @@ -6,10 +6,4 @@ public enum OpenClawCapability: String, Codable, Sendable { case screen case voiceWake case location - case device - case photos - case contacts - case calendar - case reminders - case motion } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ChatCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ChatCommands.swift deleted file mode 100644 index 98bac6205d..0000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ChatCommands.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -public enum OpenClawChatCommand: String, Codable, Sendable { - case push = "chat.push" -} - -public struct OpenClawChatPushParams: Codable, Sendable, Equatable { - public var text: String - public var speak: Bool? - - public init(text: String, speak: Bool? = nil) { - self.text = text - self.speak = speak - } -} - -public struct OpenClawChatPushPayload: Codable, Sendable, Equatable { - public var messageId: String? - - public init(messageId: String? = nil) { - self.messageId = messageId - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift deleted file mode 100644 index d99f6b9e74..0000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ContactsCommands.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Foundation - -public enum OpenClawContactsCommand: String, Codable, Sendable { - case search = "contacts.search" - case add = "contacts.add" -} - -public struct OpenClawContactsSearchParams: Codable, Sendable, Equatable { - public var query: String? - public var limit: Int? - - public init(query: String? = nil, limit: Int? = nil) { - self.query = query - self.limit = limit - } -} - -public struct OpenClawContactsAddParams: Codable, Sendable, Equatable { - public var givenName: String? - public var familyName: String? - public var organizationName: String? - public var displayName: String? - public var phoneNumbers: [String]? - public var emails: [String]? - - public init( - givenName: String? = nil, - familyName: String? = nil, - organizationName: String? = nil, - displayName: String? = nil, - phoneNumbers: [String]? = nil, - emails: [String]? = nil) - { - self.givenName = givenName - self.familyName = familyName - self.organizationName = organizationName - self.displayName = displayName - self.phoneNumbers = phoneNumbers - self.emails = emails - } -} - -public struct OpenClawContactPayload: Codable, Sendable, Equatable { - public var identifier: String - public var displayName: String - public var givenName: String - public var familyName: String - public var organizationName: String - public var phoneNumbers: [String] - public var emails: [String] - - public init( - identifier: String, - displayName: String, - givenName: String, - familyName: String, - organizationName: String, - phoneNumbers: [String], - emails: [String]) - { - self.identifier = identifier - self.displayName = displayName - self.givenName = givenName - self.familyName = familyName - self.organizationName = organizationName - self.phoneNumbers = phoneNumbers - self.emails = emails - } -} - -public struct OpenClawContactsSearchPayload: Codable, Sendable, Equatable { - public var contacts: [OpenClawContactPayload] - - public init(contacts: [OpenClawContactPayload]) { - self.contacts = contacts - } -} - -public struct OpenClawContactsAddPayload: Codable, Sendable, Equatable { - public var contact: OpenClawContactPayload - - public init(contact: OpenClawContactPayload) { - self.contact = contact - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift deleted file mode 100644 index c58224b3f1..0000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift +++ /dev/null @@ -1,134 +0,0 @@ -import Foundation - -public enum OpenClawDeviceCommand: String, Codable, Sendable { - case status = "device.status" - case info = "device.info" -} - -public enum OpenClawBatteryState: String, Codable, Sendable { - case unknown - case unplugged - case charging - case full -} - -public enum OpenClawThermalState: String, Codable, Sendable { - case nominal - case fair - case serious - case critical -} - -public enum OpenClawNetworkPathStatus: String, Codable, Sendable { - case satisfied - case unsatisfied - case requiresConnection -} - -public enum OpenClawNetworkInterfaceType: String, Codable, Sendable { - case wifi - case cellular - case wired - case other -} - -public struct OpenClawBatteryStatusPayload: Codable, Sendable, Equatable { - public var level: Double? - public var state: OpenClawBatteryState - public var lowPowerModeEnabled: Bool - - public init(level: Double?, state: OpenClawBatteryState, lowPowerModeEnabled: Bool) { - self.level = level - self.state = state - self.lowPowerModeEnabled = lowPowerModeEnabled - } -} - -public struct OpenClawThermalStatusPayload: Codable, Sendable, Equatable { - public var state: OpenClawThermalState - - public init(state: OpenClawThermalState) { - self.state = state - } -} - -public struct OpenClawStorageStatusPayload: Codable, Sendable, Equatable { - public var totalBytes: Int64 - public var freeBytes: Int64 - public var usedBytes: Int64 - - public init(totalBytes: Int64, freeBytes: Int64, usedBytes: Int64) { - self.totalBytes = totalBytes - self.freeBytes = freeBytes - self.usedBytes = usedBytes - } -} - -public struct OpenClawNetworkStatusPayload: Codable, Sendable, Equatable { - public var status: OpenClawNetworkPathStatus - public var isExpensive: Bool - public var isConstrained: Bool - public var interfaces: [OpenClawNetworkInterfaceType] - - public init( - status: OpenClawNetworkPathStatus, - isExpensive: Bool, - isConstrained: Bool, - interfaces: [OpenClawNetworkInterfaceType]) - { - self.status = status - self.isExpensive = isExpensive - self.isConstrained = isConstrained - self.interfaces = interfaces - } -} - -public struct OpenClawDeviceStatusPayload: Codable, Sendable, Equatable { - public var battery: OpenClawBatteryStatusPayload - public var thermal: OpenClawThermalStatusPayload - public var storage: OpenClawStorageStatusPayload - public var network: OpenClawNetworkStatusPayload - public var uptimeSeconds: Double - - public init( - battery: OpenClawBatteryStatusPayload, - thermal: OpenClawThermalStatusPayload, - storage: OpenClawStorageStatusPayload, - network: OpenClawNetworkStatusPayload, - uptimeSeconds: Double) - { - self.battery = battery - self.thermal = thermal - self.storage = storage - self.network = network - self.uptimeSeconds = uptimeSeconds - } -} - -public struct OpenClawDeviceInfoPayload: Codable, Sendable, Equatable { - public var deviceName: String - public var modelIdentifier: String - public var systemName: String - public var systemVersion: String - public var appVersion: String - public var appBuild: String - public var locale: String - - public init( - deviceName: String, - modelIdentifier: String, - systemName: String, - systemVersion: String, - appVersion: String, - appBuild: String, - locale: String) - { - self.deviceName = deviceName - self.modelIdentifier = modelIdentifier - self.systemName = systemName - self.systemVersion = systemVersion - self.appVersion = appVersion - self.appBuild = appBuild - self.locale = locale - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 676b10abb1..aebfcd72c1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -110,13 +110,7 @@ private enum ConnectChallengeError: Error { public actor GatewayChannelActor { private let logger = Logger(subsystem: "ai.openclaw", category: "gateway") - #if DEBUG - private var debugEventLogCount = 0 - private var debugMessageLogCount = 0 - private var debugListenLogCount = 0 - #endif private var task: WebSocketTaskBox? - private var listenTask: Task? private var pending: [String: CheckedContinuation] = [:] private var connected = false private var isConnecting = false @@ -175,9 +169,6 @@ public actor GatewayChannelActor { self.tickTask?.cancel() self.tickTask = nil - self.listenTask?.cancel() - self.listenTask = nil - self.task?.cancel(with: .goingAway, reason: nil) self.task = nil @@ -230,8 +221,6 @@ public actor GatewayChannelActor { self.isConnecting = true defer { self.isConnecting = false } - self.listenTask?.cancel() - self.listenTask = nil self.task?.cancel(with: .goingAway, reason: nil) self.task = self.session.makeWebSocketTask(url: self.url) self.task?.resume() @@ -259,7 +248,6 @@ public actor GatewayChannelActor { throw wrapped } self.listen() - self.logger.info("gateway ws listen registered") self.connected = true self.backoffMs = 500 self.lastSeq = nil @@ -432,44 +420,24 @@ public actor GatewayChannelActor { } private func listen() { - #if DEBUG - if self.debugListenLogCount < 3 { - self.debugListenLogCount += 1 - self.logger.info("gateway ws listen start") - } - #endif - self.listenTask?.cancel() - self.listenTask = Task { [weak self] in + self.task?.receive { [weak self] result in guard let self else { return } - defer { Task { await self.clearListenTask() } } - while !Task.isCancelled { - guard let task = await self.currentTask() else { return } - do { - let msg = try await task.receive() + switch result { + case let .failure(err): + Task { await self.handleReceiveFailure(err) } + case let .success(msg): + Task { await self.handle(msg) - } catch { - if Task.isCancelled { return } - await self.handleReceiveFailure(error) - return + await self.listen() } } } } - private func clearListenTask() { - self.listenTask = nil - } - - private func currentTask() -> WebSocketTaskBox? { - self.task - } - private func handleReceiveFailure(_ err: Error) async { let wrapped = self.wrap(err, context: "gateway receive") self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)") self.connected = false - self.listenTask?.cancel() - self.listenTask = nil await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)") await self.failPending(wrapped) await self.scheduleReconnect() @@ -481,13 +449,6 @@ public actor GatewayChannelActor { case let .string(s): s.data(using: .utf8) @unknown default: nil } - #if DEBUG - if self.debugMessageLogCount < 8 { - self.debugMessageLogCount += 1 - let size = data?.count ?? 0 - self.logger.info("gateway ws message received size=\(size, privacy: .public)") - } - #endif guard let data else { return } guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { self.logger.error("gateway decode failed") @@ -501,13 +462,6 @@ public actor GatewayChannelActor { } case let .event(evt): if evt.event == "connect.challenge" { return } - #if DEBUG - if self.debugEventLogCount < 12 { - self.debugEventLogCount += 1 - self.logger.info( - "gateway event received event=\(evt.event, privacy: .public) payload=\(evt.payload != nil, privacy: .public)") - } - #endif if let seq = evt.seq { if let last = lastSeq, seq > last + 1 { await self.pushHandler?(.seqGap(expected: last + 1, received: seq)) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index 5aa1021919..39190f7b88 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -11,7 +11,6 @@ private struct NodeInvokeRequestPayload: Codable, Sendable { var idempotencyKey: String? } - public actor GatewayNodeSession { private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway") private let decoder = JSONDecoder() @@ -24,78 +23,34 @@ public actor GatewayNodeSession { private var onConnected: (@Sendable () async -> Void)? private var onDisconnected: (@Sendable (String) async -> Void)? private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)? - private var hasNotifiedConnected = false - private var snapshotReceived = false - private var snapshotWaiters: [CheckedContinuation] = [] static func invokeWithTimeout( request: BridgeInvokeRequest, timeoutMs: Int?, onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse ) async -> BridgeInvokeResponse { - let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway") - let timeout: Int = { - guard let timeoutMs else { return 0 } - return max(0, timeoutMs) - }() + let timeout = max(0, timeoutMs ?? 0) guard timeout > 0 else { return await onInvoke(request) } - // Use an explicit latch so timeouts win even if onInvoke blocks (e.g., permission prompts). - final class InvokeLatch: @unchecked Sendable { - private let lock = NSLock() - private var continuation: CheckedContinuation? - private var resumed = false - - func setContinuation(_ continuation: CheckedContinuation) { - self.lock.lock() - defer { self.lock.unlock() } - self.continuation = continuation - } - - func resume(_ response: BridgeInvokeResponse) { - let cont: CheckedContinuation? - self.lock.lock() - if self.resumed { - self.lock.unlock() - return - } - self.resumed = true - cont = self.continuation - self.continuation = nil - self.lock.unlock() - cont?.resume(returning: response) - } - } - - let latch = InvokeLatch() - var onInvokeTask: Task? - var timeoutTask: Task? - defer { - onInvokeTask?.cancel() - timeoutTask?.cancel() - } - let response = await withCheckedContinuation { (cont: CheckedContinuation) in - latch.setContinuation(cont) - onInvokeTask = Task.detached { - let result = await onInvoke(request) - latch.resume(result) - } - timeoutTask = Task.detached { + return await withTaskGroup(of: BridgeInvokeResponse.self) { group in + group.addTask { await onInvoke(request) } + group.addTask { try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000) - timeoutLogger.info("node invoke timeout fired id=\(request.id, privacy: .public)") - latch.resume(BridgeInvokeResponse( + return BridgeInvokeResponse( id: request.id, ok: false, error: OpenClawNodeError( code: .unavailable, message: "node invoke timed out") - )) + ) } + + let first = await group.next()! + group.cancelAll() + return first } - timeoutLogger.info("node invoke race resolved id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") - return response } private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] private var canvasHostUrl: String? @@ -123,7 +78,6 @@ public actor GatewayNodeSession { self.onInvoke = onInvoke if shouldReconnect { - self.resetConnectionState() if let existing = self.channel { await existing.shutdown() } @@ -153,10 +107,7 @@ public actor GatewayNodeSession { do { try await channel.connect() - let snapshotReady = await self.waitForSnapshot(timeoutMs: 500) - if snapshotReady { - await self.notifyConnectedIfNeeded() - } + await onConnected() } catch { await onDisconnected(error.localizedDescription) throw error @@ -169,7 +120,6 @@ public actor GatewayNodeSession { self.activeURL = nil self.activeToken = nil self.activePassword = nil - self.resetConnectionState() } public func currentCanvasHostUrl() -> String? { @@ -229,8 +179,7 @@ public actor GatewayNodeSession { case let .snapshot(ok): let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil - self.markSnapshotReceived() - await self.notifyConnectedIfNeeded() + await self.onConnected?() case let .event(evt): await self.handleEvent(evt) default: @@ -238,98 +187,28 @@ public actor GatewayNodeSession { } } - private func resetConnectionState() { - self.hasNotifiedConnected = false - self.snapshotReceived = false - if !self.snapshotWaiters.isEmpty { - let waiters = self.snapshotWaiters - self.snapshotWaiters.removeAll() - for waiter in waiters { - waiter.resume(returning: false) - } - } - } - - private func markSnapshotReceived() { - self.snapshotReceived = true - if !self.snapshotWaiters.isEmpty { - let waiters = self.snapshotWaiters - self.snapshotWaiters.removeAll() - for waiter in waiters { - waiter.resume(returning: true) - } - } - } - - private func waitForSnapshot(timeoutMs: Int) async -> Bool { - if self.snapshotReceived { return true } - let clamped = max(0, timeoutMs) - return await withCheckedContinuation { cont in - self.snapshotWaiters.append(cont) - Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(clamped) * 1_000_000) - await self.timeoutSnapshotWaiters() - } - } - } - - private func timeoutSnapshotWaiters() { - guard !self.snapshotReceived else { return } - if !self.snapshotWaiters.isEmpty { - let waiters = self.snapshotWaiters - self.snapshotWaiters.removeAll() - for waiter in waiters { - waiter.resume(returning: false) - } - } - } - - private func notifyConnectedIfNeeded() async { - guard !self.hasNotifiedConnected else { return } - self.hasNotifiedConnected = true - await self.onConnected?() - } - private func handleEvent(_ evt: EventFrame) async { self.broadcastServerEvent(evt) guard evt.event == "node.invoke.request" else { return } - self.logger.info("node invoke request received") guard let payload = evt.payload else { return } do { - let request = try self.decodeInvokeRequest(from: payload) - let timeoutLabel = request.timeoutMs.map(String.init) ?? "none" - self.logger.info("node invoke request decoded id=\(request.id, privacy: .public) command=\(request.command, privacy: .public) timeoutMs=\(timeoutLabel, privacy: .public)") + let data = try self.encoder.encode(payload) + let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data) guard let onInvoke else { return } let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON) - self.logger.info("node invoke executing id=\(request.id, privacy: .public)") let response = await Self.invokeWithTimeout( request: req, timeoutMs: request.timeoutMs, onInvoke: onInvoke ) - self.logger.info("node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") await self.sendInvokeResult(request: request, response: response) } catch { self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)") } } - private func decodeInvokeRequest(from payload: OpenClawProtocol.AnyCodable) throws -> NodeInvokeRequestPayload { - do { - let data = try self.encoder.encode(payload) - return try self.decoder.decode(NodeInvokeRequestPayload.self, from: data) - } catch { - if let raw = payload.value as? String, let data = raw.data(using: .utf8) { - return try self.decoder.decode(NodeInvokeRequestPayload.self, from: data) - } - throw error - } - } - private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async { guard let channel = self.channel else { return } - self.logger.info("node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)") var params: [String: AnyCodable] = [ "id": AnyCodable(request.id), "nodeId": AnyCodable(request.nodeId), @@ -347,7 +226,7 @@ public actor GatewayNodeSession { do { try await channel.send(method: "node.invoke.result", params: params) } catch { - self.logger.error("node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)") } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift index eae636fd45..a0cbcd375f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift @@ -73,11 +73,6 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS if let expected { if fingerprint == expected { completionHandler(.useCredential, URLCredential(trust: trust)) - } else if params.allowTOFU { - if let storeKey = params.storeKey { - GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey) - } - completionHandler(.useCredential, URLCredential(trust: trust)) } else { completionHandler(.cancelAuthenticationChallenge, nil) } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift deleted file mode 100644 index ab487bfd00..0000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation - -public enum OpenClawMotionCommand: String, Codable, Sendable { - case activity = "motion.activity" - case pedometer = "motion.pedometer" -} - -public struct OpenClawMotionActivityParams: Codable, Sendable, Equatable { - public var startISO: String? - public var endISO: String? - public var limit: Int? - - public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) { - self.startISO = startISO - self.endISO = endISO - self.limit = limit - } -} - -public struct OpenClawMotionActivityEntry: Codable, Sendable, Equatable { - public var startISO: String - public var endISO: String - public var confidence: String - public var isWalking: Bool - public var isRunning: Bool - public var isCycling: Bool - public var isAutomotive: Bool - public var isStationary: Bool - public var isUnknown: Bool - - public init( - startISO: String, - endISO: String, - confidence: String, - isWalking: Bool, - isRunning: Bool, - isCycling: Bool, - isAutomotive: Bool, - isStationary: Bool, - isUnknown: Bool) - { - self.startISO = startISO - self.endISO = endISO - self.confidence = confidence - self.isWalking = isWalking - self.isRunning = isRunning - self.isCycling = isCycling - self.isAutomotive = isAutomotive - self.isStationary = isStationary - self.isUnknown = isUnknown - } -} - -public struct OpenClawMotionActivityPayload: Codable, Sendable, Equatable { - public var activities: [OpenClawMotionActivityEntry] - - public init(activities: [OpenClawMotionActivityEntry]) { - self.activities = activities - } -} - -public struct OpenClawPedometerParams: Codable, Sendable, Equatable { - public var startISO: String? - public var endISO: String? - - public init(startISO: String? = nil, endISO: String? = nil) { - self.startISO = startISO - self.endISO = endISO - } -} - -public struct OpenClawPedometerPayload: Codable, Sendable, Equatable { - public var startISO: String - public var endISO: String - public var steps: Int? - public var distanceMeters: Double? - public var floorsAscended: Int? - public var floorsDescended: Int? - - public init( - startISO: String, - endISO: String, - steps: Int?, - distanceMeters: Double?, - floorsAscended: Int?, - floorsDescended: Int?) - { - self.startISO = startISO - self.endISO = endISO - self.steps = steps - self.distanceMeters = distanceMeters - self.floorsAscended = floorsAscended - self.floorsDescended = floorsDescended - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotosCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotosCommands.swift deleted file mode 100644 index 8d22f5d279..0000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/PhotosCommands.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -public enum OpenClawPhotosCommand: String, Codable, Sendable { - case latest = "photos.latest" -} - -public struct OpenClawPhotosLatestParams: Codable, Sendable, Equatable { - public var limit: Int? - public var maxWidth: Int? - public var quality: Double? - - public init(limit: Int? = nil, maxWidth: Int? = nil, quality: Double? = nil) { - self.limit = limit - self.maxWidth = maxWidth - self.quality = quality - } -} - -public struct OpenClawPhotoPayload: Codable, Sendable, Equatable { - public var format: String - public var base64: String - public var width: Int - public var height: Int - public var createdAt: String? - - public init(format: String, base64: String, width: Int, height: Int, createdAt: String? = nil) { - self.format = format - self.base64 = base64 - self.width = width - self.height = height - self.createdAt = createdAt - } -} - -public struct OpenClawPhotosLatestPayload: Codable, Sendable, Equatable { - public var photos: [OpenClawPhotoPayload] - - public init(photos: [OpenClawPhotoPayload]) { - self.photos = photos - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift deleted file mode 100644 index ac275d8036..0000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/RemindersCommands.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation - -public enum OpenClawRemindersCommand: String, Codable, Sendable { - case list = "reminders.list" - case add = "reminders.add" -} - -public enum OpenClawReminderStatusFilter: String, Codable, Sendable { - case incomplete - case completed - case all -} - -public struct OpenClawRemindersListParams: Codable, Sendable, Equatable { - public var status: OpenClawReminderStatusFilter? - public var limit: Int? - - public init(status: OpenClawReminderStatusFilter? = nil, limit: Int? = nil) { - self.status = status - self.limit = limit - } -} - -public struct OpenClawRemindersAddParams: Codable, Sendable, Equatable { - public var title: String - public var dueISO: String? - public var notes: String? - public var listId: String? - public var listName: String? - - public init( - title: String, - dueISO: String? = nil, - notes: String? = nil, - listId: String? = nil, - listName: String? = nil) - { - self.title = title - self.dueISO = dueISO - self.notes = notes - self.listId = listId - self.listName = listName - } -} - -public struct OpenClawReminderPayload: Codable, Sendable, Equatable { - public var identifier: String - public var title: String - public var dueISO: String? - public var completed: Bool - public var listName: String? - - public init( - identifier: String, - title: String, - dueISO: String? = nil, - completed: Bool, - listName: String? = nil) - { - self.identifier = identifier - self.title = title - self.dueISO = dueISO - self.completed = completed - self.listName = listName - } -} - -public struct OpenClawRemindersListPayload: Codable, Sendable, Equatable { - public var reminders: [OpenClawReminderPayload] - - public init(reminders: [OpenClawReminderPayload]) { - self.reminders = reminders - } -} - -public struct OpenClawRemindersAddPayload: Codable, Sendable, Equatable { - public var reminder: OpenClawReminderPayload - - public init(reminder: OpenClawReminderPayload) { - self.reminder = reminder - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json index 78148cd6c4..9c0e57fc6a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -123,10 +123,6 @@ "screen_record": { "label": "screen record", "detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"] - }, - "invoke": { - "label": "invoke", - "detailKeys": ["node", "nodeId", "invokeCommand"] } } }, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkCommands.swift deleted file mode 100644 index 755fc97a98..0000000000 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkCommands.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -public enum OpenClawTalkCommand: String, Codable, Sendable { - case pttStart = "talk.ptt.start" - case pttStop = "talk.ptt.stop" - case pttCancel = "talk.ptt.cancel" - case pttOnce = "talk.ptt.once" -} - -public struct OpenClawTalkPTTStartPayload: Codable, Sendable, Equatable { - public var captureId: String - - public init(captureId: String) { - self.captureId = captureId - } -} - -public struct OpenClawTalkPTTStopPayload: Codable, Sendable, Equatable { - public var captureId: String - public var transcript: String? - public var status: String - - public init(captureId: String, transcript: String?, status: String) { - self.captureId = captureId - self.transcript = transcript - self.status = status - } -} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeInvokeTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeInvokeTests.swift deleted file mode 100644 index e3593d3825..0000000000 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeInvokeTests.swift +++ /dev/null @@ -1,310 +0,0 @@ -import Foundation -import Testing -@testable import OpenClawKit -import OpenClawProtocol - -@Suite struct GatewayNodeInvokeTests { - @Test - func nodeInvokeRequestSendsInvokeResult() async throws { - let task = TestWebSocketTask() - let session = TestWebSocketSession(task: task) - - task.enqueue(Self.makeEventMessage( - event: "connect.challenge", - payload: ["nonce": "test-nonce"])) - - let tracker = InvokeTracker() - let gateway = GatewayNodeSession() - try await gateway.connect( - url: URL(string: "ws://127.0.0.1:18789")!, - token: nil, - password: "test-password", - connectOptions: GatewayConnectOptions( - role: "node", - scopes: [], - caps: [], - commands: ["device.info"], - permissions: [:], - clientId: "openclaw-ios", - clientMode: "node", - clientDisplayName: "Test iOS Node"), - sessionBox: WebSocketSessionBox(session: session), - onConnected: {}, - onDisconnected: { _ in }, - onInvoke: { req in - await tracker.set(req) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{\"ok\":true}") - }) - - task.enqueue(Self.makeEventMessage( - event: "node.invoke.request", - payload: [ - "id": "invoke-1", - "nodeId": "node-1", - "command": "device.info", - "timeoutMs": 15000, - "idempotencyKey": "abc123", - ])) - - let resultFrame = try await waitForSentMethod( - task, - method: "node.invoke.result", - timeoutSeconds: 1.0) - - let sentParams = resultFrame.params?.value as? [String: OpenClawProtocol.AnyCodable] - #expect(sentParams?["id"]?.value as? String == "invoke-1") - #expect(sentParams?["nodeId"]?.value as? String == "node-1") - #expect(sentParams?["ok"]?.value as? Bool == true) - - let captured = await tracker.get() - #expect(captured?.command == "device.info") - #expect(captured?.id == "invoke-1") - } - - @Test - func nodeInvokeRequestHandlesStringPayload() async throws { - let task = TestWebSocketTask() - let session = TestWebSocketSession(task: task) - - task.enqueue(Self.makeEventMessage( - event: "connect.challenge", - payload: ["nonce": "test-nonce"])) - - let tracker = InvokeTracker() - let gateway = GatewayNodeSession() - try await gateway.connect( - url: URL(string: "ws://127.0.0.1:18789")!, - token: nil, - password: "test-password", - connectOptions: GatewayConnectOptions( - role: "node", - scopes: [], - caps: [], - commands: ["device.info"], - permissions: [:], - clientId: "openclaw-ios", - clientMode: "node", - clientDisplayName: "Test iOS Node"), - sessionBox: WebSocketSessionBox(session: session), - onConnected: {}, - onDisconnected: { _ in }, - onInvoke: { req in - await tracker.set(req) - return BridgeInvokeResponse(id: req.id, ok: true) - }) - - let payload = """ - {"id":"invoke-2","nodeId":"node-1","command":"device.info"} - """ - task.enqueue(Self.makeEventMessage( - event: "node.invoke.request", - payload: payload)) - - let resultFrame = try await waitForSentMethod( - task, - method: "node.invoke.result", - timeoutSeconds: 1.0) - - let sentParams = resultFrame.params?.value as? [String: OpenClawProtocol.AnyCodable] - #expect(sentParams?["id"]?.value as? String == "invoke-2") - #expect(sentParams?["nodeId"]?.value as? String == "node-1") - #expect(sentParams?["ok"]?.value as? Bool == true) - - let captured = await tracker.get() - #expect(captured?.command == "device.info") - #expect(captured?.id == "invoke-2") - } -} - -private enum TestError: Error { - case timeout -} - -private func waitForSentMethod( - _ task: TestWebSocketTask, - method: String, - timeoutSeconds: Double -) async throws -> RequestFrame { - try await AsyncTimeout.withTimeout( - seconds: timeoutSeconds, - onTimeout: { TestError.timeout }, - operation: { - while true { - let frames = task.sentRequests() - if let match = frames.first(where: { $0.method == method }) { - return match - } - try? await Task.sleep(nanoseconds: 50_000_000) - } - }) -} - -private actor InvokeTracker { - private var request: BridgeInvokeRequest? - - func set(_ req: BridgeInvokeRequest) { - self.request = req - } - - func get() -> BridgeInvokeRequest? { - self.request - } -} - -private final class TestWebSocketSession: WebSocketSessioning { - private let task: TestWebSocketTask - - init(task: TestWebSocketTask) { - self.task = task - } - - func makeWebSocketTask(url: URL) -> WebSocketTaskBox { - WebSocketTaskBox(task: self.task) - } -} - -private final class TestWebSocketTask: WebSocketTasking, @unchecked Sendable { - private let lock = NSLock() - private var _state: URLSessionTask.State = .suspended - private var receiveQueue: [URLSessionWebSocketTask.Message] = [] - private var receiveContinuations: [CheckedContinuation] = [] - private var receiveHandlers: [@Sendable (Result) -> Void] = [] - private var sent: [URLSessionWebSocketTask.Message] = [] - - var state: URLSessionTask.State { - self.lock.withLock { self._state } - } - - func resume() { - self.lock.withLock { self._state = .running } - } - - func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { - self.lock.withLock { self._state = .canceling } - } - - func send(_ message: URLSessionWebSocketTask.Message) async throws { - self.lock.withLock { self.sent.append(message) } - guard let frame = Self.decodeRequestFrame(message) else { return } - guard frame.method == "connect" else { return } - let id = frame.id - let response = Self.connectResponse(for: id) - self.enqueue(.data(response)) - } - - func receive() async throws -> URLSessionWebSocketTask.Message { - try await withCheckedThrowingContinuation { cont in - var next: URLSessionWebSocketTask.Message? - self.lock.withLock { - if !self.receiveQueue.isEmpty { - next = self.receiveQueue.removeFirst() - } else { - self.receiveContinuations.append(cont) - } - } - if let next { cont.resume(returning: next) } - } - } - - func receive(completionHandler: @escaping @Sendable (Result) -> Void) { - var next: URLSessionWebSocketTask.Message? - self.lock.withLock { - if !self.receiveQueue.isEmpty { - next = self.receiveQueue.removeFirst() - } else { - self.receiveHandlers.append(completionHandler) - } - } - if let next { - completionHandler(.success(next)) - } - } - - func enqueue(_ message: URLSessionWebSocketTask.Message) { - var handler: (@Sendable (Result) -> Void)? - var continuation: CheckedContinuation? - self.lock.withLock { - if !self.receiveHandlers.isEmpty { - handler = self.receiveHandlers.removeFirst() - } else if !self.receiveContinuations.isEmpty { - continuation = self.receiveContinuations.removeFirst() - } else { - self.receiveQueue.append(message) - } - } - if let handler { - handler(.success(message)) - } else if let continuation { - continuation.resume(returning: message) - } - } - - func sentRequests() -> [RequestFrame] { - let messages = self.lock.withLock { self.sent } - return messages.compactMap(Self.decodeRequestFrame) - } - - private static func decodeRequestFrame(_ message: URLSessionWebSocketTask.Message) -> RequestFrame? { - let data: Data? - switch message { - case let .data(raw): data = raw - case let .string(text): data = text.data(using: .utf8) - @unknown default: data = nil - } - guard let data else { return nil } - return try? JSONDecoder().decode(RequestFrame.self, from: data) - } - - private static func connectResponse(for id: String) -> Data { - let payload: [String: Any] = [ - "type": "hello-ok", - "protocol": 3, - "server": [ - "version": "dev", - "connId": "test-conn", - ], - "features": [ - "methods": [], - "events": [], - ], - "snapshot": [ - "presence": [], - "health": ["ok": true], - "stateVersion": ["presence": 0, "health": 0], - "uptimeMs": 0, - ], - "policy": [ - "maxPayload": 1, - "maxBufferedBytes": 1, - "tickIntervalMs": 1000, - ], - ] - let frame: [String: Any] = [ - "type": "res", - "id": id, - "ok": true, - "payload": payload, - ] - return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data() - } -} - -private extension GatewayNodeInvokeTests { - static func makeEventMessage(event: String, payload: Any) -> URLSessionWebSocketTask.Message { - let frame: [String: Any] = [ - "type": "event", - "event": event, - "payload": payload, - ] - let data = try? JSONSerialization.data(withJSONObject: frame) - return .data(data ?? Data()) - } -} - -private extension NSLock { - func withLock(_ body: () -> T) -> T { - self.lock() - defer { self.unlock() } - return body() - } -} diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 3b52ab3485..24684c72bc 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -75,7 +75,6 @@ Text + native (when enabled): - `/usage off|tokens|full|cost` (per-response usage footer or local cost summary) - `/tts off|always|inbound|tagged|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts)) - Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works. -- `/ptt start|stop|once|cancel [node=]` (push-to-talk controls for a paired node) - `/stop` - `/restart` - `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram) diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index 86f6bed4db..802a8c662f 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -133,52 +133,3 @@ describe("nodes run", () => { }); }); }); - -describe("nodes invoke", () => { - beforeEach(() => { - callGateway.mockReset(); - }); - - it("invokes arbitrary commands with params JSON", async () => { - callGateway.mockImplementation(async ({ method, params }) => { - if (method === "node.list") { - return { nodes: [{ nodeId: "ios-1" }] }; - } - if (method === "node.invoke") { - expect(params).toMatchObject({ - nodeId: "ios-1", - command: "device.info", - params: { includeBattery: true }, - timeoutMs: 12_000, - }); - return { - ok: true, - nodeId: "ios-1", - command: "device.info", - payload: { deviceName: "iPhone" }, - }; - } - throw new Error(`unexpected method: ${String(method)}`); - }); - - const tool = createOpenClawTools().find((candidate) => candidate.name === "nodes"); - if (!tool) { - throw new Error("missing nodes tool"); - } - - const result = await tool.execute("call1", { - action: "invoke", - node: "ios-1", - invokeCommand: "device.info", - invokeParamsJson: JSON.stringify({ includeBattery: true }), - invokeTimeoutMs: 12_000, - }); - - expect(result.details).toMatchObject({ - ok: true, - nodeId: "ios-1", - command: "device.info", - payload: { deviceName: "iPhone" }, - }); - }); -}); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 67e353e21d..b1fde37325 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -229,7 +229,7 @@ export function buildAgentSystemPrompt(params: { // Channel docking: add login tools here when a channel needs interactive linking. browser: "Control web browser", canvas: "Present/eval/snapshot the Canvas", - nodes: "List/describe/notify/camera/screen/invoke on paired nodes", + nodes: "List/describe/notify/camera/screen on paired nodes", cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", message: "Send messages and channel actions", gateway: "Restart, apply config, or run updates on the running OpenClaw process", @@ -382,7 +382,7 @@ export function buildAgentSystemPrompt(params: { `- ${processToolName}: manage background exec sessions`, "- browser: control openclaw's dedicated browser", "- canvas: present/eval/snapshot the Canvas", - "- nodes: list/describe/notify/camera/screen/invoke on paired nodes", + "- nodes: list/describe/notify/camera/screen on paired nodes", "- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", "- sessions_list: list sessions", "- sessions_history: fetch session history", diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index 625d6046ba..3fea81405e 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -140,10 +140,6 @@ "screen_record": { "label": "screen record", "detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"] - }, - "invoke": { - "label": "invoke", - "detailKeys": ["node", "nodeId", "invokeCommand"] } } }, diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index d3d77b89fe..4fce2a7f38 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -37,7 +37,6 @@ const NODES_TOOL_ACTIONS = [ "screen_record", "location_get", "run", - "invoke", ] as const; const NOTIFY_PRIORITIES = ["passive", "active", "timeSensitive"] as const; @@ -85,9 +84,6 @@ const NodesToolSchema = Type.Object({ commandTimeoutMs: Type.Optional(Type.Number()), invokeTimeoutMs: Type.Optional(Type.Number()), needsScreenRecording: Type.Optional(Type.Boolean()), - // invoke - invokeCommand: Type.Optional(Type.String()), - invokeParamsJson: Type.Optional(Type.String()), }); export function createNodesTool(options?: { @@ -103,7 +99,7 @@ export function createNodesTool(options?: { label: "Nodes", name: "nodes", description: - "Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location/run/invoke).", + "Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location/run).", parameters: NodesToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; @@ -442,31 +438,6 @@ export function createNodesTool(options?: { }); return jsonResult(raw?.payload ?? {}); } - case "invoke": { - const node = readStringParam(params, "node", { required: true }); - const nodeId = await resolveNodeId(gatewayOpts, node); - const invokeCommand = readStringParam(params, "invokeCommand", { required: true }); - const invokeParamsJson = - typeof params.invokeParamsJson === "string" ? params.invokeParamsJson.trim() : ""; - let invokeParams: unknown = {}; - if (invokeParamsJson) { - try { - invokeParams = JSON.parse(invokeParamsJson); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error(`invokeParamsJson must be valid JSON: ${message}`, { cause: err }); - } - } - const invokeTimeoutMs = parseTimeoutMs(params.invokeTimeoutMs); - const raw = await callGatewayTool("node.invoke", gatewayOpts, { - nodeId, - command: invokeCommand, - params: invokeParams, - timeoutMs: invokeTimeoutMs, - idempotencyKey: crypto.randomUUID(), - }); - return jsonResult(raw ?? {}); - } default: throw new Error(`Unknown action: ${action}`); } diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index a9a8c730c6..076541d98a 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -273,28 +273,6 @@ function buildChatCommands(): ChatCommandDefinition[] { ], argsMenu: "auto", }), - defineChatCommand({ - key: "ptt", - nativeName: "ptt", - description: "Push-to-talk controls for a paired node.", - textAlias: "/ptt", - acceptsArgs: true, - argsParsing: "none", - category: "tools", - args: [ - { - name: "action", - description: "start, stop, once, or cancel", - type: "string", - choices: ["start", "stop", "once", "cancel"], - }, - { - name: "node", - description: "node= (optional)", - type: "string", - }, - ], - }), defineChatCommand({ key: "config", nativeName: "config", diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 183481363b..c139fd6f64 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -21,7 +21,6 @@ import { } from "./commands-info.js"; import { handleModelsCommand } from "./commands-models.js"; import { handlePluginCommand } from "./commands-plugin.js"; -import { handlePTTCommand } from "./commands-ptt.js"; import { handleAbortTrigger, handleActivationCommand, @@ -47,7 +46,6 @@ export async function handleCommands(params: HandleCommandsParams): Promise ({ ok: true })); - -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGateway(opts as { method?: string }), - randomIdempotencyKey: () => "idem-test", -})); - -function buildParams(commandBody: string, cfg: OpenClawConfig, ctxOverrides?: Partial) { - const ctx = { - Body: commandBody, - CommandBody: commandBody, - CommandSource: "text", - CommandAuthorized: true, - Provider: "telegram", - Surface: "telegram", - ...ctxOverrides, - } as MsgContext; - - const command = buildCommandContext({ - ctx, - cfg, - isGroup: false, - triggerBodyNormalized: commandBody.trim().toLowerCase(), - commandAuthorized: true, - }); - - return { - ctx, - cfg, - command, - directives: parseInlineDirectives(commandBody), - elevated: { enabled: true, allowed: true, failures: [] }, - sessionKey: "agent:main:main", - workspaceDir: "/tmp", - defaultGroupActivation: () => "mention", - resolvedVerboseLevel: "off" as const, - resolvedReasoningLevel: "off" as const, - resolveDefaultThinkingLevel: async () => undefined, - provider: "telegram", - model: "test-model", - contextTokens: 0, - isGroup: false, - }; -} - -describe("handleCommands /ptt", () => { - it("invokes talk.ptt.once on the default iOS node", async () => { - callGateway.mockImplementation(async (opts: { method?: string; params?: unknown }) => { - if (opts.method === "node.list") { - return { - nodes: [ - { - nodeId: "ios-1", - displayName: "iPhone", - platform: "ios", - connected: true, - }, - ], - }; - } - if (opts.method === "node.invoke") { - return { - ok: true, - nodeId: "ios-1", - command: "talk.ptt.once", - payload: { status: "offline" }, - }; - } - return { ok: true }; - }); - - const cfg = { - commands: { text: true }, - channels: { telegram: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/ptt once", cfg); - const result = await handleCommands(params); - - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("PTT once"); - expect(result.reply?.text).toContain("status: offline"); - - const invokeCall = callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke"); - expect(invokeCall).toBeTruthy(); - expect(invokeCall?.[0]?.params?.command).toBe("talk.ptt.once"); - expect(invokeCall?.[0]?.params?.idempotencyKey).toBe("idem-test"); - }); -}); diff --git a/src/auto-reply/reply/commands-ptt.ts b/src/auto-reply/reply/commands-ptt.ts deleted file mode 100644 index f104b3f177..0000000000 --- a/src/auto-reply/reply/commands-ptt.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type { OpenClawConfig } from "../../config/config.js"; -import type { CommandHandler } from "./commands-types.js"; -import { callGateway, randomIdempotencyKey } from "../../gateway/call.js"; -import { logVerbose } from "../../globals.js"; - -type NodeSummary = { - nodeId: string; - displayName?: string; - platform?: string; - deviceFamily?: string; - remoteIp?: string; - connected?: boolean; -}; - -const PTT_COMMANDS: Record = { - start: "talk.ptt.start", - stop: "talk.ptt.stop", - once: "talk.ptt.once", - cancel: "talk.ptt.cancel", -}; - -function normalizeNodeKey(value: string) { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+/, "") - .replace(/-+$/, ""); -} - -function isIOSNode(node: NodeSummary): boolean { - const platform = node.platform?.toLowerCase() ?? ""; - const family = node.deviceFamily?.toLowerCase() ?? ""; - return ( - platform.startsWith("ios") || - family.includes("iphone") || - family.includes("ipad") || - family.includes("ios") - ); -} - -async function loadNodes(cfg: OpenClawConfig): Promise { - try { - const res = await callGateway<{ nodes?: NodeSummary[] }>({ - method: "node.list", - params: {}, - config: cfg, - }); - return Array.isArray(res.nodes) ? res.nodes : []; - } catch { - const res = await callGateway<{ pending?: unknown[]; paired?: NodeSummary[] }>({ - method: "node.pair.list", - params: {}, - config: cfg, - }); - return Array.isArray(res.paired) ? res.paired : []; - } -} - -function describeNodes(nodes: NodeSummary[]) { - return nodes - .map((node) => node.displayName || node.remoteIp || node.nodeId) - .filter(Boolean) - .join(", "); -} - -function resolveNodeId(nodes: NodeSummary[], query?: string): string { - const trimmed = String(query ?? "").trim(); - if (trimmed) { - const qNorm = normalizeNodeKey(trimmed); - const matches = nodes.filter((node) => { - if (node.nodeId === trimmed) { - return true; - } - if (typeof node.remoteIp === "string" && node.remoteIp === trimmed) { - return true; - } - const name = typeof node.displayName === "string" ? node.displayName : ""; - if (name && normalizeNodeKey(name) === qNorm) { - return true; - } - if (trimmed.length >= 6 && node.nodeId.startsWith(trimmed)) { - return true; - } - return false; - }); - - if (matches.length === 1) { - return matches[0].nodeId; - } - const known = describeNodes(nodes); - if (matches.length === 0) { - throw new Error(`unknown node: ${trimmed}${known ? ` (known: ${known})` : ""}`); - } - throw new Error( - `ambiguous node: ${trimmed} (matches: ${matches - .map((node) => node.displayName || node.remoteIp || node.nodeId) - .join(", ")})`, - ); - } - - const iosNodes = nodes.filter(isIOSNode); - const iosConnected = iosNodes.filter((node) => node.connected); - const iosCandidates = iosConnected.length > 0 ? iosConnected : iosNodes; - if (iosCandidates.length === 1) { - return iosCandidates[0].nodeId; - } - if (iosCandidates.length > 1) { - throw new Error( - `multiple iOS nodes found (${describeNodes(iosCandidates)}); specify node=`, - ); - } - - const connected = nodes.filter((node) => node.connected); - const fallback = connected.length > 0 ? connected : nodes; - if (fallback.length === 1) { - return fallback[0].nodeId; - } - - const known = describeNodes(nodes); - throw new Error(`node required${known ? ` (known: ${known})` : ""}`); -} - -function parsePTTArgs(commandBody: string) { - const tokens = commandBody.trim().split(/\s+/).slice(1); - let action: string | undefined; - let node: string | undefined; - for (const token of tokens) { - if (!token) { - continue; - } - if (token.toLowerCase().startsWith("node=")) { - node = token.slice("node=".length); - continue; - } - if (!action) { - action = token; - } - } - return { action, node }; -} - -function buildPTTHelpText() { - return [ - "Usage: /ptt [node=]", - "Example: /ptt once node=iphone", - ].join("\n"); -} - -export const handlePTTCommand: CommandHandler = async (params, allowTextCommands) => { - if (!allowTextCommands) { - return null; - } - const { command, cfg } = params; - const normalized = command.commandBodyNormalized.trim(); - if (!normalized.startsWith("/ptt")) { - return null; - } - if (!command.isAuthorizedSender) { - logVerbose(`Ignoring /ptt from unauthorized sender: ${command.senderId || ""}`); - return { shouldContinue: false, reply: { text: "PTT requires an authorized sender." } }; - } - - const parsed = parsePTTArgs(normalized); - const actionKey = parsed.action?.trim().toLowerCase() ?? ""; - const commandId = PTT_COMMANDS[actionKey]; - if (!commandId) { - return { shouldContinue: false, reply: { text: buildPTTHelpText() } }; - } - - try { - const nodes = await loadNodes(cfg); - const nodeId = resolveNodeId(nodes, parsed.node); - const invokeParams: Record = { - nodeId, - command: commandId, - params: {}, - idempotencyKey: randomIdempotencyKey(), - timeoutMs: 15_000, - }; - const res = await callGateway<{ - ok?: boolean; - payload?: Record; - command?: string; - nodeId?: string; - }>({ - method: "node.invoke", - params: invokeParams, - config: cfg, - }); - const payload = res.payload && typeof res.payload === "object" ? res.payload : {}; - - const lines = [`PTT ${actionKey} → ${nodeId}`]; - if (typeof payload.status === "string") { - lines.push(`status: ${payload.status}`); - } - if (typeof payload.captureId === "string") { - lines.push(`captureId: ${payload.captureId}`); - } - if (typeof payload.transcript === "string" && payload.transcript.trim()) { - lines.push(`transcript: ${payload.transcript}`); - } - - return { shouldContinue: false, reply: { text: lines.join("\n") } }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { shouldContinue: false, reply: { text: `PTT failed: ${message}` } }; - } -}; diff --git a/src/cli/nodes-cli.coverage.test.ts b/src/cli/nodes-cli.coverage.test.ts index d5ae7c14ba..13fc36caf9 100644 --- a/src/cli/nodes-cli.coverage.test.ts +++ b/src/cli/nodes-cli.coverage.test.ts @@ -251,23 +251,4 @@ describe("nodes-cli coverage", () => { }); expect(invoke?.params?.timeoutMs).toBe(6000); }); - - it("invokes talk.ptt.once via nodes talk ptt once", async () => { - runtimeLogs.length = 0; - runtimeErrors.length = 0; - callGateway.mockClear(); - randomIdempotencyKey.mockClear(); - - const { registerNodesCli } = await import("./nodes-cli.js"); - const program = new Command(); - program.exitOverride(); - registerNodesCli(program); - - await program.parseAsync(["nodes", "talk", "ptt", "once", "--node", "mac-1"], { from: "user" }); - - const invoke = callGateway.mock.calls.find((call) => call[0]?.method === "node.invoke")?.[0]; - expect(invoke).toBeTruthy(); - expect(invoke?.params?.command).toBe("talk.ptt.once"); - expect(invoke?.params?.idempotencyKey).toBe("rk_test"); - }); }); diff --git a/src/cli/nodes-cli/register.talk.ts b/src/cli/nodes-cli/register.talk.ts deleted file mode 100644 index 402fb5a50b..0000000000 --- a/src/cli/nodes-cli/register.talk.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { Command } from "commander"; -import type { NodesRpcOpts } from "./types.js"; -import { randomIdempotencyKey } from "../../gateway/call.js"; -import { defaultRuntime } from "../../runtime.js"; -import { runNodesCommand } from "./cli-utils.js"; -import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; - -type PTTAction = { - name: string; - command: string; - description: string; -}; - -const PTT_ACTIONS: PTTAction[] = [ - { name: "start", command: "talk.ptt.start", description: "Start push-to-talk capture" }, - { name: "stop", command: "talk.ptt.stop", description: "Stop push-to-talk capture" }, - { name: "once", command: "talk.ptt.once", description: "Run push-to-talk once" }, - { name: "cancel", command: "talk.ptt.cancel", description: "Cancel push-to-talk capture" }, -]; - -export function registerNodesTalkCommands(nodes: Command) { - const talk = nodes.command("talk").description("Talk/voice controls on a paired node"); - const ptt = talk.command("ptt").description("Push-to-talk controls"); - - for (const action of PTT_ACTIONS) { - nodesCallOpts( - ptt - .command(action.name) - .description(action.description) - .requiredOption("--node ", "Node id, name, or IP") - .option("--invoke-timeout ", "Node invoke timeout in ms (default 15000)", "15000") - .action(async (opts: NodesRpcOpts) => { - await runNodesCommand(`talk ptt ${action.name}`, async () => { - const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); - const invokeTimeoutMs = opts.invokeTimeout - ? Number.parseInt(String(opts.invokeTimeout), 10) - : undefined; - - const invokeParams: Record = { - nodeId, - command: action.command, - params: {}, - idempotencyKey: randomIdempotencyKey(), - }; - if (typeof invokeTimeoutMs === "number" && Number.isFinite(invokeTimeoutMs)) { - invokeParams.timeoutMs = invokeTimeoutMs; - } - - const raw = await callGatewayCli("node.invoke", opts, invokeParams); - const res = - typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; - const payload = - res.payload && typeof res.payload === "object" - ? (res.payload as Record) - : {}; - - if (opts.json) { - defaultRuntime.log(JSON.stringify(payload, null, 2)); - return; - } - - const lines = [`PTT ${action.name} → ${nodeId}`]; - if (typeof payload.status === "string") { - lines.push(`status: ${payload.status}`); - } - if (typeof payload.captureId === "string") { - lines.push(`captureId: ${payload.captureId}`); - } - if (typeof payload.transcript === "string" && payload.transcript.trim()) { - lines.push(`transcript: ${payload.transcript}`); - } - - defaultRuntime.log(lines.join("\n")); - }); - }), - { timeoutMs: 30_000 }, - ); - } -} diff --git a/src/cli/nodes-cli/register.ts b/src/cli/nodes-cli/register.ts index 5297b5933d..04e4391bff 100644 --- a/src/cli/nodes-cli/register.ts +++ b/src/cli/nodes-cli/register.ts @@ -9,7 +9,6 @@ import { registerNodesNotifyCommand } from "./register.notify.js"; import { registerNodesPairingCommands } from "./register.pairing.js"; import { registerNodesScreenCommands } from "./register.screen.js"; import { registerNodesStatusCommands } from "./register.status.js"; -import { registerNodesTalkCommands } from "./register.talk.js"; export function registerNodesCli(program: Command) { const nodes = program @@ -29,5 +28,4 @@ export function registerNodesCli(program: Command) { registerNodesCameraCommands(nodes); registerNodesScreenCommands(nodes); registerNodesLocationCommands(nodes); - registerNodesTalkCommands(nodes); } diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index 791ff63ce0..f22611404c 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -20,24 +20,6 @@ const LOCATION_COMMANDS = ["location.get"]; const SMS_COMMANDS = ["sms.send"]; -const DEVICE_COMMANDS = ["device.status", "device.info"]; - -const PHOTOS_COMMANDS = ["photos.latest"]; - -const CONTACTS_COMMANDS = ["contacts.search", "contacts.add"]; - -const CALENDAR_COMMANDS = ["calendar.events", "calendar.add"]; - -const REMINDERS_COMMANDS = ["reminders.list", "reminders.add"]; - -const MOTION_COMMANDS = ["motion.activity", "motion.pedometer"]; - -const SYSTEM_NOTIFY_COMMANDS = ["system.notify"]; - -const CHAT_COMMANDS = ["chat.push"]; - -const TALK_COMMANDS = ["talk.ptt.start", "talk.ptt.stop", "talk.ptt.cancel", "talk.ptt.once"]; - const SYSTEM_COMMANDS = [ "system.run", "system.which", @@ -48,21 +30,7 @@ const SYSTEM_COMMANDS = [ ]; const PLATFORM_DEFAULTS: Record = { - ios: [ - ...CANVAS_COMMANDS, - ...CAMERA_COMMANDS, - ...SCREEN_COMMANDS, - ...LOCATION_COMMANDS, - ...SYSTEM_NOTIFY_COMMANDS, - ...CHAT_COMMANDS, - ...DEVICE_COMMANDS, - ...PHOTOS_COMMANDS, - ...CONTACTS_COMMANDS, - ...CALENDAR_COMMANDS, - ...REMINDERS_COMMANDS, - ...MOTION_COMMANDS, - ...TALK_COMMANDS, - ], + ios: [...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS], android: [ ...CANVAS_COMMANDS, ...CAMERA_COMMANDS,