Compare commits

..

24 Commits

Author SHA1 Message Date
Josh Palmer
35b6ae3984 feat(models): tech preview – openclaw models sync openrouter
Adds opt-in CLI command to fetch OpenRouter /models catalog and write
matching model definitions into models.json. Shared parsing extracted
into openrouter-catalog.ts to avoid duplication with model-scan.

Draft POC – do not merge.
2026-02-03 00:26:46 +01:00
Josh Palmer
c83bdb73a4 Docs: expand zh-CN landing note 2026-02-02 18:35:01 +01:00
Josh Palmer
ea9eed14f8 Docs: add zh-CN landing note (#7303) (thanks @joshp123) 2026-02-02 18:35:01 +01:00
Josh Palmer
91e445c260 Docs: add zh-CN landing notice + AI image 2026-02-02 18:35:01 +01:00
Mariano Belinky
6cd3bc3a46 iOS: improve gateway auto-connect and voice permissions 2026-02-02 16:42:18 +00:00
Mariano Belinky
37eaca719a Core: update shared gateway models 2026-02-02 16:42:18 +00:00
Mariano Belinky
ff6114599e iOS: update onboarding and gateway UI 2026-02-02 16:42:18 +00:00
Mariano Belinky
532b9653be iOS: wire node commands and incremental TTS 2026-02-02 16:42:18 +00:00
Mariano Belinky
b7aac92ac4 Gateway: add PTT chat + nodes CLI 2026-02-02 16:42:18 +00:00
Mariano Belinky
1a48bce294 iOS: add PTT once/cancel 2026-02-02 16:42:18 +00:00
Mariano Belinky
17b18971f1 iOS: pause voice wake during PTT 2026-02-02 16:42:18 +00:00
Mariano Belinky
9f101d3a9a iOS: add push-to-talk node commands 2026-02-02 16:42:18 +00:00
Mariano Belinky
a884955cd6 iOS: add write commands for contacts/calendar/reminders 2026-02-02 16:42:18 +00:00
Mariano Belinky
f72ac60b01 iOS: streamline notify timeouts 2026-02-02 16:42:18 +00:00
Mariano Belinky
761188cd1d iOS: fix node notify and identity 2026-02-02 16:42:18 +00:00
Mariano Belinky
d9cadf9737 Agents: add nodes invoke action 2026-02-02 16:42:17 +00:00
Mariano Belinky
a4382607d7 Gateway: wait for snapshot before connect 2026-02-02 16:42:17 +00:00
Mariano Belinky
84e115834f Gateway: fix node invoke receive loop 2026-02-02 16:42:17 +00:00
Mariano Belinky
78f7e5147b iOS: stabilize talk mode tests 2026-02-02 16:42:17 +00:00
Mariano Belinky
7b0a0f3dac iOS: wire node services and tests 2026-02-02 16:42:17 +00:00
Shakker
3711143549 chore: fix formatting and CI 2026-02-02 16:41:49 +00:00
Shakker
777756e1c2 fix(webchat): respect user scroll position during streaming and refresh (thanks @marcomarandiz)
Merges #7226
2026-02-02 16:22:28 +00:00
Josh Palmer
7cee8c2345 Docs: expand zh-Hans nav (#7242) (thanks @joshp123) 2026-02-02 17:07:34 +01:00
Josh Palmer
e0aa8457c2 Docs: expand zh-Hans nav and fix assets 2026-02-02 17:07:34 +01:00
78 changed files with 7223 additions and 421 deletions

View File

@@ -7,6 +7,8 @@ Docs: https://docs.openclaw.ai
### Changes
- Docs: seed zh-CN translations. (#6619) Thanks @joshp123.
- Docs: expand zh-Hans navigation and fix zh-CN index asset paths. (#7242) Thanks @joshp123.
- Docs: add zh-CN landing notice + AI-translated image. (#7303) Thanks @joshp123.
### Fixes
@@ -44,7 +46,7 @@ Docs: https://docs.openclaw.ai
- Streaming: stabilize partial streaming filters.
- Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation.
- Tools: align tool execute adapters/signatures (legacy + parameter order + arg normalization).
- Tools: treat "*" tool allowlist entries as valid to avoid spurious unknown-entry warnings.
- Tools: treat "\*" tool allowlist entries as valid to avoid spurious unknown-entry warnings.
- Skills: update session-logs paths from .clawdbot to .openclaw. (#4502)
- Slack: harden media fetch limits and Slack file URL validation. (#6639) Thanks @davidiach.
- Lint: satisfy curly rule after import sorting. (#6310)

110
IOS-PRIORITIES.md Normal file
View File

@@ -0,0 +1,110 @@
# 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 doesnt 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`

View File

@@ -0,0 +1,173 @@
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)
}
}

View File

@@ -0,0 +1,25 @@
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)
}
}

View File

@@ -0,0 +1,214 @@
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<String>()
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
}

View File

@@ -0,0 +1,87 @@
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
}
}

View File

@@ -0,0 +1,69 @@
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
}
}

View File

@@ -0,0 +1,48 @@
import Foundation
import UIKit
enum NodeDisplayName {
private static let genericNames: Set<String> = ["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
}
}

View File

@@ -1,8 +1,15 @@
import OpenClawKit
import Darwin
import AVFoundation
import Contacts
import CoreLocation
import CoreMotion
import EventKit
import Foundation
import OpenClawKit
import Network
import Observation
import Photos
import ReplayKit
import Speech
import SwiftUI
import UIKit
@@ -60,6 +67,11 @@ 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,
@@ -74,13 +86,24 @@ final class GatewayConnectionController {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
let stableID = self.manualStableID(host: host, port: port)
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
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))
guard let url = self.buildGatewayURL(
host: host,
port: port,
port: resolvedPort,
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,
@@ -90,6 +113,38 @@ 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
@@ -134,11 +189,19 @@ 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: manualTLS)
let tlsParams = self.resolveManualTLSParams(
stableID: stableID,
tlsEnabled: resolvedUseTLS,
allowTOFUReset: self.shouldForceTLS(host: manualHost))
guard let url = self.buildGatewayURL(
host: manualHost,
@@ -156,30 +219,70 @@ 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 }
guard let targetStableID = candidates.first(where: { id in
if let targetStableID = candidates.first(where: { id in
self.gateways.contains(where: { $0.stableID == id })
}) else { 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 }
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 }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: target.stableID,
tls: tlsParams,
token: token,
password: password)
return
}
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: target.stableID,
tls: tlsParams,
token: token,
password: password)
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
}
}
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
@@ -205,10 +308,10 @@ final class GatewayConnectionController {
password: String?)
{
guard let appModel else { return }
let connectOptions = self.makeConnectOptions()
let connectOptions = self.makeConnectOptions(stableID: gatewayStableID)
Task { [weak self] in
guard let self else { return }
Task { [weak appModel] in
guard let appModel else { return }
await MainActor.run {
appModel.gatewayStatusText = "Connecting…"
}
@@ -237,13 +340,17 @@ final class GatewayConnectionController {
return nil
}
private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? {
private func resolveManualTLSParams(
stableID: String,
tlsEnabled: Bool,
allowTOFUReset: Bool = false) -> GatewayTLSParams?
{
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if tlsEnabled || stored != nil {
return GatewayTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: stored == nil,
allowTOFU: stored == nil || allowTOFUReset,
storeKey: stableID)
}
@@ -251,12 +358,12 @@ final class GatewayConnectionController {
}
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
return lanHost
}
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
return tailnet
}
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
return lanHost
}
return nil
}
@@ -269,38 +376,69 @@ 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() -> GatewayConnectOptions {
private func makeConnectOptions(stableID: String?) -> 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: [:],
clientId: "openclaw-ios",
permissions: self.currentPermissions(),
clientId: resolvedClientId,
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 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)
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)
}
return candidate
return resolved
}
private func currentCaps() -> [String] {
@@ -320,6 +458,15 @@ 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
}
@@ -335,10 +482,11 @@ final class GatewayConnectionController {
OpenClawCanvasA2UICommand.reset.rawValue,
OpenClawScreenCommand.record.rawValue,
OpenClawSystemCommand.notify.rawValue,
OpenClawSystemCommand.which.rawValue,
OpenClawSystemCommand.run.rawValue,
OpenClawSystemCommand.execApprovalsGet.rawValue,
OpenClawSystemCommand.execApprovalsSet.rawValue,
OpenClawChatCommand.push.rawValue,
OpenClawTalkCommand.pttStart.rawValue,
OpenClawTalkCommand.pttStop.rawValue,
OpenClawTalkCommand.pttCancel.rawValue,
OpenClawTalkCommand.pttOnce.rawValue,
]
let caps = Set(self.currentCaps())
@@ -350,10 +498,76 @@ 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 {
@@ -407,6 +621,10 @@ extension GatewayConnectionController {
self.currentCommands()
}
func _test_currentPermissions() -> [String: Bool] {
self.currentPermissions()
}
func _test_platformString() -> String {
self.platformString()
}

View File

@@ -0,0 +1,85 @@
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<Void, Never>?
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
}
}
}

View File

@@ -11,7 +11,13 @@ 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"
@@ -21,6 +27,7 @@ enum GatewaySettingsStore {
self.ensureStableInstanceID()
self.ensurePreferredGatewayStableID()
self.ensureLastDiscoveredGatewayStableID()
self.ensureManualGatewayPassword()
}
static func loadStableInstanceID() -> String? {
@@ -107,6 +114,49 @@ 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)"
}
@@ -174,4 +224,23 @@ 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)
}
}
}

View File

@@ -0,0 +1,103 @@
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)
}
}

View File

@@ -1,8 +1,41 @@
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<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>?
private var resumed = false
func setContinuation(_ continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>) {
self.lock.lock()
defer { self.lock.unlock() }
self.continuation = continuation
}
func resume(_ response: Result<T, NotificationCallError>) {
let cont: CheckedContinuation<Result<T, NotificationCallError>, 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
@@ -15,9 +48,9 @@ final class NodeAppModel {
}
var isBackgrounded: Bool = false
let screen = ScreenController()
let camera = CameraController()
private let screenRecorder = ScreenRecordService()
let screen: ScreenController
private let camera: any CameraServicing
private let screenRecorder: any ScreenRecordingServicing
var gatewayStatusText: String = "Offline"
var gatewayServerName: String?
var gatewayRemoteAddress: String?
@@ -29,10 +62,20 @@ final class NodeAppModel {
private var gatewayTask: Task<Void, Never>?
private var voiceWakeSyncTask: Task<Void, Never>?
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
@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 = LocationService()
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
private var lastAutoA2uiURL: String?
private var pttVoiceWakeSuspended = false
private var gatewayConnected = false
var gatewaySession: GatewayNodeSession { self.gateway }
@@ -42,7 +85,33 @@ final class NodeAppModel {
var cameraFlashNonce: Int = 0
var screenRecordActive: Bool = false
init() {
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
self.voiceWake.configure { [weak self] cmd in
guard let self else { return }
let sessionKey = await MainActor.run { self.mainSessionKey }
@@ -107,7 +176,10 @@ final class NodeAppModel {
return raw.isEmpty ? "-" : raw
}()
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
let host = NodeDisplayName.resolve(
existing: UserDefaults.standard.string(forKey: "node.displayName"),
deviceName: UIDevice.current.name,
interfaceIdiom: UIDevice.current.userInterfaceIdiom)
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
let contextJSON = OpenClawCanvasA2UIAction.compactJSON(userAction["context"])
let sessionKey = self.mainSessionKey
@@ -175,8 +247,12 @@ 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
}
@@ -212,6 +288,7 @@ final class NodeAppModel {
connectOptions: GatewayConnectOptions)
{
self.gatewayTask?.cancel()
self.gatewayHealthMonitor.stop()
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
let id = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -223,6 +300,9 @@ 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 {
@@ -239,7 +319,7 @@ final class NodeAppModel {
url: url,
token: token,
password: password,
connectOptions: connectOptions,
connectOptions: currentOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return }
@@ -247,6 +327,7 @@ 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 {
@@ -255,6 +336,7 @@ final class NodeAppModel {
}
await self.refreshBrandingFromGateway()
await self.startVoiceWakeSync()
await MainActor.run { self.startGatewayHealthMonitor() }
await self.showA2UIOnConnectIfNeeded()
},
onDisconnected: { [weak self] reason in
@@ -263,9 +345,11 @@ 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 {
@@ -284,12 +368,30 @@ 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)))
@@ -303,6 +405,7 @@ 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"
@@ -313,17 +416,29 @@ 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"
@@ -418,6 +533,30 @@ 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)
@@ -523,30 +662,19 @@ final class NodeAppModel {
}
do {
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 try await self.capabilityRouter.handle(req)
} catch let error as NodeCapabilityRouter.RouterError {
switch error {
case .unknownCommand:
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.") {
@@ -561,7 +689,8 @@ final class NodeAppModel {
}
private func isBackgroundRestricted(_ command: String) -> Bool {
command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.")
command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.") ||
command.hasPrefix("talk.")
}
private func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
@@ -626,6 +755,7 @@ 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) ?? ""
@@ -636,6 +766,7 @@ 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)
@@ -859,9 +990,427 @@ 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<T: Sendable>(
timeoutSeconds: Double,
operation: @escaping @Sendable () async throws -> T
) async -> Result<T, NotificationCallError> {
let latch = NotificationInvokeLatch<T>()
var opTask: Task<Void, Never>?
var timeoutTask: Task<Void, Never>?
defer {
opTask?.cancel()
timeoutTask?.cancel()
}
let clamped = max(0.0, timeoutSeconds)
return await withCheckedContinuation { (cont: CheckedContinuation<Result<T, NotificationCallError>, 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

View File

@@ -0,0 +1,88 @@
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<OpenClawPedometerPayload, Error>) 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"
}
}
}

View File

@@ -0,0 +1,311 @@
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<String> {
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
}
}

View File

@@ -15,7 +15,7 @@ struct OpenClawApp: App {
var body: some Scene {
WindowGroup {
RootCanvas()
RootView()
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.gatewayController)

View File

@@ -0,0 +1,171 @@
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",
])
}
}

View File

@@ -0,0 +1,46 @@
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
}
}
}

View File

@@ -0,0 +1,64 @@
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 {}

View File

@@ -0,0 +1,58 @@
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<Void, Error>) in
self.center.add(request) { error in
if let error {
cont.resume(throwing: error)
} else {
cont.resume(returning: ())
}
}
}
}
}

View File

@@ -17,7 +17,8 @@ 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 = "iOS Node"
@AppStorage("node.displayName") private var displayName: String = NodeDisplayName.defaultValue(
for: UIDevice.current.userInterfaceIdiom)
@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
@@ -40,6 +41,7 @@ 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 {
@@ -120,7 +122,7 @@ struct SettingsTab: View {
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", value: self.$manualGatewayPort, format: .number)
TextField("Port (optional)", text: self.manualPortBinding)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
@@ -140,11 +142,11 @@ struct SettingsTab: View {
}
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535)
.isEmpty || !self.manualPortIsValid)
Text(
"Use this when mDNS/Bonjour discovery is blocked. "
+ "The gateway WebSocket listens on port 18789 by default.")
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -232,6 +234,7 @@ 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) ?? ""
@@ -255,6 +258,9 @@ 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
}
@@ -278,8 +284,24 @@ struct SettingsTab: View {
@ViewBuilder
private func gatewayList(showing: GatewayListMode) -> some View {
if self.gatewayController.gateways.isEmpty {
Text("No gateways found yet.")
.foregroundStyle(.secondary)
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)
}
}
} else {
let connectedID = self.appModel.connectedGatewayID
let rows = self.gatewayController.gateways.filter { gateway in
@@ -377,13 +399,77 @@ 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<String> {
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.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else {
guard self.manualPortIsValid else {
self.connectStatus.text = "Failed: invalid port"
return
}

View File

@@ -72,12 +72,6 @@ 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)
@@ -110,7 +104,7 @@ struct StatusPill: View {
if let activity {
return "\(self.gateway.title), \(activity.title)"
}
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
return self.gateway.title
}
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import AVFAudio
import Foundation
import Observation
import OpenClawKit
import Speech
import SwabbleKit
@@ -159,14 +160,18 @@ final class VoiceWakeManager: NSObject {
let micOk = await Self.requestMicrophonePermission()
guard micOk else {
self.statusText = "Microphone permission denied"
self.statusText = Self.permissionMessage(
kind: "Microphone",
status: AVAudioSession.sharedInstance().recordPermission)
self.isListening = false
return
}
let speechOk = await Self.requestSpeechPermission()
guard speechOk else {
self.statusText = "Speech recognition permission denied"
self.statusText = Self.permissionMessage(
kind: "Speech recognition",
status: SFSpeechRecognizer.authorizationStatus())
self.isListening = false
return
}
@@ -364,20 +369,101 @@ final class VoiceWakeManager: NSObject {
}
private nonisolated static func requestMicrophonePermission() async -> Bool {
await withCheckedContinuation(isolation: nil) { cont in
AVAudioApplication.requestRecordPermission { ok in
cont.resume(returning: ok)
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 requestSpeechPermission() async -> Bool {
await withCheckedContinuation(isolation: nil) { cont in
SFSpeechRecognizer.requestAuthorization { status in
cont.resume(returning: status == .authorized)
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 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

View File

@@ -2,20 +2,32 @@ 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
@@ -40,6 +52,7 @@ 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
@@ -47,13 +60,20 @@ 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

View File

@@ -0,0 +1,20 @@
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"]))
}
}

View File

@@ -40,6 +40,7 @@ private func withUserDefaults<T>(_ 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)
}
}
@@ -61,6 +62,11 @@ private func withUserDefaults<T>(_ 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))
}
}
@@ -76,4 +82,48 @@ private func withUserDefaults<T>(_ 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"))
}
}

View File

@@ -0,0 +1,60 @@
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)
}
}

View File

@@ -7,11 +7,14 @@ private struct KeychainEntry: Hashable {
let account: String
}
private let gatewayService = "bot.molt.gateway"
private let nodeService = "bot.molt.node"
private let gatewayService = "ai.openclaw.gateway"
private let nodeService = "ai.openclaw.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
@@ -124,4 +127,33 @@ 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")
}
}

View File

@@ -1,7 +1,9 @@
import OpenClawKit
import CoreLocation
import Foundation
import OpenClawKit
import Testing
import UIKit
import UserNotifications
@testable import OpenClaw
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
@@ -29,6 +31,210 @@ private func withUserDefaults<T>(_ 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<T: Decodable>(_ 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) {
@@ -124,6 +330,11 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
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 {
@@ -155,6 +366,470 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
#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")!

View File

@@ -0,0 +1,34 @@
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"))
}
}

View File

@@ -0,0 +1,33 @@
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)
}
}

View File

@@ -360,11 +360,12 @@ 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: self.sessionBox,
session: resolvedSessionBox,
pushHandler: { [weak self] push in
await self?.handle(push: push)
})
@@ -380,6 +381,21 @@ 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

View File

@@ -0,0 +1,93 @@
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
}
}

View File

@@ -6,4 +6,10 @@ public enum OpenClawCapability: String, Codable, Sendable {
case screen
case voiceWake
case location
case device
case photos
case contacts
case calendar
case reminders
case motion
}

View File

@@ -0,0 +1,23 @@
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
}
}

View File

@@ -0,0 +1,85 @@
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
}
}

View File

@@ -0,0 +1,134 @@
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
}
}

View File

@@ -110,7 +110,13 @@ 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<Void, Never>?
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
private var connected = false
private var isConnecting = false
@@ -169,6 +175,9 @@ 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
@@ -221,6 +230,8 @@ 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()
@@ -248,6 +259,7 @@ public actor GatewayChannelActor {
throw wrapped
}
self.listen()
self.logger.info("gateway ws listen registered")
self.connected = true
self.backoffMs = 500
self.lastSeq = nil
@@ -420,24 +432,44 @@ public actor GatewayChannelActor {
}
private func listen() {
self.task?.receive { [weak self] result in
#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
guard let self else { return }
switch result {
case let .failure(err):
Task { await self.handleReceiveFailure(err) }
case let .success(msg):
Task {
defer { Task { await self.clearListenTask() } }
while !Task.isCancelled {
guard let task = await self.currentTask() else { return }
do {
let msg = try await task.receive()
await self.handle(msg)
await self.listen()
} catch {
if Task.isCancelled { return }
await self.handleReceiveFailure(error)
return
}
}
}
}
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()
@@ -449,6 +481,13 @@ 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")
@@ -462,6 +501,13 @@ 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))

View File

@@ -11,6 +11,7 @@ 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()
@@ -23,34 +24,78 @@ 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<Bool, Never>] = []
static func invokeWithTimeout(
request: BridgeInvokeRequest,
timeoutMs: Int?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
) async -> BridgeInvokeResponse {
let timeout = max(0, timeoutMs ?? 0)
let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
let timeout: Int = {
guard let timeoutMs else { return 0 }
return max(0, timeoutMs)
}()
guard timeout > 0 else {
return await onInvoke(request)
}
return await withTaskGroup(of: BridgeInvokeResponse.self) { group in
group.addTask { await onInvoke(request) }
group.addTask {
// 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<BridgeInvokeResponse, Never>?
private var resumed = false
func setContinuation(_ continuation: CheckedContinuation<BridgeInvokeResponse, Never>) {
self.lock.lock()
defer { self.lock.unlock() }
self.continuation = continuation
}
func resume(_ response: BridgeInvokeResponse) {
let cont: CheckedContinuation<BridgeInvokeResponse, 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)
}
}
let latch = InvokeLatch()
var onInvokeTask: Task<Void, Never>?
var timeoutTask: Task<Void, Never>?
defer {
onInvokeTask?.cancel()
timeoutTask?.cancel()
}
let response = await withCheckedContinuation { (cont: CheckedContinuation<BridgeInvokeResponse, Never>) in
latch.setContinuation(cont)
onInvokeTask = Task.detached {
let result = await onInvoke(request)
latch.resume(result)
}
timeoutTask = Task.detached {
try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
return BridgeInvokeResponse(
timeoutLogger.info("node invoke timeout fired id=\(request.id, privacy: .public)")
latch.resume(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<EventFrame>.Continuation] = [:]
private var canvasHostUrl: String?
@@ -78,6 +123,7 @@ public actor GatewayNodeSession {
self.onInvoke = onInvoke
if shouldReconnect {
self.resetConnectionState()
if let existing = self.channel {
await existing.shutdown()
}
@@ -107,7 +153,10 @@ public actor GatewayNodeSession {
do {
try await channel.connect()
await onConnected()
let snapshotReady = await self.waitForSnapshot(timeoutMs: 500)
if snapshotReady {
await self.notifyConnectedIfNeeded()
}
} catch {
await onDisconnected(error.localizedDescription)
throw error
@@ -120,6 +169,7 @@ public actor GatewayNodeSession {
self.activeURL = nil
self.activeToken = nil
self.activePassword = nil
self.resetConnectionState()
}
public func currentCanvasHostUrl() -> String? {
@@ -179,7 +229,8 @@ public actor GatewayNodeSession {
case let .snapshot(ok):
let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines)
self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil
await self.onConnected?()
self.markSnapshotReceived()
await self.notifyConnectedIfNeeded()
case let .event(evt):
await self.handleEvent(evt)
default:
@@ -187,28 +238,98 @@ 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 data = try self.encoder.encode(payload)
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
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)")
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),
@@ -226,7 +347,7 @@ public actor GatewayNodeSession {
do {
try await channel.send(method: "node.invoke.result", params: params)
} catch {
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
self.logger.error("node invoke result failed id=\(request.id, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
}
}

View File

@@ -73,6 +73,11 @@ 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)
}

View File

@@ -0,0 +1,95 @@
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
}
}

View File

@@ -0,0 +1,41 @@
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
}
}

View File

@@ -0,0 +1,82 @@
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
}
}

View File

@@ -123,6 +123,10 @@
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
},
"invoke": {
"label": "invoke",
"detailKeys": ["node", "nodeId", "invokeCommand"]
}
}
},

View File

@@ -0,0 +1,28 @@
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
}
}

View File

@@ -0,0 +1,310 @@
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<URLSessionWebSocketTask.Message, Error>] = []
private var receiveHandlers: [@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> 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<URLSessionWebSocketTask.Message, Error>) -> 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<URLSessionWebSocketTask.Message, Error>) -> Void)?
var continuation: CheckedContinuation<URLSessionWebSocketTask.Message, Error>?
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<T>(_ body: () -> T) -> T {
self.lock()
defer { self.unlock() }
return body()
}
}

View File

@@ -22,6 +22,7 @@ openclaw models status
openclaw models list
openclaw models set <model-or-alias>
openclaw models scan
openclaw models sync openrouter
```
`openclaw models status` shows the resolved default/fallbacks plus an auth overview.
@@ -54,6 +55,18 @@ Options:
- `--probe-max-tokens <n>`
- `--agent <id>` (configured agent id; overrides `OPENCLAW_AGENT_DIR`/`PI_CODING_AGENT_DIR`)
### `models sync openrouter`
Fetches the OpenRouter `/models` catalog and writes it to the agent `models.json`
file (under `OPENCLAW_AGENT_DIR`/`PI_CODING_AGENT_DIR`, or the default agent path).
Restart the gateway after syncing so `/model` and the picker reload the catalog.
Options:
- `--free-only` (only include free models)
- `--provider <name>` (filter by provider prefix)
- `--json` (machine-readable output)
## Aliases + fallbacks
```bash

View File

@@ -1160,8 +1160,316 @@
"language": "zh-Hans",
"groups": [
{
"group": "开始",
"pages": ["zh-CN/index", "zh-CN/start/getting-started", "zh-CN/start/wizard"]
"group": "Start Here",
"pages": [
"zh-CN/index",
"zh-CN/start/getting-started",
"zh-CN/start/wizard",
"zh-CN/start/setup",
"zh-CN/start/pairing",
"zh-CN/start/openclaw",
"zh-CN/start/showcase",
"zh-CN/start/hubs",
"zh-CN/start/onboarding",
"zh-CN/start/lore"
]
},
{
"group": "Help",
"pages": ["zh-CN/help/index", "zh-CN/help/troubleshooting", "zh-CN/help/faq"]
},
{
"group": "Install & Updates",
"pages": [
"zh-CN/install/index",
"zh-CN/install/installer",
"zh-CN/install/updating",
"zh-CN/install/development-channels",
"zh-CN/install/uninstall",
"zh-CN/install/ansible",
"zh-CN/install/nix",
"zh-CN/install/docker",
"zh-CN/railway",
"zh-CN/render",
"zh-CN/northflank",
"zh-CN/install/bun"
]
},
{
"group": "CLI",
"pages": [
"zh-CN/cli/index",
"zh-CN/cli/setup",
"zh-CN/cli/onboard",
"zh-CN/cli/configure",
"zh-CN/cli/doctor",
"zh-CN/cli/dashboard",
"zh-CN/cli/reset",
"zh-CN/cli/uninstall",
"zh-CN/cli/browser",
"zh-CN/cli/message",
"zh-CN/cli/agent",
"zh-CN/cli/agents",
"zh-CN/cli/status",
"zh-CN/cli/health",
"zh-CN/cli/sessions",
"zh-CN/cli/channels",
"zh-CN/cli/directory",
"zh-CN/cli/skills",
"zh-CN/cli/plugins",
"zh-CN/cli/memory",
"zh-CN/cli/models",
"zh-CN/cli/logs",
"zh-CN/cli/system",
"zh-CN/cli/nodes",
"zh-CN/cli/approvals",
"zh-CN/cli/gateway",
"zh-CN/cli/tui",
"zh-CN/cli/voicecall",
"zh-CN/cli/cron",
"zh-CN/cli/dns",
"zh-CN/cli/docs",
"zh-CN/cli/hooks",
"zh-CN/cli/pairing",
"zh-CN/cli/security",
"zh-CN/cli/update",
"zh-CN/cli/sandbox"
]
},
{
"group": "Core Concepts",
"pages": [
"zh-CN/concepts/architecture",
"zh-CN/concepts/agent",
"zh-CN/concepts/agent-loop",
"zh-CN/concepts/system-prompt",
"zh-CN/concepts/context",
"zh-CN/token-use",
"zh-CN/concepts/oauth",
"zh-CN/concepts/agent-workspace",
"zh-CN/concepts/memory",
"zh-CN/concepts/multi-agent",
"zh-CN/concepts/compaction",
"zh-CN/concepts/session",
"zh-CN/concepts/session-pruning",
"zh-CN/concepts/sessions",
"zh-CN/concepts/session-tool",
"zh-CN/concepts/presence",
"zh-CN/concepts/channel-routing",
"zh-CN/concepts/messages",
"zh-CN/concepts/streaming",
"zh-CN/concepts/markdown-formatting",
"zh-CN/concepts/groups",
"zh-CN/concepts/group-messages",
"zh-CN/concepts/typing-indicators",
"zh-CN/concepts/queue",
"zh-CN/concepts/retry",
"zh-CN/concepts/model-providers",
"zh-CN/concepts/models",
"zh-CN/concepts/model-failover",
"zh-CN/concepts/usage-tracking",
"zh-CN/concepts/timezone",
"zh-CN/concepts/typebox"
]
},
{
"group": "Gateway & Ops",
"pages": [
"zh-CN/gateway/index",
"zh-CN/gateway/protocol",
"zh-CN/gateway/bridge-protocol",
"zh-CN/gateway/pairing",
"zh-CN/gateway/gateway-lock",
"zh-CN/environment",
"zh-CN/gateway/configuration",
"zh-CN/gateway/multiple-gateways",
"zh-CN/gateway/configuration-examples",
"zh-CN/gateway/authentication",
"zh-CN/gateway/openai-http-api",
"zh-CN/gateway/tools-invoke-http-api",
"zh-CN/gateway/cli-backends",
"zh-CN/gateway/local-models",
"zh-CN/gateway/background-process",
"zh-CN/gateway/health",
"zh-CN/gateway/heartbeat",
"zh-CN/gateway/doctor",
"zh-CN/gateway/logging",
"zh-CN/gateway/security/index",
"zh-CN/security/formal-verification",
"zh-CN/gateway/sandbox-vs-tool-policy-vs-elevated",
"zh-CN/gateway/sandboxing",
"zh-CN/gateway/troubleshooting",
"zh-CN/debugging",
"zh-CN/gateway/remote",
"zh-CN/gateway/remote-gateway-readme",
"zh-CN/gateway/discovery",
"zh-CN/gateway/bonjour",
"zh-CN/gateway/tailscale"
]
},
{
"group": "Web & Interfaces",
"pages": [
"zh-CN/web/index",
"zh-CN/web/control-ui",
"zh-CN/web/dashboard",
"zh-CN/web/webchat",
"zh-CN/tui"
]
},
{
"group": "Channels",
"pages": [
"zh-CN/channels/index",
"zh-CN/channels/whatsapp",
"zh-CN/channels/telegram",
"zh-CN/channels/grammy",
"zh-CN/channels/discord",
"zh-CN/channels/slack",
"zh-CN/channels/googlechat",
"zh-CN/channels/mattermost",
"zh-CN/channels/signal",
"zh-CN/channels/imessage",
"zh-CN/channels/msteams",
"zh-CN/channels/line",
"zh-CN/channels/matrix",
"zh-CN/channels/zalo",
"zh-CN/channels/zalouser",
"zh-CN/broadcast-groups",
"zh-CN/channels/troubleshooting",
"zh-CN/channels/location"
]
},
{
"group": "Providers",
"pages": [
"zh-CN/providers/index",
"zh-CN/providers/models",
"zh-CN/providers/openai",
"zh-CN/providers/anthropic",
"zh-CN/bedrock",
"zh-CN/providers/moonshot",
"zh-CN/providers/minimax",
"zh-CN/providers/vercel-ai-gateway",
"zh-CN/providers/openrouter",
"zh-CN/providers/synthetic",
"zh-CN/providers/opencode",
"zh-CN/providers/glm",
"zh-CN/providers/zai"
]
},
{
"group": "Automation & Hooks",
"pages": [
"zh-CN/hooks",
"zh-CN/hooks/soul-evil",
"zh-CN/automation/auth-monitoring",
"zh-CN/automation/webhook",
"zh-CN/automation/gmail-pubsub",
"zh-CN/automation/cron-jobs",
"zh-CN/automation/cron-vs-heartbeat",
"zh-CN/automation/poll"
]
},
{
"group": "Tools & Skills",
"pages": [
"zh-CN/tools/index",
"zh-CN/tools/lobster",
"zh-CN/tools/llm-task",
"zh-CN/plugin",
"zh-CN/plugins/voice-call",
"zh-CN/plugins/zalouser",
"zh-CN/tools/exec",
"zh-CN/tools/web",
"zh-CN/tools/apply-patch",
"zh-CN/tools/elevated",
"zh-CN/tools/browser",
"zh-CN/tools/browser-login",
"zh-CN/tools/chrome-extension",
"zh-CN/tools/browser-linux-troubleshooting",
"zh-CN/tools/slash-commands",
"zh-CN/tools/thinking",
"zh-CN/tools/agent-send",
"zh-CN/tools/subagents",
"zh-CN/multi-agent-sandbox-tools",
"zh-CN/tools/reactions",
"zh-CN/tools/skills",
"zh-CN/tools/skills-config",
"zh-CN/tools/clawhub"
]
},
{
"group": "Nodes & Media",
"pages": [
"zh-CN/nodes/index",
"zh-CN/nodes/camera",
"zh-CN/nodes/images",
"zh-CN/nodes/audio",
"zh-CN/nodes/location-command",
"zh-CN/nodes/voicewake",
"zh-CN/nodes/talk"
]
},
{
"group": "Platforms",
"pages": [
"zh-CN/platforms/index",
"zh-CN/platforms/macos",
"zh-CN/platforms/macos-vm",
"zh-CN/platforms/ios",
"zh-CN/platforms/android",
"zh-CN/platforms/windows",
"zh-CN/platforms/linux",
"zh-CN/platforms/fly",
"zh-CN/platforms/hetzner",
"zh-CN/platforms/gcp",
"zh-CN/platforms/exe-dev"
]
},
{
"group": "macOS Companion App",
"pages": [
"zh-CN/platforms/mac/dev-setup",
"zh-CN/platforms/mac/menu-bar",
"zh-CN/platforms/mac/voicewake",
"zh-CN/platforms/mac/voice-overlay",
"zh-CN/platforms/mac/webchat",
"zh-CN/platforms/mac/canvas",
"zh-CN/platforms/mac/child-process",
"zh-CN/platforms/mac/health",
"zh-CN/platforms/mac/icon",
"zh-CN/platforms/mac/logging",
"zh-CN/platforms/mac/permissions",
"zh-CN/platforms/mac/remote",
"zh-CN/platforms/mac/signing",
"zh-CN/platforms/mac/release",
"zh-CN/platforms/mac/bundled-gateway",
"zh-CN/platforms/mac/xpc",
"zh-CN/platforms/mac/skills",
"zh-CN/platforms/mac/peekaboo"
]
},
{
"group": "Reference & Templates",
"pages": [
"zh-CN/testing",
"zh-CN/scripts",
"zh-CN/reference/session-management-compaction",
"zh-CN/reference/rpc",
"zh-CN/reference/device-models",
"zh-CN/reference/test",
"zh-CN/reference/RELEASING",
"zh-CN/reference/AGENTS.default",
"zh-CN/reference/templates/AGENTS",
"zh-CN/reference/templates/BOOT",
"zh-CN/reference/templates/BOOTSTRAP",
"zh-CN/reference/templates/HEARTBEAT",
"zh-CN/reference/templates/IDENTITY",
"zh-CN/reference/templates/SOUL",
"zh-CN/reference/templates/TOOLS",
"zh-CN/reference/templates/USER"
]
}
]
}

View File

@@ -75,6 +75,7 @@ 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=<id>]` (push-to-talk controls for a paired node)
- `/stop`
- `/restart`
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View File

@@ -49,7 +49,7 @@ Client Gateway网关
| 消息 | `send``poll``agent``agent.wait` | 有副作用的操作需要 `idempotencyKey` |
| 聊天 | `chat.history``chat.send``chat.abort``chat.inject` | WebChat 使用这些 |
| 会话 | `sessions.list``sessions.patch``sessions.delete` | 会话管理 |
| 节点 | `node.list``node.invoke``node.pair.*` | Gateway网关 WS + 节点操作 |
| 节点 | `node.list``node.invoke``node.pair.*` | Gateway网关 WS + 节点操作 |
| 事件 | `tick``presence``agent``chat``health``shutdown` | 服务器推送 |
权威列表位于 `src/gateway/server.ts``METHODS``EVENTS`)。

View File

@@ -24,15 +24,15 @@ x-i18n:
快速分诊命令(按顺序执行):
| 命令 | 告诉你什么 | 何时使用 |
| ---------------------------------- | --------------------------------------------------------------------------------- | -------------------------------- |
| 命令 | 告诉你什么 | 何时使用 |
| ---------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------- |
| `openclaw status` | 本地摘要:操作系统 + 更新、Gateway网关可达性/模式、服务、智能体/会话、提供商配置状态 | 首次检查,快速概览 |
| `openclaw status --all` | 完整本地诊断(只读、可粘贴、基本安全)包含日志尾部 | 需要分享调试报告时 |
| `openclaw status --deep` | 运行 Gateway网关健康检查包括提供商探测需要 Gateway网关可达 | 当"已配置"不等于"正常工作"时 |
| `openclaw gateway probe` | Gateway网关发现 + 可达性(本地 + 远程目标) | 怀疑探测了错误的 Gateway网关时 |
| `openclaw channels status --probe` | 向运行中的 Gateway网关查询渠道状态可选探测 | Gateway网关可达但渠道异常时 |
| `openclaw status --all` | 完整本地诊断(只读、可粘贴、基本安全)包含日志尾部 | 需要分享调试报告时 |
| `openclaw status --deep` | 运行 Gateway网关健康检查包括提供商探测需要 Gateway网关可达 | 当"已配置"不等于"正常工作"时 |
| `openclaw gateway probe` | Gateway网关发现 + 可达性(本地 + 远程目标) | 怀疑探测了错误的 Gateway网关时 |
| `openclaw channels status --probe` | 向运行中的 Gateway网关查询渠道状态可选探测 | Gateway网关可达但渠道异常时 |
| `openclaw gateway status` | 管理器状态launchd/systemd/schtasks、运行时 PID/退出码、最后一次 Gateway网关错误 | 服务"看起来已加载"但实际未运行时 |
| `openclaw logs --follow` | 实时日志(运行时问题的最佳信号源) | 需要查看实际失败原因时 |
| `openclaw logs --follow` | 实时日志(运行时问题的最佳信号源) | 需要查看实际失败原因时 |
**分享输出:** 优先使用 `openclaw status --all`(它会脱敏令牌)。如果粘贴 `openclaw status` 的输出,建议先设置 `OPENCLAW_SHOW_SECRETS=0`(令牌预览)。
@@ -685,13 +685,13 @@ openclaw channels login --verbose
## 日志位置
| 日志 | 位置 |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Gateway网关文件日志结构化 | `/tmp/openclaw/openclaw-YYYY-MM-DD.log`(或 `logging.file` |
| 日志 | 位置 |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Gateway网关文件日志结构化 | `/tmp/openclaw/openclaw-YYYY-MM-DD.log`(或 `logging.file` |
| Gateway网关服务日志管理器 | macOS`$OPENCLAW_STATE_DIR/logs/gateway.log` + `gateway.err.log`(默认:`~/.openclaw/logs/...`profile 使用 `~/.openclaw-<profile>/logs/...`<br />Linux`journalctl --user -u openclaw-gateway[-<profile>].service -n 200 --no-pager`<br />Windows`schtasks /Query /TN "OpenClaw Gateway网关 (<profile>)" /V /FO LIST` |
| 会话文件 | `$OPENCLAW_STATE_DIR/agents/<agentId>/sessions/` |
| 媒体缓存 | `$OPENCLAW_STATE_DIR/media/` |
| 凭据 | `$OPENCLAW_STATE_DIR/credentials/` |
| 会话文件 | `$OPENCLAW_STATE_DIR/agents/<agentId>/sessions/` |
| 媒体缓存 | `$OPENCLAW_STATE_DIR/media/` |
| 凭据 | `$OPENCLAW_STATE_DIR/credentials/` |
## 健康检查

View File

@@ -16,6 +16,8 @@ x-i18n:
> _"EXFOLIATE! EXFOLIATE!"_ — 大概是一只太空龙虾说的
> **中文文档提示:** 本页及其他中文文档由自动化翻译流水线生成。如果你发现翻译问题,请在 [#6995](https://github.com/openclaw/openclaw/issues/6995) 反馈(不要提交 PR。我们正在积极扩展对中文用户、模型与消息平台的支持更多内容即将推出需要支持请到 Discord 的 [#help-中文](https://discord.com/channels/1456350064065904867/1466722439789674741)。
<p align="center">
<img
src="/assets/openclaw-logo-text-dark.png"
@@ -62,7 +64,10 @@ OpenClaw 同时也驱动着 OpenClaw 助手。
远程访问: [Web 界面](/web) 和 [Tailscale](/gateway/tailscale)
<p align="center">
<img src="whatsapp-openclaw.jpg" alt="OpenClaw" width="420" />
<img src="/whatsapp-openclaw.jpg" alt="OpenClaw(英文原图)" width="360" />
<img src="/whatsapp-openclaw-ai-zh.jpg" alt="OpenClawAI 自动翻译)" width="360" />
<br />
<em>左:英文原图 · 右AI 自动翻译(玩笑版)</em>
</p>
## 工作原理

View File

@@ -87,7 +87,7 @@ primary_region = "iad"
| 设置 | 原因 |
| ------------------------------ | ------------------------------------------------------------------------- |
| `--bind lan` | 绑定到 `0.0.0.0`,使 Fly 的代理能够访问 Gateway网关 |
| `--bind lan` | 绑定到 `0.0.0.0`,使 Fly 的代理能够访问 Gateway网关 |
| `--allow-unconfigured` | 无需配置文件即可启动(之后再创建) |
| `internal_port = 3000` | 必须与 `--port 3000`(或 `OPENCLAW_GATEWAY_PORT`)匹配,用于 Fly 健康检查 |
| `memory = "2048mb"` | 512MB 太小;推荐 2GB |

View File

@@ -414,18 +414,18 @@ gcloud compute ssh openclaw-gateway --zone=us-central1-a -- -L 18789:127.0.0.1:1
OpenClaw 在 Docker 中运行,但 Docker 并非数据来源。
所有长期状态必须在重启、重建和重启动后仍然存在。
| 组件 | 位置 | 持久化机制 | 备注 |
| -------------- | --------------------------------- | --------------- | --------------------------- |
| Gateway网关配置 | `/home/node/.openclaw/` | 宿主机卷挂载 | 包含 `openclaw.json`、令牌 |
| 模型认证配置 | `/home/node/.openclaw/` | 宿主机卷挂载 | OAuth 令牌、API 密钥 |
| Skills配置 | `/home/node/.openclaw/skills/` | 宿主机卷挂载 | Skills 级别状态 |
| 智能体工作区 | `/home/node/.openclaw/workspace/` | 宿主机卷挂载 | 代码和智能体产物 |
| WhatsApp 会话 | `/home/node/.openclaw/` | 宿主机卷挂载 | 保留二维码登录 |
| Gmail 密钥环 | `/home/node/.openclaw/` | 宿主机卷 + 密码 | 需要 `GOG_KEYRING_PASSWORD` |
| 外部二进制文件 | `/usr/local/bin/` | Docker 镜像 | 必须在构建时内置 |
| Node 运行时 | 容器文件系统 | Docker 镜像 | 每次镜像构建时重建 |
| 操作系统软件包 | 容器文件系统 | Docker 镜像 | 请勿在运行时安装 |
| Docker 容器 | 临时性 | 可重启 | 可安全销毁 |
| 组件 | 位置 | 持久化机制 | 备注 |
| --------------- | --------------------------------- | --------------- | --------------------------- |
| Gateway网关配置 | `/home/node/.openclaw/` | 宿主机卷挂载 | 包含 `openclaw.json`、令牌 |
| 模型认证配置 | `/home/node/.openclaw/` | 宿主机卷挂载 | OAuth 令牌、API 密钥 |
| Skills配置 | `/home/node/.openclaw/skills/` | 宿主机卷挂载 | Skills 级别状态 |
| 智能体工作区 | `/home/node/.openclaw/workspace/` | 宿主机卷挂载 | 代码和智能体产物 |
| WhatsApp 会话 | `/home/node/.openclaw/` | 宿主机卷挂载 | 保留二维码登录 |
| Gmail 密钥环 | `/home/node/.openclaw/` | 宿主机卷 + 密码 | 需要 `GOG_KEYRING_PASSWORD` |
| 外部二进制文件 | `/usr/local/bin/` | Docker 镜像 | 必须在构建时内置 |
| Node 运行时 | 容器文件系统 | Docker 镜像 | 每次镜像构建时重建 |
| 操作系统软件包 | 容器文件系统 | Docker 镜像 | 请勿在运行时安装 |
| Docker 容器 | 临时性 | 可重启 | 可安全销毁 |
---

View File

@@ -323,15 +323,15 @@ ssh -N -L 18789:127.0.0.1:18789 root@YOUR_VPS_IP
OpenClaw 在 Docker 中运行,但 Docker 不是数据源。
所有长期状态必须能在重启、重建和重启后保留。
| 组件 | 位置 | 持久化机制 | 备注 |
| -------------- | --------------------------------- | --------------- | --------------------------- |
| Gateway网关配置 | `/home/node/.openclaw/` | 宿主机卷挂载 | 包含 `openclaw.json`、令牌 |
| 模型认证配置 | `/home/node/.openclaw/` | 宿主机卷挂载 | OAuth 令牌、API 密钥 |
| Skills配置 | `/home/node/.openclaw/skills/` | 宿主机卷挂载 | Skills 级别状态 |
| 智能体工作区 | `/home/node/.openclaw/workspace/` | 宿主机卷挂载 | 代码和智能体产物 |
| WhatsApp 会话 | `/home/node/.openclaw/` | 宿主机卷挂载 | 保留二维码登录状态 |
| Gmail 密钥环 | `/home/node/.openclaw/` | 宿主机卷 + 密码 | 需要 `GOG_KEYRING_PASSWORD` |
| 外部二进制文件 | `/usr/local/bin/` | Docker 镜像 | 必须在构建时内置 |
| Node 运行时 | 容器文件系统 | Docker 镜像 | 每次构建镜像时重建 |
| 操作系统软件包 | 容器文件系统 | Docker 镜像 | 不要在运行时安装 |
| Docker 容器 | 临时性 | 可重启 | 可安全销毁 |
| 组件 | 位置 | 持久化机制 | 备注 |
| --------------- | --------------------------------- | --------------- | --------------------------- |
| Gateway网关配置 | `/home/node/.openclaw/` | 宿主机卷挂载 | 包含 `openclaw.json`、令牌 |
| 模型认证配置 | `/home/node/.openclaw/` | 宿主机卷挂载 | OAuth 令牌、API 密钥 |
| Skills配置 | `/home/node/.openclaw/skills/` | 宿主机卷挂载 | Skills 级别状态 |
| 智能体工作区 | `/home/node/.openclaw/workspace/` | 宿主机卷挂载 | 代码和智能体产物 |
| WhatsApp 会话 | `/home/node/.openclaw/` | 宿主机卷挂载 | 保留二维码登录状态 |
| Gmail 密钥环 | `/home/node/.openclaw/` | 宿主机卷 + 密码 | 需要 `GOG_KEYRING_PASSWORD` |
| 外部二进制文件 | `/usr/local/bin/` | Docker 镜像 | 必须在构建时内置 |
| Node 运行时 | 容器文件系统 | Docker 镜像 | 每次构建镜像时重建 |
| 操作系统软件包 | 容器文件系统 | Docker 镜像 | 不要在运行时安装 |
| Docker 容器 | 临时性 | 可重启 | 可安全销毁 |

View File

@@ -176,6 +176,7 @@ pnpm ui:dev # 首次运行时自动安装 UI 依赖
然后将 UI 指向你的 Gateway网关 WS URL例如 `ws://127.0.0.1:18789`)。
## 调试/测试:开发服务器 + 远程 Gateway网关控制界面是静态文件WebSocket 目标可配置,可以
与 HTTP 源不同。当你想在本地使用 Vite 开发服务器但 Gateway网关运行在其他地方时这很方便。
1. 启动 UI 开发服务器:`pnpm ui:dev`

View File

@@ -8,8 +8,14 @@ import {
type Tool,
} from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import {
fetchOpenRouterModels,
isFreeOpenRouterModel,
parseModality,
type OpenRouterModelMeta,
type OpenRouterModelPricing,
} from "./openrouter-catalog.js";
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
const DEFAULT_TIMEOUT_MS = 12_000;
const DEFAULT_CONCURRENCY = 3;
@@ -22,29 +28,6 @@ const TOOL_PING: Tool = {
parameters: Type.Object({}),
};
type OpenRouterModelMeta = {
id: string;
name: string;
contextLength: number | null;
maxCompletionTokens: number | null;
supportedParameters: string[];
supportedParametersCount: number;
supportsToolsMeta: boolean;
modality: string | null;
inferredParamB: number | null;
createdAtMs: number | null;
pricing: OpenRouterModelPricing | null;
};
type OpenRouterModelPricing = {
prompt: number;
completion: number;
request: number;
image: number;
webSearch: number;
internalReasoning: number;
};
export type ProbeResult = {
ok: boolean;
latencyMs: number | null;
@@ -84,102 +67,6 @@ export type OpenRouterScanOptions = {
type OpenAIModel = Model<"openai-completions">;
function normalizeCreatedAtMs(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
if (value <= 0) {
return null;
}
if (value > 1e12) {
return Math.round(value);
}
return Math.round(value * 1000);
}
function inferParamBFromIdOrName(text: string): number | null {
const raw = text.toLowerCase();
const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g);
let best: number | null = null;
for (const match of matches) {
const numRaw = match[1];
if (!numRaw) {
continue;
}
const value = Number(numRaw);
if (!Number.isFinite(value) || value <= 0) {
continue;
}
if (best === null || value > best) {
best = value;
}
}
return best;
}
function parseModality(modality: string | null): Array<"text" | "image"> {
if (!modality) {
return ["text"];
}
const normalized = modality.toLowerCase();
const parts = normalized.split(/[^a-z]+/).filter(Boolean);
const hasImage = parts.includes("image");
return hasImage ? ["text", "image"] : ["text"];
}
function parseNumberString(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const num = Number(trimmed);
if (!Number.isFinite(num)) {
return null;
}
return num;
}
function parseOpenRouterPricing(value: unknown): OpenRouterModelPricing | null {
if (!value || typeof value !== "object") {
return null;
}
const obj = value as Record<string, unknown>;
const prompt = parseNumberString(obj.prompt);
const completion = parseNumberString(obj.completion);
const request = parseNumberString(obj.request) ?? 0;
const image = parseNumberString(obj.image) ?? 0;
const webSearch = parseNumberString(obj.web_search) ?? 0;
const internalReasoning = parseNumberString(obj.internal_reasoning) ?? 0;
if (prompt === null || completion === null) {
return null;
}
return {
prompt,
completion,
request,
image,
webSearch,
internalReasoning,
};
}
function isFreeOpenRouterModel(entry: OpenRouterModelMeta): boolean {
if (entry.id.endsWith(":free")) {
return true;
}
if (!entry.pricing) {
return false;
}
return entry.pricing.prompt === 0 && entry.pricing.completion === 0;
}
async function withTimeout<T>(
timeoutMs: number,
fn: (signal: AbortSignal) => Promise<T>,
@@ -193,74 +80,6 @@ async function withTimeout<T>(
}
}
async function fetchOpenRouterModels(fetchImpl: typeof fetch): Promise<OpenRouterModelMeta[]> {
const res = await fetchImpl(OPENROUTER_MODELS_URL, {
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`OpenRouter /models failed: HTTP ${res.status}`);
}
const payload = (await res.json()) as { data?: unknown };
const entries = Array.isArray(payload.data) ? payload.data : [];
return entries
.map((entry) => {
if (!entry || typeof entry !== "object") {
return null;
}
const obj = entry as Record<string, unknown>;
const id = typeof obj.id === "string" ? obj.id.trim() : "";
if (!id) {
return null;
}
const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
const contextLength =
typeof obj.context_length === "number" && Number.isFinite(obj.context_length)
? obj.context_length
: null;
const maxCompletionTokens =
typeof obj.max_completion_tokens === "number" && Number.isFinite(obj.max_completion_tokens)
? obj.max_completion_tokens
: typeof obj.max_output_tokens === "number" && Number.isFinite(obj.max_output_tokens)
? obj.max_output_tokens
: null;
const supportedParameters = Array.isArray(obj.supported_parameters)
? obj.supported_parameters
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean)
: [];
const supportedParametersCount = supportedParameters.length;
const supportsToolsMeta = supportedParameters.includes("tools");
const modality =
typeof obj.modality === "string" && obj.modality.trim() ? obj.modality.trim() : null;
const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`);
const createdAtMs = normalizeCreatedAtMs(obj.created_at);
const pricing = parseOpenRouterPricing(obj.pricing);
return {
id,
name,
contextLength,
maxCompletionTokens,
supportedParameters,
supportedParametersCount,
supportsToolsMeta,
modality,
inferredParamB,
createdAtMs,
pricing,
} satisfies OpenRouterModelMeta;
})
.filter((entry): entry is OpenRouterModelMeta => Boolean(entry));
}
async function probeTool(
model: OpenAIModel,
apiKey: string,
@@ -509,5 +328,5 @@ export async function scanOpenRouterModels(
);
}
export { OPENROUTER_MODELS_URL };
export { OPENROUTER_MODELS_URL } from "./openrouter-catalog.js";
export type { OpenRouterModelMeta, OpenRouterModelPricing };

View File

@@ -133,3 +133,52 @@ 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" },
});
});
});

View File

@@ -0,0 +1,227 @@
import type { Model } from "@mariozechner/pi-ai";
import type { ModelDefinitionConfig } from "../config/types.models.js";
export const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
export type OpenRouterModelPricing = {
prompt: number;
completion: number;
request: number;
image: number;
webSearch: number;
internalReasoning: number;
};
export type OpenRouterModelMeta = {
id: string;
name: string;
contextLength: number | null;
maxCompletionTokens: number | null;
supportedParameters: string[];
supportedParametersCount: number;
supportsToolsMeta: boolean;
modality: string | null;
inferredParamB: number | null;
createdAtMs: number | null;
pricing: OpenRouterModelPricing | null;
};
export function normalizeCreatedAtMs(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
if (value <= 0) {
return null;
}
if (value > 1e12) {
return Math.round(value);
}
return Math.round(value * 1000);
}
export function inferParamBFromIdOrName(text: string): number | null {
const raw = text.toLowerCase();
const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g);
let best: number | null = null;
for (const match of matches) {
const numRaw = match[1];
if (!numRaw) {
continue;
}
const value = Number(numRaw);
if (!Number.isFinite(value) || value <= 0) {
continue;
}
if (best === null || value > best) {
best = value;
}
}
return best;
}
export function parseModality(modality: string | null): Array<"text" | "image"> {
if (!modality) {
return ["text"];
}
const normalized = modality.toLowerCase();
const parts = normalized.split(/[^a-z]+/).filter(Boolean);
const hasImage = parts.includes("image");
return hasImage ? ["text", "image"] : ["text"];
}
function parseNumberString(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const num = Number(trimmed);
if (!Number.isFinite(num)) {
return null;
}
return num;
}
export function parseOpenRouterPricing(value: unknown): OpenRouterModelPricing | null {
if (!value || typeof value !== "object") {
return null;
}
const obj = value as Record<string, unknown>;
const prompt = parseNumberString(obj.prompt);
const completion = parseNumberString(obj.completion);
const request = parseNumberString(obj.request) ?? 0;
const image = parseNumberString(obj.image) ?? 0;
const webSearch = parseNumberString(obj.web_search) ?? 0;
const internalReasoning = parseNumberString(obj.internal_reasoning) ?? 0;
if (prompt === null || completion === null) {
return null;
}
return {
prompt,
completion,
request,
image,
webSearch,
internalReasoning,
};
}
export function isFreeOpenRouterModel(entry: OpenRouterModelMeta): boolean {
if (entry.id.endsWith(":free")) {
return true;
}
if (!entry.pricing) {
return false;
}
return entry.pricing.prompt === 0 && entry.pricing.completion === 0;
}
export async function fetchOpenRouterModels(
fetchImpl: typeof fetch,
): Promise<OpenRouterModelMeta[]> {
const res = await fetchImpl(OPENROUTER_MODELS_URL, {
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`OpenRouter /models failed: HTTP ${res.status}`);
}
const payload = (await res.json()) as { data?: unknown };
const entries = Array.isArray(payload.data) ? payload.data : [];
return entries
.map((entry) => {
if (!entry || typeof entry !== "object") {
return null;
}
const obj = entry as Record<string, unknown>;
const id = typeof obj.id === "string" ? obj.id.trim() : "";
if (!id) {
return null;
}
const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
const contextLength =
typeof obj.context_length === "number" && Number.isFinite(obj.context_length)
? obj.context_length
: null;
const maxCompletionTokens =
typeof obj.max_completion_tokens === "number" && Number.isFinite(obj.max_completion_tokens)
? obj.max_completion_tokens
: typeof obj.max_output_tokens === "number" && Number.isFinite(obj.max_output_tokens)
? obj.max_output_tokens
: null;
const supportedParameters = Array.isArray(obj.supported_parameters)
? obj.supported_parameters
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean)
: [];
const supportedParametersCount = supportedParameters.length;
const supportsToolsMeta = supportedParameters.includes("tools");
const modality =
typeof obj.modality === "string" && obj.modality.trim() ? obj.modality.trim() : null;
const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`);
const createdAtMs = normalizeCreatedAtMs(obj.created_at);
const pricing = parseOpenRouterPricing(obj.pricing);
return {
id,
name,
contextLength,
maxCompletionTokens,
supportedParameters,
supportedParametersCount,
supportsToolsMeta,
modality,
inferredParamB,
createdAtMs,
pricing,
} satisfies OpenRouterModelMeta;
})
.filter((entry): entry is OpenRouterModelMeta => Boolean(entry));
}
function resolvePositiveNumber(value: number | null | undefined, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.round(value);
}
return Math.round(fallback);
}
const REASONING_HINTS = ["reasoning", "reasoning_effort"];
export function buildOpenRouterModelDefinition(params: {
entry: OpenRouterModelMeta;
baseModel: Model<"openai-completions">;
}): ModelDefinitionConfig {
const { entry, baseModel } = params;
const reasoning = entry.supportedParameters.some((param) =>
REASONING_HINTS.some((hint) => param.toLowerCase().includes(hint)),
);
const pricing = entry.pricing;
return {
id: entry.id,
name: entry.name || entry.id,
reasoning,
input: parseModality(entry.modality),
cost: {
input: pricing?.prompt ?? 0,
output: pricing?.completion ?? 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: resolvePositiveNumber(entry.contextLength, baseModel.contextWindow),
maxTokens: resolvePositiveNumber(entry.maxCompletionTokens, baseModel.maxTokens),
} satisfies ModelDefinitionConfig;
}

View File

@@ -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 on paired nodes",
nodes: "List/describe/notify/camera/screen/invoke 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 on paired nodes",
"- nodes: list/describe/notify/camera/screen/invoke 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",

View File

@@ -140,6 +140,10 @@
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
},
"invoke": {
"label": "invoke",
"detailKeys": ["node", "nodeId", "invokeCommand"]
}
}
},

View File

@@ -37,6 +37,7 @@ const NODES_TOOL_ACTIONS = [
"screen_record",
"location_get",
"run",
"invoke",
] as const;
const NOTIFY_PRIORITIES = ["passive", "active", "timeSensitive"] as const;
@@ -84,6 +85,9 @@ 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?: {
@@ -99,7 +103,7 @@ export function createNodesTool(options?: {
label: "Nodes",
name: "nodes",
description:
"Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location/run).",
"Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location/run/invoke).",
parameters: NodesToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -438,6 +442,31 @@ 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}`);
}

View File

@@ -273,6 +273,28 @@ 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=<id> (optional)",
type: "string",
},
],
}),
defineChatCommand({
key: "config",
nativeName: "config",

View File

@@ -21,6 +21,7 @@ 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,
@@ -46,6 +47,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
handleUsageCommand,
handleRestartCommand,
handleTtsCommands,
handlePTTCommand,
handleHelpCommand,
handleCommandsListCommand,
handleStatusCommand,

View File

@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
const callGateway = vi.fn(async (_opts: { method?: string }) => ({ 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<MsgContext>) {
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");
});
});

View File

@@ -0,0 +1,208 @@
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<string, string> = {
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<NodeSummary[]> {
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=<id>`,
);
}
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 <start|stop|once|cancel> [node=<id>]",
"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 || "<unknown>"}`);
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<string, unknown> = {
nodeId,
command: commandId,
params: {},
idempotencyKey: randomIdempotencyKey(),
timeoutMs: 15_000,
};
const res = await callGateway<{
ok?: boolean;
payload?: Record<string, unknown>;
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}` } };
}
};

View File

@@ -24,6 +24,7 @@ import {
modelsSetCommand,
modelsSetImageCommand,
modelsStatusCommand,
modelsSyncOpenRouterCommand,
} from "../commands/models.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
@@ -275,6 +276,30 @@ export function registerModelsCli(program: Command) {
});
});
const sync = models.command("sync").description("Sync remote model catalogs");
sync.action(() => {
sync.help();
});
sync
.command("openrouter")
.description("Sync OpenRouter model catalog into models.json")
.option("--provider <name>", "Filter by provider prefix")
.option("--free-only", "Only include free OpenRouter models", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runModelsCommand(async () => {
await modelsSyncOpenRouterCommand(
{
provider: opts.provider as string | undefined,
freeOnly: Boolean(opts.freeOnly),
json: Boolean(opts.json),
},
defaultRuntime,
);
});
});
models.action(async (opts) => {
await runModelsCommand(async () => {
await modelsStatusCommand(

View File

@@ -251,4 +251,23 @@ 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");
});
});

View File

@@ -0,0 +1,79 @@
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 <idOrNameOrIp>", "Node id, name, or IP")
.option("--invoke-timeout <ms>", "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<string, unknown> = {
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<string, unknown>)
: {};
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 },
);
}
}

View File

@@ -9,6 +9,7 @@ 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
@@ -28,4 +29,5 @@ export function registerNodesCli(program: Command) {
registerNodesCameraCommands(nodes);
registerNodesScreenCommands(nodes);
registerNodesLocationCommands(nodes);
registerNodesTalkCommands(nodes);
}

View File

@@ -0,0 +1,127 @@
import type { Model } from "@mariozechner/pi-ai";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const loadConfig = vi.fn().mockReturnValue({});
const ensureOpenClawModelsJson = vi.fn().mockResolvedValue(undefined);
const resolveOpenClawAgentDir = vi.fn();
const fetchOpenRouterModels = vi.fn();
const getModel = vi.fn();
vi.mock("../config/config.js", () => ({
CONFIG_PATH: "/tmp/openclaw.json",
loadConfig,
}));
vi.mock("../agents/models-config.js", () => ({
ensureOpenClawModelsJson,
}));
vi.mock("../agents/agent-paths.js", () => ({
resolveOpenClawAgentDir,
}));
vi.mock("@mariozechner/pi-ai", () => ({
getModel,
}));
vi.mock("../agents/openrouter-catalog.js", async () => {
const actual = await vi.importActual<typeof import("../agents/openrouter-catalog.js")>(
"../agents/openrouter-catalog.js",
);
return { ...actual, fetchOpenRouterModels };
});
function makeRuntime() {
return { log: vi.fn(), error: vi.fn() };
}
describe("models sync openrouter", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-models-"));
resolveOpenClawAgentDir.mockReturnValue(tempDir);
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.clearAllMocks();
});
it("writes filtered OpenRouter models to models.json", async () => {
const baseModel = {
id: "openrouter/auto",
name: "OpenRouter: Auto Router",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 2000000,
maxTokens: 30000,
} satisfies Model<"openai-completions">;
getModel.mockReturnValue(baseModel);
fetchOpenRouterModels.mockResolvedValue([
{
id: "anthropic/claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
contextLength: 200000,
maxCompletionTokens: 8192,
supportedParameters: ["tools"],
supportedParametersCount: 1,
supportsToolsMeta: true,
modality: "text+image",
inferredParamB: 80,
createdAtMs: null,
pricing: {
prompt: 0,
completion: 0,
request: 0,
image: 0,
webSearch: 0,
internalReasoning: 0,
},
},
{
id: "openai/gpt-5.2",
name: "GPT-5.2",
contextLength: 200000,
maxCompletionTokens: 8192,
supportedParameters: ["tools"],
supportedParametersCount: 1,
supportsToolsMeta: true,
modality: "text",
inferredParamB: 0,
createdAtMs: null,
pricing: {
prompt: 1,
completion: 2,
request: 0,
image: 0,
webSearch: 0,
internalReasoning: 0,
},
},
]);
const runtime = makeRuntime();
const { modelsSyncOpenRouterCommand } = await import("./models/sync.js");
await modelsSyncOpenRouterCommand({ provider: "anthropic", freeOnly: true }, runtime as never);
const modelsPath = path.join(tempDir, "models.json");
const raw = await fs.readFile(modelsPath, "utf8");
const parsed = JSON.parse(raw) as {
providers?: Record<string, { models?: Array<{ id?: string }> }>;
};
const models = parsed.providers?.openrouter?.models ?? [];
const ids = models.map((entry) => entry.id);
expect(ids).toContain("openrouter/auto");
expect(ids).toContain("anthropic/claude-sonnet-4-5");
expect(ids).not.toContain("openai/gpt-5.2");
expect(runtime.log).toHaveBeenCalled();
});
});

View File

@@ -31,3 +31,4 @@ export { modelsListCommand, modelsStatusCommand } from "./models/list.js";
export { modelsScanCommand } from "./models/scan.js";
export { modelsSetCommand } from "./models/set.js";
export { modelsSetImageCommand } from "./models/set-image.js";
export { modelsSyncOpenRouterCommand } from "./models/sync.js";

217
src/commands/models/sync.ts Normal file
View File

@@ -0,0 +1,217 @@
import { getModel, type Model } from "@mariozechner/pi-ai";
import fs from "node:fs/promises";
import path from "node:path";
import type { ModelApi, ModelDefinitionConfig } from "../../config/types.models.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js";
import { ensureOpenClawModelsJson } from "../../agents/models-config.js";
import {
buildOpenRouterModelDefinition,
fetchOpenRouterModels,
isFreeOpenRouterModel,
type OpenRouterModelMeta,
} from "../../agents/openrouter-catalog.js";
import { withProgressTotals } from "../../cli/progress.js";
import { loadConfig } from "../../config/config.js";
const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
const DEFAULT_OPENROUTER_API_KEY_REF = "OPENROUTER_API_KEY";
const DEFAULT_OPENROUTER_API: ModelApi = "openai-completions";
const PROGRESS_STEP = 50;
type ModelsJson = {
providers?: Record<string, ModelsJsonProvider>;
};
type ModelsJsonProvider = {
baseUrl?: string;
apiKey?: string;
api?: ModelApi;
headers?: Record<string, string>;
authHeader?: boolean;
models?: ModelDefinitionConfig[];
};
function normalizeProviderFilter(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
return trimmed.toLowerCase();
}
async function readModelsJson(filePath: string): Promise<ModelsJson> {
try {
const raw = await fs.readFile(filePath, "utf8");
if (!raw.trim()) {
return { providers: {} };
}
const parsed = JSON.parse(raw) as ModelsJson;
if (!parsed || typeof parsed !== "object") {
return { providers: {} };
}
return parsed;
} catch (err) {
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
return { providers: {} };
}
throw err;
}
}
async function writeModelsJson(filePath: string, payload: ModelsJson): Promise<void> {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
const raw = `${JSON.stringify(payload, null, 2)}\n`;
await fs.writeFile(filePath, raw, { mode: 0o600 });
}
function buildOpenRouterAutoModel(
baseModel: Model<"openai-completions"> | undefined,
): ModelDefinitionConfig {
if (!baseModel) {
throw new Error("Missing base OpenRouter model (openrouter/auto).");
}
return {
id: baseModel.id,
name: baseModel.name || baseModel.id,
reasoning: baseModel.reasoning ?? false,
input: baseModel.input ?? ["text"],
cost: baseModel.cost ?? {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: baseModel.contextWindow ?? 1,
maxTokens: baseModel.maxTokens ?? 1,
} satisfies ModelDefinitionConfig;
}
function filterOpenRouterCatalog(params: {
catalog: OpenRouterModelMeta[];
providerFilter?: string;
freeOnly?: boolean;
}) {
const providerFilter = normalizeProviderFilter(params.providerFilter);
return params.catalog.filter((entry) => {
if (params.freeOnly && !isFreeOpenRouterModel(entry)) {
return false;
}
if (providerFilter) {
const prefix = entry.id.split("/")[0]?.toLowerCase() ?? "";
if (prefix !== providerFilter) {
return false;
}
}
return true;
});
}
export async function modelsSyncOpenRouterCommand(
opts: {
provider?: string;
freeOnly?: boolean;
json?: boolean;
},
runtime: RuntimeEnv,
) {
const cfg = loadConfig();
await ensureOpenClawModelsJson(cfg);
const baseModel = getModel("openrouter", "openrouter/auto") as
| Model<"openai-completions">
| undefined;
if (!baseModel) {
throw new Error("Missing built-in OpenRouter base model definition.");
}
const { models, filteredCount } = await withProgressTotals(
{
label: "Fetching OpenRouter models...",
indeterminate: true,
enabled: opts.json !== true,
},
async (update, progress) => {
const catalog = await fetchOpenRouterModels(fetch);
const filtered = filterOpenRouterCatalog({
catalog,
providerFilter: opts.provider,
freeOnly: opts.freeOnly,
}).toSorted((a, b) => a.id.localeCompare(b.id));
progress.setLabel(`Building OpenRouter catalog (${filtered.length})`);
const total = filtered.length + 1;
let completed = 0;
const nextModels: ModelDefinitionConfig[] = [];
for (const entry of filtered) {
nextModels.push(buildOpenRouterModelDefinition({ entry, baseModel }));
completed += 1;
if (completed % PROGRESS_STEP === 0 || completed === total) {
update({ completed, total });
}
}
const autoModel = buildOpenRouterAutoModel(baseModel);
if (!nextModels.some((entry) => entry.id === autoModel.id)) {
nextModels.unshift(autoModel);
}
update({ completed: total, total });
return { models: nextModels, filteredCount: filtered.length };
},
);
const agentDir = resolveOpenClawAgentDir();
const modelsPath = path.join(agentDir, "models.json");
const existing = await readModelsJson(modelsPath);
const providers = existing.providers ? { ...existing.providers } : {};
const existingProvider = providers.openrouter ?? {};
providers.openrouter = {
baseUrl: existingProvider.baseUrl ?? DEFAULT_OPENROUTER_BASE_URL,
apiKey: existingProvider.apiKey ?? DEFAULT_OPENROUTER_API_KEY_REF,
api: existingProvider.api ?? DEFAULT_OPENROUTER_API,
headers: existingProvider.headers,
authHeader: existingProvider.authHeader,
models,
} satisfies ModelsJsonProvider;
const nextPayload: ModelsJson = {
...existing,
providers,
};
await writeModelsJson(modelsPath, nextPayload);
if (opts.json) {
runtime.log(
JSON.stringify(
{
ok: true,
provider: "openrouter",
modelCount: models.length,
filteredCount,
path: modelsPath,
freeOnly: Boolean(opts.freeOnly),
providerFilter: normalizeProviderFilter(opts.provider) ?? null,
restartRequired: true,
},
null,
2,
),
);
return;
}
runtime.log(`Synced ${models.length} OpenRouter models to ${modelsPath}.`);
if (opts.freeOnly) {
runtime.log(`Filter: free-only (${filteredCount} OpenRouter catalog entries).`);
} else if (opts.provider) {
runtime.log(
`Filter: provider=${normalizeProviderFilter(opts.provider)} (${filteredCount} entries).`,
);
}
runtime.log("Restart the gateway to pick up the updated catalog.");
}

View File

@@ -20,6 +20,24 @@ 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",
@@ -30,7 +48,21 @@ const SYSTEM_COMMANDS = [
];
const PLATFORM_DEFAULTS: Record<string, string[]> = {
ios: [...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS],
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,
],
android: [
...CANVAS_COMMANDS,
...CAMERA_COMMANDS,

View File

@@ -6,12 +6,14 @@ import { handleChatScroll, scheduleChatScroll, resetChatScroll } from "./app-scr
/* ------------------------------------------------------------------ */
/** Minimal ScrollHost stub for unit tests. */
function createScrollHost(overrides: {
scrollHeight?: number;
scrollTop?: number;
clientHeight?: number;
overflowY?: string;
} = {}) {
function createScrollHost(
overrides: {
scrollHeight?: number;
scrollTop?: number;
clientHeight?: number;
overflowY?: string;
} = {},
) {
const {
scrollHeight = 2000,
scrollTop = 1500,

View File

@@ -323,7 +323,10 @@ export class OpenClawApp extends LitElement {
scrollToBottom() {
resetChatScrollInternal(this as unknown as Parameters<typeof resetChatScrollInternal>[0]);
scheduleChatScrollInternal(this as unknown as Parameters<typeof scheduleChatScrollInternal>[0], true);
scheduleChatScrollInternal(
this as unknown as Parameters<typeof scheduleChatScrollInternal>[0],
true,
);
}
async loadAssistantIdentity() {