Anforderungen  |   Konzepte  |   Entwurf  |   Entwicklung  |   Qualitätssicherung  |   Lebenszyklus  |   Steuerung
 
 
 
 


Impressum NodeAppModel.swift   Sprache: unbekannt

 
Spracherkennung für: .swift vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]

import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import Observation
import os
import Security
import SwiftUI
import UIKit
import UserNotifications

// Wrap errors without pulling non-Sendable types into async notification paths.
private struct NotificationCallError: Error, Sendable {
    let message: String
}

private struct GatewayRelayIdentityResponse: Decodable {
    let deviceId: String
    let publicKey: String
}

// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
    private let lock = NSLock()
    private var continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>?
    private var resumed = false

    func setContinuation(_ continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>) {
        self.lock.lock()
        defer { self.lock.unlock() }
        self.continuation = continuation
    }

    func resume(_ response: Result<T, NotificationCallError>) {
        let cont: CheckedContinuation<Result<T, NotificationCallError>, Never>?
        self.lock.lock()
        if self.resumed {
            self.lock.unlock()
            return
        }
        self.resumed = true
        cont = self.continuation
        self.continuation = nil
        self.lock.unlock()
        cont?.resume(returning: response)
    }
}

private enum IOSDeepLinkAgentPolicy {
    static let maxMessageChars = 20000
    static let maxUnkeyedConfirmChars = 240
}

@MainActor
@Observable
// swiftlint:disable type_body_length file_length
final class NodeAppModel {
    struct AgentDeepLinkPrompt: Identifiable, Equatable {
        let id: String
        let messagePreview: String
        let urlPreview: String
        let request: AgentDeepLink
    }

    struct ExecApprovalPrompt: Identifiable, Equatable, Codable, Sendable {
        let id: String
        let commandText: String
        let commandPreview: String?
        let allowedDecisions: [String]
        let host: String?
        let nodeId: String?
        let agentId: String?
        let expiresAtMs: Int?

        var allowsAllowAlways: Bool {
            self.allowedDecisions.contains("allow-always")
        }
    }

    private enum ExecApprovalResolutionOutcome {
        case resolved
        case stale
        case unavailable
        case failed(message: String)
    }

    private struct PersistedWatchExecApprovalBridgeState: Codable {
        var approvals: [ExecApprovalPrompt]
        var pendingApprovalIDs: [String]?
    }

    private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
    private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
    private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
    private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
    private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
    private let watchExecApprovalLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchExecApproval")
    private let execApprovalNotificationLogger = Logger(
        subsystem: "ai.openclaw.ios",
        category: "ExecApprovalNotification")
    enum CameraHUDKind {
        case photo
        case recording
        case success
        case error
    }

    var isBackgrounded: Bool = false
    let screen: ScreenController
    private let camera: any CameraServicing
    private let screenRecorder: any ScreenRecordingServicing
    var gatewayStatusText: String = "Offline"
    var nodeStatusText: String = "Offline"
    var operatorStatusText: String = "Offline"
    var gatewayServerName: String?
    var gatewayRemoteAddress: String?
    var connectedGatewayID: String?
    var gatewayAutoReconnectEnabled: Bool = true
    // When the gateway requires pairing approval, we pause reconnect churn and show a stable UX.
    // Reconnect loops (both our own and the underlying WebSocket watchdog) can otherwise generate
    // multiple pending requests and cause the onboarding UI to "flip-flop".
    var gatewayPairingPaused: Bool = false
    var gatewayPairingRequestId: String?
    private(set) var lastGatewayProblem: GatewayConnectionProblem?
    var gatewayDisplayStatusText: String {
        self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
    }
    var seamColorHex: String?
    private var mainSessionBaseKey: String = "main"
    var selectedAgentId: String?
    var gatewayDefaultAgentId: String?
    var gatewayAgents: [AgentSummary] = []
    var homeCanvasRevision: Int = 0
    var lastShareEventText: String = "No share events yet."
    var openChatRequestID: Int = 0
    private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
    private(set) var pendingExecApprovalPrompt: ExecApprovalPrompt?
    private(set) var pendingExecApprovalPromptResolving: Bool = false
    private(set) var pendingExecApprovalPromptErrorText: String?
    private var pendingExecApprovalPromptRequestGeneration: Int = 0
    private var queuedAgentDeepLinkPrompt: AgentDeepLinkPrompt?
    private var lastAgentDeepLinkPromptAt: Date = .distantPast
    @ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?

    // Primary "node" connection: used for device capabilities and node.invoke requests.
    private let nodeGateway = GatewayNodeSession()
    // Secondary "operator" connection: used for chat/talk/config/voicewake requests.
    private let operatorGateway = GatewayNodeSession()
    private var nodeGatewayTask: Task<Void, Never>?
    private var operatorGatewayTask: Task<Void, Never>?
    private var voiceWakeSyncTask: Task<Void, Never>?
    @ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
    @ObservationIgnored private lazy var capabilityRouter: NodeCapabilityRouter = self.buildCapabilityRouter()
    private let gatewayHealthMonitor = GatewayHealthMonitor()
    private var gatewayHealthMonitorDisabled = false
    private let notificationCenter: NotificationCentering
    let voiceWake = VoiceWakeManager()
    let talkMode: TalkModeManager
    private let locationService: any LocationServicing
    private let deviceStatusService: any DeviceStatusServicing
    private let photosService: any PhotosServicing
    private let contactsService: any ContactsServicing
    private let calendarService: any CalendarServicing
    private let remindersService: any RemindersServicing
    private let motionService: any MotionServicing
    private let watchMessagingService: any WatchMessagingServicing
    var lastAutoA2uiURL: String?
    private var pttVoiceWakeSuspended = false
    private var talkVoiceWakeSuspended = false
    private var backgroundVoiceWakeSuspended = false
    private var backgroundTalkSuspended = false
    private var backgroundTalkKeptActive = false
    private var backgroundedAt: Date?
    private var reconnectAfterBackgroundArmed = false
    private var backgroundGraceTaskID: UIBackgroundTaskIdentifier = .invalid
    @ObservationIgnored private var backgroundGraceTaskTimer: Task<Void, Never>?
    private var backgroundReconnectSuppressed = false
    private var backgroundReconnectLeaseUntil: Date?
    private var lastSignificantLocationWakeAt: Date?
    @ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
    private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:]
    private var pendingWatchExecApprovalRecoveryIDs: [String] = []
    private var pendingForegroundActionDrainInFlight = false

    private var gatewayConnected = false
    private var operatorConnected = false
    private var shareDeliveryChannel: String?
    private var shareDeliveryTo: String?
    private var apnsDeviceTokenHex: String?
    private var apnsLastRegisteredTokenHex: String?
    @ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
    var gatewaySession: GatewayNodeSession { self.nodeGateway }
    var operatorSession: GatewayNodeSession { self.operatorGateway }
    private(set) var activeGatewayConnectConfig: GatewayConnectConfig?

    private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"

    var cameraHUDText: String?
    var cameraHUDKind: CameraHUDKind?
    var cameraFlashNonce: Int = 0
    var screenRecordActive: Bool = false

    init(
        screen: ScreenController = ScreenController(),
        camera: any CameraServicing = CameraController(),
        screenRecorder: any ScreenRecordingServicing = ScreenRecordService(),
        locationService: any LocationServicing = LocationService(),
        notificationCenter: NotificationCentering = LiveNotificationCenter(),
        deviceStatusService: any DeviceStatusServicing = DeviceStatusService(),
        photosService: any PhotosServicing = PhotoLibraryService(),
        contactsService: any ContactsServicing = ContactsService(),
        calendarService: any CalendarServicing = CalendarService(),
        remindersService: any RemindersServicing = RemindersService(),
        motionService: any MotionServicing = MotionService(),
        watchMessagingService: any WatchMessagingServicing = WatchMessagingService(),
        talkMode: TalkModeManager = TalkModeManager())
    {
        self.screen = screen
        self.camera = camera
        self.screenRecorder = screenRecorder
        self.locationService = locationService
        self.notificationCenter = notificationCenter
        self.deviceStatusService = deviceStatusService
        self.photosService = photosService
        self.contactsService = contactsService
        self.calendarService = calendarService
        self.remindersService = remindersService
        self.motionService = motionService
        self.watchMessagingService = watchMessagingService
        self.talkMode = talkMode
        self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey)
        self.restorePersistedWatchExecApprovalBridgeState()
        GatewayDiagnostics.bootstrap()
        GatewayDiagnostics.log("node app model: init start")
        self.watchMessagingService.setStatusHandler { [weak self] status in
            Task { @MainActor in
                GatewayDiagnostics.log(
                    "node app model: watch status callback "
                        + "reachable=\(status.reachable) activation=\(status.activationState) "
                        + "backgrounded=\(self?.isBackgrounded ?? false)")
                await self?.handleWatchMessagingStatusChanged(status)
            }
        }
        self.watchMessagingService.setReplyHandler { [weak self] event in
            Task { @MainActor in
                await self?.handleWatchQuickReply(event)
            }
        }
        self.watchMessagingService.setExecApprovalResolveHandler { [weak self] event in
            Task { @MainActor in
                await self?.handleWatchExecApprovalResolve(event)
            }
        }
        self.watchMessagingService.setExecApprovalSnapshotRequestHandler { [weak self] event in
            Task { @MainActor in
                guard let self else { return }
                GatewayDiagnostics.log(
                    "node app model: watch snapshot request id=\(event.requestId) backgrounded=\(self.isBackgrounded)")
                guard self.isBackgrounded else {
                    self.watchExecApprovalLogger.debug(
                        "watch exec approval snapshot skipped reason=watch_request_foreground")
                    GatewayDiagnostics.log("node app model: watch snapshot request skipped in foreground")
                    return
                }
                await self.refreshWatchExecApprovalSnapshotOnDemand(reason: "watch_request")
            }
        }

        self.voiceWake.configure { [weak self] cmd in
            guard let self else { return }
            let sessionKey = await MainActor.run { self.mainSessionKey }
            do {
                try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey)
            } catch {
                // Best-effort only.
            }
        }

        let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
        self.voiceWake.setEnabled(enabled)
        self.talkMode.attachGateway(self.operatorGateway)
        self.refreshLastShareEventFromRelay()
        let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
        // Route through the coordinator so VoiceWake and Talk don't fight over the microphone.
        self.setTalkEnabled(talkEnabled)

        // Wire up deep links from canvas taps
        self.screen.onDeepLink = { [weak self] url in
            guard let self else { return }
            Task { @MainActor in
                await self.handleDeepLink(url: url)
            }
        }

        // Wire up A2UI action clicks (buttons, etc.)
        self.screen.onA2UIAction = { [weak self] body in
            guard let self else { return }
            Task { @MainActor in
                await self.handleCanvasA2UIAction(body: body)
            }
        }
    }

    private func handleCanvasA2UIAction(body: [String: Any]) async {
        let userActionAny = body["userAction"] ?? body
        let userAction: [String: Any] = {
            if let dict = userActionAny as? [String: Any] { return dict }
            if let dict = userActionAny as? [AnyHashable: Any] {
                return dict.reduce(into: [String: Any]()) { acc, pair in
                    guard let key = pair.key as? String else { return }
                    acc[key] = pair.value
                }
            }
            return [:]
        }()
        guard !userAction.isEmpty else { return }

        guard let name = OpenClawCanvasA2UIAction.extractActionName(userAction) else { return }
        let actionId: String = {
            let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
            return id.isEmpty ? UUID().uuidString : id
        }()

        let surfaceId: String = {
            let raw = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
            return raw.isEmpty ? "main" : raw
        }()
        let sourceComponentId: String = {
            let raw = (userAction[
                "sourceComponentId",
            ] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
            return raw.isEmpty ? "-" : raw
        }()

        let host = NodeDisplayName.resolve(
            existing: UserDefaults.standard.string(forKey: "node.displayName"),
            deviceName: UIDevice.current.name,
            interfaceIdiom: UIDevice.current.userInterfaceIdiom)
        let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
        let contextJSON = OpenClawCanvasA2UIAction.compactJSON(userAction["context"])
        let sessionKey = self.mainSessionKey

        let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext(
            actionName: name,
            session: .init(key: sessionKey, surfaceId: surfaceId),
            component: .init(id: sourceComponentId, host: host, instanceId: instanceId),
            contextJSON: contextJSON)
        let message = OpenClawCanvasA2UIAction.formatAgentMessage(messageContext)

        let ok: Bool
        var errorText: String?
        if await !self.isGatewayConnected() {
            ok = false
            errorText = "gateway not connected"
        } 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
            }
        }

        let js = OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: ok, error: errorText)
        do {
            _ = try await self.screen.eval(javaScript: js)
        } catch {
            // ignore
        }
    }


    func setScenePhase(_ phase: ScenePhase) {
        let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled")
        GatewayDiagnostics.log("node app model: scene phase=\(String(describing: phase))")
        switch phase {
        case .background:
            self.isBackgrounded = true
            self.stopGatewayHealthMonitor()
            self.backgroundedAt = Date()
            self.reconnectAfterBackgroundArmed = true
            self.beginBackgroundConnectionGracePeriod()
            // Release voice wake mic in background.
            self.backgroundVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture()
            let shouldKeepTalkActive = keepTalkActive && self.talkMode.isEnabled
            self.backgroundTalkKeptActive = shouldKeepTalkActive
            self.backgroundTalkSuspended = self.talkMode.suspendForBackground(keepActive: shouldKeepTalkActive)
        case .active, .inactive:
            self.isBackgrounded = false
            self.endBackgroundConnectionGracePeriod(reason: "scene_foreground")
            self.clearBackgroundReconnectSuppression(reason: "scene_foreground")
            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 }
                    let keptActive = await MainActor.run { self.backgroundTalkKeptActive }
                    await MainActor.run {
                        self.backgroundTalkSuspended = false
                        self.backgroundTalkKeptActive = false
                    }
                    await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive)
                }
                Task { [weak self] in
                    await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "scene_active")
                }
            }
            if phase == .active, self.reconnectAfterBackgroundArmed {
                self.reconnectAfterBackgroundArmed = false
                let backgroundedFor = self.backgroundedAt.map { Date().timeIntervalSince($0) } ?? 0
                self.backgroundedAt = nil
                // iOS may suspend network sockets in background without a clean close.
                // On foreground, force a fresh handshake to avoid "connected but dead" states.
                if backgroundedFor >= 3.0 {
                    Task { [weak self] in
                        guard let self else { return }
                        let operatorWasConnected = await MainActor.run { self.operatorConnected }
                        if operatorWasConnected {
                            // Prefer keeping the connection if it's healthy; reconnect only when needed.
                            let healthy = (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
                            // Foreground recovery must actively restart the saved gateway config.
                            // Disconnecting stale sockets alone can leave us idle if the old
                            // reconnect tasks were suppressed or otherwise got stuck in background.
                            self.gatewayStatusText = "Reconnecting…"
                            self.talkMode.updateGatewayConnected(false)
                            if let cfg = self.activeGatewayConnectConfig {
                                self.applyGatewayConnectConfig(cfg)
                            }
                        }
                    }
                }
            }
        @unknown default:
            self.isBackgrounded = false
            self.endBackgroundConnectionGracePeriod(reason: "scene_unknown")
            self.clearBackgroundReconnectSuppression(reason: "scene_unknown")
        }
    }

    private func beginBackgroundConnectionGracePeriod(seconds: TimeInterval = 25) {
        self.grantBackgroundReconnectLease(seconds: seconds, reason: "scene_background_grace")
        self.endBackgroundConnectionGracePeriod(reason: "restart")
        let taskID = UIApplication.shared.beginBackgroundTask(withName: "gateway-background-grace") { [weak self] in
            Task { @MainActor in
                self?.suppressBackgroundReconnect(
                    reason: "background_grace_expired",
                    disconnectIfNeeded: true)
                self?.endBackgroundConnectionGracePeriod(reason: "expired")
            }
        }
        guard taskID != .invalid else {
            self.pushWakeLogger.info("Background grace unavailable: beginBackgroundTask returned invalid")
            return
        }
        self.backgroundGraceTaskID = taskID
        self.pushWakeLogger.info("Background grace started seconds=\(seconds, privacy: .public)")
        self.backgroundGraceTaskTimer = Task { [weak self] in
            guard let self else { return }
            try? await Task.sleep(nanoseconds: UInt64(max(1, seconds) * 1_000_000_000))
            await MainActor.run {
                self.suppressBackgroundReconnect(reason: "background_grace_timer", disconnectIfNeeded: true)
                self.endBackgroundConnectionGracePeriod(reason: "timer")
            }
        }
    }

    private func endBackgroundConnectionGracePeriod(reason: String) {
        self.backgroundGraceTaskTimer?.cancel()
        self.backgroundGraceTaskTimer = nil
        guard self.backgroundGraceTaskID != .invalid else { return }
        UIApplication.shared.endBackgroundTask(self.backgroundGraceTaskID)
        self.backgroundGraceTaskID = .invalid
        self.pushWakeLogger.info("Background grace ended reason=\(reason, privacy: .public)")
    }

    private func grantBackgroundReconnectLease(seconds: TimeInterval, reason: String) {
        guard self.isBackgrounded else { return }
        let leaseSeconds = max(5, seconds)
        let leaseUntil = Date().addingTimeInterval(leaseSeconds)
        if let existing = self.backgroundReconnectLeaseUntil, existing > leaseUntil {
            // Keep the longer lease if one is already active.
        } else {
            self.backgroundReconnectLeaseUntil = leaseUntil
        }
        let wasSuppressed = self.backgroundReconnectSuppressed
        self.backgroundReconnectSuppressed = false
        let leaseLogMessage =
            "Background reconnect lease reason=\(reason) "
            + "seconds=\(leaseSeconds) wasSuppressed=\(wasSuppressed)"
        self.pushWakeLogger.info("\(leaseLogMessage, privacy: .public)")
    }

    private func suppressBackgroundReconnect(reason: String, disconnectIfNeeded: Bool) {
        guard self.isBackgrounded else { return }
        let hadLease = self.backgroundReconnectLeaseUntil != nil
        let changed = hadLease || !self.backgroundReconnectSuppressed
        self.backgroundReconnectLeaseUntil = nil
        self.backgroundReconnectSuppressed = true
        guard changed else { return }
        let suppressLogMessage =
            "Background reconnect suppressed reason=\(reason) "
            + "disconnect=\(disconnectIfNeeded)"
        self.pushWakeLogger.info("\(suppressLogMessage, privacy: .public)")
        guard disconnectIfNeeded else { return }
        Task { [weak self] in
            guard let self else { return }
            await self.operatorGateway.disconnect()
            await self.nodeGateway.disconnect()
            await MainActor.run {
                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)")
    }

    func setVoiceWakeEnabled(_ enabled: Bool) {
        self.voiceWake.setEnabled(enabled)
        if enabled {
            // If talk is enabled, voice wake should not grab the mic.
            if self.talkMode.isEnabled {
                self.voiceWake.setSuppressedByTalk(true)
                self.talkVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture()
            }
        } else {
            self.voiceWake.setSuppressedByTalk(false)
            self.talkVoiceWakeSuspended = false
        }
    }

    func setTalkEnabled(_ enabled: Bool) {
        UserDefaults.standard.set(enabled, forKey: "talk.enabled")
        if enabled {
            // Voice wake holds the microphone continuously; talk mode needs exclusive access for STT.
            // When talk is enabled from the UI, prioritize talk and pause voice wake.
            self.voiceWake.setSuppressedByTalk(true)
            self.talkVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture()
        } else {
            self.voiceWake.setSuppressedByTalk(false)
            self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.talkVoiceWakeSuspended)
            self.talkVoiceWakeSuspended = false
        }
        self.talkMode.setEnabled(enabled)
        Task { [weak self] in
            await self?.pushTalkModeToGateway(
                enabled: enabled,
                phase: enabled ? "enabled" : "disabled")
        }
    }

    func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool {
        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
        }
    }

    var seamColor: Color {
        Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
    }

    private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
    private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
    private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
    private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()

    private func refreshBrandingFromGateway() async {
        do {
            let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
            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) ?? ""
            let session = config["session"] as? [String: Any]
            let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
            await MainActor.run {
                self.seamColorHex = raw.isEmpty ? nil : raw
                self.mainSessionBaseKey = mainKey
                self.talkMode.updateMainSessionKey(self.mainSessionKey)
                self.homeCanvasRevision &+= 1
            }
        } catch {
            if let gatewayError = error as? GatewayResponseError {
                let lower = gatewayError.message.lowercased()
                if lower.contains("unauthorized role") {
                    return
                }
            }
            // ignore
        }
    }

    private func refreshAgentsFromGateway() 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)
                self.homeCanvasRevision &+= 1
            }
        } catch {
            // Best-effort only.
        }
    }

    func refreshGatewayOverviewIfConnected() async {
        guard await self.isOperatorConnected() else { return }
        await self.refreshBrandingFromGateway()
        await self.refreshAgentsFromGateway()
    }

    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)
        self.homeCanvasRevision &+= 1
        if let relay = ShareGatewayRelaySettings.loadConfig() {
            ShareGatewayRelaySettings.saveConfig(
                ShareGatewayRelayConfig(
                    gatewayURLString: relay.gatewayURLString,
                    token: relay.token,
                    password: relay.password,
                    sessionKey: self.mainSessionKey,
                    deliveryChannel: self.shareDeliveryChannel,
                    deliveryTo: self.shareDeliveryTo))
        }
    }

    func setGlobalWakeWords(_ words: [String]) async {
        let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words)

        struct Payload: Codable {
            var triggers: [String]
        }
        let payload = Payload(triggers: sanitized)
        guard let data = try? JSONEncoder().encode(payload),
              let json = String(data: data, encoding: .utf8)
        else { return }

        do {
            _ = try await self.operatorGateway.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12)
        } catch {
            // Best-effort only.
        }
    }

    private func startVoiceWakeSync() async {
        self.voiceWakeSyncTask?.cancel()
        self.voiceWakeSyncTask = Task { [weak self] in
            guard let self else { return }

            if !self.isGatewayHealthMonitorDisabled() {
                await self.refreshWakeWordsFromGateway()
            }

            let stream = await self.operatorGateway.subscribeServerEvents(bufferingNewest: 200)
            for await evt in stream {
                if Task.isCancelled { return }
                guard let payload = evt.payload else { continue }
                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
                }
            }
        }
    }

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

    private func startGatewayHealthMonitor() {
        self.gatewayHealthMonitorDisabled = false
        self.gatewayHealthMonitor.start(
            check: { [weak self] in
                guard let self else { return false }
                if await MainActor.run(body: { self.isGatewayHealthMonitorDisabled() }) { return true }
                do {
                    let data = try await self.operatorGateway.request(
                        method: "health",
                        paramsJSON: nil,
                        timeoutSeconds: 6
                    )
                    guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else {
                        return false
                    }
                    return decoded.ok ?? false
                } catch {
                    if let gatewayError = error as? GatewayResponseError {
                        let lower = gatewayError.message.lowercased()
                        if lower.contains("unauthorized role") || lower.contains("missing scope") {
                            await self.setGatewayHealthMonitorDisabled(true)
                            return true
                        }
                    }
                    return false
                }
            },
            onFailure: { [weak self] _ in
                guard let self else { return }
                await self.operatorGateway.disconnect()
                await self.nodeGateway.disconnect()
                await MainActor.run {
                    self.operatorConnected = false
                    self.gatewayConnected = false
                    self.gatewayStatusText = "Reconnecting…"
                    self.talkMode.updateGatewayConnected(false)
                }
            })
    }

    private func stopGatewayHealthMonitor() {
        self.gatewayHealthMonitor.stop()
    }

    private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
        let command = req.command

        if self.isBackgrounded, self.isBackgroundRestricted(command) {
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(
                    code: .backgroundUnavailable,
                    message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground"))
        }

        if command.hasPrefix("camera."), !self.isCameraEnabled() {
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(
                    code: .unavailable,
                    message: "CAMERA_DISABLED: enable Camera in iOS Settings → Camera → Allow Camera"))
        }

        do {
            return try await self.capabilityRouter.handle(req)
        } catch let error as NodeCapabilityRouter.RouterError {
            switch error {
            case .unknownCommand:
                return BridgeInvokeResponse(
                    id: req.id,
                    ok: false,
                    error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
            case .handlerUnavailable:
                return BridgeInvokeResponse(
                    id: req.id,
                    ok: false,
                    error: OpenClawNodeError(code: .unavailable, message: "node handler unavailable"))
            }
        } catch {
            if command.hasPrefix("camera.") {
                let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
                self.showCameraHUD(text: text, kind: .error, autoHideSeconds: 2.2)
            }
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(code: .unavailable, message: error.localizedDescription))
        }
    }

    private func isBackgroundRestricted(_ command: String) -> Bool {
        command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.") ||
            command.hasPrefix("talk.")
    }

    private func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        let mode = self.locationMode()
        guard mode != .off else {
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(
                    code: .unavailable,
                    message: "LOCATION_DISABLED: enable Location in Settings"))
        }
        if self.isBackgrounded, mode != .always {
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(
                    code: .backgroundUnavailable,
                    message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always"))
        }
        let params = (try? Self.decodeParams(OpenClawLocationGetParams.self, from: req.paramsJSON)) ??
            OpenClawLocationGetParams()
        let desired = params.desiredAccuracy ??
            (self.isLocationPreciseEnabled() ? .precise : .balanced)
        let status = self.locationService.authorizationStatus()
        if status != .authorizedAlways, status != .authorizedWhenInUse {
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(
                    code: .unavailable,
                    message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
        }
        if self.isBackgrounded, status != .authorizedAlways {
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(
                    code: .unavailable,
                    message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access"))
        }
        let location = try await self.locationService.currentLocation(
            params: params,
            desiredAccuracy: desired,
            maxAgeMs: params.maxAgeMs,
            timeoutMs: params.timeoutMs)
        let isPrecise = self.locationService.accuracyAuthorization() == .fullAccuracy
        let payload = OpenClawLocationPayload(
            lat: location.coordinate.latitude,
            lon: location.coordinate.longitude,
            accuracyMeters: location.horizontalAccuracy,
            altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
            speedMps: location.speed >= 0 ? location.speed : nil,
            headingDeg: location.course >= 0 ? location.course : nil,
            timestamp: ISO8601DateFormatter().string(from: location.timestamp),
            isPrecise: isPrecise,
            source: nil)
        let json = try Self.encodePayload(payload)
        return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
    }

    private func handleCanvasInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        switch req.command {
        case OpenClawCanvasCommand.present.rawValue:
            // iOS ignores placement hints; canvas always fills the screen.
            let params = (try? Self.decodeParams(OpenClawCanvasPresentParams.self, from: req.paramsJSON)) ??
                OpenClawCanvasPresentParams()
            let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
            if url.isEmpty {
                self.screen.showDefaultCanvas()
            } else {
                let trustedA2UIURL = await self.resolveA2UIHostURL()
                self.screen.navigate(
                    to: url,
                    trustA2UIActions: trustedA2UIURL == Self.normalizeURLForTrustComparison(url))
            }
            return BridgeInvokeResponse(id: req.id, ok: true)
        case OpenClawCanvasCommand.hide.rawValue:
            self.screen.showDefaultCanvas()
            return BridgeInvokeResponse(id: req.id, ok: true)
        case OpenClawCanvasCommand.navigate.rawValue:
            let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON)
            let trimmedURL = params.url.trimmingCharacters(in: .whitespacesAndNewlines)
            let trustedA2UIURL = await self.resolveA2UIHostURL()
            self.screen.navigate(
                to: trimmedURL,
                trustA2UIActions: trustedA2UIURL == Self.normalizeURLForTrustComparison(trimmedURL))
            return BridgeInvokeResponse(id: req.id, ok: true)
        case OpenClawCanvasCommand.evalJS.rawValue:
            let params = try Self.decodeParams(OpenClawCanvasEvalParams.self, from: req.paramsJSON)
            let result = try await self.screen.eval(javaScript: params.javaScript)
            let payload = try Self.encodePayload(["result": result])
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
        case OpenClawCanvasCommand.snapshot.rawValue:
            let params = try? Self.decodeParams(OpenClawCanvasSnapshotParams.self, from: req.paramsJSON)
            let format = params?.format ?? .jpeg
            let maxWidth: CGFloat? = {
                if let raw = params?.maxWidth, raw > 0 { return CGFloat(raw) }
                // Keep default snapshots comfortably below the gateway client's maxPayload.
                // For full-res, clients should explicitly request a larger maxWidth.
                return switch format {
                case .png: 900
                case .jpeg: 1600
                }
            }()
            let base64 = try await 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,
                error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
        }
    }

    private func handleCanvasA2UIInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        let command = req.command
        switch command {
        case OpenClawCanvasA2UICommand.reset.rawValue:
            switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
            case .ready:
                break
            case .hostNotConfigured:
                return BridgeInvokeResponse(
                    id: req.id,
                    ok: false,
                    error: OpenClawNodeError(
                        code: .unavailable,
                        message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
            case .hostUnavailable:
                return BridgeInvokeResponse(
                    id: req.id,
                    ok: false,
                    error: OpenClawNodeError(
                        code: .unavailable,
                        message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
            }
            let json = try await self.screen.eval(javaScript: """
            (() => {
              const host = globalThis.openclawA2UI;
              if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" });
              return JSON.stringify(host.reset());
            })()
            """)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)

        case OpenClawCanvasA2UICommand.push.rawValue, OpenClawCanvasA2UICommand.pushJSONL.rawValue:
            let messages: [OpenClawKit.AnyCodable]
            if command == OpenClawCanvasA2UICommand.pushJSONL.rawValue {
                let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
                messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
            } else {
                do {
                    let params = try Self.decodeParams(OpenClawCanvasA2UIPushParams.self, from: req.paramsJSON)
                    messages = params.messages
                } catch {
                    // Be forgiving: some clients still send JSONL payloads to `canvas.a2ui.push`.
                    let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
                    messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
                }
            }

            switch await self.ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
            case .ready:
                break
            case .hostNotConfigured:
                return BridgeInvokeResponse(
                    id: req.id,
                    ok: false,
                    error: OpenClawNodeError(
                        code: .unavailable,
                        message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
            case .hostUnavailable:
                return BridgeInvokeResponse(
                    id: req.id,
                    ok: false,
                    error: OpenClawNodeError(
                        code: .unavailable,
                        message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
            }

            let messagesJSON = try OpenClawCanvasA2UIJSONL.encodeMessagesJSONArray(messages)
            let js = """
            (() => {
              try {
                const host = globalThis.openclawA2UI;
                if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" });
                const messages = \(messagesJSON);
                return JSON.stringify(host.applyMessages(messages));
              } catch (e) {
                return JSON.stringify({ ok: false, error: String(e?.message ?? e) });
              }
            })()
            """
            let resultJSON = try await self.screen.eval(javaScript: js)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
        default:
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
        }
    }

    private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        switch req.command {
        case OpenClawCameraCommand.list.rawValue:
            let devices = await 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)
        case OpenClawCameraCommand.snap.rawValue:
            self.showCameraHUD(text: "Taking photo…", kind: .photo)
            self.triggerCameraFlash()
            let params = (try? Self.decodeParams(OpenClawCameraSnapParams.self, from: req.paramsJSON)) ??
                OpenClawCameraSnapParams()
            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)
        case OpenClawCameraCommand.clip.rawValue:
            let params = (try? Self.decodeParams(OpenClawCameraClipParams.self, from: req.paramsJSON)) ??
                OpenClawCameraClipParams()

            let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false
            defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) }

            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,
                error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
        }
    }

    private func handleScreenRecordInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        let params = (try? Self.decodeParams(OpenClawScreenRecordParams.self, from: req.paramsJSON)) ??
            OpenClawScreenRecordParams()
        if let format = params.format, format.lowercased() != "mp4" {
            throw NSError(domain: "Screen", code: 30, userInfo: [
                NSLocalizedDescriptionKey: "INVALID_REQUEST: screen format must be mp4",
            ])
        }
        // Status pill mirrors screen recording state so it stays visible without overlay stacking.
        self.screenRecordActive = true
        defer { self.screenRecordActive = false }
        let path = try await self.screenRecorder.record(
            screenIndex: params.screenIndex,
            durationMs: params.durationMs,
            fps: params.fps,
            includeAudio: params.includeAudio,
            outPath: nil)
        defer { try? FileManager().removeItem(atPath: path) }
        let data = try Data(contentsOf: URL(fileURLWithPath: path))
        struct Payload: Codable {
            var format: String
            var base64: String
            var durationMs: Int?
            var fps: Double?
            var screenIndex: Int?
            var hasAudio: Bool
        }
        let payload = try Self.encodePayload(Payload(
            format: "mp4",
            base64: data.base64EncodedString(),
            durationMs: params.durationMs,
            fps: params.fps,
            screenIndex: params.screenIndex,
            hasAudio: params.includeAudio ?? true))
        return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
    }

    private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        let params = try Self.decodeParams(OpenClawSystemNotifyParams.self, from: req.paramsJSON)
        let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
        let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
        if title.isEmpty, body.isEmpty {
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty notification"))
        }

        let finalStatus = await self.requestNotificationAuthorizationIfNeeded()
        guard finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral else {
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications"))
        }

        let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
            let content = UNMutableNotificationContent()
            content.title = title
            content.body = body
            if #available(iOS 15.0, *) {
                switch params.priority ?? .active {
                case .passive:
                    content.interruptionLevel = .passive
                case .timeSensitive:
                    content.interruptionLevel = .timeSensitive
                case .active:
                    content.interruptionLevel = .active
                }
            }
            let soundValue = params.sound?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
            if let soundValue, ["none", "silent", "off", "false", "0"].contains(soundValue) {
                content.sound = nil
            } else {
                content.sound = .default
            }
            let request = UNNotificationRequest(
                identifier: UUID().uuidString,
                content: content,
                trigger: nil)
            try await notificationCenter.add(request)
        }
        if case let .failure(error) = addResult {
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(code: .unavailable, message: "NOTIFICATION_FAILED: \(error.message)"))
        }
        return BridgeInvokeResponse(id: req.id, ok: true)
    }

    private func handleChatPushInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        let params = try Self.decodeParams(OpenClawChatPushParams.self, from: req.paramsJSON)
        let text = params.text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !text.isEmpty else {
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty chat.push text"))
        }

        let finalStatus = await self.requestNotificationAuthorizationIfNeeded()
        let messageId = UUID().uuidString
        if finalStatus == .authorized || finalStatus == .provisional || finalStatus == .ephemeral {
            let addResult = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
                let content = UNMutableNotificationContent()
                content.title = "OpenClaw"
                content.body = text
                content.sound = .default
                content.userInfo = ["messageId": messageId]
                let request = UNNotificationRequest(
                    identifier: messageId,
                    content: content,
                    trigger: nil)
                try await notificationCenter.add(request)
            }
            if case let .failure(error) = addResult {
                return BridgeInvokeResponse(
                    id: req.id,
                    ok: false,
                    error: OpenClawNodeError(code: .unavailable, message: "NOTIFICATION_FAILED: \(error.message)"))
            }
        }

        if params.speak ?? true {
            let toSpeak = text
            Task { @MainActor in
                try? await TalkSystemSpeechSynthesizer.shared.speak(text: toSpeak)
            }
        }

        let payload = OpenClawChatPushPayload(messageId: messageId)
        let json = try Self.encodePayload(payload)
        return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
    }

    private func requestNotificationAuthorizationIfNeeded() async -> NotificationAuthorizationStatus {
        let status = await self.notificationAuthorizationStatus()
        guard status == .notDetermined else { return status }

        // Avoid hanging invoke requests if the permission prompt is never answered.
        _ = await self.runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
            _ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
        }

        let updatedStatus = await self.notificationAuthorizationStatus()
        if Self.isNotificationAuthorizationAllowed(updatedStatus) {
            // Refresh APNs registration immediately after the first permission grant so the
            // gateway can receive a push registration without requiring an app relaunch.
            await MainActor.run {
                UIApplication.shared.registerForRemoteNotifications()
            }
        }
        return updatedStatus
    }

    private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
        let result = await self.runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in
            await notificationCenter.authorizationStatus()
        }
        switch result {
        case let .success(status):
            return status
        case .failure:
            return .denied
        }
    }

    private static func isNotificationAuthorizationAllowed(
        _ status: NotificationAuthorizationStatus
    ) -> Bool {
        switch status {
        case .authorized, .provisional, .ephemeral:
            true
        case .denied, .notDetermined:
            false
        }
    }

    private func runNotificationCall<T: Sendable>(
        timeoutSeconds: Double,
        operation: @escaping @Sendable () async throws -> T
    ) async -> Result<T, NotificationCallError> {
        let latch = NotificationInvokeLatch<T>()
        var opTask: Task<Void, Never>?
        var timeoutTask: Task<Void, Never>?
        defer {
            opTask?.cancel()
            timeoutTask?.cancel()
        }
        let clamped = max(0.0, timeoutSeconds)
        return await withCheckedContinuation { (cont: CheckedContinuation<Result<T, NotificationCallError>, Never>) in
            latch.setContinuation(cont)
            opTask = Task { @MainActor in
                do {
                    let value = try await operation()
                    latch.resume(.success(value))
                } catch {
                    latch.resume(.failure(NotificationCallError(message: error.localizedDescription)))
                }
            }
            timeoutTask = Task.detached {
                if clamped > 0 {
                    try? await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000))
                }
                latch.resume(.failure(NotificationCallError(message: "notification request timed out")))
            }
        }
    }

    private func handleDeviceInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        switch req.command {
        case OpenClawDeviceCommand.status.rawValue:
            let payload = try await self.deviceStatusService.status()
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        case OpenClawDeviceCommand.info.rawValue:
            let payload = self.deviceStatusService.info()
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        default:
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
        }
    }

    private func handlePhotosInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        let params = (try? Self.decodeParams(OpenClawPhotosLatestParams.self, from: req.paramsJSON)) ??
            OpenClawPhotosLatestParams()
        let payload = try await self.photosService.latest(params: params)
        let json = try Self.encodePayload(payload)
        return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
    }

    private func handleContactsInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        switch req.command {
        case OpenClawContactsCommand.search.rawValue:
            let params = (try? Self.decodeParams(OpenClawContactsSearchParams.self, from: req.paramsJSON)) ??
                OpenClawContactsSearchParams()
            let payload = try await self.contactsService.search(params: params)
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        case OpenClawContactsCommand.add.rawValue:
            let params = try Self.decodeParams(OpenClawContactsAddParams.self, from: req.paramsJSON)
            let payload = try await self.contactsService.add(params: params)
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        default:
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
        }
    }

    private func handleCalendarInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        switch req.command {
        case OpenClawCalendarCommand.events.rawValue:
            let params = (try? Self.decodeParams(OpenClawCalendarEventsParams.self, from: req.paramsJSON)) ??
                OpenClawCalendarEventsParams()
            let payload = try await self.calendarService.events(params: params)
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        case OpenClawCalendarCommand.add.rawValue:
            let params = try Self.decodeParams(OpenClawCalendarAddParams.self, from: req.paramsJSON)
            let payload = try await self.calendarService.add(params: params)
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        default:
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
        }
    }

    private func handleRemindersInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        switch req.command {
        case OpenClawRemindersCommand.list.rawValue:
            let params = (try? Self.decodeParams(OpenClawRemindersListParams.self, from: req.paramsJSON)) ??
                OpenClawRemindersListParams()
            let payload = try await self.remindersService.list(params: params)
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        case OpenClawRemindersCommand.add.rawValue:
            let params = try Self.decodeParams(OpenClawRemindersAddParams.self, from: req.paramsJSON)
            let payload = try await self.remindersService.add(params: params)
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        default:
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
        }
    }

    private func handleMotionInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        switch req.command {
        case OpenClawMotionCommand.activity.rawValue:
            let params = (try? Self.decodeParams(OpenClawMotionActivityParams.self, from: req.paramsJSON)) ??
                OpenClawMotionActivityParams()
            let payload = try await self.motionService.activities(params: params)
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        case OpenClawMotionCommand.pedometer.rawValue:
            let params = (try? Self.decodeParams(OpenClawPedometerParams.self, from: req.paramsJSON)) ??
                OpenClawPedometerParams()
            let payload = try await self.motionService.pedometer(params: params)
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        default:
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
        }
    }

    private func handleTalkInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
        switch req.command {
        case OpenClawTalkCommand.pttStart.rawValue:
            self.pttVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture()
            let payload = try await self.talkMode.beginPushToTalk()
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        case OpenClawTalkCommand.pttStop.rawValue:
            let payload = await self.talkMode.endPushToTalk()
            self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended)
            self.pttVoiceWakeSuspended = false
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        case OpenClawTalkCommand.pttCancel.rawValue:
            let payload = await self.talkMode.cancelPushToTalk()
            self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended)
            self.pttVoiceWakeSuspended = false
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        case OpenClawTalkCommand.pttOnce.rawValue:
            self.pttVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture()
            defer {
                self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.pttVoiceWakeSuspended)
                self.pttVoiceWakeSuspended = false
            }
            let payload = try await self.talkMode.runPushToTalkOnce()
            let json = try Self.encodePayload(payload)
            return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
        default:
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
        }
    }

}

private extension NodeAppModel {
    // Central registry for node invoke routing to keep commands in one place.
    func buildCapabilityRouter() -> NodeCapabilityRouter {
        var handlers: [String: NodeCapabilityRouter.Handler] = [:]

        func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) {
            for command in commands {
                handlers[command] = handler
            }
        }

        register([OpenClawLocationCommand.get.rawValue]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleLocationInvoke(req)
        }

        register([
            OpenClawCanvasCommand.present.rawValue,
            OpenClawCanvasCommand.hide.rawValue,
            OpenClawCanvasCommand.navigate.rawValue,
            OpenClawCanvasCommand.evalJS.rawValue,
            OpenClawCanvasCommand.snapshot.rawValue,
        ]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleCanvasInvoke(req)
        }

        register([
            OpenClawCanvasA2UICommand.reset.rawValue,
            OpenClawCanvasA2UICommand.push.rawValue,
            OpenClawCanvasA2UICommand.pushJSONL.rawValue,
        ]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleCanvasA2UIInvoke(req)
        }

        register([
            OpenClawCameraCommand.list.rawValue,
            OpenClawCameraCommand.snap.rawValue,
            OpenClawCameraCommand.clip.rawValue,
        ]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleCameraInvoke(req)
        }

        register([OpenClawScreenCommand.record.rawValue]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleScreenRecordInvoke(req)
        }

        register([OpenClawSystemCommand.notify.rawValue]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleSystemNotify(req)
        }

        register([OpenClawChatCommand.push.rawValue]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleChatPushInvoke(req)
        }

        register([
            OpenClawDeviceCommand.status.rawValue,
            OpenClawDeviceCommand.info.rawValue,
        ]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleDeviceInvoke(req)
        }

        register([
            OpenClawWatchCommand.status.rawValue,
            OpenClawWatchCommand.notify.rawValue,
        ]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleWatchInvoke(req)
        }

        register([OpenClawPhotosCommand.latest.rawValue]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handlePhotosInvoke(req)
        }

        register([
            OpenClawContactsCommand.search.rawValue,
            OpenClawContactsCommand.add.rawValue,
        ]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleContactsInvoke(req)
        }

        register([
            OpenClawCalendarCommand.events.rawValue,
            OpenClawCalendarCommand.add.rawValue,
        ]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleCalendarInvoke(req)
        }

        register([
            OpenClawRemindersCommand.list.rawValue,
            OpenClawRemindersCommand.add.rawValue,
        ]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleRemindersInvoke(req)
        }

        register([
            OpenClawMotionCommand.activity.rawValue,
            OpenClawMotionCommand.pedometer.rawValue,
        ]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleMotionInvoke(req)
        }

        register([
            OpenClawTalkCommand.pttStart.rawValue,
            OpenClawTalkCommand.pttStop.rawValue,
            OpenClawTalkCommand.pttCancel.rawValue,
            OpenClawTalkCommand.pttOnce.rawValue,
        ]) { [weak self] req in
            guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
            return try await self.handleTalkInvoke(req)
        }

        return NodeCapabilityRouter(handlers: handlers)
    }

    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 normalizedParams = Self.normalizeWatchNotifyParams(params)
            let title = normalizedParams.title
            let body = normalizedParams.body
            if title.isEmpty && body.isEmpty {
                return BridgeInvokeResponse(
                    id: req.id,
                    ok: false,
                    error: OpenClawNodeError(
                        code: .invalidRequest,
                        message: "INVALID_REQUEST: empty watch notification"))
            }
            do {
                let result = try await self.watchMessagingService.sendNotification(
                    id: req.id,
                    params: normalizedParams)
                if result.queuedForDelivery || !result.deliveredImmediately {
                    let invokeID = req.id
                    Task { @MainActor in
                        await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded(
                            invokeID: invokeID,
                            params: normalizedParams,
                            sendResult: result)
                    }
                }
                let payload = OpenClawWatchNotifyPayload(
                    deliveredImmediately: result.deliveredImmediately,
                    queuedForDelivery: result.queuedForDelivery,
                    transport: result.transport)
                let json = try Self.encodePayload(payload)
                return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
            } catch {
                return BridgeInvokeResponse(
                    id: req.id,
                    ok: false,
                    error: OpenClawNodeError(
                        code: .unavailable,
                        message: error.localizedDescription))
            }
        default:
            return BridgeInvokeResponse(
                id: req.id,
                ok: false,
                error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
        }
    }

    func locationMode() -> OpenClawLocationMode {
        let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
        return OpenClawLocationMode(rawValue: raw) ?? .off
    }

    func isLocationPreciseEnabled() -> Bool {
        // iOS settings now expose a single location mode control.
        // Default location tool precision stays high unless a command explicitly requests balanced.
        true
    }

    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 {
        // Default-on: if the key doesn't exist yet, treat it as enabled.
        if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
        return UserDefaults.standard.bool(forKey: "camera.enabled")
    }

    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 {
    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)
    }

    var chatSessionKey: String {
        // Keep chat aligned with the gateway's resolved main session key.
        // A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI.
        self.mainSessionKey
    }

    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
    }

    func connectToGateway(
        url: URL,
        gatewayStableID: String,
        tls: GatewayTLSParams?,
        token: String?,
        bootstrapToken: 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,
            bootstrapToken: bootstrapToken,
            password: password,
            nodeOptions: connectOptions)
        self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
        if self.shouldStartOperatorGatewayLoop(
            token: token,
            bootstrapToken: bootstrapToken,
            password: password,
            stableID: effectiveStableID)
        {
            self.startOperatorGatewayLoop(
                url: url,
                stableID: effectiveStableID,
                token: token,
                bootstrapToken: bootstrapToken,
                password: password,
                nodeOptions: connectOptions,
                sessionBox: sessionBox)
        } else {
            self.operatorGatewayTask = nil
            Task { await self.operatorGateway.disconnect() }
        }
        self.startNodeGatewayLoop(
            url: url,
            stableID: effectiveStableID,
            token: token,
            bootstrapToken: bootstrapToken,
            password: password,
            nodeOptions: connectOptions,
            sessionBox: sessionBox)
    }

    /// Preferred entry-point: apply a single config object and start both sessions.
    func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig) {
        self.activeGatewayConnectConfig = cfg
        self.connectToGateway(
            url: cfg.url,
            // Preserve the caller-provided stableID (may be empty) and let connectToGateway
            // derive the effective stable id consistently for persistence keys.
            gatewayStableID: cfg.stableID,
            tls: cfg.tls,
            token: cfg.token,
            bootstrapToken: cfg.bootstrapToken,
            password: cfg.password,
            connectOptions: cfg.nodeOptions)
    }

    func disconnectGateway() {
        self.gatewayAutoReconnectEnabled = false
        self.gatewayPairingPaused = false
        self.gatewayPairingRequestId = nil
        self.lastGatewayProblem = nil
        self.nodeGatewayTask?.cancel()
        self.nodeGatewayTask = nil
        self.operatorGatewayTask?.cancel()
        self.operatorGatewayTask = nil
        self.voiceWakeSyncTask?.cancel()
        self.voiceWakeSyncTask = nil
        LiveActivityManager.shared.handleDisconnect()
        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)
        ShareGatewayRelaySettings.clearConfig()
        self.showLocalCanvasOnDisconnect()
    }
}

private extension NodeAppModel {
    func prepareForGatewayConnect(url: URL, stableID: String) {
        self.gatewayAutoReconnectEnabled = true
        self.gatewayPairingPaused = false
        self.gatewayPairingRequestId = nil
        self.lastGatewayProblem = nil
        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
        LiveActivityManager.shared.handleDisconnect()
        self.gatewayDefaultAgentId = nil
        self.gatewayAgents = []
        self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
        self.homeCanvasRevision &+= 1
        self.apnsLastRegisteredTokenHex = nil
    }

    func clearGatewayConnectionProblem() {
        self.lastGatewayProblem = nil
        self.gatewayPairingPaused = false
        self.gatewayPairingRequestId = nil
    }

    func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
        self.lastGatewayProblem = problem
        self.gatewayStatusText = problem.statusText
        self.gatewayServerName = nil
        self.gatewayRemoteAddress = nil
        self.gatewayConnected = false
        self.showLocalCanvasOnDisconnect()
        if problem.pauseReconnect {
            self.gatewayAutoReconnectEnabled = false
        }
        if problem.needsPairingApproval {
            self.gatewayPairingPaused = true
            self.gatewayPairingRequestId = problem.requestId
        } else {
            self.gatewayPairingPaused = false
            self.gatewayPairingRequestId = nil
        }
    }

    func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
        guard let lastGatewayProblem else { return false }
        return GatewayConnectionProblemMapper.shouldPreserve(
            previousProblem: lastGatewayProblem,
            overDisconnectReason: reason)
    }

    func shouldStartOperatorGatewayLoop(
        token: String?,
        bootstrapToken: String?,
        password: String?,
        stableID _: String) -> Bool
    {
        Self.shouldStartOperatorGatewayLoop(
            token: token,
            bootstrapToken: bootstrapToken,
            password: password,
            hasStoredOperatorToken: self.hasStoredGatewayRoleToken("operator"))
    }

    func hasStoredGatewayRoleToken(_ role: String) -> Bool {
        let identity = DeviceIdentityStore.loadOrCreate()
        return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
    }

    nonisolated static func shouldStartOperatorGatewayLoop(
        token: String?,
        bootstrapToken: String?,
        password: String?,
        hasStoredOperatorToken: Bool) -> Bool
    {
        let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        if !trimmedToken.isEmpty {
            return true
        }
        let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        if !trimmedPassword.isEmpty {
            return true
        }
        let trimmedBootstrapToken = bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        if !trimmedBootstrapToken.isEmpty {
            return false
        }
        return hasStoredOperatorToken
    }

    nonisolated static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
        guard let config else { return nil }
        let trimmedBootstrapToken = config.bootstrapToken?
            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        guard !trimmedBootstrapToken.isEmpty else { return config }
        return GatewayConnectConfig(
            url: config.url,
            stableID: config.stableID,
            tls: config.tls,
            token: config.token,
            bootstrapToken: nil,
            password: config.password,
            nodeOptions: config.nodeOptions)
    }

    func currentGatewayReconnectAuth(
        fallbackToken: String?,
        fallbackBootstrapToken: String?,
        fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?)
    {
        if let cfg = self.activeGatewayConnectConfig {
            return (cfg.token, cfg.bootstrapToken, cfg.password)
        }
        return (fallbackToken, fallbackBootstrapToken, fallbackPassword)
    }

    func clearPersistedGatewayBootstrapTokenIfNeeded() {
        // Always drop the in-memory bootstrap token after the first successful
        // bootstrap connect so reconnect loops cannot reuse a spent token.
        self.activeGatewayConnectConfig = Self.clearingBootstrapToken(in: self.activeGatewayConnectConfig)

        let trimmedInstanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        guard !trimmedInstanceId.isEmpty else { return }
        guard
            GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: trimmedInstanceId) != nil
        else { return }

        GatewaySettingsStore.clearGatewayBootstrapToken(instanceId: trimmedInstanceId)
    }

    private func handleSuccessfulBootstrapGatewayOnboarding(
        url: URL,
        stableID: String,
        token: String?,
        password: String?,
        nodeOptions: GatewayConnectOptions,
        sessionBox: WebSocketSessionBox?) async
    {
        self.clearPersistedGatewayBootstrapTokenIfNeeded()
        if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
            token: token,
            bootstrapToken: nil,
            password: password,
            stableID: stableID)
        {
            self.startOperatorGatewayLoop(
                url: url,
                stableID: stableID,
                token: token,
                bootstrapToken: nil,
                password: password,
                nodeOptions: nodeOptions,
                sessionBox: sessionBox)
        }

        // QR bootstrap onboarding should surface the system notification permission
        // prompt immediately so visible APNs alerts work without a second manual step.
        _ = await self.requestNotificationAuthorizationIfNeeded()
    }

    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
    }

    func startOperatorGatewayLoop(
        url: URL,
        stableID: String,
        token: String?,
        bootstrapToken: String?,
        password: String?,
        nodeOptions: GatewayConnectOptions,
        sessionBox: WebSocketSessionBox?)
    {
        // Operator session reconnects independently (chat/talk/config/voicewake), but we tie its
        // lifecycle to the current gateway config so it doesn't keep running across Disconnect.
        self.operatorGatewayTask = Task { [weak self] in
            guard let self else { return }
            var attempt = 0
            while !Task.isCancelled {
                if self.gatewayPairingPaused {
                    try? await Task.sleep(nanoseconds: 1_000_000_000)
                    continue
                }
                if !self.gatewayAutoReconnectEnabled {
                    try? await Task.sleep(nanoseconds: 1_000_000_000)
                    continue
                }
                if self.shouldPauseReconnectLoopInBackground(source: "operator_loop") {
                    try? await Task.sleep(nanoseconds: 2_000_000_000)
                    continue
                }
                if await self.isOperatorConnected() {
                    try? await Task.sleep(nanoseconds: 1_000_000_000)
                    continue
                }

                let reconnectAuth = self.currentGatewayReconnectAuth(
                    fallbackToken: token,
                    fallbackBootstrapToken: bootstrapToken,
                    fallbackPassword: password)
                let effectiveClientId =
                    GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) ?? nodeOptions.clientId
                let operatorOptions = self.makeOperatorConnectOptions(
                    clientId: effectiveClientId,
                    displayName: nodeOptions.clientDisplayName,
                    includeApprovalScope: self.shouldRequestOperatorApprovalScope(
                        token: reconnectAuth.token,
                        password: reconnectAuth.password))

                do {
                    try await self.operatorGateway.connect(
                        url: url,
                        token: reconnectAuth.token,
                        bootstrapToken: reconnectAuth.bootstrapToken,
                        password: reconnectAuth.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 ?? "?")")
                            await self.talkMode.reloadConfig()
                            await self.refreshBrandingFromGateway()
                            await self.refreshAgentsFromGateway()
                            await self.refreshShareRouteFromGateway()
                            await self.registerAPNsTokenIfNeeded()
                            await self.startVoiceWakeSync()
                            await MainActor.run { LiveActivityManager.shared.handleReconnect() }
                            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)
                                LiveActivityManager.shared.handleDisconnect()
                            }
                            GatewayDiagnostics.log("operator gateway disconnected reason=\(reason)")
                            await MainActor.run { self.stopGatewayHealthMonitor() }
                        },
                        onInvoke: { req in
                            // Operator session should not handle node.invoke requests.
                            BridgeInvokeResponse(
                                id: req.id,
                                ok: false,
                                error: OpenClawNodeError(
                                    code: .invalidRequest,
                                    message: "INVALID_REQUEST: operator session cannot invoke node commands"))
                        })

                    attempt = 0
                    try? await Task.sleep(nanoseconds: 1_000_000_000)
                } catch {
                    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))
                }
            }
        }
    }

    // Legacy reconnect state machine; follow-up refactor needed to split into helpers.
    // swiftlint:disable:next function_body_length
    func startNodeGatewayLoop(
        url: URL,
        stableID: String,
        token: String?,
        bootstrapToken: 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
            var pausedForPairingApproval = false

            while !Task.isCancelled {
                if self.gatewayPairingPaused {
                    try? await Task.sleep(nanoseconds: 1_000_000_000)
                    continue
                }
                if !self.gatewayAutoReconnectEnabled {
                    try? await Task.sleep(nanoseconds: 1_000_000_000)
                    continue
                }
                if self.shouldPauseReconnectLoopInBackground(source: "node_loop") {
                    try? await Task.sleep(nanoseconds: 2_000_000_000)
                    continue
                }
                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
                    let liveActivity = LiveActivityManager.shared
                    if liveActivity.isActive {
                        liveActivity.handleConnecting()
                    } else {
                        liveActivity.startActivity(
                            agentName: self.selectedAgentId ?? "main",
                            sessionKey: self.mainSessionKey)
                    }
                }

                do {
                    let epochMs = Int(Date().timeIntervalSince1970 * 1000)
                    let reconnectAuth = self.currentGatewayReconnectAuth(
                        fallbackToken: token,
                        fallbackBootstrapToken: bootstrapToken,
                        fallbackPassword: password)
                    let connectedOptions = currentOptions
                    GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)")
                    try await self.nodeGateway.connect(
                        url: url,
                        token: reconnectAuth.token,
                        bootstrapToken: reconnectAuth.bootstrapToken,
                        password: reconnectAuth.password,
                        connectOptions: connectedOptions,
                        sessionBox: sessionBox,
                        onConnected: { [weak self] in
                            guard let self else { return }
                            await MainActor.run {
                                self.clearGatewayConnectionProblem()
                                self.gatewayStatusText = "Connected"
                                self.gatewayServerName = url.host ?? "gateway"
                                self.gatewayConnected = true
                                self.screen.errorText = nil
                                UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
                            }
                            let usedBootstrapToken =
                                reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
                                reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
                                    .isEmpty == false
                            if usedBootstrapToken {
                                await self.handleSuccessfulBootstrapGatewayOnboarding(
                                    url: url,
                                    stableID: stableID,
                                    token: reconnectAuth.token,
                                    password: reconnectAuth.password,
                                    nodeOptions: connectedOptions,
                                    sessionBox: sessionBox)
                            }
                            let relayData = await MainActor.run {
                                (
                                    sessionKey: self.mainSessionKey,
                                    deliveryChannel: self.shareDeliveryChannel,
                                    deliveryTo: self.shareDeliveryTo
                                )
                            }
                            ShareGatewayRelaySettings.saveConfig(
                                ShareGatewayRelayConfig(
                                    gatewayURLString: url.absoluteString,
                                    token: reconnectAuth.token,
                                    password: reconnectAuth.password,
                                    sessionKey: relayData.sessionKey,
                                    deliveryChannel: relayData.deliveryChannel,
                                    deliveryTo: relayData.deliveryTo))
                            GatewayDiagnostics.log(
                                "gateway connected host=\(url.host ?? "?") "
                                    + "scheme=\(url.scheme ?? "?")"
                            )
                            if let addr = await self.nodeGateway.currentRemoteAddress() {
                                await MainActor.run { self.gatewayRemoteAddress = addr }
                            }
                            await self.showA2UIOnConnectIfNeeded()
                            await self.onNodeGatewayConnected()
                            await MainActor.run {
                                SignificantLocationMonitor.startIfNeeded(
                                    locationService: self.locationService,
                                    locationMode: self.locationMode(),
                                    gateway: self.nodeGateway,
                                    beforeSend: { [weak self] in
                                        await self?.handleSignificantLocationWakeIfNeeded()
                                    })
                            }
                        },
                        onDisconnected: { [weak self] reason in
                            guard let self else { return }
                            await MainActor.run {
                                if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason),
                                   let lastGatewayProblem = self.lastGatewayProblem
                                {
                                    self.gatewayStatusText = lastGatewayProblem.statusText
                                } else {
                                    self.gatewayStatusText = "Disconnected: \(reason)"
                                }
                                self.gatewayServerName = nil
                                self.gatewayRemoteAddress = nil
                                self.gatewayConnected = false
                                self.showLocalCanvasOnDisconnect()
                            }
                            GatewayDiagnostics.log("gateway disconnected reason: \(reason)")
                        },
                        onInvoke: { [weak self] req in
                            guard let self else {
                                return BridgeInvokeResponse(
                                    id: req.id,
                                    ok: false,
                                    error: OpenClawNodeError(
                                        code: .unavailable,
                                        message: "UNAVAILABLE: node not ready"))
                            }
                            return await self.handleInvoke(req)
                        })

                    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
                    let problem = await MainActor.run {
                        let nextProblem = GatewayConnectionProblemMapper.map(
                            error: error,
                            preserving: self.lastGatewayProblem)
                        if let nextProblem {
                            self.applyGatewayConnectionProblem(nextProblem)
                        } else {
                            self.lastGatewayProblem = nil
                            self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
                            self.gatewayServerName = nil
                            self.gatewayRemoteAddress = nil
                            self.gatewayConnected = false
                            self.showLocalCanvasOnDisconnect()
                        }
                        return nextProblem
                    }
                    GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)")

                    if problem?.needsPairingApproval == true {
                        // Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
                        // we don't generate multiple pending requests while waiting for approval.
                        pausedForPairingApproval = true
                        self.operatorGatewayTask?.cancel()
                        self.operatorGatewayTask = nil
                        await self.operatorGateway.disconnect()
                        await self.nodeGateway.disconnect()
                        break
                    }

                    if problem?.pauseReconnect == true {
                        continue
                    }

                    let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
                    try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
                }
            }

            if pausedForPairingApproval {
                // Leave the status text + request id intact so onboarding can guide the user.
                return
            }

            await MainActor.run {
                self.lastGatewayProblem = nil
                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 shouldRequestOperatorApprovalScope(token: String?, password: String?) -> Bool {
        let identity = DeviceIdentityStore.loadOrCreate()
        let storedOperatorScopes = DeviceAuthStore
            .loadToken(deviceId: identity.deviceId, role: "operator")?
            .scopes ?? []
        return Self.shouldRequestOperatorApprovalScope(
            token: token,
            password: password,
            storedOperatorScopes: storedOperatorScopes)
    }

    nonisolated static func shouldRequestOperatorApprovalScope(
        token: String?,
        password: String?,
        storedOperatorScopes: [String]
    ) -> Bool {
        let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        if !trimmedToken.isEmpty {
            return true
        }
        let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        if !trimmedPassword.isEmpty {
            return true
        }
        return storedOperatorScopes.contains("operator.approvals")
    }

    func makeOperatorConnectOptions(
        clientId: String,
        displayName: String?,
        includeApprovalScope: Bool
    ) -> GatewayConnectOptions {
        var scopes = ["operator.read", "operator.write", "operator.talk.secrets"]
        // Preserve reconnect compatibility for older paired operator tokens that were
        // approved before iOS requested operator.approvals by default.
        if includeApprovalScope {
            scopes.append("operator.approvals")
        }
        return GatewayConnectOptions(
            role: "operator",
            scopes: scopes,
            caps: [],
            commands: [],
            permissions: [:],
            clientId: clientId,
            clientMode: "ui",
            clientDisplayName: displayName,
            includeDeviceIdentity: true)
    }

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

extension NodeAppModel {
    private struct PendingForegroundNodeAction: Decodable {
        var id: String
        var command: String
        var paramsJSON: String?
        var enqueuedAtMs: Int?
    }

    private struct PendingForegroundNodeActionsResponse: Decodable {
        var nodeId: String?
        var actions: [PendingForegroundNodeAction]
    }

    private struct PendingForegroundNodeActionsAckRequest: Encodable {
        var ids: [String]
    }

    private func refreshShareRouteFromGateway() 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
            }
            let selected = exactMatch
            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 {
            // Best-effort only.
        }
    }

    func runSharePipelineSelfTest() async {
        self.recordShareEvent("Share self-test running…")

        let payload = SharedContentPayload(
            title: "OpenClaw Share Self-Test",
            url: URL(string: "https://openclaw.ai/share-self-test"),
            text: "Validate iOS share->deep-link->gateway forwarding.")
        guard let deepLink = ShareToAgentDeepLink.buildURL(
            from: payload,
            instruction: "Reply with: SHARE SELF-TEST OK")
        else {
            self.recordShareEvent("Self-test failed: could not build deep link.")
            return
        }

        await self.handleDeepLink(url: deepLink)
    }

    func refreshLastShareEventFromRelay() {
        if let event = ShareGatewayRelaySettings.loadLastEvent() {
            self.lastShareEventText = event
        }
    }

    func recordShareEvent(_ text: String) {
        ShareGatewayRelaySettings.saveLastEvent(text)
        self.refreshLastShareEventFromRelay()
    }

    func reloadTalkConfig() {
        Task { [weak self] in
            await self?.talkMode.reloadConfig()
        }
    }

    /// Back-compat hook retained for older gateway-connect flows.
    func onNodeGatewayConnected() async {
        await self.registerAPNsTokenIfNeeded()
        await self.flushQueuedWatchRepliesIfConnected()
        await self.syncWatchExecApprovalSnapshot(reason: "node_connected")
        await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
    }

    private func resumePendingForegroundNodeActionsIfNeeded(trigger: String) async {
        guard !self.isBackgrounded else { return }
        guard await self.isGatewayConnected() else { return }
        guard !self.pendingForegroundActionDrainInFlight else { return }

        self.pendingForegroundActionDrainInFlight = true
        defer { self.pendingForegroundActionDrainInFlight = false }

        do {
            let payload = try await self.nodeGateway.request(
                method: "node.pending.pull",
                paramsJSON: "{}",
                timeoutSeconds: 6)
            let decoded = try JSONDecoder().decode(
                PendingForegroundNodeActionsResponse.self,
                from: payload)
            guard !decoded.actions.isEmpty else { return }
            // swiftlint:disable:next line_length
            self.pendingActionLogger.info("Pending actions pulled trigger=\(trigger, privacy: .public) count=\(decoded.actions.count, privacy: .public)")
            await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
        } catch {
            // Best-effort only.
        }
    }

    private func applyPendingForegroundNodeActions(
        _ actions: [PendingForegroundNodeAction],
        trigger: String) async
    {
        for action in actions {
            guard !self.isBackgrounded else {
                self.pendingActionLogger.info(
                    "Pending action replay paused trigger=\(trigger, privacy: .public): app backgrounded")
                return
            }
            let req = BridgeInvokeRequest(
                id: action.id,
                command: action.command,
                paramsJSON: action.paramsJSON)
            let result = await self.handleInvoke(req)
            // swiftlint:disable:next line_length
            self.pendingActionLogger.info("Pending action replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) ok=\(result.ok, privacy: .public)")
            guard result.ok else { return }
            let acked = await self.ackPendingForegroundNodeAction(
                id: action.id,
                trigger: trigger,
                command: action.command)
            guard acked else { return }
        }
    }

    private func ackPendingForegroundNodeAction(
        id: String,
        trigger: String,
        command: String) async -> Bool
    {
        do {
            let payload = try JSONEncoder().encode(PendingForegroundNodeActionsAckRequest(ids: [id]))
            let paramsJSON = String(bytes: payload, encoding: .utf8) ?? "{}"
            _ = try await self.nodeGateway.request(
                method: "node.pending.ack",
                paramsJSON: paramsJSON,
                timeoutSeconds: 6)
            return true
        } catch {
            // swiftlint:disable:next line_length
            self.pendingActionLogger.error("Pending action ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public) command=\(command, privacy: .public) error=\(String(describing: error), privacy: .public)")
            return false
        }
    }

    private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
        switch self.watchReplyCoordinator.ingest(event, isGatewayConnected: await self.isGatewayConnected()) {
        case .dropMissingFields:
            self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
        case .deduped(let replyId):
            self.watchReplyLogger.debug(
                "watch reply deduped replyId=\(replyId, privacy: .public)")
        case let .queue(replyId, actionId):
            self.watchReplyLogger.info(
                "watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)")
        case .forward:
            await self.forwardWatchReplyToAgent(event)
        }
    }

    private func flushQueuedWatchRepliesIfConnected() async {
        for event in self.watchReplyCoordinator.drainIfConnected(await self.isGatewayConnected()) {
            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)
            let forwardedMessage =
                "watch reply forwarded replyId=\(event.replyId) "
                + "action=\(event.actionId)"
            self.watchReplyLogger.info("\(forwardedMessage, privacy: .public)")
            self.openChatRequestID &+= 1
        } catch {
            let failedMessage =
                "watch reply forwarding failed replyId=\(event.replyId) "
                + "error=\(error.localizedDescription)"
            self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
            self.watchReplyCoordinator.requeueFront(event)
        }
    }

    private static func makeWatchReplyAgentMessage(_ event: WatchQuickReplyEvent) -> String {
        let actionLabel = event.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines)
        let promptId = event.promptId.trimmingCharacters(in: .whitespacesAndNewlines)
        let transport = event.transport.trimmingCharacters(in: .whitespacesAndNewlines)
        let summary = actionLabel?.isEmpty == false ? actionLabel! : event.actionId
        var lines: [String] = []
        lines.append("Watch reply: \(summary)")
        lines.append("promptId=\(promptId.isEmpty ? "unknown" : promptId)")
        lines.append("actionId=\(event.actionId)")
        lines.append("replyId=\(event.replyId)")
        if !transport.isEmpty {
            lines.append("transport=\(transport)")
        }
        if let sentAtMs = event.sentAtMs {
            lines.append("sentAtMs=\(sentAtMs)")
        }
        if let note = event.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
            lines.append("note=\(note)")
        }
        return lines.joined(separator: "\n")
    }

    private func restorePersistedWatchExecApprovalBridgeState() {
        guard let data = UserDefaults.standard.data(forKey: Self.watchExecApprovalBridgeStateKey),
              let state = try? JSONDecoder().decode(PersistedWatchExecApprovalBridgeState.self, from: data)
        else {
            return
        }
        self.watchExecApprovalPromptsByID = Dictionary(
            uniqueKeysWithValues: state.approvals.map { ($0.id, $0) })
        self.pendingWatchExecApprovalRecoveryIDs = (state.pendingApprovalIDs ?? [])
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
            .sorted()
        self.pruneExpiredWatchExecApprovalPrompts()
    }

    private func persistWatchExecApprovalBridgeState() {
        self.pruneExpiredWatchExecApprovalPrompts()
        let approvals = self.watchExecApprovalPromptsByID.values.sorted { lhs, rhs in
            let lhsExpires = lhs.expiresAtMs ?? Int.max
            let rhsExpires = rhs.expiresAtMs ?? Int.max
            if lhsExpires != rhsExpires {
                return lhsExpires < rhsExpires
            }
            return lhs.id < rhs.id
        }
        let pendingApprovalIDs = self.pendingWatchExecApprovalRecoveryIDs
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
            .sorted()
        guard let data = try? JSONEncoder().encode(
            PersistedWatchExecApprovalBridgeState(
                approvals: approvals,
                pendingApprovalIDs: pendingApprovalIDs))
        else {
            return
        }
        UserDefaults.standard.set(data, forKey: Self.watchExecApprovalBridgeStateKey)
    }

    private func pruneExpiredWatchExecApprovalPrompts(nowMs: Int? = nil) {
        let currentNowMs = nowMs ?? Int(Date().timeIntervalSince1970 * 1000)
        self.watchExecApprovalPromptsByID = self.watchExecApprovalPromptsByID.filter { _, prompt in
            guard let expiresAtMs = prompt.expiresAtMs else { return true }
            return expiresAtMs > currentNowMs
        }
    }

    private func handleWatchMessagingStatusChanged(_ status: WatchMessagingStatus) async {
        GatewayDiagnostics.log(
            "watch exec approval: status changed "
                + "reachable=\(status.reachable) activation=\(status.activationState) "
                + "backgrounded=\(self.isBackgrounded)")
        guard self.isBackgrounded else { return }
        guard status.supported, status.paired, status.appInstalled else { return }
        guard status.reachable || status.activationState == "activated" else { return }
        let reason = status.reachable ? "watch_reachable" : "watch_activated"
        await self.syncWatchExecApprovalSnapshot(reason: reason)
    }

    private func appendPendingWatchExecApprovalRecoveryID(_ approvalId: String) {
        let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedApprovalID.isEmpty else { return }
        guard !self.pendingWatchExecApprovalRecoveryIDs.contains(normalizedApprovalID) else { return }
        self.pendingWatchExecApprovalRecoveryIDs.append(normalizedApprovalID)
        self.pendingWatchExecApprovalRecoveryIDs.sort()
        GatewayDiagnostics.log(
            "watch exec approval: queued recovery "
                + "id=\(normalizedApprovalID) pendingCount=\(self.pendingWatchExecApprovalRecoveryIDs.count)")
        self.persistWatchExecApprovalBridgeState()
    }

    private func removePendingWatchExecApprovalRecoveryID(_ approvalId: String) {
        let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedApprovalID.isEmpty else { return }
        let originalCount = self.pendingWatchExecApprovalRecoveryIDs.count
        self.pendingWatchExecApprovalRecoveryIDs.removeAll { $0 == normalizedApprovalID }
        guard self.pendingWatchExecApprovalRecoveryIDs.count != originalCount else { return }
        GatewayDiagnostics.log(
            "watch exec approval: cleared recovery "
                + "id=\(normalizedApprovalID) pendingCount=\(self.pendingWatchExecApprovalRecoveryIDs.count)")
        self.persistWatchExecApprovalBridgeState()
    }

    private func upsertWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
        self.watchExecApprovalPromptsByID[prompt.id] = prompt
        self.removePendingWatchExecApprovalRecoveryID(prompt.id)
        self.persistWatchExecApprovalBridgeState()
    }

    private func removeWatchExecApprovalPrompt(_ approvalId: String) {
        let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedApprovalID.isEmpty else { return }
        self.watchExecApprovalPromptsByID.removeValue(forKey: normalizedApprovalID)
        self.removePendingWatchExecApprovalRecoveryID(normalizedApprovalID)
        self.persistWatchExecApprovalBridgeState()
    }

    private static func makeWatchExecApprovalItem(from prompt: ExecApprovalPrompt) -> OpenClawWatchExecApprovalItem {
        let decisions = prompt.allowedDecisions.compactMap { decision in
            let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
            return OpenClawWatchExecApprovalDecision(rawValue: normalizedDecision)
        }
        let preview = Self.trimmedOrNil(prompt.commandPreview) ?? Self.trimmedOrNil(prompt.commandText)
        return OpenClawWatchExecApprovalItem(
            id: prompt.id,
            commandText: prompt.commandText,
            commandPreview: preview,
            host: Self.trimmedOrNil(prompt.host),
            nodeId: Self.trimmedOrNil(prompt.nodeId),
            agentId: Self.trimmedOrNil(prompt.agentId),
            expiresAtMs: prompt.expiresAtMs,
            allowedDecisions: decisions,
            // Prefer the watch's neutral/default presentation until exec.approval.get
            // carries an explicit risk signal for exec approvals.
            risk: nil)
    }

    nonisolated private static func shouldResetWatchExecApprovalResolvingStateOnPrompt(
        reason: String) -> Bool
    {
        reason == "resolve_retry"
    }

    private func publishWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt, reason: String) async {
        let message = OpenClawWatchExecApprovalPromptMessage(
            approval: Self.makeWatchExecApprovalItem(from: prompt),
            sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
            deliveryId: UUID().uuidString,
            resetResolvingState: Self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason))
        do {
            _ = try await self.watchMessagingService.sendExecApprovalPrompt(message)
            self.watchExecApprovalLogger.debug(
                "watch exec approval prompt sent id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public)")
        } catch {
            // swiftlint:disable:next line_length
            self.watchExecApprovalLogger.error("watch exec approval prompt failed id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
        }
        await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_snapshot")
    }

    private func publishWatchExecApprovalResolved(
        approvalId: String,
        decision: OpenClawWatchExecApprovalDecision?,
        source: String) async
    {
        let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedApprovalID.isEmpty else { return }
        self.removeWatchExecApprovalPrompt(normalizedApprovalID)
        let message = OpenClawWatchExecApprovalResolvedMessage(
            approvalId: normalizedApprovalID,
            decision: decision,
            resolvedAtMs: Int(Date().timeIntervalSince1970 * 1000),
            source: source)
        do {
            _ = try await self.watchMessagingService.sendExecApprovalResolved(message)
        } catch {
            // swiftlint:disable:next line_length
            self.watchExecApprovalLogger.error("watch exec approval resolved update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
        }
        await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot")
    }

    private func publishWatchExecApprovalExpired(
        approvalId: String,
        reason: OpenClawWatchExecApprovalCloseReason) async
    {
        let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedApprovalID.isEmpty else { return }
        self.removeWatchExecApprovalPrompt(normalizedApprovalID)
        let message = OpenClawWatchExecApprovalExpiredMessage(
            approvalId: normalizedApprovalID,
            reason: reason,
            expiredAtMs: Int(Date().timeIntervalSince1970 * 1000))
        do {
            _ = try await self.watchMessagingService.sendExecApprovalExpired(message)
        } catch {
            // swiftlint:disable:next line_length
            self.watchExecApprovalLogger.error("watch exec approval expiry update failed id=\(normalizedApprovalID, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
        }
        await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)")
    }

    private func syncWatchExecApprovalSnapshot(reason: String) async {
        self.pruneExpiredWatchExecApprovalPrompts()
        GatewayDiagnostics.log(
            "watch exec approval: sync snapshot start "
                + "reason=\(reason) cacheCount=\(self.watchExecApprovalPromptsByID.count) "
                + "backgrounded=\(self.isBackgrounded)")
        let approvals = self.watchExecApprovalPromptsByID.values
            .sorted { lhs, rhs in
                let lhsExpires = lhs.expiresAtMs ?? Int.max
                let rhsExpires = rhs.expiresAtMs ?? Int.max
                if lhsExpires != rhsExpires {
                    return lhsExpires < rhsExpires
                }
                return lhs.id < rhs.id
            }
            .map(Self.makeWatchExecApprovalItem)
        let message = OpenClawWatchExecApprovalSnapshotMessage(
            approvals: approvals,
            sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
            snapshotId: UUID().uuidString)
        do {
            _ = try await self.watchMessagingService.syncExecApprovalSnapshot(message)
            GatewayDiagnostics.log(
                "watch exec approval: sync snapshot sent reason=\(reason) count=\(approvals.count)")
            // swiftlint:disable:next line_length
            self.watchExecApprovalLogger.debug("watch exec approval snapshot sent reason=\(reason, privacy: .public) count=\(approvals.count, privacy: .public)")
        } catch {
            GatewayDiagnostics.log(
                "watch exec approval: sync snapshot failed reason=\(reason) error=\(error.localizedDescription)")
            // swiftlint:disable:next line_length
            self.watchExecApprovalLogger.error("watch exec approval snapshot failed reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
        }
    }

    private func refreshWatchExecApprovalSnapshotOnDemand(reason: String) async {
        GatewayDiagnostics.log("watch exec approval: refresh on demand start reason=\(reason)")
        await self.hydrateWatchExecApprovalCacheIfNeeded(reason: reason)
        await self.syncWatchExecApprovalSnapshot(reason: reason)
        GatewayDiagnostics.log("watch exec approval: refresh on demand end reason=\(reason)")
    }

    nonisolated private static func watchExecApprovalIDsNeedingFetch(
        candidateIDs: [String],
        cachedApprovalIDs: [String]) -> [String]
    {
        let cachedIDs = Set(cachedApprovalIDs.compactMap { id -> String? in
            let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines)
            return normalizedID.isEmpty ? nil : normalizedID
        })
        var idsToFetch: [String] = []
        var seen = Set<String>()
        for rawID in candidateIDs {
            let normalizedID = rawID.trimmingCharacters(in: .whitespacesAndNewlines)
            guard !normalizedID.isEmpty else { continue }
            guard seen.insert(normalizedID).inserted else { continue }
            guard !cachedIDs.contains(normalizedID) else { continue }
            idsToFetch.append(normalizedID)
        }
        return idsToFetch
    }

    private func hydrateWatchExecApprovalCacheIfNeeded(reason: String) async {
        self.pruneExpiredWatchExecApprovalPrompts()

        let approvalIDs = await self.pendingExecApprovalIDsForWatchRecovery()
        let missingApprovalIDs = Self.watchExecApprovalIDsNeedingFetch(
            candidateIDs: approvalIDs,
            cachedApprovalIDs: Array(self.watchExecApprovalPromptsByID.keys))
        GatewayDiagnostics.log(
            "watch exec approval: hydrate candidates "
                + "reason=\(reason) ids=\(approvalIDs.joined(separator: ",")) "
                + "missing=\(missingApprovalIDs.joined(separator: ",")) "
                + "cached=\(self.watchExecApprovalPromptsByID.count)")
        guard !missingApprovalIDs.isEmpty else {
            self.watchExecApprovalLogger.debug(
                "watch exec approval hydrate skipped reason=\(reason, privacy: .public): no missing approval ids")
            return
        }

        for approvalId in missingApprovalIDs {
            GatewayDiagnostics.log(
                "watch exec approval: hydrate fetch start id=\(approvalId) reason=\(reason)")
            let outcome = await self.fetchExecApprovalPrompt(
                approvalId: approvalId,
                sourceReason: reason)
            switch outcome {
            case let .loaded(prompt):
                GatewayDiagnostics.log("watch exec approval: hydrate fetch loaded id=\(approvalId)")
                self.upsertWatchExecApprovalPrompt(prompt)
            case .stale:
                GatewayDiagnostics.log("watch exec approval: hydrate fetch stale id=\(approvalId)")
                self.removePendingWatchExecApprovalRecoveryID(approvalId)
                await ExecApprovalNotificationBridge.removeNotifications(
                    forApprovalID: approvalId,
                    notificationCenter: self.notificationCenter)
            case let .failed(message):
                // swiftlint:disable:next line_length
                self.watchExecApprovalLogger.error("watch exec approval hydrate failed id=\(approvalId, privacy: .public) reason=\(reason, privacy: .public) error=\(message, privacy: .public)")
            }
        }
    }

    private func pendingExecApprovalIDsForWatchRecovery() async -> [String] {
        var ids: [String] = []
        var seen = Set<String>()

        func append(_ rawID: String?) {
            let approvalId = rawID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
            guard !approvalId.isEmpty, seen.insert(approvalId).inserted else { return }
            ids.append(approvalId)
        }

        append(self.pendingExecApprovalPrompt?.id)
        for approvalId in self.pendingWatchExecApprovalRecoveryIDs {
            append(approvalId)
        }
        for approvalId in self.watchExecApprovalPromptsByID.keys.sorted() {
            append(approvalId)
        }

        let delivered = await self.notificationCenter.deliveredNotifications()
        GatewayDiagnostics.log("watch exec approval: delivered notifications count=\(delivered.count)")
        for snapshot in delivered {
            guard ExecApprovalNotificationBridge.payloadKind(userInfo: snapshot.userInfo)
                == ExecApprovalNotificationBridge.requestedKind
            else {
                continue
            }
            append(ExecApprovalNotificationBridge.approvalID(from: snapshot.userInfo))
        }

        return ids
    }

    private func handleWatchExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) async {
        let normalizedApprovalID = event.approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedApprovalID.isEmpty else { return }
        if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
            self.pendingExecApprovalPromptResolving = true
            self.pendingExecApprovalPromptErrorText = nil
        }
        let outcome = await self.resolveExecApprovalNotificationDecision(
            approvalId: normalizedApprovalID,
            decision: event.decision.rawValue,
            sourceReason: "watch_resolve")
        if case let .failed(message) = outcome {
            if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
                self.pendingExecApprovalPromptResolving = false
                self.pendingExecApprovalPromptErrorText = message
            }
            if let prompt = self.watchExecApprovalPromptsByID[normalizedApprovalID] {
                await self.publishWatchExecApprovalPrompt(prompt, reason: "resolve_retry")
            }
        }
    }

    func handleExecApprovalRequestedRemotePush(approvalId: String) async -> Bool {
        let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedApprovalID.isEmpty else { return false }
        self.appendPendingWatchExecApprovalRecoveryID(normalizedApprovalID)
        let fetchedPrompt = await self.fetchExecApprovalPrompt(
            approvalId: normalizedApprovalID,
            sourceReason: "push_request")
        switch fetchedPrompt {
        case let .loaded(prompt):
            self.upsertWatchExecApprovalPrompt(prompt)
            await self.publishWatchExecApprovalPrompt(prompt, reason: "push_request")
            return true
        case .stale:
            await ExecApprovalNotificationBridge.removeNotifications(
                forApprovalID: normalizedApprovalID,
                notificationCenter: self.notificationCenter)
            self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
            await self.publishWatchExecApprovalExpired(
                approvalId: normalizedApprovalID,
                reason: .notFound)
            return true
        case let .failed(message):
            // swiftlint:disable:next line_length
            self.watchExecApprovalLogger.error("watch exec approval push fetch failed id=\(normalizedApprovalID, privacy: .public) error=\(message, privacy: .public)")
            return false
        }
    }

    func handleExecApprovalResolvedRemotePush(approvalId: String) async {
        let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedApprovalID.isEmpty else { return }

        let hadWatchPrompt = self.watchExecApprovalPromptsByID[normalizedApprovalID] != nil
        let hadPendingPrompt = self.pendingExecApprovalPrompt?.id == normalizedApprovalID
        let hadPendingRecoveryID = self.pendingWatchExecApprovalRecoveryIDs.contains(normalizedApprovalID)
        guard hadWatchPrompt || hadPendingPrompt || hadPendingRecoveryID else {
            return
        }

        await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .resolved)
        self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
    }

    func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool {
        let wakeId = Self.makePushWakeAttemptID()
        guard Self.isSilentPushPayload(userInfo) else {
            self.pushWakeLogger.info("Ignored APNs payload wakeId=\(wakeId, privacy: .public): not silent push")
            return false
        }
        let pushKind = Self.openclawPushKind(userInfo)
        let receivedMessage =
            "Silent push received wakeId=\(wakeId) "
            + "kind=\(pushKind) "
            + "backgrounded=\(self.isBackgrounded) "
            + "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
        self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")

        if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
            userInfo: userInfo,
            notificationCenter: self.notificationCenter)
        {
            if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
                await self.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
            }
            self.execApprovalNotificationLogger.info(
                "Handled exec approval cleanup push wakeId=\(wakeId, privacy: .public)")
            return true
        }

        let execApprovalPushKind = ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo)
        let isExecApprovalRequestPush = execApprovalPushKind == ExecApprovalNotificationBridge.requestedKind
        if isExecApprovalRequestPush,
           let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
        {
            let handled = await self.handleExecApprovalRequestedRemotePush(approvalId: approvalId)
            if handled {
                // swiftlint:disable:next line_length
                self.execApprovalNotificationLogger.info("Handled exec approval request push wakeId=\(wakeId, privacy: .public) id=\(approvalId, privacy: .public)")
            }
            return handled
        }

        let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
        let outcomeMessage =
            "Silent push outcome wakeId=\(wakeId) "
            + "applied=\(result.applied) "
            + "reason=\(result.reason) "
            + "durationMs=\(result.durationMs)"
        self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
        return result.applied
    }

    func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool {
        let wakeId = Self.makePushWakeAttemptID()
        let receivedMessage =
            "Background refresh wake received wakeId=\(wakeId) "
            + "trigger=\(trigger) "
            + "backgrounded=\(self.isBackgrounded) "
            + "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
        self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
        let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
        let outcomeMessage =
            "Background refresh wake outcome wakeId=\(wakeId) "
            + "applied=\(result.applied) "
            + "reason=\(result.reason) "
            + "durationMs=\(result.durationMs)"
        self.pushWakeLogger.info("\(outcomeMessage, 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
        {
            let throttledMessage =
                "Location wake throttled wakeId=\(wakeId) "
                + "elapsedSec=\(now.timeIntervalSince(last))"
            self.locationWakeLogger.info("\(throttledMessage, privacy: .public)")
            return
        }
        self.lastSignificantLocationWakeAt = now

        let beginMessage =
            "Location wake begin wakeId=\(wakeId) "
            + "backgrounded=\(self.isBackgrounded) "
            + "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
        self.locationWakeLogger.info("\(beginMessage, privacy: .public)")
        let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
        let triggerMessage =
            "Location wake trigger wakeId=\(wakeId) "
            + "applied=\(result.applied) "
            + "reason=\(result.reason) "
            + "durationMs=\(result.durationMs)"
        self.locationWakeLogger.info("\(triggerMessage, 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)")
    }

    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
        }
        let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
        if !usesRelayTransport && token == self.apnsLastRegisteredTokenHex {
            return
        }
        guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
              !topic.isEmpty
        else {
            return
        }

        do {
            let gatewayIdentity: PushRelayGatewayIdentity?
            if usesRelayTransport {
                guard self.operatorConnected else { return }
                gatewayIdentity = try await self.fetchPushRelayGatewayIdentity()
            } else {
                gatewayIdentity = nil
            }
            let payloadJSON = try await self.pushRegistrationManager.makeGatewayRegistrationPayload(
                apnsTokenHex: token,
                topic: topic,
                gatewayIdentity: gatewayIdentity)
            await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: payloadJSON)
            self.apnsLastRegisteredTokenHex = token
        } catch {
            self.pushWakeLogger.error(
                "APNs registration publish failed: \(error.localizedDescription, privacy: .public)")
        }
    }

    private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity {
        let response = try await self.operatorGateway.request(
            method: "gateway.identity.get",
            paramsJSON: "{}",
            timeoutSeconds: 8)
        let decoded = try JSONDecoder().decode(GatewayRelayIdentityResponse.self, from: response)
        let deviceId = decoded.deviceId.trimmingCharacters(in: .whitespacesAndNewlines)
        let publicKey = decoded.publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !deviceId.isEmpty, !publicKey.isEmpty else {
            throw PushRelayError.relayMisconfigured("Gateway identity response missing required fields")
        }
        return PushRelayGatewayIdentity(deviceId: deviceId, publicKey: publicKey)
    }

    private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool {
        guard let apsAny = userInfo["aps"] else { return false }
        if let aps = apsAny as? [AnyHashable: Any] {
            return Self.hasContentAvailable(aps["content-available"])
        }
        if let aps = apsAny as? [String: Any] {
            return Self.hasContentAvailable(aps["content-available"])
        }
        return false
    }

    private static func hasContentAvailable(_ value: Any?) -> Bool {
        if let number = value as? NSNumber {
            return number.intValue == 1
        }
        if let text = value as? String {
            return text.trimmingCharacters(in: .whitespacesAndNewlines) == "1"
        }
        return false
    }

    private static func makePushWakeAttemptID() -> String {
        let raw = UUID().uuidString.replacingOccurrences(of: "-", with: "")
        return String(raw.prefix(8))
    }

    private static func openclawPushKind(_ userInfo: [AnyHashable: Any]) -> String {
        if let payload = userInfo["openclaw"] as? [String: Any],
           let kind = payload["kind"] as? String
        {
            let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines)
            if !trimmed.isEmpty { return trimmed }
        }
        if let payload = userInfo["openclaw"] as? [AnyHashable: Any],
           let kind = payload["kind"] as? String
        {
            let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines)
            if !trimmed.isEmpty { return trimmed }
        }
        return "unknown"
    }

    private struct ExecApprovalGetRequest: Encodable {
        let id: String
    }

    private struct ExecApprovalResolveRequest: Encodable {
        let id: String
        let decision: String
    }

    private struct ExecApprovalGetResponse: Decodable {
        var id: String
        var commandText: String
        var commandPreview: String?
        var allowedDecisions: [String]
        var host: String?
        var nodeId: String?
        var agentId: String?
        var expiresAtMs: Int?
    }

    func presentExecApprovalNotificationPrompt(_ prompt: ExecApprovalNotificationPrompt) async {
        let approvalId = prompt.approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !approvalId.isEmpty else { return }

        self.pendingExecApprovalPromptRequestGeneration &+= 1
        let requestGeneration = self.pendingExecApprovalPromptRequestGeneration
        self.pendingExecApprovalPromptResolving = true
        self.pendingExecApprovalPromptErrorText = nil

        let fetchedPrompt = await self.fetchExecApprovalPrompt(approvalId: approvalId)
        guard self.pendingExecApprovalPromptRequestGeneration == requestGeneration else {
            return
        }
        self.pendingExecApprovalPromptResolving = false
        switch fetchedPrompt {
        case let .loaded(fetchedPrompt):
            self.presentFetchedExecApprovalPrompt(fetchedPrompt)
        case .stale:
            await ExecApprovalNotificationBridge.removeNotifications(
                forApprovalID: approvalId,
                notificationCenter: self.notificationCenter)
            self.clearPendingExecApprovalPromptIfMatches(approvalId)
            await self.publishWatchExecApprovalExpired(approvalId: approvalId, reason: .notFound)
        case let .failed(message):
            // swiftlint:disable:next line_length
            self.execApprovalNotificationLogger.error("Exec approval prompt fetch failed id=\(approvalId, privacy: .public) reason=\(message, privacy: .public)")
        }
    }

    private enum ExecApprovalPromptFetchOutcome {
        case loaded(ExecApprovalPrompt)
        case stale
        case failed(message: String)
    }

    private func presentFetchedExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
        self.pendingExecApprovalPrompt = prompt
        self.pendingExecApprovalPromptResolving = false
        self.pendingExecApprovalPromptErrorText = nil
        self.upsertWatchExecApprovalPrompt(prompt)
        Task { @MainActor [weak self] in
            await self?.publishWatchExecApprovalPrompt(prompt, reason: "present_prompt")
        }
    }

    private static func makeExecApprovalPrompt(from details: ExecApprovalGetResponse) -> ExecApprovalPrompt? {
        let approvalId = details.id.trimmingCharacters(in: .whitespacesAndNewlines)
        let commandText = details.commandText.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !approvalId.isEmpty, !commandText.isEmpty else { return nil }
        return ExecApprovalPrompt(
            id: approvalId,
            commandText: commandText,
            commandPreview: details.commandPreview?.trimmingCharacters(in: .whitespacesAndNewlines),
            allowedDecisions: details.allowedDecisions.compactMap { decision in
                let trimmed = decision.trimmingCharacters(in: .whitespacesAndNewlines)
                return trimmed.isEmpty ? nil : trimmed
            },
            host: details.host?.trimmingCharacters(in: .whitespacesAndNewlines),
            nodeId: details.nodeId?.trimmingCharacters(in: .whitespacesAndNewlines),
            agentId: details.agentId?.trimmingCharacters(in: .whitespacesAndNewlines),
            expiresAtMs: details.expiresAtMs)
    }

    nonisolated private static func shouldUseBackgroundAwareExecApprovalReconnect(
        sourceReason: String,
        isBackgrounded: Bool) -> Bool
    {
        guard isBackgrounded else { return false }
        switch sourceReason {
        case "watch_request", "push_request", "watch_resolve", "notification_action":
            return true
        default:
            return false
        }
    }

    private func fetchExecApprovalPrompt(
        approvalId: String,
        sourceReason: String? = nil) async -> ExecApprovalPromptFetchOutcome
    {
        let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
        let fetchReason: String
        if let normalizedSourceReason, !normalizedSourceReason.isEmpty {
            fetchReason = normalizedSourceReason
        } else {
            fetchReason = "direct"
        }
        GatewayDiagnostics.log(
            "watch exec approval: fetch prompt start id=\(approvalId) reason=\(fetchReason)")
        let connected: Bool
        if Self.shouldUseBackgroundAwareExecApprovalReconnect(
            sourceReason: fetchReason,
            isBackgrounded: self.isBackgrounded)
        {
            connected = await self.ensureOperatorApprovalConnectionForWatchReview(
                timeoutMs: 12_000,
                reason: fetchReason)
        } else {
            connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
        }
        guard connected else {
            GatewayDiagnostics.log(
                "watch exec approval: fetch prompt operator not connected id=\(approvalId) reason=\(fetchReason)")
            return .failed(message: "operator_not_connected")
        }

        do {
            let payloadJSON = try Self.encodePayload(ExecApprovalGetRequest(id: approvalId))
            let response = try await self.operatorGateway.request(
                method: "exec.approval.get",
                paramsJSON: payloadJSON,
                timeoutSeconds: 12)
            let details = try JSONDecoder().decode(ExecApprovalGetResponse.self, from: response)
            guard let prompt = Self.makeExecApprovalPrompt(from: details) else {
                GatewayDiagnostics.log(
                    "watch exec approval: fetch prompt invalid payload id=\(approvalId) reason=\(fetchReason)")
                return .failed(message: "invalid_prompt_payload")
            }
            GatewayDiagnostics.log(
                "watch exec approval: fetch prompt loaded id=\(approvalId) reason=\(fetchReason)")
            return .loaded(prompt)
        } catch {
            if Self.isApprovalNotificationStaleError(error) {
                GatewayDiagnostics.log(
                    "watch exec approval: fetch prompt stale id=\(approvalId) reason=\(fetchReason)")
                return .stale
            }
            GatewayDiagnostics.log(
                "watch exec approval: fetch prompt failed "
                    + "id=\(approvalId) reason=\(fetchReason) "
                    + "error=\(error.localizedDescription)")
            return .failed(message: error.localizedDescription)
        }
    }

    func dismissPendingExecApprovalPrompt() {
        self.pendingExecApprovalPrompt = nil
        self.pendingExecApprovalPromptResolving = false
        self.pendingExecApprovalPromptErrorText = nil
    }

    func dismissPendingExecApprovalPrompt(approvalId: String) {
        self.clearPendingExecApprovalPromptIfMatches(approvalId)
    }

    func resolvePendingExecApprovalPrompt(decision: String) async {
        guard let prompt = self.pendingExecApprovalPrompt else { return }
        let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedDecision.isEmpty else { return }

        self.pendingExecApprovalPromptResolving = true
        self.pendingExecApprovalPromptErrorText = nil
        let outcome = await self.resolveExecApprovalNotificationDecision(
            approvalId: prompt.id,
            decision: normalizedDecision)
        switch outcome {
        case .resolved, .stale, .unavailable:
            break
        case let .failed(message):
            self.pendingExecApprovalPromptResolving = false
            self.pendingExecApprovalPromptErrorText = message
        }
    }

    func handleExecApprovalNotificationDecision(
        approvalId: String,
        decision: String
    ) async {
        let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedApprovalID.isEmpty else { return }

        if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
            self.pendingExecApprovalPromptResolving = true
            self.pendingExecApprovalPromptErrorText = nil
        }

        let outcome = await self.resolveExecApprovalNotificationDecision(
            approvalId: normalizedApprovalID,
            decision: decision)
        switch outcome {
        case .resolved, .stale, .unavailable:
            break
        case let .failed(message):
            if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
                self.pendingExecApprovalPromptResolving = false
                self.pendingExecApprovalPromptErrorText = message
            }
        }
    }

    private func resolveExecApprovalNotificationDecision(
        approvalId: String,
        decision: String,
        sourceReason: String? = nil
    ) async -> ExecApprovalResolutionOutcome {
        let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
        let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
        let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
        let resolutionReason = (normalizedSourceReason?.isEmpty == false) ? normalizedSourceReason! : "direct"
        guard !normalizedApprovalID.isEmpty, !normalizedDecision.isEmpty else {
            return .failed(message: "Invalid approval request.")
        }

        let connected: Bool
        if Self.shouldUseBackgroundAwareExecApprovalReconnect(
            sourceReason: resolutionReason,
            isBackgrounded: self.isBackgrounded)
        {
            connected = await self.ensureOperatorApprovalConnectionForWatchReview(
                timeoutMs: 12_000,
                reason: resolutionReason)
        } else {
            connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
        }
        guard connected else {
            self.execApprovalNotificationLogger.error(
                "Exec approval action failed id=\(normalizedApprovalID, privacy: .public): operator not connected")
            return .failed(message: "OpenClaw couldn't connect to the gateway operator session.")
        }

        do {
            let payloadJSON = try Self.encodePayload(
                ExecApprovalResolveRequest(id: normalizedApprovalID, decision: normalizedDecision))
            _ = try await self.operatorGateway.request(
                method: "exec.approval.resolve",
                paramsJSON: payloadJSON,
                timeoutSeconds: 12)
            await ExecApprovalNotificationBridge.removeNotifications(
                forApprovalID: normalizedApprovalID,
                notificationCenter: self.notificationCenter)
            self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
            await self.publishWatchExecApprovalResolved(
                approvalId: normalizedApprovalID,
                decision: OpenClawWatchExecApprovalDecision(rawValue: normalizedDecision),
                source: "iphone")
            return .resolved
        } catch {
            if Self.isApprovalNotificationStaleError(error) {
                await ExecApprovalNotificationBridge.removeNotifications(
                    forApprovalID: normalizedApprovalID,
                    notificationCenter: self.notificationCenter)
                self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
                await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .notFound)
                return .stale
            }
            if Self.isApprovalNotificationUnavailableError(error) {
                await ExecApprovalNotificationBridge.removeNotifications(
                    forApprovalID: normalizedApprovalID,
                    notificationCenter: self.notificationCenter)
                self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
                await self.publishWatchExecApprovalExpired(approvalId: normalizedApprovalID, reason: .unavailable)
                return .unavailable
            }
            let logMessage =
                "Exec approval action failed id=\(normalizedApprovalID) error=\(error.localizedDescription)"
            self.execApprovalNotificationLogger.error("\(logMessage, privacy: .public)")
            return .failed(
                message: "OpenClaw couldn't resolve this approval right now. Try again.")
        }
    }

    private func clearPendingExecApprovalPromptIfMatches(_ approvalId: String) {
        let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
        guard self.pendingExecApprovalPrompt?.id == normalizedApprovalID else { return }
        self.dismissPendingExecApprovalPrompt()
    }

    nonisolated private static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
        guard let gatewayError = error as? GatewayResponseError else { return false }
        if gatewayError.code != "INVALID_REQUEST" {
            return false
        }
        if gatewayError.detailsReason == "APPROVAL_NOT_FOUND" {
            return true
        }
        return gatewayError.message.lowercased().contains("unknown or expired approval id")
    }

    nonisolated private static func isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
        guard let gatewayError = error as? GatewayResponseError else { return false }
        if gatewayError.code != "INVALID_REQUEST" {
            return false
        }
        if gatewayError.detailsReason == "APPROVAL_ALLOW_ALWAYS_UNAVAILABLE" {
            return true
        }
        return gatewayError.message.lowercased().contains("allow-always is unavailable")
    }

    private struct 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 Task.isCancelled {
                return false
            }
            if await self.isGatewayConnected() {
                return true
            }
            do {
                try await Task.sleep(nanoseconds: pollIntervalNs)
            } catch {
                return false
            }
        }
        return await self.isGatewayConnected()
    }

    private func waitForOperatorConnection(timeoutMs: Int, pollMs: Int) async -> Bool {
        let clampedTimeoutMs = max(0, timeoutMs)
        let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000
        let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0)
        while Date() < deadline {
            if Task.isCancelled {
                return false
            }
            if await self.isOperatorConnected() {
                return true
            }
            do {
                try await Task.sleep(nanoseconds: pollIntervalNs)
            } catch {
                return false
            }
        }
        return await self.isOperatorConnected()
    }

    private func ensureOperatorReconnectLoopIfNeeded() {
        guard let cfg = self.activeGatewayConnectConfig else {
            return
        }
        guard self.operatorGatewayTask == nil else {
            return
        }
        let stableID = cfg.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
        let effectiveStableID = stableID.isEmpty ? cfg.url.absoluteString : stableID
        let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
        self.startOperatorGatewayLoop(
            url: cfg.url,
            stableID: effectiveStableID,
            token: cfg.token,
            bootstrapToken: cfg.bootstrapToken,
            password: cfg.password,
            nodeOptions: cfg.nodeOptions,
            sessionBox: sessionBox)
    }

    private func ensureOperatorApprovalConnectionForWatchReview(timeoutMs: Int, reason: String) async -> Bool {
        let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
        let reconnectReason = normalizedReason.isEmpty ? "watch_request" : normalizedReason
        if await self.isOperatorConnected() {
            GatewayDiagnostics.log(
                "watch exec approval: watch_request_reconnect_connected "
                    + "reason=\(reconnectReason) phase=already_connected")
            return true
        }

        guard self.isBackgrounded else {
            GatewayDiagnostics.log(
                "watch exec approval: watch_request_reconnect_begin "
                    + "reason=\(reconnectReason) backgrounded=false strategy=default")
            let connected = await self.ensureOperatorApprovalConnection(timeoutMs: timeoutMs)
            GatewayDiagnostics.log(
                "watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") "
                    + "reason=\(reconnectReason) phase=foreground_delegate")
            return connected
        }

        guard self.gatewayAutoReconnectEnabled else {
            GatewayDiagnostics.log(
                "watch exec approval: watch_request_reconnect_timeout "
                    + "reason=\(reconnectReason) phase=auto_reconnect_disabled")
            return false
        }

        guard let cfg = self.activeGatewayConnectConfig else {
            GatewayDiagnostics.log(
                "watch exec approval: watch_request_reconnect_timeout "
                    + "reason=\(reconnectReason) phase=no_active_gateway_config")
            return false
        }

        GatewayDiagnostics.log(
            "watch exec approval: watch_request_reconnect_begin reason=\(reconnectReason) backgrounded=true")
        let leaseSeconds = min(45.0, max(15.0, Double(max(timeoutMs, 1_000)) / 1000.0 + 8.0))
        self.grantBackgroundReconnectLease(seconds: leaseSeconds, reason: "watch_review_\(reconnectReason)")
        GatewayDiagnostics.log(
            "watch exec approval: watch_request_reconnect_lease_granted "
                + "reason=\(reconnectReason) seconds=\(leaseSeconds)")

        let hadReconnectLoop = self.operatorGatewayTask != nil
        let canStartReconnectLoop = hadReconnectLoop || self.shouldStartOperatorGatewayLoop(
            token: cfg.token,
            bootstrapToken: cfg.bootstrapToken,
            password: cfg.password,
            stableID: cfg.effectiveStableID)
        guard canStartReconnectLoop else {
            GatewayDiagnostics.log(
                "watch exec approval: watch_request_reconnect_timeout "
                    + "reason=\(reconnectReason) phase=no_operator_reconnect_auth")
            return false
        }

        self.ensureOperatorReconnectLoopIfNeeded()
        GatewayDiagnostics.log(
            "watch exec approval: watch_request_reconnect_loop_\(hadReconnectLoop ? "reused" : "started") "
                + "reason=\(reconnectReason)")

        let initialWaitMs = min(2_500, max(750, timeoutMs / 4))
        GatewayDiagnostics.log(
            "watch exec approval: watch_request_reconnect_wait "
                + "reason=\(reconnectReason) phase=initial timeoutMs=\(initialWaitMs)")
        if await self.waitForOperatorConnection(timeoutMs: initialWaitMs, pollMs: 200) {
            GatewayDiagnostics.log(
                "watch exec approval: watch_request_reconnect_connected "
                    + "reason=\(reconnectReason) phase=initial")
            return true
        }

        GatewayDiagnostics.log(
            "watch exec approval: watch_request_reconnect_restart reason=\(reconnectReason)")
        self.operatorGatewayTask?.cancel()
        self.operatorGatewayTask = nil
        await self.operatorGateway.disconnect()
        self.operatorConnected = false
        self.talkMode.updateGatewayConnected(false)
        self.stopGatewayHealthMonitor()

        let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
        self.startOperatorGatewayLoop(
            url: cfg.url,
            stableID: cfg.effectiveStableID,
            token: cfg.token,
            bootstrapToken: cfg.bootstrapToken,
            password: cfg.password,
            nodeOptions: cfg.nodeOptions,
            sessionBox: sessionBox)

        let remainingWaitMs = max(250, timeoutMs - initialWaitMs)
        GatewayDiagnostics.log(
            "watch exec approval: watch_request_reconnect_wait "
                + "reason=\(reconnectReason) phase=restart timeoutMs=\(remainingWaitMs)")
        let connected = await self.waitForOperatorConnection(timeoutMs: remainingWaitMs, pollMs: 200)
        GatewayDiagnostics.log(
            "watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") "
                + "reason=\(reconnectReason) phase=restart")
        return connected
    }

    private func ensureOperatorApprovalConnection(timeoutMs: Int) async -> Bool {
        if await self.isOperatorConnected() {
            return true
        }
        self.ensureOperatorReconnectLoopIfNeeded()
        return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250)
    }

    private func 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))
        }

        guard self.isBackgrounded else {
            self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): app not backgrounded")
            return makeResult(false, "not_backgrounded")
        }
        guard self.gatewayAutoReconnectEnabled else {
            self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): auto reconnect disabled")
            return makeResult(false, "auto_reconnect_disabled")
        }
        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")
        }

        self.pushWakeLogger.info(
            "Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)")
        self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)")
        await self.operatorGateway.disconnect()
        await self.nodeGateway.disconnect()
        self.operatorConnected = false
        self.gatewayConnected = false
        self.gatewayStatusText = "Reconnecting…"
        self.talkMode.updateGatewayConnected(false)
        self.applyGatewayConnectConfig(cfg)
        self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)")
        return makeResult(true, "reconnect_triggered")
    }
}

extension NodeAppModel {
    private func refreshWakeWordsFromGateway() async {
        do {
            let data = try await self.operatorGateway.request(
                method: "voicewake.get",
                paramsJSON: "{}",
                timeoutSeconds: 8
            )
            guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
            VoiceWakePreferences.saveTriggerWords(triggers)
        } catch {
            if let gatewayError = error as? GatewayResponseError {
                let lower = gatewayError.message.lowercased()
                if lower.contains("unauthorized role") || lower.contains("missing scope") {
                    self.setGatewayHealthMonitorDisabled(true)
                    return
                }
            }
            // Best-effort only.
        }
    }

    private func isGatewayHealthMonitorDisabled() -> Bool {
        self.gatewayHealthMonitorDisabled
    }

    private func setGatewayHealthMonitorDisabled(_ disabled: Bool) {
        self.gatewayHealthMonitorDisabled = disabled
    }

    func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
        if await !self.isGatewayConnected() {
            throw NSError(domain: "Gateway", code: 10, userInfo: [
                NSLocalizedDescriptionKey: "Gateway not connected",
            ])
        }
        struct Payload: Codable {
            var text: String
            var sessionKey: String?
        }
        let payload = Payload(text: text, sessionKey: sessionKey)
        let data = try JSONEncoder().encode(payload)
        guard let json = String(bytes: data, encoding: .utf8) else {
            throw NSError(domain: "NodeAppModel", code: 1, userInfo: [
                NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
            ])
        }
        await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json)
    }

    func handleDeepLink(url: URL) async {
        guard let route = DeepLinkParser.parse(url) else { return }

        switch route {
        case let .agent(link):
            await self.handleAgentDeepLink(link, originalURL: url)
        case .gateway:
            break
        }
    }

    private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async {
        let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !message.isEmpty else { return }
        self.deepLinkLogger.info(
            "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)"
        )

        if message.count > IOSDeepLinkAgentPolicy.maxMessageChars {
            self.screen.errorText = "Deep link too large (message exceeds "
                + "\(IOSDeepLinkAgentPolicy.maxMessageChars) characters)."
            self.recordShareEvent("Rejected: message too large (\(message.count) chars).")
            return
        }

        guard await self.isGatewayConnected() else {
            self.screen.errorText = "Gateway not connected (cannot forward deep link)."
            self.recordShareEvent("Failed: gateway not connected.")
            self.deepLinkLogger.error("agent deep link rejected: gateway not connected")
            return
        }

        let allowUnattended = self.isUnattendedDeepLinkAllowed(link.key)
        if !allowUnattended {
            if message.count > IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars {
                self.screen.errorText = "Deep link blocked (message too long without key)."
                self.recordShareEvent(
                    "Rejected: deep link over \(IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars) chars without key.")
                self.deepLinkLogger.error(
                    "agent deep link rejected: unkeyed message too long chars=\(message.count, privacy: .public)")
                return
            }
            let urlText = originalURL.absoluteString
            let prompt = AgentDeepLinkPrompt(
                id: UUID().uuidString,
                messagePreview: message,
                urlPreview: urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText,
                request: self.effectiveAgentDeepLinkForPrompt(link))

            let promptIntervalSeconds = 5.0
            let elapsed = Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt)
            if elapsed < promptIntervalSeconds {
                if self.pendingAgentDeepLinkPrompt != nil {
                    self.pendingAgentDeepLinkPrompt = prompt
                    self.recordShareEvent("Updated local confirmation request (\(message.count) chars).")
                    self.deepLinkLogger.debug("agent deep link prompt coalesced into active confirmation")
                    return
                }

                let remaining = max(0, promptIntervalSeconds - elapsed)
                self.queueAgentDeepLinkPrompt(prompt, initialDelaySeconds: remaining)
                self.recordShareEvent("Queued local confirmation (\(message.count) chars).")
                self.deepLinkLogger.debug("agent deep link prompt queued due to rate limit")
                return
            }

            self.presentAgentDeepLinkPrompt(prompt)
            self.recordShareEvent("Awaiting local confirmation (\(message.count) chars).")
            self.deepLinkLogger.info("agent deep link requires local confirmation")
            return
        }

        await self.submitAgentDeepLink(link, messageCharCount: message.count)
    }

    private func sendAgentRequest(link: AgentDeepLink) async throws {
        if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            throw NSError(domain: "DeepLink", code: 1, userInfo: [
                NSLocalizedDescriptionKey: "invalid agent message",
            ])
        }

        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",
            ])
        }
        await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json)
    }

    private func isGatewayConnected() async -> Bool {
        self.gatewayConnected
    }

    private func applyMainSessionKey(_ key: String?) {
        let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines)
        if trimmed == current { return }
        self.mainSessionBaseKey = trimmed
        self.talkMode.updateMainSessionKey(self.mainSessionKey)
    }

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

    func approvePendingAgentDeepLinkPrompt() async {
        guard let prompt = self.pendingAgentDeepLinkPrompt else { return }
        self.pendingAgentDeepLinkPrompt = nil
        guard await self.isGatewayConnected() else {
            self.screen.errorText = "Gateway not connected (cannot forward deep link)."
            self.recordShareEvent("Failed: gateway not connected.")
            self.deepLinkLogger.error("agent deep link approval failed: gateway not connected")
            return
        }
        await self.submitAgentDeepLink(prompt.request, messageCharCount: prompt.messagePreview.count)
    }

    func declinePendingAgentDeepLinkPrompt() {
        guard self.pendingAgentDeepLinkPrompt != nil else { return }
        self.pendingAgentDeepLinkPrompt = nil
        self.screen.errorText = "Deep link cancelled."
        self.recordShareEvent("Cancelled: deep link confirmation declined.")
        self.deepLinkLogger.info("agent deep link cancelled by local user")
    }

    private func presentAgentDeepLinkPrompt(_ prompt: AgentDeepLinkPrompt) {
        self.lastAgentDeepLinkPromptAt = Date()
        self.pendingAgentDeepLinkPrompt = prompt
    }

    private func queueAgentDeepLinkPrompt(_ prompt: AgentDeepLinkPrompt, initialDelaySeconds: TimeInterval) {
        self.queuedAgentDeepLinkPrompt = prompt
        guard self.queuedAgentDeepLinkPromptTask == nil else { return }

        self.queuedAgentDeepLinkPromptTask = Task { [weak self] in
            guard let self else { return }
            let delayNs = UInt64(max(0, initialDelaySeconds) * 1_000_000_000)
            if delayNs > 0 {
                do {
                    try await Task.sleep(nanoseconds: delayNs)
                } catch {
                    return
                }
            }
            await self.deliverQueuedAgentDeepLinkPrompt()
        }
    }

    private func deliverQueuedAgentDeepLinkPrompt() async {
        defer { self.queuedAgentDeepLinkPromptTask = nil }
        let promptIntervalSeconds = 5.0
        while let prompt = self.queuedAgentDeepLinkPrompt {
            if self.pendingAgentDeepLinkPrompt != nil {
                do {
                    try await Task.sleep(nanoseconds: 200_000_000)
                } catch {
                    return
                }
                continue
            }

            let elapsed = Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt)
            if elapsed < promptIntervalSeconds {
                let remaining = max(0, promptIntervalSeconds - elapsed)
                do {
                    try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
                } catch {
                    return
                }
                continue
            }

            self.queuedAgentDeepLinkPrompt = nil
            self.presentAgentDeepLinkPrompt(prompt)
            self.recordShareEvent("Awaiting local confirmation (\(prompt.messagePreview.count) chars).")
            self.deepLinkLogger.info("agent deep link queued prompt delivered")
        }
    }

    private func submitAgentDeepLink(_ link: AgentDeepLink, messageCharCount: Int) async {
        do {
            try await self.sendAgentRequest(link: link)
            self.screen.errorText = nil
            self.recordShareEvent("Sent to gateway (\(messageCharCount) chars).")
            self.deepLinkLogger.info("agent deep link forwarded to gateway")
            self.openChatRequestID &+= 1
        } catch {
            self.screen.errorText = "Agent request failed: \(error.localizedDescription)"
            self.recordShareEvent("Failed: \(error.localizedDescription)")
            self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)")
        }
    }

    private func effectiveAgentDeepLinkForPrompt(_ link: AgentDeepLink) -> AgentDeepLink {
        // Without a trusted key, strip delivery/routing knobs to reduce exfiltration risk.
        AgentDeepLink(
            message: link.message,
            sessionKey: link.sessionKey,
            thinking: link.thinking,
            deliver: false,
            to: nil,
            channel: nil,
            timeoutSeconds: link.timeoutSeconds,
            key: link.key)
    }

    private func isUnattendedDeepLinkAllowed(_ key: String?) -> Bool {
        let normalizedKey = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        guard !normalizedKey.isEmpty else { return false }
        return normalizedKey == Self.canvasUnattendedDeepLinkKey || normalizedKey == Self.expectedDeepLinkKey()
    }

    private static func expectedDeepLinkKey() -> String {
        let defaults = UserDefaults.standard
        if let key = defaults.string(forKey: self.deepLinkKeyUserDefaultsKey), !key.isEmpty {
            return key
        }
        let key = self.generateDeepLinkKey()
        defaults.set(key, forKey: self.deepLinkKeyUserDefaultsKey)
        return key
    }

    private static func generateDeepLinkKey() -> String {
        var bytes = [UInt8](repeating: 0, count: 32)
        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
        let data = Data(bytes)
        return data
            .base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }
}

extension NodeAppModel {
    func _bridgeConsumeMirroredWatchReply(_ event: WatchQuickReplyEvent) async {
        await self.handleWatchQuickReply(event)
    }
}

#if DEBUG
extension NodeAppModel {
    func _test_handleInvoke(_ req: BridgeInvokeRequest) 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()
    }

    func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
        self.applyTalkModeSync(enabled: enabled, phase: phase)
    }

    func _test_queuedWatchReplyCount() -> Int {
        self.watchReplyCoordinator.queuedCount
    }

    func _test_setGatewayConnected(_ connected: Bool) {
        self.gatewayConnected = connected
    }

    func _test_applyPendingForegroundNodeActions(
        _ actions: [(id: String, command: String, paramsJSON: String?)]) async
    {
        let mapped = actions.map { action in
            PendingForegroundNodeAction(
                id: action.id,
                command: action.command,
                paramsJSON: action.paramsJSON,
                enqueuedAtMs: nil)
        }
        await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
    }

    func _test_makeOperatorConnectOptions(
        clientId: String,
        displayName: String?,
        includeApprovalScope: Bool
    ) -> GatewayConnectOptions {
        self.makeOperatorConnectOptions(
            clientId: clientId,
            displayName: displayName,
            includeApprovalScope: includeApprovalScope)
    }

    func _test_presentExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
        self.presentFetchedExecApprovalPrompt(prompt)
    }

    func _test_dismissPendingExecApprovalPrompt() {
        self.dismissPendingExecApprovalPrompt()
    }

    func _test_pendingExecApprovalPrompt() -> ExecApprovalPrompt? {
        self.pendingExecApprovalPrompt
    }

    func _test_recordPendingWatchExecApprovalRecoveryID(_ approvalId: String) {
        self.appendPendingWatchExecApprovalRecoveryID(approvalId)
    }

    func _test_pendingWatchExecApprovalRecoveryIDs() -> [String] {
        self.pendingWatchExecApprovalRecoveryIDs
    }

    func _test_pendingExecApprovalIDsForWatchRecovery() async -> [String] {
        await self.pendingExecApprovalIDsForWatchRecovery()
    }

    nonisolated static func _test_isApprovalNotificationStaleError(_ error: Error) -> Bool {
        self.isApprovalNotificationStaleError(error)
    }

    nonisolated static func _test_isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
        self.isApprovalNotificationUnavailableError(error)
    }

    nonisolated static func _test_shouldUseBackgroundAwareExecApprovalReconnect(
        sourceReason: String,
        isBackgrounded: Bool) -> Bool
    {
        self.shouldUseBackgroundAwareExecApprovalReconnect(
            sourceReason: sourceReason,
            isBackgrounded: isBackgrounded)
    }

    nonisolated static func _test_watchExecApprovalIDsNeedingFetch(
        candidateIDs: [String],
        cachedApprovalIDs: [String]) -> [String]
    {
        self.watchExecApprovalIDsNeedingFetch(
            candidateIDs: candidateIDs,
            cachedApprovalIDs: cachedApprovalIDs)
    }

    nonisolated static func _test_shouldResetWatchExecApprovalResolvingStateOnPrompt(
        reason: String) -> Bool
    {
        self.shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: reason)
    }

    static func _test_makeExecApprovalPrompt(
        id: String,
        commandText: String,
        allowedDecisions: [String],
        host: String?,
        nodeId: String?,
        agentId: String?,
        expiresAtMs: Int?
    ) -> ExecApprovalPrompt? {
        self.makeExecApprovalPrompt(
            from: ExecApprovalGetResponse(
                id: id,
                commandText: commandText,
                commandPreview: nil,
                allowedDecisions: allowedDecisions,
                host: host,
                nodeId: nodeId,
                agentId: agentId,
                expiresAtMs: expiresAtMs))
    }

    static func _test_currentDeepLinkKey() -> String {
        self.expectedDeepLinkKey()
    }

    static func _test_resetPersistedWatchExecApprovalBridgeState() {
        UserDefaults.standard.removeObject(forKey: self.watchExecApprovalBridgeStateKey)
    }

    nonisolated static func _test_shouldStartOperatorGatewayLoop(
        token: String?,
        bootstrapToken: String?,
        password: String?,
        hasStoredOperatorToken: Bool) -> Bool
    {
        self.shouldStartOperatorGatewayLoop(
            token: token,
            bootstrapToken: bootstrapToken,
            password: password,
            hasStoredOperatorToken: hasStoredOperatorToken)
    }

    nonisolated static func _test_shouldRequestOperatorApprovalScope(
        token: String?,
        password: String?,
        storedOperatorScopes: [String]
    ) -> Bool {
        self.shouldRequestOperatorApprovalScope(
            token: token,
            password: password,
            storedOperatorScopes: storedOperatorScopes)
    }

    nonisolated static func _test_clearingBootstrapToken(
        in config: GatewayConnectConfig?
    ) -> GatewayConnectConfig? {
        self.clearingBootstrapToken(in: config)
    }

    func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
        await self.handleSuccessfulBootstrapGatewayOnboarding(
            url: URL(string: "wss://gateway.example")!,
            stableID: "test-gateway",
            token: nil,
            password: nil,
            nodeOptions: GatewayConnectOptions(
                role: "node",
                scopes: [],
                caps: [],
                commands: [],
                permissions: [:],
                clientId: "openclaw-ios",
                clientMode: "node",
                clientDisplayName: nil),
            sessionBox: nil)
    }

}
#endif
// swiftlint:enable type_body_length file_length

[zur Elbe Produktseite wechseln0.117QuellennavigatorsAnalyse erneut starten2026-04-27]

                                                                                                                                                                                                                                                                                                                                                                                                     


Neuigkeiten

     Aktuelles
     Motto des Tages

Software

     Produkte
     Quellcodebibliothek

Aktivitäten

     Artikel über Sicherheit
     Anleitung zur Aktivierung von SSL

Muße

     Gedichte
     Musik
     Bilder

Jenseits des Üblichen ....
    

Besucherstatistik

Besucherstatistik

Monitoring

Montastic status badge