Files
openclaw-openclaw/apps/ios/Sources/Model/NodeAppModel.swift
Peter Steinberger f95ca90378 fix(ios): persist voice notes through offline delivery (#101236)
* fix(ios): persist voice note delivery state

* fix(ios): pin chats during attachment staging

* fix(ios): keep voice notes on captured chat

* fix(ios): keep voice notes durable through staging

* fix(ios): harden voice note lifecycle

* test(ios): satisfy strict concurrency in voice note proof

* fix(ios): keep legacy attachment sends live

* fix(ios): retain legacy drafts during outbox restore

* fix(ios): pin voice note recipient presentation

* fix(ios): preserve confirmed voice note duration

* chore(i18n): refresh voice note source inventory

* chore: leave release notes to release automation
2026-07-07 01:21:16 +01:00

7459 lines
326 KiB
Swift

import CoreLocation
import CryptoKit
import Observation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import os
import Security
import SwiftUI
import UIKit
import UserNotifications
/// Wrap errors without pulling non-Sendable types into async notification paths.
private struct NotificationCallError: Error {
let message: String
}
private struct GatewayRelayIdentityResponse: Decodable {
let deviceId: String
let publicKey: String
}
private struct WatchChatPreview {
var items: [OpenClawWatchChatItem]
var statusText: String?
}
private struct WatchChatMetadataEnvelope: Decodable {
struct Metadata: Decodable {
var id: String?
}
var metadata: Metadata?
var messageToolMirror: [String: String]?
enum CodingKeys: String, CodingKey {
case metadata = "__openclaw"
case messageToolMirror = "openclawMessageToolMirror"
}
}
private struct WatchChatMessageEntry {
var message: OpenClawChatMessage
var text: String
var serverId: String?
var isMessageToolMirror: Bool
}
private struct ExecApprovalGatewayEventPayload: Decodable {
var id: String
}
private struct NodeEventRequestPayload: Encodable {
var event: String
var payloadJSON: 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)
}
}
private enum IOSDeepLinkAgentPolicy {
static let maxMessageChars = 20000
static let maxUnkeyedConfirmChars = 240
}
@MainActor
@Observable
// swiftlint:disable type_body_length file_length
final class NodeAppModel {
private nonisolated static let watchChatPreviewItemLimit = 5
private nonisolated static let watchMessageMaxImmediateRetryAttempts = 3
struct AgentDeepLinkPrompt: Identifiable, Equatable {
let id: String
let messagePreview: String
let urlPreview: String
let request: AgentDeepLink
}
struct ExecApprovalPrompt: Identifiable, Equatable, Codable {
let id: String
let gatewayStableID: String
let commandText: String
let commandPreview: String?
let allowedDecisions: [String]
let host: String?
let nodeId: String?
let agentId: String?
let expiresAtMs: Int?
var allowsAllowAlways: Bool {
self.allowedDecisions.contains("allow-always")
}
}
struct NotificationPermissionGuidancePrompt: Identifiable, Equatable {
let id = UUID()
let approvalId: String
}
private enum ExecApprovalResolutionOutcome {
case resolved
case stale
case unavailable
case failed(message: String)
}
private struct GatewaySessionRouteContext {
let route: GatewayNodeSessionRoute
let gatewayStableID: String
let routeGeneration: UInt64
}
private enum ExecApprovalPushRouteValidation {
case validated(GatewaySessionRouteContext)
case unavailable
case mismatchedOwner
}
private enum WatchMessageSendOutcome {
case sent
case retry
case discard
}
private struct PersistedWatchExecApprovalBridgeState: Codable {
var approvals: [ExecApprovalPrompt]
var pendingApprovalPushes: [ExecApprovalNotificationPrompt]?
var pendingResolvedPushes: [ExecApprovalNotificationPrompt]?
var pendingResolutions: [WatchExecApprovalResolveEvent]?
}
private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink")
private nonisolated static let agentRequestNodeEventTimeoutSeconds = 8
private nonisolated static let execApprovalNotificationGuidanceSuppressedKey =
"notifications.execApprovalGuidance.suppressed"
private let pushWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PushWake")
private let pendingActionLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PendingAction")
private let locationWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "LocationWake")
private let watchReplyLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchReply")
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchExecApproval")
private let execApprovalNotificationLogger = Logger(
subsystem: "ai.openclawfoundation.app",
category: "ExecApprovalNotification")
enum CameraHUDKind {
case photo
case recording
case success
case error
}
var isBackgrounded: Bool = false
let screen: ScreenController
private let camera: any CameraServicing
private let screenRecorder: any ScreenRecordingServicing
var gatewayStatusText: String = "Offline"
var nodeStatusText: String = "Offline"
var operatorStatusText: String = "Offline"
private(set) var isAppleReviewDemoModeEnabled: Bool = false
private(set) var isScreenshotFixtureModeEnabled: Bool = false
var isOperatorGatewayConnected: Bool {
self.operatorConnected
}
private(set) var hasOperatorAdminScope: Bool = false
var gatewayServerName: String?
var gatewayRemoteAddress: String?
var connectedGatewayID: String?
var gatewayAutoReconnectEnabled: Bool = true
// When the gateway requires pairing approval, we pause reconnect churn and show a stable UX.
// Reconnect loops (both our own and the underlying WebSocket watchdog) can otherwise generate
// multiple pending requests and cause the onboarding UI to "flip-flop".
var gatewayPairingPaused: Bool = false
var gatewayPairingRequestId: String?
// Bumped on every non-nil assignment, including re-reports of an equal problem;
// value equality alone cannot tell the UI to re-surface or shake the toast.
private(set) var gatewayProblemReportCount = 0
private(set) var lastGatewayProblem: GatewayConnectionProblem? {
didSet { if self.lastGatewayProblem != nil { self.gatewayProblemReportCount &+= 1 } }
}
private var operatorGatewayProblem: GatewayConnectionProblem?
var gatewayDisplayStatusText: String {
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
}
private var mainSessionBaseKey: String = "main"
private var gatewaySessionScope: String?
private var focusedChatSessionKey: String?
// Two-part unread guard mirroring Android: the opened key survives read
// confirmations so later unread episodes on the same open chat re-acknowledge;
// the acknowledged key is the per-episode pending flag.
@ObservationIgnored private var openedChatSessionKey: String?
@ObservationIgnored private var readAcknowledgedChatSessionKey: String?
var selectedAgentId: String?
var gatewayDefaultAgentId: String?
var gatewayAgents: [AgentSummary] = []
var homeCanvasRevision: Int = 0
var lastShareEventText: String = "No share events yet."
var openChatRequestID: Int = 0
var gatewaySetupRequestID: Int = 0
private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
private var pendingGatewaySetupLink: GatewayConnectDeepLink?
private(set) var pendingExecApprovalPrompt: ExecApprovalPrompt?
private(set) var pendingExecApprovalPromptResolving: Bool = false
private(set) var pendingExecApprovalPromptErrorText: String?
private var pendingExecApprovalPromptRequestGeneration: Int = 0
private(set) var pendingNotificationPermissionGuidancePrompt: NotificationPermissionGuidancePrompt?
private var queuedAgentDeepLinkPrompt: AgentDeepLinkPrompt?
private var lastAgentDeepLinkPromptAt: Date = .distantPast
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
/// Primary "node" connection: used for device capabilities and node.invoke requests.
private let nodeGateway = GatewayNodeSession()
// Secondary "operator" connection: used for chat/talk/config/voicewake requests.
private let operatorGateway = GatewayNodeSession()
private var nodeGatewayTask: Task<Void, Never>?
private var operatorGatewayTask: Task<Void, Never>?
@ObservationIgnored private var gatewaySessionResetTask: Task<Void, Never>?
@ObservationIgnored private var gatewaySessionResetGeneration: UInt64 = 0
@ObservationIgnored private var gatewayRouteGeneration: UInt64 = 0
@ObservationIgnored private var credentialHandoffFailureGeneration: UInt64?
@ObservationIgnored private(set) var gatewayConnectGeneration: UInt64 = 0
private var forceOperatorTalkPermissionUpgradeRequest = false
private var lastTalkPermissionReconnectAttemptAt: Date?
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 var gatewayHealthMonitorDisabled = false
private let notificationCenter: NotificationCentering
let voiceWake = VoiceWakeManager()
let voiceNoteRecorder: OpenClawVoiceNoteRecorder
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 let watchMessagingService: any WatchMessagingServicing
#if DEBUG
@ObservationIgnored private var testAgentRequestHandler: ((AgentDeepLink) async throws -> Void)?
#endif
private var pttVoiceWakeLeaseCount = 0
private var pttVoiceWakeWasSuspended = false
private var pttSessionOwnsVoiceWakeLease = false
private var talkInvokeInFlight = false
private var talkInvokeWaiters: [CheckedContinuation<Void, Never>] = []
private var talkVoiceWakeSuspended = false
private var backgroundVoiceWakeSuspended = false
private var backgroundTalkSuspended = false
private var backgroundTalkKeptActive = false
private var backgroundedAt: Date?
private var reconnectAfterBackgroundArmed = false
private var backgroundGraceTaskID: UIBackgroundTaskIdentifier = .invalid
@ObservationIgnored private var backgroundGraceTaskTimer: Task<Void, Never>?
private var backgroundReconnectSuppressed = false
private var backgroundReconnectLeaseUntil: Date?
@ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false
private var lastSignificantLocationWakeAt: Date?
@ObservationIgnored private let watchMessageOutbox = WatchMessageOutbox()
@ObservationIgnored private var watchMessageFlushInFlight = false
@ObservationIgnored private var watchMessageRetryAttempts: [String: Int] = [:]
@ObservationIgnored private var watchMessageRetryTask: Task<Void, Never>?
@ObservationIgnored private let appleReviewDemoChatTransport = AppleReviewDemoChatTransport()
@ObservationIgnored private var chatTranscriptCachesByGatewayID: [String: OpenClawChatSQLiteTranscriptCache] = [:]
private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:]
private var pendingWatchExecApprovalRecoveryPushes: [ExecApprovalNotificationPrompt] = []
private var pendingExecApprovalResolvedPushes: [ExecApprovalNotificationPrompt] = []
private var pendingWatchExecApprovalResolutions: [WatchExecApprovalResolveEvent] = []
private var pendingForegroundActionDrainInFlight = false
private var pendingForegroundActionDrainRequested = false
private var completedPendingForegroundActionIDsByGateway: [String: Set<String>] = [:]
private var gatewayConnected = false
private var operatorConnected = false
private var shareDeliveryChannel: String?
private var shareDeliveryTo: String?
private var apnsDeviceTokenHex: String?
private var apnsLastRegisteredTokenHex: String?
private var apnsLastRegisteredGatewayStableID: String?
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
var operatorSession: GatewayNodeSession {
self.operatorGateway
}
var isTalkCaptureActive: Bool {
// PTT owns its Voice Wake lease before permission and audio setup.
// Count that pending interval so Chat cannot race another mic owner.
self.talkMode.isEnabled || self.talkMode.isPushToTalkActive || self.pttVoiceWakeLeaseCount > 0
}
var localChatFixture: LocalChatFixture? {
if self.isScreenshotFixtureModeEnabled { return .appScreenshots }
if self.isAppleReviewDemoModeEnabled { return .appleReviewDemo }
return nil
}
var isLocalChatFixtureEnabled: Bool {
self.localChatFixture != nil
}
var isLocalGatewayFixtureEnabled: Bool {
self.isAppleReviewDemoModeEnabled || self.isScreenshotFixtureModeEnabled
}
var chatTransportModeID: String {
if self.isScreenshotFixtureModeEnabled { return "screenshots" }
if self.isAppleReviewDemoModeEnabled { return "apple-review-demo" }
return self.isOperatorGatewayConnected ? "operator" : "offline"
}
func makeChatTransport(outboxGatewayID: String? = nil) -> any OpenClawChatTransport {
if self.isScreenshotFixtureModeEnabled {
return LocalFixtureChatTransport(fixture: .appScreenshots)
}
if self.isAppleReviewDemoModeEnabled {
return AppleReviewDemoChatTransport()
}
return IOSGatewayChatTransport(
gateway: self.operatorSession,
globalAgentId: self.chatDeliveryAgentId,
outboxGatewayID: outboxGatewayID)
}
/// Gateway identity the transcript cache is scoped to: the active
/// connection's stableID, or the keychain-persisted active gateway on
/// cold open before the gateway session is up. Nil for fixture transports
/// and unpaired installs so demo or foreign rows can never leak into a
/// real gateway's transcript.
var chatTranscriptCacheGatewayID: String? {
guard !self.isLocalGatewayFixtureEnabled else { return nil }
let stableID = self.activeGatewayConnectConfig?.effectiveStableID
?? self.connectedGatewayID
?? GatewaySettingsStore.activeGatewayEntry()?.stableID
guard let stableID, !stableID.isEmpty else { return nil }
return stableID
}
/// Recreation key for the chat view model. Includes the cache gateway
/// identity: switching paired gateways while the transport mode stays
/// "operator" must rebuild the view model so transcripts are never read
/// from or written under another gateway's cache scope.
var chatViewModelIdentityID: String {
"\(self.chatTransportModeID)|\(self.chatTranscriptCacheGatewayID ?? "")|\(self.chatTranscriptCacheGeneration)"
}
/// Stable owner key for the long-lived chat view model. Connectivity still
/// changes `chatViewModelIdentityID` for session-list refreshes, but must
/// not rebuild Chat and discard an offline draft on the same gateway.
var chatViewModelOwnerID: String {
let modeID = self.isLocalGatewayFixtureEnabled ? self.chatTransportModeID : "gateway"
return "\(modeID)|\(self.chatTranscriptCacheGatewayID ?? "")|\(self.chatTranscriptCacheGeneration)"
}
private var chatTranscriptCacheGeneration = 0
/// Offline transcript cache plus durable command outbox, both scoped to
/// the paired gateway identity (one SQLite file per gateway, memoized so
/// retire/purge can close every open handle). Nil for fixture/unpaired
/// transports: no cache and no outbox.
func makeChatOfflineStore() -> OpenClawChatSQLiteTranscriptCache? {
guard let gatewayID = self.chatTranscriptCacheGatewayID else { return nil }
if let cache = self.chatTranscriptCachesByGatewayID[gatewayID] {
return cache
}
guard let databaseURL = Self.chatTranscriptCacheDatabaseURL(gatewayID: gatewayID) else { return nil }
let cache = OpenClawChatSQLiteTranscriptCache(databaseURL: databaseURL, gatewayID: gatewayID)
self.chatTranscriptCachesByGatewayID[gatewayID] = cache
return cache
}
var hasVerifiedChatOfflineRoutingIdentity: Bool {
self.chatTranscriptCacheGatewayID != nil &&
self.chatDeliveryAgentId != nil &&
self.chatSessionRoutingContract != nil
}
func restoreChatSessionRoutingIdentityIfNeeded() async {
guard !self.isLocalGatewayFixtureEnabled,
self.chatSessionRoutingContract == nil,
let store = self.makeChatOfflineStore(),
let identity = await store.loadSessionRoutingIdentity(),
self.chatTranscriptCacheGatewayID == store.gatewayID,
self.chatSessionRoutingContract == nil
else { return }
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: store.gatewayID)
self.gatewaySessionScope = identity.scope
self.mainSessionBaseKey = identity.mainSessionKey
self.gatewayDefaultAgentId = identity.defaultAgentID
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
}
func loadCachedChatSessions() async -> [OpenClawChatSessionEntry] {
guard let cache = self.makeChatOfflineStore() else { return [] }
return await cache.loadSessions()
}
func storeCachedChatSessions(_ sessions: [OpenClawChatSessionEntry]) async {
guard let cache = self.makeChatOfflineStore() else { return }
await cache.storeSessions(sessions)
}
/// Delete one gateway's cache during bootstrap replacement, or the whole
/// disposable database during a full onboarding reset. The offline command
/// outbox shares each gateway's database file, so purging a cache also
/// drops that gateway's queued commands.
func purgeChatTranscriptCache(gatewayID: String? = nil) async {
if let gatewayID, !gatewayID.isEmpty {
guard let databaseURL = Self.chatTranscriptCacheDatabaseURL(gatewayID: gatewayID) else { return }
if let cache = self.chatTranscriptCachesByGatewayID[gatewayID] {
await cache.retire()
}
OpenClawChatSQLiteTranscriptCache.removeDatabaseFiles(at: databaseURL)
self.chatTranscriptCachesByGatewayID.removeValue(forKey: gatewayID)
self.chatTranscriptCacheGeneration &+= 1
return
}
// Full reset retires every open handle before removing SQLite sidecars,
// so deleted transcript bytes cannot survive in WAL or journal pages.
for cache in self.chatTranscriptCachesByGatewayID.values {
await cache.retire()
}
if let directoryURL = Self.chatTranscriptCacheDirectoryURL() {
try? FileManager.default.removeItem(at: directoryURL)
}
self.chatTranscriptCachesByGatewayID.removeAll()
self.chatTranscriptCacheGeneration &+= 1
}
/// Debug launch reset runs before Chat can create a cache actor, so direct
/// file removal preserves the launch flag's synchronous startup contract.
func purgeChatTranscriptCacheBeforeStartup() {
guard let directoryURL = Self.chatTranscriptCacheDirectoryURL() else { return }
try? FileManager.default.removeItem(at: directoryURL)
self.chatTranscriptCachesByGatewayID.removeAll()
self.chatTranscriptCacheGeneration &+= 1
}
private static func chatTranscriptCacheDirectoryURL() -> URL? {
try? OpenClawNodeStorage.appSupportDir()
.appendingPathComponent("chat-cache", isDirectory: true)
}
static func chatTranscriptCacheDatabaseURL(gatewayID: String) -> URL? {
let digest = SHA256.hash(data: Data(gatewayID.utf8))
.map { String(format: "%02x", $0) }
.joined()
return Self.chatTranscriptCacheDirectoryURL()?
.appendingPathComponent("\(digest).sqlite", isDirectory: false)
}
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
private static let backgroundAliveLastSuccessAtMsKey = "gateway.backgroundAlive.lastSuccessAtMs"
private static let backgroundAliveLastTriggerKey = "gateway.backgroundAlive.lastTrigger"
private static let foregroundResumeHealthTimeoutSeconds = 1
private static let watchChatCompletionWaitMs = 75000
private static let watchChatRunWaitSliceMs = 60000
var cameraHUDText: String?
var cameraHUDKind: CameraHUDKind?
var cameraFlashNonce: Int = 0
var screenRecordActive: Bool = false
init(
screen: ScreenController = ScreenController(),
camera: any CameraServicing = CameraController(),
screenRecorder: any ScreenRecordingServicing = ScreenRecordService(),
locationService: any LocationServicing = LocationService(),
notificationCenter: NotificationCentering = LiveNotificationCenter(),
deviceStatusService: any DeviceStatusServicing = DeviceStatusService(),
photosService: any PhotosServicing = PhotoLibraryService(),
contactsService: any ContactsServicing = ContactsService(),
calendarService: any CalendarServicing = CalendarService(),
remindersService: any RemindersServicing = RemindersService(),
motionService: any MotionServicing = MotionService(),
watchMessagingService: any WatchMessagingServicing = WatchMessagingService(),
talkMode: TalkModeManager = TalkModeManager(),
voiceNoteRecorder: OpenClawVoiceNoteRecorder = OpenClawVoiceNoteRecorder())
{
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.watchMessagingService = watchMessagingService
self.talkMode = talkMode
self.voiceNoteRecorder = voiceNoteRecorder
self.voiceNoteRecorder.setCaptureAdmissionHandler { [weak self] in
self?.isTalkCaptureActive == false
}
self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey)
restorePersistedWatchExecApprovalBridgeState()
GatewayDiagnostics.bootstrap()
GatewayDiagnostics.log("node app model: init start")
self.watchMessagingService.setStatusHandler { [weak self] status in
Task { @MainActor in
GatewayDiagnostics.log(
"node app model: watch status callback "
+ "reachable=\(status.reachable) activation=\(status.activationState) "
+ "backgrounded=\(self?.isBackgrounded ?? false)")
await self?.handleWatchMessagingStatusChanged(status)
}
}
self.watchMessagingService.setReplyHandler { [weak self] event in
Task { @MainActor in
await self?.handleWatchQuickReply(event)
}
}
self.watchMessagingService.setExecApprovalResolveHandler { [weak self] event in
Task { @MainActor in
_ = await self?.handleWatchExecApprovalResolve(event)
}
}
self.watchMessagingService.setExecApprovalSnapshotRequestHandler { [weak self] event in
Task { @MainActor in
guard let self else { return }
GatewayDiagnostics.log(
"node app model: watch snapshot request id=\(event.requestId) backgrounded=\(self.isBackgrounded)")
guard self.isBackgrounded else {
self.watchExecApprovalLogger.debug(
"watch exec approval snapshot skipped reason=watch_request_foreground")
GatewayDiagnostics.log("node app model: watch snapshot request skipped in foreground")
return
}
await self.refreshWatchExecApprovalSnapshotOnDemand(reason: "watch_request")
}
}
self.watchMessagingService.setAppSnapshotRequestHandler { [weak self] event in
Task { @MainActor in
guard let self else { return }
GatewayDiagnostics.log(
"node app model: watch app snapshot request id=\(event.requestId)")
await self.syncWatchAppSnapshot(reason: "watch_app_request", includeChat: true)
}
}
self.watchMessagingService.setAppCommandHandler { [weak self] event in
Task { @MainActor in
await self?.handleWatchAppCommand(event)
}
}
self.voiceWake.configure { [weak self] cmd in
guard let self else { return }
let sessionKey = await MainActor.run { self.mainSessionKey }
do {
try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey)
} catch {
// Best-effort only.
}
}
self.voiceNoteRecorder.onRecordingActiveChanged = { [weak self] isActive in
self?.voiceWake.setSuppressedByVoiceNote(isActive)
}
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled)
self.talkMode.attachGateway(self.operatorGateway)
refreshOperatorAdminScopeFromStore()
refreshLastShareEventFromRelay()
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
self.setTalkEnabled(talkEnabled)
self.locationService.setAuthorizationChangeHandler { [weak self] status in
guard let self else { return }
self.reconcileSignificantLocationMonitoring(
mode: self.locationMode(),
authorizationStatus: status)
}
// Wire up deep links from canvas taps
self.screen.onDeepLink = { [weak self] url in
guard let self else { return }
Task { @MainActor in
await self.handleDeepLink(url: url)
}
}
// Wire up A2UI action clicks (buttons, etc.)
self.screen.onA2UIAction = { [weak self] body in
guard let self else { return }
Task { @MainActor in
await self.handleCanvasA2UIAction(body: body)
}
}
}
private func handleCanvasA2UIAction(body: [String: Any]) async {
let userActionAny = body["userAction"] ?? body
let userAction: [String: Any] = {
if let dict = userActionAny as? [String: Any] { return dict }
if let dict = userActionAny as? [AnyHashable: Any] {
return dict.reduce(into: [String: Any]()) { acc, pair in
guard let key = pair.key as? String else { return }
acc[key] = pair.value
}
}
return [:]
}()
guard !userAction.isEmpty else { return }
guard let name = OpenClawCanvasA2UIAction.extractActionName(userAction) else { return }
let actionId: String = {
let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return id.isEmpty ? UUID().uuidString : id
}()
let surfaceId: String = {
let raw = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return raw.isEmpty ? "main" : raw
}()
let sourceComponentId: String = {
let raw = (userAction[
"sourceComponentId",
] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return raw.isEmpty ? "-" : raw
}()
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 = mainSessionKey
let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext(
actionName: name,
session: .init(key: sessionKey, surfaceId: surfaceId),
component: .init(id: sourceComponentId, host: host, instanceId: instanceId),
contextJSON: contextJSON)
let message = OpenClawCanvasA2UIAction.formatAgentMessage(messageContext)
let ok: Bool
var errorText: String?
if await !isGatewayConnected() {
ok = false
errorText = "gateway not connected"
} else {
do {
try await sendAgentRequest(link: AgentDeepLink(
message: message,
sessionKey: sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: nil,
timeoutSeconds: nil,
key: actionId))
ok = true
} catch {
ok = false
errorText = error.localizedDescription
}
}
let js = OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: ok, error: errorText)
do {
_ = try await self.screen.eval(javaScript: js)
} catch {
// ignore
}
}
func setScenePhase(_ phase: ScenePhase) {
let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled")
GatewayDiagnostics.log("node app model: scene phase=\(String(describing: phase))")
switch phase {
case .background:
self.isBackgrounded = true
self.stopGatewayHealthMonitor()
self.backgroundedAt = Date()
self.reconnectAfterBackgroundArmed = true
self.beginBackgroundConnectionGracePeriod()
if self.voiceNoteRecorder.isRecording || self.voiceNoteRecorder.isRequestingPermission {
// Cancel first: releasing the voice-note suppression reason can
// schedule Voice Wake, which the background suspension must catch.
self.voiceNoteRecorder.cancel()
}
// Release voice wake mic in background.
self.backgroundVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture()
let shouldKeepTalkActive = keepTalkActive && self.talkMode.isEnabled
self.backgroundTalkKeptActive = shouldKeepTalkActive
self.backgroundTalkSuspended = self.talkMode.suspendForBackground(keepActive: shouldKeepTalkActive)
case .active, .inactive:
self.isBackgrounded = false
self.endBackgroundConnectionGracePeriod(reason: "scene_foreground")
self.clearBackgroundReconnectSuppression(reason: "scene_foreground")
var shouldStartGatewayHealthMonitor = self.operatorConnected
if phase == .active {
self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.backgroundVoiceWakeSuspended)
self.backgroundVoiceWakeSuspended = false
Task { [weak self] in
guard let self else { return }
let suspended = await MainActor.run { self.backgroundTalkSuspended }
let keptActive = await MainActor.run { self.backgroundTalkKeptActive }
await MainActor.run {
self.backgroundTalkSuspended = false
self.backgroundTalkKeptActive = false
}
await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive)
}
Task { [weak self] in
await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "scene_active")
}
}
if phase == .active, self.reconnectAfterBackgroundArmed {
self.reconnectAfterBackgroundArmed = false
let backgroundedFor = self.backgroundedAt.map { Date().timeIntervalSince($0) } ?? 0
self.backgroundedAt = nil
// iOS may suspend network sockets in background without a clean close.
// On foreground, force a fresh handshake to avoid "connected but dead" states.
if backgroundedFor >= 3.0 {
shouldStartGatewayHealthMonitor = false
self.foregroundGatewayResumeCheckInFlight = true
Task { [weak self] in
guard let self else { return }
let operatorWasConnected = await MainActor.run { self.operatorConnected }
if operatorWasConnected {
// Prefer keeping the connection if it's healthy; reconnect only when needed.
let healthy = await (try? self.operatorGateway.request(
method: "health",
paramsJSON: nil,
timeoutSeconds: Self.foregroundResumeHealthTimeoutSeconds)) != nil
if healthy {
await MainActor.run {
self.foregroundGatewayResumeCheckInFlight = false
self.startGatewayHealthMonitor()
}
return
}
}
await MainActor.run {
self.foregroundGatewayResumeCheckInFlight = false
}
await self.restartGatewaySessionsAfterForegroundStaleConnection()
}
}
}
if shouldStartGatewayHealthMonitor {
self.startGatewayHealthMonitor()
}
@unknown default:
self.isBackgrounded = false
self.endBackgroundConnectionGracePeriod(reason: "scene_unknown")
self.clearBackgroundReconnectSuppression(reason: "scene_unknown")
}
}
private func beginBackgroundConnectionGracePeriod(seconds: TimeInterval = 25) {
self.grantBackgroundReconnectLease(seconds: seconds, reason: "scene_background_grace")
self.endBackgroundConnectionGracePeriod(reason: "restart")
let taskID = UIApplication.shared.beginBackgroundTask(withName: "gateway-background-grace") { [weak self] in
Task { @MainActor in
self?.suppressBackgroundReconnect(
reason: "background_grace_expired",
disconnectIfNeeded: true)
self?.endBackgroundConnectionGracePeriod(reason: "expired")
}
}
guard taskID != .invalid else {
self.pushWakeLogger.info("Background grace unavailable: beginBackgroundTask returned invalid")
return
}
self.backgroundGraceTaskID = taskID
self.pushWakeLogger.info("Background grace started seconds=\(seconds, privacy: .public)")
self.backgroundGraceTaskTimer = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(max(1, seconds) * 1_000_000_000))
await MainActor.run {
self.suppressBackgroundReconnect(reason: "background_grace_timer", disconnectIfNeeded: true)
self.endBackgroundConnectionGracePeriod(reason: "timer")
}
}
}
private func endBackgroundConnectionGracePeriod(reason: String) {
self.backgroundGraceTaskTimer?.cancel()
self.backgroundGraceTaskTimer = nil
guard self.backgroundGraceTaskID != .invalid else { return }
UIApplication.shared.endBackgroundTask(self.backgroundGraceTaskID)
self.backgroundGraceTaskID = .invalid
self.pushWakeLogger.info("Background grace ended reason=\(reason, privacy: .public)")
}
private func grantBackgroundReconnectLease(seconds: TimeInterval, reason: String) {
guard self.isBackgrounded else { return }
let leaseSeconds = max(5, seconds)
let leaseUntil = Date().addingTimeInterval(leaseSeconds)
if let existing = backgroundReconnectLeaseUntil, existing > leaseUntil {
// Keep the longer lease if one is already active.
} else {
self.backgroundReconnectLeaseUntil = leaseUntil
}
let wasSuppressed = self.backgroundReconnectSuppressed
self.backgroundReconnectSuppressed = false
let leaseLogMessage =
"Background reconnect lease reason=\(reason) "
+ "seconds=\(leaseSeconds) wasSuppressed=\(wasSuppressed)"
self.pushWakeLogger.info("\(leaseLogMessage, privacy: .public)")
}
private func suppressBackgroundReconnect(reason: String, disconnectIfNeeded: Bool) {
guard self.isBackgrounded else { return }
let hadLease = self.backgroundReconnectLeaseUntil != nil
let changed = hadLease || !self.backgroundReconnectSuppressed
self.backgroundReconnectLeaseUntil = nil
self.backgroundReconnectSuppressed = true
guard changed else { return }
let suppressLogMessage =
"Background reconnect suppressed reason=\(reason) "
+ "disconnect=\(disconnectIfNeeded)"
self.pushWakeLogger.info("\(suppressLogMessage, privacy: .public)")
guard disconnectIfNeeded else { return }
Task { [weak self] in
guard let self else { return }
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
await MainActor.run {
guard !self.isLocalGatewayFixtureEnabled else { return }
self.setOperatorConnected(false)
self.gatewayConnected = false
self.talkMode.updateGatewayConnected(false)
if self.isBackgrounded {
self.gatewayStatusText = "Background idle"
LiveActivityManager.shared.endActivity(reason: "background_idle")
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.showLocalCanvasOnDisconnect()
}
}
}
}
private func clearBackgroundReconnectSuppression(reason: String) {
let changed = self.backgroundReconnectSuppressed || self.backgroundReconnectLeaseUntil != nil
self.backgroundReconnectSuppressed = false
self.backgroundReconnectLeaseUntil = nil
guard changed else { return }
self.pushWakeLogger.info("Background reconnect cleared reason=\(reason, privacy: .public)")
}
func setVoiceWakeEnabled(_ enabled: Bool) {
self.voiceWake.setEnabled(enabled)
if enabled {
// If talk is enabled, voice wake should not grab the mic.
if self.talkMode.isEnabled {
self.voiceWake.setSuppressedByTalk(true)
self.talkVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture()
}
} else {
self.voiceWake.setSuppressedByTalk(false)
self.talkVoiceWakeSuspended = false
}
}
func setTalkEnabled(_ enabled: Bool) {
if self.isAppleReviewDemoModeEnabled {
UserDefaults.standard.set(false, forKey: "talk.enabled")
self.talkMode.setEnabled(false)
self.talkMode.statusText = "Demo mode only"
return
}
UserDefaults.standard.set(enabled, forKey: "talk.enabled")
if enabled {
if self.voiceNoteRecorder.isRecording || self.voiceNoteRecorder.isRequestingPermission {
self.voiceNoteRecorder.cancel()
}
// Voice wake holds the microphone continuously; talk mode needs exclusive access for STT.
// When talk is enabled from the UI, prioritize talk and pause voice wake.
self.voiceWake.setSuppressedByTalk(true)
self.talkVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture()
} else {
self.voiceWake.setSuppressedByTalk(false)
self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.talkVoiceWakeSuspended)
self.talkVoiceWakeSuspended = false
}
self.talkMode.setEnabled(enabled)
Task { [weak self] in
await self?.pushTalkModeToGateway(
enabled: enabled,
phase: enabled ? "enabled" : "disabled")
}
}
func setTalkProviderSelection(_ rawValue: String) {
let selection = TalkModeProviderSelection.resolved(rawValue)
UserDefaults.standard.set(selection.rawValue, forKey: TalkModeProviderSelection.storageKey)
self.talkMode.applyProviderSelectionChanged()
}
func setTalkRealtimeVoiceSelection(_ rawValue: String) {
let voice = TalkModeRealtimeVoiceSelection.resolvedOverride(rawValue) ?? ""
UserDefaults.standard.set(voice, forKey: TalkModeRealtimeVoiceSelection.storageKey)
self.talkMode.applyProviderSelectionChanged()
}
func requestTalkPermissionUpgrade() {
guard let config = activeGatewayConnectConfig else {
self.talkMode.gatewayTalkPermissionState = .requestFailed("Gateway is not connected")
self.talkMode.statusText = "Gateway not connected"
return
}
GatewayDiagnostics.log("talk permission upgrade requested")
self.talkMode.gatewayTalkPermissionState = .requestingUpgrade
self.talkMode.statusText = "Requesting Talk approval"
self.forceOperatorTalkPermissionUpgradeRequest = true
self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
let sessionBox = config.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
Task { [weak self] in
guard let self else { return }
await self.operatorGateway.disconnect()
await MainActor.run {
self.startOperatorGatewayLoop(
url: config.url,
stableID: config.effectiveStableID,
token: config.token,
bootstrapToken: config.bootstrapToken,
password: config.password,
nodeOptions: config.nodeOptions,
sessionBox: sessionBox)
}
}
}
func pollTalkPermissionUpgrade() async {
guard self.talkMode.gatewayTalkPermissionState.isApprovalRequestInProgress else {
await self.talkMode.reloadConfig()
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "talk_permission_poll")
return
}
guard let cfg = activeGatewayConnectConfig else {
self.talkMode.gatewayTalkPermissionState = .requestFailed("Gateway is not connected")
self.talkMode.statusText = "Gateway not connected"
return
}
let now = Date()
if let lastTalkPermissionReconnectAttemptAt,
now.timeIntervalSince(lastTalkPermissionReconnectAttemptAt) < 6
{
return
}
lastTalkPermissionReconnectAttemptAt = now
GatewayDiagnostics.log("talk permission approval poll reconnect")
self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
ensureOperatorReconnectLoopIfNeeded()
if self.operatorGatewayTask == nil {
let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
startOperatorGatewayLoop(
url: cfg.url,
stableID: cfg.effectiveStableID,
token: cfg.token,
bootstrapToken: cfg.bootstrapToken,
password: cfg.password,
nodeOptions: cfg.nodeOptions,
sessionBox: sessionBox)
}
guard await waitForOperatorConnection(timeoutMs: 2500, pollMs: 250) else {
return
}
await self.talkMode.reloadConfig()
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "talk_permission_poll_connected")
}
func setTalkSpeakerphoneEnabled(_ enabled: Bool) {
UserDefaults.standard.set(enabled, forKey: TalkDefaults.speakerphoneEnabledKey)
self.talkMode.applyAudioRoutePreferenceChanged()
}
func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool {
guard mode != .off else {
self.reconcileSignificantLocationMonitoring(
mode: mode,
authorizationStatus: self.locationService.authorizationStatus())
return true
}
let status = await locationService.ensureAuthorization(mode: mode)
switch status {
case .authorizedAlways:
self.reconcileSignificantLocationMonitoring(mode: mode, authorizationStatus: status)
return true
case .authorizedWhenInUse:
self.reconcileSignificantLocationMonitoring(mode: mode, authorizationStatus: status)
return true
default:
self.reconcileSignificantLocationMonitoring(mode: mode, authorizationStatus: status)
return false
}
}
private func reconcileSignificantLocationMonitoring(
mode: OpenClawLocationMode,
authorizationStatus: CLAuthorizationStatus)
{
guard mode == .always, authorizationStatus == .authorizedAlways else {
self.locationService.setBackgroundLocationUpdatesEnabled(false)
self.locationService.stopMonitoringSignificantLocationChanges()
return
}
SignificantLocationMonitor.startIfNeeded(
locationService: self.locationService,
locationMode: mode,
gateway: self.nodeGateway,
beforeSend: { [weak self] in
await self?.handleSignificantLocationWakeIfNeeded()
})
}
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
private func refreshBrandingFromGateway(shouldApply: () -> Bool = { true }) async {
do {
guard let sourceGatewayID = self.chatTranscriptCacheGatewayID,
let sourceRoute = await operatorGateway.currentRoute(ifGatewayID: sourceGatewayID)
else { return }
let res = try await operatorGateway.request(
method: "config.get",
paramsJSON: "{}",
timeoutSeconds: 8,
ifCurrentRoute: sourceRoute)
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return }
let session = config["session"] as? [String: Any]
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
let scope = (session?["scope"] as? String) ?? "per-sender"
guard shouldApply(), self.chatTranscriptCacheGatewayID == sourceGatewayID else { return }
await MainActor.run {
self.mainSessionBaseKey = mainKey
self.gatewaySessionScope = scope
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
}
} catch {
if let gatewayError = error as? GatewayResponseError {
let lower = gatewayError.message.lowercased()
if lower.contains("unauthorized role") {
return
}
}
// ignore
}
}
private func refreshAgentsFromGateway(shouldApply: () -> Bool = { true }) async {
do {
guard let sourceGatewayID = self.chatTranscriptCacheGatewayID,
let sourceStore = self.makeChatOfflineStore(),
sourceStore.gatewayID == sourceGatewayID,
let sourceRoute = await operatorGateway.currentRoute(ifGatewayID: sourceGatewayID)
else { return }
let res = try await operatorGateway.request(
method: "agents.list",
paramsJSON: "{}",
timeoutSeconds: 8,
ifCurrentRoute: sourceRoute)
let decoded = try JSONDecoder().decode(AgentsListResult.self, from: res)
let routingIdentity = OpenClawChatSessionRoutingIdentity(
scope: decoded.scope.value as? String,
mainSessionKey: decoded.mainkey,
defaultAgentID: decoded.defaultid)
guard shouldApply(), self.chatTranscriptCacheGatewayID == sourceGatewayID else { return }
await MainActor.run {
self.gatewayDefaultAgentId = decoded.defaultid
self.gatewayAgents = decoded.agents
self.gatewaySessionScope = decoded.scope.value as? String
self.applyMainSessionKey(decoded.mainkey)
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if !selected.isEmpty, !decoded.agents.contains(where: { $0.id == selected }) {
self.selectedAgentId = nil
self.focusedChatSessionKey = nil
}
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
}
if let routingIdentity {
await sourceStore.storeSessionRoutingIdentity(routingIdentity)
}
} catch {
// Best-effort only.
}
}
func refreshGatewayOverviewIfConnected() async {
guard await isOperatorConnected() else { return }
if self.foregroundGatewayResumeCheckInFlight {
GatewayDiagnostics.log("gateway overview refresh deferred reason=foreground_resume_check")
try? await Task.sleep(
nanoseconds: UInt64(Self.foregroundResumeHealthTimeoutSeconds) * 1_000_000_000)
guard await isOperatorConnected(), !self.foregroundGatewayResumeCheckInFlight else { return }
}
await self.refreshBrandingFromGateway()
await self.refreshAgentsFromGateway()
}
func setSelectedAgentId(_ agentId: String?) {
let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let nextSelectedAgentId = trimmed.isEmpty ? nil : trimmed
let currentSelectedAgentId = self.selectedAgentId?.trimmingCharacters(in: .whitespacesAndNewlines)
let selectedAgentChanged = currentSelectedAgentId != nextSelectedAgentId
let stableID = (connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if stableID.isEmpty {
self.selectedAgentId = nextSelectedAgentId
} else {
self.selectedAgentId = nextSelectedAgentId
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId)
}
if selectedAgentChanged {
self.focusedChatSessionKey = nil
}
self.talkMode.updateMainSessionKey(mainSessionKey)
self.homeCanvasRevision &+= 1
if let relay = ShareGatewayRelaySettings.loadConfig() {
ShareGatewayRelaySettings.saveConfig(
ShareGatewayRelayConfig(
gatewayURLString: relay.gatewayURLString,
gatewayStableID: relay.gatewayStableID,
token: relay.token,
password: relay.password,
sessionKey: mainSessionKey,
deliveryChannel: self.shareDeliveryChannel,
deliveryTo: self.shareDeliveryTo))
}
}
func setGlobalWakeWords(_ words: [String]) async {
let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words)
struct Payload: Codable {
var triggers: [String]
}
let payload = Payload(triggers: sanitized)
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else { return }
do {
_ = try await self.operatorGateway.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12)
} catch {
// Best-effort only.
}
}
private func startVoiceWakeSync(shouldContinue: @escaping @MainActor @Sendable () -> Bool = { true }) async {
guard shouldContinue() else { return }
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = Task { [weak self] in
guard let self else { return }
if !self.isGatewayHealthMonitorDisabled() {
await self.refreshWakeWordsFromGateway(shouldApply: shouldContinue)
}
guard shouldContinue() else { return }
guard let operatorRoute = await self.operatorGateway.currentRoute(), shouldContinue() else { return }
let stream = await self.operatorGateway.subscribeServerEvents(bufferingNewest: 200)
for await evt in stream {
if Task.isCancelled || !shouldContinue() { return }
guard evt.payload != nil else { continue }
await self.handleOperatorGatewayServerEvent(
evt,
expectedOperatorRoute: operatorRoute,
shouldContinue: shouldContinue)
}
}
}
private func handleOperatorGatewayServerEvent(
_ evt: EventFrame,
expectedOperatorRoute: GatewayNodeSessionRoute? = nil,
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
{
guard shouldContinue(), let payload = evt.payload else { return }
switch evt.event {
case "voicewake.changed":
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
case "talk.mode":
struct Payload: Decodable {
var enabled: Bool
var phase: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
case ExecApprovalNotificationBridge.requestedKind:
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
await self.presentNotificationPermissionGuidanceForExecApprovalIfNeeded(
approvalId: approvalId,
shouldApply: shouldContinue)
guard shouldContinue() else { return }
await presentExecApprovalGatewayEventPrompt(
approvalId: approvalId,
expectedOperatorRoute: expectedOperatorRoute,
shouldContinue: shouldContinue)
case ExecApprovalNotificationBridge.resolvedKind:
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
await handleExecApprovalResolvedForCurrentGateway(
approvalId: approvalId,
shouldContinue: shouldContinue)
default:
return
}
}
private nonisolated static func execApprovalEventID(from payload: AnyCodable) -> String? {
guard let decoded = try? GatewayPayloadDecoding.decode(
payload,
as: ExecApprovalGatewayEventPayload.self)
else {
return nil
}
let approvalId = decoded.id.trimmingCharacters(in: .whitespacesAndNewlines)
return approvalId.isEmpty ? nil : approvalId
}
private func applyTalkModeSync(enabled: Bool, phase: String?) {
_ = phase
guard self.talkMode.isEnabled != enabled else { return }
self.setTalkEnabled(enabled)
}
private func pushTalkModeToGateway(enabled: Bool, phase: String?) async {
guard await isOperatorConnected() else { return }
struct TalkModePayload: Encodable {
var enabled: Bool
var phase: String?
}
let payload = TalkModePayload(enabled: enabled, phase: phase)
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else { return }
_ = try? await self.operatorGateway.request(
method: "talk.mode",
paramsJSON: json,
timeoutSeconds: 8)
}
private func startGatewayHealthMonitor() {
self.gatewayHealthMonitorDisabled = false
self.gatewayHealthMonitor.start(
check: { [weak self] in
guard let self else { return false }
if await MainActor.run(body: { self.isGatewayHealthMonitorDisabled() }) { return true }
do {
let data = try await self.operatorGateway.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 {
if let gatewayError = error as? GatewayResponseError {
let lower = gatewayError.message.lowercased()
if lower.contains("unauthorized role") || lower.contains("missing scope") {
await self.setGatewayHealthMonitorDisabled(true)
return true
}
}
return false
}
},
onFailure: { [weak self] _ in
guard let self else { return }
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
await MainActor.run {
guard !self.isLocalGatewayFixtureEnabled else { return }
self.setOperatorConnected(false)
self.gatewayConnected = false
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)
}
})
}
private func stopGatewayHealthMonitor() {
self.gatewayHealthMonitor.stop()
}
private func handleInvoke(
_ req: BridgeInvokeRequest,
gatewayStableID: String? = nil) async -> BridgeInvokeResponse
{
let command = req.command
if self.isBackgrounded, self.isBackgroundRestricted(command) {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .backgroundUnavailable,
message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground"))
}
if command.hasPrefix("camera."), !isCameraEnabled() {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "CAMERA_DISABLED: enable Camera in iOS Settings → Camera → Allow Camera"))
}
do {
return try await self.capabilityRouter.handle(
Self.scopedWatchNotificationRequest(req, gatewayStableID: gatewayStableID))
} 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.") {
let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
showCameraHUD(text: text, kind: .error, autoHideSeconds: 2.2)
}
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .unavailable, message: error.localizedDescription))
}
}
private static func scopedWatchNotificationRequest(
_ req: BridgeInvokeRequest,
gatewayStableID: String?) -> BridgeInvokeRequest
{
guard req.command == OpenClawWatchCommand.notify.rawValue,
var params = try? decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON)
else { return req }
// Gateway identity comes from the installed node route, never the request payload.
params.gatewayStableID = trimmedOrNil(gatewayStableID)
guard let paramsJSON = try? encodePayload(params) else { return req }
return BridgeInvokeRequest(
type: req.type,
id: req.id,
command: req.command,
paramsJSON: paramsJSON,
nodeId: req.nodeId)
}
private func isBackgroundRestricted(_ command: String) -> Bool {
command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.") ||
command.hasPrefix("talk.")
}
private func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let mode = locationMode()
guard mode != .off else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "LOCATION_DISABLED: enable Location in Settings"))
}
if self.isBackgrounded, mode != .always {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .backgroundUnavailable,
message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always"))
}
let params = (try? Self.decodeParams(OpenClawLocationGetParams.self, from: req.paramsJSON)) ??
OpenClawLocationGetParams()
let desired = params.desiredAccuracy ??
(isLocationPreciseEnabled() ? .precise : .balanced)
let status = self.locationService.authorizationStatus()
if status != .authorizedAlways, status != .authorizedWhenInUse {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
}
if self.isBackgrounded, status != .authorizedAlways {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access"))
}
let location = try await locationService.currentLocation(
params: params,
desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs)
let isPrecise = self.locationService.accuracyAuthorization() == .fullAccuracy
let payload = OpenClawLocationPayload(
lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
accuracyMeters: location.horizontalAccuracy,
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
speedMps: location.speed >= 0 ? location.speed : nil,
headingDeg: location.course >= 0 ? location.course : nil,
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
isPrecise: isPrecise,
source: nil)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
}
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) ?? ""
if url.isEmpty {
self.screen.presentDefaultCanvas()
} else {
self.screen.present(urlString: url)
}
return BridgeInvokeResponse(id: req.id, ok: true)
case OpenClawCanvasCommand.hide.rawValue:
self.screen.hideCanvas()
return BridgeInvokeResponse(id: req.id, ok: true)
case OpenClawCanvasCommand.navigate.rawValue:
let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON)
let trimmedURL = params.url.trimmingCharacters(in: .whitespacesAndNewlines)
self.screen.present(urlString: trimmedURL)
return BridgeInvokeResponse(id: req.id, ok: true)
case OpenClawCanvasCommand.evalJS.rawValue:
let params = try Self.decodeParams(OpenClawCanvasEvalParams.self, from: req.paramsJSON)
let result = try await screen.eval(javaScript: params.javaScript)
let payload = try Self.encodePayload(["result": result])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case OpenClawCanvasCommand.snapshot.rawValue:
let params = try? Self.decodeParams(OpenClawCanvasSnapshotParams.self, from: req.paramsJSON)
let format = params?.format ?? .jpeg
let maxWidth: CGFloat? = {
if let raw = params?.maxWidth, raw > 0 { return CGFloat(raw) }
// Keep default snapshots comfortably below the gateway client's maxPayload.
// For full-res, clients should explicitly request a larger maxWidth.
return switch format {
case .png: 900
case .jpeg: 1600
}
}()
let base64 = try await screen.snapshotBase64(
maxWidth: maxWidth,
format: format,
quality: params?.quality)
let payload = try Self.encodePayload([
"format": format == .jpeg ? "jpeg" : "png",
"base64": base64,
])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
}
}
private func handleCanvasA2UIInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let command = req.command
switch command {
case OpenClawCanvasA2UICommand.reset.rawValue:
switch await ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
case .ready:
break
case .hostUnavailable:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: bundled A2UI host not reachable"))
}
let json = try await screen.eval(javaScript: """
(() => {
const host = globalThis.openclawA2UI;
if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" });
return JSON.stringify(host.reset());
})()
""")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case OpenClawCanvasA2UICommand.push.rawValue, OpenClawCanvasA2UICommand.pushJSONL.rawValue:
let messages: [OpenClawKit.AnyCodable]
if command == OpenClawCanvasA2UICommand.pushJSONL.rawValue {
let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
} else {
do {
let params = try Self.decodeParams(OpenClawCanvasA2UIPushParams.self, from: req.paramsJSON)
messages = params.messages
} catch {
// Be forgiving: some clients still send JSONL payloads to `canvas.a2ui.push`.
let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
}
}
switch await ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
case .ready:
break
case .hostUnavailable:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: bundled A2UI host not reachable"))
}
let messagesJSON = try OpenClawCanvasA2UIJSONL.encodeMessagesJSONArray(messages)
let js = """
(() => {
try {
const host = globalThis.openclawA2UI;
if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" });
const messages = \(messagesJSON);
return JSON.stringify(host.applyMessages(messages));
} catch (e) {
return JSON.stringify({ ok: false, error: String(e?.message ?? e) });
}
})()
"""
let resultJSON = try await screen.eval(javaScript: js)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
}
}
private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
switch req.command {
case OpenClawCameraCommand.list.rawValue:
let devices = await camera.listDevices()
struct Payload: Codable {
var devices: [CameraController.CameraDeviceInfo]
}
let payload = try Self.encodePayload(Payload(devices: devices))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case OpenClawCameraCommand.snap.rawValue:
showCameraHUD(text: "Taking photo…", kind: .photo)
triggerCameraFlash()
let params = (try? Self.decodeParams(OpenClawCameraSnapParams.self, from: req.paramsJSON)) ??
OpenClawCameraSnapParams()
let res = try await camera.snap(params: params)
struct Payload: Codable {
var format: String
var base64: String
var width: Int
var height: Int
}
let payload = try Self.encodePayload(Payload(
format: res.format,
base64: res.base64,
width: res.width,
height: res.height))
showCameraHUD(text: "Photo captured", kind: .success, autoHideSeconds: 1.6)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case OpenClawCameraCommand.clip.rawValue:
let params = (try? Self.decodeParams(OpenClawCameraClipParams.self, from: req.paramsJSON)) ??
OpenClawCameraClipParams()
let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false
defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) }
showCameraHUD(text: "Recording…", kind: .recording)
let res = try await camera.clip(params: params)
struct Payload: Codable {
var format: String
var base64: String
var durationMs: Int
var hasAudio: Bool
}
let payload = try Self.encodePayload(Payload(
format: res.format,
base64: res.base64,
durationMs: res.durationMs,
hasAudio: res.hasAudio))
showCameraHUD(text: "Clip captured", kind: .success, autoHideSeconds: 1.8)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
}
}
private func handleScreenRecordInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = (try? Self.decodeParams(OpenClawScreenRecordParams.self, from: req.paramsJSON)) ??
OpenClawScreenRecordParams()
if let format = params.format, format.lowercased() != "mp4" {
throw NSError(domain: "Screen", code: 30, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: screen format must be mp4",
])
}
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
self.screenRecordActive = true
defer { self.screenRecordActive = false }
let path = try await screenRecorder.record(
screenIndex: params.screenIndex,
durationMs: params.durationMs,
fps: params.fps,
includeAudio: params.includeAudio,
outPath: nil)
defer { try? FileManager().removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path))
struct Payload: Codable {
var format: String
var base64: String
var durationMs: Int?
var fps: Double?
var screenIndex: Int?
var hasAudio: Bool
}
let payload = try Self.encodePayload(Payload(
format: "mp4",
base64: data.base64EncodedString(),
durationMs: params.durationMs,
fps: params.fps,
screenIndex: params.screenIndex,
hasAudio: params.includeAudio ?? true))
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 status = await notificationAuthorizationStatus()
guard Self.isNotificationAuthorizationAllowed(status) else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications"))
}
let addResult = await 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 shouldSpeak = params.speak ?? true
let status = await notificationAuthorizationStatus()
let notificationsAllowed = Self.isNotificationAuthorizationAllowed(status)
if !notificationsAllowed, !shouldSpeak {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications"))
}
let messageId = UUID().uuidString
if notificationsAllowed {
let addResult = await 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 shouldSpeak {
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 notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
let result = await runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in
await notificationCenter.authorizationStatus()
}
switch result {
case let .success(status):
return status
case .failure:
return .denied
}
}
private static func isNotificationAuthorizationAllowed(
_ status: NotificationAuthorizationStatus) -> Bool
{
switch status {
case .authorized, .provisional, .ephemeral:
true
case .denied, .notDetermined:
false
}
}
private func presentNotificationPermissionGuidanceForExecApprovalIfNeeded(
approvalId: String,
shouldApply: @MainActor @Sendable () -> Bool = { true }) async
{
guard shouldApply(), !self.execApprovalNotificationGuidanceSuppressed else { return }
let status = await notificationAuthorizationStatus()
guard shouldApply(), !Self.isNotificationAuthorizationAllowed(status) else { return }
self.pendingNotificationPermissionGuidancePrompt =
NotificationPermissionGuidancePrompt(approvalId: approvalId)
}
var execApprovalNotificationGuidanceSuppressed: Bool {
UserDefaults.standard.bool(forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
}
func dismissNotificationPermissionGuidancePrompt(suppressFuture: Bool) {
if suppressFuture {
UserDefaults.standard.set(true, forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
}
self.pendingNotificationPermissionGuidancePrompt = nil
}
func resetExecApprovalNotificationGuidanceSuppression() {
UserDefaults.standard.removeObject(forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
}
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 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 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 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 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 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 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 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 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 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 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 {
if req.command == OpenClawTalkCommand.pttOnce.rawValue {
try self.rejectTalkCaptureWhileVoiceNoteActive()
self.acquirePttVoiceWakeLease()
defer { self.releasePttVoiceWakeLease() }
let payload = try await talkMode.runPushToTalkOnce()
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
}
await self.acquireTalkInvoke()
defer { self.releaseTalkInvoke() }
switch req.command {
case OpenClawTalkCommand.pttStart.rawValue:
try self.rejectTalkCaptureWhileVoiceNoteActive()
let acquiredLease = !self.pttSessionOwnsVoiceWakeLease
if acquiredLease {
self.acquirePttVoiceWakeLease()
self.pttSessionOwnsVoiceWakeLease = true
}
let payload: OpenClawTalkPTTStartPayload
do {
payload = try await self.talkMode.beginPushToTalk()
} catch {
if acquiredLease {
self.pttSessionOwnsVoiceWakeLease = false
self.releasePttVoiceWakeLease()
}
throw error
}
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case OpenClawTalkCommand.pttStop.rawValue:
let payload = await talkMode.endPushToTalk()
if self.pttSessionOwnsVoiceWakeLease {
self.pttSessionOwnsVoiceWakeLease = false
self.releasePttVoiceWakeLease()
}
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case OpenClawTalkCommand.pttCancel.rawValue:
let payload = await talkMode.cancelPushToTalk()
if self.pttSessionOwnsVoiceWakeLease {
self.pttSessionOwnsVoiceWakeLease = false
self.releasePttVoiceWakeLease()
}
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 rejectTalkCaptureWhileVoiceNoteActive() throws {
// Remote PTT bypasses the Chat Talk toggle. Preserve the user's draft;
// Talk must not reconfigure AVAudioSession while its recorder owns it.
guard self.voiceNoteRecorder.isRecording || self.voiceNoteRecorder.isRequestingPermission else { return }
throw NSError(domain: "TalkMode", code: 8, userInfo: [
NSLocalizedDescriptionKey: "Finish or cancel the active voice note before starting push-to-talk.",
])
}
private func acquirePttVoiceWakeLease() {
if self.pttVoiceWakeLeaseCount == 0 {
self.pttVoiceWakeWasSuspended = self.voiceWake.suspendForExternalAudioCapture()
}
self.pttVoiceWakeLeaseCount += 1
}
private func releasePttVoiceWakeLease() {
guard self.pttVoiceWakeLeaseCount > 0 else { return }
self.pttVoiceWakeLeaseCount -= 1
guard self.pttVoiceWakeLeaseCount == 0 else { return }
// Overlapping one-shot and session PTT captures share one Voice Wake suspension.
// Resume only after the final owner releases it or microphone capture can overlap.
self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeWasSuspended)
self.pttVoiceWakeWasSuspended = false
}
private func acquireTalkInvoke() async {
if !self.talkInvokeInFlight {
self.talkInvokeInFlight = true
return
}
await withCheckedContinuation { continuation in
self.talkInvokeWaiters.append(continuation)
}
}
private func releaseTalkInvoke() {
guard !self.talkInvokeWaiters.isEmpty else {
self.talkInvokeInFlight = false
return
}
self.talkInvokeWaiters.removeFirst().resume()
}
}
extension NodeAppModel {
/// Central registry for node invoke routing to keep commands in one place.
private 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([
OpenClawWatchCommand.status.rawValue,
OpenClawWatchCommand.notify.rawValue,
]) { [weak self] req in
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
return try await self.handleWatchInvoke(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)
}
private func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
switch req.command {
case OpenClawWatchCommand.status.rawValue:
let status = await watchMessagingService.status()
let payload = OpenClawWatchStatusPayload(
supported: status.supported,
paired: status.paired,
appInstalled: status.appInstalled,
reachable: status.reachable,
activationState: status.activationState)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case OpenClawWatchCommand.notify.rawValue:
let params = try Self.decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON)
let normalizedParams = Self.normalizeWatchNotifyParams(params)
let title = normalizedParams.title
let body = normalizedParams.body
if title.isEmpty, body.isEmpty {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .invalidRequest,
message: "INVALID_REQUEST: empty watch notification"))
}
do {
let gatewayStableID = currentWatchChatGatewayStableID()
self.watchMessageOutbox.recordPromptRoute(
promptID: normalizedParams.promptId,
gatewayStableID: gatewayStableID)
let result = try await watchMessagingService.sendNotification(
id: req.id,
params: normalizedParams,
gatewayStableID: gatewayStableID)
if result.queuedForDelivery || !result.deliveredImmediately {
let invokeID = req.id
Task { @MainActor in
await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded(
invokeID: invokeID,
params: normalizedParams,
gatewayStableID: gatewayStableID,
sendResult: result)
}
}
let payload = OpenClawWatchNotifyPayload(
deliveredImmediately: result.deliveredImmediately,
queuedForDelivery: result.queuedForDelivery,
transport: result.transport)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
} catch {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: error.localizedDescription))
}
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
}
}
private func locationMode() -> OpenClawLocationMode {
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
return OpenClawLocationMode(rawValue: raw) ?? .off
}
private func isLocationPreciseEnabled() -> Bool {
// iOS settings now expose a single location mode control.
// Default location tool precision stays high unless a command explicitly requests balanced.
true
}
fileprivate static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
guard let json, let data = json.data(using: .utf8) else {
throw NSError(domain: "Gateway", code: 20, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
])
}
return try JSONDecoder().decode(type, from: data)
}
fileprivate static func encodePayload(_ obj: some Encodable) throws -> String {
let data = try JSONEncoder().encode(obj)
guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8",
])
}
return json
}
private func isCameraEnabled() -> Bool {
// Default-on: if the key doesn't exist yet, treat it as enabled.
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
return UserDefaults.standard.bool(forKey: "camera.enabled")
}
private func triggerCameraFlash() {
self.cameraFlashNonce &+= 1
}
private func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
self.cameraHUDDismissTask?.cancel()
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
self.cameraHUDText = text
self.cameraHUDKind = kind
}
guard let autoHideSeconds else { return }
self.cameraHUDDismissTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(autoHideSeconds * 1_000_000_000))
withAnimation(.easeOut(duration: 0.25)) {
self.cameraHUDText = nil
self.cameraHUDKind = nil
}
}
}
}
extension NodeAppModel {
var mainSessionKey: String {
let base = SessionKey.normalizeMainKey(self.mainSessionBaseKey)
let agentId = (selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let defaultId = (gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base }
return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base)
}
var chatSessionKey: String {
if let focused = focusedChatSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines),
!focused.isEmpty
{
return focused
}
return self.defaultChatSessionKey
}
var defaultChatSessionKey: String {
// Keep chat aligned with the gateway's resolved main session key.
// A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI.
self.mainSessionKey
}
func openChat(sessionKey: String?, unread: Bool = false) {
self.focusChatSession(sessionKey)
let activeKey = self.chatSessionKey
self.openedChatSessionKey = activeKey
if self.readAcknowledgedChatSessionKey != activeKey {
self.readAcknowledgedChatSessionKey = nil
}
if unread {
self.acknowledgeChatSessionReadIfNeeded(activeKey)
}
self.openChatRequestID &+= 1
}
/// One acknowledgement per unread episode: the pending flag clears when a fresh
/// snapshot confirms the read (unread != true), so a run finishing while the
/// session stays open re-acknowledges without patch loops (the gateway stamps
/// lastReadAt server-side, which makes the exchange convergent).
func reconcileChatSessionReadState(_ entries: [OpenClawChatSessionEntry]) {
guard let openedKey = self.openedChatSessionKey,
let entry = entries.first(where: { $0.key == openedKey })
else { return }
if entry.unread != true {
if self.readAcknowledgedChatSessionKey == openedKey {
self.readAcknowledgedChatSessionKey = nil
}
return
}
// Only the currently open chat auto-acknowledges fresh unread episodes.
guard openedKey == self.chatSessionKey else { return }
self.acknowledgeChatSessionReadIfNeeded(openedKey)
}
private func acknowledgeChatSessionReadIfNeeded(_ sessionKey: String) {
guard self.readAcknowledgedChatSessionKey != sessionKey else { return }
self.readAcknowledgedChatSessionKey = sessionKey
let transport = self.makeChatTransport()
Task { @MainActor in
do {
try await transport.patchSession(
key: sessionKey,
label: nil,
category: nil,
pinned: nil,
archived: nil,
unread: false)
} catch {
if self.readAcknowledgedChatSessionKey == sessionKey {
self.readAcknowledgedChatSessionKey = nil
}
}
}
}
func focusChatSession(_ sessionKey: String?) {
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
self.focusedChatSessionKey = trimmed.isEmpty ? nil : trimmed
self.talkMode.updateMainSessionKey(self.chatSessionKey)
}
var chatAgentId: String {
if let sessionAgentId = SessionKey.agentId(from: chatSessionKey) {
return sessionAgentId
}
return self.selectedOrDefaultAgentId
}
/// Verified routing owner for sends. Unlike `chatAgentId`, this has no
/// display fallback: a cold offline start must wait for persisted or
/// gateway-provided ownership before it can queue durable work.
var chatDeliveryAgentId: String? {
if let sessionAgentId = SessionKey.agentId(from: chatSessionKey) {
return sessionAgentId.lowercased()
}
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if !selected.isEmpty { return selected.lowercased() }
let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return defaultId.isEmpty ? nil : defaultId.lowercased()
}
var chatSessionRoutingContract: String? {
OpenClawChatSessionRoutingContract.make(
scope: self.gatewaySessionScope,
mainKey: self.mainSessionBaseKey,
defaultAgentID: self.gatewayDefaultAgentId)
}
var chatAgentName: String {
self.agentDisplayName(for: self.chatAgentId, fallback: "Main")
}
var chatAgentAvatarURL: String? {
self.agentIdentityValue(for: self.chatAgentId, key: "avatarUrl")
}
var chatAgentAvatarText: String? {
self.agentIdentityValue(for: self.chatAgentId, key: "emoji")
}
var activeAgentName: String {
self.agentDisplayName(for: self.selectedOrDefaultAgentId, fallback: "Main")
}
private var selectedOrDefaultAgentId: String {
let agentId = (selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let defaultId = (gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return agentId.isEmpty ? defaultId : agentId
}
private func agentDisplayName(for agentId: String, fallback: String) -> String {
let resolvedId = agentId.trimmingCharacters(in: .whitespacesAndNewlines)
if resolvedId.isEmpty { return fallback }
if let match = gatewayAgents.first(where: { $0.id == resolvedId }) {
let name = (match.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? match.id : name
}
return resolvedId
}
private func agentIdentityValue(for agentId: String, key: String) -> String? {
let resolvedId = agentId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !resolvedId.isEmpty,
let match = gatewayAgents.first(where: { $0.id == resolvedId }),
let rawValue = match.identity?[key]?.value as? String
else {
return nil
}
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
func connectToGateway(
url: URL,
gatewayStableID: String,
tls: GatewayTLSParams?,
token: String?,
bootstrapToken: String?,
password: String?,
connectOptions: GatewayConnectOptions,
forceReconnect: Bool = false)
{
let stableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
let effectiveStableID = stableID.isEmpty ? url.absoluteString : stableID
let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
let nextConfig = GatewayConnectConfig(
url: url,
stableID: stableID,
tls: tls,
token: token,
bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions)
let previousGatewayStableID = self.activeGatewayConnectConfig?.effectiveStableID
?? self.connectedGatewayID
let targetChanged = previousGatewayStableID.map {
!$0.isEmpty && $0 != effectiveStableID
} ?? false
let hasForeignCachedApproval = self.watchExecApprovalPromptsByID.values.contains {
$0.gatewayStableID != effectiveStableID
}
if hasForeignCachedApproval || targetChanged {
// Approval IDs are gateway-local authorization handles. A target switch must remove
// every cached surface so stale prompts cannot authorize work on the replacement.
invalidateExecApprovalSurfacesForGatewayChange()
}
let operatorLoopRequired = shouldStartOperatorGatewayLoop(
token: token,
bootstrapToken: bootstrapToken,
password: password,
deviceAuthGatewayID: connectOptions.deviceAuthGatewayID ?? effectiveStableID,
allowStoredDeviceAuth: connectOptions.allowStoredDeviceAuth)
if let activeConfig = activeGatewayConnectConfig,
activeConfig.hasSameConnectionInputs(as: nextConfig),
nodeGatewayTask != nil,
operatorGatewayTask != nil || !operatorLoopRequired,
!forceReconnect
{
self.gatewayAutoReconnectEnabled = true
return
}
self.gatewayRouteGeneration &+= 1
self.activeGatewayConnectConfig = nextConfig
prepareForGatewayConnect(stableID: effectiveStableID)
if operatorLoopRequired {
startOperatorGatewayLoop(
url: url,
stableID: effectiveStableID,
token: token,
bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions,
sessionBox: sessionBox)
} else {
self.operatorGatewayTask = nil
Task { await self.operatorGateway.disconnect() }
}
startNodeGatewayLoop(
url: url,
stableID: effectiveStableID,
token: token,
bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions,
sessionBox: sessionBox)
}
/// Preferred entry-point: apply a single config object and start both sessions.
func applyGatewayConnectConfig(
_ cfg: GatewayConnectConfig,
forceReconnect: Bool = false)
{
let generation = self.beginGatewayConnectAttempt()
self.applyGatewayConnectConfig(
cfg,
forceReconnect: forceReconnect,
expectedGeneration: generation)
}
/// Applies queued work only while its originating gateway attempt is still current.
func applyGatewayConnectConfig(
_ cfg: GatewayConnectConfig,
forceReconnect: Bool = false,
expectedGeneration: UInt64)
{
guard expectedGeneration == self.gatewayConnectGeneration else { return }
self.isAppleReviewDemoModeEnabled = false
self.isScreenshotFixtureModeEnabled = false
self.connectToGateway(
url: cfg.url,
// Preserve the caller-provided stableID (may be empty) and let connectToGateway
// derive the effective stable id consistently for persistence keys.
gatewayStableID: cfg.stableID,
tls: cfg.tls,
token: cfg.token,
bootstrapToken: cfg.bootstrapToken,
password: cfg.password,
connectOptions: cfg.nodeOptions,
forceReconnect: forceReconnect)
}
func beginGatewayConnectAttempt() -> UInt64 {
self.gatewayConnectGeneration &+= 1
return self.gatewayConnectGeneration
}
private func invalidateGatewayConnectAttempts() {
self.gatewayConnectGeneration &+= 1
}
var hasGatewaySessionResetInFlight: Bool {
self.gatewaySessionResetTask != nil
}
func waitForGatewaySessionResetIfNeeded() async {
while let gatewaySessionResetTask {
await gatewaySessionResetTask.value
}
}
@discardableResult
private func beginGatewaySessionReset(chainingAfterExisting: Bool = false) -> Task<Void, Never> {
let previousResetTask = self.gatewaySessionResetTask
if let previousResetTask, !chainingAfterExisting {
return previousResetTask
}
let nodeGatewayTask = self.nodeGatewayTask
let operatorGatewayTask = self.operatorGatewayTask
self.talkMode.updateGatewayConnected(false)
self.gatewayRouteGeneration &+= 1
nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
let operatorGateway = self.operatorGateway
let nodeGateway = self.nodeGateway
self.gatewaySessionResetGeneration &+= 1
let resetGeneration = self.gatewaySessionResetGeneration
// Disconnect first so canceled receive loops can unwind, then keep the barrier until their
// cleanup exits. A stale loop may otherwise disconnect a replacement session after reset.
let gatewaySessionResetTask = Task {
await previousResetTask?.value
await operatorGateway.disconnect()
await nodeGateway.disconnect()
await operatorGatewayTask?.value
await nodeGatewayTask?.value
if self.gatewaySessionResetGeneration == resetGeneration {
self.gatewaySessionResetTask = nil
}
}
self.gatewaySessionResetTask = gatewaySessionResetTask
return gatewaySessionResetTask
}
func resetGatewaySessionsForForcedReconnect() async {
await self.beginGatewaySessionReset().value
}
func resetGatewaySessionsForTargetSwitch() async {
// A target awaiting TLS trust must not retain a reconnect route to the previous gateway.
invalidateExecApprovalSurfacesForGatewayChange()
self.invalidateGatewayConnectAttempts()
self.disableGatewayAutoReconnect()
self.activeGatewayConnectConfig = nil
ShareGatewayRelaySettings.clearConfig()
await self.resetGatewaySessionsForForcedReconnect()
guard !self.gatewayAutoReconnectEnabled, self.activeGatewayConnectConfig == nil else { return }
// A canceled loop may have persisted its reconnect flag and relay config while teardown was in flight.
self.disableGatewayAutoReconnect()
ShareGatewayRelaySettings.clearConfig()
self.gatewayHealthMonitor.stop()
self.gatewayStatusText = "Offline"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil
self.gatewayConnected = false
setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
}
private func restartGatewaySessionsAfterForegroundStaleConnection() async {
guard self.gatewayAutoReconnectEnabled, let cfg = activeGatewayConnectConfig else { return }
let generation = self.gatewayConnectGeneration
await self.resetGatewaySessionsForForcedReconnect()
guard generation == self.gatewayConnectGeneration,
self.gatewayAutoReconnectEnabled,
self.activeGatewayConnectConfig?.hasSameConnectionInputs(as: cfg) == true,
self.nodeGatewayTask == nil,
self.operatorGatewayTask == nil
else { return }
guard !self.isLocalGatewayFixtureEnabled else { return }
setOperatorConnected(false)
self.gatewayConnected = false
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)
self.applyGatewayConnectConfig(
cfg,
forceReconnect: true,
expectedGeneration: generation)
}
func disconnectGateway() {
self.disconnectGateway(disablePersistedAutoConnect: true, invalidateConnectAttempts: true)
}
func suspendGatewayForTargetReview() {
// Target review pauses live reconnects without changing the user's launch preference.
self.disconnectGateway(disablePersistedAutoConnect: false, invalidateConnectAttempts: true)
}
/// A replacement target may already own the connect generation while the forgotten route is live.
/// Preserve that generation so teardown cannot strand the replacement offline.
func disconnectForgottenGateway(preservingPendingConnectAttempt: Bool) {
self.disconnectGateway(
disablePersistedAutoConnect: !preservingPendingConnectAttempt,
invalidateConnectAttempts: !preservingPendingConnectAttempt)
}
private func disconnectGateway(
disablePersistedAutoConnect: Bool,
invalidateConnectAttempts: Bool)
{
invalidateExecApprovalSurfacesForGatewayChange()
if invalidateConnectAttempts {
self.invalidateGatewayConnectAttempts()
}
self.isAppleReviewDemoModeEnabled = false
self.isScreenshotFixtureModeEnabled = false
if disablePersistedAutoConnect {
self.disableGatewayAutoReconnect()
} else {
self.gatewayAutoReconnectEnabled = false
}
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
// Publish teardown through the shared barrier before returning. A replacement connect
// must await old loop cleanup instead of racing this synchronous UI action.
_ = self.beginGatewaySessionReset(chainingAfterExisting: true)
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
LiveActivityManager.shared.endActivity(reason: "manual_disconnect")
self.gatewayHealthMonitor.stop()
self.gatewayStatusText = "Offline"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil
self.activeGatewayConnectConfig = nil
self.gatewayConnected = false
setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
self.talkMode.updateMainSessionKey(self.mainSessionKey)
ShareGatewayRelaySettings.clearConfig()
showLocalCanvasOnDisconnect()
}
private func disableGatewayAutoReconnect() {
// Runtime teardown and persisted startup routing must move together. Otherwise a relaunch
// during target review silently reconnects the gateway the user just left.
self.gatewayAutoReconnectEnabled = false
UserDefaults.standard.set(false, forKey: "gateway.autoconnect")
}
}
extension NodeAppModel {
func resumeGatewayAfterTargetReview(_ config: GatewayConnectConfig) {
let generation = self.beginGatewayConnectAttempt()
self.gatewayStatusText = "Connecting…"
// Reapply the exact suspended route only after teardown; a newer target invalidates the generation.
Task { [weak self] in
guard let self else { return }
await self.waitForGatewaySessionResetIfNeeded()
guard generation == self.gatewayConnectGeneration else { return }
self.applyGatewayConnectConfig(config, expectedGeneration: generation)
}
}
private func prepareForGatewayConnect(stableID: String) {
self.isAppleReviewDemoModeEnabled = false
self.isScreenshotFixtureModeEnabled = false
self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
self.credentialHandoffFailureGeneration = nil
self.nodeGatewayTask?.cancel()
self.operatorGatewayTask?.cancel()
self.gatewayHealthMonitor.stop()
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.connectedGatewayID = stableID
self.gatewayConnected = false
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
LiveActivityManager.shared.endActivity(reason: "new_gateway_connect")
self.mainSessionBaseKey = "main"
self.gatewaySessionScope = nil
self.gatewayDefaultAgentId = nil
self.gatewayAgents = []
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
self.focusedChatSessionKey = nil
self.homeCanvasRevision &+= 1
self.apnsLastRegisteredTokenHex = nil
self.apnsLastRegisteredGatewayStableID = nil
Task { [weak self] in
await self?.restoreChatSessionRoutingIdentityIfNeeded()
}
}
private func clearGatewayConnectionProblem() {
if let operatorGatewayProblem {
self.lastGatewayProblem = operatorGatewayProblem
if operatorGatewayProblem.needsPairingApproval {
self.gatewayPairingPaused = true
self.gatewayPairingRequestId = operatorGatewayProblem.requestId
} else {
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
}
return
}
self.lastGatewayProblem = nil
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
}
func beginGatewayPreconnectVerification(statusText: String) {
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.gatewayStatusText = statusText
}
private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
guard !self.isLocalGatewayFixtureEnabled else { return }
self.lastGatewayProblem = problem
self.gatewayStatusText = problem.statusText
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
showLocalCanvasOnDisconnect()
if problem.pauseReconnect {
self.gatewayAutoReconnectEnabled = false
}
if problem.needsPairingApproval {
self.gatewayPairingPaused = true
self.gatewayPairingRequestId = problem.requestId
} else {
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
}
if problem.needsPairingApproval || problem.pauseReconnect {
LiveActivityManager.shared.showAttention(
statusText: problem.needsPairingApproval ? "Approval needed" : "Action required",
agentName: self.activeAgentName,
sessionKey: self.mainSessionKey)
}
}
private func applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
guard !self.isLocalGatewayFixtureEnabled else { return }
self.operatorGatewayProblem = problem
self.lastGatewayProblem = problem
self.gatewayStatusText = problem.statusText
if problem.needsPairingApproval {
self.gatewayPairingPaused = true
self.gatewayPairingRequestId = problem.requestId
}
if problem.needsPairingApproval || problem.pauseReconnect {
LiveActivityManager.shared.showAttention(
statusText: problem.needsPairingApproval ? "Approval needed" : "Action required",
agentName: self.activeAgentName,
sessionKey: self.mainSessionKey)
}
}
private func clearOperatorGatewayConnectionProblemIfCurrent() {
guard let operatorGatewayProblem else { return }
self.operatorGatewayProblem = nil
guard self.lastGatewayProblem == operatorGatewayProblem else { return }
self.lastGatewayProblem = nil
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
if self.gatewayServerName != nil {
self.gatewayStatusText = "Connected"
}
if self.gatewayConnected {
LiveActivityManager.shared.handleReconnect()
}
}
private func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
guard let lastGatewayProblem else { return false }
return GatewayConnectionProblemMapper.shouldPreserve(
previousProblem: lastGatewayProblem,
overDisconnectReason: reason)
}
private func shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
password: String?,
deviceAuthGatewayID: String,
allowStoredDeviceAuth: Bool = true) -> Bool
{
Self.shouldStartOperatorGatewayLoop(
token: token,
bootstrapToken: bootstrapToken,
password: password,
hasStoredOperatorToken: allowStoredDeviceAuth && self.hasStoredGatewayRoleToken(
"operator",
gatewayID: deviceAuthGatewayID))
}
private func hasStoredGatewayRoleToken(_ role: String, gatewayID: String) -> Bool {
let identity = DeviceIdentityStore.loadOrCreate()
return DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: role,
gatewayID: gatewayID) != nil
}
fileprivate nonisolated static func shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
password: String?,
hasStoredOperatorToken: Bool) -> Bool
{
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedToken.isEmpty {
return true
}
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPassword.isEmpty {
return true
}
let trimmedBootstrapToken = bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedBootstrapToken.isEmpty {
return false
}
return hasStoredOperatorToken
}
private func currentGatewayReconnectAuth(
fallbackToken: String?,
fallbackBootstrapToken: String?,
fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?)
{
if let cfg = activeGatewayConnectConfig {
return (cfg.token, cfg.bootstrapToken, cfg.password)
}
return (fallbackToken, fallbackBootstrapToken, fallbackPassword)
}
private func currentGatewayReconnectOptions(
stableID: String,
fallback: GatewayConnectOptions) -> GatewayConnectOptions
{
guard let config = activeGatewayConnectConfig,
config.effectiveStableID == stableID
else { return fallback }
return config.nodeOptions
}
private nonisolated static func usesBootstrapCredential(
token: String?,
bootstrapToken: String?,
password: String?) -> Bool
{
token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
password?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
private func completeSuccessfulGatewayAuthHandoff(
stableID: String,
routeGeneration: UInt64,
issuedRoles: Set<String>,
nodeOptions: GatewayConnectOptions) -> GatewayConnectOptions?
{
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return nil }
// Bootstrap authentication is single-use. Do not keep a consumed bootstrap
// route alive unless both replacement sessions can authenticate from secure storage.
guard issuedRoles.isSuperset(of: ["node", "operator"]) else {
return nodeOptions.allowStoredDeviceAuth ? nodeOptions : nil
}
guard let config = activeGatewayConnectConfig,
config.effectiveStableID == stableID
else { return nil }
let instanceID = GatewaySettingsStore.currentInstanceID()
let deviceAuthGatewayID = nodeOptions.deviceAuthGatewayID ?? stableID
if let metadata = GatewaySettingsStore.loadGatewayCredentialMetadata(
instanceId: instanceID,
gatewayStableID: deviceAuthGatewayID),
metadata.suppressStoredDeviceAuth,
!GatewaySettingsStore.completeGatewayCredentialHandoff(
instanceId: instanceID,
gatewayStableID: deviceAuthGatewayID)
{
return nil
}
var reconnectOptions = nodeOptions
reconnectOptions.allowStoredDeviceAuth = true
self.activeGatewayConnectConfig = GatewayConnectConfig(
url: config.url,
stableID: config.stableID,
tls: config.tls,
token: config.token,
bootstrapToken: nil,
password: config.password,
nodeOptions: reconnectOptions)
if self.operatorGatewayTask == nil,
self.shouldStartOperatorGatewayLoop(
token: config.token,
bootstrapToken: nil,
password: config.password,
deviceAuthGatewayID: deviceAuthGatewayID,
allowStoredDeviceAuth: true)
{
let sessionBox = config.tls.map {
WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0))
}
self.startOperatorGatewayLoop(
url: config.url,
stableID: stableID,
token: config.token,
bootstrapToken: nil,
password: config.password,
nodeOptions: reconnectOptions,
sessionBox: sessionBox)
}
return reconnectOptions
}
private func gatewayOptionsAfterSuccessfulConnection(
_ nodeOptions: GatewayConnectOptions,
stableID: String,
routeGeneration: UInt64,
auth: (token: String?, bootstrapToken: String?, password: String?)) async -> GatewayConnectOptions?
{
guard !nodeOptions.allowStoredDeviceAuth else { return nodeOptions }
guard Self.usesBootstrapCredential(
token: auth.token,
bootstrapToken: auth.bootstrapToken,
password: auth.password)
else {
return nodeOptions
}
let issuedRoles = await nodeGateway.currentIssuedDeviceAuthRoles()
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return nil }
guard let reconnectOptions = completeSuccessfulGatewayAuthHandoff(
stableID: stableID,
routeGeneration: routeGeneration,
issuedRoles: issuedRoles,
nodeOptions: nodeOptions)
else {
await self.handleGatewayCredentialHandoffPersistenceFailure(
stableID: stableID,
routeGeneration: routeGeneration)
return nil
}
return reconnectOptions
}
private func handleGatewayCredentialHandoffPersistenceFailure(
stableID: String,
routeGeneration: UInt64) async
{
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
guard self.credentialHandoffFailureGeneration != routeGeneration else { return }
self.credentialHandoffFailureGeneration = routeGeneration
self.disableGatewayAutoReconnect()
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
await self.nodeGateway.disconnect()
await self.operatorGateway.disconnect()
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
self.applyGatewayConnectionProblem(GatewayConnectionProblem(
kind: .unknown,
owner: .iphone,
title: "Credential save failed",
message: "OpenClaw disconnected because it could not securely save the new gateway credential.",
retryable: true,
pauseReconnect: true,
technicalDetails: "Gateway credential handoff persistence failed."))
}
private func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
guard self.isBackgrounded else { return }
guard !self.backgroundReconnectSuppressed else { return }
guard let leaseUntil = backgroundReconnectLeaseUntil else {
self.suppressBackgroundReconnect(reason: "\(source):no_lease", disconnectIfNeeded: true)
return
}
if Date() >= leaseUntil {
self.suppressBackgroundReconnect(reason: "\(source):lease_expired", disconnectIfNeeded: true)
}
}
private func shouldPauseReconnectLoopInBackground(source: String) -> Bool {
self.refreshBackgroundReconnectSuppressionIfNeeded(source: source)
return self.isBackgrounded && self.backgroundReconnectSuppressed
}
private func gatewayReconnectLoopDelay(source: String) -> UInt64? {
if !self.gatewayAutoReconnectEnabled || self.gatewayPairingPaused {
return 1_000_000_000
}
return self.shouldPauseReconnectLoopInBackground(source: source)
? 2_000_000_000
: nil
}
private func isCurrentGatewayRoute(generation: UInt64, stableID: String) -> Bool {
generation == self.gatewayRouteGeneration &&
self.activeGatewayConnectConfig?.effectiveStableID == stableID
}
private func gatewayRouteCheck(
generation: UInt64,
stableID: String) -> @MainActor @Sendable () -> Bool
{
{ [weak self] in
self?.isCurrentGatewayRoute(generation: generation, stableID: stableID) == true
}
}
private func handleOperatorGatewayConnected(
url: URL,
stableID: String,
routeGeneration: UInt64) async
{
guard !self.isLocalGatewayFixtureEnabled,
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID)
else { return }
self.setOperatorConnected(true)
self.clearOperatorGatewayConnectionProblemIfCurrent()
self.forceOperatorTalkPermissionUpgradeRequest = false
self.talkMode.updateGatewayConnected(true)
GatewayDiagnostics.log(
"operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
let shouldContinue = self.gatewayRouteCheck(
generation: routeGeneration,
stableID: stableID)
await flushPendingWatchExecApprovalResolutions(shouldContinue: shouldContinue)
guard shouldContinue() else { return }
await self.talkMode.reloadConfig(shouldApply: shouldContinue)
guard shouldContinue() else { return }
await self.talkMode.prefetchRealtimeSessionIfReady(
reason: "operator_connected",
shouldApply: shouldContinue)
guard shouldContinue() else { return }
await self.refreshBrandingFromGateway(shouldApply: shouldContinue)
guard shouldContinue() else { return }
await self.refreshAgentsFromGateway(shouldApply: shouldContinue)
guard shouldContinue() else { return }
await refreshShareRouteFromGateway(shouldApply: shouldContinue)
guard shouldContinue() else { return }
await registerAPNsTokenIfNeeded(shouldContinue: shouldContinue)
guard shouldContinue() else { return }
await self.startVoiceWakeSync(shouldContinue: shouldContinue)
guard shouldContinue() else { return }
self.startGatewayHealthMonitor()
}
private func handleNodeGatewayConnected(
url: URL,
stableID: String,
routeGeneration: UInt64,
nodeOptions: GatewayConnectOptions,
auth: (token: String?, bootstrapToken: String?, password: String?)) async
{
guard !self.isLocalGatewayFixtureEnabled,
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID)
else { return }
let usedBootstrapToken = Self.usesBootstrapCredential(
token: auth.token,
bootstrapToken: auth.bootstrapToken,
password: auth.password)
if usedBootstrapToken {
let issuedRoles = await nodeGateway.currentIssuedDeviceAuthRoles()
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
guard self.completeSuccessfulGatewayAuthHandoff(
stableID: stableID,
routeGeneration: routeGeneration,
issuedRoles: issuedRoles,
nodeOptions: nodeOptions) != nil
else {
await self.handleGatewayCredentialHandoffPersistenceFailure(
stableID: stableID,
routeGeneration: routeGeneration)
return
}
}
self.clearGatewayConnectionProblem()
self.gatewayStatusText = "Connected"
self.gatewayServerName = url.host ?? "gateway"
self.gatewayConnected = true
_ = GatewaySettingsStore.markGatewayConnected(
stableID: stableID,
atMs: Int(Date().timeIntervalSince1970 * 1000))
self.screen.errorText = nil
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
LiveActivityManager.shared.handleReconnect()
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig(
gatewayURLString: url.absoluteString,
gatewayStableID: nodeOptions.deviceAuthGatewayID,
token: auth.token,
password: auth.password,
sessionKey: self.mainSessionKey,
deliveryChannel: self.shareDeliveryChannel,
deliveryTo: self.shareDeliveryTo))
GatewayDiagnostics.log(
"gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
if let address = await nodeGateway.currentRemoteAddress() {
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
self.gatewayRemoteAddress = address
}
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
await showA2UIOnConnectIfNeeded()
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
let shouldContinue = self.gatewayRouteCheck(
generation: routeGeneration,
stableID: stableID)
await onNodeGatewayConnected(shouldContinue: shouldContinue)
guard shouldContinue() else { return }
SignificantLocationMonitor.startIfNeeded(
locationService: self.locationService,
locationMode: self.locationMode(),
gateway: self.nodeGateway,
beforeSend: { [weak self] in
await self?.handleSignificantLocationWakeIfNeeded()
})
}
private func startOperatorGatewayLoop(
url: URL,
stableID: String,
token: String?,
bootstrapToken: String?,
password: String?,
nodeOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?)
{
let routeGeneration = self.gatewayRouteGeneration
// Async reconnect helpers can resume after Disconnect or a target switch. Only the
// current route may install a new loop after those suspension points.
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
// Operator session reconnects independently (chat/talk/config/voicewake), but we tie its
// lifecycle to the current gateway config so it doesn't keep running across Disconnect.
self.operatorGatewayTask = Task { [weak self] in
guard let self else { return }
var attempt = 0
while !Task.isCancelled,
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID)
{
if let delay = self.gatewayReconnectLoopDelay(source: "operator_loop") {
try? await Task.sleep(nanoseconds: delay)
continue
}
if await self.isOperatorConnected() {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
let reconnectAuth = self.currentGatewayReconnectAuth(
fallbackToken: token,
fallbackBootstrapToken: bootstrapToken,
fallbackPassword: password)
// Bootstrap handoff enables stored auth in the active config. Reconnects must
// consume that current ownership state instead of the loop's one-shot bootstrap options.
let reconnectOptions = self.currentGatewayReconnectOptions(
stableID: stableID,
fallback: nodeOptions)
let effectiveClientId =
GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) ?? reconnectOptions.clientId
let talkPermissionUpgradeRequest = self.forceOperatorTalkPermissionUpgradeRequest
let deviceAuthGatewayID = reconnectOptions.deviceAuthGatewayID ?? stableID
let operatorOptions = self.makeOperatorConnectOptions(
clientId: effectiveClientId,
displayName: reconnectOptions.clientDisplayName,
deviceAuthGatewayID: deviceAuthGatewayID,
includeAdminScope: self.shouldRequestOperatorAdminScope(
gatewayID: deviceAuthGatewayID,
token: reconnectAuth.token,
password: reconnectAuth.password,
forceTalkPermissionUpgradeRequest: talkPermissionUpgradeRequest),
includeApprovalScope: self.shouldRequestOperatorApprovalScope(
gatewayID: deviceAuthGatewayID,
token: reconnectAuth.token,
password: reconnectAuth.password,
forceTalkPermissionUpgradeRequest: talkPermissionUpgradeRequest),
forceExplicitScopes: talkPermissionUpgradeRequest,
allowStoredDeviceAuth: reconnectOptions.allowStoredDeviceAuth)
do {
try await self.operatorGateway.connect(
url: url,
token: reconnectAuth.token,
bootstrapToken: reconnectAuth.bootstrapToken,
password: reconnectAuth.password,
connectOptions: operatorOptions,
sessionBox: sessionBox,
extraHeadersProvider: {
GatewaySettingsStore.loadGatewayCustomHeaders(gatewayStableID: stableID)
},
onConnected: { [weak self] in
await self?.handleOperatorGatewayConnected(
url: url,
stableID: stableID,
routeGeneration: routeGeneration)
},
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
guard !self.isLocalGatewayFixtureEnabled,
self.isCurrentGatewayRoute(
generation: routeGeneration,
stableID: stableID)
else { return }
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
LiveActivityManager.shared.endActivity(reason: "operator_disconnected")
}
GatewayDiagnostics.log("operator gateway disconnected reason=\(reason)")
await MainActor.run {
guard self.isCurrentGatewayRoute(
generation: routeGeneration,
stableID: stableID)
else { return }
self.stopGatewayHealthMonitor()
}
},
onInvoke: { req in
// Operator session should not handle node.invoke requests.
BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .invalidRequest,
message: "INVALID_REQUEST: operator session cannot invoke node commands"))
})
attempt = 0
try? await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
guard self.isCurrentGatewayRoute(
generation: routeGeneration,
stableID: stableID) else { break }
attempt += 1
GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)")
let problem: GatewayConnectionProblem? = await MainActor.run {
let nextProblem = GatewayConnectionProblemMapper.map(error: error)
guard !self.isLocalGatewayFixtureEnabled,
self.isCurrentGatewayRoute(
generation: routeGeneration,
stableID: stableID)
else { return nil }
if let nextProblem {
if nextProblem.needsPairingApproval || nextProblem.pauseReconnect {
self.applyOperatorGatewayConnectionProblem(nextProblem)
}
if talkPermissionUpgradeRequest, nextProblem.kind == .pairingScopeUpgradeRequired {
self.talkMode.markTalkPermissionUpgradeRequested(requestId: nextProblem.requestId)
}
}
return nextProblem
}
if problem?.needsPairingApproval == true {
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
await self.operatorGateway.disconnect()
break
}
if problem?.pauseReconnect == true {
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
await self.operatorGateway.disconnect()
break
}
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
}
}
}
}
// Legacy reconnect state machine; follow-up refactor needed to split into helpers.
// swiftlint:disable:next function_body_length
private func startNodeGatewayLoop(
url: URL,
stableID: String,
token: String?,
bootstrapToken: String?,
password: String?,
nodeOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?)
{
let routeGeneration = self.gatewayRouteGeneration
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
self.nodeGatewayTask = Task { [weak self] in
guard let self else { return }
var attempt = 0
var currentOptions = nodeOptions
var didFallbackClientId = false
var pausedForPairingApproval = false
while !Task.isCancelled,
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID)
{
if let delay = self.gatewayReconnectLoopDelay(source: "node_loop") {
try? await Task.sleep(nanoseconds: delay)
continue
}
if await self.isGatewayConnected() {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
await MainActor.run {
guard !self.isLocalGatewayFixtureEnabled,
self.isCurrentGatewayRoute(
generation: routeGeneration,
stableID: stableID)
else { return }
self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
LiveActivityManager.shared.showConnecting(
statusText: (attempt == 0) ? "Connecting..." : "Reconnecting...",
agentName: self.activeAgentName,
sessionKey: self.mainSessionKey)
}
do {
let epochMs = Int(Date().timeIntervalSince1970 * 1000)
let reconnectAuth = self.currentGatewayReconnectAuth(
fallbackToken: token,
fallbackBootstrapToken: bootstrapToken,
fallbackPassword: password)
let connectedOptions = currentOptions
GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)")
try await self.nodeGateway.connect(
url: url,
token: reconnectAuth.token,
bootstrapToken: reconnectAuth.bootstrapToken,
password: reconnectAuth.password,
connectOptions: connectedOptions,
sessionBox: sessionBox,
extraHeadersProvider: {
GatewaySettingsStore.loadGatewayCustomHeaders(gatewayStableID: stableID)
},
onConnected: { [weak self] in
await self?.handleNodeGatewayConnected(
url: url,
stableID: stableID,
routeGeneration: routeGeneration,
nodeOptions: connectedOptions,
auth: reconnectAuth)
},
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
guard !self.isLocalGatewayFixtureEnabled,
self.isCurrentGatewayRoute(
generation: routeGeneration,
stableID: stableID)
else { return }
if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason),
let lastGatewayProblem = self.lastGatewayProblem
{
self.gatewayStatusText = lastGatewayProblem.statusText
} else {
self.gatewayStatusText = "Disconnected: \(reason)"
}
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect()
}
GatewayDiagnostics.log("gateway disconnected reason: \(reason)")
},
onInvoke: { [weak self] req in
guard let self else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "UNAVAILABLE: node not ready"))
}
return await self.handleInvoke(req, gatewayStableID: stableID)
})
guard let reconnectOptions = await self.gatewayOptionsAfterSuccessfulConnection(
currentOptions,
stableID: stableID,
routeGeneration: routeGeneration,
auth: reconnectAuth)
else { break }
currentOptions = reconnectOptions
attempt = 0
try? await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
if Task.isCancelled ||
!self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID)
{
break
}
if !didFallbackClientId,
let fallbackClientId = self.legacyClientIdFallback(
currentClientId: currentOptions.clientId,
error: error)
{
didFallbackClientId = true
currentOptions.clientId = fallbackClientId
GatewaySettingsStore.saveGatewayClientIdOverride(
stableID: stableID,
clientId: fallbackClientId)
await MainActor.run { self.gatewayStatusText = "Gateway rejected client id. Retrying…" }
continue
}
attempt += 1
let problem: GatewayConnectionProblem? = await MainActor.run {
let nextProblem = GatewayConnectionProblemMapper.map(
error: error,
preserving: self.lastGatewayProblem)
guard !self.isLocalGatewayFixtureEnabled,
self.isCurrentGatewayRoute(
generation: routeGeneration,
stableID: stableID)
else { return nil }
if let nextProblem {
self.applyGatewayConnectionProblem(nextProblem)
} else {
self.lastGatewayProblem = nil
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.gatewayConnected = false
self.showLocalCanvasOnDisconnect()
}
return nextProblem
}
GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)")
if problem?.needsPairingApproval == true {
// Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
// we don't generate multiple pending requests while waiting for approval.
pausedForPairingApproval = true
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
break
}
if problem?.pauseReconnect == true {
continue
}
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
}
}
if pausedForPairingApproval {
// Leave the status text + request id intact so onboarding can guide the user.
return
}
if self.credentialHandoffFailureGeneration == routeGeneration {
return
}
await MainActor.run {
guard !self.isLocalGatewayFixtureEnabled,
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID)
else { return }
self.lastGatewayProblem = nil
self.gatewayStatusText = "Offline"
LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped")
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil
self.gatewayConnected = false
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
// Retain the last verified routing contract for offline
// capture; reconnect compares it with the live gateway before replay.
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.showLocalCanvasOnDisconnect()
}
}
}
private func shouldRequestOperatorApprovalScope(
gatewayID: String,
token: String?,
password: String?,
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
{
let identity = DeviceIdentityStore.loadOrCreate()
let storedOperatorScopes = DeviceAuthStore
.loadToken(deviceId: identity.deviceId, role: "operator", gatewayID: gatewayID)?
.scopes ?? []
return Self.shouldRequestOperatorApprovalScope(
token: token,
password: password,
storedOperatorScopes: storedOperatorScopes,
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
}
fileprivate nonisolated static func shouldRequestOperatorApprovalScope(
token: String?,
password: String?,
storedOperatorScopes: [String],
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
{
if forceTalkPermissionUpgradeRequest {
return storedOperatorScopes.contains("operator.approvals")
}
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedToken.isEmpty {
return true
}
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPassword.isEmpty {
return true
}
return storedOperatorScopes.contains("operator.approvals")
}
private func shouldRequestOperatorAdminScope(
gatewayID: String,
token: String?,
password: String?,
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
{
let identity = DeviceIdentityStore.loadOrCreate()
let storedOperatorScopes = DeviceAuthStore
.loadToken(deviceId: identity.deviceId, role: "operator", gatewayID: gatewayID)?
.scopes ?? []
return Self.shouldRequestOperatorAdminScope(
token: token,
password: password,
storedOperatorScopes: storedOperatorScopes,
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
}
fileprivate nonisolated static func shouldRequestOperatorAdminScope(
token: String?,
password: String?,
storedOperatorScopes: [String],
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
{
if forceTalkPermissionUpgradeRequest {
return false
}
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedToken.isEmpty {
return true
}
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPassword.isEmpty {
return true
}
return storedOperatorScopes.contains("operator.admin")
}
private func makeOperatorConnectOptions(
clientId: String,
displayName: String?,
deviceAuthGatewayID: String? = nil,
includeAdminScope: Bool = false,
includeApprovalScope: Bool,
forceExplicitScopes: Bool = false,
allowStoredDeviceAuth: Bool = true) -> GatewayConnectOptions
{
var scopes = ["operator.read", "operator.write", "operator.talk.secrets"]
if includeAdminScope {
scopes.append("operator.admin")
}
// Preserve reconnect compatibility for older paired operator tokens that were
// approved before iOS requested operator.approvals by default.
if includeApprovalScope {
scopes.append("operator.approvals")
}
return GatewayConnectOptions(
role: "operator",
scopes: scopes,
scopesAreExplicit: forceExplicitScopes,
caps: [],
commands: [],
permissions: [:],
clientId: clientId,
clientMode: "ui",
clientDisplayName: displayName,
includeDeviceIdentity: true,
allowStoredDeviceAuth: allowStoredDeviceAuth,
deviceAuthGatewayID: deviceAuthGatewayID)
}
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"
}
private func isOperatorConnected() async -> Bool {
self.operatorConnected
}
private func setOperatorConnected(_ connected: Bool) {
let changed = self.operatorConnected != connected
self.operatorConnected = connected
self.operatorStatusText = connected ? "Connected" : "Offline"
self.refreshOperatorAdminScopeFromStore()
guard connected else {
guard changed else { return }
Task { [weak self] in
await self?.syncWatchAppSnapshot(reason: "operator_offline")
}
return
}
if changed {
// Immediate retries are bounded per connection. A real reconnect grants queued
// messages a fresh budget so one exhausted head cannot strand the durable outbox.
self.watchMessageRetryAttempts.removeAll()
}
Task { [weak self] in
await self?.flushPendingExecApprovalResolvedPushes()
await self?.flushQueuedWatchMessagesIfAvailable()
guard changed else { return }
await self?.syncWatchAppSnapshot(reason: "operator_online")
}
}
private func refreshOperatorAdminScopeFromStore() {
guard let config = activeGatewayConnectConfig else {
self.hasOperatorAdminScope = false
return
}
let gatewayID = config.nodeOptions.deviceAuthGatewayID ?? config.effectiveStableID
let identity = DeviceIdentityStore.loadOrCreate()
self.hasOperatorAdminScope = DeviceAuthStore
.loadToken(deviceId: identity.deviceId, role: "operator", gatewayID: gatewayID)?
.scopes
.contains("operator.admin") == true
}
}
extension NodeAppModel {
func enterAppleReviewDemoMode() {
self.invalidateGatewayConnectAttempts()
self.isAppleReviewDemoModeEnabled = true
self.isScreenshotFixtureModeEnabled = false
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
self.credentialHandoffFailureGeneration = nil
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
self.gatewayHealthMonitor.stop()
LiveActivityManager.shared.endActivity(reason: "apple_review_demo")
Task {
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
}
self.gatewayStatusText = "Connected"
self.nodeStatusText = "Connected"
self.gatewayServerName = AppleReviewDemoMode.gatewayName
self.gatewayRemoteAddress = AppleReviewDemoMode.gatewayAddress
self.connectedGatewayID = AppleReviewDemoMode.gatewayID
self.activeGatewayConnectConfig = nil
self.gatewayConnected = true
self.setOperatorConnected(false)
UserDefaults.standard.set(false, forKey: "talk.enabled")
UserDefaults.standard.set(false, forKey: "talk.background.enabled")
self.talkMode.updateGatewayConnected(false)
self.talkMode.setEnabled(false)
self.talkMode.statusText = "Demo mode only"
self.mainSessionBaseKey = "main"
self.gatewaySessionScope = "per-sender"
self.selectedAgentId = nil
self.gatewayDefaultAgentId = "main"
self.gatewayAgents = AppleReviewDemoMode.agents
self.focusedChatSessionKey = nil
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
}
func enterScreenshotFixtureMode() {
self.invalidateGatewayConnectAttempts()
self.isAppleReviewDemoModeEnabled = false
self.isScreenshotFixtureModeEnabled = true
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
self.gatewayHealthMonitor.stop()
LiveActivityManager.shared.endActivity(reason: "screenshot_fixture")
Task {
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
}
self.gatewayStatusText = "Connected"
self.nodeStatusText = "Connected"
self.gatewayServerName = ScreenshotFixtureMode.gatewayName
self.gatewayRemoteAddress = ScreenshotFixtureMode.gatewayAddress
self.connectedGatewayID = ScreenshotFixtureMode.gatewayID
self.activeGatewayConnectConfig = nil
self.gatewayConnected = true
self.setOperatorConnected(true)
self.hasOperatorAdminScope = true
self.mainSessionBaseKey = "main"
self.gatewaySessionScope = "per-sender"
self.selectedAgentId = nil
self.gatewayDefaultAgentId = "main"
self.gatewayAgents = ScreenshotFixtureMode.agents
self.focusedChatSessionKey = nil
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.talkMode.enterScreenshotFixtureMode()
self.homeCanvasRevision &+= 1
}
}
extension NodeAppModel {
private struct PendingForegroundNodeAction: Decodable {
var id: String
var command: String
var paramsJSON: String?
var enqueuedAtMs: Int?
}
private struct PendingForegroundNodeActionsResponse: Decodable {
var nodeId: String?
var actions: [PendingForegroundNodeAction]
}
private struct PendingForegroundNodeActionsAckRequest: Encodable {
var ids: [String]
}
private func refreshShareRouteFromGateway(shouldApply: () -> Bool = { true }) async {
struct Params: Codable {
var includeGlobal: Bool
var includeUnknown: Bool
var limit: Int
}
struct SessionRow: Decodable {
var key: String
var updatedAt: Double?
var lastChannel: String?
var lastTo: String?
}
struct SessionsListResult: Decodable {
var sessions: [SessionRow]
}
let normalize: (String?) -> String? = { raw in
let value = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
do {
let data = try JSONEncoder().encode(
Params(includeGlobal: true, includeUnknown: false, limit: 80))
guard let json = String(data: data, encoding: .utf8) else { return }
let response = try await operatorGateway.request(
method: "sessions.list",
paramsJSON: json,
timeoutSeconds: 10)
let decoded = try JSONDecoder().decode(SessionsListResult.self, from: response)
let currentKey = self.mainSessionKey
let sorted = decoded.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
let exactMatch = sorted.first { row in
row.key == currentKey && normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil
}
let selected = exactMatch
let channel = normalize(selected?.lastChannel)
let to = normalize(selected?.lastTo)
guard shouldApply() else { return }
await MainActor.run {
self.shareDeliveryChannel = channel
self.shareDeliveryTo = to
if let relay = ShareGatewayRelaySettings.loadConfig() {
ShareGatewayRelaySettings.saveConfig(
ShareGatewayRelayConfig(
gatewayURLString: relay.gatewayURLString,
gatewayStableID: relay.gatewayStableID,
token: relay.token,
password: relay.password,
sessionKey: self.mainSessionKey,
deliveryChannel: channel,
deliveryTo: to))
}
}
} catch {
// Best-effort only.
}
}
func runSharePipelineSelfTest() async {
self.recordShareEvent("Share self-test running…")
let payload = SharedContentPayload(
title: "OpenClaw Share Self-Test",
url: URL(string: "https://openclaw.ai/share-self-test"),
text: "Validate iOS share->deep-link->gateway forwarding.")
guard let deepLink = ShareToAgentDeepLink.buildURL(
from: payload,
instruction: "Reply with: SHARE SELF-TEST OK")
else {
self.recordShareEvent("Self-test failed: could not build deep link.")
return
}
await handleDeepLink(url: deepLink)
}
func refreshLastShareEventFromRelay() {
if let event = ShareGatewayRelaySettings.loadLastEvent() {
self.lastShareEventText = event
}
}
func recordShareEvent(_ text: String) {
ShareGatewayRelaySettings.saveLastEvent(text)
self.refreshLastShareEventFromRelay()
}
/// Back-compat hook retained for older gateway-connect flows.
func onNodeGatewayConnected(
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
{
guard shouldContinue() else { return }
await self.registerAPNsTokenIfNeeded(shouldContinue: shouldContinue)
guard shouldContinue() else { return }
await self.syncWatchAppSnapshot(
reason: "node_connected",
includeChat: true,
shouldContinue: shouldContinue)
guard shouldContinue() else { return }
await self.syncWatchExecApprovalSnapshot(
reason: "node_connected",
shouldContinue: shouldContinue)
guard shouldContinue() else { return }
await self.resumePendingForegroundNodeActionsIfNeeded(
trigger: "node_connected",
shouldContinue: shouldContinue)
}
private func resumePendingForegroundNodeActionsIfNeeded(
trigger: String,
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
{
guard shouldContinue() else { return }
guard !self.isBackgrounded else { return }
guard await isGatewayConnected() else { return }
guard !self.pendingForegroundActionDrainInFlight else {
self.pendingForegroundActionDrainRequested = true
return
}
self.pendingForegroundActionDrainInFlight = true
defer {
self.pendingForegroundActionDrainInFlight = false
if self.pendingForegroundActionDrainRequested {
self.pendingForegroundActionDrainRequested = false
// Serialize non-idempotent action execution, then retry against whichever
// exact route is current after the suspended drain has unwound.
Task { @MainActor [weak self] in
await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "coalesced")
}
}
}
let routeGeneration = self.gatewayRouteGeneration
guard let gatewayStableID = self.connectedGatewayID,
let nodeRoute = await self.nodeGateway.currentRoute(),
shouldContinue(),
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: gatewayStableID)
else { return }
do {
let routeContext = GatewaySessionRouteContext(
route: nodeRoute,
gatewayStableID: gatewayStableID,
routeGeneration: routeGeneration)
let payload = try await nodeGateway.request(
method: "node.pending.pull",
paramsJSON: "{}",
timeoutSeconds: 6,
ifCurrentRoute: nodeRoute)
let decoded = try JSONDecoder().decode(
PendingForegroundNodeActionsResponse.self,
from: payload)
guard await self.isCurrentGatewaySessionRoute(
routeContext,
session: self.nodeGateway,
shouldContinue: shouldContinue)
else { return }
self.retainCompletedPendingForegroundActionIDs(
presentIn: decoded.actions,
gatewayStableID: gatewayStableID)
guard !decoded.actions.isEmpty else { return }
self.pendingActionLogger
.info("pending actions trigger=\(trigger, privacy: .public)")
self.pendingActionLogger.info("pending actions count=\(decoded.actions.count, privacy: .public)")
await self.applyPendingForegroundNodeActions(
decoded.actions,
trigger: trigger,
routeContext: routeContext,
shouldContinue: shouldContinue)
} catch {
// Best-effort only.
}
}
private func applyPendingForegroundNodeActions(
_ actions: [PendingForegroundNodeAction],
trigger: String,
routeContext: GatewaySessionRouteContext? = nil,
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
{
for action in actions {
guard shouldContinue() else { return }
if let routeContext {
guard await self.isCurrentGatewaySessionRoute(
routeContext,
session: self.nodeGateway,
shouldContinue: shouldContinue)
else { return }
}
guard !self.isBackgrounded else {
self.pendingActionLogger.info(
"Pending action replay paused trigger=\(trigger, privacy: .public): app backgrounded")
return
}
let req = BridgeInvokeRequest(
id: action.id,
command: action.command,
paramsJSON: action.paramsJSON)
let gatewayStableID = routeContext?.gatewayStableID
let alreadyCompleted = gatewayStableID.map {
self.completedPendingForegroundActionIDsByGateway[$0]?.contains(action.id) == true
} ?? false
if !alreadyCompleted {
let result = await handleInvoke(
req,
gatewayStableID: gatewayStableID ?? self.connectedGatewayID)
self.pendingActionLogger
.info("pending replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public)")
self.pendingActionLogger.info("pending replay ok=\(result.ok, privacy: .public)")
self.pendingActionLogger.info("pending replay command=\(action.command, privacy: .public)")
guard result.ok else { return }
if let gatewayStableID {
// The gateway queue is connection-independent. Remember successful local
// execution until its source gateway accepts the ACK so reconnects cannot replay it.
self.completedPendingForegroundActionIDsByGateway[gatewayStableID, default: []]
.insert(action.id)
}
guard shouldContinue() else { return }
}
let acked = await ackPendingForegroundNodeAction(
id: action.id,
trigger: trigger,
command: action.command,
routeContext: routeContext)
guard acked else { return }
if let gatewayStableID {
self.removeCompletedPendingForegroundActionID(
action.id,
gatewayStableID: gatewayStableID)
}
}
}
private func retainCompletedPendingForegroundActionIDs(
presentIn actions: [PendingForegroundNodeAction],
gatewayStableID: String)
{
guard let completed = self.completedPendingForegroundActionIDsByGateway[gatewayStableID] else {
return
}
let retained = completed.intersection(actions.map(\.id))
if retained.isEmpty {
self.completedPendingForegroundActionIDsByGateway.removeValue(forKey: gatewayStableID)
} else {
self.completedPendingForegroundActionIDsByGateway[gatewayStableID] = retained
}
}
private func removeCompletedPendingForegroundActionID(
_ id: String,
gatewayStableID: String)
{
self.completedPendingForegroundActionIDsByGateway[gatewayStableID]?.remove(id)
if self.completedPendingForegroundActionIDsByGateway[gatewayStableID]?.isEmpty == true {
self.completedPendingForegroundActionIDsByGateway.removeValue(forKey: gatewayStableID)
}
}
private func isCurrentGatewaySessionRoute(
_ context: GatewaySessionRouteContext,
session: GatewayNodeSession,
shouldContinue: @MainActor @Sendable () -> Bool) async -> Bool
{
guard shouldContinue(),
self.isCurrentGatewayRoute(
generation: context.routeGeneration,
stableID: context.gatewayStableID)
else { return false }
guard await session.currentRoute() == context.route else { return false }
return shouldContinue() &&
self.isCurrentGatewayRoute(
generation: context.routeGeneration,
stableID: context.gatewayStableID)
}
private func ackPendingForegroundNodeAction(
id: String,
trigger: String,
command: String,
routeContext: GatewaySessionRouteContext?) async -> Bool
{
do {
let expectedRoute: GatewayNodeSessionRoute?
if let routeContext {
guard self.activeGatewayConnectConfig?.effectiveStableID == routeContext.gatewayStableID,
let currentRoute = await self.nodeGateway.currentRoute(),
self.activeGatewayConnectConfig?.effectiveStableID == routeContext.gatewayStableID
else { return false }
expectedRoute = currentRoute
} else {
expectedRoute = nil
}
let payload = try JSONEncoder().encode(PendingForegroundNodeActionsAckRequest(ids: [id]))
let paramsJSON = String(bytes: payload, encoding: .utf8) ?? "{}"
_ = try await self.nodeGateway.request(
method: "node.pending.ack",
paramsJSON: paramsJSON,
timeoutSeconds: 6,
ifCurrentRoute: expectedRoute)
return true
} catch {
self.pendingActionLogger
.error("pending ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public)")
self.pendingActionLogger.error("pending ack command=\(command, privacy: .public)")
self.pendingActionLogger.error("pending ack error=\(String(describing: error), privacy: .public)")
return false
}
}
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
let replyID = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
let actionID = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !replyID.isEmpty, !actionID.isEmpty else {
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
return
}
let payloadGatewayID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let currentGatewayID = self.currentWatchChatGatewayStableID()
let routedGatewayID = self.watchMessageOutbox.gatewayStableID(forPromptID: event.promptId) ?? ""
let sourceGatewayID: String = if !payloadGatewayID.isEmpty {
payloadGatewayID
} else if !routedGatewayID.isEmpty {
routedGatewayID
} else {
""
}
if !sourceGatewayID.isEmpty, let currentGatewayID, currentGatewayID != sourceGatewayID {
self.watchReplyLogger.info("watch reply dropped: stale gateway target")
return
}
guard !sourceGatewayID.isEmpty else {
self.watchReplyLogger.info("watch reply dropped: unresolved gateway target")
return
}
let gatewayStableID = sourceGatewayID
let message = WatchAppCommandEvent(
commandId: replyID,
command: .sendChat,
sessionKey: event.sessionKey,
gatewayStableID: gatewayStableID,
text: Self.makeWatchReplyAgentMessage(event),
sentAtMs: event.sentAtMs,
transport: event.transport,
messageKind: .quickReply)
let needsReconnect = !self.isWatchMessageSendAvailable()
await self.handleWatchMessage(message)
guard needsReconnect else { return }
let connected = await ensureOperatorApprovalConnectionForWatchReview(
timeoutMs: 12000,
reason: "watch_reply")
guard connected, self.currentWatchChatGatewayStableID() == gatewayStableID else {
self.watchReplyLogger.info("watch reply remains queued: gateway target unavailable")
return
}
await self.flushQueuedWatchMessagesIfAvailable()
}
private static func makeWatchReplyAgentMessage(_ event: WatchQuickReplyEvent) -> String {
let actionLabel = event.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines)
let promptId = event.promptId.trimmingCharacters(in: .whitespacesAndNewlines)
let transport = event.transport.trimmingCharacters(in: .whitespacesAndNewlines)
let summary = actionLabel?.isEmpty == false ? actionLabel! : event.actionId
var lines: [String] = []
lines.append("Watch reply: \(summary)")
lines.append("promptId=\(promptId.isEmpty ? "unknown" : promptId)")
lines.append("actionId=\(event.actionId)")
lines.append("replyId=\(event.replyId)")
if !transport.isEmpty {
lines.append("transport=\(transport)")
}
if let sentAtMs = event.sentAtMs {
lines.append("sentAtMs=\(sentAtMs)")
}
if let note = event.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
lines.append("note=\(note)")
}
return lines.joined(separator: "\n")
}
private func restorePersistedWatchExecApprovalBridgeState() {
guard let data = UserDefaults.standard.data(forKey: Self.watchExecApprovalBridgeStateKey),
let state = try? JSONDecoder().decode(PersistedWatchExecApprovalBridgeState.self, from: data)
else {
return
}
self.watchExecApprovalPromptsByID = Dictionary(
uniqueKeysWithValues: state.approvals.map { ($0.id, $0) })
var restoredPushes = Set<ExecApprovalNotificationPrompt>()
self.pendingWatchExecApprovalRecoveryPushes = (state.pendingApprovalPushes ?? [])
.filter { push in
!push.approvalId.isEmpty &&
push.gatewayDeviceId?.isEmpty != true &&
restoredPushes.insert(push).inserted
}
.sorted { lhs, rhs in
(lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId)
}
var restoredResolvedPushes = Set<ExecApprovalNotificationPrompt>()
self.pendingExecApprovalResolvedPushes = (state.pendingResolvedPushes ?? [])
.filter { push in
!push.approvalId.isEmpty &&
push.gatewayDeviceId?.isEmpty != true &&
restoredResolvedPushes.insert(push).inserted
}
.sorted { lhs, rhs in
(lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId)
}
var restoredReplyIDs = Set<String>()
self.pendingWatchExecApprovalResolutions = Array((state.pendingResolutions ?? []).filter { event in
let replyID = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
let approvalID = event.approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !replyID.isEmpty &&
!approvalID.isEmpty &&
!gatewayID.isEmpty &&
restoredReplyIDs.insert(replyID).inserted
}.suffix(32))
self.pruneExpiredWatchExecApprovalPrompts()
}
private func currentExecApprovalGatewayStableID() -> String? {
let stableID = self.activeGatewayConnectConfig?.effectiveStableID
?? self.connectedGatewayID
?? ""
let normalizedStableID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
return normalizedStableID.isEmpty ? nil : normalizedStableID
}
private func isExecApprovalPromptCurrent(_ prompt: ExecApprovalPrompt) -> Bool {
self.currentExecApprovalGatewayStableID() == prompt.gatewayStableID
}
private func invalidateExecApprovalSurfacesForGatewayChange() {
self.pendingExecApprovalPromptRequestGeneration &+= 1
self.dismissPendingExecApprovalPrompt()
self.pendingNotificationPermissionGuidancePrompt = nil
self.watchExecApprovalPromptsByID.removeAll()
let requestedPushes = self.pendingWatchExecApprovalRecoveryPushes
self.pendingWatchExecApprovalRecoveryPushes.removeAll()
let resolvedPushes = self.pendingExecApprovalResolvedPushes
self.pendingExecApprovalResolvedPushes.removeAll()
self.persistWatchExecApprovalBridgeState()
Task { @MainActor [weak self] in
if let self {
// Keep notification pushes until terminal state so route invalidation can remove
// only alerts owned by the old gateway, never a newly delivered replacement.
for push in Set(requestedPushes + resolvedPushes) {
await ExecApprovalNotificationBridge.removeNotifications(
for: push,
notificationCenter: self.notificationCenter)
}
}
await self?.syncWatchExecApprovalSnapshot(reason: "gateway_changed")
}
}
private func persistWatchExecApprovalBridgeState() {
self.pruneExpiredWatchExecApprovalPrompts()
let approvals = self.watchExecApprovalPromptsByID.values.sorted { lhs, rhs in
let lhsExpires = lhs.expiresAtMs ?? Int.max
let rhsExpires = rhs.expiresAtMs ?? Int.max
if lhsExpires != rhsExpires {
return lhsExpires < rhsExpires
}
return lhs.id < rhs.id
}
let pendingApprovalPushes = self.pendingWatchExecApprovalRecoveryPushes.sorted { lhs, rhs in
(lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId)
}
let pendingResolvedPushes = self.pendingExecApprovalResolvedPushes.sorted { lhs, rhs in
(lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId)
}
guard let data = try? JSONEncoder().encode(
PersistedWatchExecApprovalBridgeState(
approvals: approvals,
pendingApprovalPushes: pendingApprovalPushes,
pendingResolvedPushes: pendingResolvedPushes,
pendingResolutions: pendingWatchExecApprovalResolutions))
else {
return
}
UserDefaults.standard.set(data, forKey: Self.watchExecApprovalBridgeStateKey)
}
private func pruneExpiredWatchExecApprovalPrompts(nowMs: Int? = nil) {
let currentNowMs = nowMs ?? Int(Date().timeIntervalSince1970 * 1000)
self.watchExecApprovalPromptsByID = self.watchExecApprovalPromptsByID.filter { _, prompt in
guard let expiresAtMs = prompt.expiresAtMs else { return true }
return expiresAtMs > currentNowMs
}
}
private func handleWatchMessagingStatusChanged(_ status: WatchMessagingStatus) async {
GatewayDiagnostics.log(
"watch exec approval: status changed "
+ "reachable=\(status.reachable) activation=\(status.activationState) "
+ "backgrounded=\(self.isBackgrounded)")
guard status.supported, status.paired, status.appInstalled else { return }
guard status.reachable || status.activationState == "activated" else { return }
let reason = status.reachable ? "watch_reachable" : "watch_activated"
await self.syncWatchAppSnapshot(reason: reason, includeChat: status.reachable)
guard self.isBackgrounded else { return }
await self.syncWatchExecApprovalSnapshot(reason: reason)
}
private func appendPendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) {
guard !self.pendingWatchExecApprovalRecoveryPushes.contains(push) else { return }
self.pendingWatchExecApprovalRecoveryPushes.append(push)
self.pendingWatchExecApprovalRecoveryPushes.sort { lhs, rhs in
(lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId)
}
GatewayDiagnostics.log(
"watch exec approval: queued recovery "
+ "id=\(push.approvalId) pendingCount=\(self.pendingWatchExecApprovalRecoveryPushes.count)")
self.persistWatchExecApprovalBridgeState()
}
private func removePendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) {
let originalCount = self.pendingWatchExecApprovalRecoveryPushes.count
self.pendingWatchExecApprovalRecoveryPushes.removeAll { $0 == push }
guard self.pendingWatchExecApprovalRecoveryPushes.count != originalCount else { return }
GatewayDiagnostics.log(
"watch exec approval: cleared recovery "
+ "id=\(push.approvalId) pendingCount=\(self.pendingWatchExecApprovalRecoveryPushes.count)")
self.persistWatchExecApprovalBridgeState()
}
private func appendPendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) {
guard !self.pendingExecApprovalResolvedPushes.contains(push) else { return }
// A silent resolution push is not replayed by the gateway. Keep it until the
// authenticated owner route returns so its matching notification cannot linger.
self.pendingExecApprovalResolvedPushes.append(push)
if self.pendingExecApprovalResolvedPushes.count > 32 {
self.pendingExecApprovalResolvedPushes.removeFirst()
}
self.pendingExecApprovalResolvedPushes.sort { lhs, rhs in
(lhs.gatewayDeviceId ?? "", lhs.approvalId) < (rhs.gatewayDeviceId ?? "", rhs.approvalId)
}
self.persistWatchExecApprovalBridgeState()
}
private func removePendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) {
let originalCount = self.pendingExecApprovalResolvedPushes.count
self.pendingExecApprovalResolvedPushes.removeAll { $0 == push }
guard self.pendingExecApprovalResolvedPushes.count != originalCount else { return }
self.persistWatchExecApprovalBridgeState()
}
private func upsertWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
guard self.isExecApprovalPromptCurrent(prompt) else { return }
self.watchExecApprovalPromptsByID[prompt.id] = prompt
self.persistWatchExecApprovalBridgeState()
}
private func removeWatchExecApprovalPrompt(_ approvalId: String) {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
self.watchExecApprovalPromptsByID.removeValue(forKey: normalizedApprovalID)
self.persistWatchExecApprovalBridgeState()
}
private static func makeWatchExecApprovalItem(from prompt: ExecApprovalPrompt) -> OpenClawWatchExecApprovalItem {
let decisions = prompt.allowedDecisions.compactMap { decision in
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
return OpenClawWatchExecApprovalDecision(rawValue: normalizedDecision)
}
let preview = Self.trimmedOrNil(prompt.commandPreview) ?? Self.trimmedOrNil(prompt.commandText)
return OpenClawWatchExecApprovalItem(
id: prompt.id,
gatewayStableID: prompt.gatewayStableID,
commandText: prompt.commandText,
commandPreview: preview,
host: Self.trimmedOrNil(prompt.host),
nodeId: Self.trimmedOrNil(prompt.nodeId),
agentId: Self.trimmedOrNil(prompt.agentId),
expiresAtMs: prompt.expiresAtMs,
allowedDecisions: decisions,
// Prefer the watch's neutral/default presentation until exec.approval.get
// carries an explicit risk signal for exec approvals.
risk: nil)
}
private nonisolated static func shouldResetWatchExecApprovalResolvingStateOnPrompt(
reason: String) -> Bool
{
reason == "resolve_retry"
}
private func publishWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt, reason: String) async {
guard self.isExecApprovalPromptCurrent(prompt) else { return }
let deliveryGeneration = self.gatewayConnectGeneration
let message = OpenClawWatchExecApprovalPromptMessage(
approval: Self.makeWatchExecApprovalItem(from: prompt),
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
deliveryId: UUID().uuidString,
resetResolvingState: Self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason))
do {
_ = try await self.watchMessagingService.sendExecApprovalPrompt(message)
self.watchExecApprovalLogger.debug(
"watch exec approval prompt sent id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public)")
} catch {
self.watchExecApprovalLogger
.error(
"watch approval prompt failed id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public)")
self.watchExecApprovalLogger.error(
"watch approval prompt error=\(error.localizedDescription, privacy: .public)")
}
if deliveryGeneration != self.gatewayConnectGeneration {
// WatchConnectivity may finish by durably queueing the old payload after a route
// switch. Publish the replacement owner snapshots after that send completes.
await self.syncWatchAppSnapshot(reason: "\(reason)_route_repair")
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_route_repair")
return
}
await self.syncWatchAppSnapshot(reason: "\(reason)_app")
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_snapshot")
}
private func publishWatchExecApprovalResolved(
approvalId: String,
gatewayStableID: String,
decision: OpenClawWatchExecApprovalDecision?,
source: String) async
{
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
if self.watchExecApprovalPromptsByID[normalizedApprovalID]?.gatewayStableID == gatewayStableID {
self.removeWatchExecApprovalPrompt(normalizedApprovalID)
}
let message = OpenClawWatchExecApprovalResolvedMessage(
approvalId: normalizedApprovalID,
gatewayStableID: gatewayStableID,
decision: decision,
resolvedAtMs: Int(Date().timeIntervalSince1970 * 1000),
source: source)
do {
_ = try await self.watchMessagingService.sendExecApprovalResolved(message)
} catch {
self.watchExecApprovalLogger
.error(
"watch approval resolve failed id=\(normalizedApprovalID, privacy: .public)")
self.watchExecApprovalLogger.error(
"watch approval resolve error=\(error.localizedDescription, privacy: .public)")
}
await self.syncWatchAppSnapshot(reason: "resolved_app")
await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot")
}
private func publishWatchExecApprovalExpired(
approvalId: String,
gatewayStableID: String,
reason: OpenClawWatchExecApprovalCloseReason) async
{
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
if self.watchExecApprovalPromptsByID[normalizedApprovalID]?.gatewayStableID == gatewayStableID {
self.removeWatchExecApprovalPrompt(normalizedApprovalID)
}
let message = OpenClawWatchExecApprovalExpiredMessage(
approvalId: normalizedApprovalID,
gatewayStableID: gatewayStableID,
reason: reason,
expiredAtMs: Int(Date().timeIntervalSince1970 * 1000))
do {
_ = try await self.watchMessagingService.sendExecApprovalExpired(message)
} catch {
self.watchExecApprovalLogger
.error(
"watch approval expiry failed id=\(normalizedApprovalID, privacy: .public)")
self.watchExecApprovalLogger.error(
"watch approval expiry error=\(error.localizedDescription, privacy: .public)")
}
await self.syncWatchAppSnapshot(reason: "expired_\(reason.rawValue)_app")
await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)")
}
private func syncWatchExecApprovalSnapshot(
reason: String,
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
{
guard shouldContinue() else { return }
let deliveryGeneration = self.gatewayConnectGeneration
self.pruneExpiredWatchExecApprovalPrompts()
GatewayDiagnostics.log(
"watch exec approval: sync snapshot start "
+ "reason=\(reason) cacheCount=\(self.watchExecApprovalPromptsByID.count) "
+ "backgrounded=\(self.isBackgrounded)")
let approvals = self.watchExecApprovalPromptsByID.values
.filter(self.isExecApprovalPromptCurrent)
.sorted { lhs, rhs in
let lhsExpires = lhs.expiresAtMs ?? Int.max
let rhsExpires = rhs.expiresAtMs ?? Int.max
if lhsExpires != rhsExpires {
return lhsExpires < rhsExpires
}
return lhs.id < rhs.id
}
.map(Self.makeWatchExecApprovalItem)
let message = OpenClawWatchExecApprovalSnapshotMessage(
approvals: approvals,
gatewayStableID: currentExecApprovalGatewayStableID(),
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
snapshotId: UUID().uuidString)
do {
guard shouldContinue() else { return }
_ = try await self.watchMessagingService.syncExecApprovalSnapshot(message)
GatewayDiagnostics.log(
"watch exec approval: sync snapshot sent reason=\(reason) count=\(approvals.count)")
self.watchExecApprovalLogger
.debug("watch approval snapshot reason=\(reason, privacy: .public)")
self.watchExecApprovalLogger.debug(
"watch approval snapshot count=\(approvals.count, privacy: .public)")
if deliveryGeneration != self.gatewayConnectGeneration {
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_route_repair")
}
} catch {
GatewayDiagnostics.log(
"watch exec approval: sync snapshot failed reason=\(reason) error=\(error.localizedDescription)")
self.watchExecApprovalLogger
.error(
"watch approval snapshot failed reason=\(reason, privacy: .public)")
self.watchExecApprovalLogger.error(
"watch approval snapshot error=\(error.localizedDescription, privacy: .public)")
}
}
private func makeWatchChatPreview() async -> WatchChatPreview {
do {
let payload: OpenClawChatHistoryPayload
if self.isAppleReviewDemoModeEnabled {
payload = try await self.appleReviewDemoChatTransport.requestHistory(sessionKey: self.chatSessionKey)
} else {
guard self.isOperatorGatewayConnected else {
return WatchChatPreview(
items: [],
statusText: "Connect iPhone chat to read messages")
}
payload = try await IOSGatewayChatTransport(gateway: self.operatorSession)
.requestHistory(sessionKey: self.chatSessionKey)
}
let items = Self.makeWatchChatItems(from: payload.messages ?? [])
return WatchChatPreview(
items: items,
statusText: items.isEmpty ? "No chat messages yet" : nil)
} catch {
GatewayDiagnostics.log("watch app snapshot: chat preview failed error=\(error.localizedDescription)")
return WatchChatPreview(items: [], statusText: "Chat unavailable")
}
}
private nonisolated static func watchChatReplyText(
from raw: [OpenClawKit.AnyCodable],
runId: String,
submittedText: String,
submittedAtMs: Int) -> String?
{
let entries = raw.compactMap(self.decodeWatchChatMessage)
if let directReply = entries.last(where: {
self.isTerminalWatchAssistant($0) && $0.message.idempotencyKey == runId
}) {
return directReply.text
}
let userIdempotencyKey = "\(runId):user"
let exactUserIndex = entries.lastIndex(where: {
$0.message.role.lowercased() == "user" &&
$0.message.idempotencyKey == userIdempotencyKey
})
let queuedUserIndex = entries.lastIndex(where: { entry in
guard entry.message.role.lowercased() == "user",
let timestampMs = self.watchTimestampMs(entry.message.timestamp),
timestampMs >= submittedAtMs
else {
return false
}
return entry.text.contains(submittedText)
})
guard let userIndex = exactUserIndex ?? queuedUserIndex else { return nil }
return entries[(userIndex + 1)...].first(where: {
self.isTerminalWatchAssistant($0)
})?.text
}
private nonisolated static func isTerminalWatchAssistant(_ entry: WatchChatMessageEntry) -> Bool {
guard entry.message.role.lowercased() == "assistant" else { return false }
if entry.isMessageToolMirror {
return true
}
guard let stopReason = entry.message.stopReason?.lowercased() else { return false }
// Tool-use rows can contain visible progress text, but a later assistant row owns the final reply.
return stopReason != "tooluse" && stopReason != "tool_use" && stopReason != "tool_calls"
}
private nonisolated static func decodeWatchChatMessage(
_ raw: OpenClawKit.AnyCodable) -> WatchChatMessageEntry?
{
guard let data = try? JSONEncoder().encode(raw),
let message = try? JSONDecoder().decode(OpenClawChatMessage.self, from: data),
let text = nonEmptyWatchChatText(watchChatText(from: message))
else {
return nil
}
let metadata = try? JSONDecoder().decode(WatchChatMetadataEnvelope.self, from: data)
return WatchChatMessageEntry(
message: message,
text: text,
serverId: metadata?.metadata?.id,
isMessageToolMirror: metadata?.messageToolMirror != nil)
}
private nonisolated static func makeWatchChatItems(
from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem]
{
let readableMessages = raw.compactMap(self.decodeWatchChatMessage)
var idOccurrences: [String: Int] = [:]
let identified = readableMessages.map { entry -> (WatchChatMessageEntry, String) in
let baseId = entry.serverId.map { "\(entry.message.role)-\($0)" }
?? self.watchChatFallbackKey(entry)
idOccurrences[baseId, default: 0] += 1
let stableId = "\(baseId)-\(idOccurrences[baseId]!)"
return (entry, stableId)
}
return identified.suffix(self.watchChatPreviewItemLimit).map { entry, stableId in
let timestampMs = self.watchTimestampMs(entry.message.timestamp)
return OpenClawWatchChatItem(
id: stableId,
role: entry.message.role,
text: self.truncatedWatchChatText(entry.text),
timestampMs: timestampMs)
}
}
private nonisolated static func watchChatFallbackKey(_ entry: WatchChatMessageEntry) -> String {
let timestamp = self.watchTimestampMs(entry.message.timestamp).map(String.init) ?? "missing"
let source = "\(entry.message.role)\u{0}\(timestamp)\u{0}\(entry.text)"
let digest = SHA256.hash(data: Data(source.utf8)).map { String(format: "%02x", $0) }.joined()
return "\(entry.message.role)-\(digest)"
}
private nonisolated static func watchChatText(from message: OpenClawChatMessage) -> String {
let parts = message.content.compactMap { content -> String? in
let kind = (content.type ?? "text").lowercased()
guard kind.isEmpty || kind == "text" || kind == "output_text" else { return nil }
if let text = self.nonEmptyWatchChatText(content.text) {
return text
}
if let text = self.nonEmptyWatchChatText(content.content?.value as? String) {
return text
}
if let dict = content.content?.value as? [String: OpenClawKit.AnyCodable],
let text = self.nonEmptyWatchChatText(dict["text"]?.value as? String)
{
return text
}
return nil
}
let contentText = parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !contentText.isEmpty {
return contentText
}
return message.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
private nonisolated static func nonEmptyWatchChatText(_ text: String?) -> String? {
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private nonisolated static func truncatedWatchChatText(_ text: String) -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count > 240 else { return trimmed }
return "\(trimmed.prefix(237))..."
}
private nonisolated static func watchTimestampMs(_ timestamp: Double?) -> Int? {
guard let timestamp, timestamp.isFinite, timestamp >= 0 else { return nil }
let milliseconds = timestamp > 100_000_000_000 ? timestamp : timestamp * 1000
let maxReasonableEpochMs: Double = 32_503_680_000_000
guard milliseconds.isFinite,
milliseconds >= 0,
milliseconds <= maxReasonableEpochMs
else {
return nil
}
return Int(milliseconds)
}
private func makeWatchAppSnapshot(
chatPreview: WatchChatPreview? = nil) -> OpenClawWatchAppSnapshotMessage
{
self.pruneExpiredWatchExecApprovalPrompts()
let watchGatewayConnected = self.isAppleReviewDemoModeEnabled
|| (self.gatewayConnected && self.operatorConnected)
let displayStatusText = self.gatewayDisplayStatusText
let watchGatewayStatusText = watchGatewayConnected || displayStatusText != "Connected"
? displayStatusText
: self.operatorStatusText
return OpenClawWatchAppSnapshotMessage(
gatewayStatusText: watchGatewayStatusText,
gatewayConnected: watchGatewayConnected,
agentName: self.chatAgentName,
agentAvatarURL: self.chatAgentAvatarURL,
agentAvatarText: self.chatAgentAvatarText,
sessionKey: self.chatSessionKey,
gatewayStableID: self.currentWatchChatGatewayStableID(),
talkStatusText: self.talkMode.statusText,
talkEnabled: self.talkMode.isEnabled,
talkListening: self.talkMode.isListening,
talkSpeaking: self.talkMode.isSpeaking,
pendingApprovalCount: self.watchExecApprovalPromptsByID.count,
chatItems: chatPreview?.items,
chatStatusText: chatPreview?.statusText,
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
snapshotId: UUID().uuidString)
}
private func handleWatchAppCommand(_ event: WatchAppCommandEvent) async {
GatewayDiagnostics.log(
"watch app command: handle id=\(event.commandId) command=\(event.command.rawValue)")
if event.command != .sendChat,
!self.watchAppCommandTargetsCurrentGatewayIfTagged(event)
{
GatewayDiagnostics.log("watch app command skipped: stale gateway target")
await self.syncWatchAppSnapshot(reason: "watch_command_stale_gateway", includeChat: true)
return
}
switch event.command {
case .refresh:
break
case .openChat:
self.openChat(sessionKey: event.sessionKey ?? self.chatSessionKey)
case .sendChat:
await self.handleWatchChatCommand(event)
return
case .startTalk:
guard !self.isAppleReviewDemoModeEnabled else { break }
self.talkMode.updateMainSessionKey(event.sessionKey ?? self.chatSessionKey)
self.setTalkEnabled(true)
case .stopTalk:
self.setTalkEnabled(false)
}
await self.syncWatchAppSnapshot(
reason: "watch_command_\(event.command.rawValue)",
includeChat: true)
}
private func handleWatchChatCommand(_ event: WatchAppCommandEvent) async {
if self.currentWatchChatGatewayStableID() == nil {
// Startup may deliver a route-tagged Watch action before restoring that route.
// Queue it without publishing an ownerless snapshot that would erase Watch routing.
await self.handleWatchMessage(event)
return
}
guard self.watchMessageTargetsCurrentGateway(event) else {
GatewayDiagnostics.log("watch chat send skipped: stale gateway target")
await self.syncWatchAppSnapshot(reason: "watch_chat_stale_gateway", includeChat: true)
return
}
await self.handleWatchMessage(event)
}
private func handleWatchMessage(_ event: WatchAppCommandEvent) async {
let eventGatewayID = self.normalizedWatchMessageGatewayStableID(event)
let isAvailable = self.isWatchMessageSendAvailable()
if isAvailable, !self.watchMessageTargetsCurrentGateway(event) {
GatewayDiagnostics.log("watch message send skipped: stale gateway target")
return
}
switch self.watchMessageOutbox.ingest(
event,
isAvailable: isAvailable,
gatewayStableID: eventGatewayID)
{
case .dropMissingFields:
GatewayDiagnostics.log("watch message send skipped: missing id/text")
case .dropMissingTarget:
GatewayDiagnostics.log("watch message send skipped: missing gateway target")
case let .deduped(messageID):
GatewayDiagnostics.log("watch message send deduped id=\(messageID)")
case let .queue(messageID):
GatewayDiagnostics.log("watch message send queued id=\(messageID)")
if self.watchMessageKind(event) == .chat,
self.currentWatchChatGatewayStableID() != nil
{
await self.syncWatchAppSnapshot(reason: "watch_chat_queued", includeChat: true)
}
case .forward:
switch await self.forwardWatchMessage(event, requeueOnFailure: true) {
case .sent, .discard:
self.watchMessageOutbox.removeQueuedMessage(
messageID: event.commandId,
gatewayStableID: eventGatewayID)
self.watchMessageRetryAttempts[event.commandId] = nil
case .retry:
self.scheduleWatchMessageRetry(messageID: event.commandId)
}
}
}
private func flushQueuedWatchMessagesIfAvailable() async {
guard !self.watchMessageFlushInFlight else { return }
self.watchMessageFlushInFlight = true
defer { self.watchMessageFlushInFlight = false }
guard let gatewayStableID = currentWatchChatGatewayStableID() else { return }
while self.currentWatchChatGatewayStableID() == gatewayStableID {
guard let event = watchMessageOutbox.nextQueuedMessage(
isAvailable: isWatchMessageSendAvailable(),
gatewayStableID: gatewayStableID)
else { return }
guard self.watchMessageTargetsCurrentGateway(event) else { return }
switch await self.forwardWatchMessage(event, requeueOnFailure: false) {
case .sent, .discard:
self.watchMessageOutbox.removeQueuedMessage(
messageID: event.commandId,
gatewayStableID: gatewayStableID)
self.watchMessageRetryAttempts[event.commandId] = nil
case .retry:
self.scheduleWatchMessageRetry(messageID: event.commandId)
return
}
}
}
private func scheduleWatchMessageRetry(messageID: String) {
guard self.isWatchMessageSendAvailable(), self.watchMessageRetryTask == nil else { return }
let attempt = (watchMessageRetryAttempts[messageID] ?? 0) + 1
guard attempt <= Self.watchMessageMaxImmediateRetryAttempts else {
GatewayDiagnostics.log("watch message retry deferred until reconnect id=\(messageID)")
return
}
self.watchMessageRetryAttempts[messageID] = attempt
let delayNanoseconds = UInt64(500 * (1 << (attempt - 1))) * 1_000_000
self.watchMessageRetryTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: delayNanoseconds)
guard let self else { return }
self.watchMessageRetryTask = nil
await self.flushQueuedWatchMessagesIfAvailable()
}
}
private func isWatchMessageSendAvailable() -> Bool {
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
}
private func currentWatchChatGatewayStableID() -> String? {
self.connectedGatewayID?.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func normalizedWatchMessageGatewayStableID(_ event: WatchAppCommandEvent) -> String? {
let gatewayStableID = event.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return gatewayStableID.isEmpty ? nil : gatewayStableID
}
private func watchMessageTargetsCurrentGateway(_ event: WatchAppCommandEvent) -> Bool {
let eventGatewayID = self.normalizedWatchMessageGatewayStableID(event) ?? ""
let currentGatewayID = self.currentWatchChatGatewayStableID() ?? ""
guard !eventGatewayID.isEmpty, !currentGatewayID.isEmpty else { return false }
return eventGatewayID == currentGatewayID
}
private func watchAppCommandTargetsCurrentGatewayIfTagged(_ event: WatchAppCommandEvent) -> Bool {
guard let eventGatewayID = normalizedWatchMessageGatewayStableID(event) else {
// Ownerless commands predate route tagging and remain valid for compatibility.
return true
}
return eventGatewayID == self.currentWatchChatGatewayStableID()
}
private func watchMessageKind(_ event: WatchAppCommandEvent) -> WatchMessageKind {
event.messageKind ?? .chat
}
private func forwardWatchMessage(
_ event: WatchAppCommandEvent,
requeueOnFailure: Bool) async -> WatchMessageSendOutcome
{
guard self.watchMessageTargetsCurrentGateway(event) else {
GatewayDiagnostics.log("watch message send skipped: stale gateway target")
return .retry
}
let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !text.isEmpty else {
GatewayDiagnostics.log("watch chat send skipped: empty text")
return .discard
}
let messageKind = self.watchMessageKind(event)
let fallbackSessionKey = messageKind == .quickReply ? self.mainSessionKey : self.chatSessionKey
let sessionKey = (event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? event.sessionKey!
: fallbackSessionKey
if messageKind == .chat {
self.focusChatSession(sessionKey)
}
let thinking = messageKind == .quickReply ? "low" : "auto"
do {
let submittedAtMs = Int(Date().timeIntervalSince1970 * 1000)
if self.isAppleReviewDemoModeEnabled {
let response = try await appleReviewDemoChatTransport.sendMessage(
sessionKey: sessionKey,
message: text,
thinking: thinking,
idempotencyKey: event.commandId,
attachments: [])
if messageKind == .quickReply {
await self.finishForwardedWatchMessage(event)
return .sent
}
let history = try await appleReviewDemoChatTransport.requestHistory(sessionKey: sessionKey)
if let replyText = Self.watchChatReplyText(
from: history.messages ?? [],
runId: response.runId,
submittedText: text,
submittedAtMs: submittedAtMs)
{
await self.sendWatchChatCompletion(commandId: event.commandId, replyText: replyText)
}
await self.syncWatchAppSnapshot(reason: "watch_chat_completed", includeChat: true)
return .sent
}
guard self.isOperatorGatewayConnected else {
GatewayDiagnostics.log("watch chat send skipped: operator gateway disconnected")
if requeueOnFailure {
self.watchMessageOutbox.requeueFront(
event,
gatewayStableID: self.normalizedWatchMessageGatewayStableID(event))
}
return .retry
}
guard self.watchMessageTargetsCurrentGateway(event),
let operatorRoute = await operatorSession.currentRoute(),
isOperatorGatewayConnected,
watchMessageTargetsCurrentGateway(event)
else {
GatewayDiagnostics.log("watch chat send skipped: gateway route changed before dispatch")
return .retry
}
let transport = IOSGatewayChatTransport(gateway: operatorSession)
let completionDeadline = Date().addingTimeInterval(
Double(Self.watchChatCompletionWaitMs) / 1000)
let response = try await transport.sendMessage(
sessionKey: sessionKey,
message: text,
thinking: thinking,
idempotencyKey: event.commandId,
attachments: [],
ifCurrentRoute: operatorRoute)
if messageKind == .quickReply {
await self.finishForwardedWatchMessage(event)
return .sent
}
await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true)
_ = await transport.waitForRunCompletion(
runId: response.runId,
timeoutMs: Self.watchChatRunWaitSliceMs,
ifCurrentRoute: operatorRoute)
if let replyText = await waitForWatchChatReply(
transport: transport,
sessionKey: sessionKey,
runId: response.runId,
submittedText: text,
submittedAtMs: submittedAtMs,
deadline: completionDeadline,
expectedRoute: operatorRoute)
{
guard self.watchMessageTargetsCurrentGateway(event),
await self.operatorSession.currentRoute() == operatorRoute
else {
GatewayDiagnostics.log("watch chat completion skipped: gateway route changed")
return .discard
}
await self.sendWatchChatCompletion(commandId: event.commandId, replyText: replyText)
}
await self.syncWatchAppSnapshot(
reason: "watch_chat_completed",
includeChat: true,
shouldContinue: { self.watchMessageTargetsCurrentGateway(event) })
return .sent
} catch is CancellationError {
if !self.watchMessageTargetsCurrentGateway(event) {
GatewayDiagnostics.log("watch chat send canceled: gateway target changed")
return .discard
}
GatewayDiagnostics.log("watch chat send canceled before dispatch")
if requeueOnFailure {
self.watchMessageOutbox.requeueFront(
event,
gatewayStableID: self.normalizedWatchMessageGatewayStableID(event))
}
return .retry
} catch {
GatewayDiagnostics.log("watch chat send failed error=\(error.localizedDescription)")
if Self.shouldDiscardFailedWatchMessage(error) {
GatewayDiagnostics.log("watch message discarded after permanent send failure id=\(event.commandId)")
return .discard
}
if requeueOnFailure {
self.watchMessageOutbox.requeueFront(
event,
gatewayStableID: self.normalizedWatchMessageGatewayStableID(event))
}
return .retry
}
}
private func waitForWatchChatReply(
transport: IOSGatewayChatTransport,
sessionKey: String,
runId: String,
submittedText: String,
submittedAtMs: Int,
deadline: Date,
expectedRoute: GatewayNodeSessionRoute) async -> String?
{
repeat {
guard await self.operatorSession.currentRoute() == expectedRoute else { return nil }
if let payload = try? await transport.requestHistory(
sessionKey: sessionKey,
ifCurrentRoute: expectedRoute),
let replyText = Self.watchChatReplyText(
from: payload.messages ?? [],
runId: runId,
submittedText: submittedText,
submittedAtMs: submittedAtMs)
{
return replyText
}
guard Date() < deadline else { return nil }
try? await Task.sleep(for: .seconds(1))
} while !Task.isCancelled
return nil
}
private func sendWatchChatCompletion(commandId: String, replyText: String) async {
do {
_ = try await self.watchMessagingService.sendChatCompletion(
OpenClawWatchChatCompletionMessage(
commandId: commandId,
replyText: replyText,
sentAtMs: Int(Date().timeIntervalSince1970 * 1000)))
} catch {
GatewayDiagnostics.log(
"watch chat completion failed commandId=\(commandId) error=\(error.localizedDescription)")
}
}
private nonisolated static func shouldDiscardFailedWatchMessage(_ error: Error) -> Bool {
guard let gatewayError = error as? GatewayResponseError else { return false }
guard gatewayError.code == "INVALID_REQUEST" else { return false }
return !gatewayError.message.lowercased().hasSuffix("retry.")
}
private func finishForwardedWatchMessage(_ event: WatchAppCommandEvent) async {
if self.watchMessageKind(event) == .chat {
await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true)
return
}
self.watchReplyLogger.info(
"watch reply forwarded replyId=\(event.commandId, privacy: .public)")
self.openChatRequestID &+= 1
}
private func syncWatchAppSnapshot(
reason: String,
includeChat: Bool = false,
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
{
guard shouldContinue() else { return }
let deliveryGeneration = self.gatewayConnectGeneration
let chatPreview = includeChat ? await makeWatchChatPreview() : nil
guard shouldContinue() else { return }
guard deliveryGeneration == self.gatewayConnectGeneration else {
await self.syncWatchAppSnapshot(reason: "\(reason)_route_repair")
return
}
let message = self.makeWatchAppSnapshot(chatPreview: chatPreview)
do {
guard shouldContinue() else { return }
_ = try await self.watchMessagingService.syncAppSnapshot(message)
GatewayDiagnostics.log(
"watch app snapshot: sent reason=\(reason) "
+ "connected=\(message.gatewayConnected) approvals=\(message.pendingApprovalCount) "
+ "chatItems=\(message.chatItems?.count ?? -1)")
if deliveryGeneration != self.gatewayConnectGeneration {
await self.syncWatchAppSnapshot(reason: "\(reason)_route_repair")
}
} catch {
GatewayDiagnostics.log(
"watch app snapshot: failed reason=\(reason) error=\(error.localizedDescription)")
}
}
private func refreshWatchExecApprovalSnapshotOnDemand(reason: String) async {
GatewayDiagnostics.log("watch exec approval: refresh on demand start reason=\(reason)")
await self.hydrateWatchExecApprovalCacheIfNeeded(reason: reason)
await self.syncWatchExecApprovalSnapshot(reason: reason)
await self.syncWatchAppSnapshot(reason: "\(reason)_app", includeChat: true)
GatewayDiagnostics.log("watch exec approval: refresh on demand end reason=\(reason)")
}
private nonisolated static func watchExecApprovalIDsNeedingFetch(
candidateIDs: [String],
cachedApprovalIDs: [String]) -> [String]
{
let cachedIDs = Set(cachedApprovalIDs.compactMap { id -> String? in
let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines)
return normalizedID.isEmpty ? nil : normalizedID
})
var idsToFetch: [String] = []
var seen = Set<String>()
for rawID in candidateIDs {
let normalizedID = rawID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedID.isEmpty else { continue }
guard seen.insert(normalizedID).inserted else { continue }
guard !cachedIDs.contains(normalizedID) else { continue }
idsToFetch.append(normalizedID)
}
return idsToFetch
}
private func hydrateWatchExecApprovalCacheIfNeeded(reason: String) async {
self.pruneExpiredWatchExecApprovalPrompts()
let approvalPushes = await pendingExecApprovalPushesForWatchRecovery()
let missingApprovalIDs = Set(Self.watchExecApprovalIDsNeedingFetch(
candidateIDs: approvalPushes.map(\.approvalId),
cachedApprovalIDs: Array(self.watchExecApprovalPromptsByID.keys)))
GatewayDiagnostics.log(
"watch exec approval: hydrate candidates "
+ "reason=\(reason) ids=\(approvalPushes.map(\.approvalId).joined(separator: ",")) "
+ "missing=\(missingApprovalIDs.sorted().joined(separator: ",")) "
+ "cached=\(self.watchExecApprovalPromptsByID.count)")
guard !missingApprovalIDs.isEmpty else {
self.watchExecApprovalLogger.debug(
"watch exec approval hydrate skipped reason=\(reason, privacy: .public): no missing approval ids")
return
}
for push in approvalPushes where missingApprovalIDs.contains(push.approvalId) {
let approvalId = push.approvalId
GatewayDiagnostics.log(
"watch exec approval: hydrate fetch start id=\(approvalId) reason=\(reason)")
let operatorRoute: GatewayNodeSessionRoute
switch await self.validateExecApprovalPushRoute(push, sourceReason: reason) {
case let .validated(context):
operatorRoute = context.route
case .unavailable:
continue
case .mismatchedOwner:
await ExecApprovalNotificationBridge.removeNotifications(
for: push,
notificationCenter: self.notificationCenter)
self.removePendingWatchExecApprovalRecoveryPush(push)
continue
}
let outcome = await fetchExecApprovalPrompt(
approvalId: approvalId,
sourceReason: reason,
expectedOperatorRoute: operatorRoute)
switch outcome {
case let .loaded(prompt):
GatewayDiagnostics.log("watch exec approval: hydrate fetch loaded id=\(approvalId)")
self.upsertWatchExecApprovalPrompt(prompt)
case .stale:
GatewayDiagnostics.log("watch exec approval: hydrate fetch stale id=\(approvalId)")
self.removePendingWatchExecApprovalRecoveryPush(push)
await ExecApprovalNotificationBridge.removeNotifications(
for: push,
notificationCenter: self.notificationCenter)
case let .failed(message):
self.watchExecApprovalLogger
.error("watch approval hydrate failed id=\(approvalId, privacy: .public)")
self.watchExecApprovalLogger.error("watch approval hydrate reason=\(reason, privacy: .public)")
self.watchExecApprovalLogger.error("watch approval hydrate error=\(message, privacy: .public)")
}
}
}
private func pendingExecApprovalPushesForWatchRecovery() async -> [ExecApprovalNotificationPrompt] {
var pushes = self.pendingWatchExecApprovalRecoveryPushes
var seen = Set(pushes)
let delivered = await notificationCenter.deliveredNotifications()
GatewayDiagnostics.log("watch exec approval: delivered notifications count=\(delivered.count)")
for snapshot in delivered {
guard let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: snapshot.userInfo),
seen.insert(push).inserted
else { continue }
pushes.append(push)
// Notification Center may be the only surviving source after relaunch.
// Persist its owner tag so later route invalidation can remove only this alert.
self.appendPendingWatchExecApprovalRecoveryPush(push)
}
return pushes
}
@discardableResult
private func handleWatchExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) async -> Bool {
let normalizedApprovalID = event.approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return true }
guard let routedEvent = ownerScopedWatchExecApprovalEvent(
event,
approvalID: normalizedApprovalID)
else {
await self.syncWatchExecApprovalSnapshot(reason: "legacy_watch_reply_rejected")
return true
}
guard let currentGatewayStableID = currentExecApprovalGatewayStableID() else {
self.enqueuePendingWatchExecApprovalResolution(routedEvent)
return false
}
guard Self.trimmedOrNil(routedEvent.gatewayStableID) == currentGatewayStableID else {
// Watch replies can arrive after a gateway switch. Reassert the current
// snapshot instead of allowing an old same-ID prompt to target the new gateway.
await self.syncWatchExecApprovalSnapshot(reason: "stale_gateway_reply")
return true
}
guard let prompt = watchExecApprovalPromptsByID[normalizedApprovalID],
prompt.gatewayStableID == currentGatewayStableID,
isExecApprovalPromptCurrent(prompt)
else {
await self.publishWatchExecApprovalExpired(
approvalId: normalizedApprovalID,
gatewayStableID: currentGatewayStableID,
reason: .unavailable)
return true
}
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
self.pendingExecApprovalPromptResolving = true
self.pendingExecApprovalPromptErrorText = nil
}
let outcome = await resolveExecApprovalNotificationDecision(
approvalId: normalizedApprovalID,
decision: routedEvent.decision.rawValue,
expectedGatewayStableID: prompt.gatewayStableID,
sourceReason: "watch_resolve")
if case let .failed(message) = outcome {
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
self.pendingExecApprovalPromptResolving = false
self.pendingExecApprovalPromptErrorText = message
}
if let prompt = watchExecApprovalPromptsByID[normalizedApprovalID] {
await self.publishWatchExecApprovalPrompt(prompt, reason: "resolve_retry")
}
return false
}
return true
}
private func ownerScopedWatchExecApprovalEvent(
_ event: WatchExecApprovalResolveEvent,
approvalID: String) -> WatchExecApprovalResolveEvent?
{
if Self.trimmedOrNil(event.gatewayStableID) != nil {
return event
}
guard let prompt = watchExecApprovalPromptsByID[approvalID] else { return nil }
// A shipped Watch binary can omit the owner field. Bind only to the prompt that
// originally supplied this approval ID; never infer ownership from a later route.
var routedEvent = event
routedEvent.gatewayStableID = prompt.gatewayStableID
return routedEvent
}
private func enqueuePendingWatchExecApprovalResolution(_ event: WatchExecApprovalResolveEvent) {
let replyID = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !replyID.isEmpty,
!self.pendingWatchExecApprovalResolutions.contains(where: { $0.replyId == replyID })
else { return }
// transferUserInfo is durable only until delivery. Retain the delivered action until
// startup restores a route, while bounding malformed or replayed Watch traffic.
self.pendingWatchExecApprovalResolutions.append(event)
if self.pendingWatchExecApprovalResolutions.count > 32 {
self.pendingWatchExecApprovalResolutions.removeFirst()
}
self.persistWatchExecApprovalBridgeState()
}
private func removePendingWatchExecApprovalResolution(replyID: String) {
let originalCount = self.pendingWatchExecApprovalResolutions.count
self.pendingWatchExecApprovalResolutions.removeAll { $0.replyId == replyID }
guard self.pendingWatchExecApprovalResolutions.count != originalCount else { return }
self.persistWatchExecApprovalBridgeState()
}
private func flushPendingWatchExecApprovalResolutions(
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
{
guard shouldContinue(), !self.pendingWatchExecApprovalResolutions.isEmpty else { return }
await self.hydrateWatchExecApprovalCacheIfNeeded(reason: "queued_watch_resolve")
guard shouldContinue(), let currentGatewayStableID = currentExecApprovalGatewayStableID() else { return }
let pending = self.pendingWatchExecApprovalResolutions
var discardedMismatchedOwner = false
for event in pending {
guard shouldContinue() else { return }
let owner = Self.trimmedOrNil(event.gatewayStableID)
guard owner == currentGatewayStableID else {
discardedMismatchedOwner = true
self.removePendingWatchExecApprovalResolution(replyID: event.replyId)
continue
}
let completed = await handleWatchExecApprovalResolve(event)
if completed {
self.removePendingWatchExecApprovalResolution(replyID: event.replyId)
}
}
if discardedMismatchedOwner, shouldContinue() {
await self.syncWatchExecApprovalSnapshot(reason: "queued_stale_gateway_reply")
}
}
func handleExecApprovalRequestedRemotePush(_ push: ExecApprovalNotificationPrompt) async -> Bool {
let normalizedApprovalID = push.approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return false }
let operatorRoute: GatewayNodeSessionRoute
switch await self.validateExecApprovalPushRoute(push, sourceReason: "push_request") {
case let .validated(context):
operatorRoute = context.route
case .unavailable:
// APNs delivery is one-shot. Retain the owner-tagged request until its route
// returns so Watch recovery cannot lose an approval during a reconnect.
self.appendPendingWatchExecApprovalRecoveryPush(push)
return true
case .mismatchedOwner:
await ExecApprovalNotificationBridge.removeNotifications(
for: push,
notificationCenter: self.notificationCenter)
self.removePendingWatchExecApprovalRecoveryPush(push)
return true
}
self.appendPendingWatchExecApprovalRecoveryPush(push)
guard let gatewayStableID = currentExecApprovalGatewayStableID() else { return true }
let fetchedPrompt = await fetchExecApprovalPrompt(
approvalId: normalizedApprovalID,
sourceReason: "push_request",
expectedOperatorRoute: operatorRoute)
switch fetchedPrompt {
case let .loaded(prompt):
self.upsertWatchExecApprovalPrompt(prompt)
await self.publishWatchExecApprovalPrompt(prompt, reason: "push_request")
return true
case .stale:
await ExecApprovalNotificationBridge.removeNotifications(
for: push,
notificationCenter: self.notificationCenter)
self.removePendingWatchExecApprovalRecoveryPush(push)
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
await self.publishWatchExecApprovalExpired(
approvalId: normalizedApprovalID,
gatewayStableID: gatewayStableID,
reason: .notFound)
return true
case let .failed(message):
self.watchExecApprovalLogger
.error(
"watch approval push fetch failed id=\(normalizedApprovalID, privacy: .public)")
self.watchExecApprovalLogger.error("watch approval push fetch error=\(message, privacy: .public)")
return false
}
}
@discardableResult
private func handleExecApprovalResolvedForCurrentGateway(
approvalId: String,
recoveryPushGatewayDeviceID: String? = nil,
routeContext: GatewaySessionRouteContext? = nil,
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
-> Bool
{
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty,
await self.canApplyExecApprovalResolvedState(
routeContext: routeContext,
shouldContinue: shouldContinue)
else { return false }
let currentGatewayStableID = self.currentExecApprovalGatewayStableID()
let hadWatchPrompt = if let currentGatewayStableID {
self.watchExecApprovalPromptsByID[normalizedApprovalID]?.gatewayStableID == currentGatewayStableID
} else {
false
}
let hadPendingPrompt = if let currentGatewayStableID {
self.pendingExecApprovalPrompt?.id == normalizedApprovalID &&
self.pendingExecApprovalPrompt?.gatewayStableID == currentGatewayStableID
} else {
false
}
let recoveryPushes: [ExecApprovalNotificationPrompt] = if let recoveryPushGatewayDeviceID = Self
.trimmedOrNil(recoveryPushGatewayDeviceID)
{
self.pendingWatchExecApprovalRecoveryPushes.filter { push in
push.approvalId == normalizedApprovalID &&
Self.trimmedOrNil(push.gatewayDeviceId) == recoveryPushGatewayDeviceID
}
} else {
[]
}
let hadPendingRecoveryID = !recoveryPushes.isEmpty
let hadGuidancePrompt = self.pendingNotificationPermissionGuidancePrompt?.approvalId == normalizedApprovalID
let hadApprovalSurface = hadWatchPrompt || hadPendingPrompt || hadPendingRecoveryID
guard hadApprovalSurface || hadGuidancePrompt else {
return true
}
if hadApprovalSurface, let currentGatewayStableID {
await self.publishWatchExecApprovalExpired(
approvalId: normalizedApprovalID,
gatewayStableID: currentGatewayStableID,
reason: .resolved)
guard await self.canApplyExecApprovalResolvedState(
routeContext: routeContext,
shouldContinue: shouldContinue)
else { return false }
}
for push in recoveryPushes {
await ExecApprovalNotificationBridge.removeNotifications(
for: push,
notificationCenter: self.notificationCenter)
guard await self.canApplyExecApprovalResolvedState(
routeContext: routeContext,
shouldContinue: shouldContinue)
else { return false }
self.removePendingWatchExecApprovalRecoveryPush(push)
}
guard await self.canApplyExecApprovalResolvedState(
routeContext: routeContext,
shouldContinue: shouldContinue)
else { return false }
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
return true
}
private func canApplyExecApprovalResolvedState(
routeContext: GatewaySessionRouteContext?,
shouldContinue: @MainActor @Sendable () -> Bool) async -> Bool
{
guard shouldContinue() else { return false }
guard let routeContext else { return true }
return await self.isCurrentGatewaySessionRoute(
routeContext,
session: self.operatorGateway,
shouldContinue: shouldContinue)
}
func handleExecApprovalResolvedRemotePush(_ push: ExecApprovalNotificationPrompt) async -> Bool {
switch await self.validateExecApprovalPushRoute(push, sourceReason: "push_resolved") {
case let .validated(context):
let applied = await self.applyValidatedExecApprovalResolvedPush(push, context: context)
if !applied {
self.appendPendingExecApprovalResolvedPush(push)
}
case .unavailable:
self.appendPendingExecApprovalResolvedPush(push)
if Self.trimmedOrNil(push.gatewayDeviceId) != nil {
// The terminal push already identifies its notification owner. Remove that
// exact alert now while retaining durable state for route-bound Watch cleanup.
await ExecApprovalNotificationBridge.removeNotifications(
for: push,
notificationCenter: self.notificationCenter)
}
case .mismatchedOwner:
// The payload names another gateway. Exact owner matching makes cleanup safe,
// but it must not mutate approval state for the active gateway.
await ExecApprovalNotificationBridge.removeNotifications(
for: push,
notificationCenter: self.notificationCenter)
self.removePendingWatchExecApprovalRecoveryPush(push)
self.removePendingExecApprovalResolvedPush(push)
}
return true
}
@discardableResult
private func applyValidatedExecApprovalResolvedPush(
_ push: ExecApprovalNotificationPrompt,
context: GatewaySessionRouteContext) async -> Bool
{
let routeIsCurrent: @MainActor @Sendable () -> Bool = { [weak self] in
self?.isCurrentGatewayRoute(
generation: context.routeGeneration,
stableID: context.gatewayStableID) == true
}
guard await self.isCurrentGatewaySessionRoute(
context,
session: self.operatorGateway,
shouldContinue: routeIsCurrent)
else { return false }
guard await self.handleExecApprovalResolvedForCurrentGateway(
approvalId: push.approvalId,
recoveryPushGatewayDeviceID: push.gatewayDeviceId,
routeContext: context,
shouldContinue: routeIsCurrent)
else { return false }
guard await self.isCurrentGatewaySessionRoute(
context,
session: self.operatorGateway,
shouldContinue: routeIsCurrent)
else { return false }
await ExecApprovalNotificationBridge.removeNotifications(
for: push,
notificationCenter: self.notificationCenter,
includingLegacyOwnerless: true)
guard await self.isCurrentGatewaySessionRoute(
context,
session: self.operatorGateway,
shouldContinue: routeIsCurrent)
else { return false }
self.removePendingWatchExecApprovalRecoveryPush(push)
self.removePendingExecApprovalResolvedPush(push)
return true
}
private func flushPendingExecApprovalResolvedPushes(
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
{
guard shouldContinue(), !self.pendingExecApprovalResolvedPushes.isEmpty else { return }
for push in self.pendingExecApprovalResolvedPushes {
guard shouldContinue() else { return }
switch await self.validateExecApprovalPushRoute(
push,
sourceReason: "push_resolved",
shouldContinue: shouldContinue)
{
case let .validated(context):
guard await self.applyValidatedExecApprovalResolvedPush(push, context: context) else {
return
}
case .unavailable:
return
case .mismatchedOwner:
await ExecApprovalNotificationBridge.removeNotifications(
for: push,
notificationCenter: self.notificationCenter)
self.removePendingWatchExecApprovalRecoveryPush(push)
self.removePendingExecApprovalResolvedPush(push)
}
}
}
func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool {
let wakeId = Self.makePushWakeAttemptID()
guard Self.isSilentPushPayload(userInfo) else {
self.pushWakeLogger.info("Ignored APNs payload wakeId=\(wakeId, privacy: .public): not silent push")
return false
}
let pushKind = Self.openclawPushKind(userInfo)
let receivedMessage =
"Silent push received wakeId=\(wakeId) "
+ "kind=\(pushKind) "
+ "backgrounded=\(isBackgrounded) "
+ "autoReconnect=\(gatewayAutoReconnectEnabled)"
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
if let push = ExecApprovalNotificationBridge.parseResolvedPush(userInfo: userInfo) {
let handled = await handleExecApprovalResolvedRemotePush(push)
let cleanupMessage =
"Handled exec approval cleanup push wakeId=\(wakeId) "
+ "handled=\(handled)"
self.execApprovalNotificationLogger.info(
"\(cleanupMessage, privacy: .public)")
return handled
}
if let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: userInfo) {
let handled = await handleExecApprovalRequestedRemotePush(push)
if handled {
let handledMessage =
"handled approval push wakeId=\(wakeId) "
+ "id=\(push.approvalId)"
self.execApprovalNotificationLogger
.info("\(handledMessage, privacy: .public)")
}
return handled
}
let result = await performBackgroundAliveBeaconIfNeeded(
wakeId: wakeId,
trigger: .silentPush)
let outcomeMessage =
"Silent push outcome wakeId=\(wakeId) "
+ "applied=\(result.applied) "
+ "handled=\(result.handled) "
+ "reason=\(result.reason) "
+ "durationMs=\(result.durationMs)"
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
return result.handled
}
func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool {
let wakeId = Self.makePushWakeAttemptID()
let normalizedTrigger = BackgroundAliveBeacon.normalizeTrigger(trigger)
let receivedMessage =
"Background refresh wake received wakeId=\(wakeId) "
+ "trigger=\(normalizedTrigger.rawValue) "
+ "backgrounded=\(self.isBackgrounded) "
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
let result = await performBackgroundAliveBeaconIfNeeded(
wakeId: wakeId,
trigger: normalizedTrigger)
let outcomeMessage =
"Background refresh wake outcome wakeId=\(wakeId) "
+ "applied=\(result.applied) "
+ "handled=\(result.handled) "
+ "reason=\(result.reason) "
+ "durationMs=\(result.durationMs)"
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
return result.handled
}
func handleSignificantLocationWakeIfNeeded() async {
let wakeId = Self.makePushWakeAttemptID()
let now = Date()
let throttleWindowSeconds: TimeInterval = 180
if await isGatewayConnected() {
self.locationWakeLogger.info(
"Location wake no-op wakeId=\(wakeId, privacy: .public): already connected")
return
}
if let last = lastSignificantLocationWakeAt,
now.timeIntervalSince(last) < throttleWindowSeconds
{
let throttledMessage =
"Location wake throttled wakeId=\(wakeId) "
+ "elapsedSec=\(now.timeIntervalSince(last))"
self.locationWakeLogger.info("\(throttledMessage, privacy: .public)")
return
}
self.lastSignificantLocationWakeAt = now
let beginMessage =
"Location wake begin wakeId=\(wakeId) "
+ "backgrounded=\(isBackgrounded) "
+ "autoReconnect=\(gatewayAutoReconnectEnabled)"
self.locationWakeLogger.info("\(beginMessage, privacy: .public)")
let result = await performBackgroundAliveBeaconIfNeeded(
wakeId: wakeId,
trigger: .significantLocation)
let triggerMessage =
"Location wake trigger wakeId=\(wakeId) "
+ "applied=\(result.applied) "
+ "handled=\(result.handled) "
+ "reason=\(result.reason) "
+ "durationMs=\(result.durationMs)"
self.locationWakeLogger.info("\(triggerMessage, privacy: .public)")
guard result.applied else { return }
let connected = await waitForGatewayConnection(timeoutMs: 5000, pollMs: 250)
self.locationWakeLogger.info(
"Location wake post-check wakeId=\(wakeId, privacy: .public) connected=\(connected, privacy: .public)")
}
func updateAPNsDeviceToken(_ tokenData: Data) {
let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
let trimmed = tokenHex.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.apnsDeviceTokenHex = trimmed
UserDefaults.standard.set(trimmed, forKey: Self.apnsDeviceTokenUserDefaultsKey)
Task { [weak self] in
await self?.registerAPNsTokenIfNeeded()
}
}
private func registerAPNsTokenIfNeeded(
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
{
guard shouldContinue() else { return }
let usesRelayTransport = await pushRegistrationManager.usesRelayTransport
guard shouldContinue() else { return }
guard await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport) else {
return
}
guard shouldContinue() else { return }
guard self.gatewayConnected else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("gateway_offline")
}
return
}
guard let nodeRoute = await nodeGateway.currentRoute(), shouldContinue() else { return }
guard let token = apnsDeviceTokenHex?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("missing_apns_token")
}
return
}
let gatewayStableID = self.activeGatewayConnectConfig?.effectiveStableID
?? self.connectedGatewayID
?? ""
if !usesRelayTransport,
!Self.shouldPublishDirectAPNsRegistration(
token: token,
gatewayStableID: gatewayStableID,
lastToken: self.apnsLastRegisteredTokenHex,
lastGatewayStableID: self.apnsLastRegisteredGatewayStableID)
{
return
}
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!topic.isEmpty
else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("missing_topic")
}
return
}
do {
let gatewayIdentity: PushRelayGatewayIdentity?
if usesRelayTransport {
guard self.operatorConnected else {
GatewayDiagnostics.pushRelay.skipped("operator_offline")
return
}
GatewayDiagnostics.pushRelay.stage("gateway identity request start")
gatewayIdentity = try await self.fetchPushRelayGatewayIdentity()
guard shouldContinue() else { return }
GatewayDiagnostics.pushRelay.stage("gateway identity request complete")
} else {
gatewayIdentity = nil
}
if usesRelayTransport {
GatewayDiagnostics.pushRelay.stage("gateway registration payload start")
}
let payloadJSON = try await pushRegistrationManager.makeGatewayRegistrationPayload(
apnsTokenHex: token,
topic: topic,
gatewayIdentity: gatewayIdentity)
guard shouldContinue() else { return }
let published = await nodeGateway.sendEvent(
event: "push.apns.register",
payloadJSON: payloadJSON,
ifCurrentRoute: nodeRoute)
guard published, shouldContinue() else { return }
self.apnsLastRegisteredTokenHex = token
self.apnsLastRegisteredGatewayStableID = gatewayStableID
if usesRelayTransport {
GatewayDiagnostics.pushRelay.stage("gateway registration event published")
}
} catch {
self.pushWakeLogger.error(
"APNs registration publish failed: \(error.localizedDescription, privacy: .public)")
if usesRelayTransport {
GatewayDiagnostics.pushRelay.failed("registration", error: error)
}
}
}
private func canPublishAPNsRegistration(usesRelayTransport: Bool) async -> Bool {
guard PushEnrollmentConsent.disclosureAccepted else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("enrollment_disclosure_not_accepted")
}
return false
}
let status = await notificationAuthorizationStatus()
guard Self.isNotificationAuthorizationAllowed(status) else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("notifications_not_authorized")
}
return false
}
return true
}
nonisolated static func shouldPublishDirectAPNsRegistration(
token: String,
gatewayStableID: String,
lastToken: String?,
lastGatewayStableID: String?) -> Bool
{
token != lastToken || gatewayStableID != lastGatewayStableID
}
private func fetchPushRelayGatewayIdentity(
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute? = nil) async throws -> PushRelayGatewayIdentity
{
let response = try await operatorGateway.request(
method: "gateway.identity.get",
paramsJSON: "{}",
timeoutSeconds: 8,
ifCurrentRoute: expectedRoute)
let decoded = try JSONDecoder().decode(GatewayRelayIdentityResponse.self, from: response)
let deviceId = decoded.deviceId.trimmingCharacters(in: .whitespacesAndNewlines)
let publicKey = decoded.publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !deviceId.isEmpty, !publicKey.isEmpty else {
throw PushRelayError.relayMisconfigured("Gateway identity response missing required fields")
}
return PushRelayGatewayIdentity(deviceId: deviceId, publicKey: publicKey)
}
private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool {
guard let apsAny = userInfo["aps"] else { return false }
if let aps = apsAny as? [AnyHashable: Any] {
return Self.hasContentAvailable(aps["content-available"])
}
if let aps = apsAny as? [String: Any] {
return Self.hasContentAvailable(aps["content-available"])
}
return false
}
private static func hasContentAvailable(_ value: Any?) -> Bool {
if let number = value as? NSNumber {
return number.intValue == 1
}
if let text = value as? String {
return text.trimmingCharacters(in: .whitespacesAndNewlines) == "1"
}
return false
}
private static func makePushWakeAttemptID() -> String {
let raw = UUID().uuidString.replacingOccurrences(of: "-", with: "")
return String(raw.prefix(8))
}
private static func openclawPushKind(_ userInfo: [AnyHashable: Any]) -> String {
if let payload = userInfo["openclaw"] as? [String: Any],
let kind = payload["kind"] as? String
{
let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
if let payload = userInfo["openclaw"] as? [AnyHashable: Any],
let kind = payload["kind"] as? String
{
let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
return "unknown"
}
private struct ExecApprovalGetRequest: Encodable {
let id: String
}
private struct ExecApprovalResolveRequest: Encodable {
let id: String
let decision: String
}
private struct ExecApprovalGetResponse: Decodable {
var id: String
var commandText: String
var commandPreview: String?
var allowedDecisions: [String]
var host: String?
var nodeId: String?
var agentId: String?
var expiresAtMs: Int?
}
func presentExecApprovalNotificationPrompt(
_ prompt: ExecApprovalNotificationPrompt,
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
{
let approvalId = prompt.approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard shouldContinue(), !approvalId.isEmpty else { return }
let operatorRoute: GatewayNodeSessionRoute
switch await self.validateExecApprovalPushRoute(
prompt,
sourceReason: "notification_action",
shouldContinue: shouldContinue)
{
case let .validated(context):
operatorRoute = context.route
case .unavailable:
guard shouldContinue() else { return }
self.appendPendingWatchExecApprovalRecoveryPush(prompt)
return
case .mismatchedOwner:
await ExecApprovalNotificationBridge.removeNotifications(
for: prompt,
notificationCenter: self.notificationCenter)
self.removePendingWatchExecApprovalRecoveryPush(prompt)
return
}
self.appendPendingWatchExecApprovalRecoveryPush(prompt)
await self.presentExecApprovalPrompt(
approvalId: approvalId,
notificationPush: prompt,
expectedOperatorRoute: operatorRoute,
shouldContinue: shouldContinue)
}
private func presentExecApprovalGatewayEventPrompt(
approvalId: String,
expectedOperatorRoute: GatewayNodeSessionRoute? = nil,
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
{
await self.presentExecApprovalPrompt(
approvalId: approvalId,
notificationPush: nil,
expectedOperatorRoute: expectedOperatorRoute,
shouldContinue: shouldContinue)
}
private func presentExecApprovalPrompt(
approvalId: String,
notificationPush: ExecApprovalNotificationPrompt?,
expectedOperatorRoute: GatewayNodeSessionRoute?,
shouldContinue: @MainActor @Sendable () -> Bool) async
{
guard shouldContinue(), !approvalId.isEmpty else { return }
self.pendingExecApprovalPromptRequestGeneration &+= 1
let requestGeneration = self.pendingExecApprovalPromptRequestGeneration
self.pendingExecApprovalPromptResolving = true
self.pendingExecApprovalPromptErrorText = nil
let fetchedPrompt = await fetchExecApprovalPrompt(
approvalId: approvalId,
expectedOperatorRoute: expectedOperatorRoute,
shouldContinue: shouldContinue)
guard shouldContinue(), self.pendingExecApprovalPromptRequestGeneration == requestGeneration else {
if self.pendingExecApprovalPromptRequestGeneration == requestGeneration {
self.pendingExecApprovalPromptResolving = false
}
return
}
self.pendingExecApprovalPromptResolving = false
switch fetchedPrompt {
case let .loaded(fetchedPrompt):
self.presentFetchedExecApprovalPrompt(fetchedPrompt)
case .stale:
if let notificationPush {
await ExecApprovalNotificationBridge.removeNotifications(
for: notificationPush,
notificationCenter: self.notificationCenter)
self.removePendingWatchExecApprovalRecoveryPush(notificationPush)
}
self.clearPendingExecApprovalPromptIfMatches(approvalId)
if let gatewayStableID = currentExecApprovalGatewayStableID() {
await self.publishWatchExecApprovalExpired(
approvalId: approvalId,
gatewayStableID: gatewayStableID,
reason: .notFound)
}
case let .failed(message):
self.execApprovalNotificationLogger
.error("approval prompt fetch failed id=\(approvalId, privacy: .public)")
self.execApprovalNotificationLogger.error("approval prompt fetch reason=\(message, privacy: .public)")
}
}
private enum ExecApprovalPromptFetchOutcome {
case loaded(ExecApprovalPrompt)
case stale
case failed(message: String)
}
private func presentFetchedExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
guard self.isExecApprovalPromptCurrent(prompt) else { return }
self.pendingExecApprovalPrompt = prompt
self.pendingExecApprovalPromptResolving = false
self.pendingExecApprovalPromptErrorText = nil
self.upsertWatchExecApprovalPrompt(prompt)
Task { @MainActor [weak self] in
await self?.publishWatchExecApprovalPrompt(prompt, reason: "present_prompt")
}
}
private static func makeExecApprovalPrompt(
from details: ExecApprovalGetResponse,
gatewayStableID: String) -> ExecApprovalPrompt?
{
let approvalId = details.id.trimmingCharacters(in: .whitespacesAndNewlines)
let commandText = details.commandText.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedGatewayStableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !approvalId.isEmpty, !commandText.isEmpty, !normalizedGatewayStableID.isEmpty else { return nil }
return ExecApprovalPrompt(
id: approvalId,
gatewayStableID: normalizedGatewayStableID,
commandText: commandText,
commandPreview: details.commandPreview?.trimmingCharacters(in: .whitespacesAndNewlines),
allowedDecisions: details.allowedDecisions.compactMap { decision in
let trimmed = decision.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
},
host: details.host?.trimmingCharacters(in: .whitespacesAndNewlines),
nodeId: details.nodeId?.trimmingCharacters(in: .whitespacesAndNewlines),
agentId: details.agentId?.trimmingCharacters(in: .whitespacesAndNewlines),
expiresAtMs: details.expiresAtMs)
}
private nonisolated static func shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: String,
isBackgrounded: Bool) -> Bool
{
guard isBackgrounded else { return false }
switch sourceReason {
case "watch_request", "push_request", "push_resolved", "watch_resolve", "notification_action":
return true
default:
return false
}
}
private func operatorRouteForExecApproval(
sourceReason: String,
expectedOperatorRoute: GatewayNodeSessionRoute? = nil,
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> GatewaySessionRouteContext?
{
guard shouldContinue(), let gatewayStableID = currentExecApprovalGatewayStableID() else {
return nil
}
let routeGeneration = self.gatewayRouteGeneration
let connected: Bool = if expectedOperatorRoute != nil {
self.operatorConnected
} else if Self.shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: sourceReason,
isBackgrounded: self.isBackgrounded)
{
await self.ensureOperatorApprovalConnectionForWatchReview(
timeoutMs: 12000,
reason: sourceReason)
} else {
await self.ensureOperatorApprovalConnection(timeoutMs: 12000)
}
guard shouldContinue(), connected,
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: gatewayStableID)
else {
return nil
}
let route: GatewayNodeSessionRoute? = if let expectedOperatorRoute {
expectedOperatorRoute
} else {
await self.operatorGateway.currentRoute()
}
guard let route,
shouldContinue(),
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: gatewayStableID)
else {
return nil
}
return GatewaySessionRouteContext(
route: route,
gatewayStableID: gatewayStableID,
routeGeneration: routeGeneration)
}
private func validatedExecApprovalPushRoute(
_ push: ExecApprovalNotificationPrompt,
sourceReason: String,
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> GatewayNodeSessionRoute?
{
guard case let .validated(context) = await validateExecApprovalPushRoute(
push,
sourceReason: sourceReason,
shouldContinue: shouldContinue)
else {
return nil
}
return context.route
}
private func validateExecApprovalPushRoute(
_ push: ExecApprovalNotificationPrompt,
sourceReason: String,
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> ExecApprovalPushRouteValidation
{
guard let context = await operatorRouteForExecApproval(
sourceReason: sourceReason,
shouldContinue: shouldContinue)
else {
return .unavailable
}
// Gateways shipped before owner-tagged APNs payloads are still safe when the
// approval is resolved only through the currently authenticated operator route.
guard let expectedGatewayDeviceID = push.gatewayDeviceId else {
return .validated(context)
}
do {
let identity = try await fetchPushRelayGatewayIdentity(ifCurrentRoute: context.route)
guard shouldContinue(),
self.isCurrentGatewayRoute(
generation: context.routeGeneration,
stableID: context.gatewayStableID)
else {
return .unavailable
}
guard identity.deviceId == expectedGatewayDeviceID else {
return .mismatchedOwner
}
return .validated(context)
} catch {
return .unavailable
}
}
private func fetchExecApprovalPrompt(
approvalId: String,
sourceReason: String? = nil,
expectedOperatorRoute: GatewayNodeSessionRoute? = nil,
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> ExecApprovalPromptFetchOutcome
{
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
let fetchReason: String = if let normalizedSourceReason, !normalizedSourceReason.isEmpty {
normalizedSourceReason
} else {
"direct"
}
GatewayDiagnostics.log(
"watch exec approval: fetch prompt start id=\(approvalId) reason=\(fetchReason)")
guard let context = await operatorRouteForExecApproval(
sourceReason: fetchReason,
expectedOperatorRoute: expectedOperatorRoute,
shouldContinue: shouldContinue)
else {
GatewayDiagnostics.log(
"watch exec approval: fetch prompt operator not connected id=\(approvalId) reason=\(fetchReason)")
return .failed(message: "operator_not_connected")
}
do {
let payloadJSON = try Self.encodePayload(ExecApprovalGetRequest(id: approvalId))
let response = try await operatorGateway.request(
method: "exec.approval.get",
paramsJSON: payloadJSON,
timeoutSeconds: 12,
ifCurrentRoute: context.route)
guard shouldContinue(), self.currentExecApprovalGatewayStableID() == context.gatewayStableID else {
return .failed(message: "gateway_changed")
}
let details = try JSONDecoder().decode(ExecApprovalGetResponse.self, from: response)
guard let prompt = Self.makeExecApprovalPrompt(
from: details,
gatewayStableID: context.gatewayStableID)
else {
GatewayDiagnostics.log(
"watch exec approval: fetch prompt invalid payload id=\(approvalId) reason=\(fetchReason)")
return .failed(message: "invalid_prompt_payload")
}
GatewayDiagnostics.log(
"watch exec approval: fetch prompt loaded id=\(approvalId) reason=\(fetchReason)")
return .loaded(prompt)
} catch is CancellationError {
return .failed(message: "route_changed")
} catch {
guard self.currentExecApprovalGatewayStableID() == context.gatewayStableID else {
return .failed(message: "gateway_changed")
}
if Self.isApprovalNotificationStaleError(error) {
GatewayDiagnostics.log(
"watch exec approval: fetch prompt stale id=\(approvalId) reason=\(fetchReason)")
return .stale
}
GatewayDiagnostics.log(
"watch exec approval: fetch prompt failed "
+ "id=\(approvalId) reason=\(fetchReason) "
+ "error=\(error.localizedDescription)")
return .failed(message: error.localizedDescription)
}
}
func dismissPendingExecApprovalPrompt() {
self.pendingExecApprovalPrompt = nil
self.pendingExecApprovalPromptResolving = false
self.pendingExecApprovalPromptErrorText = nil
}
func dismissPendingExecApprovalPrompt(approvalId: String) {
self.clearPendingExecApprovalPromptIfMatches(approvalId)
}
func resolvePendingExecApprovalPrompt(decision: String) async {
guard let prompt = pendingExecApprovalPrompt else { return }
guard self.isExecApprovalPromptCurrent(prompt) else {
self.dismissPendingExecApprovalPrompt()
return
}
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedDecision.isEmpty else { return }
self.pendingExecApprovalPromptResolving = true
self.pendingExecApprovalPromptErrorText = nil
let outcome = await resolveExecApprovalNotificationDecision(
approvalId: prompt.id,
decision: normalizedDecision,
expectedGatewayStableID: prompt.gatewayStableID)
switch outcome {
case .resolved, .stale, .unavailable:
break
case let .failed(message):
self.pendingExecApprovalPromptResolving = false
self.pendingExecApprovalPromptErrorText = message
}
}
private func resolveExecApprovalNotificationDecision(
approvalId: String,
decision: String,
expectedGatewayStableID: String,
sourceReason: String? = nil) async -> ExecApprovalResolutionOutcome
{
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolutionReason = (normalizedSourceReason?.isEmpty == false) ? normalizedSourceReason! : "direct"
guard !normalizedApprovalID.isEmpty, !normalizedDecision.isEmpty else {
return .failed(message: "Invalid approval request.")
}
guard self.currentExecApprovalGatewayStableID() == expectedGatewayStableID else {
return .failed(message: "This approval belongs to a different gateway.")
}
let connected: Bool = if Self.shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: resolutionReason,
isBackgrounded: self.isBackgrounded)
{
await self.ensureOperatorApprovalConnectionForWatchReview(
timeoutMs: 12000,
reason: resolutionReason)
} else {
await self.ensureOperatorApprovalConnection(timeoutMs: 12000)
}
guard connected,
self.currentExecApprovalGatewayStableID() == expectedGatewayStableID,
let operatorRoute = await operatorGateway.currentRoute()
else {
self.execApprovalNotificationLogger.error(
"Exec approval action failed id=\(normalizedApprovalID, privacy: .public): operator not connected")
return .failed(message: "OpenClaw couldn't connect to the gateway operator session.")
}
do {
let payloadJSON = try Self.encodePayload(
ExecApprovalResolveRequest(id: normalizedApprovalID, decision: normalizedDecision))
_ = try await self.operatorGateway.request(
method: "exec.approval.resolve",
paramsJSON: payloadJSON,
timeoutSeconds: 12,
ifCurrentRoute: operatorRoute)
guard self.currentExecApprovalGatewayStableID() == expectedGatewayStableID else {
return .resolved
}
await self.removeCurrentGatewayExecApprovalNotifications(
approvalId: normalizedApprovalID)
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
await self.publishWatchExecApprovalResolved(
approvalId: normalizedApprovalID,
gatewayStableID: expectedGatewayStableID,
decision: OpenClawWatchExecApprovalDecision(rawValue: normalizedDecision),
source: "iphone")
return .resolved
} catch {
guard self.currentExecApprovalGatewayStableID() == expectedGatewayStableID else {
return .failed(message: "This approval belongs to a different gateway.")
}
if Self.isApprovalNotificationStaleError(error) {
await self.removeCurrentGatewayExecApprovalNotifications(
approvalId: normalizedApprovalID)
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
await self.publishWatchExecApprovalExpired(
approvalId: normalizedApprovalID,
gatewayStableID: expectedGatewayStableID,
reason: .notFound)
return .stale
}
if Self.isApprovalNotificationUnavailableError(error) {
await self.removeCurrentGatewayExecApprovalNotifications(
approvalId: normalizedApprovalID)
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
await self.publishWatchExecApprovalExpired(
approvalId: normalizedApprovalID,
gatewayStableID: expectedGatewayStableID,
reason: .unavailable)
return .unavailable
}
let logMessage =
"Exec approval action failed id=\(normalizedApprovalID) error=\(error.localizedDescription)"
self.execApprovalNotificationLogger.error("\(logMessage, privacy: .public)")
return .failed(
message: "OpenClaw couldn't resolve this approval right now. Try again.")
}
}
private func clearPendingExecApprovalPromptIfMatches(_ approvalId: String) {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
self.clearNotificationPermissionGuidancePromptIfMatches(normalizedApprovalID)
guard self.pendingExecApprovalPrompt?.id == normalizedApprovalID else { return }
self.dismissPendingExecApprovalPrompt()
}
private func removeCurrentGatewayExecApprovalNotifications(approvalId: String) async {
let delivered = await notificationCenter.deliveredNotifications()
var seen = Set<ExecApprovalNotificationPrompt>()
for snapshot in delivered {
guard let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: snapshot.userInfo),
push.approvalId == approvalId,
seen.insert(push).inserted,
await validatedExecApprovalPushRoute(
push,
sourceReason: "notification_action") != nil
else {
continue
}
await ExecApprovalNotificationBridge.removeNotifications(
for: push,
notificationCenter: self.notificationCenter)
}
}
private func clearNotificationPermissionGuidancePromptIfMatches(_ approvalId: String) {
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.pendingNotificationPermissionGuidancePrompt?.approvalId == normalizedApprovalID else { return }
self.pendingNotificationPermissionGuidancePrompt = nil
}
private nonisolated static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
guard let gatewayError = error as? GatewayResponseError else { return false }
if gatewayError.code != "INVALID_REQUEST" {
return false
}
if gatewayError.detailsReason == "APPROVAL_NOT_FOUND" {
return true
}
return gatewayError.message.lowercased().contains("unknown or expired approval id")
}
private nonisolated static func isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
guard let gatewayError = error as? GatewayResponseError else { return false }
if gatewayError.code != "INVALID_REQUEST" {
return false
}
if gatewayError.detailsReason == "APPROVAL_ALLOW_ALWAYS_UNAVAILABLE" {
return true
}
return gatewayError.message.lowercased().contains("allow-always is unavailable")
}
private struct BackgroundAliveWakeAttemptResult {
var applied: Bool
var handled: Bool
var reason: String
var durationMs: Int
}
private func waitForGatewayConnection(timeoutMs: Int, pollMs: Int) async -> Bool {
let clampedTimeoutMs = max(0, timeoutMs)
let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000
let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0)
while Date() < deadline {
if Task.isCancelled {
return false
}
if await isGatewayConnected() {
return true
}
do {
try await Task.sleep(nanoseconds: pollIntervalNs)
} catch {
return false
}
}
return await isGatewayConnected()
}
private func waitForOperatorConnection(timeoutMs: Int, pollMs: Int) async -> Bool {
let clampedTimeoutMs = max(0, timeoutMs)
let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000
let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0)
while Date() < deadline {
if Task.isCancelled {
return false
}
if await self.isOperatorConnected() {
return true
}
do {
try await Task.sleep(nanoseconds: pollIntervalNs)
} catch {
return false
}
}
return await self.isOperatorConnected()
}
private func ensureOperatorReconnectLoopIfNeeded() {
guard let cfg = activeGatewayConnectConfig else {
return
}
guard self.operatorGatewayTask == nil else {
return
}
let stableID = cfg.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
let effectiveStableID = stableID.isEmpty ? cfg.url.absoluteString : stableID
let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
self.startOperatorGatewayLoop(
url: cfg.url,
stableID: effectiveStableID,
token: cfg.token,
bootstrapToken: cfg.bootstrapToken,
password: cfg.password,
nodeOptions: cfg.nodeOptions,
sessionBox: sessionBox)
}
private func ensureOperatorApprovalConnectionForWatchReview(timeoutMs: Int, reason: String) async -> Bool {
let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
let reconnectReason = normalizedReason.isEmpty ? "watch_request" : normalizedReason
if await self.isOperatorConnected() {
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_connected "
+ "reason=\(reconnectReason) phase=already_connected")
return true
}
guard self.isBackgrounded else {
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_begin "
+ "reason=\(reconnectReason) backgrounded=false strategy=default")
let connected = await ensureOperatorApprovalConnection(timeoutMs: timeoutMs)
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") "
+ "reason=\(reconnectReason) phase=foreground_delegate")
return connected
}
guard self.gatewayAutoReconnectEnabled else {
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_timeout "
+ "reason=\(reconnectReason) phase=auto_reconnect_disabled")
return false
}
guard let cfg = activeGatewayConnectConfig else {
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_timeout "
+ "reason=\(reconnectReason) phase=no_active_gateway_config")
return false
}
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_begin reason=\(reconnectReason) backgrounded=true")
let leaseSeconds = min(45.0, max(15.0, Double(max(timeoutMs, 1000)) / 1000.0 + 8.0))
self.grantBackgroundReconnectLease(seconds: leaseSeconds, reason: "watch_review_\(reconnectReason)")
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_lease_granted "
+ "reason=\(reconnectReason) seconds=\(leaseSeconds)")
let hadReconnectLoop = self.operatorGatewayTask != nil
let canStartReconnectLoop = hadReconnectLoop || self.shouldStartOperatorGatewayLoop(
token: cfg.token,
bootstrapToken: cfg.bootstrapToken,
password: cfg.password,
deviceAuthGatewayID: cfg.nodeOptions.deviceAuthGatewayID ?? cfg.effectiveStableID,
allowStoredDeviceAuth: cfg.nodeOptions.allowStoredDeviceAuth)
guard canStartReconnectLoop else {
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_timeout "
+ "reason=\(reconnectReason) phase=no_operator_reconnect_auth")
return false
}
self.ensureOperatorReconnectLoopIfNeeded()
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_loop_\(hadReconnectLoop ? "reused" : "started") "
+ "reason=\(reconnectReason)")
let initialWaitMs = min(2500, max(750, timeoutMs / 4))
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_wait "
+ "reason=\(reconnectReason) phase=initial timeoutMs=\(initialWaitMs)")
if await self.waitForOperatorConnection(timeoutMs: initialWaitMs, pollMs: 200) {
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_connected "
+ "reason=\(reconnectReason) phase=initial")
return true
}
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_restart reason=\(reconnectReason)")
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
await self.operatorGateway.disconnect()
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
self.stopGatewayHealthMonitor()
let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
self.startOperatorGatewayLoop(
url: cfg.url,
stableID: cfg.effectiveStableID,
token: cfg.token,
bootstrapToken: cfg.bootstrapToken,
password: cfg.password,
nodeOptions: cfg.nodeOptions,
sessionBox: sessionBox)
let remainingWaitMs = max(250, timeoutMs - initialWaitMs)
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_wait "
+ "reason=\(reconnectReason) phase=restart timeoutMs=\(remainingWaitMs)")
let connected = await waitForOperatorConnection(timeoutMs: remainingWaitMs, pollMs: 200)
GatewayDiagnostics.log(
"watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") "
+ "reason=\(reconnectReason) phase=restart")
return connected
}
private func ensureOperatorApprovalConnection(timeoutMs: Int) async -> Bool {
if await self.isOperatorConnected() {
return true
}
self.ensureOperatorReconnectLoopIfNeeded()
return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250)
}
private func performBackgroundAliveBeaconIfNeeded(
wakeId: String,
trigger: BackgroundAliveBeacon.Trigger) async -> BackgroundAliveWakeAttemptResult
{
let startedAt = Date()
let makeResult: (Bool, Bool, String) -> BackgroundAliveWakeAttemptResult = { applied, handled, reason in
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
return BackgroundAliveWakeAttemptResult(
applied: applied,
handled: handled,
reason: reason,
durationMs: max(0, durationMs))
}
guard self.isBackgrounded else {
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): app not backgrounded")
return makeResult(false, false, "not_backgrounded")
}
guard self.gatewayAutoReconnectEnabled else {
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): auto reconnect disabled")
return makeResult(false, false, "auto_reconnect_disabled")
}
let now = Date()
let gatewayConnected = await isGatewayConnected()
var appliedReconnect = false
if !gatewayConnected {
guard let cfg = activeGatewayConnectConfig else {
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): no active gateway config")
return makeResult(false, false, "no_active_gateway_config")
}
let generation = self.gatewayConnectGeneration
self.pushWakeLogger.info(
"Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)")
self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)")
await self.resetGatewaySessionsForForcedReconnect()
guard generation == self.gatewayConnectGeneration,
self.gatewayAutoReconnectEnabled,
self.activeGatewayConnectConfig?.hasSameConnectionInputs(as: cfg) == true
else {
return makeResult(false, false, "reconnect_superseded")
}
self.setOperatorConnected(false)
self.gatewayConnected = false
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)
self.applyGatewayConnectConfig(cfg, expectedGeneration: generation)
appliedReconnect = true
self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)")
let connected = await waitForGatewayConnection(timeoutMs: 12000, pollMs: 250)
guard connected else {
return makeResult(appliedReconnect, false, "connect_timeout")
}
guard generation == self.gatewayConnectGeneration else {
return makeResult(appliedReconnect, false, "reconnect_superseded")
}
} else if BackgroundAliveBeacon.shouldSkipRecentSuccess(
isGatewayConnected: true,
now: now,
lastSuccessAtMs: UserDefaults.standard.object(forKey: Self.backgroundAliveLastSuccessAtMsKey) as? Double)
{
return makeResult(false, true, "recent_success")
}
let beacon = await publishBackgroundAliveBeacon(trigger: trigger)
if beacon.handled {
let successAtMs = Date().timeIntervalSince1970 * 1000
UserDefaults.standard.set(successAtMs, forKey: Self.backgroundAliveLastSuccessAtMsKey)
UserDefaults.standard.set(trigger.rawValue, forKey: Self.backgroundAliveLastTriggerKey)
return makeResult(appliedReconnect, true, beacon.reason)
}
return makeResult(appliedReconnect, false, beacon.reason)
}
private func publishBackgroundAliveBeacon(
trigger: BackgroundAliveBeacon.Trigger) async -> (handled: Bool, reason: String)
{
do {
let pushTransport = await pushRegistrationManager.usesRelayTransport ? "relay" : "direct"
let displayName = NodeDisplayName.resolve(
existing: UserDefaults.standard.string(forKey: "node.displayName"),
deviceName: UIDevice.current.name,
interfaceIdiom: UIDevice.current.userInterfaceIdiom)
let payload = BackgroundAliveBeacon.makePayload(
trigger: trigger,
displayName: displayName,
pushTransport: pushTransport)
let paramsJSON = try BackgroundAliveBeacon.makeNodeEventRequestPayloadJSON(payload: payload)
let response = try await nodeGateway.request(
method: "node.event",
paramsJSON: paramsJSON,
timeoutSeconds: 8)
guard let decoded = BackgroundAliveBeacon.decodeResponse(response) else {
return (false, "invalid_response")
}
if decoded.handled == true {
return (true, decoded.reason ?? "beacon_persisted")
}
return (false, decoded.reason ?? "unsupported")
} catch {
return (false, "beacon_failed")
}
}
}
extension NodeAppModel {
private func refreshWakeWordsFromGateway(
shouldApply: @escaping @MainActor @Sendable () -> Bool = { true }) async
{
do {
let data = try await operatorGateway.request(
method: "voicewake.get",
paramsJSON: "{}",
timeoutSeconds: 8)
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
guard shouldApply() else { return }
VoiceWakePreferences.saveTriggerWords(triggers)
} catch {
guard shouldApply() else { return }
if let gatewayError = error as? GatewayResponseError {
let lower = gatewayError.message.lowercased()
if lower.contains("unauthorized role") || lower.contains("missing scope") {
self.setGatewayHealthMonitorDisabled(true)
return
}
}
// Best-effort only.
}
}
private func isGatewayHealthMonitorDisabled() -> Bool {
self.gatewayHealthMonitorDisabled
}
private func setGatewayHealthMonitorDisabled(_ disabled: Bool) {
self.gatewayHealthMonitorDisabled = disabled
}
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
if await !self.isGatewayConnected() {
throw NSError(domain: "Gateway", code: 10, userInfo: [
NSLocalizedDescriptionKey: "Gateway not connected",
])
}
struct Payload: Codable {
var text: String
var sessionKey: String?
}
let payload = Payload(text: text, sessionKey: sessionKey)
let data = try JSONEncoder().encode(payload)
guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
])
}
await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json)
}
func handleDeepLink(url: URL) async {
guard let route = DeepLinkParser.parse(url) else { return }
switch route {
case let .agent(link):
await self.handleAgentDeepLink(link, originalURL: url)
case let .gateway(link):
self.stageGatewaySetupLink(link)
case .dashboard:
break
}
}
func stageGatewaySetupLink(_ link: GatewayConnectDeepLink) {
self.pendingGatewaySetupLink = link
self.gatewaySetupRequestID &+= 1
}
func consumePendingGatewaySetupLink() -> GatewayConnectDeepLink? {
defer { self.pendingGatewaySetupLink = nil }
return self.pendingGatewaySetupLink
}
private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async {
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else { return }
self.deepLinkLogger.info(
"agent deep link messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)")
if message.count > IOSDeepLinkAgentPolicy.maxMessageChars {
self.screen.errorText = "Deep link too large (message exceeds "
+ "\(IOSDeepLinkAgentPolicy.maxMessageChars) characters)."
self.recordShareEvent("Rejected: message too large (\(message.count) chars).")
return
}
guard await self.isGatewayConnected() else {
self.screen.errorText = "Gateway not connected (cannot forward deep link)."
self.recordShareEvent("Failed: gateway not connected.")
self.deepLinkLogger.error("agent deep link rejected: gateway not connected")
return
}
let allowUnattended = self.isUnattendedDeepLinkAllowed(link.key)
if !allowUnattended {
if message.count > IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars {
self.screen.errorText = "Deep link blocked (message too long without key)."
self.recordShareEvent(
"Rejected: deep link over \(IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars) chars without key.")
self.deepLinkLogger.error(
"agent deep link rejected: unkeyed message too long chars=\(message.count, privacy: .public)")
return
}
let urlText = originalURL.absoluteString
let prompt = AgentDeepLinkPrompt(
id: UUID().uuidString,
messagePreview: message,
urlPreview: urlText.count > 500 ? "\(urlText.prefix(500))" : urlText,
request: self.effectiveAgentDeepLinkForPrompt(link))
let promptIntervalSeconds = 5.0
let elapsed = Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt)
if elapsed < promptIntervalSeconds {
if self.pendingAgentDeepLinkPrompt != nil {
self.pendingAgentDeepLinkPrompt = prompt
self.recordShareEvent("Updated local confirmation request (\(message.count) chars).")
self.deepLinkLogger.debug("agent deep link prompt coalesced into active confirmation")
return
}
let remaining = max(0, promptIntervalSeconds - elapsed)
self.queueAgentDeepLinkPrompt(prompt, initialDelaySeconds: remaining)
self.recordShareEvent("Queued local confirmation (\(message.count) chars).")
self.deepLinkLogger.debug("agent deep link prompt queued due to rate limit")
return
}
self.presentAgentDeepLinkPrompt(prompt)
self.recordShareEvent("Awaiting local confirmation (\(message.count) chars).")
self.deepLinkLogger.info("agent deep link requires local confirmation")
return
}
await self.submitAgentDeepLink(link, messageCharCount: message.count)
}
private func sendAgentRequest(
link: AgentDeepLink,
expectedNodeRoute: GatewayNodeSessionRoute? = nil) async throws
{
if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
throw NSError(domain: "DeepLink", code: 1, userInfo: [
NSLocalizedDescriptionKey: "invalid agent message",
])
}
#if DEBUG
if let testAgentRequestHandler {
try await testAgentRequestHandler(link)
return
}
#endif
let data = try JSONEncoder().encode(link)
guard let json = String(bytes: data, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
])
}
let requestData = try JSONEncoder().encode(NodeEventRequestPayload(event: "agent.request", payloadJSON: json))
guard let requestJSON = String(bytes: requestData, encoding: .utf8) else {
throw NSError(domain: "NodeAppModel", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode agent request node event as UTF-8",
])
}
_ = try await self.nodeGateway.request(
method: "node.event",
paramsJSON: requestJSON,
timeoutSeconds: Self.agentRequestNodeEventTimeoutSeconds,
ifCurrentRoute: expectedNodeRoute)
}
private func isGatewayConnected() async -> Bool {
self.gatewayConnected
}
private func applyMainSessionKey(_ key: String?) {
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == current { return }
self.mainSessionBaseKey = trimmed
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
func approvePendingAgentDeepLinkPrompt() async {
guard let prompt = pendingAgentDeepLinkPrompt else { return }
self.pendingAgentDeepLinkPrompt = nil
guard await self.isGatewayConnected() else {
self.screen.errorText = "Gateway not connected (cannot forward deep link)."
self.recordShareEvent("Failed: gateway not connected.")
self.deepLinkLogger.error("agent deep link approval failed: gateway not connected")
return
}
await self.submitAgentDeepLink(prompt.request, messageCharCount: prompt.messagePreview.count)
}
func declinePendingAgentDeepLinkPrompt() {
guard self.pendingAgentDeepLinkPrompt != nil else { return }
self.pendingAgentDeepLinkPrompt = nil
self.screen.errorText = "Deep link cancelled."
self.recordShareEvent("Cancelled: deep link confirmation declined.")
self.deepLinkLogger.info("agent deep link cancelled by local user")
}
private func presentAgentDeepLinkPrompt(_ prompt: AgentDeepLinkPrompt) {
self.lastAgentDeepLinkPromptAt = Date()
self.pendingAgentDeepLinkPrompt = prompt
}
private func queueAgentDeepLinkPrompt(_ prompt: AgentDeepLinkPrompt, initialDelaySeconds: TimeInterval) {
self.queuedAgentDeepLinkPrompt = prompt
guard self.queuedAgentDeepLinkPromptTask == nil else { return }
self.queuedAgentDeepLinkPromptTask = Task { [weak self] in
guard let self else { return }
let delayNs = UInt64(max(0, initialDelaySeconds) * 1_000_000_000)
if delayNs > 0 {
do {
try await Task.sleep(nanoseconds: delayNs)
} catch {
return
}
}
await self.deliverQueuedAgentDeepLinkPrompt()
}
}
private func deliverQueuedAgentDeepLinkPrompt() async {
defer { self.queuedAgentDeepLinkPromptTask = nil }
let promptIntervalSeconds = 5.0
while let prompt = queuedAgentDeepLinkPrompt {
if self.pendingAgentDeepLinkPrompt != nil {
do {
try await Task.sleep(nanoseconds: 200_000_000)
} catch {
return
}
continue
}
let elapsed = Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt)
if elapsed < promptIntervalSeconds {
let remaining = max(0, promptIntervalSeconds - elapsed)
do {
try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
} catch {
return
}
continue
}
self.queuedAgentDeepLinkPrompt = nil
self.presentAgentDeepLinkPrompt(prompt)
self.recordShareEvent("Awaiting local confirmation (\(prompt.messagePreview.count) chars).")
self.deepLinkLogger.info("agent deep link queued prompt delivered")
}
}
private func submitAgentDeepLink(_ link: AgentDeepLink, messageCharCount: Int) async {
do {
try await self.sendAgentRequest(link: link)
self.screen.errorText = nil
self.recordShareEvent("Sent to gateway (\(messageCharCount) chars).")
self.deepLinkLogger.info("agent deep link forwarded to gateway")
self.openChatRequestID &+= 1
} catch {
self.screen.errorText = "Agent request failed: \(error.localizedDescription)"
self.recordShareEvent("Failed: \(error.localizedDescription)")
self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)")
}
}
private func effectiveAgentDeepLinkForPrompt(_ link: AgentDeepLink) -> AgentDeepLink {
// Without a trusted key, strip delivery/routing knobs to reduce exfiltration risk.
AgentDeepLink(
message: link.message,
sessionKey: link.sessionKey,
thinking: link.thinking,
deliver: false,
to: nil,
channel: nil,
timeoutSeconds: link.timeoutSeconds,
key: link.key)
}
private func isUnattendedDeepLinkAllowed(_ key: String?) -> Bool {
let normalizedKey = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !normalizedKey.isEmpty else { return false }
return normalizedKey == Self.canvasUnattendedDeepLinkKey || normalizedKey == Self.expectedDeepLinkKey()
}
private static func expectedDeepLinkKey() -> String {
let defaults = UserDefaults.standard
if let key = defaults.string(forKey: deepLinkKeyUserDefaultsKey), !key.isEmpty {
return key
}
let key = self.generateDeepLinkKey()
defaults.set(key, forKey: self.deepLinkKeyUserDefaultsKey)
return key
}
private static func generateDeepLinkKey() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
let data = Data(bytes)
return data
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
extension NodeAppModel {
func _bridgeConsumeMirroredWatchReply(_ event: WatchQuickReplyEvent) async {
await self.handleWatchQuickReply(event)
}
}
#if DEBUG
extension NodeAppModel {
func _test_handleInvoke(
_ req: BridgeInvokeRequest,
gatewayStableID: String? = nil) async -> BridgeInvokeResponse
{
await self.handleInvoke(req, gatewayStableID: gatewayStableID)
}
func _test_acquirePttVoiceWakeLease() {
self.acquirePttVoiceWakeLease()
}
func _test_releasePttVoiceWakeLease() {
self.releasePttVoiceWakeLease()
}
static func _test_decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
try self.decodeParams(type, from: json)
}
static func _test_encodePayload(_ obj: some Encodable) throws -> String {
try self.encodePayload(obj)
}
func _test_handleCanvasA2UIAction(body: [String: Any]) async {
await self.handleCanvasA2UIAction(body: body)
}
func _test_queuedWatchReplyCount() -> Int {
self.watchMessageOutbox.queuedCount(kind: .quickReply)
}
func _test_setWatchMessageRetryAttempts(_ attempts: Int, messageID: String) {
self.watchMessageRetryAttempts[messageID] = attempts
}
func _test_watchMessageRetryAttempts(messageID: String) -> Int? {
self.watchMessageRetryAttempts[messageID]
}
func _test_queuedWatchChatCommandCount() -> Int {
self.watchMessageOutbox.queuedCount(kind: .chat)
}
func _test_queuedWatchChatCommandIds() -> [String] {
self.watchMessageOutbox.queuedMessageIDs(kind: .chat)
}
func _test_recordWatchPromptRoute(promptID: String, gatewayStableID: String) {
self.watchMessageOutbox.recordPromptRoute(
promptID: promptID,
gatewayStableID: gatewayStableID)
}
func _test_setConnectedGatewayID(_ gatewayID: String?) {
self.connectedGatewayID = gatewayID
}
func _test_setAgentRequestHandler(_ handler: @escaping (AgentDeepLink) async throws -> Void) {
self.testAgentRequestHandler = handler
}
static func _test_resetPersistedWatchChatQueueState() {
WatchMessageOutbox.resetPersistedQueue()
}
static func _test_resetPersistedWatchReplyQueueState() {
WatchMessageOutbox.resetPersistedQueue()
}
func _test_setGatewayConnected(_ connected: Bool) {
self.gatewayConnected = connected
}
func _test_setOperatorConnected(_ connected: Bool) {
self.setOperatorConnected(connected)
}
func _test_canPublishAPNsRegistration(usesRelayTransport: Bool = true) async -> Bool {
await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport)
}
nonisolated static func _test_makeWatchChatItems(from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] {
self.makeWatchChatItems(from: raw)
}
nonisolated static func _test_watchChatReplyText(
from raw: [OpenClawKit.AnyCodable],
runId: String,
submittedText: String,
submittedAtMs: Int) -> String?
{
self.watchChatReplyText(
from: raw,
runId: runId,
submittedText: submittedText,
submittedAtMs: submittedAtMs)
}
func _test_isGatewayConnected() -> Bool {
self.gatewayConnected
}
func _test_refreshOperatorAdminScopeFromStore() {
self.refreshOperatorAdminScopeFromStore()
}
func _test_applyPendingForegroundNodeActions(
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
{
let mapped = actions.map { action in
PendingForegroundNodeAction(
id: action.id,
command: action.command,
paramsJSON: action.paramsJSON,
enqueuedAtMs: nil)
}
await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
}
func _test_makeOperatorConnectOptions(
clientId: String,
displayName: String?,
includeAdminScope: Bool = false,
includeApprovalScope: Bool,
forceExplicitScopes: Bool = false) -> GatewayConnectOptions
{
self.makeOperatorConnectOptions(
clientId: clientId,
displayName: displayName,
includeAdminScope: includeAdminScope,
includeApprovalScope: includeApprovalScope,
forceExplicitScopes: forceExplicitScopes)
}
func _test_presentExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
if self.currentExecApprovalGatewayStableID() == nil {
self.connectedGatewayID = prompt.gatewayStableID
}
self.presentFetchedExecApprovalPrompt(prompt)
}
func _test_dismissPendingExecApprovalPrompt() {
self.dismissPendingExecApprovalPrompt()
}
func _test_applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
self.applyOperatorGatewayConnectionProblem(problem)
}
func _test_clearOperatorGatewayConnectionProblemIfCurrent() {
self.clearOperatorGatewayConnectionProblemIfCurrent()
}
func _test_clearGatewayConnectionProblem() {
self.clearGatewayConnectionProblem()
}
func _test_pendingExecApprovalPrompt() -> ExecApprovalPrompt? {
self.pendingExecApprovalPrompt
}
func _test_pendingNotificationPermissionGuidancePrompt() -> NotificationPermissionGuidancePrompt? {
self.pendingNotificationPermissionGuidancePrompt
}
func _debug_presentNotificationPermissionGuidancePromptForScreenshot() {
self.resetExecApprovalNotificationGuidanceSuppression()
self.pendingNotificationPermissionGuidancePrompt =
NotificationPermissionGuidancePrompt(approvalId: "screenshot-exec-approval")
}
func _test_resetExecApprovalNotificationGuidanceSuppression() {
self.resetExecApprovalNotificationGuidanceSuppression()
}
func _test_recordPendingWatchExecApprovalRecoveryID(
_ approvalId: String,
gatewayDeviceId: String = "test-gateway-device")
{
self.appendPendingWatchExecApprovalRecoveryPush(ExecApprovalNotificationPrompt(
approvalId: approvalId,
gatewayDeviceId: gatewayDeviceId))
}
func _test_pendingWatchExecApprovalRecoveryIDs() -> [String] {
self.pendingWatchExecApprovalRecoveryPushes.map(\.approvalId)
}
func _test_pendingWatchExecApprovalRecoveryPushes() -> [ExecApprovalNotificationPrompt] {
self.pendingWatchExecApprovalRecoveryPushes
}
func _test_handleExecApprovalResolvedForCurrentGateway(
approvalId: String,
recoveryPushGatewayDeviceID: String?) async
{
await self.handleExecApprovalResolvedForCurrentGateway(
approvalId: approvalId,
recoveryPushGatewayDeviceID: recoveryPushGatewayDeviceID)
}
func _test_pendingExecApprovalResolvedPushes() -> [ExecApprovalNotificationPrompt] {
self.pendingExecApprovalResolvedPushes
}
func _test_pendingExecApprovalIDsForWatchRecovery() async -> [String] {
await self.pendingExecApprovalPushesForWatchRecovery().map(\.approvalId)
}
nonisolated static func _test_isApprovalNotificationStaleError(_ error: Error) -> Bool {
self.isApprovalNotificationStaleError(error)
}
nonisolated static func _test_isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
self.isApprovalNotificationUnavailableError(error)
}
nonisolated static func _test_shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: String,
isBackgrounded: Bool) -> Bool
{
self.shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: sourceReason,
isBackgrounded: isBackgrounded)
}
nonisolated static func _test_execApprovalEventID(from payload: AnyCodable) -> String? {
self.execApprovalEventID(from: payload)
}
func _test_handleOperatorGatewayServerEvent(_ event: EventFrame) async {
await self.handleOperatorGatewayServerEvent(event)
}
func _test_handleOperatorGatewayServerEvent(
_ event: EventFrame,
shouldContinue: @escaping @MainActor @Sendable () -> Bool) async
{
await self.handleOperatorGatewayServerEvent(event, shouldContinue: shouldContinue)
}
nonisolated static func _test_watchExecApprovalIDsNeedingFetch(
candidateIDs: [String],
cachedApprovalIDs: [String]) -> [String]
{
self.watchExecApprovalIDsNeedingFetch(
candidateIDs: candidateIDs,
cachedApprovalIDs: cachedApprovalIDs)
}
nonisolated static func _test_shouldResetWatchExecApprovalResolvingStateOnPrompt(
reason: String) -> Bool
{
self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason)
}
static func _test_makeExecApprovalPrompt(
id: String,
gatewayStableID: String = "test-gateway",
commandText: String,
allowedDecisions: [String],
host: String?,
nodeId: String?,
agentId: String?,
expiresAtMs: Int?) -> ExecApprovalPrompt?
{
self.makeExecApprovalPrompt(
from: ExecApprovalGetResponse(
id: id,
commandText: commandText,
commandPreview: nil,
allowedDecisions: allowedDecisions,
host: host,
nodeId: nodeId,
agentId: agentId,
expiresAtMs: expiresAtMs),
gatewayStableID: gatewayStableID)
}
static func _test_currentDeepLinkKey() -> String {
self.expectedDeepLinkKey()
}
nonisolated static func _test_shouldDiscardFailedWatchMessage(
code: String,
message: String = "test") -> Bool
{
self.shouldDiscardFailedWatchMessage(
GatewayResponseError(method: "chat.send", code: code, message: message, details: nil))
}
static func _test_resetPersistedWatchExecApprovalBridgeState() {
UserDefaults.standard.removeObject(forKey: self.watchExecApprovalBridgeStateKey)
}
nonisolated static func _test_shouldStartOperatorGatewayLoop(
token: String?,
bootstrapToken: String?,
password: String?,
hasStoredOperatorToken: Bool) -> Bool
{
self.shouldStartOperatorGatewayLoop(
token: token,
bootstrapToken: bootstrapToken,
password: password,
hasStoredOperatorToken: hasStoredOperatorToken)
}
nonisolated static func _test_usesBootstrapCredential(
token: String?,
bootstrapToken: String?,
password: String?) -> Bool
{
self.usesBootstrapCredential(
token: token,
bootstrapToken: bootstrapToken,
password: password)
}
nonisolated static func _test_shouldRequestOperatorApprovalScope(
token: String?,
password: String?,
storedOperatorScopes: [String],
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
{
self.shouldRequestOperatorApprovalScope(
token: token,
password: password,
storedOperatorScopes: storedOperatorScopes,
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
}
func _test_shouldRequestStoredOperatorApprovalScope(
gatewayID: String,
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
{
self.shouldRequestOperatorApprovalScope(
gatewayID: gatewayID,
token: nil,
password: nil,
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
}
nonisolated static func _test_shouldRequestOperatorAdminScope(
token: String?,
password: String?,
storedOperatorScopes: [String],
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
{
self.shouldRequestOperatorAdminScope(
token: token,
password: password,
storedOperatorScopes: storedOperatorScopes,
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
}
func _test_shouldRequestStoredOperatorAdminScope(gatewayID: String) -> Bool {
self.shouldRequestOperatorAdminScope(gatewayID: gatewayID, token: nil, password: nil)
}
func _test_completeSuccessfulGatewayAuthHandoff(
issuedRoles: Set<String>,
nodeOptions: GatewayConnectOptions) -> GatewayConnectOptions?
{
guard let stableID = activeGatewayConnectConfig?.effectiveStableID else { return nil }
return self.completeSuccessfulGatewayAuthHandoff(
stableID: stableID,
routeGeneration: self.gatewayRouteGeneration,
issuedRoles: issuedRoles,
nodeOptions: nodeOptions)
}
func _test_currentGatewayReconnectOptions(
stableID: String,
fallback: GatewayConnectOptions) -> GatewayConnectOptions
{
self.currentGatewayReconnectOptions(stableID: stableID, fallback: fallback)
}
func _test_hasGatewayLoopTasks() -> (node: Bool, operator: Bool) {
(self.nodeGatewayTask != nil, self.operatorGatewayTask != nil)
}
func _test_setGatewayLoopTasks(
node: Task<Void, Never>?,
operator: Task<Void, Never>? = nil)
{
self.nodeGatewayTask = node
self.operatorGatewayTask = `operator`
}
func _test_setGatewaySessionResetTask(_ task: Task<Void, Never>?) {
self.gatewaySessionResetGeneration &+= 1
let resetGeneration = self.gatewaySessionResetGeneration
guard let task else {
self.gatewaySessionResetTask = nil
return
}
self.gatewaySessionResetTask = Task {
await task.value
if self.gatewaySessionResetGeneration == resetGeneration {
self.gatewaySessionResetTask = nil
}
}
}
func _test_restartGatewaySessionsAfterForegroundStaleConnection() async {
await self.restartGatewaySessionsAfterForegroundStaleConnection()
}
}
#endif
// swiftlint:enable type_body_length file_length