2026-02-08 18:08:13 +01:00
import OpenClawChatUI
2026-01-30 03:15:10 +01:00
import OpenClawKit
2026-02-08 18:08:13 +01:00
import OpenClawProtocol
2025-12-14 05:04:58 +00:00
import Observation
2026-02-17 20:08:50 +00:00
import os
2025-12-12 21:18:54 +00:00
import SwiftUI
2025-12-18 11:38:32 +01:00
import UIKit
2026-02-08 18:08:13 +01:00
import UserNotifications
// W r a p e r r o r s w i t h o u t p u l l i n g n o n - S e n d a b l e t y p e s i n t o a s y n c n o t i f i c a t i o n p a t h s .
private struct NotificationCallError : Error , Sendable {
let message : String
}
// E n s u r e s n o t i f i c a t i o n r e q u e s t s r e t u r n p r o m p t l y e v e n i f t h e s y s t e m p r o m p t b l o c k s .
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 )
}
}
2025-12-12 21:18:54 +00:00
@ MainActor
2025-12-14 05:04:58 +00:00
@ Observable
final class NodeAppModel {
2026-02-17 20:08:50 +00:00
private let deepLinkLogger = Logger ( subsystem : " ai.openclaw.ios " , category : " DeepLink " )
2026-02-18 21:00:17 +00:00
private let pushWakeLogger = Logger ( subsystem : " ai.openclaw.ios " , category : " PushWake " )
2026-02-19 20:20:28 +00:00
private let locationWakeLogger = Logger ( subsystem : " ai.openclaw.ios " , category : " LocationWake " )
2026-02-20 16:39:13 +00:00
private let watchReplyLogger = Logger ( subsystem : " ai.openclaw.ios " , category : " WatchReply " )
2025-12-18 14:48:35 +01:00
enum CameraHUDKind {
case photo
case recording
case success
case error
}
2025-12-14 05:04:58 +00:00
var isBackgrounded : Bool = false
2026-02-08 18:08:13 +01:00
let screen : ScreenController
private let camera : any CameraServicing
private let screenRecorder : any ScreenRecordingServicing
2026-01-19 05:44:36 +00:00
var gatewayStatusText : String = " Offline "
2026-02-16 16:07:22 +00:00
var nodeStatusText : String = " Offline "
var operatorStatusText : String = " Offline "
2026-01-19 05:44:36 +00:00
var gatewayServerName : String ?
var gatewayRemoteAddress : String ?
var connectedGatewayID : String ?
2026-02-08 18:08:13 +01:00
var gatewayAutoReconnectEnabled : Bool = true
2026-02-16 16:22:51 +00:00
// W h e n t h e g a t e w a y r e q u i r e s p a i r i n g a p p r o v a l , w e p a u s e r e c o n n e c t c h u r n a n d s h o w a s t a b l e U X .
// R e c o n n e c t l o o p s ( b o t h o u r o w n a n d t h e u n d e r l y i n g W e b S o c k e t w a t c h d o g ) c a n o t h e r w i s e g e n e r a t e
// m u l t i p l e p e n d i n g r e q u e s t s a n d c a u s e t h e o n b o a r d i n g U I t o " f l i p - f l o p " .
var gatewayPairingPaused : Bool = false
var gatewayPairingRequestId : String ?
2025-12-30 04:14:36 +01:00
var seamColorHex : String ?
2026-02-08 18:08:13 +01:00
private var mainSessionBaseKey : String = " main "
var selectedAgentId : String ?
var gatewayDefaultAgentId : String ?
var gatewayAgents : [ AgentSummary ] = [ ]
2026-02-17 20:08:50 +00:00
var lastShareEventText : String = " No share events yet. "
var openChatRequestID : Int = 0
2026-02-08 18:08:13 +01:00
// P r i m a r y " n o d e " c o n n e c t i o n : u s e d f o r d e v i c e c a p a b i l i t i e s a n d n o d e . i n v o k e r e q u e s t s .
private let nodeGateway = GatewayNodeSession ( )
// S e c o n d a r y " o p e r a t o r " c o n n e c t i o n : u s e d f o r c h a t / t a l k / c o n f i g / v o i c e w a k e r e q u e s t s .
private let operatorGateway = GatewayNodeSession ( )
private var nodeGatewayTask : Task < Void , Never > ?
private var operatorGatewayTask : Task < Void , Never > ?
2025-12-14 05:05:20 +00:00
private var voiceWakeSyncTask : Task < Void , Never > ?
2025-12-18 14:48:35 +01:00
@ ObservationIgnored private var cameraHUDDismissTask : Task < Void , Never > ?
2026-02-08 18:08:13 +01:00
@ ObservationIgnored private lazy var capabilityRouter : NodeCapabilityRouter = self . buildCapabilityRouter ( )
private let gatewayHealthMonitor = GatewayHealthMonitor ( )
private var gatewayHealthMonitorDisabled = false
private let notificationCenter : NotificationCentering
2025-12-12 21:18:54 +00:00
let voiceWake = VoiceWakeManager ( )
2026-02-08 18:08:13 +01:00
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
2026-02-18 13:37:41 +00:00
private let watchMessagingService : any WatchMessagingServicing
2026-02-08 18:08:13 +01:00
var lastAutoA2uiURL : String ?
private var pttVoiceWakeSuspended = false
private var talkVoiceWakeSuspended = false
private var backgroundVoiceWakeSuspended = false
private var backgroundTalkSuspended = false
2026-02-16 17:36:17 +00:00
private var backgroundTalkKeptActive = false
2026-02-08 18:08:13 +01:00
private var backgroundedAt : Date ?
private var reconnectAfterBackgroundArmed = false
2026-02-19 20:20:28 +00:00
private var backgroundGraceTaskID : UIBackgroundTaskIdentifier = . invalid
@ ObservationIgnored private var backgroundGraceTaskTimer : Task < Void , Never > ?
private var backgroundReconnectSuppressed = false
private var backgroundReconnectLeaseUntil : Date ?
private var lastSignificantLocationWakeAt : Date ?
2026-02-20 16:39:13 +00:00
private var queuedWatchReplies : [ WatchQuickReplyEvent ] = [ ]
private var seenWatchReplyIds = Set < String > ( )
2025-12-12 21:18:54 +00:00
2026-01-19 05:44:36 +00:00
private var gatewayConnected = false
2026-02-08 18:08:13 +01:00
private var operatorConnected = false
2026-02-17 20:08:50 +00:00
private var shareDeliveryChannel : String ?
private var shareDeliveryTo : String ?
2026-02-18 19:37:03 +00:00
private var apnsDeviceTokenHex : String ?
private var apnsLastRegisteredTokenHex : String ?
2026-02-08 18:08:13 +01:00
var gatewaySession : GatewayNodeSession { self . nodeGateway }
var operatorSession : GatewayNodeSession { self . operatorGateway }
private ( set ) var activeGatewayConnectConfig : GatewayConnectConfig ?
2025-12-14 00:17:07 +00:00
2025-12-18 14:48:35 +01:00
var cameraHUDText : String ?
var cameraHUDKind : CameraHUDKind ?
var cameraFlashNonce : Int = 0
2025-12-29 23:42:22 +01:00
var screenRecordActive : Bool = false
2025-12-18 14:48:35 +01:00
2026-02-08 18:08:13 +01:00
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 ( ) ,
2026-02-18 13:37:41 +00:00
watchMessagingService : any WatchMessagingServicing = WatchMessagingService ( ) ,
2026-02-08 18:08:13 +01:00
talkMode : TalkModeManager = TalkModeManager ( ) )
{
self . screen = screen
self . camera = camera
self . screenRecorder = screenRecorder
self . locationService = locationService
self . notificationCenter = notificationCenter
self . deviceStatusService = deviceStatusService
self . photosService = photosService
self . contactsService = contactsService
self . calendarService = calendarService
self . remindersService = remindersService
self . motionService = motionService
2026-02-18 13:37:41 +00:00
self . watchMessagingService = watchMessagingService
2026-02-08 18:08:13 +01:00
self . talkMode = talkMode
2026-02-18 19:37:03 +00:00
self . apnsDeviceTokenHex = UserDefaults . standard . string ( forKey : Self . apnsDeviceTokenUserDefaultsKey )
2026-02-08 18:08:13 +01:00
GatewayDiagnostics . bootstrap ( )
2026-02-20 16:39:13 +00:00
self . watchMessagingService . setReplyHandler { [ weak self ] event in
Task { @ MainActor in
await self ? . handleWatchQuickReply ( event )
}
}
2026-02-08 18:08:13 +01:00
2025-12-12 21:18:54 +00:00
self . voiceWake . configure { [ weak self ] cmd in
guard let self else { return }
2025-12-30 11:16:15 +01:00
let sessionKey = await MainActor . run { self . mainSessionKey }
2025-12-12 21:18:54 +00:00
do {
try await self . sendVoiceTranscript ( text : cmd , sessionKey : sessionKey )
} catch {
// B e s t - e f f o r t o n l y .
}
}
let enabled = UserDefaults . standard . bool ( forKey : " voiceWake.enabled " )
self . voiceWake . setEnabled ( enabled )
2026-02-08 18:08:13 +01:00
self . talkMode . attachGateway ( self . operatorGateway )
2026-02-17 20:08:50 +00:00
self . refreshLastShareEventFromRelay ( )
2025-12-29 23:21:05 +01:00
let talkEnabled = UserDefaults . standard . bool ( forKey : " talk.enabled " )
2026-02-08 18:08:13 +01:00
// R o u t e t h r o u g h t h e c o o r d i n a t o r s o V o i c e W a k e a n d T a l k d o n ' t f i g h t o v e r t h e m i c r o p h o n e .
self . setTalkEnabled ( talkEnabled )
2025-12-14 05:17:32 +00:00
2025-12-14 05:14:26 +00:00
// W i r e u p d e e p l i n k s f r o m c a n v a s t a p s
self . screen . onDeepLink = { [ weak self ] url in
guard let self else { return }
Task { @ MainActor in
await self . handleDeepLink ( url : url )
}
}
2025-12-18 11:38:32 +01:00
// W i r e u p A 2 U I a c t i o n c l i c k s ( b u t t o n s , e t c . )
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 }
2026-01-30 03:15:10 +01:00
guard let name = OpenClawCanvasA2UIAction . extractActionName ( userAction ) else { return }
2025-12-18 11:38:32 +01:00
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
} ( )
2026-02-08 18:08:13 +01:00
let host = NodeDisplayName . resolve (
existing : UserDefaults . standard . string ( forKey : " node.displayName " ) ,
deviceName : UIDevice . current . name ,
interfaceIdiom : UIDevice . current . userInterfaceIdiom )
2025-12-18 11:38:32 +01:00
let instanceId = ( UserDefaults . standard . string ( forKey : " node.instanceId " ) ? ? " ios-node " ) . lowercased ( )
2026-01-30 03:15:10 +01:00
let contextJSON = OpenClawCanvasA2UIAction . compactJSON ( userAction [ " context " ] )
2026-01-15 08:57:08 +00:00
let sessionKey = self . mainSessionKey
2025-12-18 11:38:32 +01:00
2026-01-30 03:15:10 +01:00
let messageContext = OpenClawCanvasA2UIAction . AgentMessageContext (
2025-12-18 11:38:32 +01:00
actionName : name ,
2025-12-21 01:48:01 +01:00
session : . init ( key : sessionKey , surfaceId : surfaceId ) ,
component : . init ( id : sourceComponentId , host : host , instanceId : instanceId ) ,
2025-12-18 11:38:32 +01:00
contextJSON : contextJSON )
2026-01-30 03:15:10 +01:00
let message = OpenClawCanvasA2UIAction . formatAgentMessage ( messageContext )
2025-12-18 11:38:32 +01:00
let ok : Bool
2025-12-20 01:48:22 +01:00
var errorText : String ?
2026-01-19 05:44:36 +00:00
if await ! self . isGatewayConnected ( ) {
2025-12-18 11:38:32 +01:00
ok = false
2026-01-19 05:44:36 +00:00
errorText = " gateway not connected "
2025-12-18 11:38:32 +01:00
} else {
do {
try await self . 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
}
}
2026-01-30 03:15:10 +01:00
let js = OpenClawCanvasA2UIAction . jsDispatchA2UIActionStatus ( actionId : actionId , ok : ok , error : errorText )
2025-12-18 11:38:32 +01:00
do {
_ = try await self . screen . eval ( javaScript : js )
} catch {
// i g n o r e
}
2025-12-12 21:18:54 +00:00
}
2025-12-21 12:32:36 +01:00
2025-12-12 21:18:54 +00:00
func setScenePhase ( _ phase : ScenePhase ) {
2026-02-16 17:36:17 +00:00
let keepTalkActive = UserDefaults . standard . bool ( forKey : " talk.background.enabled " )
2025-12-12 21:18:54 +00:00
switch phase {
case . background :
self . isBackgrounded = true
2026-02-08 18:08:13 +01:00
self . stopGatewayHealthMonitor ( )
self . backgroundedAt = Date ( )
self . reconnectAfterBackgroundArmed = true
2026-02-19 20:20:28 +00:00
self . beginBackgroundConnectionGracePeriod ( )
2026-02-16 17:36:17 +00:00
// R e l e a s e v o i c e w a k e m i c i n b a c k g r o u n d .
2026-02-08 18:08:13 +01:00
self . backgroundVoiceWakeSuspended = self . voiceWake . suspendForExternalAudioCapture ( )
2026-02-16 17:36:17 +00:00
let shouldKeepTalkActive = keepTalkActive && self . talkMode . isEnabled
self . backgroundTalkKeptActive = shouldKeepTalkActive
self . backgroundTalkSuspended = self . talkMode . suspendForBackground ( keepActive : shouldKeepTalkActive )
2025-12-12 21:18:54 +00:00
case . active , . inactive :
self . isBackgrounded = false
2026-02-19 20:20:28 +00:00
self . endBackgroundConnectionGracePeriod ( reason : " scene_foreground " )
self . clearBackgroundReconnectSuppression ( reason : " scene_foreground " )
2026-02-08 18:08:13 +01:00
if self . operatorConnected {
self . startGatewayHealthMonitor ( )
}
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 }
2026-02-16 17:36:17 +00:00
let keptActive = await MainActor . run { self . backgroundTalkKeptActive }
await MainActor . run {
self . backgroundTalkSuspended = false
self . backgroundTalkKeptActive = false
}
await self . talkMode . resumeAfterBackground ( wasSuspended : suspended , wasKeptActive : keptActive )
2026-02-08 18:08:13 +01:00
}
}
if phase = = . active , self . reconnectAfterBackgroundArmed {
self . reconnectAfterBackgroundArmed = false
let backgroundedFor = self . backgroundedAt . map { Date ( ) . timeIntervalSince ( $0 ) } ? ? 0
self . backgroundedAt = nil
// i O S m a y s u s p e n d n e t w o r k s o c k e t s i n b a c k g r o u n d w i t h o u t a c l e a n c l o s e .
// O n f o r e g r o u n d , f o r c e a f r e s h h a n d s h a k e t o a v o i d " c o n n e c t e d b u t d e a d " s t a t e s .
if backgroundedFor >= 3.0 {
Task { [ weak self ] in
guard let self else { return }
let operatorWasConnected = await MainActor . run { self . operatorConnected }
if operatorWasConnected {
// P r e f e r k e e p i n g t h e c o n n e c t i o n i f i t ' s h e a l t h y ; r e c o n n e c t o n l y w h e n n e e d e d .
let healthy = ( try ? await self . operatorGateway . request (
method : " health " ,
paramsJSON : nil ,
timeoutSeconds : 2 ) ) != nil
if healthy {
await MainActor . run { self . startGatewayHealthMonitor ( ) }
return
}
}
await self . operatorGateway . disconnect ( )
await self . nodeGateway . disconnect ( )
await MainActor . run {
self . operatorConnected = false
self . gatewayConnected = false
self . talkMode . updateGatewayConnected ( false )
}
}
}
}
2025-12-12 21:18:54 +00:00
@ unknown default :
self . isBackgrounded = false
2026-02-19 20:20:28 +00:00
self . endBackgroundConnectionGracePeriod ( reason : " scene_unknown " )
self . clearBackgroundReconnectSuppression ( reason : " scene_unknown " )
2025-12-12 21:18:54 +00:00
}
}
2026-02-19 20:20:28 +00:00
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 = self . backgroundReconnectLeaseUntil , existing > leaseUntil {
// K e e p t h e l o n g e r l e a s e i f o n e i s a l r e a d y a c t i v e .
} else {
self . backgroundReconnectLeaseUntil = leaseUntil
}
let wasSuppressed = self . backgroundReconnectSuppressed
self . backgroundReconnectSuppressed = false
self . pushWakeLogger . info (
" Background reconnect lease reason= \( reason , privacy : . public ) seconds= \( leaseSeconds , privacy : . public ) wasSuppressed= \( wasSuppressed , 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 }
self . pushWakeLogger . info (
" Background reconnect suppressed reason= \( reason , privacy : . public ) disconnect= \( disconnectIfNeeded , 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 {
self . operatorConnected = false
self . gatewayConnected = false
self . talkMode . updateGatewayConnected ( false )
if self . isBackgrounded {
self . gatewayStatusText = " 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 ) " )
}
2025-12-13 19:53:17 +00:00
func setVoiceWakeEnabled ( _ enabled : Bool ) {
self . voiceWake . setEnabled ( enabled )
2026-02-08 18:08:13 +01:00
if enabled {
// I f t a l k i s e n a b l e d , v o i c e w a k e s h o u l d n o t g r a b t h e m i c .
if self . talkMode . isEnabled {
self . voiceWake . setSuppressedByTalk ( true )
self . talkVoiceWakeSuspended = self . voiceWake . suspendForExternalAudioCapture ( )
}
} else {
self . voiceWake . setSuppressedByTalk ( false )
self . talkVoiceWakeSuspended = false
}
2025-12-13 19:53:17 +00:00
}
2025-12-29 23:21:05 +01:00
func setTalkEnabled ( _ enabled : Bool ) {
2026-02-16 16:22:51 +00:00
UserDefaults . standard . set ( enabled , forKey : " talk.enabled " )
2026-02-08 18:08:13 +01:00
if enabled {
// V o i c e w a k e h o l d s t h e m i c r o p h o n e c o n t i n u o u s l y ; t a l k m o d e n e e d s e x c l u s i v e a c c e s s f o r S T T .
// W h e n t a l k i s e n a b l e d f r o m t h e U I , p r i o r i t i z e t a l k a n d p a u s e v o i c e w a k e .
self . voiceWake . setSuppressedByTalk ( true )
self . talkVoiceWakeSuspended = self . voiceWake . suspendForExternalAudioCapture ( )
} else {
self . voiceWake . setSuppressedByTalk ( false )
self . voiceWake . resumeAfterExternalAudioCapture ( wasSuspended : self . talkVoiceWakeSuspended )
self . talkVoiceWakeSuspended = false
}
2025-12-29 23:21:05 +01:00
self . talkMode . setEnabled ( enabled )
2026-02-16 16:22:51 +00:00
Task { [ weak self ] in
await self ? . pushTalkModeToGateway (
enabled : enabled ,
phase : enabled ? " enabled " : " disabled " )
}
2025-12-29 23:21:05 +01:00
}
2026-01-30 03:15:10 +01:00
func requestLocationPermissions ( mode : OpenClawLocationMode ) async -> Bool {
2026-01-04 00:54:44 +01:00
guard mode != . off else { return true }
let status = await self . locationService . ensureAuthorization ( mode : mode )
switch status {
case . authorizedAlways :
return true
case . authorizedWhenInUse :
return mode != . always
default :
return false
}
}
2026-01-15 08:57:08 +00:00
private func applyMainSessionKey ( _ key : String ? ) {
let trimmed = ( key ? ? " " ) . trimmingCharacters ( in : . whitespacesAndNewlines )
guard ! trimmed . isEmpty else { return }
2026-02-08 18:08:13 +01:00
let current = self . mainSessionBaseKey . trimmingCharacters ( in : . whitespacesAndNewlines )
2026-01-15 08:57:08 +00:00
if trimmed = = current { return }
2026-02-08 18:08:13 +01:00
self . mainSessionBaseKey = trimmed
self . talkMode . updateMainSessionKey ( self . mainSessionKey )
2026-01-15 08:57:08 +00:00
}
2025-12-30 04:14:36 +01:00
var seamColor : Color {
Self . color ( fromHex : self . seamColorHex ) ? ? Self . defaultSeamColor
}
2025-12-30 07:40:02 +01:00
private static let defaultSeamColor = Color ( red : 79 / 255.0 , green : 122 / 255.0 , blue : 154 / 255.0 )
2026-02-18 19:37:03 +00:00
private static let apnsDeviceTokenUserDefaultsKey = " push.apns.deviceTokenHex "
private static var apnsEnvironment : String {
#if DEBUG
" sandbox "
#else
" production "
#endif
}
2025-12-30 04:14:36 +01:00
private static func color ( fromHex raw : String ? ) -> Color ? {
let trimmed = ( raw ? ? " " ) . trimmingCharacters ( in : . whitespacesAndNewlines )
guard ! trimmed . isEmpty else { return nil }
let hex = trimmed . hasPrefix ( " # " ) ? String ( trimmed . dropFirst ( ) ) : trimmed
guard hex . count = = 6 , let value = Int ( hex , radix : 16 ) else { return nil }
let r = Double ( ( value >> 16 ) & 0xFF ) / 255.0
let g = Double ( ( value >> 8 ) & 0xFF ) / 255.0
let b = Double ( value & 0xFF ) / 255.0
return Color ( red : r , green : g , blue : b )
}
private func refreshBrandingFromGateway ( ) async {
do {
2026-02-08 18:08:13 +01:00
let res = try await self . operatorGateway . request ( method : " config.get " , paramsJSON : " {} " , timeoutSeconds : 8 )
2025-12-30 04:14:36 +01:00
guard let json = try JSONSerialization . jsonObject ( with : res ) as ? [ String : Any ] else { return }
guard let config = json [ " config " ] as ? [ String : Any ] else { return }
let ui = config [ " ui " ] as ? [ String : Any ]
let raw = ( ui ? [ " seamColor " ] as ? String ) ? . trimmingCharacters ( in : . whitespacesAndNewlines ) ? ? " "
2025-12-30 06:47:19 +01:00
let session = config [ " session " ] as ? [ String : Any ]
2026-01-09 22:38:12 +01:00
let mainKey = SessionKey . normalizeMainKey ( session ? [ " mainKey " ] as ? String )
2025-12-30 04:14:36 +01:00
await MainActor . run {
self . seamColorHex = raw . isEmpty ? nil : raw
2026-02-08 18:08:13 +01:00
self . mainSessionBaseKey = mainKey
self . talkMode . updateMainSessionKey ( self . mainSessionKey )
2025-12-30 04:14:36 +01:00
}
} catch {
2026-02-08 18:08:13 +01:00
if let gatewayError = error as ? GatewayResponseError {
let lower = gatewayError . message . lowercased ( )
if lower . contains ( " unauthorized role " ) {
return
}
}
2025-12-30 04:14:36 +01:00
// i g n o r e
}
}
2026-02-08 18:08:13 +01:00
private func refreshAgentsFromGateway ( ) async {
do {
let res = try await self . operatorGateway . request ( method : " agents.list " , paramsJSON : " {} " , timeoutSeconds : 8 )
let decoded = try JSONDecoder ( ) . decode ( AgentsListResult . self , from : res )
await MainActor . run {
self . gatewayDefaultAgentId = decoded . defaultid
self . gatewayAgents = decoded . agents
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 . talkMode . updateMainSessionKey ( self . mainSessionKey )
}
} catch {
// B e s t - e f f o r t o n l y .
}
}
func setSelectedAgentId ( _ agentId : String ? ) {
let trimmed = ( agentId ? ? " " ) . trimmingCharacters ( in : . whitespacesAndNewlines )
let stableID = ( self . connectedGatewayID ? ? " " ) . trimmingCharacters ( in : . whitespacesAndNewlines )
if stableID . isEmpty {
self . selectedAgentId = trimmed . isEmpty ? nil : trimmed
} else {
self . selectedAgentId = trimmed . isEmpty ? nil : trimmed
GatewaySettingsStore . saveGatewaySelectedAgentId ( stableID : stableID , agentId : self . selectedAgentId )
}
self . talkMode . updateMainSessionKey ( self . mainSessionKey )
2026-02-17 20:08:50 +00:00
if let relay = ShareGatewayRelaySettings . loadConfig ( ) {
ShareGatewayRelaySettings . saveConfig (
ShareGatewayRelayConfig (
gatewayURLString : relay . gatewayURLString ,
token : relay . token ,
password : relay . password ,
sessionKey : self . mainSessionKey ,
2026-02-17 23:47:34 +01:00
deliveryChannel : self . shareDeliveryChannel ,
deliveryTo : self . shareDeliveryTo ) )
2026-02-17 20:08:50 +00:00
}
2026-02-08 18:08:13 +01:00
}
2025-12-14 05:05:20 +00:00
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 {
2026-02-08 18:08:13 +01:00
_ = try await self . operatorGateway . request ( method : " voicewake.set " , paramsJSON : json , timeoutSeconds : 12 )
2025-12-14 05:05:20 +00:00
} catch {
// B e s t - e f f o r t o n l y .
}
}
private func startVoiceWakeSync ( ) async {
self . voiceWakeSyncTask ? . cancel ( )
self . voiceWakeSyncTask = Task { [ weak self ] in
guard let self else { return }
2026-02-08 18:08:13 +01:00
if ! ( await self . isGatewayHealthMonitorDisabled ( ) ) {
await self . refreshWakeWordsFromGateway ( )
}
2025-12-14 05:05:20 +00:00
2026-02-08 18:08:13 +01:00
let stream = await self . operatorGateway . subscribeServerEvents ( bufferingNewest : 200 )
2025-12-14 05:05:20 +00:00
for await evt in stream {
if Task . isCancelled { return }
2026-01-19 06:22:01 +00:00
guard let payload = evt . payload else { continue }
2026-02-16 16:22:51 +00:00
switch evt . event {
case " voicewake.changed " :
struct Payload : Decodable { var triggers : [ String ] }
guard let decoded = try ? GatewayPayloadDecoding . decode ( payload , as : Payload . self ) else { continue }
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 { continue }
self . applyTalkModeSync ( enabled : decoded . enabled , phase : decoded . phase )
default :
continue
}
2025-12-14 05:05:20 +00:00
}
}
}
2026-02-16 16:22:51 +00:00
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 self . 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 )
}
2026-02-08 18:08:13 +01:00
private func startGatewayHealthMonitor ( ) {
self . gatewayHealthMonitorDisabled = false
self . gatewayHealthMonitor . start (
check : { [ weak self ] in
guard let self else { return false }
if await 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 ( )
2026-02-19 20:20:28 +00:00
if lower . contains ( " unauthorized role " ) || lower . contains ( " missing scope " ) {
2026-02-08 18:08:13 +01:00
await self . setGatewayHealthMonitorDisabled ( true )
return true
}
}
return false
}
} ,
onFailure : { [ weak self ] _ in
guard let self else { return }
await self . operatorGateway . disconnect ( )
2026-02-18 13:23:06 +00:00
await self . nodeGateway . disconnect ( )
2026-02-08 18:08:13 +01:00
await MainActor . run {
self . operatorConnected = false
2026-02-18 13:23:06 +00:00
self . gatewayConnected = false
self . gatewayStatusText = " Reconnecting… "
2026-02-08 18:08:13 +01:00
self . talkMode . updateGatewayConnected ( false )
}
} )
}
private func stopGatewayHealthMonitor ( ) {
self . gatewayHealthMonitor . stop ( )
}
2025-12-14 05:05:20 +00:00
private func refreshWakeWordsFromGateway ( ) async {
do {
2026-02-08 18:08:13 +01:00
let data = try await self . operatorGateway . request ( method : " voicewake.get " , paramsJSON : " {} " , timeoutSeconds : 8 )
2025-12-14 05:05:20 +00:00
guard let triggers = VoiceWakePreferences . decodeGatewayTriggers ( from : data ) else { return }
VoiceWakePreferences . saveTriggerWords ( triggers )
} catch {
2026-02-08 18:08:13 +01:00
if let gatewayError = error as ? GatewayResponseError {
let lower = gatewayError . message . lowercased ( )
2026-02-19 20:20:28 +00:00
if lower . contains ( " unauthorized role " ) || lower . contains ( " missing scope " ) {
2026-02-08 18:08:13 +01:00
await self . setGatewayHealthMonitorDisabled ( true )
return
}
}
2025-12-14 05:05:20 +00:00
// B e s t - e f f o r t o n l y .
}
}
2026-02-08 18:08:13 +01:00
private func isGatewayHealthMonitorDisabled ( ) -> Bool {
self . gatewayHealthMonitorDisabled
}
private func setGatewayHealthMonitorDisabled ( _ disabled : Bool ) {
self . gatewayHealthMonitorDisabled = disabled
}
2025-12-13 19:53:17 +00:00
func sendVoiceTranscript ( text : String , sessionKey : String ? ) async throws {
2026-01-19 05:44:36 +00:00
if await ! self . isGatewayConnected ( ) {
throw NSError ( domain : " Gateway " , code : 10 , userInfo : [
NSLocalizedDescriptionKey : " Gateway not connected " ,
] )
}
2025-12-13 19:53:17 +00:00
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 " ,
] )
}
2026-02-08 18:08:13 +01:00
await self . nodeGateway . sendEvent ( event : " voice.transcript " , payloadJSON : json )
2025-12-13 19:53:17 +00:00
}
2025-12-12 21:18:54 +00:00
2025-12-13 01:18:48 +00:00
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 )
2026-02-16 16:22:51 +00:00
case . gateway :
break
2025-12-13 01:18:48 +00:00
}
}
private func handleAgentDeepLink ( _ link : AgentDeepLink , originalURL : URL ) async {
let message = link . message . trimmingCharacters ( in : . whitespacesAndNewlines )
guard ! message . isEmpty else { return }
2026-02-17 20:08:50 +00:00
self . deepLinkLogger . info (
" agent deep link received messageChars= \( message . count ) url= \( originalURL . absoluteString , privacy : . public ) "
)
2025-12-13 01:18:48 +00:00
if message . count > 20000 {
self . screen . errorText = " Deep link too large (message exceeds 20,000 characters). "
2026-02-17 20:08:50 +00:00
self . recordShareEvent ( " Rejected: message too large ( \( message . count ) chars). " )
2025-12-13 01:18:48 +00:00
return
}
2026-01-19 05:44:36 +00:00
guard await self . isGatewayConnected ( ) else {
self . screen . errorText = " Gateway not connected (cannot forward deep link). "
2026-02-17 20:08:50 +00:00
self . recordShareEvent ( " Failed: gateway not connected. " )
self . deepLinkLogger . error ( " agent deep link rejected: gateway not connected " )
2025-12-13 01:18:48 +00:00
return
}
do {
try await self . sendAgentRequest ( link : link )
self . screen . errorText = nil
2026-02-17 20:08:50 +00:00
self . recordShareEvent ( " Sent to gateway ( \( message . count ) chars). " )
self . deepLinkLogger . info ( " agent deep link forwarded to gateway " )
self . openChatRequestID &+= 1
2025-12-13 01:18:48 +00:00
} catch {
self . screen . errorText = " Agent request failed: \( error . localizedDescription ) "
2026-02-17 20:08:50 +00:00
self . recordShareEvent ( " Failed: \( error . localizedDescription ) " )
self . deepLinkLogger . error ( " agent deep link send failed: \( error . localizedDescription , privacy : . public ) " )
2025-12-13 01:18:48 +00:00
}
}
private func sendAgentRequest ( link : AgentDeepLink ) async throws {
if link . message . trimmingCharacters ( in : . whitespacesAndNewlines ) . isEmpty {
throw NSError ( domain : " DeepLink " , code : 1 , userInfo : [
NSLocalizedDescriptionKey : " invalid agent message " ,
] )
}
2026-01-19 05:44:36 +00:00
// i O S g a t e w a y f o r w a r d s t o t h e g a t e w a y ; n o l o c a l a u t h p r o m p t s h e r e .
2026-01-30 03:15:10 +01:00
// ( K e y - b a s e d u n a t t e n d e d a u t h i s h a n d l e d o n m a c O S f o r o p e n c l a w : / / l i n k s . )
2025-12-13 19:53:17 +00:00
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 " ,
] )
}
2026-02-08 18:08:13 +01:00
await self . nodeGateway . sendEvent ( event : " agent.request " , payloadJSON : json )
2025-12-13 19:53:17 +00:00
}
2025-12-13 01:18:48 +00:00
2026-01-19 05:44:36 +00:00
private func isGatewayConnected ( ) async -> Bool {
self . gatewayConnected
2025-12-13 01:18:48 +00:00
}
2025-12-12 21:18:54 +00:00
private func handleInvoke ( _ req : BridgeInvokeRequest ) async -> BridgeInvokeResponse {
2025-12-18 01:17:23 +00:00
let command = req . command
2025-12-18 01:57:31 +01:00
2026-01-04 16:23:46 +01:00
if self . isBackgrounded , self . isBackgroundRestricted ( command ) {
2025-12-12 21:18:54 +00:00
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError (
2025-12-12 21:18:54 +00:00
code : . backgroundUnavailable ,
2025-12-19 02:56:48 +01:00
message : " NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground " ) )
2025-12-14 00:48:58 +00:00
}
2025-12-18 01:57:31 +01:00
if command . hasPrefix ( " camera. " ) , ! self . isCameraEnabled ( ) {
2025-12-14 00:48:58 +00:00
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError (
2025-12-14 00:48:58 +00:00
code : . unavailable ,
message : " CAMERA_DISABLED: enable Camera in iOS Settings → Camera → Allow Camera " ) )
2025-12-12 21:18:54 +00:00
}
do {
2026-02-08 18:08:13 +01:00
return try await self . capabilityRouter . handle ( req )
} catch let error as NodeCapabilityRouter . RouterError {
switch error {
case . unknownCommand :
2025-12-12 21:18:54 +00:00
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError ( code : . invalidRequest , message : " INVALID_REQUEST: unknown command " ) )
2026-02-08 18:08:13 +01:00
case . handlerUnavailable :
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
error : OpenClawNodeError ( code : . unavailable , message : " node handler unavailable " ) )
2026-01-31 09:32:29 +01:00
}
2025-12-12 21:18:54 +00:00
} catch {
2025-12-18 14:48:35 +01:00
if command . hasPrefix ( " camera. " ) {
let text = ( error as ? LocalizedError ) ? . errorDescription ? ? error . localizedDescription
self . showCameraHUD ( text : text , kind : . error , autoHideSeconds : 2.2 )
}
2025-12-12 21:18:54 +00:00
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError ( code : . unavailable , message : error . localizedDescription ) )
2025-12-12 21:18:54 +00:00
}
}
2026-01-04 16:23:46 +01:00
private func isBackgroundRestricted ( _ command : String ) -> Bool {
2026-02-08 18:08:13 +01:00
command . hasPrefix ( " canvas. " ) || command . hasPrefix ( " camera. " ) || command . hasPrefix ( " screen. " ) ||
command . hasPrefix ( " talk. " )
2026-01-04 16:23:46 +01:00
}
private func handleLocationInvoke ( _ req : BridgeInvokeRequest ) async throws -> BridgeInvokeResponse {
let mode = self . locationMode ( )
guard mode != . off else {
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError (
2026-01-04 16:23:46 +01:00
code : . unavailable ,
message : " LOCATION_DISABLED: enable Location in Settings " ) )
}
if self . isBackgrounded , mode != . always {
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError (
2026-01-04 16:23:46 +01:00
code : . backgroundUnavailable ,
message : " LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always " ) )
}
2026-01-30 03:15:10 +01:00
let params = ( try ? Self . decodeParams ( OpenClawLocationGetParams . self , from : req . paramsJSON ) ) ? ?
OpenClawLocationGetParams ( )
2026-01-04 16:23:46 +01:00
let desired = params . desiredAccuracy ? ?
( self . isLocationPreciseEnabled ( ) ? . precise : . balanced )
let status = self . locationService . authorizationStatus ( )
if status != . authorizedAlways , status != . authorizedWhenInUse {
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError (
2026-01-04 16:23:46 +01:00
code : . unavailable ,
message : " LOCATION_PERMISSION_REQUIRED: grant Location permission " ) )
}
if self . isBackgrounded , status != . authorizedAlways {
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError (
2026-01-04 16:23:46 +01:00
code : . unavailable ,
message : " LOCATION_PERMISSION_REQUIRED: enable Always for background access " ) )
}
let location = try await self . locationService . currentLocation (
params : params ,
desiredAccuracy : desired ,
maxAgeMs : params . maxAgeMs ,
timeoutMs : params . timeoutMs )
let isPrecise = self . locationService . accuracyAuthorization ( ) = = . fullAccuracy
2026-01-30 03:15:10 +01:00
let payload = OpenClawLocationPayload (
2026-01-04 16:23:46 +01:00
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 {
2026-01-30 03:15:10 +01:00
case OpenClawCanvasCommand . present . rawValue :
2026-02-08 18:08:13 +01:00
// i O S i g n o r e s p l a c e m e n t h i n t s ; c a n v a s a l w a y s f i l l s t h e s c r e e n .
2026-01-30 03:15:10 +01:00
let params = ( try ? Self . decodeParams ( OpenClawCanvasPresentParams . self , from : req . paramsJSON ) ) ? ?
OpenClawCanvasPresentParams ( )
2026-01-04 16:23:46 +01:00
let url = params . url ? . trimmingCharacters ( in : . whitespacesAndNewlines ) ? ? " "
if url . isEmpty {
self . screen . showDefaultCanvas ( )
} else {
self . screen . navigate ( to : url )
}
return BridgeInvokeResponse ( id : req . id , ok : true )
2026-01-30 03:15:10 +01:00
case OpenClawCanvasCommand . hide . rawValue :
2026-02-08 18:08:13 +01:00
self . screen . showDefaultCanvas ( )
2026-01-04 16:23:46 +01:00
return BridgeInvokeResponse ( id : req . id , ok : true )
2026-01-30 03:15:10 +01:00
case OpenClawCanvasCommand . navigate . rawValue :
let params = try Self . decodeParams ( OpenClawCanvasNavigateParams . self , from : req . paramsJSON )
2026-01-04 16:23:46 +01:00
self . screen . navigate ( to : params . url )
return BridgeInvokeResponse ( id : req . id , ok : true )
2026-01-30 03:15:10 +01:00
case OpenClawCanvasCommand . evalJS . rawValue :
let params = try Self . decodeParams ( OpenClawCanvasEvalParams . self , from : req . paramsJSON )
2026-01-04 16:23:46 +01:00
let result = try await self . screen . eval ( javaScript : params . javaScript )
let payload = try Self . encodePayload ( [ " result " : result ] )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : payload )
2026-01-30 03:15:10 +01:00
case OpenClawCanvasCommand . snapshot . rawValue :
let params = try ? Self . decodeParams ( OpenClawCanvasSnapshotParams . self , from : req . paramsJSON )
2026-01-04 16:23:46 +01:00
let format = params ? . format ? ? . jpeg
let maxWidth : CGFloat ? = {
if let raw = params ? . maxWidth , raw > 0 { return CGFloat ( raw ) }
// K e e p d e f a u l t s n a p s h o t s c o m f o r t a b l y b e l o w t h e g a t e w a y c l i e n t ' s m a x P a y l o a d .
// F o r f u l l - r e s , c l i e n t s s h o u l d e x p l i c i t l y r e q u e s t a l a r g e r m a x W i d t h .
return switch format {
case . png : 900
case . jpeg : 1600
}
} ( )
let base64 = try await self . 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 ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError ( code : . invalidRequest , message : " INVALID_REQUEST: unknown command " ) )
2026-01-04 16:23:46 +01:00
}
}
private func handleCanvasA2UIInvoke ( _ req : BridgeInvokeRequest ) async throws -> BridgeInvokeResponse {
let command = req . command
switch command {
2026-01-30 03:15:10 +01:00
case OpenClawCanvasA2UICommand . reset . rawValue :
2026-01-04 16:23:46 +01:00
guard let a2uiUrl = await self . resolveA2UIHostURL ( ) else {
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError (
2026-01-04 16:23:46 +01:00
code : . unavailable ,
message : " A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host " ) )
}
self . screen . navigate ( to : a2uiUrl )
if await ! self . screen . waitForA2UIReady ( timeoutMs : 5000 ) {
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError (
2026-01-04 16:23:46 +01:00
code : . unavailable ,
message : " A2UI_HOST_UNAVAILABLE: A2UI host not reachable " ) )
}
let json = try await self . screen . eval ( javaScript : " " "
( ( ) = > {
2026-01-30 03:15:10 +01:00
const host = globalThis . openclawA2UI ;
if ( ! host ) return JSON . stringify ( { ok : false , error : " missing openclawA2UI " } ) ;
return JSON . stringify ( host . reset ( ) ) ;
2026-01-04 16:23:46 +01:00
} ) ( )
" " " )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
2026-01-30 03:15:10 +01:00
case OpenClawCanvasA2UICommand . push . rawValue , OpenClawCanvasA2UICommand . pushJSONL . rawValue :
2026-02-08 18:08:13 +01:00
let messages : [ OpenClawKit . AnyCodable ]
2026-01-30 03:15:10 +01:00
if command = = OpenClawCanvasA2UICommand . pushJSONL . rawValue {
let params = try Self . decodeParams ( OpenClawCanvasA2UIPushJSONLParams . self , from : req . paramsJSON )
messages = try OpenClawCanvasA2UIJSONL . decodeMessagesFromJSONL ( params . jsonl )
2026-01-04 16:23:46 +01:00
} else {
do {
2026-01-30 03:15:10 +01:00
let params = try Self . decodeParams ( OpenClawCanvasA2UIPushParams . self , from : req . paramsJSON )
2026-01-04 16:23:46 +01:00
messages = params . messages
} catch {
// B e f o r g i v i n g : s o m e c l i e n t s s t i l l s e n d J S O N L p a y l o a d s t o ` c a n v a s . a 2 u i . p u s h ` .
2026-01-30 03:15:10 +01:00
let params = try Self . decodeParams ( OpenClawCanvasA2UIPushJSONLParams . self , from : req . paramsJSON )
messages = try OpenClawCanvasA2UIJSONL . decodeMessagesFromJSONL ( params . jsonl )
2026-01-04 16:23:46 +01:00
}
}
guard let a2uiUrl = await self . resolveA2UIHostURL ( ) else {
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError (
2026-01-04 16:23:46 +01:00
code : . unavailable ,
message : " A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host " ) )
}
self . screen . navigate ( to : a2uiUrl )
if await ! self . screen . waitForA2UIReady ( timeoutMs : 5000 ) {
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError (
2026-01-04 16:23:46 +01:00
code : . unavailable ,
message : " A2UI_HOST_UNAVAILABLE: A2UI host not reachable " ) )
}
2026-01-30 03:15:10 +01:00
let messagesJSON = try OpenClawCanvasA2UIJSONL . encodeMessagesJSONArray ( messages )
2026-01-04 16:23:46 +01:00
let js = " " "
( ( ) = > {
try {
2026-01-30 03:15:10 +01:00
const host = globalThis . openclawA2UI ;
if ( ! host ) return JSON . stringify ( { ok : false , error : " missing openclawA2UI " } ) ;
2026-01-04 16:23:46 +01:00
const messages = \ ( messagesJSON ) ;
2026-01-30 03:15:10 +01:00
return JSON . stringify ( host . applyMessages ( messages ) ) ;
2026-01-04 16:23:46 +01:00
} catch ( e ) {
return JSON . stringify ( { ok : false , error : String ( e ? . message ? ? e ) } ) ;
}
} ) ( )
" " "
let resultJSON = try await self . screen . eval ( javaScript : js )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : resultJSON )
default :
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError ( code : . invalidRequest , message : " INVALID_REQUEST: unknown command " ) )
2026-01-04 16:23:46 +01:00
}
}
private func handleCameraInvoke ( _ req : BridgeInvokeRequest ) async throws -> BridgeInvokeResponse {
switch req . command {
2026-01-30 03:15:10 +01:00
case OpenClawCameraCommand . list . rawValue :
2026-01-04 16:23:46 +01:00
let devices = await self . 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 )
2026-01-30 03:15:10 +01:00
case OpenClawCameraCommand . snap . rawValue :
2026-01-04 16:23:46 +01:00
self . showCameraHUD ( text : " Taking photo… " , kind : . photo )
self . triggerCameraFlash ( )
2026-01-30 03:15:10 +01:00
let params = ( try ? Self . decodeParams ( OpenClawCameraSnapParams . self , from : req . paramsJSON ) ) ? ?
OpenClawCameraSnapParams ( )
2026-01-04 16:23:46 +01:00
let res = try await self . 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 ) )
self . showCameraHUD ( text : " Photo captured " , kind : . success , autoHideSeconds : 1.6 )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : payload )
2026-01-30 03:15:10 +01:00
case OpenClawCameraCommand . clip . rawValue :
let params = ( try ? Self . decodeParams ( OpenClawCameraClipParams . self , from : req . paramsJSON ) ) ? ?
OpenClawCameraClipParams ( )
2026-01-04 16:23:46 +01:00
let suspended = ( params . includeAudio ? ? true ) ? self . voiceWake . suspendForExternalAudioCapture ( ) : false
defer { self . voiceWake . resumeAfterExternalAudioCapture ( wasSuspended : suspended ) }
self . showCameraHUD ( text : " Recording… " , kind : . recording )
let res = try await self . 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 ) )
self . 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 ,
2026-01-30 03:15:10 +01:00
error : OpenClawNodeError ( code : . invalidRequest , message : " INVALID_REQUEST: unknown command " ) )
2026-01-04 16:23:46 +01:00
}
}
private func handleScreenRecordInvoke ( _ req : BridgeInvokeRequest ) async throws -> BridgeInvokeResponse {
2026-01-30 03:15:10 +01:00
let params = ( try ? Self . decodeParams ( OpenClawScreenRecordParams . self , from : req . paramsJSON ) ) ? ?
OpenClawScreenRecordParams ( )
2026-01-04 16:23:46 +01:00
if let format = params . format , format . lowercased ( ) != " mp4 " {
throw NSError ( domain : " Screen " , code : 30 , userInfo : [
NSLocalizedDescriptionKey : " INVALID_REQUEST: screen format must be mp4 " ,
] )
}
// S t a t u s p i l l m i r r o r s s c r e e n r e c o r d i n g s t a t e s o i t s t a y s v i s i b l e w i t h o u t o v e r l a y s t a c k i n g .
self . screenRecordActive = true
defer { self . screenRecordActive = false }
let path = try await self . screenRecorder . record (
screenIndex : params . screenIndex ,
durationMs : params . durationMs ,
fps : params . fps ,
includeAudio : params . includeAudio ,
outPath : nil )
2026-01-18 20:46:29 +01:00
defer { try ? FileManager ( ) . removeItem ( atPath : path ) }
2026-01-04 16:23:46 +01:00
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 )
}
2026-02-08 18:08:13 +01:00
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 " ) )
}
2026-01-19 13:37:28 +00:00
2026-02-08 18:08:13 +01:00
let finalStatus = await self . requestNotificationAuthorizationIfNeeded ( )
guard finalStatus = = . authorized || finalStatus = = . provisional || finalStatus = = . ephemeral else {
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
error : OpenClawNodeError ( code : . unavailable , message : " NOT_AUTHORIZED: notifications " ) )
}
2026-01-04 00:54:44 +01:00
2026-02-08 18:08:13 +01:00
let addResult = await self . runNotificationCall ( timeoutSeconds : 2.0 ) { [ notificationCenter ] in
let content = UNMutableNotificationContent ( )
content . title = title
content . body = body
if #available ( iOS 15.0 , * ) {
switch params . priority ? ? . active {
case . passive :
content . interruptionLevel = . passive
case . timeSensitive :
content . interruptionLevel = . timeSensitive
case . active :
content . interruptionLevel = . active
}
}
let soundValue = params . sound ? . trimmingCharacters ( in : . whitespacesAndNewlines ) . lowercased ( )
if let soundValue , [ " none " , " silent " , " off " , " false " , " 0 " ] . contains ( soundValue ) {
content . sound = nil
} else {
content . sound = . default
}
let request = UNNotificationRequest (
identifier : UUID ( ) . uuidString ,
content : content ,
trigger : nil )
try await notificationCenter . add ( request )
}
if case let . failure ( error ) = addResult {
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
error : OpenClawNodeError ( code : . unavailable , message : " NOTIFICATION_FAILED: \( error . message ) " ) )
}
return BridgeInvokeResponse ( id : req . id , ok : true )
2026-01-04 00:54:44 +01:00
}
2026-02-08 18:08:13 +01:00
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 " ) )
2025-12-12 21:18:54 +00:00
}
2026-02-08 18:08:13 +01:00
let finalStatus = await self . requestNotificationAuthorizationIfNeeded ( )
let messageId = UUID ( ) . uuidString
if finalStatus = = . authorized || finalStatus = = . provisional || finalStatus = = . ephemeral {
let addResult = await self . runNotificationCall ( timeoutSeconds : 2.0 ) { [ notificationCenter ] in
let content = UNMutableNotificationContent ( )
content . title = " OpenClaw "
content . body = text
content . sound = . default
content . userInfo = [ " messageId " : messageId ]
let request = UNNotificationRequest (
identifier : messageId ,
content : content ,
trigger : nil )
try await notificationCenter . add ( request )
}
if case let . failure ( error ) = addResult {
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
error : OpenClawNodeError ( code : . unavailable , message : " NOTIFICATION_FAILED: \( error . message ) " ) )
}
2025-12-13 19:53:17 +00:00
}
2025-12-14 00:48:58 +00:00
2026-02-08 18:08:13 +01:00
if params . speak ? ? true {
let toSpeak = text
Task { @ MainActor in
try ? await TalkSystemSpeechSynthesizer . shared . speak ( text : toSpeak )
}
}
2025-12-18 14:48:35 +01:00
2026-02-08 18:08:13 +01:00
let payload = OpenClawChatPushPayload ( messageId : messageId )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
2025-12-18 14:48:35 +01:00
}
2026-02-08 18:08:13 +01:00
private func requestNotificationAuthorizationIfNeeded ( ) async -> NotificationAuthorizationStatus {
let status = await self . notificationAuthorizationStatus ( )
guard status = = . notDetermined else { return status }
2025-12-18 14:48:35 +01:00
2026-02-08 18:08:13 +01:00
// A v o i d h a n g i n g i n v o k e r e q u e s t s i f t h e p e r m i s s i o n p r o m p t i s n e v e r a n s w e r e d .
_ = await self . runNotificationCall ( timeoutSeconds : 2.0 ) { [ notificationCenter ] in
_ = try await notificationCenter . requestAuthorization ( options : [ . alert , . sound , . badge ] )
2025-12-18 14:48:35 +01:00
}
2026-02-08 18:08:13 +01:00
return await self . notificationAuthorizationStatus ( )
}
private func notificationAuthorizationStatus ( ) async -> NotificationAuthorizationStatus {
let result = await self . runNotificationCall ( timeoutSeconds : 1.5 ) { [ notificationCenter ] in
await notificationCenter . authorizationStatus ( )
}
switch result {
case let . success ( status ) :
return status
case . failure :
return . denied
2025-12-18 14:48:35 +01:00
}
}
2025-12-24 20:00:45 +01:00
2026-02-08 18:08:13 +01:00
private func runNotificationCall < T : Sendable > (
timeoutSeconds : Double ,
operation : @ escaping @ Sendable ( ) async throws -> T
) async -> Result < T , NotificationCallError > {
let latch = NotificationInvokeLatch < T > ( )
var opTask : Task < Void , Never > ?
var timeoutTask : Task < Void , Never > ?
defer {
opTask ? . cancel ( )
timeoutTask ? . cancel ( )
}
let clamped = max ( 0.0 , timeoutSeconds )
return await withCheckedContinuation { ( cont : CheckedContinuation < Result < T , NotificationCallError > , Never > ) in
latch . setContinuation ( cont )
opTask = Task { @ MainActor in
do {
let value = try await operation ( )
latch . resume ( . success ( value ) )
} catch {
latch . resume ( . failure ( NotificationCallError ( message : error . localizedDescription ) ) )
}
}
timeoutTask = Task . detached {
if clamped > 0 {
try ? await Task . sleep ( nanoseconds : UInt64 ( clamped * 1_000_000_000 ) )
}
latch . resume ( . failure ( NotificationCallError ( message : " notification request timed out " ) ) )
}
}
}
private func handleDeviceInvoke ( _ req : BridgeInvokeRequest ) async throws -> BridgeInvokeResponse {
switch req . command {
case OpenClawDeviceCommand . status . rawValue :
let payload = try await self . deviceStatusService . status ( )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
case OpenClawDeviceCommand . info . rawValue :
let payload = self . deviceStatusService . info ( )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
default :
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
error : OpenClawNodeError ( code : . invalidRequest , message : " INVALID_REQUEST: unknown command " ) )
}
}
private func handlePhotosInvoke ( _ req : BridgeInvokeRequest ) async throws -> BridgeInvokeResponse {
let params = ( try ? Self . decodeParams ( OpenClawPhotosLatestParams . self , from : req . paramsJSON ) ) ? ?
OpenClawPhotosLatestParams ( )
let payload = try await self . photosService . latest ( params : params )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
}
private func handleContactsInvoke ( _ req : BridgeInvokeRequest ) async throws -> BridgeInvokeResponse {
switch req . command {
case OpenClawContactsCommand . search . rawValue :
let params = ( try ? Self . decodeParams ( OpenClawContactsSearchParams . self , from : req . paramsJSON ) ) ? ?
OpenClawContactsSearchParams ( )
let payload = try await self . contactsService . search ( params : params )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
case OpenClawContactsCommand . add . rawValue :
let params = try Self . decodeParams ( OpenClawContactsAddParams . self , from : req . paramsJSON )
let payload = try await self . contactsService . add ( params : params )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
default :
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
error : OpenClawNodeError ( code : . invalidRequest , message : " INVALID_REQUEST: unknown command " ) )
}
}
private func handleCalendarInvoke ( _ req : BridgeInvokeRequest ) async throws -> BridgeInvokeResponse {
switch req . command {
case OpenClawCalendarCommand . events . rawValue :
let params = ( try ? Self . decodeParams ( OpenClawCalendarEventsParams . self , from : req . paramsJSON ) ) ? ?
OpenClawCalendarEventsParams ( )
let payload = try await self . calendarService . events ( params : params )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
case OpenClawCalendarCommand . add . rawValue :
let params = try Self . decodeParams ( OpenClawCalendarAddParams . self , from : req . paramsJSON )
let payload = try await self . calendarService . add ( params : params )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
default :
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
error : OpenClawNodeError ( code : . invalidRequest , message : " INVALID_REQUEST: unknown command " ) )
}
}
private func handleRemindersInvoke ( _ req : BridgeInvokeRequest ) async throws -> BridgeInvokeResponse {
switch req . command {
case OpenClawRemindersCommand . list . rawValue :
let params = ( try ? Self . decodeParams ( OpenClawRemindersListParams . self , from : req . paramsJSON ) ) ? ?
OpenClawRemindersListParams ( )
let payload = try await self . remindersService . list ( params : params )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
case OpenClawRemindersCommand . add . rawValue :
let params = try Self . decodeParams ( OpenClawRemindersAddParams . self , from : req . paramsJSON )
let payload = try await self . remindersService . add ( params : params )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
default :
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
error : OpenClawNodeError ( code : . invalidRequest , message : " INVALID_REQUEST: unknown command " ) )
}
}
private func handleMotionInvoke ( _ req : BridgeInvokeRequest ) async throws -> BridgeInvokeResponse {
switch req . command {
case OpenClawMotionCommand . activity . rawValue :
let params = ( try ? Self . decodeParams ( OpenClawMotionActivityParams . self , from : req . paramsJSON ) ) ? ?
OpenClawMotionActivityParams ( )
let payload = try await self . motionService . activities ( params : params )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
case OpenClawMotionCommand . pedometer . rawValue :
let params = ( try ? Self . decodeParams ( OpenClawPedometerParams . self , from : req . paramsJSON ) ) ? ?
OpenClawPedometerParams ( )
let payload = try await self . motionService . pedometer ( params : params )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
default :
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
error : OpenClawNodeError ( code : . invalidRequest , message : " INVALID_REQUEST: unknown command " ) )
}
}
private func handleTalkInvoke ( _ req : BridgeInvokeRequest ) async throws -> BridgeInvokeResponse {
switch req . command {
case OpenClawTalkCommand . pttStart . rawValue :
self . pttVoiceWakeSuspended = self . voiceWake . suspendForExternalAudioCapture ( )
let payload = try await self . talkMode . beginPushToTalk ( )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
case OpenClawTalkCommand . pttStop . rawValue :
let payload = await self . talkMode . endPushToTalk ( )
self . voiceWake . resumeAfterExternalAudioCapture ( wasSuspended : self . pttVoiceWakeSuspended )
self . pttVoiceWakeSuspended = false
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
case OpenClawTalkCommand . pttCancel . rawValue :
let payload = await self . talkMode . cancelPushToTalk ( )
self . voiceWake . resumeAfterExternalAudioCapture ( wasSuspended : self . pttVoiceWakeSuspended )
self . pttVoiceWakeSuspended = false
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
case OpenClawTalkCommand . pttOnce . rawValue :
self . pttVoiceWakeSuspended = self . voiceWake . suspendForExternalAudioCapture ( )
defer {
self . voiceWake . resumeAfterExternalAudioCapture ( wasSuspended : self . pttVoiceWakeSuspended )
self . pttVoiceWakeSuspended = false
}
let payload = try await self . talkMode . runPushToTalkOnce ( )
let json = try Self . encodePayload ( payload )
return BridgeInvokeResponse ( id : req . id , ok : true , payloadJSON : json )
default :
return BridgeInvokeResponse (
id : req . id ,
ok : false ,
error : OpenClawNodeError ( code : . invalidRequest , message : " INVALID_REQUEST: unknown command " ) )
}
}
}
private extension NodeAppModel {
// C e n t r a l r e g i s t r y f o r n o d e i n v o k e r o u t i n g t o k e e p c o m m a n d s i n o n e p l a c e .
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 )
}
2026-02-18 13:37:41 +00:00
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 )
}
2026-02-08 18:08:13 +01:00
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 )
}
2026-02-18 13:37:41 +00:00
func handleWatchInvoke ( _ req : BridgeInvokeRequest ) async throws -> BridgeInvokeResponse {
switch req . command {
case OpenClawWatchCommand . status . rawValue :
let status = await self . 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 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 watch notification " ) )
}
do {
let result = try await self . watchMessagingService . sendNotification (
id : req . id ,
2026-02-20 16:39:13 +00:00
params : params )
2026-02-20 19:04:58 +00:00
if result . queuedForDelivery || ! result . deliveredImmediately {
let invokeID = req . id
Task { @ MainActor in
await WatchPromptNotificationBridge . scheduleMirroredWatchPromptNotificationIfNeeded (
invokeID : invokeID ,
params : params ,
sendResult : result )
}
}
2026-02-18 13:37:41 +00:00
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 " ) )
}
}
2026-02-08 18:08:13 +01:00
func locationMode ( ) -> OpenClawLocationMode {
let raw = UserDefaults . standard . string ( forKey : " location.enabledMode " ) ? ? " off "
return OpenClawLocationMode ( rawValue : raw ) ? ? . off
}
func isLocationPreciseEnabled ( ) -> Bool {
2026-02-17 20:08:50 +00:00
// i O S s e t t i n g s n o w e x p o s e a s i n g l e l o c a t i o n m o d e c o n t r o l .
// D e f a u l t l o c a t i o n t o o l p r e c i s i o n s t a y s h i g h u n l e s s a c o m m a n d e x p l i c i t l y r e q u e s t s b a l a n c e d .
true
2026-02-08 18:08:13 +01:00
}
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 )
}
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
}
func isCameraEnabled ( ) -> Bool {
// D e f a u l t - o n : i f t h e k e y d o e s n ' t e x i s t y e t , t r e a t i t a s e n a b l e d .
if UserDefaults . standard . object ( forKey : " camera.enabled " ) = = nil { return true }
return UserDefaults . standard . bool ( forKey : " camera.enabled " )
}
func triggerCameraFlash ( ) {
self . cameraFlashNonce &+= 1
}
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 {
2026-02-19 05:05:40 +08:00
var mainSessionKey : String {
let base = SessionKey . normalizeMainKey ( self . mainSessionBaseKey )
let agentId = ( self . selectedAgentId ? ? " " ) . trimmingCharacters ( in : . whitespacesAndNewlines )
let defaultId = ( self . gatewayDefaultAgentId ? ? " " ) . trimmingCharacters ( in : . whitespacesAndNewlines )
if agentId . isEmpty || ( ! defaultId . isEmpty && agentId = = defaultId ) { return base }
return SessionKey . makeAgentSessionKey ( agentId : agentId , baseKey : base )
}
2026-02-19 18:42:56 +00:00
var chatSessionKey : String {
let base = " ios "
let agentId = ( self . selectedAgentId ? ? " " ) . trimmingCharacters ( in : . whitespacesAndNewlines )
let defaultId = ( self . gatewayDefaultAgentId ? ? " " ) . trimmingCharacters ( in : . whitespacesAndNewlines )
if agentId . isEmpty || ( ! defaultId . isEmpty && agentId = = defaultId ) { return base }
return SessionKey . makeAgentSessionKey ( agentId : agentId , baseKey : base )
}
2026-02-19 05:05:40 +08:00
var activeAgentName : String {
let agentId = ( self . selectedAgentId ? ? " " ) . trimmingCharacters ( in : . whitespacesAndNewlines )
let defaultId = ( self . gatewayDefaultAgentId ? ? " " ) . trimmingCharacters ( in : . whitespacesAndNewlines )
let resolvedId = agentId . isEmpty ? defaultId : agentId
if resolvedId . isEmpty { return " Main " }
if let match = self . gatewayAgents . first ( where : { $0 . id = = resolvedId } ) {
let name = ( match . name ? ? " " ) . trimmingCharacters ( in : . whitespacesAndNewlines )
return name . isEmpty ? match . id : name
}
return resolvedId
}
2026-02-08 18:08:13 +01:00
func connectToGateway (
url : URL ,
gatewayStableID : String ,
tls : GatewayTLSParams ? ,
token : String ? ,
password : String ? ,
connectOptions : GatewayConnectOptions )
{
let stableID = gatewayStableID . trimmingCharacters ( in : . whitespacesAndNewlines )
let effectiveStableID = stableID . isEmpty ? url . absoluteString : stableID
let sessionBox = tls . map { WebSocketSessionBox ( session : GatewayTLSPinningSession ( params : $0 ) ) }
self . activeGatewayConnectConfig = GatewayConnectConfig (
url : url ,
stableID : stableID ,
tls : tls ,
token : token ,
password : password ,
nodeOptions : connectOptions )
self . prepareForGatewayConnect ( url : url , stableID : effectiveStableID )
self . startOperatorGatewayLoop (
url : url ,
stableID : effectiveStableID ,
token : token ,
password : password ,
nodeOptions : connectOptions ,
sessionBox : sessionBox )
self . startNodeGatewayLoop (
url : url ,
stableID : effectiveStableID ,
token : token ,
password : password ,
nodeOptions : connectOptions ,
sessionBox : sessionBox )
}
// / P r e f e r r e d e n t r y - p o i n t : a p p l y a s i n g l e c o n f i g o b j e c t a n d s t a r t b o t h s e s s i o n s .
func applyGatewayConnectConfig ( _ cfg : GatewayConnectConfig ) {
self . activeGatewayConnectConfig = cfg
self . connectToGateway (
url : cfg . url ,
// P r e s e r v e t h e c a l l e r - p r o v i d e d s t a b l e I D ( m a y b e e m p t y ) a n d l e t c o n n e c t T o G a t e w a y
// d e r i v e t h e e f f e c t i v e s t a b l e i d c o n s i s t e n t l y f o r p e r s i s t e n c e k e y s .
gatewayStableID : cfg . stableID ,
tls : cfg . tls ,
token : cfg . token ,
password : cfg . password ,
connectOptions : cfg . nodeOptions )
}
func disconnectGateway ( ) {
self . gatewayAutoReconnectEnabled = false
2026-02-16 16:22:51 +00:00
self . gatewayPairingPaused = false
self . gatewayPairingRequestId = nil
2026-02-08 18:08:13 +01:00
self . nodeGatewayTask ? . cancel ( )
self . nodeGatewayTask = nil
self . operatorGatewayTask ? . cancel ( )
self . operatorGatewayTask = nil
self . voiceWakeSyncTask ? . cancel ( )
self . voiceWakeSyncTask = nil
self . gatewayHealthMonitor . stop ( )
Task {
await self . operatorGateway . disconnect ( )
await self . nodeGateway . disconnect ( )
}
self . gatewayStatusText = " Offline "
self . gatewayServerName = nil
self . gatewayRemoteAddress = nil
self . connectedGatewayID = nil
self . activeGatewayConnectConfig = nil
self . gatewayConnected = false
self . operatorConnected = false
self . talkMode . updateGatewayConnected ( false )
self . seamColorHex = nil
self . mainSessionBaseKey = " main "
self . talkMode . updateMainSessionKey ( self . mainSessionKey )
2026-02-17 20:08:50 +00:00
ShareGatewayRelaySettings . clearConfig ( )
2026-02-08 18:08:13 +01:00
self . showLocalCanvasOnDisconnect ( )
}
}
private extension NodeAppModel {
func prepareForGatewayConnect ( url : URL , stableID : String ) {
self . gatewayAutoReconnectEnabled = true
2026-02-16 16:22:51 +00:00
self . gatewayPairingPaused = false
self . gatewayPairingRequestId = nil
2026-02-08 18:08:13 +01:00
self . nodeGatewayTask ? . cancel ( )
self . operatorGatewayTask ? . cancel ( )
self . gatewayHealthMonitor . stop ( )
self . gatewayServerName = nil
self . gatewayRemoteAddress = nil
self . connectedGatewayID = stableID
self . gatewayConnected = false
self . operatorConnected = false
self . voiceWakeSyncTask ? . cancel ( )
self . voiceWakeSyncTask = nil
self . gatewayDefaultAgentId = nil
self . gatewayAgents = [ ]
self . selectedAgentId = GatewaySettingsStore . loadGatewaySelectedAgentId ( stableID : stableID )
2026-02-18 19:37:03 +00:00
self . apnsLastRegisteredTokenHex = nil
2026-02-08 18:08:13 +01:00
}
2026-02-19 20:20:28 +00:00
func refreshBackgroundReconnectSuppressionIfNeeded ( source : String ) {
guard self . isBackgrounded else { return }
guard ! self . backgroundReconnectSuppressed else { return }
guard let leaseUntil = self . backgroundReconnectLeaseUntil else {
self . suppressBackgroundReconnect ( reason : " \( source ) :no_lease " , disconnectIfNeeded : true )
return
}
if Date ( ) >= leaseUntil {
self . suppressBackgroundReconnect ( reason : " \( source ) :lease_expired " , disconnectIfNeeded : true )
}
}
func shouldPauseReconnectLoopInBackground ( source : String ) -> Bool {
self . refreshBackgroundReconnectSuppressionIfNeeded ( source : source )
return self . isBackgrounded && self . backgroundReconnectSuppressed
}
2026-02-08 18:08:13 +01:00
func startOperatorGatewayLoop (
url : URL ,
stableID : String ,
token : String ? ,
password : String ? ,
nodeOptions : GatewayConnectOptions ,
sessionBox : WebSocketSessionBox ? )
{
// O p e r a t o r s e s s i o n r e c o n n e c t s i n d e p e n d e n t l y ( c h a t / t a l k / c o n f i g / v o i c e w a k e ) , b u t w e t i e i t s
// l i f e c y c l e t o t h e c u r r e n t g a t e w a y c o n f i g s o i t d o e s n ' t k e e p r u n n i n g a c r o s s D i s c o n n e c t .
self . operatorGatewayTask = Task { [ weak self ] in
guard let self else { return }
var attempt = 0
while ! Task . isCancelled {
2026-02-16 16:22:51 +00:00
if self . gatewayPairingPaused {
try ? await Task . sleep ( nanoseconds : 1_000_000_000 )
continue
}
2026-02-17 13:12:53 +00:00
if ! self . gatewayAutoReconnectEnabled {
try ? await Task . sleep ( nanoseconds : 1_000_000_000 )
continue
}
2026-02-19 20:20:28 +00:00
if self . shouldPauseReconnectLoopInBackground ( source : " operator_loop " ) { try ? await Task . sleep ( nanoseconds : 2_000_000_000 ) ; continue }
2026-02-08 18:08:13 +01:00
if await self . isOperatorConnected ( ) {
try ? await Task . sleep ( nanoseconds : 1_000_000_000 )
continue
}
let effectiveClientId =
GatewaySettingsStore . loadGatewayClientIdOverride ( stableID : stableID ) ? ? nodeOptions . clientId
let operatorOptions = self . makeOperatorConnectOptions (
clientId : effectiveClientId ,
displayName : nodeOptions . clientDisplayName )
do {
try await self . operatorGateway . connect (
url : url ,
token : token ,
password : password ,
connectOptions : operatorOptions ,
sessionBox : sessionBox ,
onConnected : { [ weak self ] in
guard let self else { return }
await MainActor . run {
self . operatorConnected = true
self . talkMode . updateGatewayConnected ( true )
}
GatewayDiagnostics . log (
" operator gateway connected host= \( url . host ? ? " ? " ) scheme= \( url . scheme ? ? " ? " ) " )
2026-02-21 10:34:20 +02:00
await self . talkMode . reloadConfig ( )
2026-02-08 18:08:13 +01:00
await self . refreshBrandingFromGateway ( )
await self . refreshAgentsFromGateway ( )
2026-02-17 20:08:50 +00:00
await self . refreshShareRouteFromGateway ( )
2026-02-08 18:08:13 +01:00
await self . startVoiceWakeSync ( )
await MainActor . run { self . startGatewayHealthMonitor ( ) }
} ,
onDisconnected : { [ weak self ] reason in
guard let self else { return }
await MainActor . run {
self . operatorConnected = false
self . talkMode . updateGatewayConnected ( false )
}
GatewayDiagnostics . log ( " operator gateway disconnected reason= \( reason ) " )
await MainActor . run { self . stopGatewayHealthMonitor ( ) }
} ,
onInvoke : { req in
// O p e r a t o r s e s s i o n s h o u l d n o t h a n d l e n o d e . i n v o k e r e q u e s t s .
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 {
attempt += 1
GatewayDiagnostics . log ( " operator gateway connect error: \( error . localizedDescription ) " )
let sleepSeconds = min ( 8.0 , 0.5 * pow ( 1.7 , Double ( attempt ) ) )
try ? await Task . sleep ( nanoseconds : UInt64 ( sleepSeconds * 1_000_000_000 ) )
}
}
}
}
func startNodeGatewayLoop (
url : URL ,
stableID : String ,
token : String ? ,
password : String ? ,
nodeOptions : GatewayConnectOptions ,
sessionBox : WebSocketSessionBox ? )
{
self . nodeGatewayTask = Task { [ weak self ] in
guard let self else { return }
var attempt = 0
var currentOptions = nodeOptions
var didFallbackClientId = false
2026-02-16 16:22:51 +00:00
var pausedForPairingApproval = false
2026-02-08 18:08:13 +01:00
while ! Task . isCancelled {
2026-02-16 16:22:51 +00:00
if self . gatewayPairingPaused {
try ? await Task . sleep ( nanoseconds : 1_000_000_000 )
continue
}
2026-02-17 13:12:53 +00:00
if ! self . gatewayAutoReconnectEnabled {
try ? await Task . sleep ( nanoseconds : 1_000_000_000 )
continue
}
2026-02-19 20:20:28 +00:00
if self . shouldPauseReconnectLoopInBackground ( source : " node_loop " ) { try ? await Task . sleep ( nanoseconds : 2_000_000_000 ) ; continue }
2026-02-08 18:08:13 +01:00
if await self . isGatewayConnected ( ) {
try ? await Task . sleep ( nanoseconds : 1_000_000_000 )
continue
}
await MainActor . run {
self . gatewayStatusText = ( attempt = = 0 ) ? " Connecting… " : " Reconnecting… "
self . gatewayServerName = nil
self . gatewayRemoteAddress = nil
}
do {
let epochMs = Int ( Date ( ) . timeIntervalSince1970 * 1000 )
GatewayDiagnostics . log ( " connect attempt epochMs= \( epochMs ) url= \( url . absoluteString ) " )
try await self . nodeGateway . connect (
url : url ,
token : token ,
password : password ,
connectOptions : currentOptions ,
sessionBox : sessionBox ,
onConnected : { [ weak self ] in
guard let self else { return }
await MainActor . run {
self . gatewayStatusText = " Connected "
self . gatewayServerName = url . host ? ? " gateway "
self . gatewayConnected = true
self . screen . errorText = nil
UserDefaults . standard . set ( true , forKey : " gateway.autoconnect " )
}
2026-02-17 20:08:50 +00:00
let relayData = await MainActor . run {
(
sessionKey : self . mainSessionKey ,
deliveryChannel : self . shareDeliveryChannel ,
deliveryTo : self . shareDeliveryTo
)
}
ShareGatewayRelaySettings . saveConfig (
ShareGatewayRelayConfig (
gatewayURLString : url . absoluteString ,
token : token ,
password : password ,
sessionKey : relayData . sessionKey ,
deliveryChannel : relayData . deliveryChannel ,
deliveryTo : relayData . deliveryTo ) )
2026-02-16 16:22:51 +00:00
GatewayDiagnostics . log ( " gateway connected host= \( url . host ? ? " ? " ) scheme= \( url . scheme ? ? " ? " ) " )
2026-02-08 18:08:13 +01:00
if let addr = await self . nodeGateway . currentRemoteAddress ( ) {
await MainActor . run { self . gatewayRemoteAddress = addr }
}
await self . showA2UIOnConnectIfNeeded ( )
2026-02-16 16:22:51 +00:00
await self . onNodeGatewayConnected ( )
2026-02-19 20:20:28 +00:00
await MainActor . run {
SignificantLocationMonitor . startIfNeeded (
locationService : self . locationService ,
locationMode : self . locationMode ( ) ,
gateway : self . nodeGateway ,
beforeSend : { [ weak self ] in
await self ? . handleSignificantLocationWakeIfNeeded ( )
} )
}
2026-02-08 18:08:13 +01:00
} ,
onDisconnected : { [ weak self ] reason in
guard let self else { return }
await MainActor . run {
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 )
} )
attempt = 0
try ? await Task . sleep ( nanoseconds : 1_000_000_000 )
} catch {
if Task . isCancelled { break }
if ! didFallbackClientId ,
let fallbackClientId = self . legacyClientIdFallback (
currentClientId : currentOptions . clientId ,
error : error )
{
didFallbackClientId = true
currentOptions . clientId = fallbackClientId
GatewaySettingsStore . saveGatewayClientIdOverride (
stableID : stableID ,
clientId : fallbackClientId )
await MainActor . run { self . gatewayStatusText = " Gateway rejected client id. Retrying… " }
continue
}
attempt += 1
await MainActor . run {
self . gatewayStatusText = " Gateway error: \( error . localizedDescription ) "
self . gatewayServerName = nil
self . gatewayRemoteAddress = nil
self . gatewayConnected = false
self . showLocalCanvasOnDisconnect ( )
}
GatewayDiagnostics . log ( " gateway connect error: \( error . localizedDescription ) " )
2026-02-16 16:22:51 +00:00
2026-02-17 13:12:53 +00:00
// I f a u t h i s m i s s i n g / r e j e c t e d , p a u s e r e c o n n e c t c h u r n u n t i l t h e u s e r i n t e r v e n e s .
// R e c o n n e c t l o o p s o n l y s p a m t h e s a m e f a i l i n g h a n d s h a k e a n d m a k e o n b o a r d i n g n o i s y .
let lower = error . localizedDescription . lowercased ( )
if lower . contains ( " unauthorized " ) || lower . contains ( " gateway token missing " ) {
await MainActor . run {
self . gatewayAutoReconnectEnabled = false
}
}
2026-02-16 16:22:51 +00:00
// I f p a i r i n g i s r e q u i r e d , s t o p r e c o n n e c t c h u r n . T h e u s e r m u s t a p p r o v e t h e r e q u e s t
// o n t h e g a t e w a y b e f o r e a n o t h e r c o n n e c t a t t e m p t w i l l s u c c e e d , a n d r e t r y l o o p s c a n
// g e n e r a t e m u l t i p l e p e n d i n g r e q u e s t s .
if lower . contains ( " not_paired " ) || lower . contains ( " pairing required " ) {
let requestId : String ? = {
// G a t e w a y R e s p o n s e E r r o r f o r c o n n e c t d e c o r a t e s t h e m e s s a g e w i t h ` ( r e q u e s t I d : . . . ) ` .
// K e e p t h i s r e s i l i e n t s i n c e o t h e r l a y e r s m a y w r a p t h e t e x t .
let text = error . localizedDescription
guard let start = text . range ( of : " (requestId: " ) ? . upperBound else { return nil }
guard let end = text [ start . . . ] . firstIndex ( of : " ) " ) else { return nil }
let raw = String ( text [ start . . < end ] ) . trimmingCharacters ( in : . whitespacesAndNewlines )
return raw . isEmpty ? nil : raw
} ( )
await MainActor . run {
self . gatewayAutoReconnectEnabled = false
self . gatewayPairingPaused = true
self . gatewayPairingRequestId = requestId
if let requestId , ! requestId . isEmpty {
self . gatewayStatusText =
2026-02-17 20:08:50 +00:00
" Pairing required (requestId: \( requestId ) ). Approve on gateway and return to OpenClaw. "
2026-02-16 16:22:51 +00:00
} else {
2026-02-17 20:08:50 +00:00
self . gatewayStatusText = " Pairing required. Approve on gateway and return to OpenClaw. "
2026-02-16 16:22:51 +00:00
}
}
// H a r d s t o p t h e u n d e r l y i n g W e b S o c k e t w a t c h d o g r e c o n n e c t s s o t h e U I s t a y s s t a b l e a n d
// w e d o n ' t g e n e r a t e m u l t i p l e p e n d i n g r e q u e s t s w h i l e w a i t i n g f o r a p p r o v a l .
pausedForPairingApproval = true
self . operatorGatewayTask ? . cancel ( )
self . operatorGatewayTask = nil
await self . operatorGateway . disconnect ( )
await self . nodeGateway . disconnect ( )
break
}
2026-02-08 18:08:13 +01:00
let sleepSeconds = min ( 8.0 , 0.5 * pow ( 1.7 , Double ( attempt ) ) )
try ? await Task . sleep ( nanoseconds : UInt64 ( sleepSeconds * 1_000_000_000 ) )
}
}
2026-02-16 16:22:51 +00:00
if pausedForPairingApproval {
// L e a v e t h e s t a t u s t e x t + r e q u e s t i d i n t a c t s o o n b o a r d i n g c a n g u i d e t h e u s e r .
return
}
2026-02-08 18:08:13 +01:00
await MainActor . run {
self . gatewayStatusText = " Offline "
self . gatewayServerName = nil
self . gatewayRemoteAddress = nil
self . connectedGatewayID = nil
self . gatewayConnected = false
self . operatorConnected = false
self . talkMode . updateGatewayConnected ( false )
self . seamColorHex = nil
self . mainSessionBaseKey = " main "
self . talkMode . updateMainSessionKey ( self . mainSessionKey )
self . showLocalCanvasOnDisconnect ( )
}
}
}
func makeOperatorConnectOptions ( clientId : String , displayName : String ? ) -> GatewayConnectOptions {
GatewayConnectOptions (
role : " operator " ,
2026-02-13 21:37:49 +05:30
scopes : [ " operator.read " , " operator.write " , " operator.talk.secrets " ] ,
2026-02-08 18:08:13 +01:00
caps : [ ] ,
commands : [ ] ,
permissions : [ : ] ,
clientId : clientId ,
clientMode : " ui " ,
clientDisplayName : displayName ,
2026-02-20 14:16:00 +02:00
includeDeviceIdentity : true )
2026-02-08 18:08:13 +01:00
}
func legacyClientIdFallback ( currentClientId : String , error : Error ) -> String ? {
let normalizedClientId = currentClientId . trimmingCharacters ( in : . whitespacesAndNewlines ) . lowercased ( )
guard normalizedClientId = = " openclaw-ios " else { return nil }
let message = error . localizedDescription . lowercased ( )
guard message . contains ( " invalid connect params " ) , message . contains ( " /client/id " ) else {
return nil
}
return " moltbot-ios "
}
func isOperatorConnected ( ) async -> Bool {
self . operatorConnected
}
}
2026-02-16 16:22:51 +00:00
extension NodeAppModel {
2026-02-17 20:08:50 +00:00
private func refreshShareRouteFromGateway ( ) 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 self . 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
}
2026-02-17 23:47:34 +01:00
let selected = exactMatch
2026-02-17 20:08:50 +00:00
let channel = normalize ( selected ? . lastChannel )
let to = normalize ( selected ? . lastTo )
await MainActor . run {
self . shareDeliveryChannel = channel
self . shareDeliveryTo = to
if let relay = ShareGatewayRelaySettings . loadConfig ( ) {
ShareGatewayRelaySettings . saveConfig (
ShareGatewayRelayConfig (
gatewayURLString : relay . gatewayURLString ,
token : relay . token ,
password : relay . password ,
sessionKey : self . mainSessionKey ,
deliveryChannel : channel ,
deliveryTo : to ) )
}
}
} catch {
// B e s t - e f f o r t o n l y .
}
}
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 self . handleDeepLink ( url : deepLink )
}
func refreshLastShareEventFromRelay ( ) {
if let event = ShareGatewayRelaySettings . loadLastEvent ( ) {
self . lastShareEventText = event
}
}
func recordShareEvent ( _ text : String ) {
ShareGatewayRelaySettings . saveLastEvent ( text )
self . refreshLastShareEventFromRelay ( )
}
2026-02-16 16:22:51 +00:00
func reloadTalkConfig ( ) {
Task { [ weak self ] in
await self ? . talkMode . reloadConfig ( )
}
}
// / B a c k - c o m p a t h o o k r e t a i n e d f o r o l d e r g a t e w a y - c o n n e c t f l o w s .
2026-02-18 19:37:03 +00:00
func onNodeGatewayConnected ( ) async {
await self . registerAPNsTokenIfNeeded ( )
2026-02-20 16:39:13 +00:00
await self . flushQueuedWatchRepliesIfConnected ( )
}
private func handleWatchQuickReply ( _ event : WatchQuickReplyEvent ) async {
let replyId = event . replyId . trimmingCharacters ( in : . whitespacesAndNewlines )
let actionId = event . actionId . trimmingCharacters ( in : . whitespacesAndNewlines )
if replyId . isEmpty || actionId . isEmpty {
self . watchReplyLogger . info ( " watch reply dropped: missing replyId/actionId " )
return
}
if self . seenWatchReplyIds . contains ( replyId ) {
self . watchReplyLogger . debug (
" watch reply deduped replyId= \( replyId , privacy : . public ) " )
return
}
self . seenWatchReplyIds . insert ( replyId )
if await ! self . isGatewayConnected ( ) {
self . queuedWatchReplies . append ( event )
self . watchReplyLogger . info (
" watch reply queued replyId= \( replyId , privacy : . public ) action= \( actionId , privacy : . public ) " )
return
}
await self . forwardWatchReplyToAgent ( event )
}
private func flushQueuedWatchRepliesIfConnected ( ) async {
guard await self . isGatewayConnected ( ) else { return }
guard ! self . queuedWatchReplies . isEmpty else { return }
let pending = self . queuedWatchReplies
self . queuedWatchReplies . removeAll ( )
for event in pending {
await self . forwardWatchReplyToAgent ( event )
}
}
private func forwardWatchReplyToAgent ( _ event : WatchQuickReplyEvent ) async {
let sessionKey = event . sessionKey ? . trimmingCharacters ( in : . whitespacesAndNewlines )
let effectiveSessionKey = ( sessionKey ? . isEmpty = = false ) ? sessionKey : self . mainSessionKey
let message = Self . makeWatchReplyAgentMessage ( event )
let link = AgentDeepLink (
message : message ,
sessionKey : effectiveSessionKey ,
thinking : " low " ,
deliver : false ,
to : nil ,
channel : nil ,
timeoutSeconds : nil ,
key : event . replyId )
do {
try await self . sendAgentRequest ( link : link )
self . watchReplyLogger . info (
" watch reply forwarded replyId= \( event . replyId , privacy : . public ) action= \( event . actionId , privacy : . public ) " )
self . openChatRequestID &+= 1
} catch {
self . watchReplyLogger . error (
" watch reply forwarding failed replyId= \( event . replyId , privacy : . public ) error= \( error . localizedDescription , privacy : . public ) " )
self . queuedWatchReplies . insert ( event , at : 0 )
}
}
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 " )
2026-02-18 19:37:03 +00:00
}
2026-02-18 21:00:17 +00:00
func handleSilentPushWake ( _ userInfo : [ AnyHashable : Any ] ) async -> Bool {
2026-02-19 20:20:28 +00:00
let wakeId = Self . makePushWakeAttemptID ( )
2026-02-18 21:00:17 +00:00
guard Self . isSilentPushPayload ( userInfo ) else {
2026-02-19 20:20:28 +00:00
self . pushWakeLogger . info ( " Ignored APNs payload wakeId= \( wakeId , privacy : . public ) : not silent push " )
2026-02-18 21:00:17 +00:00
return false
}
2026-02-19 20:20:28 +00:00
let pushKind = Self . openclawPushKind ( userInfo )
self . pushWakeLogger . info (
" Silent push received wakeId= \( wakeId , privacy : . public ) kind= \( pushKind , privacy : . public ) backgrounded= \( self . isBackgrounded , privacy : . public ) autoReconnect= \( self . gatewayAutoReconnectEnabled , privacy : . public ) " )
let result = await self . reconnectGatewaySessionsForSilentPushIfNeeded ( wakeId : wakeId )
self . pushWakeLogger . info (
" Silent push outcome wakeId= \( wakeId , privacy : . public ) applied= \( result . applied , privacy : . public ) reason= \( result . reason , privacy : . public ) durationMs= \( result . durationMs , privacy : . public ) " )
return result . applied
}
func handleBackgroundRefreshWake ( trigger : String = " bg_app_refresh " ) async -> Bool {
let wakeId = Self . makePushWakeAttemptID ( )
self . pushWakeLogger . info (
" Background refresh wake received wakeId= \( wakeId , privacy : . public ) trigger= \( trigger , privacy : . public ) backgrounded= \( self . isBackgrounded , privacy : . public ) autoReconnect= \( self . gatewayAutoReconnectEnabled , privacy : . public ) " )
let result = await self . reconnectGatewaySessionsForSilentPushIfNeeded ( wakeId : wakeId )
self . pushWakeLogger . info (
" Background refresh wake outcome wakeId= \( wakeId , privacy : . public ) applied= \( result . applied , privacy : . public ) reason= \( result . reason , privacy : . public ) durationMs= \( result . durationMs , privacy : . public ) " )
return result . applied
}
func handleSignificantLocationWakeIfNeeded ( ) async {
let wakeId = Self . makePushWakeAttemptID ( )
let now = Date ( )
let throttleWindowSeconds : TimeInterval = 180
if await self . isGatewayConnected ( ) {
self . locationWakeLogger . info (
" Location wake no-op wakeId= \( wakeId , privacy : . public ) : already connected " )
return
}
if let last = self . lastSignificantLocationWakeAt ,
now . timeIntervalSince ( last ) < throttleWindowSeconds
{
self . locationWakeLogger . info (
" Location wake throttled wakeId= \( wakeId , privacy : . public ) elapsedSec= \( now . timeIntervalSince ( last ) , privacy : . public ) " )
return
}
self . lastSignificantLocationWakeAt = now
self . locationWakeLogger . info (
" Location wake begin wakeId= \( wakeId , privacy : . public ) backgrounded= \( self . isBackgrounded , privacy : . public ) autoReconnect= \( self . gatewayAutoReconnectEnabled , privacy : . public ) " )
let result = await self . reconnectGatewaySessionsForSilentPushIfNeeded ( wakeId : wakeId )
self . locationWakeLogger . info (
" Location wake trigger wakeId= \( wakeId , privacy : . public ) applied= \( result . applied , privacy : . public ) reason= \( result . reason , privacy : . public ) durationMs= \( result . durationMs , privacy : . public ) " )
guard result . applied else { return }
let connected = await self . waitForGatewayConnection ( timeoutMs : 5000 , pollMs : 250 )
self . locationWakeLogger . info (
" Location wake post-check wakeId= \( wakeId , privacy : . public ) connected= \( connected , privacy : . public ) " )
2026-02-18 21:00:17 +00:00
}
2026-02-18 19:37:03 +00:00
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 ( ) async {
guard self . gatewayConnected else { return }
guard let token = self . apnsDeviceTokenHex ? . trimmingCharacters ( in : . whitespacesAndNewlines ) ,
! token . isEmpty
else {
return
}
if token = = self . apnsLastRegisteredTokenHex {
return
}
guard let topic = Bundle . main . bundleIdentifier ? . trimmingCharacters ( in : . whitespacesAndNewlines ) ,
! topic . isEmpty
else {
return
}
struct PushRegistrationPayload : Codable {
var token : String
var topic : String
var environment : String
}
let payload = PushRegistrationPayload (
token : token ,
topic : topic ,
environment : Self . apnsEnvironment )
do {
let json = try Self . encodePayload ( payload )
await self . nodeGateway . sendEvent ( event : " push.apns.register " , payloadJSON : json )
self . apnsLastRegisteredTokenHex = token
} catch {
// B e s t - e f f o r t o n l y .
}
}
2026-02-18 21:00:17 +00:00
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
}
2026-02-19 20:20:28 +00:00
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 SilentPushWakeAttemptResult {
var applied : 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 await self . isGatewayConnected ( ) {
return true
}
try ? await Task . sleep ( nanoseconds : pollIntervalNs )
}
return await self . isGatewayConnected ( )
}
private func reconnectGatewaySessionsForSilentPushIfNeeded (
wakeId : String
) async -> SilentPushWakeAttemptResult {
let startedAt = Date ( )
let makeResult : ( Bool , String ) -> SilentPushWakeAttemptResult = { applied , reason in
let durationMs = Int ( Date ( ) . timeIntervalSince ( startedAt ) * 1000 )
return SilentPushWakeAttemptResult (
applied : applied ,
reason : reason ,
durationMs : max ( 0 , durationMs ) )
}
2026-02-18 21:00:17 +00:00
guard self . isBackgrounded else {
2026-02-19 20:20:28 +00:00
self . pushWakeLogger . info ( " Wake no-op wakeId= \( wakeId , privacy : . public ) : app not backgrounded " )
return makeResult ( false , " not_backgrounded " )
2026-02-18 21:00:17 +00:00
}
guard self . gatewayAutoReconnectEnabled else {
2026-02-19 20:20:28 +00:00
self . pushWakeLogger . info ( " Wake no-op wakeId= \( wakeId , privacy : . public ) : auto reconnect disabled " )
return makeResult ( false , " auto_reconnect_disabled " )
2026-02-18 21:00:17 +00:00
}
2026-02-19 20:20:28 +00:00
guard let cfg = self . activeGatewayConnectConfig else {
self . pushWakeLogger . info ( " Wake no-op wakeId= \( wakeId , privacy : . public ) : no active gateway config " )
return makeResult ( false , " no_active_gateway_config " )
2026-02-18 21:00:17 +00:00
}
2026-02-19 20:20:28 +00:00
self . pushWakeLogger . info (
" Wake reconnect begin wakeId= \( wakeId , privacy : . public ) stableID= \( cfg . stableID , privacy : . public ) " )
self . grantBackgroundReconnectLease ( seconds : 30 , reason : " wake_ \( wakeId ) " )
2026-02-18 21:00:17 +00:00
await self . operatorGateway . disconnect ( )
await self . nodeGateway . disconnect ( )
self . operatorConnected = false
self . gatewayConnected = false
self . gatewayStatusText = " Reconnecting… "
self . talkMode . updateGatewayConnected ( false )
2026-02-19 20:20:28 +00:00
self . applyGatewayConnectConfig ( cfg )
self . pushWakeLogger . info ( " Wake reconnect trigger applied wakeId= \( wakeId , privacy : . public ) " )
return makeResult ( true , " reconnect_triggered " )
2026-02-18 21:00:17 +00:00
}
2026-02-16 16:22:51 +00:00
}
2026-02-20 19:04:58 +00:00
extension NodeAppModel {
func _bridgeConsumeMirroredWatchReply ( _ event : WatchQuickReplyEvent ) async {
await self . handleWatchQuickReply ( event )
}
}
2026-02-08 18:08:13 +01:00
#if DEBUG
2025-12-24 20:00:45 +01:00
extension NodeAppModel {
func _test_handleInvoke ( _ req : BridgeInvokeRequest ) async -> BridgeInvokeResponse {
await self . handleInvoke ( req )
}
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_isCameraEnabled ( ) -> Bool {
self . isCameraEnabled ( )
}
func _test_triggerCameraFlash ( ) {
self . triggerCameraFlash ( )
}
func _test_showCameraHUD ( text : String , kind : CameraHUDKind , autoHideSeconds : Double ? = nil ) {
self . showCameraHUD ( text : text , kind : kind , autoHideSeconds : autoHideSeconds )
}
func _test_handleCanvasA2UIAction ( body : [ String : Any ] ) async {
await self . handleCanvasA2UIAction ( body : body )
}
func _test_showLocalCanvasOnDisconnect ( ) {
self . showLocalCanvasOnDisconnect ( )
}
2026-02-16 16:22:51 +00:00
func _test_applyTalkModeSync ( enabled : Bool , phase : String ? = nil ) {
self . applyTalkModeSync ( enabled : enabled , phase : phase )
}
2026-02-20 16:39:13 +00:00
func _test_queuedWatchReplyCount ( ) -> Int {
self . queuedWatchReplies . count
}
2025-12-24 20:00:45 +01:00
}
#endif