Compare commits
24 Commits
fix/webcha
...
openrouter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35b6ae3984 | ||
|
|
c83bdb73a4 | ||
|
|
ea9eed14f8 | ||
|
|
91e445c260 | ||
|
|
6cd3bc3a46 | ||
|
|
37eaca719a | ||
|
|
ff6114599e | ||
|
|
532b9653be | ||
|
|
b7aac92ac4 | ||
|
|
1a48bce294 | ||
|
|
17b18971f1 | ||
|
|
9f101d3a9a | ||
|
|
a884955cd6 | ||
|
|
f72ac60b01 | ||
|
|
761188cd1d | ||
|
|
d9cadf9737 | ||
|
|
a4382607d7 | ||
|
|
84e115834f | ||
|
|
78f7e5147b | ||
|
|
7b0a0f3dac | ||
|
|
3711143549 | ||
|
|
777756e1c2 | ||
|
|
7cee8c2345 | ||
|
|
e0aa8457c2 |
@@ -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
110
IOS-PRIORITIES.md
Normal 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 doesn’t change UI).
|
||||
- `canvas.present` ignores placement params (macOS supports window placement).
|
||||
|
||||
These may be acceptable platform limitations, but they should be explicitly handled/documented so the node surface is consistent and predictable.
|
||||
|
||||
## iOS vs. macOS node feature parity
|
||||
|
||||
macOS node mode (`apps/macos/Sources/OpenClaw/NodeMode/*`) supports:
|
||||
|
||||
- `system.run`, `system.which`, `system.notify`, `system.execApprovals.get/set`.
|
||||
- Permission reporting in `connect.permissions`.
|
||||
- Canvas window placement + hide.
|
||||
|
||||
iOS currently implements the shared node surface (canvas/camera/screen/location + voice) but does **not** match macOS on the system/exec side and permission reporting.
|
||||
|
||||
## Prioritized work items (ordered by importance)
|
||||
|
||||
1. **Fix the command/implementation mismatch for `system.*`**
|
||||
- Either remove `system.*` from iOS `currentCommands()` **or** implement iOS equivalents (at minimum `system.notify` via local notifications) with clear error semantics for unsupported actions.
|
||||
- This is the highest risk mismatch because it misleads the gateway and any operator about what the iOS node can actually do.
|
||||
|
||||
2. **Report real iOS permission state in `connect.permissions`**
|
||||
- Mirror macOS behavior by sending camera/microphone/location/screen-recording permission flags.
|
||||
- This enables the gateway to make better decisions and reduces “it failed because permissions” surprises.
|
||||
|
||||
3. **Clarify/normalize iOS canvas behaviors**
|
||||
- Decide how `canvas.hide` should behave on iOS (e.g., return to the local scaffold) and implement it.
|
||||
- Document that `canvas.present` ignores placement on iOS, or add a platform-specific best effort.
|
||||
|
||||
4. **Explicitly document platform deltas vs. macOS node**
|
||||
- The docs currently describe `system.*` under “Nodes” and cite macOS/headless node support. iOS should be clearly marked as not supporting system exec to avoid incorrect user expectations.
|
||||
|
||||
5. **Release readiness (if the goal is to move beyond internal preview)**
|
||||
- Docs state the iOS app is “internal preview” (`docs/platforms/ios.md`).
|
||||
- If public distribution is desired, build out TestFlight/App Store release steps (fastlane exists in `apps/ios/fastlane/`).
|
||||
|
||||
## Files referenced (key evidence)
|
||||
|
||||
- iOS node behavior: `apps/ios/Sources/Model/NodeAppModel.swift`
|
||||
- iOS command declarations: `apps/ios/Sources/Gateway/GatewayConnectionController.swift`
|
||||
- iOS discovery + TLS: `apps/ios/Sources/Gateway/*`
|
||||
- iOS voice: `apps/ios/Sources/Voice/*`
|
||||
- iOS screen/camera/location: `apps/ios/Sources/Screen/*`, `apps/ios/Sources/Camera/*`, `apps/ios/Sources/Location/*`
|
||||
- Shared protocol + commands: `apps/shared/OpenClawKit/Sources/OpenClawKit/*`
|
||||
- macOS node runtime: `apps/macos/Sources/OpenClaw/NodeMode/*`
|
||||
- Node + protocol docs: `docs/nodes/index.md`, `docs/gateway/protocol.md`, `docs/platforms/ios.md`
|
||||
173
apps/ios/Sources/Calendar/CalendarService.swift
Normal file
173
apps/ios/Sources/Calendar/CalendarService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
25
apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift
Normal file
25
apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
214
apps/ios/Sources/Contacts/ContactsService.swift
Normal file
214
apps/ios/Sources/Contacts/ContactsService.swift
Normal 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
|
||||
}
|
||||
87
apps/ios/Sources/Device/DeviceStatusService.swift
Normal file
87
apps/ios/Sources/Device/DeviceStatusService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
69
apps/ios/Sources/Device/NetworkStatusService.swift
Normal file
69
apps/ios/Sources/Device/NetworkStatusService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
48
apps/ios/Sources/Device/NodeDisplayName.swift
Normal file
48
apps/ios/Sources/Device/NodeDisplayName.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
85
apps/ios/Sources/Gateway/GatewayHealthMonitor.swift
Normal file
85
apps/ios/Sources/Gateway/GatewayHealthMonitor.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
103
apps/ios/Sources/Media/PhotoLibraryService.swift
Normal file
103
apps/ios/Sources/Media/PhotoLibraryService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
88
apps/ios/Sources/Motion/MotionService.swift
Normal file
88
apps/ios/Sources/Motion/MotionService.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
311
apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
Normal file
311
apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ struct OpenClawApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootCanvas()
|
||||
RootView()
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
|
||||
171
apps/ios/Sources/Reminders/RemindersService.swift
Normal file
171
apps/ios/Sources/Reminders/RemindersService.swift
Normal 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",
|
||||
])
|
||||
}
|
||||
}
|
||||
46
apps/ios/Sources/RootView.swift
Normal file
46
apps/ios/Sources/RootView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
64
apps/ios/Sources/Services/NodeServiceProtocols.swift
Normal file
64
apps/ios/Sources/Services/NodeServiceProtocols.swift
Normal 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 {}
|
||||
58
apps/ios/Sources/Services/NotificationService.swift
Normal file
58
apps/ios/Sources/Services/NotificationService.swift
Normal 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: ())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
20
apps/ios/Tests/ContactsServiceTests.swift
Normal file
20
apps/ios/Tests/ContactsServiceTests.swift
Normal 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"]))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
60
apps/ios/Tests/GatewayHealthMonitorTests.swift
Normal file
60
apps/ios/Tests/GatewayHealthMonitorTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")!
|
||||
|
||||
34
apps/ios/Tests/NodeDisplayNameTests.swift
Normal file
34
apps/ios/Tests/NodeDisplayNameTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
33
apps/ios/Tests/TalkModeIncrementalTests.swift
Normal file
33
apps/ios/Tests/TalkModeIncrementalTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
134
apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift
Normal file
134
apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,10 @@
|
||||
"screen_record": {
|
||||
"label": "screen record",
|
||||
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
|
||||
},
|
||||
"invoke": {
|
||||
"label": "invoke",
|
||||
"detailKeys": ["node", "nodeId", "invokeCommand"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
312
docs/docs.json
312
docs/docs.json
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
BIN
docs/whatsapp-openclaw-ai-zh.jpg
Normal file
BIN
docs/whatsapp-openclaw-ai-zh.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
@@ -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`)。
|
||||
|
||||
@@ -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/` |
|
||||
|
||||
## 健康检查
|
||||
|
||||
|
||||
@@ -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="OpenClaw(AI 自动翻译)" width="360" />
|
||||
<br />
|
||||
<em>左:英文原图 · 右:AI 自动翻译(玩笑版)</em>
|
||||
</p>
|
||||
|
||||
## 工作原理
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 容器 | 临时性 | 可重启 | 可安全销毁 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 容器 | 临时性 | 可重启 | 可安全销毁 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
227
src/agents/openrouter-catalog.ts
Normal file
227
src/agents/openrouter-catalog.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -140,6 +140,10 @@
|
||||
"screen_record": {
|
||||
"label": "screen record",
|
||||
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
|
||||
},
|
||||
"invoke": {
|
||||
"label": "invoke",
|
||||
"detailKeys": ["node", "nodeId", "invokeCommand"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
94
src/auto-reply/reply/commands-ptt.test.ts
Normal file
94
src/auto-reply/reply/commands-ptt.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
208
src/auto-reply/reply/commands-ptt.ts
Normal file
208
src/auto-reply/reply/commands-ptt.ts
Normal 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}` } };
|
||||
}
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
79
src/cli/nodes-cli/register.talk.ts
Normal file
79
src/cli/nodes-cli/register.talk.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
127
src/commands/models.sync.test.ts
Normal file
127
src/commands/models.sync.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
217
src/commands/models/sync.ts
Normal 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.");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user