iOS: alpha node app + setup-code onboarding (#11756)

This commit is contained in:
Mariano Belinky
2026-02-08 18:08:13 +01:00
parent 730f86dd5c
commit 6aedc54bd7
45 changed files with 6096 additions and 516 deletions

View File

@@ -0,0 +1,93 @@
import Foundation
public enum OpenClawCalendarCommand: String, Codable, Sendable {
case events = "calendar.events"
case add = "calendar.add"
}
public struct OpenClawCalendarEventsParams: Codable, Sendable, Equatable {
public var startISO: String?
public var endISO: String?
public var limit: Int?
public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) {
self.startISO = startISO
self.endISO = endISO
self.limit = limit
}
}
public struct OpenClawCalendarAddParams: Codable, Sendable, Equatable {
public var title: String
public var startISO: String
public var endISO: String
public var isAllDay: Bool?
public var location: String?
public var notes: String?
public var calendarId: String?
public var calendarTitle: String?
public init(
title: String,
startISO: String,
endISO: String,
isAllDay: Bool? = nil,
location: String? = nil,
notes: String? = nil,
calendarId: String? = nil,
calendarTitle: String? = nil)
{
self.title = title
self.startISO = startISO
self.endISO = endISO
self.isAllDay = isAllDay
self.location = location
self.notes = notes
self.calendarId = calendarId
self.calendarTitle = calendarTitle
}
}
public struct OpenClawCalendarEventPayload: Codable, Sendable, Equatable {
public var identifier: String
public var title: String
public var startISO: String
public var endISO: String
public var isAllDay: Bool
public var location: String?
public var calendarTitle: String?
public init(
identifier: String,
title: String,
startISO: String,
endISO: String,
isAllDay: Bool,
location: String? = nil,
calendarTitle: String? = nil)
{
self.identifier = identifier
self.title = title
self.startISO = startISO
self.endISO = endISO
self.isAllDay = isAllDay
self.location = location
self.calendarTitle = calendarTitle
}
}
public struct OpenClawCalendarEventsPayload: Codable, Sendable, Equatable {
public var events: [OpenClawCalendarEventPayload]
public init(events: [OpenClawCalendarEventPayload]) {
self.events = events
}
}
public struct OpenClawCalendarAddPayload: Codable, Sendable, Equatable {
public var event: OpenClawCalendarEventPayload
public init(event: OpenClawCalendarEventPayload) {
self.event = event
}
}

View File

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

View File

@@ -0,0 +1,23 @@
import Foundation
public enum OpenClawChatCommand: String, Codable, Sendable {
case push = "chat.push"
}
public struct OpenClawChatPushParams: Codable, Sendable, Equatable {
public var text: String
public var speak: Bool?
public init(text: String, speak: Bool? = nil) {
self.text = text
self.speak = speak
}
}
public struct OpenClawChatPushPayload: Codable, Sendable, Equatable {
public var messageId: String?
public init(messageId: String? = nil) {
self.messageId = messageId
}
}

View File

@@ -0,0 +1,85 @@
import Foundation
public enum OpenClawContactsCommand: String, Codable, Sendable {
case search = "contacts.search"
case add = "contacts.add"
}
public struct OpenClawContactsSearchParams: Codable, Sendable, Equatable {
public var query: String?
public var limit: Int?
public init(query: String? = nil, limit: Int? = nil) {
self.query = query
self.limit = limit
}
}
public struct OpenClawContactsAddParams: Codable, Sendable, Equatable {
public var givenName: String?
public var familyName: String?
public var organizationName: String?
public var displayName: String?
public var phoneNumbers: [String]?
public var emails: [String]?
public init(
givenName: String? = nil,
familyName: String? = nil,
organizationName: String? = nil,
displayName: String? = nil,
phoneNumbers: [String]? = nil,
emails: [String]? = nil)
{
self.givenName = givenName
self.familyName = familyName
self.organizationName = organizationName
self.displayName = displayName
self.phoneNumbers = phoneNumbers
self.emails = emails
}
}
public struct OpenClawContactPayload: Codable, Sendable, Equatable {
public var identifier: String
public var displayName: String
public var givenName: String
public var familyName: String
public var organizationName: String
public var phoneNumbers: [String]
public var emails: [String]
public init(
identifier: String,
displayName: String,
givenName: String,
familyName: String,
organizationName: String,
phoneNumbers: [String],
emails: [String])
{
self.identifier = identifier
self.displayName = displayName
self.givenName = givenName
self.familyName = familyName
self.organizationName = organizationName
self.phoneNumbers = phoneNumbers
self.emails = emails
}
}
public struct OpenClawContactsSearchPayload: Codable, Sendable, Equatable {
public var contacts: [OpenClawContactPayload]
public init(contacts: [OpenClawContactPayload]) {
self.contacts = contacts
}
}
public struct OpenClawContactsAddPayload: Codable, Sendable, Equatable {
public var contact: OpenClawContactPayload
public init(contact: OpenClawContactPayload) {
self.contact = contact
}
}

View File

@@ -0,0 +1,134 @@
import Foundation
public enum OpenClawDeviceCommand: String, Codable, Sendable {
case status = "device.status"
case info = "device.info"
}
public enum OpenClawBatteryState: String, Codable, Sendable {
case unknown
case unplugged
case charging
case full
}
public enum OpenClawThermalState: String, Codable, Sendable {
case nominal
case fair
case serious
case critical
}
public enum OpenClawNetworkPathStatus: String, Codable, Sendable {
case satisfied
case unsatisfied
case requiresConnection
}
public enum OpenClawNetworkInterfaceType: String, Codable, Sendable {
case wifi
case cellular
case wired
case other
}
public struct OpenClawBatteryStatusPayload: Codable, Sendable, Equatable {
public var level: Double?
public var state: OpenClawBatteryState
public var lowPowerModeEnabled: Bool
public init(level: Double?, state: OpenClawBatteryState, lowPowerModeEnabled: Bool) {
self.level = level
self.state = state
self.lowPowerModeEnabled = lowPowerModeEnabled
}
}
public struct OpenClawThermalStatusPayload: Codable, Sendable, Equatable {
public var state: OpenClawThermalState
public init(state: OpenClawThermalState) {
self.state = state
}
}
public struct OpenClawStorageStatusPayload: Codable, Sendable, Equatable {
public var totalBytes: Int64
public var freeBytes: Int64
public var usedBytes: Int64
public init(totalBytes: Int64, freeBytes: Int64, usedBytes: Int64) {
self.totalBytes = totalBytes
self.freeBytes = freeBytes
self.usedBytes = usedBytes
}
}
public struct OpenClawNetworkStatusPayload: Codable, Sendable, Equatable {
public var status: OpenClawNetworkPathStatus
public var isExpensive: Bool
public var isConstrained: Bool
public var interfaces: [OpenClawNetworkInterfaceType]
public init(
status: OpenClawNetworkPathStatus,
isExpensive: Bool,
isConstrained: Bool,
interfaces: [OpenClawNetworkInterfaceType])
{
self.status = status
self.isExpensive = isExpensive
self.isConstrained = isConstrained
self.interfaces = interfaces
}
}
public struct OpenClawDeviceStatusPayload: Codable, Sendable, Equatable {
public var battery: OpenClawBatteryStatusPayload
public var thermal: OpenClawThermalStatusPayload
public var storage: OpenClawStorageStatusPayload
public var network: OpenClawNetworkStatusPayload
public var uptimeSeconds: Double
public init(
battery: OpenClawBatteryStatusPayload,
thermal: OpenClawThermalStatusPayload,
storage: OpenClawStorageStatusPayload,
network: OpenClawNetworkStatusPayload,
uptimeSeconds: Double)
{
self.battery = battery
self.thermal = thermal
self.storage = storage
self.network = network
self.uptimeSeconds = uptimeSeconds
}
}
public struct OpenClawDeviceInfoPayload: Codable, Sendable, Equatable {
public var deviceName: String
public var modelIdentifier: String
public var systemName: String
public var systemVersion: String
public var appVersion: String
public var appBuild: String
public var locale: String
public init(
deviceName: String,
modelIdentifier: String,
systemName: String,
systemVersion: String,
appVersion: String,
appBuild: String,
locale: String)
{
self.deviceName = deviceName
self.modelIdentifier = modelIdentifier
self.systemName = systemName
self.systemVersion = systemVersion
self.appVersion = appVersion
self.appBuild = appBuild
self.locale = locale
}
}

View File

@@ -72,6 +72,10 @@ public struct GatewayConnectOptions: Sendable {
public var clientId: String
public var clientMode: String
public var clientDisplayName: String?
// When false, the connection omits the signed device identity payload.
// This is useful for secondary "operator" connections where the shared gateway token
// should authorize without triggering device pairing flows.
public var includeDeviceIdentity: Bool
public init(
role: String,
@@ -81,7 +85,8 @@ public struct GatewayConnectOptions: Sendable {
permissions: [String: Bool],
clientId: String,
clientMode: String,
clientDisplayName: String?)
clientDisplayName: String?,
includeDeviceIdentity: Bool = true)
{
self.role = role
self.scopes = scopes
@@ -91,6 +96,7 @@ public struct GatewayConnectOptions: Sendable {
self.clientId = clientId
self.clientMode = clientMode
self.clientDisplayName = clientDisplayName
self.includeDeviceIdentity = includeDeviceIdentity
}
}
@@ -128,7 +134,7 @@ public actor GatewayChannelActor {
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let connectTimeoutSeconds: Double = 6
private let connectChallengeTimeoutSeconds: Double = 0.75
private let connectChallengeTimeoutSeconds: Double = 3.0
private var watchdogTask: Task<Void, Never>?
private var tickTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000
@@ -307,9 +313,15 @@ public actor GatewayChannelActor {
if !options.permissions.isEmpty {
params["permissions"] = ProtoAnyCodable(options.permissions)
}
let identity = DeviceIdentityStore.loadOrCreate()
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
let authToken = storedToken ?? self.token
let includeDeviceIdentity = options.includeDeviceIdentity
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
let storedToken =
(includeDeviceIdentity && identity != nil)
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
: nil
// If we're not sending a device identity, a device token can't be validated server-side.
// In that mode we always use the shared gateway token/password.
let authToken = includeDeviceIdentity ? (storedToken ?? self.token) : self.token
let authSource: GatewayAuthSource
if storedToken != nil {
authSource = .deviceToken
@@ -322,7 +334,7 @@ public actor GatewayChannelActor {
}
self.lastAuthSource = authSource
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
let canFallbackToShared = storedToken != nil && self.token != nil
let canFallbackToShared = includeDeviceIdentity && storedToken != nil && self.token != nil
if let authToken {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
} else if let password = self.password {
@@ -333,7 +345,7 @@ public actor GatewayChannelActor {
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
identity.deviceId,
identity?.deviceId ?? "",
clientId,
clientMode,
role,
@@ -345,18 +357,20 @@ public actor GatewayChannelActor {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
var device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
if includeDeviceIdentity, let identity {
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
var device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
}
params["device"] = ProtoAnyCodable(device)
}
let frame = RequestFrame(
@@ -371,7 +385,9 @@ public actor GatewayChannelActor {
try await self.handleConnectResponse(response, identity: identity, role: role)
} catch {
if canFallbackToShared {
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
if let identity {
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
}
throw error
}
@@ -379,7 +395,7 @@ public actor GatewayChannelActor {
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity,
identity: DeviceIdentity?,
role: String
) async throws {
if res.ok == false {
@@ -404,11 +420,13 @@ public actor GatewayChannelActor {
let authRole = auth["role"]?.value as? String ?? role
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
if let identity {
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes)
}
}
self.lastTick = Date()
self.tickTask?.cancel()
@@ -498,7 +516,10 @@ public actor GatewayChannelActor {
}
})
} catch {
if error is ConnectChallengeError { return nil }
if error is ConnectChallengeError {
self.logger.warning("gateway connect challenge timed out")
return nil
}
throw error
}
}

View File

@@ -21,6 +21,7 @@ public actor GatewayNodeSession {
private var activeURL: URL?
private var activeToken: String?
private var activePassword: String?
private var activeConnectOptionsKey: String?
private var connectOptions: GatewayConnectOptions?
private var onConnected: (@Sendable () async -> Void)?
private var onDisconnected: (@Sendable (String) async -> Void)?
@@ -103,6 +104,42 @@ public actor GatewayNodeSession {
public init() {}
private func connectOptionsKey(_ options: GatewayConnectOptions) -> String {
func sorted(_ values: [String]) -> String {
values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.sorted()
.joined(separator: ",")
}
let role = options.role.trimmingCharacters(in: .whitespacesAndNewlines)
let scopes = sorted(options.scopes)
let caps = sorted(options.caps)
let commands = sorted(options.commands)
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
let permissions = options.permissions
.map { key, value in
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
return "\(trimmed)=\(value ? "1" : "0")"
}
.sorted()
.joined(separator: ",")
return [
role,
scopes,
caps,
commands,
clientId,
clientMode,
clientDisplayName,
includeDeviceIdentity,
permissions,
].joined(separator: "|")
}
public func connect(
url: URL,
token: String?,
@@ -113,9 +150,11 @@ public actor GatewayNodeSession {
onDisconnected: @escaping @Sendable (String) async -> Void,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
) async throws {
let nextOptionsKey = self.connectOptionsKey(connectOptions)
let shouldReconnect = self.activeURL != url ||
self.activeToken != token ||
self.activePassword != password ||
self.activeConnectOptionsKey != nextOptionsKey ||
self.channel == nil
self.connectOptions = connectOptions
@@ -138,12 +177,13 @@ public actor GatewayNodeSession {
},
connectOptions: connectOptions,
disconnectHandler: { [weak self] reason in
await self?.onDisconnected?(reason)
await self?.handleChannelDisconnected(reason)
})
self.channel = channel
self.activeURL = url
self.activeToken = token
self.activePassword = password
self.activeConnectOptionsKey = nextOptionsKey
}
guard let channel = self.channel else {
@@ -157,7 +197,6 @@ public actor GatewayNodeSession {
_ = await self.waitForSnapshot(timeoutMs: 500)
await self.notifyConnectedIfNeeded()
} catch {
await onDisconnected(error.localizedDescription)
throw error
}
}
@@ -168,6 +207,7 @@ public actor GatewayNodeSession {
self.activeURL = nil
self.activeToken = nil
self.activePassword = nil
self.activeConnectOptionsKey = nil
self.resetConnectionState()
}
@@ -249,6 +289,13 @@ public actor GatewayNodeSession {
}
}
private func handleChannelDisconnected(_ reason: String) async {
// The underlying channel can auto-reconnect; resetting state here ensures we surface a fresh
// onConnected callback once a new snapshot arrives after reconnect.
self.resetConnectionState()
await self.onDisconnected?(reason)
}
private func markSnapshotReceived() {
self.snapshotReceived = true
if !self.snapshotWaiters.isEmpty {

View File

@@ -0,0 +1,95 @@
import Foundation
public enum OpenClawMotionCommand: String, Codable, Sendable {
case activity = "motion.activity"
case pedometer = "motion.pedometer"
}
public struct OpenClawMotionActivityParams: Codable, Sendable, Equatable {
public var startISO: String?
public var endISO: String?
public var limit: Int?
public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) {
self.startISO = startISO
self.endISO = endISO
self.limit = limit
}
}
public struct OpenClawMotionActivityEntry: Codable, Sendable, Equatable {
public var startISO: String
public var endISO: String
public var confidence: String
public var isWalking: Bool
public var isRunning: Bool
public var isCycling: Bool
public var isAutomotive: Bool
public var isStationary: Bool
public var isUnknown: Bool
public init(
startISO: String,
endISO: String,
confidence: String,
isWalking: Bool,
isRunning: Bool,
isCycling: Bool,
isAutomotive: Bool,
isStationary: Bool,
isUnknown: Bool)
{
self.startISO = startISO
self.endISO = endISO
self.confidence = confidence
self.isWalking = isWalking
self.isRunning = isRunning
self.isCycling = isCycling
self.isAutomotive = isAutomotive
self.isStationary = isStationary
self.isUnknown = isUnknown
}
}
public struct OpenClawMotionActivityPayload: Codable, Sendable, Equatable {
public var activities: [OpenClawMotionActivityEntry]
public init(activities: [OpenClawMotionActivityEntry]) {
self.activities = activities
}
}
public struct OpenClawPedometerParams: Codable, Sendable, Equatable {
public var startISO: String?
public var endISO: String?
public init(startISO: String? = nil, endISO: String? = nil) {
self.startISO = startISO
self.endISO = endISO
}
}
public struct OpenClawPedometerPayload: Codable, Sendable, Equatable {
public var startISO: String
public var endISO: String
public var steps: Int?
public var distanceMeters: Double?
public var floorsAscended: Int?
public var floorsDescended: Int?
public init(
startISO: String,
endISO: String,
steps: Int?,
distanceMeters: Double?,
floorsAscended: Int?,
floorsDescended: Int?)
{
self.startISO = startISO
self.endISO = endISO
self.steps = steps
self.distanceMeters = distanceMeters
self.floorsAscended = floorsAscended
self.floorsDescended = floorsDescended
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
public enum OpenClawPhotosCommand: String, Codable, Sendable {
case latest = "photos.latest"
}
public struct OpenClawPhotosLatestParams: Codable, Sendable, Equatable {
public var limit: Int?
public var maxWidth: Int?
public var quality: Double?
public init(limit: Int? = nil, maxWidth: Int? = nil, quality: Double? = nil) {
self.limit = limit
self.maxWidth = maxWidth
self.quality = quality
}
}
public struct OpenClawPhotoPayload: Codable, Sendable, Equatable {
public var format: String
public var base64: String
public var width: Int
public var height: Int
public var createdAt: String?
public init(format: String, base64: String, width: Int, height: Int, createdAt: String? = nil) {
self.format = format
self.base64 = base64
self.width = width
self.height = height
self.createdAt = createdAt
}
}
public struct OpenClawPhotosLatestPayload: Codable, Sendable, Equatable {
public var photos: [OpenClawPhotoPayload]
public init(photos: [OpenClawPhotoPayload]) {
self.photos = photos
}
}

View File

@@ -0,0 +1,82 @@
import Foundation
public enum OpenClawRemindersCommand: String, Codable, Sendable {
case list = "reminders.list"
case add = "reminders.add"
}
public enum OpenClawReminderStatusFilter: String, Codable, Sendable {
case incomplete
case completed
case all
}
public struct OpenClawRemindersListParams: Codable, Sendable, Equatable {
public var status: OpenClawReminderStatusFilter?
public var limit: Int?
public init(status: OpenClawReminderStatusFilter? = nil, limit: Int? = nil) {
self.status = status
self.limit = limit
}
}
public struct OpenClawRemindersAddParams: Codable, Sendable, Equatable {
public var title: String
public var dueISO: String?
public var notes: String?
public var listId: String?
public var listName: String?
public init(
title: String,
dueISO: String? = nil,
notes: String? = nil,
listId: String? = nil,
listName: String? = nil)
{
self.title = title
self.dueISO = dueISO
self.notes = notes
self.listId = listId
self.listName = listName
}
}
public struct OpenClawReminderPayload: Codable, Sendable, Equatable {
public var identifier: String
public var title: String
public var dueISO: String?
public var completed: Bool
public var listName: String?
public init(
identifier: String,
title: String,
dueISO: String? = nil,
completed: Bool,
listName: String? = nil)
{
self.identifier = identifier
self.title = title
self.dueISO = dueISO
self.completed = completed
self.listName = listName
}
}
public struct OpenClawRemindersListPayload: Codable, Sendable, Equatable {
public var reminders: [OpenClawReminderPayload]
public init(reminders: [OpenClawReminderPayload]) {
self.reminders = reminders
}
}
public struct OpenClawRemindersAddPayload: Codable, Sendable, Equatable {
public var reminder: OpenClawReminderPayload
public init(reminder: OpenClawReminderPayload) {
self.reminder = reminder
}
}

View File

@@ -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
}
}