mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 15:36:07 +00:00
* refactor(apple): share chat gateway requests * fix(ci): sync native app inventory * docs: keep Apple release note in PR body
10771 lines
461 KiB
Swift
10771 lines
461 KiB
Swift
import CoreLocation
|
|
import CryptoKit
|
|
import Observation
|
|
import OpenClawChatUI
|
|
import OpenClawKit
|
|
import OpenClawProtocol
|
|
import os
|
|
import Security
|
|
import SwiftUI
|
|
import UIKit
|
|
import UserNotifications
|
|
|
|
/// Wrap errors without pulling non-Sendable types into async notification paths.
|
|
private struct NotificationCallError: Error {
|
|
let message: String
|
|
}
|
|
|
|
private struct GatewayRelayIdentityResponse: Decodable {
|
|
let deviceId: String
|
|
let publicKey: String
|
|
}
|
|
|
|
private struct WatchChatPreview {
|
|
var items: [OpenClawWatchChatItem]
|
|
var status: OpenClawWatchAppStatus?
|
|
var statusText: String?
|
|
}
|
|
|
|
private struct WatchChatMetadataEnvelope: Decodable {
|
|
struct Metadata: Decodable {
|
|
var id: String?
|
|
}
|
|
|
|
var metadata: Metadata?
|
|
var messageToolMirror: [String: String]?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case metadata = "__openclaw"
|
|
case messageToolMirror = "openclawMessageToolMirror"
|
|
}
|
|
}
|
|
|
|
private struct WatchChatMessageEntry {
|
|
var message: OpenClawChatMessage
|
|
var text: String
|
|
var serverId: String?
|
|
var isMessageToolMirror: Bool
|
|
}
|
|
|
|
private struct ExecApprovalGatewayEventPayload: Decodable {
|
|
var id: String
|
|
}
|
|
|
|
private struct NodeEventRequestPayload: Encodable {
|
|
var event: String
|
|
var payloadJSON: String
|
|
}
|
|
|
|
/// Ensures notification requests return promptly even if the system prompt blocks.
|
|
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>?
|
|
private var resumed = false
|
|
|
|
func setContinuation(_ continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>) {
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
self.continuation = continuation
|
|
}
|
|
|
|
func resume(_ response: Result<T, NotificationCallError>) {
|
|
let cont: CheckedContinuation<Result<T, NotificationCallError>, Never>?
|
|
self.lock.lock()
|
|
if self.resumed {
|
|
self.lock.unlock()
|
|
return
|
|
}
|
|
self.resumed = true
|
|
cont = self.continuation
|
|
self.continuation = nil
|
|
self.lock.unlock()
|
|
cont?.resume(returning: response)
|
|
}
|
|
}
|
|
|
|
private enum IOSDeepLinkAgentPolicy {
|
|
static let maxMessageChars = 20000
|
|
static let maxUnkeyedConfirmChars = 240
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
// swiftlint:disable type_body_length file_length
|
|
final class NodeAppModel {
|
|
private nonisolated static let watchChatPreviewItemLimit = 5
|
|
private nonisolated static let watchMessageMaxImmediateRetryAttempts = 3
|
|
|
|
struct AgentDeepLinkPrompt: Identifiable, Equatable {
|
|
let id: String
|
|
let messagePreview: String
|
|
let urlPreview: String
|
|
let request: AgentDeepLink
|
|
}
|
|
|
|
struct ExecApprovalPrompt: Identifiable, Equatable, Codable {
|
|
let id: String
|
|
let kind: String?
|
|
let gatewayStableID: String
|
|
let commandText: String
|
|
let commandPreview: String?
|
|
let warningText: String?
|
|
let allowedDecisions: [String]
|
|
let host: String?
|
|
let nodeId: String?
|
|
let agentId: String?
|
|
let expiresAtMs: Int64?
|
|
|
|
var allowsAllowOnce: Bool {
|
|
self.allowedDecisions.contains(ApprovalDecision.allowOnce.rawValue)
|
|
}
|
|
|
|
var allowsAllowAlways: Bool {
|
|
self.allowedDecisions.contains(ApprovalDecision.allowAlways.rawValue)
|
|
}
|
|
|
|
var allowsDeny: Bool {
|
|
self.allowedDecisions.contains(ApprovalDecision.deny.rawValue)
|
|
}
|
|
}
|
|
|
|
struct ExecApprovalInboxKey: Hashable, Sendable {
|
|
let approvalID: ExecApprovalIdentifier.Key
|
|
let gatewayID: GatewayStableIdentifier.Key
|
|
}
|
|
|
|
struct ExecApprovalInboxItem: Identifiable, Equatable {
|
|
let id: ExecApprovalInboxKey
|
|
let prompt: ExecApprovalPrompt
|
|
}
|
|
|
|
enum ExecApprovalOutcomeTone: Equatable {
|
|
case success
|
|
case danger
|
|
case warning
|
|
case neutral
|
|
}
|
|
|
|
struct ExecApprovalOutcome: Equatable {
|
|
let text: String
|
|
let tone: ExecApprovalOutcomeTone
|
|
}
|
|
|
|
struct NotificationPermissionGuidancePrompt: Identifiable, Equatable {
|
|
let id = UUID()
|
|
let approvalId: String
|
|
}
|
|
|
|
private struct ExecApprovalPushKey: Hashable {
|
|
let approvalID: ExecApprovalIdentifier.Key
|
|
let gatewayDeviceID: GatewayStableIdentifier.Key?
|
|
}
|
|
|
|
private struct PersistedExecApprovalReadback: Codable, Equatable {
|
|
let approvalId: String
|
|
let gatewayStableID: String
|
|
}
|
|
|
|
private struct PersistedExecApprovalReadbackKey: Hashable {
|
|
let approvalID: ExecApprovalIdentifier.Key
|
|
let gatewayID: GatewayStableIdentifier.Key
|
|
}
|
|
|
|
private struct PersistedExecApprovalUncertainty: Codable, Equatable {
|
|
let approvalId: String
|
|
let gatewayStableID: String
|
|
let message: String
|
|
}
|
|
|
|
private typealias ExecApprovalResolutionKey = ExecApprovalInboxKey
|
|
|
|
private struct ExecApprovalResolutionAttempt: Equatable {
|
|
let key: ExecApprovalResolutionKey
|
|
let token: UUID
|
|
}
|
|
|
|
private struct ExecApprovalResolutionAttemptState {
|
|
let token: UUID
|
|
var writeInFlight: Bool
|
|
}
|
|
|
|
private struct ExecApprovalUncertaintyState {
|
|
let token: UUID
|
|
let message: String
|
|
}
|
|
|
|
private struct ExecApprovalReadbackFence {
|
|
let key: ExecApprovalResolutionKey
|
|
let uncertaintyToken: UUID?
|
|
}
|
|
|
|
private enum ExecApprovalResolutionOutcome {
|
|
case resolved(ExecApprovalTerminalResult, applied: Bool)
|
|
case pendingRetry(message: String)
|
|
case stale
|
|
case uncertain(message: String)
|
|
case failed(message: String)
|
|
}
|
|
|
|
private struct LegacyExecApprovalGetResult: Decodable {
|
|
let id: String
|
|
let commandText: String
|
|
let commandPreview: String?
|
|
let warningText: String?
|
|
let allowedDecisions: [String]
|
|
let host: String?
|
|
let nodeId: String?
|
|
let agentId: String?
|
|
let expiresAtMs: Int64?
|
|
}
|
|
|
|
private enum ExecApprovalRPCFamily: Equatable {
|
|
case unified
|
|
case legacy
|
|
case unavailable
|
|
}
|
|
|
|
private struct ExecApprovalTerminalResult {
|
|
let id: String
|
|
let verdict: ExecApprovalTerminalVerdict
|
|
let resolvedAtMs: Int64
|
|
|
|
var status: String {
|
|
self.verdict.status
|
|
}
|
|
|
|
var decision: String? {
|
|
self.verdict.decision
|
|
}
|
|
}
|
|
|
|
private enum ExecApprovalTerminalVerdict {
|
|
case allowOnce
|
|
case allowAlways
|
|
case deny
|
|
case expired
|
|
case cancelled
|
|
case resolvedUnknown
|
|
|
|
var status: String {
|
|
switch self {
|
|
case .allowOnce, .allowAlways:
|
|
"allowed"
|
|
case .deny:
|
|
"denied"
|
|
case .expired:
|
|
"expired"
|
|
case .cancelled:
|
|
"cancelled"
|
|
case .resolvedUnknown:
|
|
"resolved"
|
|
}
|
|
}
|
|
|
|
var decision: String? {
|
|
switch self {
|
|
case .allowOnce:
|
|
ApprovalDecision.allowOnce.rawValue
|
|
case .allowAlways:
|
|
ApprovalDecision.allowAlways.rawValue
|
|
case .deny:
|
|
ApprovalDecision.deny.rawValue
|
|
case .expired, .cancelled, .resolvedUnknown:
|
|
nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct GatewaySessionRouteContext {
|
|
let route: GatewayNodeSessionRoute
|
|
let gatewayStableID: String
|
|
let routeGeneration: UInt64
|
|
}
|
|
|
|
private struct NodeGatewayLoopContext: Sendable {
|
|
let url: URL
|
|
let stableID: String
|
|
let routeGeneration: UInt64
|
|
let fallbackToken: String?
|
|
let fallbackBootstrapToken: String?
|
|
let fallbackPassword: String?
|
|
let initialOptions: GatewayConnectOptions
|
|
let sessionBox: WebSocketSessionBox?
|
|
}
|
|
|
|
private struct NodeGatewayLoopState: Sendable {
|
|
var attempt = 0
|
|
var options: GatewayConnectOptions
|
|
var didFallbackClientID = false
|
|
}
|
|
|
|
private enum NodeGatewayLoopStep: Sendable {
|
|
case retry(NodeGatewayLoopState)
|
|
case stop
|
|
case stopPreservingStatus
|
|
}
|
|
|
|
private struct APNsRegistrationContext: Sendable {
|
|
let usesRelayTransport: Bool
|
|
let nodeRoute: GatewayNodeSessionRoute
|
|
let token: String
|
|
let gatewayStableID: String
|
|
let topic: String
|
|
}
|
|
|
|
private enum ExecApprovalPushRouteValidation {
|
|
case validated(GatewaySessionRouteContext)
|
|
case unavailable
|
|
case mismatchedOwner
|
|
}
|
|
|
|
private enum WatchMessageSendOutcome {
|
|
case sent
|
|
case retry
|
|
case discard
|
|
}
|
|
|
|
private struct PersistedWatchExecApprovalBridgeState: Codable {
|
|
var approvals: [ExecApprovalPrompt]
|
|
var pendingApprovalReadbacks: [PersistedExecApprovalReadback]?
|
|
var approvalUncertainties: [PersistedExecApprovalUncertainty]?
|
|
var pendingApprovalPushes: [ExecApprovalNotificationPrompt]?
|
|
var pendingResolvedPushes: [ExecApprovalNotificationPrompt]?
|
|
var pendingResolutions: [WatchExecApprovalResolveEvent]?
|
|
}
|
|
|
|
private let deepLinkLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "DeepLink")
|
|
private nonisolated static let agentRequestNodeEventTimeoutSeconds = 8
|
|
private nonisolated static let execApprovalNotificationGuidanceSuppressedKey =
|
|
"notifications.execApprovalGuidance.suppressed"
|
|
private let pushWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PushWake")
|
|
private let pendingActionLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "PendingAction")
|
|
private let locationWakeLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "LocationWake")
|
|
private let watchReplyLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchReply")
|
|
private let watchExecApprovalLogger = Logger(subsystem: "ai.openclawfoundation.app", category: "WatchExecApproval")
|
|
private let execApprovalNotificationLogger = Logger(
|
|
subsystem: "ai.openclawfoundation.app",
|
|
category: "ExecApprovalNotification")
|
|
enum CameraHUDKind {
|
|
case photo
|
|
case recording
|
|
case success
|
|
case error
|
|
}
|
|
|
|
private enum AuxiliaryAudioCapture: Equatable {
|
|
case cameraClip
|
|
case screenRecording
|
|
}
|
|
|
|
var isBackgrounded: Bool = false
|
|
let screen: ScreenController
|
|
private let camera: any CameraServicing
|
|
private let screenRecorder: any ScreenRecordingServicing
|
|
private var watchGatewayConnectionStatus: OpenClawWatchAppStatusCode?
|
|
var gatewayStatusText: String = "Offline" {
|
|
didSet {
|
|
self.watchGatewayConnectionStatus = nil
|
|
}
|
|
}
|
|
|
|
var nodeStatusText: String = "Offline"
|
|
var operatorStatusText: String = "Offline"
|
|
private(set) var isAppleReviewDemoModeEnabled: Bool = false
|
|
private(set) var isScreenshotFixtureModeEnabled: Bool = false
|
|
var isOperatorGatewayConnected: Bool {
|
|
self.operatorConnected
|
|
}
|
|
|
|
private(set) var hasOperatorAdminScope: Bool = false
|
|
|
|
var gatewayServerName: String?
|
|
var gatewayRemoteAddress: String?
|
|
var connectedGatewayID: String?
|
|
var gatewayAutoReconnectEnabled: Bool = true
|
|
// When the gateway requires pairing approval, we pause reconnect churn and show a stable UX.
|
|
// Reconnect loops (both our own and the underlying WebSocket watchdog) can otherwise generate
|
|
// multiple pending requests and cause the onboarding UI to "flip-flop".
|
|
var gatewayPairingPaused: Bool = false
|
|
var gatewayPairingRequestId: String?
|
|
// Bumped on every non-nil assignment, including re-reports of an equal problem;
|
|
// value equality alone cannot tell the UI to re-surface or shake the toast.
|
|
private(set) var gatewayProblemReportCount = 0
|
|
private(set) var lastGatewayProblem: GatewayConnectionProblem? {
|
|
didSet { if self.lastGatewayProblem != nil { self.gatewayProblemReportCount &+= 1 } }
|
|
}
|
|
|
|
// Live connection problems drive retry behavior. lastGatewayProblem may outlive both so the UI
|
|
// keeps the previous failure readable while an explicit reconnect starts a fresh attempt.
|
|
private var nodeGatewayProblem: GatewayConnectionProblem?
|
|
private var operatorGatewayProblem: GatewayConnectionProblem?
|
|
var gatewayDisplayStatusText: String {
|
|
self.lastGatewayProblem?.localizedStatusText ?? self.gatewayStatusText
|
|
}
|
|
|
|
private var mainSessionBaseKey: String = "main"
|
|
private var gatewaySessionScope: String?
|
|
private var focusedChatSessionKey: String?
|
|
// Two-part unread guard mirroring Android: the opened key survives read
|
|
// confirmations so later unread episodes on the same open chat re-acknowledge;
|
|
// the acknowledged key is the per-episode pending flag.
|
|
@ObservationIgnored private var openedChatSessionKey: String?
|
|
@ObservationIgnored private var readAcknowledgedChatSessionKey: String?
|
|
var selectedAgentId: String?
|
|
var gatewayDefaultAgentId: String?
|
|
var gatewayAgents: [AgentSummary] = []
|
|
var homeCanvasRevision: Int = 0
|
|
var lastShareEventText: String = "No share events yet."
|
|
var openChatRequestID: Int = 0
|
|
var gatewaySetupRequestID: Int = 0
|
|
private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
|
|
private var pendingGatewaySetupLink: GatewayConnectDeepLink?
|
|
private(set) var pendingExecApprovalPrompt: ExecApprovalPrompt?
|
|
private(set) var pendingExecApprovalPromptResolving: Bool = false
|
|
private(set) var pendingExecApprovalPromptErrorText: String?
|
|
// A canonical applied:false winner keeps the prompt visible but freezes its actions.
|
|
private(set) var pendingExecApprovalPromptOutcome: ExecApprovalOutcome?
|
|
var pendingExecApprovalPromptResolvedText: String? {
|
|
self.pendingExecApprovalPromptOutcome?.text
|
|
}
|
|
|
|
var pendingExecApprovalInboxItems: [ExecApprovalInboxItem] {
|
|
self.execApprovalInboxPromptsByKey.compactMap { key, prompt in
|
|
guard !self.terminalExecApprovalKeys.contains(key) else { return nil }
|
|
return ExecApprovalInboxItem(id: key, prompt: prompt)
|
|
}.sorted { lhs, rhs in
|
|
let lhsExpires = lhs.prompt.expiresAtMs ?? Int64.max
|
|
let rhsExpires = rhs.prompt.expiresAtMs ?? Int64.max
|
|
if lhsExpires != rhsExpires {
|
|
return lhsExpires < rhsExpires
|
|
}
|
|
if lhs.id.gatewayID != rhs.id.gatewayID {
|
|
return Self.exactStringSortsBefore(
|
|
lhs.prompt.gatewayStableID,
|
|
rhs.prompt.gatewayStableID)
|
|
}
|
|
return Self.approvalIDSortsBefore(lhs.prompt.id, rhs.prompt.id)
|
|
}
|
|
}
|
|
|
|
var pendingExecApprovalCount: Int {
|
|
self.pendingExecApprovalInboxItems.count
|
|
}
|
|
|
|
/// An uncertain resolution keeps approval actions frozen, but must not trap the
|
|
/// reviewer in a modal or Settings detail while canonical readback is unavailable.
|
|
var pendingExecApprovalPromptCanDismiss: Bool {
|
|
!self.pendingExecApprovalPromptResolving || self.pendingExecApprovalPromptErrorText != nil
|
|
}
|
|
|
|
private var pendingExecApprovalPromptRequestGeneration: Int = 0
|
|
private var pendingExecApprovalPromptSurfaceGeneration: UInt64 = 0
|
|
private(set) var pendingNotificationPermissionGuidancePrompt: NotificationPermissionGuidancePrompt?
|
|
private var queuedAgentDeepLinkPrompt: AgentDeepLinkPrompt?
|
|
private var lastAgentDeepLinkPromptAt: Date = .distantPast
|
|
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
|
|
|
|
/// Primary "node" connection: used for device capabilities and node.invoke requests.
|
|
private let nodeGateway = GatewayNodeSession()
|
|
// Secondary "operator" connection: used for chat/talk/config/voicewake requests.
|
|
private let operatorGateway = GatewayNodeSession()
|
|
private var nodeGatewayTask: Task<Void, Never>?
|
|
private var operatorGatewayTask: Task<Void, Never>?
|
|
@ObservationIgnored private var gatewaySessionResetTask: Task<Void, Never>?
|
|
@ObservationIgnored private var gatewaySessionResetGeneration: UInt64 = 0
|
|
@ObservationIgnored private var gatewayRouteGeneration: UInt64 = 0
|
|
@ObservationIgnored private var operatorTalkConnectionGeneration: UInt64 = 0
|
|
@ObservationIgnored private var credentialHandoffFailureGeneration: UInt64?
|
|
@ObservationIgnored private(set) var gatewayConnectGeneration: UInt64 = 0
|
|
private var forceOperatorTalkPermissionUpgradeRequest = false
|
|
private var lastTalkPermissionReconnectAttemptAt: Date?
|
|
private var voiceWakeSyncTask: Task<Void, Never>?
|
|
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
|
@ObservationIgnored private var cameraHUDOwnerID: String?
|
|
@ObservationIgnored private lazy var capabilityRouter: NodeCapabilityRouter = self.buildCapabilityRouter()
|
|
private let gatewayHealthMonitor = GatewayHealthMonitor()
|
|
private var gatewayHealthMonitorDisabled = false
|
|
private let notificationCenter: NotificationCentering
|
|
let voiceWake = VoiceWakeManager()
|
|
let voiceNoteRecorder: OpenClawVoiceNoteRecorder
|
|
let talkMode: TalkModeManager
|
|
private let locationService: any LocationServicing
|
|
private let deviceStatusService: any DeviceStatusServicing
|
|
private let photosService: any PhotosServicing
|
|
private let contactsService: any ContactsServicing
|
|
private let calendarService: any CalendarServicing
|
|
private let remindersService: any RemindersServicing
|
|
private let motionService: any MotionServicing
|
|
private let healthSummaryService: any HealthSummaryServicing
|
|
private let watchMessagingService: any WatchMessagingServicing
|
|
#if DEBUG
|
|
@ObservationIgnored private var testAgentRequestHandler: ((AgentDeepLink) async throws -> Void)?
|
|
@ObservationIgnored private var testTalkCapturePreparationHandler: (() async -> Void)?
|
|
@ObservationIgnored private var testTalkCaptureStartedHandler: (() async -> Void)?
|
|
@ObservationIgnored private var testChatSessionRoutingRestoreHandler: (() async -> Void)?
|
|
@ObservationIgnored private var testExecApprovalPromptFetchHandler:
|
|
((String, String) async -> ExecApprovalPromptFetchOutcome)?
|
|
@ObservationIgnored private var testExecApprovalResolutionHandler:
|
|
((String, String, String) async -> ExecApprovalResolutionOutcome)?
|
|
@ObservationIgnored private var testExecApprovalResolutionReconcilesUnknownAck = false
|
|
#endif
|
|
private var pttVoiceWakeLeaseCaptureId: String?
|
|
private var talkPttCommandEpoch: UInt64 = 0
|
|
private var talkPreparationInFlight = false
|
|
private var auxiliaryAudioCapture: AuxiliaryAudioCapture?
|
|
private var foregroundCaptureCancellations: [UUID: @MainActor () -> Void] = [:]
|
|
private var talkPreparationWaiters: [(id: UUID, continuation: CheckedContinuation<Bool, Never>)] = []
|
|
private var backgroundTalkKeptActive = false
|
|
private var backgroundedAt: Date?
|
|
private var reconnectAfterBackgroundArmed = false
|
|
private var backgroundGraceTaskID: UIBackgroundTaskIdentifier = .invalid
|
|
@ObservationIgnored private var backgroundGraceTaskTimer: Task<Void, Never>?
|
|
private var backgroundReconnectSuppressed = false
|
|
private var backgroundReconnectLeaseUntil: Date?
|
|
@ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false
|
|
private var lastSignificantLocationWakeAt: Date?
|
|
@ObservationIgnored private let watchMessageOutbox = WatchMessageOutbox()
|
|
@ObservationIgnored private var watchMessageFlushInFlight = false
|
|
@ObservationIgnored private var watchMessageRetryAttempts: [String: Int] = [:]
|
|
@ObservationIgnored private var watchMessageRetryTask: Task<Void, Never>?
|
|
@ObservationIgnored private let appleReviewDemoChatTransport = AppleReviewDemoChatTransport()
|
|
@ObservationIgnored private var chatTranscriptCachesByGatewayID: [String: OpenClawChatSQLiteTranscriptCache] = [:]
|
|
@ObservationIgnored private var chatSessionRoutingRestoreTask: Task<Void, Never>?
|
|
private var watchExecApprovalPromptsByID: [ExecApprovalIdentifier.Key: ExecApprovalPrompt] = [:]
|
|
private var execApprovalInboxPromptsByKey: [ExecApprovalInboxKey: ExecApprovalPrompt] = [:]
|
|
private var dismissedExecApprovalPresentationKeys: Set<ExecApprovalInboxKey> = []
|
|
private var terminalExecApprovalKeys: Set<ExecApprovalInboxKey> = []
|
|
@ObservationIgnored private var terminalExecApprovalKeyOrder: [ExecApprovalInboxKey] = []
|
|
@ObservationIgnored private var resettableWatchResolutionAttempts:
|
|
[ExecApprovalInboxKey: [ExactOpaqueIdentifierKey: String]] = [:]
|
|
private var pendingPersistedExecApprovalReadbacks: [PersistedExecApprovalReadback] = []
|
|
@ObservationIgnored private var activeExecApprovalResolutionAttempts:
|
|
[ExecApprovalResolutionKey: ExecApprovalResolutionAttemptState] = [:]
|
|
@ObservationIgnored private var execApprovalUncertainties:
|
|
[ExecApprovalResolutionKey: ExecApprovalUncertaintyState] = [:]
|
|
@ObservationIgnored private var pendingWatchExecApprovalResolutionFlushInFlight = false
|
|
private var pendingWatchExecApprovalRecoveryPushes: [ExecApprovalNotificationPrompt] = []
|
|
private var pendingExecApprovalResolvedPushes: [ExecApprovalNotificationPrompt] = []
|
|
private var pendingWatchExecApprovalResolutions: [WatchExecApprovalResolveEvent] = []
|
|
private var pendingForegroundActionDrainInFlight = false
|
|
private var pendingForegroundActionDrainRequested = false
|
|
private var completedPendingForegroundActionIDsByGateway: [String: Set<String>] = [:]
|
|
|
|
private var gatewayConnected = false
|
|
private var operatorConnected = false
|
|
private var shareDeliveryChannel: String?
|
|
private var shareDeliveryTo: String?
|
|
private var apnsDeviceTokenHex: String?
|
|
private var apnsLastRegisteredTokenHex: String?
|
|
private var apnsLastRegisteredGatewayStableID: String?
|
|
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
|
|
|
|
var operatorSession: GatewayNodeSession {
|
|
self.operatorGateway
|
|
}
|
|
|
|
var isTalkCaptureActive: Bool {
|
|
// PTT owns its Voice Wake lease before permission and audio setup.
|
|
// Count that pending interval so Chat cannot race another mic owner.
|
|
self.talkPreparationInFlight ||
|
|
self.talkMode.isEnabled ||
|
|
self.talkMode.isPushToTalkActive ||
|
|
self.pttVoiceWakeLeaseCaptureId != nil
|
|
}
|
|
|
|
var localChatFixture: LocalChatFixture? {
|
|
if self.isScreenshotFixtureModeEnabled { return .appScreenshots }
|
|
if self.isAppleReviewDemoModeEnabled { return .appleReviewDemo }
|
|
return nil
|
|
}
|
|
|
|
var isLocalChatFixtureEnabled: Bool {
|
|
self.localChatFixture != nil
|
|
}
|
|
|
|
var isLocalGatewayFixtureEnabled: Bool {
|
|
self.isAppleReviewDemoModeEnabled || self.isScreenshotFixtureModeEnabled
|
|
}
|
|
|
|
var chatTransportModeID: String {
|
|
if self.isScreenshotFixtureModeEnabled { return "screenshots" }
|
|
if self.isAppleReviewDemoModeEnabled { return "apple-review-demo" }
|
|
return self.isOperatorGatewayConnected ? "operator" : "offline"
|
|
}
|
|
|
|
func makeChatTransport(outboxGatewayID: String? = nil) -> any OpenClawChatTransport {
|
|
if self.isScreenshotFixtureModeEnabled {
|
|
return LocalFixtureChatTransport(fixture: .appScreenshots)
|
|
}
|
|
if self.isAppleReviewDemoModeEnabled {
|
|
return AppleReviewDemoChatTransport()
|
|
}
|
|
return IOSGatewayChatTransport(
|
|
gateway: self.operatorSession,
|
|
globalAgentId: self.chatDeliveryAgentId,
|
|
outboxGatewayID: outboxGatewayID)
|
|
}
|
|
|
|
/// Gateway identity the transcript cache is scoped to: the active
|
|
/// connection's stableID, or the keychain-persisted active gateway on
|
|
/// cold open before the gateway session is up. Nil for fixture transports
|
|
/// and unpaired installs so demo or foreign rows can never leak into a
|
|
/// real gateway's transcript.
|
|
var chatTranscriptCacheGatewayID: String? {
|
|
guard !self.isLocalGatewayFixtureEnabled else { return nil }
|
|
let stableID = self.activeGatewayConnectConfig?.effectiveStableID
|
|
?? self.connectedGatewayID
|
|
?? GatewaySettingsStore.activeGatewayEntry()?.stableID
|
|
guard let stableID, !stableID.isEmpty else { return nil }
|
|
return stableID
|
|
}
|
|
|
|
/// Recreation key for the chat view model. Includes the cache gateway
|
|
/// identity: switching paired gateways while the transport mode stays
|
|
/// "operator" must rebuild the view model so transcripts are never read
|
|
/// from or written under another gateway's cache scope.
|
|
var chatViewModelIdentityID: String {
|
|
"\(self.chatTransportModeID)|\(self.chatTranscriptCacheGatewayID ?? "")|\(self.chatTranscriptCacheGeneration)"
|
|
}
|
|
|
|
/// Stable owner key for the long-lived chat view model. Connectivity still
|
|
/// changes `chatViewModelIdentityID` for session-list refreshes, but must
|
|
/// not rebuild Chat and discard an offline draft on the same gateway.
|
|
var chatViewModelOwnerID: String {
|
|
let modeID = self.isLocalGatewayFixtureEnabled ? self.chatTransportModeID : "gateway"
|
|
return "\(modeID)|\(self.chatTranscriptCacheGatewayID ?? "")|\(self.chatTranscriptCacheGeneration)"
|
|
}
|
|
|
|
private var chatTranscriptCacheGeneration = 0
|
|
|
|
/// Offline transcript cache plus durable command outbox, both scoped to
|
|
/// the paired gateway identity (one SQLite file per gateway, memoized so
|
|
/// retire/purge can close every open handle). Nil for fixture/unpaired
|
|
/// transports: no cache and no outbox.
|
|
func makeChatOfflineStore() -> OpenClawChatSQLiteTranscriptCache? {
|
|
guard let gatewayID = self.chatTranscriptCacheGatewayID else { return nil }
|
|
if let cache = self.chatTranscriptCachesByGatewayID[gatewayID] {
|
|
return cache
|
|
}
|
|
guard let databaseURL = Self.chatTranscriptCacheDatabaseURL(gatewayID: gatewayID) else { return nil }
|
|
let cache = OpenClawChatSQLiteTranscriptCache(databaseURL: databaseURL, gatewayID: gatewayID)
|
|
self.chatTranscriptCachesByGatewayID[gatewayID] = cache
|
|
return cache
|
|
}
|
|
|
|
var hasVerifiedChatOfflineRoutingIdentity: Bool {
|
|
self.chatTranscriptCacheGatewayID != nil &&
|
|
self.chatDeliveryAgentId != nil &&
|
|
self.chatSessionRoutingContract != nil
|
|
}
|
|
|
|
func restoreChatSessionRoutingIdentityIfNeeded() async {
|
|
guard !self.isLocalGatewayFixtureEnabled,
|
|
self.chatSessionRoutingContract == nil,
|
|
let store = self.makeChatOfflineStore()
|
|
else { return }
|
|
let identity = await store.loadSessionRoutingIdentity()
|
|
#if DEBUG
|
|
if let testChatSessionRoutingRestoreHandler {
|
|
await testChatSessionRoutingRestoreHandler()
|
|
}
|
|
#endif
|
|
guard !Task.isCancelled,
|
|
let identity,
|
|
self.chatTranscriptCacheGatewayID == store.gatewayID,
|
|
self.chatSessionRoutingContract == nil
|
|
else { return }
|
|
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: store.gatewayID)
|
|
self.gatewaySessionScope = identity.scope
|
|
self.mainSessionBaseKey = identity.mainSessionKey
|
|
self.gatewayDefaultAgentId = identity.defaultAgentID
|
|
self.synchronizeTalkSessionKey()
|
|
self.homeCanvasRevision &+= 1
|
|
}
|
|
|
|
func loadCachedChatSessions() async -> [OpenClawChatSessionEntry] {
|
|
guard let cache = self.makeChatOfflineStore() else { return [] }
|
|
return await cache.loadSessions()
|
|
}
|
|
|
|
func storeCachedChatSessions(_ sessions: [OpenClawChatSessionEntry]) async {
|
|
guard let cache = self.makeChatOfflineStore() else { return }
|
|
await cache.storeSessions(sessions)
|
|
}
|
|
|
|
/// Delete one gateway's cache during bootstrap replacement, or the whole
|
|
/// disposable database during a full onboarding reset. The offline command
|
|
/// outbox shares each gateway's database file, so purging a cache also
|
|
/// drops that gateway's queued commands.
|
|
func purgeChatTranscriptCache(gatewayID: String? = nil) async {
|
|
if let gatewayID, !gatewayID.isEmpty {
|
|
guard let databaseURL = Self.chatTranscriptCacheDatabaseURL(gatewayID: gatewayID) else { return }
|
|
if let cache = self.chatTranscriptCachesByGatewayID[gatewayID] {
|
|
await cache.retire()
|
|
}
|
|
OpenClawChatSQLiteTranscriptCache.removeDatabaseFiles(at: databaseURL)
|
|
self.chatTranscriptCachesByGatewayID.removeValue(forKey: gatewayID)
|
|
self.chatTranscriptCacheGeneration &+= 1
|
|
return
|
|
}
|
|
|
|
// Full reset retires every open handle before removing SQLite sidecars,
|
|
// so deleted transcript bytes cannot survive in WAL or journal pages.
|
|
for cache in self.chatTranscriptCachesByGatewayID.values {
|
|
await cache.retire()
|
|
}
|
|
if let directoryURL = Self.chatTranscriptCacheDirectoryURL() {
|
|
try? FileManager.default.removeItem(at: directoryURL)
|
|
}
|
|
self.chatTranscriptCachesByGatewayID.removeAll()
|
|
self.chatTranscriptCacheGeneration &+= 1
|
|
}
|
|
|
|
/// Debug launch reset runs before Chat can create a cache actor, so direct
|
|
/// file removal preserves the launch flag's synchronous startup contract.
|
|
func purgeChatTranscriptCacheBeforeStartup() {
|
|
guard let directoryURL = Self.chatTranscriptCacheDirectoryURL() else { return }
|
|
try? FileManager.default.removeItem(at: directoryURL)
|
|
self.chatTranscriptCachesByGatewayID.removeAll()
|
|
self.chatTranscriptCacheGeneration &+= 1
|
|
}
|
|
|
|
private static func chatTranscriptCacheDirectoryURL() -> URL? {
|
|
try? OpenClawNodeStorage.appSupportDir()
|
|
.appendingPathComponent("chat-cache", isDirectory: true)
|
|
}
|
|
|
|
static func chatTranscriptCacheDatabaseURL(gatewayID: String) -> URL? {
|
|
let digest = SHA256.hash(data: Data(gatewayID.utf8))
|
|
.map { String(format: "%02x", $0) }
|
|
.joined()
|
|
return Self.chatTranscriptCacheDirectoryURL()?
|
|
.appendingPathComponent("\(digest).sqlite", isDirectory: false)
|
|
}
|
|
|
|
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
|
|
|
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
|
|
private static let backgroundAliveLastSuccessAtMsKey = "gateway.backgroundAlive.lastSuccessAtMs"
|
|
private static let backgroundAliveLastTriggerKey = "gateway.backgroundAlive.lastTrigger"
|
|
private static let foregroundResumeHealthTimeoutSeconds = 1
|
|
private static let watchChatCompletionWaitMs = 75000
|
|
private static let watchChatRunWaitSliceMs = 60000
|
|
|
|
var cameraHUDText: String?
|
|
var cameraHUDKind: CameraHUDKind?
|
|
var cameraFlashNonce: Int = 0
|
|
var screenRecordActive: Bool = false
|
|
private(set) var watchMessagingStatus = WatchMessagingStatus(
|
|
supported: false,
|
|
paired: false,
|
|
appInstalled: false,
|
|
reachable: false,
|
|
activationState: "notActivated")
|
|
|
|
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(),
|
|
healthSummaryService: any HealthSummaryServicing = HealthSummaryService(),
|
|
watchMessagingService: any WatchMessagingServicing = WatchMessagingService(),
|
|
talkMode: TalkModeManager = TalkModeManager(),
|
|
voiceNoteRecorder: OpenClawVoiceNoteRecorder = OpenClawVoiceNoteRecorder(),
|
|
audioAdmissionInitiallyAllowed: Bool = true)
|
|
{
|
|
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.healthSummaryService = healthSummaryService
|
|
self.watchMessagingService = watchMessagingService
|
|
self.talkMode = talkMode
|
|
self.voiceNoteRecorder = voiceNoteRecorder
|
|
if !audioAdmissionInitiallyAllowed {
|
|
// The production scene has not reported its initial phase yet. Keep
|
|
// every microphone owner closed until SwiftUI explicitly admits it.
|
|
self.isBackgrounded = true
|
|
self.voiceWake.setSuppressedForBackground(true)
|
|
self.talkMode.suspendForBackground()
|
|
}
|
|
self.talkMode.setPushToTalkAudioOwnershipEndHandler { [weak self] captureId in
|
|
self?.releasePttVoiceWakeLease(for: captureId)
|
|
}
|
|
self.voiceNoteRecorder.setCaptureAdmissionHandler { [weak self] in
|
|
self?.isBackgrounded == false &&
|
|
self?.isTalkCaptureActive == false &&
|
|
self?.auxiliaryAudioCapture == nil
|
|
}
|
|
self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey)
|
|
restorePersistedWatchExecApprovalBridgeState()
|
|
GatewayDiagnostics.bootstrap()
|
|
GatewayDiagnostics.log("node app model: init start")
|
|
self.watchMessagingService.setStatusHandler { [weak self] status in
|
|
Task { @MainActor in
|
|
GatewayDiagnostics.log(
|
|
"node app model: watch status callback "
|
|
+ "reachable=\(status.reachable) activation=\(status.activationState) "
|
|
+ "backgrounded=\(self?.isBackgrounded ?? false)")
|
|
await self?.handleWatchMessagingStatusChanged(status)
|
|
}
|
|
}
|
|
self.watchMessagingService.setReplyHandler { [weak self] event in
|
|
Task { @MainActor in
|
|
await self?.handleWatchQuickReply(event)
|
|
}
|
|
}
|
|
self.watchMessagingService.setExecApprovalResolveHandler { [weak self] event in
|
|
Task { @MainActor in
|
|
_ = await self?.handleWatchExecApprovalResolve(event)
|
|
}
|
|
}
|
|
self.watchMessagingService.setExecApprovalSnapshotRequestHandler { [weak self] event in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
GatewayDiagnostics.log(
|
|
"node app model: watch snapshot request id=\(event.requestId) backgrounded=\(self.isBackgrounded)")
|
|
// A correlated reply is an acknowledgment of canonical readback, not
|
|
// merely receipt. Always reconcile before echoing the Watch request.
|
|
await self.refreshWatchExecApprovalSnapshotOnDemand(
|
|
reason: "watch_request",
|
|
requestId: event.requestId,
|
|
requestGatewayStableID: event.gatewayStableID,
|
|
heldApprovals: event.heldApprovals)
|
|
}
|
|
}
|
|
self.watchMessagingService.setAppSnapshotRequestHandler { [weak self] event in
|
|
Task { @MainActor in
|
|
guard let self else { return }
|
|
GatewayDiagnostics.log(
|
|
"node app model: watch app snapshot request id=\(event.requestId)")
|
|
await self.syncWatchAppSnapshot(reason: "watch_app_request", includeChat: true)
|
|
}
|
|
}
|
|
self.watchMessagingService.setAppCommandHandler { [weak self] event in
|
|
Task { @MainActor in
|
|
await self?.handleWatchAppCommand(event)
|
|
}
|
|
}
|
|
|
|
self.voiceWake.configure { [weak self] cmd in
|
|
guard let self else { return }
|
|
let sessionKey = await MainActor.run { self.mainSessionKey }
|
|
do {
|
|
try await self.sendVoiceTranscript(text: cmd, sessionKey: sessionKey)
|
|
} catch {
|
|
// Best-effort only.
|
|
}
|
|
}
|
|
self.voiceNoteRecorder.onRecordingActiveChanged = { [weak self] isActive in
|
|
self?.voiceWake.setSuppressedByVoiceNote(isActive)
|
|
}
|
|
|
|
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
|
|
self.voiceWake.setEnabled(enabled)
|
|
self.talkMode.attachGateway(self.operatorGateway)
|
|
refreshOperatorAdminScopeFromStore()
|
|
refreshLastShareEventFromRelay()
|
|
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
|
|
self.setTalkEnabled(talkEnabled)
|
|
self.locationService.setAuthorizationChangeHandler { [weak self] status in
|
|
guard let self else { return }
|
|
self.reconcileSignificantLocationMonitoring(
|
|
mode: self.locationMode(),
|
|
authorizationStatus: status)
|
|
}
|
|
|
|
// Wire up deep links from canvas taps
|
|
self.screen.onDeepLink = { [weak self] url in
|
|
guard let self else { return }
|
|
Task { @MainActor in
|
|
await self.handleDeepLink(url: url)
|
|
}
|
|
}
|
|
|
|
// Wire up A2UI action clicks (buttons, etc.)
|
|
self.screen.onA2UIAction = { [weak self] body in
|
|
guard let self else { return }
|
|
Task { @MainActor in
|
|
await self.handleCanvasA2UIAction(body: body)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleCanvasA2UIAction(body: [String: Any]) async {
|
|
let userActionAny = body["userAction"] ?? body
|
|
let userAction: [String: Any] = {
|
|
if let dict = userActionAny as? [String: Any] { return dict }
|
|
if let dict = userActionAny as? [AnyHashable: Any] {
|
|
return dict.reduce(into: [String: Any]()) { acc, pair in
|
|
guard let key = pair.key as? String else { return }
|
|
acc[key] = pair.value
|
|
}
|
|
}
|
|
return [:]
|
|
}()
|
|
guard !userAction.isEmpty else { return }
|
|
|
|
guard let name = OpenClawCanvasA2UIAction.extractActionName(userAction) else { return }
|
|
let actionId: String = {
|
|
let id = (userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return id.isEmpty ? UUID().uuidString : id
|
|
}()
|
|
|
|
let surfaceId: String = {
|
|
let raw = (userAction["surfaceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return raw.isEmpty ? "main" : raw
|
|
}()
|
|
let sourceComponentId: String = {
|
|
let raw = (userAction[
|
|
"sourceComponentId",
|
|
] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return raw.isEmpty ? "-" : raw
|
|
}()
|
|
|
|
let host = NodeDisplayName.resolve(
|
|
existing: UserDefaults.standard.string(forKey: "node.displayName"),
|
|
deviceName: UIDevice.current.name,
|
|
interfaceIdiom: UIDevice.current.userInterfaceIdiom)
|
|
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
|
|
let contextJSON = OpenClawCanvasA2UIAction.compactJSON(userAction["context"])
|
|
let sessionKey = mainSessionKey
|
|
|
|
let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext(
|
|
actionName: name,
|
|
session: .init(key: sessionKey, surfaceId: surfaceId),
|
|
component: .init(id: sourceComponentId, host: host, instanceId: instanceId),
|
|
contextJSON: contextJSON)
|
|
let message = OpenClawCanvasA2UIAction.formatAgentMessage(messageContext)
|
|
|
|
let ok: Bool
|
|
var errorText: String?
|
|
if await !isGatewayConnected() {
|
|
ok = false
|
|
errorText = "gateway not connected"
|
|
} else {
|
|
do {
|
|
try await sendAgentRequest(link: AgentDeepLink(
|
|
message: message,
|
|
sessionKey: sessionKey,
|
|
thinking: "low",
|
|
deliver: false,
|
|
to: nil,
|
|
channel: nil,
|
|
timeoutSeconds: nil,
|
|
key: actionId))
|
|
ok = true
|
|
} catch {
|
|
ok = false
|
|
errorText = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
let js = OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(actionId: actionId, ok: ok, error: errorText)
|
|
do {
|
|
_ = try await self.screen.eval(javaScript: js)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
func setScenePhase(_ phase: ScenePhase) {
|
|
let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled")
|
|
GatewayDiagnostics.log("node app model: scene phase=\(String(describing: phase))")
|
|
switch phase {
|
|
case .background:
|
|
self.isBackgrounded = true
|
|
// This durable reason outlives asynchronous PTT/Talk teardown. A
|
|
// late lease release cannot reopen Voice Wake while backgrounded.
|
|
self.voiceWake.setSuppressedForBackground(true)
|
|
// Captures remain owners until cancellation unwinds. Their defers
|
|
// then clear tracking and any auxiliary-audio suppression they own.
|
|
for cancel in self.foregroundCaptureCancellations.values {
|
|
cancel()
|
|
}
|
|
self.stopGatewayHealthMonitor()
|
|
self.backgroundedAt = Date()
|
|
self.reconnectAfterBackgroundArmed = true
|
|
self.beginBackgroundConnectionGracePeriod()
|
|
if self.voiceNoteRecorder.isRecording || self.voiceNoteRecorder.isRequestingPermission {
|
|
// Cancel first: releasing the voice-note suppression reason can
|
|
// schedule Voice Wake, which the background suspension must catch.
|
|
self.voiceNoteRecorder.cancel()
|
|
}
|
|
// Invalidate queued or permission-suspended PTT starts before releasing
|
|
// Talk. Its capture-end callback can otherwise restart Voice Wake after
|
|
// the background suspension has already run.
|
|
self.talkPttCommandEpoch &+= 1
|
|
let shouldKeepTalkActive = keepTalkActive && self.talkMode.canKeepContinuousTalkActiveInBackground
|
|
self.backgroundTalkKeptActive = shouldKeepTalkActive
|
|
self.talkMode.suspendForBackground(keepActive: shouldKeepTalkActive)
|
|
case .inactive:
|
|
// Background -> inactive is not foreground admission. iOS passes
|
|
// through this phase before active; keep microphone gates closed.
|
|
break
|
|
case .active:
|
|
self.isBackgrounded = false
|
|
self.endBackgroundConnectionGracePeriod(reason: "scene_foreground")
|
|
self.clearBackgroundReconnectSuppression(reason: "scene_foreground")
|
|
var shouldStartGatewayHealthMonitor = self.operatorConnected
|
|
self.voiceWake.setSuppressedForBackground(false)
|
|
let keptActive = self.backgroundTalkKeptActive
|
|
self.backgroundTalkKeptActive = false
|
|
self.talkMode.resumeAfterBackground(wasKeptActive: keptActive)
|
|
Task { [weak self] in
|
|
await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "scene_active")
|
|
}
|
|
if self.reconnectAfterBackgroundArmed {
|
|
self.reconnectAfterBackgroundArmed = false
|
|
let backgroundedFor = self.backgroundedAt.map { Date().timeIntervalSince($0) } ?? 0
|
|
self.backgroundedAt = nil
|
|
// iOS may suspend network sockets in background without a clean close.
|
|
// On foreground, force a fresh handshake to avoid "connected but dead" states.
|
|
if backgroundedFor >= 3.0 {
|
|
shouldStartGatewayHealthMonitor = false
|
|
self.foregroundGatewayResumeCheckInFlight = true
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
let operatorWasConnected = await MainActor.run { self.operatorConnected }
|
|
if operatorWasConnected {
|
|
// Prefer keeping the connection if it's healthy; reconnect only when needed.
|
|
let healthy = await (try? self.operatorGateway.request(
|
|
method: "health",
|
|
paramsJSON: nil,
|
|
timeoutSeconds: Self.foregroundResumeHealthTimeoutSeconds)) != nil
|
|
if healthy {
|
|
await MainActor.run {
|
|
self.foregroundGatewayResumeCheckInFlight = false
|
|
self.startGatewayHealthMonitor()
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
await MainActor.run {
|
|
self.foregroundGatewayResumeCheckInFlight = false
|
|
}
|
|
await self.restartGatewaySessionsAfterForegroundStaleConnection()
|
|
}
|
|
}
|
|
}
|
|
if shouldStartGatewayHealthMonitor {
|
|
self.startGatewayHealthMonitor()
|
|
}
|
|
@unknown default:
|
|
self.isBackgrounded = false
|
|
self.endBackgroundConnectionGracePeriod(reason: "scene_unknown")
|
|
self.clearBackgroundReconnectSuppression(reason: "scene_unknown")
|
|
}
|
|
}
|
|
|
|
private func beginBackgroundConnectionGracePeriod(seconds: TimeInterval = 25) {
|
|
self.grantBackgroundReconnectLease(seconds: seconds, reason: "scene_background_grace")
|
|
self.endBackgroundConnectionGracePeriod(reason: "restart")
|
|
let taskID = UIApplication.shared.beginBackgroundTask(withName: "gateway-background-grace") { [weak self] in
|
|
Task { @MainActor in
|
|
self?.suppressBackgroundReconnect(
|
|
reason: "background_grace_expired",
|
|
disconnectIfNeeded: true)
|
|
self?.endBackgroundConnectionGracePeriod(reason: "expired")
|
|
}
|
|
}
|
|
guard taskID != .invalid else {
|
|
self.pushWakeLogger.info("Background grace unavailable: beginBackgroundTask returned invalid")
|
|
return
|
|
}
|
|
self.backgroundGraceTaskID = taskID
|
|
self.pushWakeLogger.info("Background grace started seconds=\(seconds, privacy: .public)")
|
|
self.backgroundGraceTaskTimer = Task { [weak self] in
|
|
guard let self else { return }
|
|
try? await Task.sleep(nanoseconds: UInt64(max(1, seconds) * 1_000_000_000))
|
|
await MainActor.run {
|
|
self.suppressBackgroundReconnect(reason: "background_grace_timer", disconnectIfNeeded: true)
|
|
self.endBackgroundConnectionGracePeriod(reason: "timer")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func endBackgroundConnectionGracePeriod(reason: String) {
|
|
self.backgroundGraceTaskTimer?.cancel()
|
|
self.backgroundGraceTaskTimer = nil
|
|
guard self.backgroundGraceTaskID != .invalid else { return }
|
|
UIApplication.shared.endBackgroundTask(self.backgroundGraceTaskID)
|
|
self.backgroundGraceTaskID = .invalid
|
|
self.pushWakeLogger.info("Background grace ended reason=\(reason, privacy: .public)")
|
|
}
|
|
|
|
private func grantBackgroundReconnectLease(seconds: TimeInterval, reason: String) {
|
|
guard self.isBackgrounded else { return }
|
|
let leaseSeconds = max(5, seconds)
|
|
let leaseUntil = Date().addingTimeInterval(leaseSeconds)
|
|
if let existing = backgroundReconnectLeaseUntil, existing > leaseUntil {
|
|
// Keep the longer lease if one is already active.
|
|
} else {
|
|
self.backgroundReconnectLeaseUntil = leaseUntil
|
|
}
|
|
let wasSuppressed = self.backgroundReconnectSuppressed
|
|
self.backgroundReconnectSuppressed = false
|
|
let leaseLogMessage =
|
|
"Background reconnect lease reason=\(reason) "
|
|
+ "seconds=\(leaseSeconds) wasSuppressed=\(wasSuppressed)"
|
|
self.pushWakeLogger.info("\(leaseLogMessage, privacy: .public)")
|
|
}
|
|
|
|
private func suppressBackgroundReconnect(reason: String, disconnectIfNeeded: Bool) {
|
|
guard self.isBackgrounded else { return }
|
|
let hadLease = self.backgroundReconnectLeaseUntil != nil
|
|
let changed = hadLease || !self.backgroundReconnectSuppressed
|
|
self.backgroundReconnectLeaseUntil = nil
|
|
self.backgroundReconnectSuppressed = true
|
|
guard changed else { return }
|
|
let suppressLogMessage =
|
|
"Background reconnect suppressed reason=\(reason) "
|
|
+ "disconnect=\(disconnectIfNeeded)"
|
|
self.pushWakeLogger.info("\(suppressLogMessage, privacy: .public)")
|
|
guard disconnectIfNeeded else { return }
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.operatorGateway.disconnect()
|
|
await self.nodeGateway.disconnect()
|
|
await MainActor.run {
|
|
guard !self.isLocalGatewayFixtureEnabled else { return }
|
|
self.setOperatorConnected(false)
|
|
self.gatewayConnected = false
|
|
self.talkMode.updateGatewayConnected(false)
|
|
if self.isBackgrounded {
|
|
self.gatewayStatusText = "Background idle"
|
|
LiveActivityManager.shared.endActivity(reason: "background_idle")
|
|
self.gatewayServerName = nil
|
|
self.gatewayRemoteAddress = nil
|
|
self.showLocalCanvasOnDisconnect()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func clearBackgroundReconnectSuppression(reason: String) {
|
|
let changed = self.backgroundReconnectSuppressed || self.backgroundReconnectLeaseUntil != nil
|
|
self.backgroundReconnectSuppressed = false
|
|
self.backgroundReconnectLeaseUntil = nil
|
|
guard changed else { return }
|
|
self.pushWakeLogger.info("Background reconnect cleared reason=\(reason, privacy: .public)")
|
|
}
|
|
|
|
func setVoiceWakeEnabled(_ enabled: Bool) {
|
|
self.voiceWake.setEnabled(enabled)
|
|
if enabled {
|
|
// If talk is enabled, voice wake should not grab the mic.
|
|
if self.talkMode.isEnabled {
|
|
self.voiceWake.setSuppressedByTalk(true)
|
|
}
|
|
} else {
|
|
self.voiceWake.setSuppressedByTalk(false)
|
|
}
|
|
}
|
|
|
|
func setTalkEnabled(_ enabled: Bool) {
|
|
if self.isAppleReviewDemoModeEnabled {
|
|
UserDefaults.standard.set(false, forKey: "talk.enabled")
|
|
self.talkMode.setEnabled(false)
|
|
self.talkMode.statusText = "Demo mode only"
|
|
return
|
|
}
|
|
if enabled, self.auxiliaryAudioCapture != nil {
|
|
UserDefaults.standard.set(false, forKey: "talk.enabled")
|
|
self.talkMode.setEnabled(false)
|
|
self.talkMode.statusText = "Finish the active audio capture first"
|
|
return
|
|
}
|
|
UserDefaults.standard.set(enabled, forKey: "talk.enabled")
|
|
if enabled {
|
|
if self.voiceNoteRecorder.isRecording || self.voiceNoteRecorder.isRequestingPermission {
|
|
self.voiceNoteRecorder.cancel()
|
|
}
|
|
// Voice wake holds the microphone continuously; talk mode needs exclusive access for STT.
|
|
// When talk is enabled from the UI, prioritize talk and pause voice wake.
|
|
self.voiceWake.setSuppressedByTalk(true)
|
|
} else {
|
|
self.voiceWake.setSuppressedByTalk(false)
|
|
}
|
|
self.talkMode.setEnabled(enabled)
|
|
Task { [weak self] in
|
|
await self?.pushTalkModeToGateway(
|
|
enabled: enabled,
|
|
phase: enabled ? "enabled" : "disabled")
|
|
}
|
|
}
|
|
|
|
func setTalkProviderSelection(_ rawValue: String) {
|
|
let selection = TalkModeProviderSelection.resolved(rawValue)
|
|
UserDefaults.standard.set(selection.rawValue, forKey: TalkModeProviderSelection.storageKey)
|
|
self.talkMode.applyProviderSelectionChanged()
|
|
}
|
|
|
|
func setTalkRealtimeVoiceSelection(_ rawValue: String) {
|
|
let voice = TalkModeRealtimeVoiceSelection.resolvedOverride(rawValue) ?? ""
|
|
UserDefaults.standard.set(voice, forKey: TalkModeRealtimeVoiceSelection.storageKey)
|
|
self.talkMode.applyProviderSelectionChanged()
|
|
}
|
|
|
|
func requestTalkPermissionUpgrade() {
|
|
guard let config = activeGatewayConnectConfig else {
|
|
self.talkMode.gatewayTalkPermissionState = .requestFailed("Gateway is not connected")
|
|
self.talkMode.statusText = "Gateway not connected"
|
|
return
|
|
}
|
|
GatewayDiagnostics.log("talk permission upgrade requested")
|
|
self.talkMode.gatewayTalkPermissionState = .requestingUpgrade
|
|
self.talkMode.statusText = "Requesting Talk approval"
|
|
self.forceOperatorTalkPermissionUpgradeRequest = true
|
|
self.gatewayAutoReconnectEnabled = true
|
|
self.gatewayPairingPaused = false
|
|
self.gatewayPairingRequestId = nil
|
|
self.lastGatewayProblem = nil
|
|
self.nodeGatewayProblem = nil
|
|
self.operatorGatewayProblem = nil
|
|
self.operatorGatewayTask?.cancel()
|
|
self.operatorGatewayTask = nil
|
|
let sessionBox = config.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.operatorGateway.disconnect()
|
|
await MainActor.run {
|
|
self.startOperatorGatewayLoop(
|
|
url: config.url,
|
|
stableID: config.effectiveStableID,
|
|
token: config.token,
|
|
bootstrapToken: config.bootstrapToken,
|
|
password: config.password,
|
|
nodeOptions: config.nodeOptions,
|
|
sessionBox: sessionBox)
|
|
}
|
|
}
|
|
}
|
|
|
|
func pollTalkPermissionUpgrade() async {
|
|
guard self.talkMode.gatewayTalkPermissionState.isApprovalRequestInProgress else {
|
|
await self.talkMode.reloadConfig()
|
|
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "talk_permission_poll")
|
|
return
|
|
}
|
|
|
|
guard let cfg = activeGatewayConnectConfig else {
|
|
self.talkMode.gatewayTalkPermissionState = .requestFailed("Gateway is not connected")
|
|
self.talkMode.statusText = "Gateway not connected"
|
|
return
|
|
}
|
|
|
|
let now = Date()
|
|
if let lastTalkPermissionReconnectAttemptAt,
|
|
now.timeIntervalSince(lastTalkPermissionReconnectAttemptAt) < 6
|
|
{
|
|
return
|
|
}
|
|
lastTalkPermissionReconnectAttemptAt = now
|
|
|
|
GatewayDiagnostics.log("talk permission approval poll reconnect")
|
|
self.gatewayAutoReconnectEnabled = true
|
|
self.gatewayPairingPaused = false
|
|
self.gatewayPairingRequestId = nil
|
|
ensureOperatorReconnectLoopIfNeeded()
|
|
|
|
if self.operatorGatewayTask == nil {
|
|
let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
|
|
startOperatorGatewayLoop(
|
|
url: cfg.url,
|
|
stableID: cfg.effectiveStableID,
|
|
token: cfg.token,
|
|
bootstrapToken: cfg.bootstrapToken,
|
|
password: cfg.password,
|
|
nodeOptions: cfg.nodeOptions,
|
|
sessionBox: sessionBox)
|
|
}
|
|
|
|
guard await waitForOperatorConnection(timeoutMs: 2500, pollMs: 250) else {
|
|
return
|
|
}
|
|
await self.talkMode.reloadConfig()
|
|
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "talk_permission_poll_connected")
|
|
}
|
|
|
|
func setTalkSpeakerphoneEnabled(_ enabled: Bool) {
|
|
UserDefaults.standard.set(enabled, forKey: TalkDefaults.speakerphoneEnabledKey)
|
|
self.talkMode.applyAudioRoutePreferenceChanged()
|
|
}
|
|
|
|
func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool {
|
|
guard mode != .off else {
|
|
self.reconcileSignificantLocationMonitoring(
|
|
mode: mode,
|
|
authorizationStatus: self.locationService.authorizationStatus())
|
|
return true
|
|
}
|
|
let status = await locationService.ensureAuthorization(mode: mode)
|
|
switch status {
|
|
case .authorizedAlways:
|
|
self.reconcileSignificantLocationMonitoring(mode: mode, authorizationStatus: status)
|
|
return true
|
|
case .authorizedWhenInUse:
|
|
self.reconcileSignificantLocationMonitoring(mode: mode, authorizationStatus: status)
|
|
return true
|
|
default:
|
|
self.reconcileSignificantLocationMonitoring(mode: mode, authorizationStatus: status)
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func reconcileSignificantLocationMonitoring(
|
|
mode: OpenClawLocationMode,
|
|
authorizationStatus: CLAuthorizationStatus)
|
|
{
|
|
guard mode == .always, authorizationStatus == .authorizedAlways else {
|
|
self.locationService.setBackgroundLocationUpdatesEnabled(false)
|
|
self.locationService.stopMonitoringSignificantLocationChanges()
|
|
return
|
|
}
|
|
SignificantLocationMonitor.startIfNeeded(
|
|
locationService: self.locationService,
|
|
locationMode: mode,
|
|
gateway: self.nodeGateway,
|
|
beforeSend: { [weak self] in
|
|
await self?.handleSignificantLocationWakeIfNeeded()
|
|
})
|
|
}
|
|
|
|
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
|
|
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
|
|
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
|
|
|
|
private func refreshBrandingFromGateway(shouldApply: () -> Bool = { true }) async {
|
|
do {
|
|
guard let sourceGatewayID = self.chatTranscriptCacheGatewayID,
|
|
let sourceRoute = await operatorGateway.currentRoute(ifGatewayID: sourceGatewayID)
|
|
else { return }
|
|
let res = try await operatorGateway.request(
|
|
method: "config.get",
|
|
paramsJSON: "{}",
|
|
timeoutSeconds: 8,
|
|
ifCurrentRoute: sourceRoute)
|
|
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
|
guard let config = json["config"] as? [String: Any] else { return }
|
|
let session = config["session"] as? [String: Any]
|
|
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
|
let scope = (session?["scope"] as? String) ?? "per-sender"
|
|
guard shouldApply(), self.chatTranscriptCacheGatewayID == sourceGatewayID else { return }
|
|
await MainActor.run {
|
|
self.mainSessionBaseKey = mainKey
|
|
self.gatewaySessionScope = scope
|
|
self.synchronizeTalkSessionKey()
|
|
self.homeCanvasRevision &+= 1
|
|
}
|
|
} catch {
|
|
if let gatewayError = error as? GatewayResponseError {
|
|
let lower = gatewayError.message.lowercased()
|
|
if lower.contains("unauthorized role") {
|
|
return
|
|
}
|
|
}
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
private func refreshAgentsFromGateway(shouldApply: () -> Bool = { true }) async {
|
|
do {
|
|
guard let sourceGatewayID = self.chatTranscriptCacheGatewayID,
|
|
let sourceStore = self.makeChatOfflineStore(),
|
|
sourceStore.gatewayID == sourceGatewayID,
|
|
let sourceRoute = await operatorGateway.currentRoute(ifGatewayID: sourceGatewayID)
|
|
else { return }
|
|
let request = OpenClawChatGatewayRequests.agentsList(timeoutMs: 8000)
|
|
let res = try await operatorGateway.request(
|
|
request,
|
|
ifCurrentRoute: sourceRoute)
|
|
let decoded = try JSONDecoder().decode(AgentsListResult.self, from: res)
|
|
let routingIdentity = OpenClawChatSessionRoutingIdentity(
|
|
scope: decoded.scope.value as? String,
|
|
mainSessionKey: decoded.mainkey,
|
|
defaultAgentID: decoded.defaultid)
|
|
guard shouldApply(), self.chatTranscriptCacheGatewayID == sourceGatewayID else { return }
|
|
await MainActor.run {
|
|
self.gatewayDefaultAgentId = decoded.defaultid
|
|
self.gatewayAgents = decoded.agents
|
|
self.gatewaySessionScope = decoded.scope.value as? String
|
|
self.applyMainSessionKey(decoded.mainkey)
|
|
|
|
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !selected.isEmpty, !decoded.agents.contains(where: { $0.id == selected }) {
|
|
self.selectedAgentId = nil
|
|
self.focusedChatSessionKey = nil
|
|
}
|
|
self.synchronizeTalkSessionKey()
|
|
self.homeCanvasRevision &+= 1
|
|
}
|
|
if let routingIdentity {
|
|
await sourceStore.storeSessionRoutingIdentity(routingIdentity)
|
|
}
|
|
} catch {
|
|
// Best-effort only.
|
|
}
|
|
}
|
|
|
|
func refreshGatewayOverviewIfConnected() async {
|
|
guard await isOperatorConnected() else { return }
|
|
if self.foregroundGatewayResumeCheckInFlight {
|
|
GatewayDiagnostics.log("gateway overview refresh deferred reason=foreground_resume_check")
|
|
try? await Task.sleep(
|
|
nanoseconds: UInt64(Self.foregroundResumeHealthTimeoutSeconds) * 1_000_000_000)
|
|
guard await isOperatorConnected(), !self.foregroundGatewayResumeCheckInFlight else { return }
|
|
}
|
|
await self.refreshBrandingFromGateway()
|
|
await self.refreshAgentsFromGateway()
|
|
}
|
|
|
|
func setSelectedAgentId(_ agentId: String?) {
|
|
let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let nextSelectedAgentId = trimmed.isEmpty ? nil : trimmed
|
|
let currentSelectedAgentId = self.selectedAgentId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let selectedAgentChanged = currentSelectedAgentId != nextSelectedAgentId
|
|
let stableID = GatewayStableIdentifier.exact(self.connectedGatewayID)
|
|
if let stableID {
|
|
self.selectedAgentId = nextSelectedAgentId
|
|
GatewaySettingsStore.saveGatewaySelectedAgentId(
|
|
stableID: stableID,
|
|
agentId: self.selectedAgentId)
|
|
} else {
|
|
self.selectedAgentId = nextSelectedAgentId
|
|
}
|
|
if selectedAgentChanged {
|
|
self.focusedChatSessionKey = nil
|
|
}
|
|
self.synchronizeTalkSessionKey()
|
|
self.homeCanvasRevision &+= 1
|
|
if let relay = ShareGatewayRelaySettings.loadConfig() {
|
|
ShareGatewayRelaySettings.saveConfig(
|
|
ShareGatewayRelayConfig(
|
|
gatewayURLString: relay.gatewayURLString,
|
|
gatewayStableID: relay.gatewayStableID,
|
|
token: relay.token,
|
|
password: relay.password,
|
|
sessionKey: mainSessionKey,
|
|
deliveryChannel: self.shareDeliveryChannel,
|
|
deliveryTo: self.shareDeliveryTo))
|
|
}
|
|
}
|
|
|
|
func setGlobalWakeWords(_ words: [String]) async {
|
|
let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words)
|
|
|
|
struct Payload: Codable {
|
|
var triggers: [String]
|
|
}
|
|
let payload = Payload(triggers: sanitized)
|
|
guard let data = try? JSONEncoder().encode(payload),
|
|
let json = String(data: data, encoding: .utf8)
|
|
else { return }
|
|
|
|
do {
|
|
_ = try await self.operatorGateway.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12)
|
|
} catch {
|
|
// Best-effort only.
|
|
}
|
|
}
|
|
|
|
private func startVoiceWakeSync(shouldContinue: @escaping @MainActor @Sendable () -> Bool = { true }) async {
|
|
guard shouldContinue() else { return }
|
|
self.voiceWakeSyncTask?.cancel()
|
|
self.voiceWakeSyncTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
|
|
if !self.isGatewayHealthMonitorDisabled() {
|
|
await self.refreshWakeWordsFromGateway(shouldApply: shouldContinue)
|
|
}
|
|
guard shouldContinue() else { return }
|
|
|
|
guard let operatorRoute = await self.operatorGateway.currentRoute(), shouldContinue() else { return }
|
|
let stream = await self.operatorGateway.subscribeServerEvents(bufferingNewest: 200)
|
|
for await evt in stream {
|
|
if Task.isCancelled || !shouldContinue() { return }
|
|
guard evt.payload != nil else { continue }
|
|
await self.handleOperatorGatewayServerEvent(
|
|
evt,
|
|
expectedOperatorRoute: operatorRoute,
|
|
shouldContinue: shouldContinue)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleOperatorGatewayServerEvent(
|
|
_ evt: EventFrame,
|
|
expectedOperatorRoute: GatewayNodeSessionRoute? = nil,
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
guard shouldContinue(), let payload = evt.payload else { return }
|
|
switch evt.event {
|
|
case "voicewake.changed":
|
|
struct Payload: Decodable { var triggers: [String] }
|
|
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
|
|
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
|
|
VoiceWakePreferences.saveTriggerWords(triggers)
|
|
case "talk.mode":
|
|
struct Payload: Decodable {
|
|
var enabled: Bool
|
|
var phase: String?
|
|
}
|
|
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
|
|
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
|
|
case ExecApprovalNotificationBridge.requestedKind:
|
|
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
|
|
if let gatewayStableID = self.currentExecApprovalGatewayStableID() {
|
|
self.appendPendingPersistedExecApprovalReadback(
|
|
approvalId: approvalId,
|
|
gatewayStableID: gatewayStableID)
|
|
}
|
|
await self.presentNotificationPermissionGuidanceForExecApprovalIfNeeded(
|
|
approvalId: approvalId,
|
|
shouldApply: shouldContinue)
|
|
guard shouldContinue() else { return }
|
|
await presentExecApprovalGatewayEventPrompt(
|
|
approvalId: approvalId,
|
|
expectedOperatorRoute: expectedOperatorRoute,
|
|
shouldContinue: shouldContinue)
|
|
case ExecApprovalNotificationBridge.resolvedKind:
|
|
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
|
|
guard let context = await self.operatorRouteForExecApproval(
|
|
sourceReason: "resolved_event",
|
|
expectedOperatorRoute: expectedOperatorRoute,
|
|
shouldContinue: shouldContinue)
|
|
else {
|
|
self.appendPendingExecApprovalResolvedPush(ExecApprovalNotificationPrompt(
|
|
approvalId: approvalId,
|
|
gatewayDeviceId: nil))
|
|
return
|
|
}
|
|
let reconciled = await handleExecApprovalResolvedForCurrentGateway(
|
|
approvalId: approvalId,
|
|
routeContext: context,
|
|
shouldContinue: shouldContinue)
|
|
if !reconciled, shouldContinue() {
|
|
self.appendPendingExecApprovalResolvedPush(ExecApprovalNotificationPrompt(
|
|
approvalId: approvalId,
|
|
gatewayDeviceId: nil))
|
|
}
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
|
|
private nonisolated static func execApprovalEventID(from payload: AnyCodable) -> String? {
|
|
guard let decoded = try? GatewayPayloadDecoding.decode(
|
|
payload,
|
|
as: ExecApprovalGatewayEventPayload.self)
|
|
else {
|
|
return nil
|
|
}
|
|
return Self.validatedApprovalID(decoded.id)
|
|
}
|
|
|
|
private nonisolated static func validatedApprovalID(_ id: String) -> String? {
|
|
ExecApprovalIdentifier.exact(id)
|
|
}
|
|
|
|
private nonisolated static func execApprovalIDKey(_ id: String) -> ExecApprovalIdentifier.Key? {
|
|
ExecApprovalIdentifier.key(id)
|
|
}
|
|
|
|
private nonisolated static func approvalIDsMatch(_ lhs: String, _ rhs: String) -> Bool {
|
|
ExecApprovalIdentifier.matches(lhs, rhs)
|
|
}
|
|
|
|
private nonisolated static func approvalIDSortsBefore(_ lhs: String, _ rhs: String) -> Bool {
|
|
ExecApprovalIdentifier.sortsBefore(lhs, rhs)
|
|
}
|
|
|
|
private nonisolated static func execApprovalResolutionKey(
|
|
approvalID: String,
|
|
gatewayStableID: String) -> ExecApprovalResolutionKey?
|
|
{
|
|
guard let approvalID = ExecApprovalIdentifier.key(approvalID),
|
|
let gatewayID = GatewayStableIdentifier.key(gatewayStableID)
|
|
else { return nil }
|
|
return ExecApprovalResolutionKey(
|
|
approvalID: approvalID,
|
|
gatewayID: gatewayID)
|
|
}
|
|
|
|
static func execApprovalInboxKey(
|
|
approvalID: String,
|
|
gatewayStableID: String?) -> ExecApprovalInboxKey?
|
|
{
|
|
guard let gatewayStableID else { return nil }
|
|
return self.execApprovalResolutionKey(
|
|
approvalID: approvalID,
|
|
gatewayStableID: gatewayStableID)
|
|
}
|
|
|
|
static func execApprovalInboxKey(_ prompt: ExecApprovalPrompt?) -> ExecApprovalInboxKey? {
|
|
guard let prompt else { return nil }
|
|
return self.execApprovalInboxKey(
|
|
approvalID: prompt.id,
|
|
gatewayStableID: prompt.gatewayStableID)
|
|
}
|
|
|
|
private func beginExecApprovalResolutionAttempt(
|
|
approvalID: String,
|
|
gatewayStableID: String) -> ExecApprovalResolutionAttempt?
|
|
{
|
|
guard let key = Self.execApprovalResolutionKey(
|
|
approvalID: approvalID,
|
|
gatewayStableID: gatewayStableID),
|
|
self.activeExecApprovalResolutionAttempts[key] == nil,
|
|
self.execApprovalUncertainties[key] == nil,
|
|
!self.terminalExecApprovalKeys.contains(key)
|
|
else { return nil }
|
|
let attempt = ExecApprovalResolutionAttempt(key: key, token: UUID())
|
|
self.activeExecApprovalResolutionAttempts[key] = ExecApprovalResolutionAttemptState(
|
|
token: attempt.token,
|
|
writeInFlight: true)
|
|
return attempt
|
|
}
|
|
|
|
private func isActiveExecApprovalResolutionAttempt(
|
|
_ attempt: ExecApprovalResolutionAttempt) -> Bool
|
|
{
|
|
self.activeExecApprovalResolutionAttempts[attempt.key]?.token == attempt.token
|
|
}
|
|
|
|
private func markExecApprovalResolutionWriteSettled(
|
|
_ attempt: ExecApprovalResolutionAttempt)
|
|
{
|
|
guard var state = self.activeExecApprovalResolutionAttempts[attempt.key],
|
|
state.token == attempt.token
|
|
else { return }
|
|
state.writeInFlight = false
|
|
self.activeExecApprovalResolutionAttempts[attempt.key] = state
|
|
}
|
|
|
|
private func finishExecApprovalResolutionAttempt(
|
|
_ attempt: ExecApprovalResolutionAttempt)
|
|
{
|
|
guard self.isActiveExecApprovalResolutionAttempt(attempt) else { return }
|
|
self.activeExecApprovalResolutionAttempts.removeValue(forKey: attempt.key)
|
|
guard !self.pendingWatchExecApprovalResolutions.isEmpty else { return }
|
|
Task { @MainActor [weak self] in
|
|
await Task.yield()
|
|
await self?.flushPendingWatchExecApprovalResolutions()
|
|
}
|
|
}
|
|
|
|
private func markExecApprovalResolutionUncertain(
|
|
approvalID: String,
|
|
gatewayStableID: String,
|
|
message: String)
|
|
{
|
|
guard let key = Self.execApprovalResolutionKey(
|
|
approvalID: approvalID,
|
|
gatewayStableID: gatewayStableID),
|
|
!self.terminalExecApprovalKeys.contains(key)
|
|
else { return }
|
|
// A lost write response is neither pending nor terminal truth. Keep this exact
|
|
// owner frozen across dismissal until approval.get classifies it canonically.
|
|
self.execApprovalUncertainties[key] = ExecApprovalUncertaintyState(
|
|
token: UUID(),
|
|
message: message)
|
|
let readback = PersistedExecApprovalReadback(
|
|
approvalId: approvalID,
|
|
gatewayStableID: gatewayStableID)
|
|
if !self.pendingPersistedExecApprovalReadbacks.contains(where: {
|
|
Self.persistedExecApprovalReadbackKey($0) == PersistedExecApprovalReadbackKey(
|
|
approvalID: key.approvalID,
|
|
gatewayID: key.gatewayID)
|
|
}) {
|
|
self.pendingPersistedExecApprovalReadbacks.append(readback)
|
|
self.pendingPersistedExecApprovalReadbacks.sort(
|
|
by: Self.persistedExecApprovalReadbackSortsBefore)
|
|
}
|
|
self.persistWatchExecApprovalBridgeState()
|
|
guard Self.execApprovalInboxKey(self.pendingExecApprovalPrompt) == key else { return }
|
|
self.pendingExecApprovalPromptResolving = true
|
|
self.pendingExecApprovalPromptErrorText = message
|
|
self.pendingExecApprovalPromptOutcome = nil
|
|
}
|
|
|
|
private func recordCanonicalExecApprovalFetchOutcome(
|
|
_ outcome: ExecApprovalPromptFetchOutcome,
|
|
fence: ExecApprovalReadbackFence?) -> ExecApprovalPromptFetchOutcome
|
|
{
|
|
guard case let .loaded(prompt) = outcome,
|
|
let promptKey = Self.execApprovalInboxKey(prompt),
|
|
let fence,
|
|
promptKey == fence.key,
|
|
let uncertaintyToken = fence.uncertaintyToken,
|
|
self.execApprovalUncertainties[promptKey]?.token == uncertaintyToken
|
|
else { return outcome }
|
|
self.execApprovalUncertainties.removeValue(forKey: promptKey)
|
|
self.pendingPersistedExecApprovalReadbacks.removeAll {
|
|
Self.persistedExecApprovalReadbackKey($0) == PersistedExecApprovalReadbackKey(
|
|
approvalID: promptKey.approvalID,
|
|
gatewayID: promptKey.gatewayID)
|
|
}
|
|
self.persistWatchExecApprovalBridgeState()
|
|
self.schedulePendingWatchExecApprovalResolutionFlush()
|
|
return outcome
|
|
}
|
|
|
|
private func execApprovalReadbackFence(approvalID: String) -> ExecApprovalReadbackFence? {
|
|
guard let gatewayStableID = self.currentExecApprovalGatewayStableID(),
|
|
let key = Self.execApprovalResolutionKey(
|
|
approvalID: approvalID,
|
|
gatewayStableID: gatewayStableID)
|
|
else { return nil }
|
|
return ExecApprovalReadbackFence(
|
|
key: key,
|
|
uncertaintyToken: self.execApprovalUncertainties[key]?.token)
|
|
}
|
|
|
|
private func schedulePendingWatchExecApprovalResolutionFlush() {
|
|
guard !self.pendingWatchExecApprovalResolutions.isEmpty else { return }
|
|
Task { @MainActor [weak self] in
|
|
await Task.yield()
|
|
await self?.flushPendingWatchExecApprovalResolutions()
|
|
}
|
|
}
|
|
|
|
private func isExecApprovalResolutionWriteInFlight(
|
|
approvalID: String,
|
|
gatewayStableID: String) -> Bool
|
|
{
|
|
guard let key = Self.execApprovalResolutionKey(
|
|
approvalID: approvalID,
|
|
gatewayStableID: gatewayStableID)
|
|
else { return false }
|
|
return self.activeExecApprovalResolutionAttempts[key]?.writeInFlight == true
|
|
}
|
|
|
|
/// True while the owner-scoped attempt lease is held, including the readback window
|
|
/// after the write settled but before the outer defer releases the lease. New
|
|
/// resolution attempts are rejected for that whole span, not just while writing.
|
|
private func hasActiveExecApprovalResolutionAttempt(
|
|
approvalID: String,
|
|
gatewayStableID: String) -> Bool
|
|
{
|
|
guard let key = Self.execApprovalResolutionKey(
|
|
approvalID: approvalID,
|
|
gatewayStableID: gatewayStableID)
|
|
else { return false }
|
|
return self.activeExecApprovalResolutionAttempts[key] != nil
|
|
}
|
|
|
|
private func markWatchResolutionAttemptResettable(
|
|
_ event: WatchExecApprovalResolveEvent)
|
|
{
|
|
guard let key = Self.execApprovalInboxKey(
|
|
approvalID: event.approvalId,
|
|
gatewayStableID: event.gatewayStableID),
|
|
let attemptKey = ExactOpaqueIdentifier.key(event.replyId)
|
|
else { return }
|
|
var attempts = self.resettableWatchResolutionAttempts[key] ?? [:]
|
|
attempts[attemptKey] = event.replyId
|
|
if attempts.count > 8,
|
|
let evictedKey = attempts.keys.min(by: { lhs, rhs in
|
|
Self.exactStringSortsBefore(lhs.rawValue, rhs.rawValue)
|
|
})
|
|
{
|
|
attempts.removeValue(forKey: evictedKey)
|
|
}
|
|
self.resettableWatchResolutionAttempts[key] = attempts
|
|
}
|
|
|
|
private func resettableWatchResolutionAttemptID(
|
|
for prompt: ExecApprovalPrompt,
|
|
heldAttemptID: String?) -> String?
|
|
{
|
|
guard let key = Self.execApprovalInboxKey(prompt),
|
|
self.activeExecApprovalResolutionAttempts[key] == nil,
|
|
let heldAttemptKey = ExactOpaqueIdentifier.key(heldAttemptID),
|
|
let recordedAttemptID = self.resettableWatchResolutionAttempts[key]?[heldAttemptKey]
|
|
else { return nil }
|
|
return recordedAttemptID
|
|
}
|
|
|
|
private nonisolated static func exactStringSortsBefore(_ lhs: String, _ rhs: String) -> Bool {
|
|
Array(lhs.utf8).lexicographicallyPrecedes(Array(rhs.utf8))
|
|
}
|
|
|
|
private nonisolated static func execApprovalPushSortsBefore(
|
|
_ lhs: ExecApprovalNotificationPrompt,
|
|
_ rhs: ExecApprovalNotificationPrompt) -> Bool
|
|
{
|
|
let lhsGatewayID = lhs.gatewayDeviceId ?? ""
|
|
let rhsGatewayID = rhs.gatewayDeviceId ?? ""
|
|
let lhsGatewayBytes = Array(lhsGatewayID.utf8)
|
|
let rhsGatewayBytes = Array(rhsGatewayID.utf8)
|
|
if lhsGatewayBytes != rhsGatewayBytes {
|
|
return lhsGatewayBytes.lexicographicallyPrecedes(rhsGatewayBytes)
|
|
}
|
|
return self.approvalIDSortsBefore(lhs.approvalId, rhs.approvalId)
|
|
}
|
|
|
|
private nonisolated static func execApprovalPushKey(
|
|
_ push: ExecApprovalNotificationPrompt) -> ExecApprovalPushKey?
|
|
{
|
|
guard let approvalID = self.execApprovalIDKey(push.approvalId) else { return nil }
|
|
let gatewayDeviceID: GatewayStableIdentifier.Key?
|
|
if let rawGatewayDeviceID = push.gatewayDeviceId {
|
|
guard let exactGatewayDeviceID = GatewayStableIdentifier.key(rawGatewayDeviceID) else { return nil }
|
|
gatewayDeviceID = exactGatewayDeviceID
|
|
} else {
|
|
gatewayDeviceID = nil
|
|
}
|
|
return ExecApprovalPushKey(
|
|
approvalID: approvalID,
|
|
gatewayDeviceID: gatewayDeviceID)
|
|
}
|
|
|
|
private nonisolated static func persistedExecApprovalReadbackKey(
|
|
_ readback: PersistedExecApprovalReadback) -> PersistedExecApprovalReadbackKey?
|
|
{
|
|
guard let approvalID = ExecApprovalIdentifier.key(readback.approvalId),
|
|
let gatewayID = GatewayStableIdentifier.key(readback.gatewayStableID)
|
|
else { return nil }
|
|
return PersistedExecApprovalReadbackKey(
|
|
approvalID: approvalID,
|
|
gatewayID: gatewayID)
|
|
}
|
|
|
|
private nonisolated static func persistedExecApprovalReadbackSortsBefore(
|
|
_ lhs: PersistedExecApprovalReadback,
|
|
_ rhs: PersistedExecApprovalReadback) -> Bool
|
|
{
|
|
if !GatewayStableIdentifier.matches(lhs.gatewayStableID, rhs.gatewayStableID) {
|
|
return self.exactStringSortsBefore(lhs.gatewayStableID, rhs.gatewayStableID)
|
|
}
|
|
return self.approvalIDSortsBefore(lhs.approvalId, rhs.approvalId)
|
|
}
|
|
|
|
private nonisolated static func persistedExecApprovalUncertaintyKey(
|
|
_ uncertainty: PersistedExecApprovalUncertainty) -> ExecApprovalResolutionKey?
|
|
{
|
|
self.execApprovalResolutionKey(
|
|
approvalID: uncertainty.approvalId,
|
|
gatewayStableID: uncertainty.gatewayStableID)
|
|
}
|
|
|
|
private nonisolated static func persistedExecApprovalUncertaintySortsBefore(
|
|
_ lhs: PersistedExecApprovalUncertainty,
|
|
_ rhs: PersistedExecApprovalUncertainty) -> Bool
|
|
{
|
|
if !GatewayStableIdentifier.matches(lhs.gatewayStableID, rhs.gatewayStableID) {
|
|
return self.exactStringSortsBefore(lhs.gatewayStableID, rhs.gatewayStableID)
|
|
}
|
|
return self.approvalIDSortsBefore(lhs.approvalId, rhs.approvalId)
|
|
}
|
|
|
|
private func applyTalkModeSync(enabled: Bool, phase: String?) {
|
|
_ = phase
|
|
guard self.talkMode.isEnabled != enabled else { return }
|
|
self.setTalkEnabled(enabled)
|
|
}
|
|
|
|
private func pushTalkModeToGateway(enabled: Bool, phase: String?) async {
|
|
guard await isOperatorConnected() else { return }
|
|
struct TalkModePayload: Encodable {
|
|
var enabled: Bool
|
|
var phase: String?
|
|
}
|
|
let payload = TalkModePayload(enabled: enabled, phase: phase)
|
|
guard let data = try? JSONEncoder().encode(payload),
|
|
let json = String(data: data, encoding: .utf8)
|
|
else { return }
|
|
_ = try? await self.operatorGateway.request(
|
|
method: "talk.mode",
|
|
paramsJSON: json,
|
|
timeoutSeconds: 8)
|
|
}
|
|
|
|
private func startGatewayHealthMonitor() {
|
|
self.gatewayHealthMonitorDisabled = false
|
|
self.gatewayHealthMonitor.start(
|
|
check: { [weak self] in
|
|
guard let self else { return false }
|
|
if await MainActor.run(body: { self.isGatewayHealthMonitorDisabled() }) { return true }
|
|
do {
|
|
let data = try await self.operatorGateway.request(
|
|
method: "health",
|
|
paramsJSON: nil,
|
|
timeoutSeconds: 6)
|
|
guard let decoded = try? JSONDecoder().decode(OpenClawGatewayHealthOK.self, from: data) else {
|
|
return false
|
|
}
|
|
return decoded.ok ?? false
|
|
} catch {
|
|
if let gatewayError = error as? GatewayResponseError {
|
|
let lower = gatewayError.message.lowercased()
|
|
if lower.contains("unauthorized role") || lower.contains("missing scope") {
|
|
await self.setGatewayHealthMonitorDisabled(true)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
},
|
|
onFailure: { [weak self] _ in
|
|
guard let self else { return }
|
|
await self.operatorGateway.disconnect()
|
|
await self.nodeGateway.disconnect()
|
|
await MainActor.run {
|
|
guard !self.isLocalGatewayFixtureEnabled else { return }
|
|
self.setOperatorConnected(false)
|
|
self.gatewayConnected = false
|
|
self.setGatewayConnectionProgress(reconnecting: true)
|
|
self.talkMode.updateGatewayConnected(false)
|
|
}
|
|
})
|
|
}
|
|
|
|
private func stopGatewayHealthMonitor() {
|
|
self.gatewayHealthMonitor.stop()
|
|
}
|
|
|
|
private func handleInvoke(
|
|
_ req: BridgeInvokeRequest,
|
|
gatewayStableID: String? = nil) async -> BridgeInvokeResponse
|
|
{
|
|
let command = req.command
|
|
|
|
if self.isBackgrounded, self.isBackgroundRestricted(command) {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(
|
|
code: .backgroundUnavailable,
|
|
message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen/talk commands require foreground"))
|
|
}
|
|
|
|
if command.hasPrefix("camera."), !isCameraEnabled() {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(
|
|
code: .unavailable,
|
|
message: "CAMERA_DISABLED: enable Camera in iOS Settings → Camera → Allow Camera"))
|
|
}
|
|
|
|
do {
|
|
return try await self.capabilityRouter.handle(
|
|
Self.scopedWatchNotificationRequest(req, gatewayStableID: gatewayStableID))
|
|
} catch let error as NodeCapabilityRouter.RouterError {
|
|
switch error {
|
|
case .unknownCommand:
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
|
case .handlerUnavailable:
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .unavailable, message: "node handler unavailable"))
|
|
}
|
|
} catch is CancellationError {
|
|
if command.hasPrefix("camera.") {
|
|
self.clearCameraHUD(ownerID: req.id)
|
|
}
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .unavailable, message: "node invoke cancelled"))
|
|
} catch {
|
|
if command.hasPrefix("camera.") {
|
|
let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
|
self.updateCameraHUD(ownerID: req.id, text: text, kind: .error, autoHideSeconds: 2.2)
|
|
}
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .unavailable, message: error.localizedDescription))
|
|
}
|
|
}
|
|
|
|
private static func scopedWatchNotificationRequest(
|
|
_ req: BridgeInvokeRequest,
|
|
gatewayStableID: String?) -> BridgeInvokeRequest
|
|
{
|
|
guard req.command == OpenClawWatchCommand.notify.rawValue,
|
|
var params = try? decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON)
|
|
else { return req }
|
|
// Gateway identity comes from the installed node route, never the request payload.
|
|
params.gatewayStableID = trimmedOrNil(gatewayStableID)
|
|
guard let paramsJSON = try? encodePayload(params) else { return req }
|
|
return BridgeInvokeRequest(
|
|
type: req.type,
|
|
id: req.id,
|
|
command: req.command,
|
|
paramsJSON: paramsJSON,
|
|
nodeId: req.nodeId)
|
|
}
|
|
|
|
private func isBackgroundRestricted(_ command: String) -> Bool {
|
|
command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.") ||
|
|
command.hasPrefix("talk.")
|
|
}
|
|
|
|
private func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
let mode = locationMode()
|
|
guard mode != .off else {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(
|
|
code: .unavailable,
|
|
message: "LOCATION_DISABLED: enable Location in Settings"))
|
|
}
|
|
if self.isBackgrounded, mode != .always {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(
|
|
code: .backgroundUnavailable,
|
|
message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always"))
|
|
}
|
|
let params = (try? Self.decodeParams(OpenClawLocationGetParams.self, from: req.paramsJSON)) ??
|
|
OpenClawLocationGetParams()
|
|
let desired = params.desiredAccuracy ??
|
|
(isLocationPreciseEnabled() ? .precise : .balanced)
|
|
let status = self.locationService.authorizationStatus()
|
|
if status != .authorizedAlways, status != .authorizedWhenInUse {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(
|
|
code: .unavailable,
|
|
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
|
|
}
|
|
if self.isBackgrounded, status != .authorizedAlways {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(
|
|
code: .unavailable,
|
|
message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access"))
|
|
}
|
|
let location = try await locationService.currentLocation(
|
|
params: params,
|
|
desiredAccuracy: desired,
|
|
maxAgeMs: params.maxAgeMs,
|
|
timeoutMs: params.timeoutMs)
|
|
let isPrecise = self.locationService.accuracyAuthorization() == .fullAccuracy
|
|
let payload = OpenClawLocationPayload(
|
|
lat: location.coordinate.latitude,
|
|
lon: location.coordinate.longitude,
|
|
accuracyMeters: location.horizontalAccuracy,
|
|
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
|
|
speedMps: location.speed >= 0 ? location.speed : nil,
|
|
headingDeg: location.course >= 0 ? location.course : nil,
|
|
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
|
|
isPrecise: isPrecise,
|
|
source: nil)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
}
|
|
|
|
private func handleCanvasInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
switch req.command {
|
|
case OpenClawCanvasCommand.present.rawValue:
|
|
// iOS ignores placement hints; canvas always fills the screen.
|
|
let params = (try? Self.decodeParams(OpenClawCanvasPresentParams.self, from: req.paramsJSON)) ??
|
|
OpenClawCanvasPresentParams()
|
|
let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if url.isEmpty {
|
|
self.screen.presentDefaultCanvas()
|
|
} else {
|
|
self.screen.present(urlString: url)
|
|
}
|
|
return BridgeInvokeResponse(id: req.id, ok: true)
|
|
case OpenClawCanvasCommand.hide.rawValue:
|
|
self.screen.hideCanvas()
|
|
return BridgeInvokeResponse(id: req.id, ok: true)
|
|
case OpenClawCanvasCommand.navigate.rawValue:
|
|
let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON)
|
|
let trimmedURL = params.url.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
self.screen.present(urlString: trimmedURL)
|
|
return BridgeInvokeResponse(id: req.id, ok: true)
|
|
case OpenClawCanvasCommand.evalJS.rawValue:
|
|
let params = try Self.decodeParams(OpenClawCanvasEvalParams.self, from: req.paramsJSON)
|
|
let result = try await screen.eval(javaScript: params.javaScript)
|
|
let payload = try Self.encodePayload(["result": result])
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
case OpenClawCanvasCommand.snapshot.rawValue:
|
|
let params = try? Self.decodeParams(OpenClawCanvasSnapshotParams.self, from: req.paramsJSON)
|
|
let format = params?.format ?? .jpeg
|
|
let maxWidth: CGFloat? = {
|
|
if let raw = params?.maxWidth, raw > 0 { return CGFloat(raw) }
|
|
// Keep default snapshots comfortably below the gateway client's maxPayload.
|
|
// For full-res, clients should explicitly request a larger maxWidth.
|
|
return switch format {
|
|
case .png: 900
|
|
case .jpeg: 1600
|
|
}
|
|
}()
|
|
let base64 = try await screen.snapshotBase64(
|
|
maxWidth: maxWidth,
|
|
format: format,
|
|
quality: params?.quality)
|
|
let payload = try Self.encodePayload([
|
|
"format": format == .jpeg ? "jpeg" : "png",
|
|
"base64": base64,
|
|
])
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
default:
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
|
}
|
|
}
|
|
|
|
private func handleCanvasA2UIInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
let command = req.command
|
|
switch command {
|
|
case OpenClawCanvasA2UICommand.reset.rawValue:
|
|
switch await ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
|
|
case .ready:
|
|
break
|
|
case .hostUnavailable:
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(
|
|
code: .unavailable,
|
|
message: "A2UI_HOST_UNAVAILABLE: bundled A2UI host not reachable"))
|
|
}
|
|
let json = try await screen.eval(javaScript: """
|
|
(() => {
|
|
const host = globalThis.openclawA2UI;
|
|
if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" });
|
|
return JSON.stringify(host.reset());
|
|
})()
|
|
""")
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
|
|
case OpenClawCanvasA2UICommand.push.rawValue, OpenClawCanvasA2UICommand.pushJSONL.rawValue:
|
|
let messages: [OpenClawKit.AnyCodable]
|
|
if command == OpenClawCanvasA2UICommand.pushJSONL.rawValue {
|
|
let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
|
|
messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
|
|
} else {
|
|
do {
|
|
let params = try Self.decodeParams(OpenClawCanvasA2UIPushParams.self, from: req.paramsJSON)
|
|
messages = params.messages
|
|
} catch {
|
|
// Be forgiving: some clients still send JSONL payloads to `canvas.a2ui.push`.
|
|
let params = try Self.decodeParams(OpenClawCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
|
|
messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
|
|
}
|
|
}
|
|
|
|
switch await ensureA2UIReadyWithCapabilityRefresh(timeoutMs: 5000) {
|
|
case .ready:
|
|
break
|
|
case .hostUnavailable:
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(
|
|
code: .unavailable,
|
|
message: "A2UI_HOST_UNAVAILABLE: bundled A2UI host not reachable"))
|
|
}
|
|
|
|
let messagesJSON = try OpenClawCanvasA2UIJSONL.encodeMessagesJSONArray(messages)
|
|
let js = """
|
|
(() => {
|
|
try {
|
|
const host = globalThis.openclawA2UI;
|
|
if (!host) return JSON.stringify({ ok: false, error: "missing openclawA2UI" });
|
|
const messages = \(messagesJSON);
|
|
return JSON.stringify(host.applyMessages(messages));
|
|
} catch (e) {
|
|
return JSON.stringify({ ok: false, error: String(e?.message ?? e) });
|
|
}
|
|
})()
|
|
"""
|
|
let resultJSON = try await screen.eval(javaScript: js)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
|
|
|
default:
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
|
}
|
|
}
|
|
|
|
private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
switch req.command {
|
|
case OpenClawCameraCommand.list.rawValue:
|
|
let devices = await camera.listDevices()
|
|
struct Payload: Codable {
|
|
var devices: [CameraController.CameraDeviceInfo]
|
|
}
|
|
let payload = try Self.encodePayload(Payload(devices: devices))
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
case OpenClawCameraCommand.snap.rawValue:
|
|
showCameraHUD(ownerID: req.id, text: "Taking photo…", kind: .photo)
|
|
triggerCameraFlash()
|
|
let params = (try? Self.decodeParams(OpenClawCameraSnapParams.self, from: req.paramsJSON)) ??
|
|
OpenClawCameraSnapParams()
|
|
let res = try await self.withForegroundCapture {
|
|
try await self.camera.snap(params: params)
|
|
}
|
|
|
|
struct Payload: Codable {
|
|
var format: String
|
|
var base64: String
|
|
var width: Int
|
|
var height: Int
|
|
}
|
|
try Task.checkCancellation()
|
|
let payload = try Self.encodePayload(Payload(
|
|
format: res.format,
|
|
base64: res.base64,
|
|
width: res.width,
|
|
height: res.height))
|
|
try Task.checkCancellation()
|
|
updateCameraHUD(ownerID: req.id, 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 includeAudio = params.includeAudio ?? true
|
|
showCameraHUD(ownerID: req.id, text: "Recording…", kind: .recording)
|
|
let res = try await self.withForegroundCapture(
|
|
audioOwner: includeAudio ? .cameraClip : nil)
|
|
{
|
|
try await self.camera.clip(params: params)
|
|
}
|
|
|
|
struct Payload: Codable {
|
|
var format: String
|
|
var base64: String
|
|
var durationMs: Int
|
|
var hasAudio: Bool
|
|
}
|
|
try Task.checkCancellation()
|
|
let payload = try Self.encodePayload(Payload(
|
|
format: res.format,
|
|
base64: res.base64,
|
|
durationMs: res.durationMs,
|
|
hasAudio: res.hasAudio))
|
|
try Task.checkCancellation()
|
|
updateCameraHUD(ownerID: req.id, 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",
|
|
])
|
|
}
|
|
let includeAudio = params.includeAudio ?? true
|
|
guard !self.screenRecordActive else {
|
|
throw NSError(domain: "Screen", code: 31, userInfo: [
|
|
NSLocalizedDescriptionKey: "SCREEN_CAPTURE_BUSY: screen recording already active",
|
|
])
|
|
}
|
|
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
|
self.screenRecordActive = true
|
|
defer { self.screenRecordActive = false }
|
|
let data = try await self.withForegroundCapture(
|
|
audioOwner: includeAudio ? .screenRecording : nil)
|
|
{
|
|
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) }
|
|
return 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: includeAudio))
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
}
|
|
|
|
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
let params = try Self.decodeParams(OpenClawSystemNotifyParams.self, from: req.paramsJSON)
|
|
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if title.isEmpty, body.isEmpty {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty notification"))
|
|
}
|
|
|
|
let status = await notificationAuthorizationStatus()
|
|
guard Self.isNotificationServingEnabled(status) else {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications"))
|
|
}
|
|
|
|
let addResult = await runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
|
|
let content = UNMutableNotificationContent()
|
|
content.title = title
|
|
content.body = body
|
|
if #available(iOS 15.0, *) {
|
|
switch params.priority ?? .active {
|
|
case .passive:
|
|
content.interruptionLevel = .passive
|
|
case .timeSensitive:
|
|
content.interruptionLevel = .timeSensitive
|
|
case .active:
|
|
content.interruptionLevel = .active
|
|
}
|
|
}
|
|
let soundValue = params.sound?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if let soundValue, ["none", "silent", "off", "false", "0"].contains(soundValue) {
|
|
content.sound = nil
|
|
} else {
|
|
content.sound = .default
|
|
}
|
|
let request = UNNotificationRequest(
|
|
identifier: UUID().uuidString,
|
|
content: content,
|
|
trigger: nil)
|
|
try await notificationCenter.add(request)
|
|
}
|
|
if case let .failure(error) = addResult {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .unavailable, message: "NOTIFICATION_FAILED: \(error.message)"))
|
|
}
|
|
return BridgeInvokeResponse(id: req.id, ok: true)
|
|
}
|
|
|
|
private func handleChatPushInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
let params = try Self.decodeParams(OpenClawChatPushParams.self, from: req.paramsJSON)
|
|
let text = params.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty else {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: empty chat.push text"))
|
|
}
|
|
|
|
let shouldSpeak = params.speak ?? true
|
|
let status = await notificationAuthorizationStatus()
|
|
let notificationsAllowed = Self.isNotificationServingEnabled(status)
|
|
if !notificationsAllowed, !shouldSpeak {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications"))
|
|
}
|
|
|
|
let messageId = UUID().uuidString
|
|
if notificationsAllowed {
|
|
let addResult = await runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
|
|
let content = UNMutableNotificationContent()
|
|
content.title = "OpenClaw"
|
|
content.body = text
|
|
content.sound = .default
|
|
content.userInfo = ["messageId": messageId]
|
|
let request = UNNotificationRequest(
|
|
identifier: messageId,
|
|
content: content,
|
|
trigger: nil)
|
|
try await notificationCenter.add(request)
|
|
}
|
|
if case let .failure(error) = addResult {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .unavailable, message: "NOTIFICATION_FAILED: \(error.message)"))
|
|
}
|
|
}
|
|
|
|
if shouldSpeak {
|
|
let toSpeak = text
|
|
Task { @MainActor in
|
|
try? await TalkSystemSpeechSynthesizer.shared.speak(text: toSpeak)
|
|
}
|
|
}
|
|
|
|
let payload = OpenClawChatPushPayload(messageId: messageId)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
}
|
|
|
|
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
|
|
let result = await runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in
|
|
await notificationCenter.authorizationStatus()
|
|
}
|
|
switch result {
|
|
case let .success(status):
|
|
return status
|
|
case .failure:
|
|
return .denied
|
|
}
|
|
}
|
|
|
|
private static func isNotificationAuthorizationAllowed(
|
|
_ status: NotificationAuthorizationStatus) -> Bool
|
|
{
|
|
switch status {
|
|
case .authorized, .provisional, .ephemeral:
|
|
true
|
|
case .denied, .notDetermined:
|
|
false
|
|
}
|
|
}
|
|
|
|
private static func isNotificationServingEnabled(
|
|
_ status: NotificationAuthorizationStatus) -> Bool
|
|
{
|
|
NotificationServingPreference.isEnabled() && self.isNotificationAuthorizationAllowed(status)
|
|
}
|
|
|
|
private func presentNotificationPermissionGuidanceForExecApprovalIfNeeded(
|
|
approvalId: String,
|
|
shouldApply: @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
guard shouldApply(), !self.execApprovalNotificationGuidanceSuppressed else { return }
|
|
let status = await notificationAuthorizationStatus()
|
|
guard shouldApply(), !Self.isNotificationAuthorizationAllowed(status) else { return }
|
|
self.pendingNotificationPermissionGuidancePrompt =
|
|
NotificationPermissionGuidancePrompt(approvalId: approvalId)
|
|
}
|
|
|
|
var execApprovalNotificationGuidanceSuppressed: Bool {
|
|
UserDefaults.standard.bool(forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
|
|
}
|
|
|
|
func dismissNotificationPermissionGuidancePrompt(suppressFuture: Bool) {
|
|
if suppressFuture {
|
|
UserDefaults.standard.set(true, forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
|
|
}
|
|
self.pendingNotificationPermissionGuidancePrompt = nil
|
|
}
|
|
|
|
func resetExecApprovalNotificationGuidanceSuppression() {
|
|
UserDefaults.standard.removeObject(forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
|
|
}
|
|
|
|
private func runNotificationCall<T: Sendable>(
|
|
timeoutSeconds: Double,
|
|
operation: @escaping @Sendable () async throws -> T) async -> Result<T, NotificationCallError>
|
|
{
|
|
let latch = NotificationInvokeLatch<T>()
|
|
var opTask: Task<Void, Never>?
|
|
var timeoutTask: Task<Void, Never>?
|
|
defer {
|
|
opTask?.cancel()
|
|
timeoutTask?.cancel()
|
|
}
|
|
let clamped = max(0.0, timeoutSeconds)
|
|
return await withCheckedContinuation { (cont: CheckedContinuation<Result<T, NotificationCallError>, Never>) in
|
|
latch.setContinuation(cont)
|
|
opTask = Task { @MainActor in
|
|
do {
|
|
let value = try await operation()
|
|
latch.resume(.success(value))
|
|
} catch {
|
|
latch.resume(.failure(NotificationCallError(message: error.localizedDescription)))
|
|
}
|
|
}
|
|
timeoutTask = Task.detached {
|
|
if clamped > 0 {
|
|
try? await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000))
|
|
}
|
|
latch.resume(.failure(NotificationCallError(message: "notification request timed out")))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleDeviceInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
switch req.command {
|
|
case OpenClawDeviceCommand.status.rawValue:
|
|
let payload = try await deviceStatusService.status()
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
case OpenClawDeviceCommand.info.rawValue:
|
|
let payload = self.deviceStatusService.info()
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
default:
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
|
}
|
|
}
|
|
|
|
private func handlePhotosInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
let params = (try? Self.decodeParams(OpenClawPhotosLatestParams.self, from: req.paramsJSON)) ??
|
|
OpenClawPhotosLatestParams()
|
|
let payload = try await photosService.latest(params: params)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
}
|
|
|
|
private func handleContactsInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
switch req.command {
|
|
case OpenClawContactsCommand.search.rawValue:
|
|
let params = (try? Self.decodeParams(OpenClawContactsSearchParams.self, from: req.paramsJSON)) ??
|
|
OpenClawContactsSearchParams()
|
|
let payload = try await contactsService.search(params: params)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
case OpenClawContactsCommand.add.rawValue:
|
|
let params = try Self.decodeParams(OpenClawContactsAddParams.self, from: req.paramsJSON)
|
|
let payload = try await contactsService.add(params: params)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
default:
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
|
}
|
|
}
|
|
|
|
private func handleCalendarInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
switch req.command {
|
|
case OpenClawCalendarCommand.events.rawValue:
|
|
let params = (try? Self.decodeParams(OpenClawCalendarEventsParams.self, from: req.paramsJSON)) ??
|
|
OpenClawCalendarEventsParams()
|
|
let payload = try await calendarService.events(params: params)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
case OpenClawCalendarCommand.add.rawValue:
|
|
let params = try Self.decodeParams(OpenClawCalendarAddParams.self, from: req.paramsJSON)
|
|
let payload = try await calendarService.add(params: params)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
default:
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
|
}
|
|
}
|
|
|
|
private func handleRemindersInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
switch req.command {
|
|
case OpenClawRemindersCommand.list.rawValue:
|
|
let params = (try? Self.decodeParams(OpenClawRemindersListParams.self, from: req.paramsJSON)) ??
|
|
OpenClawRemindersListParams()
|
|
let payload = try await remindersService.list(params: params)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
case OpenClawRemindersCommand.add.rawValue:
|
|
let params = try Self.decodeParams(OpenClawRemindersAddParams.self, from: req.paramsJSON)
|
|
let payload = try await remindersService.add(params: params)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
default:
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
|
}
|
|
}
|
|
|
|
private func handleMotionInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
switch req.command {
|
|
case OpenClawMotionCommand.activity.rawValue:
|
|
let params = (try? Self.decodeParams(OpenClawMotionActivityParams.self, from: req.paramsJSON)) ??
|
|
OpenClawMotionActivityParams()
|
|
let payload = try await motionService.activities(params: params)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
case OpenClawMotionCommand.pedometer.rawValue:
|
|
let params = (try? Self.decodeParams(OpenClawPedometerParams.self, from: req.paramsJSON)) ??
|
|
OpenClawPedometerParams()
|
|
let payload = try await motionService.pedometer(params: params)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
default:
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
|
}
|
|
}
|
|
|
|
private func handleHealthInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
guard let params = try? Self.decodeParams(OpenClawHealthSummaryParams.self, from: req.paramsJSON) else {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(
|
|
code: .invalidRequest,
|
|
message: "INVALID_REQUEST: period must be today"))
|
|
}
|
|
let payload = try await self.healthSummaryService.summary(params: params)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
}
|
|
|
|
private func handleTalkInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
try Task.checkCancellation()
|
|
switch req.command {
|
|
case OpenClawTalkCommand.pttStart.rawValue:
|
|
let commandEpoch = self.talkPttCommandEpoch
|
|
var reservedCaptureId: String?
|
|
do {
|
|
let payload = try await self.withTalkCapturePreparation(commandEpoch: commandEpoch) {
|
|
try self.rejectTalkCaptureWhileOtherAudioActive()
|
|
return try await self.talkMode.beginPushToTalk(
|
|
canStartCapture: {
|
|
self.talkPttCommandEpoch == commandEpoch && !self.isBackgrounded
|
|
},
|
|
onCaptureReserved: { captureId in
|
|
reservedCaptureId = captureId
|
|
self.acquirePttVoiceWakeLease(for: captureId)
|
|
})
|
|
}
|
|
#if DEBUG
|
|
if let testTalkCaptureStartedHandler {
|
|
await testTalkCaptureStartedHandler()
|
|
}
|
|
#endif
|
|
try self.ensureTalkPttStartCurrent(commandEpoch, captureId: payload.captureId)
|
|
let json = try Self.encodePayload(payload)
|
|
try self.ensureTalkPttStartCurrent(commandEpoch, captureId: payload.captureId)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
} catch {
|
|
if let reservedCaptureId {
|
|
_ = self.talkMode.cancelPushToTalk(captureId: reservedCaptureId)
|
|
}
|
|
throw error
|
|
}
|
|
case OpenClawTalkCommand.pttOnce.rawValue:
|
|
let commandEpoch = self.talkPttCommandEpoch
|
|
var reservedCaptureId: String?
|
|
let start: TalkPushToTalkOnceStart
|
|
do {
|
|
start = try await self.withTalkCapturePreparation(commandEpoch: commandEpoch) {
|
|
try self.rejectTalkCaptureWhileOtherAudioActive()
|
|
return try await self.talkMode.beginPushToTalkOnce(
|
|
canStartCapture: {
|
|
self.talkPttCommandEpoch == commandEpoch && !self.isBackgrounded
|
|
},
|
|
onCaptureReserved: { captureId in
|
|
reservedCaptureId = captureId
|
|
self.acquirePttVoiceWakeLease(for: captureId)
|
|
})
|
|
}
|
|
} catch {
|
|
if let reservedCaptureId {
|
|
_ = self.talkMode.cancelPushToTalk(captureId: reservedCaptureId)
|
|
}
|
|
throw error
|
|
}
|
|
let payload: OpenClawTalkPTTStopPayload = switch start {
|
|
case let .busy(busyPayload):
|
|
busyPayload
|
|
case .started:
|
|
await self.talkMode.awaitPushToTalkOnce(start)
|
|
}
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
case OpenClawTalkCommand.pttStop.rawValue:
|
|
// Interrupt commands invalidate suspended preparation before touching
|
|
// capture state, then bypass the preparation queue entirely.
|
|
self.talkPttCommandEpoch &+= 1
|
|
let payload = self.talkMode.endPushToTalk()
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
case OpenClawTalkCommand.pttCancel.rawValue:
|
|
self.talkPttCommandEpoch &+= 1
|
|
let payload = self.talkMode.cancelPushToTalk()
|
|
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 rejectTalkCaptureWhileOtherAudioActive() throws {
|
|
// Remote PTT bypasses the Chat Talk toggle. Preserve the user's draft;
|
|
// Talk must not reconfigure AVAudioSession while its recorder owns it.
|
|
if self.voiceNoteRecorder.isRecording || self.voiceNoteRecorder.isRequestingPermission {
|
|
throw NSError(domain: "TalkMode", code: 8, userInfo: [
|
|
NSLocalizedDescriptionKey: "Finish or cancel the active voice note before starting push-to-talk.",
|
|
])
|
|
}
|
|
if self.auxiliaryAudioCapture != nil {
|
|
throw NSError(domain: "TalkMode", code: 8, userInfo: [
|
|
NSLocalizedDescriptionKey: "Finish the active audio capture before starting push-to-talk.",
|
|
])
|
|
}
|
|
}
|
|
|
|
private func acquireAuxiliaryAudioCapture(_ owner: AuxiliaryAudioCapture) throws {
|
|
guard self.auxiliaryAudioCapture == nil,
|
|
!self.isBackgrounded,
|
|
!self.isTalkCaptureActive,
|
|
!self.voiceNoteRecorder.isRecording,
|
|
!self.voiceNoteRecorder.isRequestingPermission
|
|
else {
|
|
throw NSError(domain: "AudioCapture", code: 1, userInfo: [
|
|
NSLocalizedDescriptionKey: "Finish the active audio capture before starting another one.",
|
|
])
|
|
}
|
|
self.auxiliaryAudioCapture = owner
|
|
self.voiceWake.setSuppressedForAuxiliaryAudio(true)
|
|
}
|
|
|
|
private func releaseAuxiliaryAudioCapture(_ owner: AuxiliaryAudioCapture) {
|
|
guard self.auxiliaryAudioCapture == owner else { return }
|
|
self.auxiliaryAudioCapture = nil
|
|
self.voiceWake.setSuppressedForAuxiliaryAudio(false)
|
|
}
|
|
|
|
private func withForegroundCapture<T: Sendable>(
|
|
audioOwner: AuxiliaryAudioCapture? = nil,
|
|
operation: @escaping @MainActor () async throws -> T) async throws -> T
|
|
{
|
|
try self.ensureForegroundCaptureAllowed()
|
|
if let audioOwner {
|
|
try self.acquireAuxiliaryAudioCapture(audioOwner)
|
|
}
|
|
let captureTask = Task {
|
|
try Task.checkCancellation()
|
|
let result = try await operation()
|
|
try Task.checkCancellation()
|
|
return result
|
|
}
|
|
let captureId = UUID()
|
|
self.foregroundCaptureCancellations[captureId] = { captureTask.cancel() }
|
|
defer {
|
|
self.foregroundCaptureCancellations.removeValue(forKey: captureId)
|
|
if let audioOwner {
|
|
self.releaseAuxiliaryAudioCapture(audioOwner)
|
|
}
|
|
}
|
|
|
|
return try await withTaskCancellationHandler {
|
|
try await captureTask.value
|
|
} onCancel: {
|
|
captureTask.cancel()
|
|
}
|
|
}
|
|
|
|
private func ensureForegroundCaptureAllowed() throws {
|
|
guard !self.isBackgrounded else {
|
|
throw NSError(domain: "AudioCapture", code: 2, userInfo: [
|
|
NSLocalizedDescriptionKey: "NODE_BACKGROUND_UNAVAILABLE: camera and screen capture require foreground",
|
|
])
|
|
}
|
|
}
|
|
|
|
private func acquirePttVoiceWakeLease(for captureId: String) {
|
|
guard self.pttVoiceWakeLeaseCaptureId != captureId else { return }
|
|
self.pttVoiceWakeLeaseCaptureId = captureId
|
|
// The suppression reason outlives Voice Wake enable/disable toggles,
|
|
// so enabling it mid-capture cannot open a competing audio pipeline.
|
|
self.voiceWake.setSuppressedByPushToTalk(true)
|
|
}
|
|
|
|
private func releasePttVoiceWakeLease(for captureId: String) {
|
|
guard self.pttVoiceWakeLeaseCaptureId == captureId else { return }
|
|
self.pttVoiceWakeLeaseCaptureId = nil
|
|
// Capture identity makes stale stop/cancel cleanup harmless. Resume Voice
|
|
// Wake only after the live capture owner releases its lease.
|
|
self.voiceWake.setSuppressedByPushToTalk(false)
|
|
}
|
|
|
|
private func withTalkCapturePreparation<T>(
|
|
commandEpoch: UInt64,
|
|
operation: () async throws -> T) async throws -> T
|
|
{
|
|
try await self.acquireTalkPreparation()
|
|
defer { self.releaseTalkPreparation() }
|
|
try self.ensureTalkPttCommandCurrent(commandEpoch)
|
|
#if DEBUG
|
|
if let testTalkCapturePreparationHandler {
|
|
await testTalkCapturePreparationHandler()
|
|
}
|
|
#endif
|
|
try self.ensureTalkPttCommandCurrent(commandEpoch)
|
|
return try await operation()
|
|
}
|
|
|
|
private func ensureTalkPttCommandCurrent(_ commandEpoch: UInt64) throws {
|
|
try Task.checkCancellation()
|
|
guard self.talkPttCommandEpoch == commandEpoch, !self.isBackgrounded else {
|
|
throw NSError(domain: "TalkMode", code: 9, userInfo: [
|
|
NSLocalizedDescriptionKey: "PTT_CANCELLED: push-to-talk start was cancelled",
|
|
])
|
|
}
|
|
}
|
|
|
|
private func ensureTalkPttStartCurrent(_ commandEpoch: UInt64, captureId: String) throws {
|
|
try self.ensureTalkPttCommandCurrent(commandEpoch)
|
|
guard self.talkMode.isActivePushToTalkCapture(captureId) else {
|
|
throw NSError(domain: "TalkMode", code: 9, userInfo: [
|
|
NSLocalizedDescriptionKey: "PTT_CANCELLED: push-to-talk start was cancelled",
|
|
])
|
|
}
|
|
}
|
|
|
|
private func acquireTalkPreparation() async throws {
|
|
if !self.talkPreparationInFlight {
|
|
try Task.checkCancellation()
|
|
self.talkPreparationInFlight = true
|
|
return
|
|
}
|
|
let waiterID = UUID()
|
|
let acquired = await withTaskCancellationHandler {
|
|
await withCheckedContinuation { continuation in
|
|
if Task.isCancelled {
|
|
continuation.resume(returning: false)
|
|
return
|
|
}
|
|
self.talkPreparationWaiters.append((id: waiterID, continuation: continuation))
|
|
}
|
|
} onCancel: {
|
|
Task { @MainActor in
|
|
self.cancelTalkPreparationWaiter(id: waiterID)
|
|
}
|
|
}
|
|
guard acquired else {
|
|
try Task.checkCancellation()
|
|
return
|
|
}
|
|
do {
|
|
try Task.checkCancellation()
|
|
} catch {
|
|
self.releaseTalkPreparation()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private func cancelTalkPreparationWaiter(id: UUID) {
|
|
guard let index = self.talkPreparationWaiters.firstIndex(where: { $0.id == id }) else { return }
|
|
self.talkPreparationWaiters.remove(at: index).continuation.resume(returning: false)
|
|
}
|
|
|
|
private func releaseTalkPreparation() {
|
|
guard !self.talkPreparationWaiters.isEmpty else {
|
|
self.talkPreparationInFlight = false
|
|
return
|
|
}
|
|
self.talkPreparationWaiters.removeFirst().continuation.resume(returning: true)
|
|
}
|
|
}
|
|
|
|
extension NodeAppModel {
|
|
/// Central registry for node invoke routing to keep commands in one place.
|
|
private func buildCapabilityRouter() -> NodeCapabilityRouter {
|
|
var handlers: [String: NodeCapabilityRouter.Handler] = [:]
|
|
|
|
func register(_ commands: [String], handler: @escaping NodeCapabilityRouter.Handler) {
|
|
for command in commands {
|
|
handlers[command] = handler
|
|
}
|
|
}
|
|
|
|
register([OpenClawLocationCommand.get.rawValue]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleLocationInvoke(req)
|
|
}
|
|
|
|
register([
|
|
OpenClawCanvasCommand.present.rawValue,
|
|
OpenClawCanvasCommand.hide.rawValue,
|
|
OpenClawCanvasCommand.navigate.rawValue,
|
|
OpenClawCanvasCommand.evalJS.rawValue,
|
|
OpenClawCanvasCommand.snapshot.rawValue,
|
|
]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleCanvasInvoke(req)
|
|
}
|
|
|
|
register([
|
|
OpenClawCanvasA2UICommand.reset.rawValue,
|
|
OpenClawCanvasA2UICommand.push.rawValue,
|
|
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
|
|
]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleCanvasA2UIInvoke(req)
|
|
}
|
|
|
|
register([
|
|
OpenClawCameraCommand.list.rawValue,
|
|
OpenClawCameraCommand.snap.rawValue,
|
|
OpenClawCameraCommand.clip.rawValue,
|
|
]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleCameraInvoke(req)
|
|
}
|
|
|
|
register([OpenClawScreenCommand.record.rawValue]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleScreenRecordInvoke(req)
|
|
}
|
|
|
|
register([OpenClawSystemCommand.notify.rawValue]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleSystemNotify(req)
|
|
}
|
|
|
|
register([OpenClawChatCommand.push.rawValue]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleChatPushInvoke(req)
|
|
}
|
|
|
|
register([
|
|
OpenClawDeviceCommand.status.rawValue,
|
|
OpenClawDeviceCommand.info.rawValue,
|
|
]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleDeviceInvoke(req)
|
|
}
|
|
|
|
register([
|
|
OpenClawWatchCommand.status.rawValue,
|
|
OpenClawWatchCommand.notify.rawValue,
|
|
]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleWatchInvoke(req)
|
|
}
|
|
|
|
register([OpenClawPhotosCommand.latest.rawValue]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handlePhotosInvoke(req)
|
|
}
|
|
|
|
register([
|
|
OpenClawContactsCommand.search.rawValue,
|
|
OpenClawContactsCommand.add.rawValue,
|
|
]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleContactsInvoke(req)
|
|
}
|
|
|
|
register([
|
|
OpenClawCalendarCommand.events.rawValue,
|
|
OpenClawCalendarCommand.add.rawValue,
|
|
]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleCalendarInvoke(req)
|
|
}
|
|
|
|
register([
|
|
OpenClawRemindersCommand.list.rawValue,
|
|
OpenClawRemindersCommand.add.rawValue,
|
|
]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleRemindersInvoke(req)
|
|
}
|
|
|
|
register([
|
|
OpenClawMotionCommand.activity.rawValue,
|
|
OpenClawMotionCommand.pedometer.rawValue,
|
|
]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleMotionInvoke(req)
|
|
}
|
|
|
|
register([OpenClawHealthCommand.summary.rawValue]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleHealthInvoke(req)
|
|
}
|
|
|
|
register([
|
|
OpenClawTalkCommand.pttStart.rawValue,
|
|
OpenClawTalkCommand.pttStop.rawValue,
|
|
OpenClawTalkCommand.pttCancel.rawValue,
|
|
OpenClawTalkCommand.pttOnce.rawValue,
|
|
]) { [weak self] req in
|
|
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
|
return try await self.handleTalkInvoke(req)
|
|
}
|
|
|
|
return NodeCapabilityRouter(handlers: handlers)
|
|
}
|
|
|
|
private func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
switch req.command {
|
|
case OpenClawWatchCommand.status.rawValue:
|
|
let status = await watchMessagingService.status()
|
|
let payload = OpenClawWatchStatusPayload(
|
|
supported: status.supported,
|
|
paired: status.paired,
|
|
appInstalled: status.appInstalled,
|
|
reachable: status.reachable,
|
|
activationState: status.activationState)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
case OpenClawWatchCommand.notify.rawValue:
|
|
let params = try Self.decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON)
|
|
let normalizedParams = Self.normalizeWatchNotifyParams(params)
|
|
let title = normalizedParams.title
|
|
let body = normalizedParams.body
|
|
if title.isEmpty, body.isEmpty {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(
|
|
code: .invalidRequest,
|
|
message: "INVALID_REQUEST: empty watch notification"))
|
|
}
|
|
do {
|
|
let gatewayStableID = currentWatchChatGatewayStableID()
|
|
self.watchMessageOutbox.recordPromptRoute(
|
|
promptID: normalizedParams.promptId,
|
|
gatewayStableID: gatewayStableID)
|
|
let result = try await watchMessagingService.sendNotification(
|
|
id: req.id,
|
|
params: normalizedParams,
|
|
gatewayStableID: gatewayStableID)
|
|
if result.queuedForDelivery || !result.deliveredImmediately {
|
|
let invokeID = req.id
|
|
Task { @MainActor in
|
|
await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded(
|
|
invokeID: invokeID,
|
|
params: normalizedParams,
|
|
gatewayStableID: gatewayStableID,
|
|
sendResult: result)
|
|
}
|
|
}
|
|
let payload = OpenClawWatchNotifyPayload(
|
|
deliveredImmediately: result.deliveredImmediately,
|
|
queuedForDelivery: result.queuedForDelivery,
|
|
transport: result.transport)
|
|
let json = try Self.encodePayload(payload)
|
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
|
|
} catch {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(
|
|
code: .unavailable,
|
|
message: error.localizedDescription))
|
|
}
|
|
default:
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
|
}
|
|
}
|
|
|
|
func sendDirectWatchSetup() async throws -> WatchNotificationSendResult {
|
|
struct SetupCodeResponse: Decodable {
|
|
var setupCode: String
|
|
}
|
|
|
|
guard self.isOperatorGatewayConnected else {
|
|
throw NSError(domain: "WatchDirectSetup", code: 1, userInfo: [
|
|
NSLocalizedDescriptionKey: "Connect the iPhone to a Gateway first.",
|
|
])
|
|
}
|
|
guard self.hasOperatorAdminScope else {
|
|
throw NSError(domain: "WatchDirectSetup", code: 2, userInfo: [
|
|
NSLocalizedDescriptionKey: "The iPhone connection needs operator.admin access.",
|
|
])
|
|
}
|
|
let status = await watchMessagingService.status()
|
|
guard status.supported, status.paired, status.appInstalled else {
|
|
throw NSError(domain: "WatchDirectSetup", code: 3, userInfo: [
|
|
NSLocalizedDescriptionKey: "Pair an Apple Watch and install the OpenClaw watch app first.",
|
|
])
|
|
}
|
|
|
|
let response = try await operatorGateway.request(
|
|
method: "device.pair.setupCode",
|
|
paramsJSON: #"{"includeQr":false,"bootstrapProfile":"node"}"#,
|
|
timeoutSeconds: 20)
|
|
let setup = try JSONDecoder().decode(SetupCodeResponse.self, from: response)
|
|
guard let setupLink = GatewayConnectDeepLink.fromSetupCode(setup.setupCode),
|
|
setupLink.connectionEndpoints.contains(where: \.tls)
|
|
else {
|
|
throw NSError(domain: "WatchDirectSetup", code: 4, userInfo: [
|
|
NSLocalizedDescriptionKey: "Direct Apple Watch mode requires a trusted HTTPS Gateway endpoint.",
|
|
])
|
|
}
|
|
return try await self.watchMessagingService.sendDirectNodeSetup(setupCode: setup.setupCode)
|
|
}
|
|
|
|
func refreshWatchMessagingStatus() async {
|
|
self.watchMessagingStatus = await self.watchMessagingService.status()
|
|
}
|
|
|
|
private func locationMode() -> OpenClawLocationMode {
|
|
let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off"
|
|
return OpenClawLocationMode(rawValue: raw) ?? .off
|
|
}
|
|
|
|
private func isLocationPreciseEnabled() -> Bool {
|
|
// iOS settings now expose a single location mode control.
|
|
// Default location tool precision stays high unless a command explicitly requests balanced.
|
|
true
|
|
}
|
|
|
|
fileprivate static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
|
guard let json, let data = json.data(using: .utf8) else {
|
|
throw NSError(domain: "Gateway", code: 20, userInfo: [
|
|
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
|
])
|
|
}
|
|
return try JSONDecoder().decode(type, from: data)
|
|
}
|
|
|
|
fileprivate static func encodePayload(_ obj: some Encodable) throws -> String {
|
|
let data = try JSONEncoder().encode(obj)
|
|
guard let json = String(bytes: data, encoding: .utf8) else {
|
|
throw NSError(domain: "NodeAppModel", code: 21, userInfo: [
|
|
NSLocalizedDescriptionKey: "Failed to encode payload as UTF-8",
|
|
])
|
|
}
|
|
return json
|
|
}
|
|
|
|
private func isCameraEnabled() -> Bool {
|
|
// Default-on: if the key doesn't exist yet, treat it as enabled.
|
|
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
|
|
return UserDefaults.standard.bool(forKey: "camera.enabled")
|
|
}
|
|
|
|
private func triggerCameraFlash() {
|
|
self.cameraFlashNonce &+= 1
|
|
}
|
|
|
|
private func showCameraHUD(
|
|
ownerID: String,
|
|
text: String,
|
|
kind: CameraHUDKind,
|
|
autoHideSeconds: Double? = nil)
|
|
{
|
|
self.cameraHUDDismissTask?.cancel()
|
|
self.cameraHUDOwnerID = ownerID
|
|
|
|
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))
|
|
guard self.cameraHUDOwnerID == ownerID else { return }
|
|
withAnimation(.easeOut(duration: 0.25)) {
|
|
self.cameraHUDText = nil
|
|
self.cameraHUDKind = nil
|
|
}
|
|
self.cameraHUDOwnerID = nil
|
|
self.cameraHUDDismissTask = nil
|
|
}
|
|
}
|
|
|
|
private func updateCameraHUD(
|
|
ownerID: String,
|
|
text: String,
|
|
kind: CameraHUDKind,
|
|
autoHideSeconds: Double? = nil)
|
|
{
|
|
guard self.cameraHUDOwnerID == ownerID else { return }
|
|
self.showCameraHUD(ownerID: ownerID, text: text, kind: kind, autoHideSeconds: autoHideSeconds)
|
|
}
|
|
|
|
private func clearCameraHUD(ownerID: String) {
|
|
guard self.cameraHUDOwnerID == ownerID else { return }
|
|
self.cameraHUDDismissTask?.cancel()
|
|
self.cameraHUDDismissTask = nil
|
|
self.cameraHUDOwnerID = nil
|
|
self.cameraHUDText = nil
|
|
self.cameraHUDKind = nil
|
|
}
|
|
}
|
|
|
|
extension NodeAppModel {
|
|
var mainSessionKey: String {
|
|
let base = SessionKey.normalizeMainKey(self.mainSessionBaseKey)
|
|
let agentId = (selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let defaultId = (gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base }
|
|
return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base)
|
|
}
|
|
|
|
var chatSessionKey: String {
|
|
if let focused = focusedChatSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!focused.isEmpty
|
|
{
|
|
return focused
|
|
}
|
|
return self.defaultChatSessionKey
|
|
}
|
|
|
|
var defaultChatSessionKey: String {
|
|
// Keep chat aligned with the gateway's resolved main session key.
|
|
// A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI.
|
|
self.mainSessionKey
|
|
}
|
|
|
|
func openChat(sessionKey: String?, unread: Bool = false) {
|
|
self.focusChatSession(sessionKey)
|
|
let activeKey = self.chatSessionKey
|
|
self.openedChatSessionKey = activeKey
|
|
if self.readAcknowledgedChatSessionKey != activeKey {
|
|
self.readAcknowledgedChatSessionKey = nil
|
|
}
|
|
if unread {
|
|
self.acknowledgeChatSessionReadIfNeeded(activeKey)
|
|
}
|
|
self.openChatRequestID &+= 1
|
|
}
|
|
|
|
/// One acknowledgement per unread episode: the pending flag clears when a fresh
|
|
/// snapshot confirms the read (unread != true), so a run finishing while the
|
|
/// session stays open re-acknowledges without patch loops (the gateway stamps
|
|
/// lastReadAt server-side, which makes the exchange convergent).
|
|
func reconcileChatSessionReadState(_ entries: [OpenClawChatSessionEntry]) {
|
|
guard let openedKey = self.openedChatSessionKey,
|
|
let entry = entries.first(where: { $0.key == openedKey })
|
|
else { return }
|
|
if entry.unread != true {
|
|
if self.readAcknowledgedChatSessionKey == openedKey {
|
|
self.readAcknowledgedChatSessionKey = nil
|
|
}
|
|
return
|
|
}
|
|
// Only the currently open chat auto-acknowledges fresh unread episodes.
|
|
guard openedKey == self.chatSessionKey else { return }
|
|
self.acknowledgeChatSessionReadIfNeeded(openedKey)
|
|
}
|
|
|
|
private func acknowledgeChatSessionReadIfNeeded(_ sessionKey: String) {
|
|
guard self.readAcknowledgedChatSessionKey != sessionKey else { return }
|
|
self.readAcknowledgedChatSessionKey = sessionKey
|
|
let transport = self.makeChatTransport()
|
|
Task { @MainActor in
|
|
do {
|
|
try await transport.patchSession(
|
|
key: sessionKey,
|
|
label: nil,
|
|
category: nil,
|
|
pinned: nil,
|
|
archived: nil,
|
|
unread: false)
|
|
} catch {
|
|
if self.readAcknowledgedChatSessionKey == sessionKey {
|
|
self.readAcknowledgedChatSessionKey = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func focusChatSession(_ sessionKey: String?) {
|
|
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
self.focusedChatSessionKey = trimmed.isEmpty ? nil : trimmed
|
|
self.synchronizeTalkSessionKey()
|
|
}
|
|
|
|
/// Session changes invalidate queued PTT admission before Talk cancels any
|
|
/// active owner. Otherwise a waiter can wake and retarget to the new chat.
|
|
func synchronizeTalkSessionKey(_ sessionKey: String? = nil) {
|
|
let effectiveSessionKey = sessionKey ?? self.chatSessionKey
|
|
guard !self.talkMode.isUsingMainSessionKey(effectiveSessionKey) else { return }
|
|
self.talkPttCommandEpoch &+= 1
|
|
self.voiceWake.invalidatePendingCommand()
|
|
self.talkMode.updateMainSessionKey(effectiveSessionKey)
|
|
}
|
|
|
|
var chatAgentId: String {
|
|
if let sessionAgentId = SessionKey.agentId(from: chatSessionKey) {
|
|
return sessionAgentId
|
|
}
|
|
return self.selectedOrDefaultAgentId
|
|
}
|
|
|
|
/// Verified routing owner for sends. Unlike `chatAgentId`, this has no
|
|
/// display fallback: a cold offline start must wait for persisted or
|
|
/// gateway-provided ownership before it can queue durable work.
|
|
var chatDeliveryAgentId: String? {
|
|
if let sessionAgentId = SessionKey.agentId(from: chatSessionKey) {
|
|
return sessionAgentId.lowercased()
|
|
}
|
|
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !selected.isEmpty { return selected.lowercased() }
|
|
let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return defaultId.isEmpty ? nil : defaultId.lowercased()
|
|
}
|
|
|
|
var chatSessionRoutingContract: String? {
|
|
OpenClawChatSessionRoutingContract.make(
|
|
scope: self.gatewaySessionScope,
|
|
mainKey: self.mainSessionBaseKey,
|
|
defaultAgentID: self.gatewayDefaultAgentId)
|
|
}
|
|
|
|
var chatAgentName: String {
|
|
self.agentDisplayName(for: self.chatAgentId, fallback: "Main")
|
|
}
|
|
|
|
var chatAgentAvatarURL: String? {
|
|
self.agentIdentityValue(for: self.chatAgentId, key: "avatarUrl")
|
|
}
|
|
|
|
var chatAgentAvatarText: String? {
|
|
self.agentIdentityValue(for: self.chatAgentId, key: "emoji")
|
|
}
|
|
|
|
var activeAgentName: String {
|
|
self.agentDisplayName(for: self.selectedOrDefaultAgentId, fallback: "Main")
|
|
}
|
|
|
|
private var selectedOrDefaultAgentId: String {
|
|
let agentId = (selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let defaultId = (gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return agentId.isEmpty ? defaultId : agentId
|
|
}
|
|
|
|
private func agentDisplayName(for agentId: String, fallback: String) -> String {
|
|
let resolvedId = agentId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if resolvedId.isEmpty { return fallback }
|
|
if let match = gatewayAgents.first(where: { $0.id == resolvedId }) {
|
|
let name = (match.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return name.isEmpty ? match.id : name
|
|
}
|
|
return resolvedId
|
|
}
|
|
|
|
private func agentIdentityValue(for agentId: String, key: String) -> String? {
|
|
let resolvedId = agentId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !resolvedId.isEmpty,
|
|
let match = gatewayAgents.first(where: { $0.id == resolvedId }),
|
|
let rawValue = match.identity?[key]?.value as? String
|
|
else {
|
|
return nil
|
|
}
|
|
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
func connectToGateway(
|
|
url: URL,
|
|
gatewayStableID: String,
|
|
tls: GatewayTLSParams?,
|
|
token: String?,
|
|
bootstrapToken: String?,
|
|
password: String?,
|
|
connectOptions: GatewayConnectOptions,
|
|
forceReconnect: Bool = false)
|
|
{
|
|
let stableID = GatewayStableIdentifier.exact(gatewayStableID) ?? ""
|
|
let effectiveStableID = stableID.isEmpty ? url.absoluteString : stableID
|
|
let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
|
|
let nextConfig = GatewayConnectConfig(
|
|
url: url,
|
|
stableID: stableID,
|
|
tls: tls,
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
nodeOptions: connectOptions)
|
|
let previousGatewayStableID = self.activeGatewayConnectConfig?.effectiveStableID
|
|
?? self.connectedGatewayID
|
|
let preserveGatewayProblem = previousGatewayStableID.map {
|
|
!$0.isEmpty && GatewayStableIdentifier.matches($0, effectiveStableID)
|
|
} ?? false
|
|
let targetChanged = previousGatewayStableID.map {
|
|
!$0.isEmpty && !GatewayStableIdentifier.matches($0, effectiveStableID)
|
|
} ?? false
|
|
if targetChanged {
|
|
self.clearGatewayProblemForCommittedTargetSwitch(to: effectiveStableID)
|
|
}
|
|
let hasForeignCachedApproval = self.watchExecApprovalPromptsByID.values.contains {
|
|
!GatewayStableIdentifier.matches($0.gatewayStableID, effectiveStableID)
|
|
}
|
|
if hasForeignCachedApproval || targetChanged {
|
|
// Approval IDs are gateway-local authorization handles. A target switch must remove
|
|
// every cached surface so stale prompts cannot authorize work on the replacement.
|
|
invalidateExecApprovalSurfacesForGatewayChange()
|
|
}
|
|
let operatorLoopRequired = shouldStartOperatorGatewayLoop(
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
deviceAuthGatewayID: connectOptions.deviceAuthGatewayID ?? effectiveStableID,
|
|
allowStoredDeviceAuth: connectOptions.allowStoredDeviceAuth)
|
|
if let activeConfig = activeGatewayConnectConfig,
|
|
activeConfig.hasSameConnectionInputs(as: nextConfig),
|
|
nodeGatewayTask != nil,
|
|
operatorGatewayTask != nil || !operatorLoopRequired,
|
|
!forceReconnect
|
|
{
|
|
self.gatewayAutoReconnectEnabled = true
|
|
return
|
|
}
|
|
|
|
self.gatewayRouteGeneration &+= 1
|
|
self.activeGatewayConnectConfig = nextConfig
|
|
prepareForGatewayConnect(
|
|
stableID: effectiveStableID,
|
|
preservingGatewayProblem: preserveGatewayProblem)
|
|
if operatorLoopRequired {
|
|
startOperatorGatewayLoop(
|
|
url: url,
|
|
stableID: effectiveStableID,
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
nodeOptions: connectOptions,
|
|
sessionBox: sessionBox)
|
|
} else {
|
|
self.operatorGatewayTask = nil
|
|
Task { await self.operatorGateway.disconnect() }
|
|
}
|
|
startNodeGatewayLoop(
|
|
url: url,
|
|
stableID: effectiveStableID,
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
nodeOptions: connectOptions,
|
|
sessionBox: sessionBox)
|
|
}
|
|
|
|
/// Preferred entry-point: apply a single config object and start both sessions.
|
|
func applyGatewayConnectConfig(
|
|
_ cfg: GatewayConnectConfig,
|
|
forceReconnect: Bool = false)
|
|
{
|
|
let generation = self.beginGatewayConnectAttempt()
|
|
self.applyGatewayConnectConfig(
|
|
cfg,
|
|
forceReconnect: forceReconnect,
|
|
expectedGeneration: generation)
|
|
}
|
|
|
|
/// Applies queued work only while its originating gateway attempt is still current.
|
|
func applyGatewayConnectConfig(
|
|
_ cfg: GatewayConnectConfig,
|
|
forceReconnect: Bool = false,
|
|
expectedGeneration: UInt64)
|
|
{
|
|
guard expectedGeneration == self.gatewayConnectGeneration else { return }
|
|
self.isAppleReviewDemoModeEnabled = false
|
|
self.isScreenshotFixtureModeEnabled = false
|
|
self.connectToGateway(
|
|
url: cfg.url,
|
|
// Preserve the caller-provided stableID (may be empty) and let connectToGateway
|
|
// derive the effective stable id consistently for persistence keys.
|
|
gatewayStableID: cfg.stableID,
|
|
tls: cfg.tls,
|
|
token: cfg.token,
|
|
bootstrapToken: cfg.bootstrapToken,
|
|
password: cfg.password,
|
|
connectOptions: cfg.nodeOptions,
|
|
forceReconnect: forceReconnect)
|
|
}
|
|
|
|
func beginGatewayConnectAttempt() -> UInt64 {
|
|
self.gatewayConnectGeneration &+= 1
|
|
return self.gatewayConnectGeneration
|
|
}
|
|
|
|
private func invalidateGatewayConnectAttempts() {
|
|
self.gatewayConnectGeneration &+= 1
|
|
}
|
|
|
|
var hasGatewaySessionResetInFlight: Bool {
|
|
self.gatewaySessionResetTask != nil
|
|
}
|
|
|
|
func waitForGatewaySessionResetIfNeeded() async {
|
|
while let gatewaySessionResetTask {
|
|
await gatewaySessionResetTask.value
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func beginGatewaySessionReset(chainingAfterExisting: Bool = false) -> Task<Void, Never> {
|
|
let previousResetTask = self.gatewaySessionResetTask
|
|
if let previousResetTask, !chainingAfterExisting {
|
|
return previousResetTask
|
|
}
|
|
let nodeGatewayTask = self.nodeGatewayTask
|
|
let operatorGatewayTask = self.operatorGatewayTask
|
|
self.talkMode.updateGatewayConnected(false)
|
|
self.voiceWake.invalidatePendingCommand()
|
|
self.gatewayRouteGeneration &+= 1
|
|
nodeGatewayTask?.cancel()
|
|
self.nodeGatewayTask = nil
|
|
operatorGatewayTask?.cancel()
|
|
self.operatorGatewayTask = nil
|
|
let operatorGateway = self.operatorGateway
|
|
let nodeGateway = self.nodeGateway
|
|
self.gatewaySessionResetGeneration &+= 1
|
|
let resetGeneration = self.gatewaySessionResetGeneration
|
|
// Disconnect first so canceled receive loops can unwind, then keep the barrier until their
|
|
// cleanup exits. A stale loop may otherwise disconnect a replacement session after reset.
|
|
let gatewaySessionResetTask = Task {
|
|
await previousResetTask?.value
|
|
await operatorGateway.disconnect()
|
|
await nodeGateway.disconnect()
|
|
await operatorGatewayTask?.value
|
|
await nodeGatewayTask?.value
|
|
if self.gatewaySessionResetGeneration == resetGeneration {
|
|
self.gatewaySessionResetTask = nil
|
|
}
|
|
}
|
|
self.gatewaySessionResetTask = gatewaySessionResetTask
|
|
return gatewaySessionResetTask
|
|
}
|
|
|
|
func resetGatewaySessionsForForcedReconnect() async {
|
|
await self.beginGatewaySessionReset().value
|
|
}
|
|
|
|
func resetGatewaySessionsForTargetSwitch() async {
|
|
// A target awaiting TLS trust must not retain a reconnect route to the previous gateway.
|
|
invalidateExecApprovalSurfacesForGatewayChange()
|
|
self.invalidateGatewayConnectAttempts()
|
|
self.chatSessionRoutingRestoreTask?.cancel()
|
|
self.chatSessionRoutingRestoreTask = nil
|
|
self.disableGatewayAutoReconnect()
|
|
self.activeGatewayConnectConfig = nil
|
|
ShareGatewayRelaySettings.clearConfig()
|
|
await self.resetGatewaySessionsForForcedReconnect()
|
|
guard !self.gatewayAutoReconnectEnabled, self.activeGatewayConnectConfig == nil else { return }
|
|
// A canceled loop may have persisted its reconnect flag and relay config while teardown was in flight.
|
|
self.disableGatewayAutoReconnect()
|
|
ShareGatewayRelaySettings.clearConfig()
|
|
self.gatewayHealthMonitor.stop()
|
|
self.gatewayStatusText = "Offline"
|
|
self.gatewayServerName = nil
|
|
self.gatewayRemoteAddress = nil
|
|
self.connectedGatewayID = nil
|
|
self.gatewayConnected = false
|
|
setOperatorConnected(false)
|
|
self.talkMode.updateGatewayConnected(false)
|
|
}
|
|
|
|
private func restartGatewaySessionsAfterForegroundStaleConnection() async {
|
|
guard self.gatewayAutoReconnectEnabled, let cfg = activeGatewayConnectConfig else { return }
|
|
let generation = self.gatewayConnectGeneration
|
|
await self.resetGatewaySessionsForForcedReconnect()
|
|
guard generation == self.gatewayConnectGeneration,
|
|
self.gatewayAutoReconnectEnabled,
|
|
self.activeGatewayConnectConfig?.hasSameConnectionInputs(as: cfg) == true,
|
|
self.nodeGatewayTask == nil,
|
|
self.operatorGatewayTask == nil
|
|
else { return }
|
|
guard !self.isLocalGatewayFixtureEnabled else { return }
|
|
setOperatorConnected(false)
|
|
self.gatewayConnected = false
|
|
self.setGatewayConnectionProgress(reconnecting: true)
|
|
self.talkMode.updateGatewayConnected(false)
|
|
self.applyGatewayConnectConfig(
|
|
cfg,
|
|
forceReconnect: true,
|
|
expectedGeneration: generation)
|
|
}
|
|
|
|
func disconnectGateway() {
|
|
self.disconnectGateway(disablePersistedAutoConnect: true, invalidateConnectAttempts: true)
|
|
}
|
|
|
|
func suspendGatewayForTargetReview() {
|
|
// Target review pauses live reconnects without changing the user's launch preference.
|
|
self.disconnectGateway(disablePersistedAutoConnect: false, invalidateConnectAttempts: true)
|
|
}
|
|
|
|
/// A replacement target may already own the connect generation while the forgotten route is live.
|
|
/// Preserve that generation so teardown cannot strand the replacement offline.
|
|
func disconnectForgottenGateway(preservingPendingConnectAttempt: Bool) {
|
|
self.disconnectGateway(
|
|
disablePersistedAutoConnect: !preservingPendingConnectAttempt,
|
|
invalidateConnectAttempts: !preservingPendingConnectAttempt)
|
|
}
|
|
|
|
private func disconnectGateway(
|
|
disablePersistedAutoConnect: Bool,
|
|
invalidateConnectAttempts: Bool)
|
|
{
|
|
invalidateExecApprovalSurfacesForGatewayChange()
|
|
if invalidateConnectAttempts {
|
|
self.invalidateGatewayConnectAttempts()
|
|
}
|
|
self.isAppleReviewDemoModeEnabled = false
|
|
self.isScreenshotFixtureModeEnabled = false
|
|
if disablePersistedAutoConnect {
|
|
self.disableGatewayAutoReconnect()
|
|
} else {
|
|
self.gatewayAutoReconnectEnabled = false
|
|
}
|
|
self.gatewayPairingPaused = false
|
|
self.gatewayPairingRequestId = nil
|
|
self.lastGatewayProblem = nil
|
|
self.nodeGatewayProblem = nil
|
|
self.operatorGatewayProblem = nil
|
|
// Publish teardown through the shared barrier before returning. A replacement connect
|
|
// must await old loop cleanup instead of racing this synchronous UI action.
|
|
_ = self.beginGatewaySessionReset(chainingAfterExisting: true)
|
|
self.voiceWakeSyncTask?.cancel()
|
|
self.voiceWakeSyncTask = nil
|
|
LiveActivityManager.shared.endActivity(reason: "manual_disconnect")
|
|
self.gatewayHealthMonitor.stop()
|
|
self.gatewayStatusText = "Offline"
|
|
self.gatewayServerName = nil
|
|
self.gatewayRemoteAddress = nil
|
|
self.connectedGatewayID = nil
|
|
self.activeGatewayConnectConfig = nil
|
|
self.gatewayConnected = false
|
|
setOperatorConnected(false)
|
|
self.talkMode.updateGatewayConnected(false)
|
|
self.invalidateNodePushToTalkRoute()
|
|
self.chatSessionRoutingRestoreTask?.cancel()
|
|
self.chatSessionRoutingRestoreTask = nil
|
|
self.synchronizeTalkSessionKey()
|
|
ShareGatewayRelaySettings.clearConfig()
|
|
showLocalCanvasOnDisconnect()
|
|
}
|
|
|
|
private func disableGatewayAutoReconnect() {
|
|
// Runtime teardown and persisted startup routing must move together. Otherwise a relaunch
|
|
// during target review silently reconnects the gateway the user just left.
|
|
self.gatewayAutoReconnectEnabled = false
|
|
UserDefaults.standard.set(false, forKey: "gateway.autoconnect")
|
|
}
|
|
}
|
|
|
|
extension NodeAppModel {
|
|
func resumeGatewayAfterTargetReview(_ config: GatewayConnectConfig) {
|
|
let generation = self.beginGatewayConnectAttempt()
|
|
self.setGatewayConnectionProgress(reconnecting: false)
|
|
// Reapply the exact suspended route only after teardown; a newer target invalidates the generation.
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.waitForGatewaySessionResetIfNeeded()
|
|
guard generation == self.gatewayConnectGeneration else { return }
|
|
self.applyGatewayConnectConfig(config, expectedGeneration: generation)
|
|
}
|
|
}
|
|
|
|
private func prepareForGatewayConnect(
|
|
stableID: String,
|
|
preservingGatewayProblem: Bool = false)
|
|
{
|
|
self.invalidateNodePushToTalkRoute()
|
|
self.operatorTalkConnectionGeneration &+= 1
|
|
self.chatSessionRoutingRestoreTask?.cancel()
|
|
self.isAppleReviewDemoModeEnabled = false
|
|
self.isScreenshotFixtureModeEnabled = false
|
|
self.gatewayAutoReconnectEnabled = true
|
|
self.gatewayPairingPaused = false
|
|
self.gatewayPairingRequestId = nil
|
|
// A retained error is presentation history, not live operator control. Explicit retries
|
|
// must retire the old pairing state so the replacement session can clear the snapshot.
|
|
self.nodeGatewayProblem = nil
|
|
self.operatorGatewayProblem = nil
|
|
if !preservingGatewayProblem {
|
|
// Same-target reconnects keep the prior failure readable until success or a new failure.
|
|
// Initial connects and target changes must not inherit another gateway's problem state.
|
|
self.clearGatewayConnectionProblem()
|
|
}
|
|
self.credentialHandoffFailureGeneration = nil
|
|
self.nodeGatewayTask?.cancel()
|
|
self.operatorGatewayTask?.cancel()
|
|
self.gatewayHealthMonitor.stop()
|
|
self.gatewayServerName = nil
|
|
self.gatewayRemoteAddress = nil
|
|
self.connectedGatewayID = stableID
|
|
self.gatewayConnected = false
|
|
self.setOperatorConnected(false)
|
|
self.talkMode.updateGatewayConnected(false)
|
|
self.voiceWakeSyncTask?.cancel()
|
|
self.voiceWakeSyncTask = nil
|
|
LiveActivityManager.shared.endActivity(reason: "new_gateway_connect")
|
|
self.mainSessionBaseKey = "main"
|
|
self.gatewaySessionScope = nil
|
|
self.gatewayDefaultAgentId = nil
|
|
self.gatewayAgents = []
|
|
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
|
|
self.focusedChatSessionKey = nil
|
|
self.synchronizeTalkSessionKey()
|
|
self.homeCanvasRevision &+= 1
|
|
self.apnsLastRegisteredTokenHex = nil
|
|
self.apnsLastRegisteredGatewayStableID = nil
|
|
self.chatSessionRoutingRestoreTask = Task { [weak self] in
|
|
guard !Task.isCancelled, self?.connectedGatewayID == stableID else { return }
|
|
await self?.restoreChatSessionRoutingIdentityIfNeeded()
|
|
}
|
|
}
|
|
|
|
private func clearGatewayConnectionProblem() {
|
|
self.nodeGatewayProblem = nil
|
|
if let operatorGatewayProblem {
|
|
self.lastGatewayProblem = operatorGatewayProblem
|
|
if operatorGatewayProblem.needsPairingApproval {
|
|
self.gatewayPairingPaused = true
|
|
self.gatewayPairingRequestId = operatorGatewayProblem.requestId
|
|
} else {
|
|
self.gatewayPairingPaused = false
|
|
self.gatewayPairingRequestId = nil
|
|
}
|
|
return
|
|
}
|
|
self.lastGatewayProblem = nil
|
|
self.gatewayPairingPaused = false
|
|
self.gatewayPairingRequestId = nil
|
|
}
|
|
|
|
func beginGatewayPreconnectVerification(statusText: String) {
|
|
// Preflight has not committed the replacement target yet. Keep the readable snapshot
|
|
// while retiring live pairing control; the committed route switch clears the snapshot.
|
|
self.nodeGatewayProblem = nil
|
|
self.operatorGatewayProblem = nil
|
|
self.gatewayPairingPaused = false
|
|
self.gatewayPairingRequestId = nil
|
|
self.gatewayStatusText = statusText
|
|
}
|
|
|
|
private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
|
guard !self.isLocalGatewayFixtureEnabled else { return }
|
|
self.nodeGatewayProblem = problem
|
|
self.lastGatewayProblem = problem
|
|
self.gatewayStatusText = problem.statusText
|
|
self.gatewayServerName = nil
|
|
self.gatewayRemoteAddress = nil
|
|
self.gatewayConnected = false
|
|
showLocalCanvasOnDisconnect()
|
|
if problem.pauseReconnect {
|
|
self.gatewayAutoReconnectEnabled = false
|
|
}
|
|
if problem.needsPairingApproval {
|
|
self.gatewayPairingPaused = true
|
|
self.gatewayPairingRequestId = problem.requestId
|
|
} else {
|
|
self.gatewayPairingPaused = false
|
|
self.gatewayPairingRequestId = nil
|
|
}
|
|
if problem.needsPairingApproval || problem.pauseReconnect {
|
|
LiveActivityManager.shared.showAttention(
|
|
statusText: problem.needsPairingApproval
|
|
? String(localized: "Approval needed")
|
|
: String(localized: "Action required"),
|
|
agentName: self.activeAgentName,
|
|
sessionKey: self.mainSessionKey)
|
|
}
|
|
}
|
|
|
|
private func applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
|
guard !self.isLocalGatewayFixtureEnabled else { return }
|
|
self.operatorGatewayProblem = problem
|
|
self.lastGatewayProblem = problem
|
|
self.gatewayStatusText = problem.statusText
|
|
if problem.needsPairingApproval {
|
|
self.gatewayPairingPaused = true
|
|
self.gatewayPairingRequestId = problem.requestId
|
|
}
|
|
if problem.needsPairingApproval || problem.pauseReconnect {
|
|
LiveActivityManager.shared.showAttention(
|
|
statusText: problem.needsPairingApproval
|
|
? String(localized: "Approval needed")
|
|
: String(localized: "Action required"),
|
|
agentName: self.activeAgentName,
|
|
sessionKey: self.mainSessionKey)
|
|
}
|
|
}
|
|
|
|
private func clearOperatorGatewayConnectionProblemIfCurrent() {
|
|
guard let operatorGatewayProblem else { return }
|
|
self.operatorGatewayProblem = nil
|
|
guard self.lastGatewayProblem == operatorGatewayProblem else { return }
|
|
if let nodeGatewayProblem {
|
|
self.lastGatewayProblem = nodeGatewayProblem
|
|
self.gatewayPairingPaused = nodeGatewayProblem.needsPairingApproval
|
|
self.gatewayPairingRequestId = nodeGatewayProblem.needsPairingApproval
|
|
? nodeGatewayProblem.requestId
|
|
: nil
|
|
} else {
|
|
self.lastGatewayProblem = nil
|
|
self.gatewayPairingPaused = false
|
|
self.gatewayPairingRequestId = nil
|
|
}
|
|
if self.gatewayServerName != nil {
|
|
self.gatewayStatusText = "Connected"
|
|
}
|
|
if self.gatewayConnected {
|
|
LiveActivityManager.shared.handleReconnect()
|
|
}
|
|
}
|
|
|
|
private func currentGatewayProblemToKeep(forDisconnectReason reason: String) -> GatewayConnectionProblem? {
|
|
guard let lastGatewayProblem,
|
|
lastGatewayProblem == self.nodeGatewayProblem || lastGatewayProblem == self.operatorGatewayProblem,
|
|
GatewayConnectionProblemMapper.shouldPreserve(
|
|
previousProblem: lastGatewayProblem,
|
|
overDisconnectReason: reason)
|
|
else { return nil }
|
|
return lastGatewayProblem
|
|
}
|
|
|
|
private func shouldStartOperatorGatewayLoop(
|
|
token: String?,
|
|
bootstrapToken: String?,
|
|
password: String?,
|
|
deviceAuthGatewayID: String,
|
|
allowStoredDeviceAuth: Bool = true) -> Bool
|
|
{
|
|
Self.shouldStartOperatorGatewayLoop(
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
hasStoredOperatorToken: allowStoredDeviceAuth && self.hasStoredGatewayRoleToken(
|
|
"operator",
|
|
gatewayID: deviceAuthGatewayID))
|
|
}
|
|
|
|
private func hasStoredGatewayRoleToken(_ role: String, gatewayID: String) -> Bool {
|
|
let identity = DeviceIdentityStore.loadOrCreate()
|
|
return DeviceAuthStore.loadToken(
|
|
deviceId: identity.deviceId,
|
|
role: role,
|
|
gatewayID: gatewayID) != nil
|
|
}
|
|
|
|
fileprivate nonisolated static func shouldStartOperatorGatewayLoop(
|
|
token: String?,
|
|
bootstrapToken: String?,
|
|
password: String?,
|
|
hasStoredOperatorToken: Bool) -> Bool
|
|
{
|
|
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if !trimmedToken.isEmpty {
|
|
return true
|
|
}
|
|
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if !trimmedPassword.isEmpty {
|
|
return true
|
|
}
|
|
let trimmedBootstrapToken = bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if !trimmedBootstrapToken.isEmpty {
|
|
return false
|
|
}
|
|
return hasStoredOperatorToken
|
|
}
|
|
|
|
private func currentGatewayReconnectAuth(
|
|
fallbackToken: String?,
|
|
fallbackBootstrapToken: String?,
|
|
fallbackPassword: String?) -> (token: String?, bootstrapToken: String?, password: String?)
|
|
{
|
|
if let cfg = activeGatewayConnectConfig {
|
|
return (cfg.token, cfg.bootstrapToken, cfg.password)
|
|
}
|
|
return (fallbackToken, fallbackBootstrapToken, fallbackPassword)
|
|
}
|
|
|
|
private func currentGatewayReconnectOptions(
|
|
stableID: String,
|
|
fallback: GatewayConnectOptions) -> GatewayConnectOptions
|
|
{
|
|
guard let config = activeGatewayConnectConfig,
|
|
GatewayStableIdentifier.matches(config.effectiveStableID, stableID)
|
|
else { return fallback }
|
|
return config.nodeOptions
|
|
}
|
|
|
|
private nonisolated static func usesBootstrapCredential(
|
|
token: String?,
|
|
bootstrapToken: String?,
|
|
password: String?) -> Bool
|
|
{
|
|
token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
|
|
password?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
|
|
bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
|
}
|
|
|
|
private func completeSuccessfulGatewayAuthHandoff(
|
|
stableID: String,
|
|
routeGeneration: UInt64,
|
|
issuedRoles: Set<String>,
|
|
nodeOptions: GatewayConnectOptions) -> GatewayConnectOptions?
|
|
{
|
|
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return nil }
|
|
|
|
// Bootstrap authentication is single-use. Do not keep a consumed bootstrap
|
|
// route alive unless both replacement sessions can authenticate from secure storage.
|
|
guard issuedRoles.isSuperset(of: ["node", "operator"]) else {
|
|
return nodeOptions.allowStoredDeviceAuth ? nodeOptions : nil
|
|
}
|
|
guard let config = activeGatewayConnectConfig,
|
|
GatewayStableIdentifier.matches(config.effectiveStableID, stableID)
|
|
else { return nil }
|
|
let instanceID = GatewaySettingsStore.currentInstanceID()
|
|
let deviceAuthGatewayID = nodeOptions.deviceAuthGatewayID ?? stableID
|
|
if let metadata = GatewaySettingsStore.loadGatewayCredentialMetadata(
|
|
instanceId: instanceID,
|
|
gatewayStableID: deviceAuthGatewayID),
|
|
metadata.suppressStoredDeviceAuth,
|
|
!GatewaySettingsStore.completeGatewayCredentialHandoff(
|
|
instanceId: instanceID,
|
|
gatewayStableID: deviceAuthGatewayID)
|
|
{
|
|
return nil
|
|
}
|
|
var reconnectOptions = nodeOptions
|
|
reconnectOptions.allowStoredDeviceAuth = true
|
|
self.activeGatewayConnectConfig = GatewayConnectConfig(
|
|
url: config.url,
|
|
stableID: config.stableID,
|
|
tls: config.tls,
|
|
token: config.token,
|
|
bootstrapToken: nil,
|
|
password: config.password,
|
|
nodeOptions: reconnectOptions)
|
|
|
|
if self.operatorGatewayTask == nil,
|
|
self.shouldStartOperatorGatewayLoop(
|
|
token: config.token,
|
|
bootstrapToken: nil,
|
|
password: config.password,
|
|
deviceAuthGatewayID: deviceAuthGatewayID,
|
|
allowStoredDeviceAuth: true)
|
|
{
|
|
let sessionBox = config.tls.map {
|
|
WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0))
|
|
}
|
|
self.startOperatorGatewayLoop(
|
|
url: config.url,
|
|
stableID: stableID,
|
|
token: config.token,
|
|
bootstrapToken: nil,
|
|
password: config.password,
|
|
nodeOptions: reconnectOptions,
|
|
sessionBox: sessionBox)
|
|
}
|
|
return reconnectOptions
|
|
}
|
|
|
|
private func gatewayOptionsAfterSuccessfulConnection(
|
|
_ nodeOptions: GatewayConnectOptions,
|
|
stableID: String,
|
|
routeGeneration: UInt64,
|
|
auth: (token: String?, bootstrapToken: String?, password: String?)) async -> GatewayConnectOptions?
|
|
{
|
|
guard !nodeOptions.allowStoredDeviceAuth else { return nodeOptions }
|
|
guard Self.usesBootstrapCredential(
|
|
token: auth.token,
|
|
bootstrapToken: auth.bootstrapToken,
|
|
password: auth.password)
|
|
else {
|
|
return nodeOptions
|
|
}
|
|
let issuedRoles = await nodeGateway.currentIssuedDeviceAuthRoles()
|
|
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return nil }
|
|
guard let reconnectOptions = completeSuccessfulGatewayAuthHandoff(
|
|
stableID: stableID,
|
|
routeGeneration: routeGeneration,
|
|
issuedRoles: issuedRoles,
|
|
nodeOptions: nodeOptions)
|
|
else {
|
|
await self.handleGatewayCredentialHandoffPersistenceFailure(
|
|
stableID: stableID,
|
|
routeGeneration: routeGeneration)
|
|
return nil
|
|
}
|
|
return reconnectOptions
|
|
}
|
|
|
|
private func handleGatewayCredentialHandoffPersistenceFailure(
|
|
stableID: String,
|
|
routeGeneration: UInt64) async
|
|
{
|
|
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
|
|
guard self.credentialHandoffFailureGeneration != routeGeneration else { return }
|
|
self.credentialHandoffFailureGeneration = routeGeneration
|
|
self.disableGatewayAutoReconnect()
|
|
self.nodeGatewayTask?.cancel()
|
|
self.nodeGatewayTask = nil
|
|
self.operatorGatewayTask?.cancel()
|
|
self.operatorGatewayTask = nil
|
|
await self.nodeGateway.disconnect()
|
|
await self.operatorGateway.disconnect()
|
|
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
|
|
self.applyGatewayConnectionProblem(GatewayConnectionProblem(
|
|
kind: .unknown,
|
|
owner: .iphone,
|
|
title: "Credential save failed",
|
|
message: "OpenClaw disconnected because it could not securely save the new gateway credential.",
|
|
retryable: true,
|
|
pauseReconnect: true,
|
|
technicalDetails: "Gateway credential handoff persistence failed."))
|
|
}
|
|
|
|
private func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
|
|
guard self.isBackgrounded else { return }
|
|
guard !self.backgroundReconnectSuppressed else { return }
|
|
guard let leaseUntil = backgroundReconnectLeaseUntil else {
|
|
self.suppressBackgroundReconnect(reason: "\(source):no_lease", disconnectIfNeeded: true)
|
|
return
|
|
}
|
|
if Date() >= leaseUntil {
|
|
self.suppressBackgroundReconnect(reason: "\(source):lease_expired", disconnectIfNeeded: true)
|
|
}
|
|
}
|
|
|
|
private func shouldPauseReconnectLoopInBackground(source: String) -> Bool {
|
|
self.refreshBackgroundReconnectSuppressionIfNeeded(source: source)
|
|
return self.isBackgrounded && self.backgroundReconnectSuppressed
|
|
}
|
|
|
|
private func gatewayReconnectLoopDelay(source: String) -> UInt64? {
|
|
if !self.gatewayAutoReconnectEnabled || self.gatewayPairingPaused {
|
|
return 1_000_000_000
|
|
}
|
|
return self.shouldPauseReconnectLoopInBackground(source: source)
|
|
? 2_000_000_000
|
|
: nil
|
|
}
|
|
|
|
private func isCurrentGatewayRoute(generation: UInt64, stableID: String) -> Bool {
|
|
generation == self.gatewayRouteGeneration &&
|
|
GatewayStableIdentifier.matches(
|
|
self.activeGatewayConnectConfig?.effectiveStableID,
|
|
stableID)
|
|
}
|
|
|
|
private func isCurrentExecApprovalReadbackRoute(generation: UInt64, stableID: String) -> Bool {
|
|
#if DEBUG
|
|
if self.testExecApprovalPromptFetchHandler != nil {
|
|
return generation == self.gatewayRouteGeneration &&
|
|
GatewayStableIdentifier.matches(
|
|
self.currentExecApprovalGatewayStableID(),
|
|
stableID)
|
|
}
|
|
#endif
|
|
return self.isCurrentGatewayRoute(generation: generation, stableID: stableID)
|
|
}
|
|
|
|
private func gatewayRouteCheck(
|
|
generation: UInt64,
|
|
stableID: String) -> @MainActor @Sendable () -> Bool
|
|
{
|
|
{ [weak self] in
|
|
self?.isCurrentGatewayRoute(generation: generation, stableID: stableID) == true
|
|
}
|
|
}
|
|
|
|
private func handleOperatorGatewayConnected(
|
|
url: URL,
|
|
stableID: String,
|
|
routeGeneration: UInt64) async
|
|
{
|
|
guard !self.isLocalGatewayFixtureEnabled,
|
|
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID)
|
|
else { return }
|
|
self.operatorTalkConnectionGeneration &+= 1
|
|
let talkConnectionGeneration = self.operatorTalkConnectionGeneration
|
|
self.setOperatorConnected(true)
|
|
self.clearOperatorGatewayConnectionProblemIfCurrent()
|
|
self.forceOperatorTalkPermissionUpgradeRequest = false
|
|
GatewayDiagnostics.log(
|
|
"operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
|
|
|
|
let shouldContinue: @MainActor @Sendable () -> Bool = { [weak self] in
|
|
guard let self else { return false }
|
|
return self.operatorTalkConnectionGeneration == talkConnectionGeneration &&
|
|
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID)
|
|
}
|
|
// Watch approval resolutions flush from the reconcile-gated watch path
|
|
// (reconcileWatchExecApprovalCache), not eagerly on operator connect.
|
|
if let chatSessionRoutingRestoreTask {
|
|
await chatSessionRoutingRestoreTask.value
|
|
}
|
|
guard shouldContinue() else { return }
|
|
self.chatSessionRoutingRestoreTask = nil
|
|
await self.refreshBrandingFromGateway(shouldApply: shouldContinue)
|
|
guard shouldContinue() else { return }
|
|
await self.refreshAgentsFromGateway(shouldApply: shouldContinue)
|
|
guard shouldContinue() else { return }
|
|
await self.talkMode.reloadConfig(shouldApply: shouldContinue)
|
|
guard shouldContinue() else { return }
|
|
self.admitTalkAfterSessionHydration()
|
|
await self.talkMode.prefetchRealtimeSessionIfReady(
|
|
reason: "operator_connected",
|
|
shouldApply: shouldContinue)
|
|
guard shouldContinue() else { return }
|
|
await refreshShareRouteFromGateway(shouldApply: shouldContinue)
|
|
guard shouldContinue() else { return }
|
|
await registerAPNsTokenIfNeeded(shouldContinue: shouldContinue)
|
|
guard shouldContinue() else { return }
|
|
await self.startVoiceWakeSync(shouldContinue: shouldContinue)
|
|
guard shouldContinue() else { return }
|
|
self.startGatewayHealthMonitor()
|
|
}
|
|
|
|
private func admitTalkAfterSessionHydration() {
|
|
self.synchronizeTalkSessionKey()
|
|
self.talkMode.updateGatewayConnected(true)
|
|
}
|
|
|
|
private func handleNodeGatewayConnected(
|
|
url: URL,
|
|
stableID: String,
|
|
routeGeneration: UInt64,
|
|
nodeOptions: GatewayConnectOptions,
|
|
auth: (token: String?, bootstrapToken: String?, password: String?)) async
|
|
{
|
|
guard !self.isLocalGatewayFixtureEnabled,
|
|
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID)
|
|
else { return }
|
|
let usedBootstrapToken = Self.usesBootstrapCredential(
|
|
token: auth.token,
|
|
bootstrapToken: auth.bootstrapToken,
|
|
password: auth.password)
|
|
if usedBootstrapToken {
|
|
let issuedRoles = await nodeGateway.currentIssuedDeviceAuthRoles()
|
|
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
|
|
guard self.completeSuccessfulGatewayAuthHandoff(
|
|
stableID: stableID,
|
|
routeGeneration: routeGeneration,
|
|
issuedRoles: issuedRoles,
|
|
nodeOptions: nodeOptions) != nil
|
|
else {
|
|
await self.handleGatewayCredentialHandoffPersistenceFailure(
|
|
stableID: stableID,
|
|
routeGeneration: routeGeneration)
|
|
return
|
|
}
|
|
}
|
|
|
|
self.clearGatewayConnectionProblem()
|
|
self.gatewayStatusText = "Connected"
|
|
self.gatewayServerName = url.host ?? "gateway"
|
|
self.gatewayConnected = true
|
|
_ = GatewaySettingsStore.markGatewayConnected(
|
|
stableID: stableID,
|
|
atMs: Int(Date().timeIntervalSince1970 * 1000))
|
|
self.screen.errorText = nil
|
|
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
|
|
LiveActivityManager.shared.handleReconnect()
|
|
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
|
|
ShareGatewayRelaySettings.saveConfig(ShareGatewayRelayConfig(
|
|
gatewayURLString: url.absoluteString,
|
|
gatewayStableID: nodeOptions.deviceAuthGatewayID,
|
|
token: auth.token,
|
|
password: auth.password,
|
|
sessionKey: self.mainSessionKey,
|
|
deliveryChannel: self.shareDeliveryChannel,
|
|
deliveryTo: self.shareDeliveryTo))
|
|
GatewayDiagnostics.log(
|
|
"gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
|
|
|
|
if let address = await nodeGateway.currentRemoteAddress() {
|
|
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
|
|
self.gatewayRemoteAddress = address
|
|
}
|
|
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
|
|
await showA2UIOnConnectIfNeeded()
|
|
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
|
|
let shouldContinue = self.gatewayRouteCheck(
|
|
generation: routeGeneration,
|
|
stableID: stableID)
|
|
await onNodeGatewayConnected(shouldContinue: shouldContinue)
|
|
guard shouldContinue() else { return }
|
|
SignificantLocationMonitor.startIfNeeded(
|
|
locationService: self.locationService,
|
|
locationMode: self.locationMode(),
|
|
gateway: self.nodeGateway,
|
|
beforeSend: { [weak self] in
|
|
await self?.handleSignificantLocationWakeIfNeeded()
|
|
})
|
|
}
|
|
|
|
private func startOperatorGatewayLoop(
|
|
url: URL,
|
|
stableID: String,
|
|
token: String?,
|
|
bootstrapToken: String?,
|
|
password: String?,
|
|
nodeOptions: GatewayConnectOptions,
|
|
sessionBox: WebSocketSessionBox?)
|
|
{
|
|
let routeGeneration = self.gatewayRouteGeneration
|
|
// Async reconnect helpers can resume after Disconnect or a target switch. Only the
|
|
// current route may install a new loop after those suspension points.
|
|
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
|
|
// Operator session reconnects independently (chat/talk/config/voicewake), but we tie its
|
|
// lifecycle to the current gateway config so it doesn't keep running across Disconnect.
|
|
self.operatorGatewayTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
var attempt = 0
|
|
while !Task.isCancelled,
|
|
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID)
|
|
{
|
|
if let delay = self.gatewayReconnectLoopDelay(source: "operator_loop") {
|
|
try? await Task.sleep(nanoseconds: delay)
|
|
continue
|
|
}
|
|
if await self.isOperatorConnected() {
|
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
|
continue
|
|
}
|
|
|
|
let reconnectAuth = self.currentGatewayReconnectAuth(
|
|
fallbackToken: token,
|
|
fallbackBootstrapToken: bootstrapToken,
|
|
fallbackPassword: password)
|
|
// Bootstrap handoff enables stored auth in the active config. Reconnects must
|
|
// consume that current ownership state instead of the loop's one-shot bootstrap options.
|
|
let reconnectOptions = self.currentGatewayReconnectOptions(
|
|
stableID: stableID,
|
|
fallback: nodeOptions)
|
|
let effectiveClientId =
|
|
GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) ?? reconnectOptions.clientId
|
|
let talkPermissionUpgradeRequest = self.forceOperatorTalkPermissionUpgradeRequest
|
|
let deviceAuthGatewayID = reconnectOptions.deviceAuthGatewayID ?? stableID
|
|
let operatorOptions = self.makeOperatorConnectOptions(
|
|
clientId: effectiveClientId,
|
|
displayName: reconnectOptions.clientDisplayName,
|
|
deviceAuthGatewayID: deviceAuthGatewayID,
|
|
includeAdminScope: self.shouldRequestOperatorAdminScope(
|
|
gatewayID: deviceAuthGatewayID,
|
|
token: reconnectAuth.token,
|
|
password: reconnectAuth.password,
|
|
forceTalkPermissionUpgradeRequest: talkPermissionUpgradeRequest),
|
|
includeApprovalScope: self.shouldRequestOperatorApprovalScope(
|
|
gatewayID: deviceAuthGatewayID,
|
|
token: reconnectAuth.token,
|
|
password: reconnectAuth.password,
|
|
forceTalkPermissionUpgradeRequest: talkPermissionUpgradeRequest),
|
|
forceExplicitScopes: talkPermissionUpgradeRequest,
|
|
allowStoredDeviceAuth: reconnectOptions.allowStoredDeviceAuth)
|
|
|
|
do {
|
|
try await self.operatorGateway.connect(
|
|
url: url,
|
|
credentials: GatewayNodeSessionCredentials(
|
|
token: reconnectAuth.token,
|
|
bootstrapToken: reconnectAuth.bootstrapToken,
|
|
password: reconnectAuth.password),
|
|
connectOptions: operatorOptions,
|
|
sessionBox: sessionBox,
|
|
extraHeadersProvider: {
|
|
GatewaySettingsStore.loadGatewayCustomHeaders(gatewayStableID: stableID)
|
|
},
|
|
onConnected: { [weak self] in
|
|
await self?.handleOperatorGatewayConnected(
|
|
url: url,
|
|
stableID: stableID,
|
|
routeGeneration: routeGeneration)
|
|
},
|
|
onDisconnected: { [weak self] reason in
|
|
guard let self else { return }
|
|
await MainActor.run {
|
|
guard !self.isLocalGatewayFixtureEnabled,
|
|
self.isCurrentGatewayRoute(
|
|
generation: routeGeneration,
|
|
stableID: stableID)
|
|
else { return }
|
|
self.setOperatorConnected(false)
|
|
self.talkMode.updateGatewayConnected(false)
|
|
LiveActivityManager.shared.endActivity(reason: "operator_disconnected")
|
|
}
|
|
GatewayDiagnostics.log("operator gateway disconnected reason=\(reason)")
|
|
await MainActor.run {
|
|
guard self.isCurrentGatewayRoute(
|
|
generation: routeGeneration,
|
|
stableID: stableID)
|
|
else { return }
|
|
self.stopGatewayHealthMonitor()
|
|
}
|
|
},
|
|
onInvoke: { req in
|
|
// Operator session should not handle node.invoke requests.
|
|
BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(
|
|
code: .invalidRequest,
|
|
message: "INVALID_REQUEST: operator session cannot invoke node commands"))
|
|
},
|
|
onRouteInvalidated: { [weak self] in
|
|
await MainActor.run {
|
|
self?.handleOperatorGatewayRouteInvalidated(
|
|
routeGeneration: routeGeneration,
|
|
stableID: stableID)
|
|
}
|
|
})
|
|
|
|
attempt = 0
|
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
|
} catch {
|
|
guard self.isCurrentGatewayRoute(
|
|
generation: routeGeneration,
|
|
stableID: stableID) else { break }
|
|
attempt += 1
|
|
GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)")
|
|
let problem: GatewayConnectionProblem? = await MainActor.run {
|
|
let nextProblem = GatewayConnectionProblemMapper.map(error: error)
|
|
guard !self.isLocalGatewayFixtureEnabled,
|
|
self.isCurrentGatewayRoute(
|
|
generation: routeGeneration,
|
|
stableID: stableID)
|
|
else { return nil }
|
|
if let nextProblem {
|
|
if nextProblem.needsPairingApproval || nextProblem.pauseReconnect {
|
|
self.applyOperatorGatewayConnectionProblem(nextProblem)
|
|
}
|
|
if talkPermissionUpgradeRequest, nextProblem.kind == .pairingScopeUpgradeRequired {
|
|
self.talkMode.markTalkPermissionUpgradeRequested(requestId: nextProblem.requestId)
|
|
}
|
|
}
|
|
return nextProblem
|
|
}
|
|
if problem?.needsPairingApproval == true {
|
|
self.operatorGatewayTask?.cancel()
|
|
self.operatorGatewayTask = nil
|
|
await self.operatorGateway.disconnect()
|
|
break
|
|
}
|
|
if problem?.pauseReconnect == true {
|
|
self.operatorGatewayTask?.cancel()
|
|
self.operatorGatewayTask = nil
|
|
await self.operatorGateway.disconnect()
|
|
break
|
|
}
|
|
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
|
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleOperatorGatewayRouteInvalidated(routeGeneration: UInt64, stableID: String) {
|
|
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
|
|
self.invalidateOperatorTalkRoute()
|
|
}
|
|
|
|
private func invalidateOperatorTalkRoute() {
|
|
self.operatorTalkConnectionGeneration &+= 1
|
|
// A socket replacement invalidates Talk, not gateway identity hydration. The
|
|
// replacement connection must await the same restore before admitting capture.
|
|
self.setOperatorConnected(false)
|
|
self.talkMode.updateGatewayConnected(false)
|
|
self.invalidateNodePushToTalkRoute()
|
|
}
|
|
|
|
private func handleNodeGatewayRouteInvalidated(routeGeneration: UInt64, stableID: String) {
|
|
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
|
|
self.invalidateNodePushToTalkRoute()
|
|
}
|
|
|
|
private func invalidateNodePushToTalkRoute() {
|
|
self.talkPttCommandEpoch &+= 1
|
|
self.voiceWake.invalidatePendingCommand()
|
|
_ = self.talkMode.cancelPushToTalk()
|
|
}
|
|
|
|
private func startNodeGatewayLoop(
|
|
url: URL,
|
|
stableID: String,
|
|
token: String?,
|
|
bootstrapToken: String?,
|
|
password: String?,
|
|
nodeOptions: GatewayConnectOptions,
|
|
sessionBox: WebSocketSessionBox?)
|
|
{
|
|
let routeGeneration = self.gatewayRouteGeneration
|
|
guard self.isCurrentGatewayRoute(generation: routeGeneration, stableID: stableID) else { return }
|
|
let context = NodeGatewayLoopContext(
|
|
url: url,
|
|
stableID: stableID,
|
|
routeGeneration: routeGeneration,
|
|
fallbackToken: token,
|
|
fallbackBootstrapToken: bootstrapToken,
|
|
fallbackPassword: password,
|
|
initialOptions: nodeOptions,
|
|
sessionBox: sessionBox)
|
|
self.nodeGatewayTask = Task { [weak self] in
|
|
await self?.runNodeGatewayLoop(context)
|
|
}
|
|
}
|
|
|
|
private func runNodeGatewayLoop(_ context: NodeGatewayLoopContext) async {
|
|
var state = NodeGatewayLoopState(options: context.initialOptions)
|
|
|
|
gatewayLoop: while !Task.isCancelled,
|
|
self.isCurrentGatewayRoute(
|
|
generation: context.routeGeneration,
|
|
stableID: context.stableID)
|
|
{
|
|
if await self.shouldDelayNodeGatewayConnectionAttempt() {
|
|
continue
|
|
}
|
|
self.showNodeGatewayConnectingStatus(
|
|
attempt: state.attempt,
|
|
context: context)
|
|
|
|
switch await self.performNodeGatewayConnectionAttempt(context: context, state: state) {
|
|
case let .retry(nextState):
|
|
state = nextState
|
|
case .stop:
|
|
break gatewayLoop
|
|
case .stopPreservingStatus:
|
|
// Pairing owns its status until explicit recovery.
|
|
return
|
|
}
|
|
}
|
|
|
|
self.resetNodeGatewayLoopStatusIfCurrent(context)
|
|
}
|
|
|
|
private func shouldDelayNodeGatewayConnectionAttempt() async -> Bool {
|
|
if let delay = self.gatewayReconnectLoopDelay(source: "node_loop") {
|
|
try? await Task.sleep(nanoseconds: delay)
|
|
return true
|
|
}
|
|
if await self.isGatewayConnected() {
|
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func showNodeGatewayConnectingStatus(
|
|
attempt: Int,
|
|
context: NodeGatewayLoopContext)
|
|
{
|
|
guard !self.isLocalGatewayFixtureEnabled,
|
|
self.isCurrentGatewayRoute(
|
|
generation: context.routeGeneration,
|
|
stableID: context.stableID)
|
|
else { return }
|
|
self.setGatewayConnectionProgress(reconnecting: attempt != 0)
|
|
self.gatewayServerName = nil
|
|
self.gatewayRemoteAddress = nil
|
|
LiveActivityManager.shared.showConnecting(
|
|
statusText: (attempt == 0)
|
|
? String(localized: "Connecting...")
|
|
: String(localized: "Reconnecting..."),
|
|
agentName: self.activeAgentName,
|
|
sessionKey: self.mainSessionKey)
|
|
}
|
|
|
|
private func performNodeGatewayConnectionAttempt(
|
|
context: NodeGatewayLoopContext,
|
|
state: NodeGatewayLoopState) async -> NodeGatewayLoopStep
|
|
{
|
|
let epochMs = Int(Date().timeIntervalSince1970 * 1000)
|
|
let reconnectAuth = self.currentGatewayReconnectAuth(
|
|
fallbackToken: context.fallbackToken,
|
|
fallbackBootstrapToken: context.fallbackBootstrapToken,
|
|
fallbackPassword: context.fallbackPassword)
|
|
let connectedOptions = state.options
|
|
GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(context.url.absoluteString)")
|
|
|
|
do {
|
|
try await self.nodeGateway.connect(
|
|
url: context.url,
|
|
credentials: GatewayNodeSessionCredentials(
|
|
token: reconnectAuth.token,
|
|
bootstrapToken: reconnectAuth.bootstrapToken,
|
|
password: reconnectAuth.password),
|
|
connectOptions: connectedOptions,
|
|
sessionBox: context.sessionBox,
|
|
extraHeadersProvider: {
|
|
GatewaySettingsStore.loadGatewayCustomHeaders(gatewayStableID: context.stableID)
|
|
},
|
|
onConnected: { [weak self] in
|
|
await self?.handleNodeGatewayConnected(
|
|
url: context.url,
|
|
stableID: context.stableID,
|
|
routeGeneration: context.routeGeneration,
|
|
nodeOptions: connectedOptions,
|
|
auth: reconnectAuth)
|
|
},
|
|
onDisconnected: { [weak self] reason in
|
|
guard let self else { return }
|
|
await MainActor.run {
|
|
guard !self.isLocalGatewayFixtureEnabled,
|
|
self.isCurrentGatewayRoute(
|
|
generation: context.routeGeneration,
|
|
stableID: context.stableID)
|
|
else { return }
|
|
if let currentProblem = self.currentGatewayProblemToKeep(forDisconnectReason: reason) {
|
|
self.gatewayStatusText = currentProblem.statusText
|
|
} else {
|
|
self.gatewayStatusText = "Disconnected: \(reason)"
|
|
}
|
|
self.gatewayServerName = nil
|
|
self.gatewayRemoteAddress = nil
|
|
self.gatewayConnected = false
|
|
self.showLocalCanvasOnDisconnect()
|
|
}
|
|
GatewayDiagnostics.log("gateway disconnected reason: \(reason)")
|
|
},
|
|
onInvoke: { [weak self] req in
|
|
guard let self else {
|
|
return BridgeInvokeResponse(
|
|
id: req.id,
|
|
ok: false,
|
|
error: OpenClawNodeError(
|
|
code: .unavailable,
|
|
message: "UNAVAILABLE: node not ready"))
|
|
}
|
|
return await self.handleInvoke(req, gatewayStableID: context.stableID)
|
|
},
|
|
onRouteInvalidated: { [weak self] in
|
|
await MainActor.run {
|
|
self?.handleNodeGatewayRouteInvalidated(
|
|
routeGeneration: context.routeGeneration,
|
|
stableID: context.stableID)
|
|
}
|
|
})
|
|
|
|
guard let reconnectOptions = await self.gatewayOptionsAfterSuccessfulConnection(
|
|
connectedOptions,
|
|
stableID: context.stableID,
|
|
routeGeneration: context.routeGeneration,
|
|
auth: reconnectAuth)
|
|
else { return .stop }
|
|
|
|
var nextState = state
|
|
nextState.options = reconnectOptions
|
|
nextState.attempt = 0
|
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
|
return .retry(nextState)
|
|
} catch {
|
|
return await self.handleNodeGatewayConnectionError(
|
|
error,
|
|
context: context,
|
|
state: state)
|
|
}
|
|
}
|
|
|
|
private func handleNodeGatewayConnectionError(
|
|
_ error: Error,
|
|
context: NodeGatewayLoopContext,
|
|
state: NodeGatewayLoopState) async -> NodeGatewayLoopStep
|
|
{
|
|
guard !Task.isCancelled,
|
|
self.isCurrentGatewayRoute(
|
|
generation: context.routeGeneration,
|
|
stableID: context.stableID)
|
|
else { return .stop }
|
|
|
|
if !state.didFallbackClientID,
|
|
let fallbackClientID = self.legacyClientIdFallback(
|
|
currentClientId: state.options.clientId,
|
|
error: error)
|
|
{
|
|
var nextState = state
|
|
nextState.didFallbackClientID = true
|
|
nextState.options.clientId = fallbackClientID
|
|
GatewaySettingsStore.saveGatewayClientIdOverride(
|
|
stableID: context.stableID,
|
|
clientId: fallbackClientID)
|
|
self.gatewayStatusText = "Gateway rejected client id. Retrying…"
|
|
return .retry(nextState)
|
|
}
|
|
|
|
var nextState = state
|
|
nextState.attempt += 1
|
|
let problem = self.applyNodeGatewayConnectionError(
|
|
error,
|
|
context: context)
|
|
GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)")
|
|
|
|
if problem?.needsPairingApproval == true {
|
|
// Stop both watchdogs so pairing keeps one stable request and remediation surface.
|
|
self.operatorGatewayTask?.cancel()
|
|
self.operatorGatewayTask = nil
|
|
await self.operatorGateway.disconnect()
|
|
await self.nodeGateway.disconnect()
|
|
return .stopPreservingStatus
|
|
}
|
|
guard problem?.pauseReconnect != true else {
|
|
return .retry(nextState)
|
|
}
|
|
|
|
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(nextState.attempt)))
|
|
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
|
return .retry(nextState)
|
|
}
|
|
|
|
private func applyNodeGatewayConnectionError(
|
|
_ error: Error,
|
|
context: NodeGatewayLoopContext) -> GatewayConnectionProblem?
|
|
{
|
|
let nextProblem = self.mapNodeGatewayConnectionError(error)
|
|
guard !self.isLocalGatewayFixtureEnabled,
|
|
self.isCurrentGatewayRoute(
|
|
generation: context.routeGeneration,
|
|
stableID: context.stableID)
|
|
else { return nil }
|
|
self.recordNodeGatewayConnectionError(nextProblem, error: error)
|
|
return nextProblem
|
|
}
|
|
|
|
private func recordNodeGatewayConnectionError(
|
|
_ nextProblem: GatewayConnectionProblem?,
|
|
error: Error)
|
|
{
|
|
if let nextProblem {
|
|
if nextProblem == self.operatorGatewayProblem {
|
|
// A node cancellation may echo an active operator approval failure. Keep its
|
|
// control state owner-specific so resolving operator approval clears it cleanly.
|
|
self.lastGatewayProblem = nextProblem
|
|
self.gatewayStatusText = nextProblem.statusText
|
|
} else {
|
|
self.applyGatewayConnectionProblem(nextProblem)
|
|
}
|
|
} else {
|
|
self.nodeGatewayProblem = nil
|
|
if let operatorGatewayProblem {
|
|
self.lastGatewayProblem = operatorGatewayProblem
|
|
self.gatewayStatusText = operatorGatewayProblem.statusText
|
|
} else {
|
|
self.lastGatewayProblem = nil
|
|
self.gatewayPairingPaused = false
|
|
self.gatewayPairingRequestId = nil
|
|
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
|
|
}
|
|
self.gatewayServerName = nil
|
|
self.gatewayRemoteAddress = nil
|
|
self.gatewayConnected = false
|
|
self.showLocalCanvasOnDisconnect()
|
|
}
|
|
}
|
|
|
|
private func mapNodeGatewayConnectionError(_ error: Error) -> GatewayConnectionProblem? {
|
|
GatewayConnectionProblemMapper.map(
|
|
error: error,
|
|
preserving: self.operatorGatewayProblem ?? self.nodeGatewayProblem)
|
|
}
|
|
|
|
private func resetNodeGatewayLoopStatusIfCurrent(_ context: NodeGatewayLoopContext) {
|
|
guard self.credentialHandoffFailureGeneration != context.routeGeneration else { return }
|
|
guard !self.isLocalGatewayFixtureEnabled,
|
|
self.isCurrentGatewayRoute(
|
|
generation: context.routeGeneration,
|
|
stableID: context.stableID)
|
|
else { return }
|
|
self.nodeGatewayProblem = nil
|
|
self.lastGatewayProblem = nil
|
|
self.gatewayStatusText = "Offline"
|
|
LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped")
|
|
self.gatewayServerName = nil
|
|
self.gatewayRemoteAddress = nil
|
|
self.connectedGatewayID = nil
|
|
self.gatewayConnected = false
|
|
self.setOperatorConnected(false)
|
|
self.talkMode.updateGatewayConnected(false)
|
|
// Retain the last verified routing contract for offline capture; reconnect compares it
|
|
// with the live gateway before replay.
|
|
self.synchronizeTalkSessionKey()
|
|
self.showLocalCanvasOnDisconnect()
|
|
}
|
|
|
|
private func shouldRequestOperatorApprovalScope(
|
|
gatewayID: String,
|
|
token: String?,
|
|
password: String?,
|
|
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
|
|
{
|
|
let identity = DeviceIdentityStore.loadOrCreate()
|
|
let storedOperatorScopes = DeviceAuthStore
|
|
.loadToken(deviceId: identity.deviceId, role: "operator", gatewayID: gatewayID)?
|
|
.scopes ?? []
|
|
return Self.shouldRequestOperatorApprovalScope(
|
|
token: token,
|
|
password: password,
|
|
storedOperatorScopes: storedOperatorScopes,
|
|
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
|
|
}
|
|
|
|
fileprivate nonisolated static func shouldRequestOperatorApprovalScope(
|
|
token: String?,
|
|
password: String?,
|
|
storedOperatorScopes: [String],
|
|
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
|
|
{
|
|
if forceTalkPermissionUpgradeRequest {
|
|
return storedOperatorScopes.contains("operator.approvals")
|
|
}
|
|
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if !trimmedToken.isEmpty {
|
|
return true
|
|
}
|
|
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if !trimmedPassword.isEmpty {
|
|
return true
|
|
}
|
|
return storedOperatorScopes.contains("operator.approvals")
|
|
}
|
|
|
|
private func shouldRequestOperatorAdminScope(
|
|
gatewayID: String,
|
|
token: String?,
|
|
password: String?,
|
|
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
|
|
{
|
|
let identity = DeviceIdentityStore.loadOrCreate()
|
|
let storedOperatorScopes = DeviceAuthStore
|
|
.loadToken(deviceId: identity.deviceId, role: "operator", gatewayID: gatewayID)?
|
|
.scopes ?? []
|
|
return Self.shouldRequestOperatorAdminScope(
|
|
token: token,
|
|
password: password,
|
|
storedOperatorScopes: storedOperatorScopes,
|
|
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
|
|
}
|
|
|
|
fileprivate nonisolated static func shouldRequestOperatorAdminScope(
|
|
token: String?,
|
|
password: String?,
|
|
storedOperatorScopes: [String],
|
|
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
|
|
{
|
|
if forceTalkPermissionUpgradeRequest {
|
|
return false
|
|
}
|
|
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if !trimmedToken.isEmpty {
|
|
return true
|
|
}
|
|
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if !trimmedPassword.isEmpty {
|
|
return true
|
|
}
|
|
return storedOperatorScopes.contains("operator.admin")
|
|
}
|
|
|
|
private func makeOperatorConnectOptions(
|
|
clientId: String,
|
|
displayName: String?,
|
|
deviceAuthGatewayID: String? = nil,
|
|
includeAdminScope: Bool = false,
|
|
includeApprovalScope: Bool,
|
|
forceExplicitScopes: Bool = false,
|
|
allowStoredDeviceAuth: Bool = true) -> GatewayConnectOptions
|
|
{
|
|
var scopes = ["operator.read", "operator.write", "operator.talk.secrets"]
|
|
if includeAdminScope {
|
|
scopes.append("operator.admin")
|
|
}
|
|
// Preserve reconnect compatibility for older paired operator tokens that were
|
|
// approved before iOS requested operator.approvals by default.
|
|
if includeApprovalScope {
|
|
scopes.append("operator.approvals")
|
|
}
|
|
return GatewayConnectOptions(
|
|
role: "operator",
|
|
scopes: scopes,
|
|
scopesAreExplicit: forceExplicitScopes,
|
|
caps: [],
|
|
commands: [],
|
|
permissions: [:],
|
|
clientId: clientId,
|
|
clientMode: "ui",
|
|
clientDisplayName: displayName,
|
|
includeDeviceIdentity: true,
|
|
allowStoredDeviceAuth: allowStoredDeviceAuth,
|
|
deviceAuthGatewayID: deviceAuthGatewayID)
|
|
}
|
|
|
|
private func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
|
|
let normalizedClientId = currentClientId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard normalizedClientId == "openclaw-ios" else { return nil }
|
|
let message = error.localizedDescription.lowercased()
|
|
guard message.contains("invalid connect params"), message.contains("/client/id") else {
|
|
return nil
|
|
}
|
|
return "moltbot-ios"
|
|
}
|
|
|
|
private func isOperatorConnected() async -> Bool {
|
|
self.operatorConnected
|
|
}
|
|
|
|
private func setOperatorConnected(_ connected: Bool) {
|
|
let changed = self.operatorConnected != connected
|
|
self.operatorConnected = connected
|
|
self.operatorStatusText = connected ? "Connected" : "Offline"
|
|
self.refreshOperatorAdminScopeFromStore()
|
|
guard connected else {
|
|
guard changed else { return }
|
|
Task { [weak self] in
|
|
await self?.syncWatchAppSnapshot(reason: "operator_offline")
|
|
}
|
|
return
|
|
}
|
|
if changed {
|
|
// Immediate retries are bounded per connection. A real reconnect grants queued
|
|
// messages a fresh budget so one exhausted head cannot strand the durable outbox.
|
|
self.watchMessageRetryAttempts.removeAll()
|
|
}
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.flushPendingExecApprovalResolvedPushes()
|
|
var approvalStateIsAuthoritative = true
|
|
if changed {
|
|
approvalStateIsAuthoritative = await self.reconcileWatchExecApprovalCache(
|
|
reason: "operator_reconnected")
|
|
}
|
|
if changed, approvalStateIsAuthoritative {
|
|
await self.flushPendingWatchExecApprovalResolutions()
|
|
}
|
|
await self.flushQueuedWatchMessagesIfAvailable()
|
|
guard changed else { return }
|
|
await self.syncWatchAppSnapshot(reason: "operator_online")
|
|
}
|
|
}
|
|
|
|
private func refreshOperatorAdminScopeFromStore() {
|
|
guard let config = activeGatewayConnectConfig else {
|
|
self.hasOperatorAdminScope = false
|
|
return
|
|
}
|
|
let gatewayID = config.nodeOptions.deviceAuthGatewayID ?? config.effectiveStableID
|
|
let identity = DeviceIdentityStore.loadOrCreate()
|
|
self.hasOperatorAdminScope = DeviceAuthStore
|
|
.loadToken(deviceId: identity.deviceId, role: "operator", gatewayID: gatewayID)?
|
|
.scopes
|
|
.contains("operator.admin") == true
|
|
}
|
|
}
|
|
|
|
extension NodeAppModel {
|
|
func enterAppleReviewDemoMode() {
|
|
self.invalidateGatewayConnectAttempts()
|
|
self.isAppleReviewDemoModeEnabled = true
|
|
self.isScreenshotFixtureModeEnabled = false
|
|
self.gatewayAutoReconnectEnabled = false
|
|
self.gatewayPairingPaused = false
|
|
self.gatewayPairingRequestId = nil
|
|
self.lastGatewayProblem = nil
|
|
self.nodeGatewayProblem = nil
|
|
self.operatorGatewayProblem = nil
|
|
self.credentialHandoffFailureGeneration = nil
|
|
self.nodeGatewayTask?.cancel()
|
|
self.nodeGatewayTask = nil
|
|
self.operatorGatewayTask?.cancel()
|
|
self.operatorGatewayTask = nil
|
|
self.voiceWakeSyncTask?.cancel()
|
|
self.voiceWakeSyncTask = nil
|
|
self.gatewayHealthMonitor.stop()
|
|
LiveActivityManager.shared.endActivity(reason: "apple_review_demo")
|
|
|
|
Task {
|
|
await self.operatorGateway.disconnect()
|
|
await self.nodeGateway.disconnect()
|
|
}
|
|
|
|
self.gatewayStatusText = "Connected"
|
|
self.nodeStatusText = "Connected"
|
|
self.gatewayServerName = AppleReviewDemoMode.gatewayName
|
|
self.gatewayRemoteAddress = AppleReviewDemoMode.gatewayAddress
|
|
self.connectedGatewayID = AppleReviewDemoMode.gatewayID
|
|
self.activeGatewayConnectConfig = nil
|
|
self.gatewayConnected = true
|
|
self.setOperatorConnected(false)
|
|
UserDefaults.standard.set(false, forKey: "talk.enabled")
|
|
UserDefaults.standard.set(false, forKey: "talk.background.enabled")
|
|
self.talkMode.updateGatewayConnected(false)
|
|
self.talkMode.setEnabled(false)
|
|
self.talkMode.statusText = "Demo mode only"
|
|
self.mainSessionBaseKey = "main"
|
|
self.gatewaySessionScope = "per-sender"
|
|
self.selectedAgentId = nil
|
|
self.gatewayDefaultAgentId = "main"
|
|
self.gatewayAgents = AppleReviewDemoMode.agents
|
|
self.focusedChatSessionKey = nil
|
|
self.synchronizeTalkSessionKey()
|
|
self.homeCanvasRevision &+= 1
|
|
}
|
|
|
|
func enterScreenshotFixtureMode() {
|
|
self.invalidateGatewayConnectAttempts()
|
|
self.isAppleReviewDemoModeEnabled = false
|
|
self.isScreenshotFixtureModeEnabled = true
|
|
self.gatewayAutoReconnectEnabled = false
|
|
self.gatewayPairingPaused = false
|
|
self.gatewayPairingRequestId = nil
|
|
self.lastGatewayProblem = nil
|
|
self.nodeGatewayProblem = nil
|
|
self.operatorGatewayProblem = nil
|
|
self.nodeGatewayTask?.cancel()
|
|
self.nodeGatewayTask = nil
|
|
self.operatorGatewayTask?.cancel()
|
|
self.operatorGatewayTask = nil
|
|
self.voiceWakeSyncTask?.cancel()
|
|
self.voiceWakeSyncTask = nil
|
|
self.gatewayHealthMonitor.stop()
|
|
LiveActivityManager.shared.endActivity(reason: "screenshot_fixture")
|
|
|
|
Task {
|
|
await self.operatorGateway.disconnect()
|
|
await self.nodeGateway.disconnect()
|
|
}
|
|
|
|
self.gatewayStatusText = "Connected"
|
|
self.nodeStatusText = "Connected"
|
|
self.gatewayServerName = ScreenshotFixtureMode.gatewayName
|
|
self.gatewayRemoteAddress = ScreenshotFixtureMode.gatewayAddress
|
|
self.connectedGatewayID = ScreenshotFixtureMode.gatewayID
|
|
self.activeGatewayConnectConfig = nil
|
|
self.gatewayConnected = true
|
|
self.setOperatorConnected(true)
|
|
self.hasOperatorAdminScope = true
|
|
self.mainSessionBaseKey = "main"
|
|
self.gatewaySessionScope = "per-sender"
|
|
self.selectedAgentId = nil
|
|
self.gatewayDefaultAgentId = "main"
|
|
self.gatewayAgents = ScreenshotFixtureMode.agents
|
|
self.focusedChatSessionKey = nil
|
|
self.synchronizeTalkSessionKey()
|
|
self.talkMode.enterScreenshotFixtureMode()
|
|
self.homeCanvasRevision &+= 1
|
|
}
|
|
}
|
|
|
|
extension NodeAppModel {
|
|
private struct PendingForegroundNodeAction: Decodable {
|
|
var id: String
|
|
var command: String
|
|
var paramsJSON: String?
|
|
var enqueuedAtMs: Int?
|
|
}
|
|
|
|
private struct PendingForegroundNodeActionsResponse: Decodable {
|
|
var nodeId: String?
|
|
var actions: [PendingForegroundNodeAction]
|
|
}
|
|
|
|
private struct PendingForegroundNodeActionsAckRequest: Encodable {
|
|
var ids: [String]
|
|
}
|
|
|
|
private func refreshShareRouteFromGateway(shouldApply: () -> Bool = { true }) async {
|
|
struct 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 request = OpenClawChatGatewayRequests.sessionsList(
|
|
limit: 80,
|
|
search: nil,
|
|
archived: false,
|
|
timeoutMs: 10000)
|
|
let response = try await operatorGateway.request(request)
|
|
let decoded = try JSONDecoder().decode(SessionsListResult.self, from: response)
|
|
let currentKey = self.mainSessionKey
|
|
let sorted = decoded.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
|
let exactMatch = sorted.first { row in
|
|
row.key == currentKey && normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil
|
|
}
|
|
let selected = exactMatch
|
|
let channel = normalize(selected?.lastChannel)
|
|
let to = normalize(selected?.lastTo)
|
|
|
|
guard shouldApply() else { return }
|
|
await MainActor.run {
|
|
self.shareDeliveryChannel = channel
|
|
self.shareDeliveryTo = to
|
|
if let relay = ShareGatewayRelaySettings.loadConfig() {
|
|
ShareGatewayRelaySettings.saveConfig(
|
|
ShareGatewayRelayConfig(
|
|
gatewayURLString: relay.gatewayURLString,
|
|
gatewayStableID: relay.gatewayStableID,
|
|
token: relay.token,
|
|
password: relay.password,
|
|
sessionKey: self.mainSessionKey,
|
|
deliveryChannel: channel,
|
|
deliveryTo: to))
|
|
}
|
|
}
|
|
} catch {
|
|
// Best-effort only.
|
|
}
|
|
}
|
|
|
|
func runSharePipelineSelfTest() async {
|
|
self.recordShareEvent("Share self-test running…")
|
|
|
|
let payload = SharedContentPayload(
|
|
title: "OpenClaw Share Self-Test",
|
|
url: URL(string: "https://openclaw.ai/share-self-test"),
|
|
text: "Validate iOS share->deep-link->gateway forwarding.")
|
|
guard let deepLink = ShareToAgentDeepLink.buildURL(
|
|
from: payload,
|
|
instruction: "Reply with: SHARE SELF-TEST OK")
|
|
else {
|
|
self.recordShareEvent("Self-test failed: could not build deep link.")
|
|
return
|
|
}
|
|
|
|
await handleDeepLink(url: deepLink)
|
|
}
|
|
|
|
func refreshLastShareEventFromRelay() {
|
|
if let event = ShareGatewayRelaySettings.loadLastEvent() {
|
|
self.lastShareEventText = event
|
|
}
|
|
}
|
|
|
|
func recordShareEvent(_ text: String) {
|
|
ShareGatewayRelaySettings.saveLastEvent(text)
|
|
self.refreshLastShareEventFromRelay()
|
|
}
|
|
|
|
/// Back-compat hook retained for older gateway-connect flows.
|
|
func onNodeGatewayConnected(
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
guard shouldContinue() else { return }
|
|
await self.registerAPNsTokenIfNeeded(shouldContinue: shouldContinue)
|
|
guard shouldContinue() else { return }
|
|
await self.syncWatchAppSnapshot(
|
|
reason: "node_connected",
|
|
includeChat: true,
|
|
shouldContinue: shouldContinue)
|
|
guard shouldContinue() else { return }
|
|
await self.syncWatchExecApprovalSnapshot(
|
|
reason: "node_connected",
|
|
shouldContinue: shouldContinue)
|
|
guard shouldContinue() else { return }
|
|
await self.resumePendingForegroundNodeActionsIfNeeded(
|
|
trigger: "node_connected",
|
|
shouldContinue: shouldContinue)
|
|
}
|
|
|
|
private func resumePendingForegroundNodeActionsIfNeeded(
|
|
trigger: String,
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
guard shouldContinue() else { return }
|
|
guard !self.isBackgrounded else { return }
|
|
guard await isGatewayConnected() else { return }
|
|
guard !self.pendingForegroundActionDrainInFlight else {
|
|
self.pendingForegroundActionDrainRequested = true
|
|
return
|
|
}
|
|
|
|
self.pendingForegroundActionDrainInFlight = true
|
|
defer {
|
|
self.pendingForegroundActionDrainInFlight = false
|
|
if self.pendingForegroundActionDrainRequested {
|
|
self.pendingForegroundActionDrainRequested = false
|
|
// Serialize non-idempotent action execution, then retry against whichever
|
|
// exact route is current after the suspended drain has unwound.
|
|
Task { @MainActor [weak self] in
|
|
await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "coalesced")
|
|
}
|
|
}
|
|
}
|
|
|
|
let routeGeneration = self.gatewayRouteGeneration
|
|
guard let gatewayStableID = self.connectedGatewayID,
|
|
let nodeRoute = await self.nodeGateway.currentRoute(),
|
|
shouldContinue(),
|
|
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: gatewayStableID)
|
|
else { return }
|
|
|
|
do {
|
|
let routeContext = GatewaySessionRouteContext(
|
|
route: nodeRoute,
|
|
gatewayStableID: gatewayStableID,
|
|
routeGeneration: routeGeneration)
|
|
let payload = try await nodeGateway.request(
|
|
method: "node.pending.pull",
|
|
paramsJSON: "{}",
|
|
timeoutSeconds: 6,
|
|
ifCurrentRoute: nodeRoute)
|
|
let decoded = try JSONDecoder().decode(
|
|
PendingForegroundNodeActionsResponse.self,
|
|
from: payload)
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
routeContext,
|
|
session: self.nodeGateway,
|
|
shouldContinue: shouldContinue)
|
|
else { return }
|
|
self.retainCompletedPendingForegroundActionIDs(
|
|
presentIn: decoded.actions,
|
|
gatewayStableID: gatewayStableID)
|
|
guard !decoded.actions.isEmpty else { return }
|
|
self.pendingActionLogger
|
|
.info("pending actions trigger=\(trigger, privacy: .public)")
|
|
self.pendingActionLogger.info("pending actions count=\(decoded.actions.count, privacy: .public)")
|
|
await self.applyPendingForegroundNodeActions(
|
|
decoded.actions,
|
|
trigger: trigger,
|
|
routeContext: routeContext,
|
|
shouldContinue: shouldContinue)
|
|
} catch {
|
|
// Best-effort only.
|
|
}
|
|
}
|
|
|
|
private func applyPendingForegroundNodeActions(
|
|
_ actions: [PendingForegroundNodeAction],
|
|
trigger: String,
|
|
routeContext: GatewaySessionRouteContext? = nil,
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
for action in actions {
|
|
guard shouldContinue() else { return }
|
|
if let routeContext {
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
routeContext,
|
|
session: self.nodeGateway,
|
|
shouldContinue: shouldContinue)
|
|
else { return }
|
|
}
|
|
guard !self.isBackgrounded else {
|
|
self.pendingActionLogger.info(
|
|
"Pending action replay paused trigger=\(trigger, privacy: .public): app backgrounded")
|
|
return
|
|
}
|
|
let req = BridgeInvokeRequest(
|
|
id: action.id,
|
|
command: action.command,
|
|
paramsJSON: action.paramsJSON)
|
|
let gatewayStableID = routeContext?.gatewayStableID
|
|
let alreadyCompleted = gatewayStableID.map {
|
|
self.completedPendingForegroundActionIDsByGateway[$0]?.contains(action.id) == true
|
|
} ?? false
|
|
if !alreadyCompleted {
|
|
let result = await handleInvoke(
|
|
req,
|
|
gatewayStableID: gatewayStableID ?? self.connectedGatewayID)
|
|
self.pendingActionLogger
|
|
.info("pending replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public)")
|
|
self.pendingActionLogger.info("pending replay ok=\(result.ok, privacy: .public)")
|
|
self.pendingActionLogger.info("pending replay command=\(action.command, privacy: .public)")
|
|
guard result.ok else { return }
|
|
if let gatewayStableID {
|
|
// The gateway queue is connection-independent. Remember successful local
|
|
// execution until its source gateway accepts the ACK so reconnects cannot replay it.
|
|
self.completedPendingForegroundActionIDsByGateway[gatewayStableID, default: []]
|
|
.insert(action.id)
|
|
}
|
|
guard shouldContinue() else { return }
|
|
}
|
|
let acked = await ackPendingForegroundNodeAction(
|
|
id: action.id,
|
|
trigger: trigger,
|
|
command: action.command,
|
|
routeContext: routeContext)
|
|
guard acked else { return }
|
|
if let gatewayStableID {
|
|
self.removeCompletedPendingForegroundActionID(
|
|
action.id,
|
|
gatewayStableID: gatewayStableID)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func retainCompletedPendingForegroundActionIDs(
|
|
presentIn actions: [PendingForegroundNodeAction],
|
|
gatewayStableID: String)
|
|
{
|
|
guard let completed = self.completedPendingForegroundActionIDsByGateway[gatewayStableID] else {
|
|
return
|
|
}
|
|
let retained = completed.intersection(actions.map(\.id))
|
|
if retained.isEmpty {
|
|
self.completedPendingForegroundActionIDsByGateway.removeValue(forKey: gatewayStableID)
|
|
} else {
|
|
self.completedPendingForegroundActionIDsByGateway[gatewayStableID] = retained
|
|
}
|
|
}
|
|
|
|
private func removeCompletedPendingForegroundActionID(
|
|
_ id: String,
|
|
gatewayStableID: String)
|
|
{
|
|
self.completedPendingForegroundActionIDsByGateway[gatewayStableID]?.remove(id)
|
|
if self.completedPendingForegroundActionIDsByGateway[gatewayStableID]?.isEmpty == true {
|
|
self.completedPendingForegroundActionIDsByGateway.removeValue(forKey: gatewayStableID)
|
|
}
|
|
}
|
|
|
|
private func isCurrentGatewaySessionRoute(
|
|
_ context: GatewaySessionRouteContext,
|
|
session: GatewayNodeSession,
|
|
shouldContinue: @MainActor @Sendable () -> Bool) async -> Bool
|
|
{
|
|
guard shouldContinue(),
|
|
self.isCurrentGatewayRoute(
|
|
generation: context.routeGeneration,
|
|
stableID: context.gatewayStableID)
|
|
else { return false }
|
|
guard await session.currentRoute() == context.route else { return false }
|
|
return shouldContinue() &&
|
|
self.isCurrentGatewayRoute(
|
|
generation: context.routeGeneration,
|
|
stableID: context.gatewayStableID)
|
|
}
|
|
|
|
private func ackPendingForegroundNodeAction(
|
|
id: String,
|
|
trigger: String,
|
|
command: String,
|
|
routeContext: GatewaySessionRouteContext?) async -> Bool
|
|
{
|
|
do {
|
|
let expectedRoute: GatewayNodeSessionRoute?
|
|
if let routeContext {
|
|
guard GatewayStableIdentifier.matches(
|
|
self.activeGatewayConnectConfig?.effectiveStableID,
|
|
routeContext.gatewayStableID),
|
|
let currentRoute = await self.nodeGateway.currentRoute(),
|
|
GatewayStableIdentifier.matches(
|
|
self.activeGatewayConnectConfig?.effectiveStableID,
|
|
routeContext.gatewayStableID)
|
|
else { return false }
|
|
expectedRoute = currentRoute
|
|
} else {
|
|
expectedRoute = nil
|
|
}
|
|
let payload = try JSONEncoder().encode(PendingForegroundNodeActionsAckRequest(ids: [id]))
|
|
let paramsJSON = String(bytes: payload, encoding: .utf8) ?? "{}"
|
|
_ = try await self.nodeGateway.request(
|
|
method: "node.pending.ack",
|
|
paramsJSON: paramsJSON,
|
|
timeoutSeconds: 6,
|
|
ifCurrentRoute: expectedRoute)
|
|
return true
|
|
} catch {
|
|
self.pendingActionLogger
|
|
.error("pending ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public)")
|
|
self.pendingActionLogger.error("pending ack command=\(command, privacy: .public)")
|
|
self.pendingActionLogger.error("pending ack error=\(String(describing: error), privacy: .public)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
|
|
let replyID = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let actionID = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !replyID.isEmpty, !actionID.isEmpty else {
|
|
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
|
|
return
|
|
}
|
|
let payloadGatewayID = GatewayStableIdentifier.exact(event.gatewayStableID)
|
|
let currentGatewayID = self.currentWatchChatGatewayStableID()
|
|
let routedGatewayID = GatewayStableIdentifier.exact(
|
|
self.watchMessageOutbox.gatewayStableID(forPromptID: event.promptId))
|
|
let sourceGatewayID: String? = if let payloadGatewayID {
|
|
payloadGatewayID
|
|
} else if let routedGatewayID {
|
|
routedGatewayID
|
|
} else {
|
|
nil
|
|
}
|
|
if let sourceGatewayID,
|
|
let currentGatewayID,
|
|
!GatewayStableIdentifier.matches(currentGatewayID, sourceGatewayID)
|
|
{
|
|
self.watchReplyLogger.info("watch reply dropped: stale gateway target")
|
|
return
|
|
}
|
|
guard let sourceGatewayID else {
|
|
self.watchReplyLogger.info("watch reply dropped: unresolved gateway target")
|
|
return
|
|
}
|
|
let gatewayStableID = sourceGatewayID
|
|
|
|
let message = WatchAppCommandEvent(
|
|
commandId: replyID,
|
|
command: .sendChat,
|
|
sessionKey: event.sessionKey,
|
|
gatewayStableID: gatewayStableID,
|
|
text: Self.makeWatchReplyAgentMessage(event),
|
|
sentAtMs: event.sentAtMs,
|
|
transport: event.transport,
|
|
messageKind: .quickReply)
|
|
let needsReconnect = !self.isWatchMessageSendAvailable()
|
|
await self.handleWatchMessage(message)
|
|
guard needsReconnect else { return }
|
|
|
|
let connected = await ensureOperatorApprovalConnectionForWatchReview(
|
|
timeoutMs: 12000,
|
|
reason: "watch_reply")
|
|
guard connected,
|
|
GatewayStableIdentifier.matches(
|
|
self.currentWatchChatGatewayStableID(),
|
|
gatewayStableID)
|
|
else {
|
|
self.watchReplyLogger.info("watch reply remains queued: gateway target unavailable")
|
|
return
|
|
}
|
|
await self.flushQueuedWatchMessagesIfAvailable()
|
|
}
|
|
|
|
private static func makeWatchReplyAgentMessage(_ event: WatchQuickReplyEvent) -> String {
|
|
let actionLabel = event.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let promptId = event.promptId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let transport = event.transport.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let summary = actionLabel?.isEmpty == false ? actionLabel! : event.actionId
|
|
var lines: [String] = []
|
|
lines.append("Watch reply: \(summary)")
|
|
lines.append("promptId=\(promptId.isEmpty ? "unknown" : promptId)")
|
|
lines.append("actionId=\(event.actionId)")
|
|
lines.append("replyId=\(event.replyId)")
|
|
if !transport.isEmpty {
|
|
lines.append("transport=\(transport)")
|
|
}
|
|
if let sentAtMs = event.sentAtMs {
|
|
lines.append("sentAtMs=\(sentAtMs)")
|
|
}
|
|
if let note = event.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
|
|
lines.append("note=\(note)")
|
|
}
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
private func restorePersistedWatchExecApprovalBridgeState() {
|
|
guard let data = UserDefaults.standard.data(forKey: Self.watchExecApprovalBridgeStateKey),
|
|
let state = try? JSONDecoder().decode(PersistedWatchExecApprovalBridgeState.self, from: data)
|
|
else {
|
|
return
|
|
}
|
|
// Shipped caches before unified approvals have no owner kind. Preserve only
|
|
// their exact ID + gateway owner until approval.get rebuilds canonical state.
|
|
let typedApprovals = state.approvals.filter {
|
|
$0.kind == ApprovalKind.exec.rawValue
|
|
}
|
|
self.watchExecApprovalPromptsByID = typedApprovals.reduce(into: [:]) { result, prompt in
|
|
guard let approvalID = Self.execApprovalIDKey(prompt.id) else { return }
|
|
result[approvalID] = prompt
|
|
}
|
|
let legacyReadbacks = state.approvals.compactMap { prompt -> PersistedExecApprovalReadback? in
|
|
guard prompt.kind == nil else { return nil }
|
|
return PersistedExecApprovalReadback(
|
|
approvalId: prompt.id,
|
|
gatewayStableID: prompt.gatewayStableID)
|
|
}
|
|
var restoredReadbacks = Set<PersistedExecApprovalReadbackKey>()
|
|
self.pendingPersistedExecApprovalReadbacks = ((state.pendingApprovalReadbacks ?? []) + legacyReadbacks)
|
|
.filter { readback in
|
|
guard let key = Self.persistedExecApprovalReadbackKey(readback) else { return false }
|
|
return restoredReadbacks.insert(key).inserted
|
|
}
|
|
.sorted(by: Self.persistedExecApprovalReadbackSortsBefore)
|
|
var restoredUncertainties = Set<ExecApprovalResolutionKey>()
|
|
let persistedUncertainties = state.approvalUncertainties ?? []
|
|
self.execApprovalUncertainties = persistedUncertainties.reduce(into: [:]) { result, uncertainty in
|
|
guard !uncertainty.message.isEmpty,
|
|
let key = Self.persistedExecApprovalUncertaintyKey(uncertainty),
|
|
restoredUncertainties.insert(key).inserted
|
|
else { return }
|
|
result[key] = ExecApprovalUncertaintyState(
|
|
token: UUID(),
|
|
message: uncertainty.message)
|
|
}
|
|
for key in restoredUncertainties where !restoredReadbacks.contains(
|
|
PersistedExecApprovalReadbackKey(
|
|
approvalID: key.approvalID,
|
|
gatewayID: key.gatewayID))
|
|
{
|
|
self.pendingPersistedExecApprovalReadbacks.append(PersistedExecApprovalReadback(
|
|
approvalId: key.approvalID.rawValue,
|
|
gatewayStableID: key.gatewayID.rawValue))
|
|
}
|
|
self.pendingPersistedExecApprovalReadbacks.sort(
|
|
by: Self.persistedExecApprovalReadbackSortsBefore)
|
|
var restoredPushes = Set<ExecApprovalPushKey>()
|
|
self.pendingWatchExecApprovalRecoveryPushes = (state.pendingApprovalPushes ?? [])
|
|
.filter { push in
|
|
guard push.gatewayDeviceId?.isEmpty != true,
|
|
let pushKey = Self.execApprovalPushKey(push)
|
|
else { return false }
|
|
return restoredPushes.insert(pushKey).inserted
|
|
}
|
|
.sorted(by: Self.execApprovalPushSortsBefore)
|
|
var restoredResolvedPushes = Set<ExecApprovalPushKey>()
|
|
self.pendingExecApprovalResolvedPushes = (state.pendingResolvedPushes ?? [])
|
|
.filter { push in
|
|
guard push.gatewayDeviceId?.isEmpty != true,
|
|
let pushKey = Self.execApprovalPushKey(push)
|
|
else { return false }
|
|
return restoredResolvedPushes.insert(pushKey).inserted
|
|
}
|
|
.sorted(by: Self.execApprovalPushSortsBefore)
|
|
var restoredReplyIDs = Set<String>()
|
|
self.pendingWatchExecApprovalResolutions = Array((state.pendingResolutions ?? []).filter { event in
|
|
let replyID = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let approvalID = Self.validatedApprovalID(event.approvalId)
|
|
let gatewayID = GatewayStableIdentifier.exact(event.gatewayStableID)
|
|
return !replyID.isEmpty &&
|
|
approvalID != nil &&
|
|
gatewayID != nil &&
|
|
restoredReplyIDs.insert(replyID).inserted
|
|
}.suffix(32))
|
|
self.pruneExpiredWatchExecApprovalPrompts()
|
|
self.persistWatchExecApprovalBridgeState()
|
|
}
|
|
|
|
private func currentExecApprovalGatewayStableID() -> String? {
|
|
let stableID = self.activeGatewayConnectConfig?.effectiveStableID
|
|
?? self.connectedGatewayID
|
|
return GatewayStableIdentifier.exact(stableID)
|
|
}
|
|
|
|
private func isExecApprovalPromptCurrent(_ prompt: ExecApprovalPrompt) -> Bool {
|
|
prompt.kind == ApprovalKind.exec.rawValue &&
|
|
GatewayStableIdentifier.matches(
|
|
self.currentExecApprovalGatewayStableID(),
|
|
prompt.gatewayStableID)
|
|
}
|
|
|
|
private func invalidateExecApprovalSurfacesForGatewayChange() {
|
|
self.pendingExecApprovalPromptRequestGeneration &+= 1
|
|
self.dismissPendingExecApprovalPrompt()
|
|
self.pendingNotificationPermissionGuidancePrompt = nil
|
|
self.watchExecApprovalPromptsByID.removeAll()
|
|
self.execApprovalInboxPromptsByKey.removeAll()
|
|
self.dismissedExecApprovalPresentationKeys.removeAll()
|
|
self.terminalExecApprovalKeys.removeAll()
|
|
self.terminalExecApprovalKeyOrder.removeAll()
|
|
self.resettableWatchResolutionAttempts.removeAll()
|
|
// In-flight resolution attempts are owner-scoped write fences keyed by
|
|
// (approvalID, gatewayStableID). They must survive a target switch so returning
|
|
// to the owner cannot double-submit while the original write outcome is unknown;
|
|
// completion defers and terminal cleanup remove them per key.
|
|
// Uncertainties are owner-scoped durable records of lost write outcomes, not
|
|
// gateway-local UI. They must survive a target switch so returning to the owner
|
|
// keeps that approval frozen until approval.get classifies it canonically.
|
|
let requestedPushes = self.pendingWatchExecApprovalRecoveryPushes
|
|
self.pendingWatchExecApprovalRecoveryPushes.removeAll()
|
|
let resolvedPushes = self.pendingExecApprovalResolvedPushes
|
|
self.pendingExecApprovalResolvedPushes.removeAll()
|
|
self.persistWatchExecApprovalBridgeState()
|
|
Task { @MainActor [weak self] in
|
|
if let self {
|
|
// Keep notification pushes until terminal state so route invalidation can remove
|
|
// only alerts owned by the old gateway, never a newly delivered replacement.
|
|
var seen = Set<ExecApprovalPushKey>()
|
|
for push in requestedPushes + resolvedPushes {
|
|
guard let pushKey = Self.execApprovalPushKey(push),
|
|
seen.insert(pushKey).inserted
|
|
else { continue }
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: self.notificationCenter)
|
|
}
|
|
}
|
|
await self?.syncWatchExecApprovalSnapshot(reason: "gateway_changed")
|
|
}
|
|
}
|
|
|
|
private func persistWatchExecApprovalBridgeState() {
|
|
self.pruneExpiredWatchExecApprovalPrompts()
|
|
let approvals = self.watchExecApprovalPromptsByID.values.sorted { lhs, rhs in
|
|
let lhsExpires = lhs.expiresAtMs ?? Int64.max
|
|
let rhsExpires = rhs.expiresAtMs ?? Int64.max
|
|
if lhsExpires != rhsExpires {
|
|
return lhsExpires < rhsExpires
|
|
}
|
|
return Self.approvalIDSortsBefore(lhs.id, rhs.id)
|
|
}
|
|
let pendingApprovalPushes = self.pendingWatchExecApprovalRecoveryPushes
|
|
.sorted(by: Self.execApprovalPushSortsBefore)
|
|
let pendingResolvedPushes = self.pendingExecApprovalResolvedPushes
|
|
.sorted(by: Self.execApprovalPushSortsBefore)
|
|
let approvalUncertainties = self.execApprovalUncertainties.map { key, state in
|
|
PersistedExecApprovalUncertainty(
|
|
approvalId: key.approvalID.rawValue,
|
|
gatewayStableID: key.gatewayID.rawValue,
|
|
message: state.message)
|
|
}.sorted(by: Self.persistedExecApprovalUncertaintySortsBefore)
|
|
guard let data = try? JSONEncoder().encode(
|
|
PersistedWatchExecApprovalBridgeState(
|
|
approvals: approvals,
|
|
pendingApprovalReadbacks: self.pendingPersistedExecApprovalReadbacks,
|
|
approvalUncertainties: approvalUncertainties,
|
|
pendingApprovalPushes: pendingApprovalPushes,
|
|
pendingResolvedPushes: pendingResolvedPushes,
|
|
pendingResolutions: pendingWatchExecApprovalResolutions))
|
|
else {
|
|
return
|
|
}
|
|
UserDefaults.standard.set(data, forKey: Self.watchExecApprovalBridgeStateKey)
|
|
}
|
|
|
|
private func pruneExpiredWatchExecApprovalPrompts(nowMs: Int64? = nil) {
|
|
let currentNowMs = nowMs ?? Int64(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 {
|
|
self.watchMessagingStatus = status
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: status changed "
|
|
+ "reachable=\(status.reachable) activation=\(status.activationState) "
|
|
+ "backgrounded=\(self.isBackgrounded)")
|
|
guard status.supported, status.paired, status.appInstalled else { return }
|
|
guard status.reachable || status.activationState == "activated" else { return }
|
|
let reason = status.reachable ? "watch_reachable" : "watch_activated"
|
|
await self.syncWatchAppSnapshot(reason: reason, includeChat: status.reachable)
|
|
guard self.isBackgrounded else { return }
|
|
await self.syncWatchExecApprovalSnapshot(reason: reason)
|
|
}
|
|
|
|
private func appendPendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) {
|
|
guard let pushKey = Self.execApprovalPushKey(push),
|
|
!self.pendingWatchExecApprovalRecoveryPushes.contains(where: {
|
|
Self.execApprovalPushKey($0) == pushKey
|
|
})
|
|
else { return }
|
|
self.pendingWatchExecApprovalRecoveryPushes.append(push)
|
|
self.pendingWatchExecApprovalRecoveryPushes.sort(by: Self.execApprovalPushSortsBefore)
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: queued recovery "
|
|
+ "id=\(push.approvalId) pendingCount=\(self.pendingWatchExecApprovalRecoveryPushes.count)")
|
|
self.persistWatchExecApprovalBridgeState()
|
|
}
|
|
|
|
private func removePendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) {
|
|
guard let pushKey = Self.execApprovalPushKey(push) else { return }
|
|
let originalCount = self.pendingWatchExecApprovalRecoveryPushes.count
|
|
self.pendingWatchExecApprovalRecoveryPushes.removeAll {
|
|
Self.execApprovalPushKey($0) == pushKey
|
|
}
|
|
guard self.pendingWatchExecApprovalRecoveryPushes.count != originalCount else { return }
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: cleared recovery "
|
|
+ "id=\(push.approvalId) pendingCount=\(self.pendingWatchExecApprovalRecoveryPushes.count)")
|
|
self.persistWatchExecApprovalBridgeState()
|
|
}
|
|
|
|
private func appendPendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) {
|
|
guard let pushKey = Self.execApprovalPushKey(push),
|
|
!self.pendingExecApprovalResolvedPushes.contains(where: {
|
|
Self.execApprovalPushKey($0) == pushKey
|
|
})
|
|
else { return }
|
|
// A silent resolution push is not replayed by the gateway. Keep it until the
|
|
// authenticated owner route returns so its matching notification cannot linger.
|
|
self.pendingExecApprovalResolvedPushes.append(push)
|
|
if self.pendingExecApprovalResolvedPushes.count > 32 {
|
|
self.pendingExecApprovalResolvedPushes.removeFirst()
|
|
}
|
|
self.pendingExecApprovalResolvedPushes.sort(by: Self.execApprovalPushSortsBefore)
|
|
self.persistWatchExecApprovalBridgeState()
|
|
}
|
|
|
|
private func removePendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) {
|
|
guard let pushKey = Self.execApprovalPushKey(push) else { return }
|
|
let originalCount = self.pendingExecApprovalResolvedPushes.count
|
|
self.pendingExecApprovalResolvedPushes.removeAll {
|
|
Self.execApprovalPushKey($0) == pushKey
|
|
}
|
|
guard self.pendingExecApprovalResolvedPushes.count != originalCount else { return }
|
|
self.persistWatchExecApprovalBridgeState()
|
|
}
|
|
|
|
private func removePendingPersistedExecApprovalReadback(
|
|
_ readback: PersistedExecApprovalReadback)
|
|
{
|
|
guard let readbackKey = Self.persistedExecApprovalReadbackKey(readback) else { return }
|
|
let originalCount = self.pendingPersistedExecApprovalReadbacks.count
|
|
self.pendingPersistedExecApprovalReadbacks.removeAll {
|
|
Self.persistedExecApprovalReadbackKey($0) == readbackKey
|
|
}
|
|
guard self.pendingPersistedExecApprovalReadbacks.count != originalCount else { return }
|
|
self.persistWatchExecApprovalBridgeState()
|
|
}
|
|
|
|
private func appendPendingPersistedExecApprovalReadback(
|
|
approvalId: String,
|
|
gatewayStableID: String)
|
|
{
|
|
let readback = PersistedExecApprovalReadback(
|
|
approvalId: approvalId,
|
|
gatewayStableID: gatewayStableID)
|
|
guard let readbackKey = Self.persistedExecApprovalReadbackKey(readback),
|
|
!self.pendingPersistedExecApprovalReadbacks.contains(where: {
|
|
Self.persistedExecApprovalReadbackKey($0) == readbackKey
|
|
})
|
|
else { return }
|
|
// A requested event is an edge trigger, not replayed state. Retain its exact owner
|
|
// until approval.get classifies it so a reconnect cannot lose a parked approval.
|
|
self.pendingPersistedExecApprovalReadbacks.append(readback)
|
|
if self.pendingPersistedExecApprovalReadbacks.count > 64 {
|
|
self.pendingPersistedExecApprovalReadbacks.removeFirst()
|
|
}
|
|
self.pendingPersistedExecApprovalReadbacks.sort(
|
|
by: Self.persistedExecApprovalReadbackSortsBefore)
|
|
self.persistWatchExecApprovalBridgeState()
|
|
}
|
|
|
|
private func upsertWatchExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
|
|
guard self.isExecApprovalPromptCurrent(prompt),
|
|
let approvalID = Self.execApprovalIDKey(prompt.id),
|
|
let inboxKey = Self.execApprovalInboxKey(prompt),
|
|
!self.terminalExecApprovalKeys.contains(inboxKey)
|
|
else { return }
|
|
self.watchExecApprovalPromptsByID[approvalID] = prompt
|
|
self.execApprovalInboxPromptsByKey[inboxKey] = prompt
|
|
self.persistWatchExecApprovalBridgeState()
|
|
}
|
|
|
|
private func markExecApprovalOwnerTerminal(
|
|
approvalId: String,
|
|
gatewayStableID: String)
|
|
{
|
|
guard let approvalID = Self.execApprovalIDKey(approvalId),
|
|
let inboxKey = Self.execApprovalInboxKey(
|
|
approvalID: approvalId,
|
|
gatewayStableID: gatewayStableID)
|
|
else { return }
|
|
if self.terminalExecApprovalKeys.insert(inboxKey).inserted {
|
|
self.terminalExecApprovalKeyOrder.append(inboxKey)
|
|
if self.terminalExecApprovalKeyOrder.count > 256 {
|
|
let evictedKey = self.terminalExecApprovalKeyOrder.removeFirst()
|
|
self.terminalExecApprovalKeys.remove(evictedKey)
|
|
}
|
|
}
|
|
self.execApprovalInboxPromptsByKey.removeValue(forKey: inboxKey)
|
|
self.dismissedExecApprovalPresentationKeys.remove(inboxKey)
|
|
self.resettableWatchResolutionAttempts.removeValue(forKey: inboxKey)
|
|
self.activeExecApprovalResolutionAttempts.removeValue(forKey: inboxKey)
|
|
self.execApprovalUncertainties.removeValue(forKey: inboxKey)
|
|
self.pendingPersistedExecApprovalReadbacks.removeAll {
|
|
Self.persistedExecApprovalReadbackKey($0) == PersistedExecApprovalReadbackKey(
|
|
approvalID: inboxKey.approvalID,
|
|
gatewayID: inboxKey.gatewayID)
|
|
}
|
|
if GatewayStableIdentifier.matches(
|
|
self.watchExecApprovalPromptsByID[approvalID]?.gatewayStableID,
|
|
gatewayStableID)
|
|
{
|
|
self.watchExecApprovalPromptsByID.removeValue(forKey: approvalID)
|
|
}
|
|
self.persistWatchExecApprovalBridgeState()
|
|
}
|
|
|
|
private static func makeWatchExecApprovalItem(from prompt: ExecApprovalPrompt) -> OpenClawWatchExecApprovalItem {
|
|
let decisions = prompt.allowedDecisions.compactMap(OpenClawWatchExecApprovalDecision.init(rawValue:))
|
|
let preview = Self.trimmedOrNil(prompt.commandPreview) ?? Self.trimmedOrNil(prompt.commandText)
|
|
return OpenClawWatchExecApprovalItem(
|
|
id: prompt.id,
|
|
gatewayStableID: prompt.gatewayStableID,
|
|
commandText: prompt.commandText,
|
|
commandPreview: preview,
|
|
warningText: Self.trimmedOrNil(prompt.warningText),
|
|
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 approval.get
|
|
// carries an explicit risk signal for exec approvals.
|
|
risk: nil)
|
|
}
|
|
|
|
private func publishWatchExecApprovalPrompt(
|
|
_ prompt: ExecApprovalPrompt,
|
|
reason: String,
|
|
resetResolutionAttemptId: String? = nil,
|
|
syncSnapshots: Bool = true) async
|
|
{
|
|
guard self.isExecApprovalPromptCurrent(prompt),
|
|
let inboxKey = Self.execApprovalInboxKey(prompt),
|
|
!self.terminalExecApprovalKeys.contains(inboxKey)
|
|
else { return }
|
|
let deliveryGeneration = self.gatewayConnectGeneration
|
|
let message = OpenClawWatchExecApprovalPromptMessage(
|
|
approval: Self.makeWatchExecApprovalItem(from: prompt),
|
|
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
|
|
resetResolutionAttemptId: resetResolutionAttemptId)
|
|
do {
|
|
_ = try await self.watchMessagingService.sendExecApprovalPrompt(message)
|
|
self.watchExecApprovalLogger.debug(
|
|
"watch exec approval prompt sent id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public)")
|
|
} catch {
|
|
self.watchExecApprovalLogger
|
|
.error(
|
|
"watch approval prompt failed id=\(prompt.id, privacy: .public) reason=\(reason, privacy: .public)")
|
|
self.watchExecApprovalLogger.error(
|
|
"watch approval prompt error=\(error.localizedDescription, privacy: .public)")
|
|
}
|
|
guard syncSnapshots else { return }
|
|
if deliveryGeneration != self.gatewayConnectGeneration {
|
|
// WatchConnectivity may finish by durably queueing the old payload after a route
|
|
// switch. Publish the replacement owner snapshots after that send completes.
|
|
await self.syncWatchAppSnapshot(reason: "\(reason)_route_repair")
|
|
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_route_repair")
|
|
return
|
|
}
|
|
await self.syncWatchAppSnapshot(reason: "\(reason)_app")
|
|
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_snapshot")
|
|
}
|
|
|
|
private func publishWatchExecApprovalResolved(
|
|
approvalId: String,
|
|
gatewayStableID: String,
|
|
decision: OpenClawWatchExecApprovalDecision?,
|
|
outcome: OpenClawWatchExecApprovalOutcome,
|
|
outcomeText: String,
|
|
resolvedAtMs: Int64? = nil,
|
|
source: String,
|
|
syncSnapshots: Bool = true) async
|
|
{
|
|
guard let approvalID = Self.validatedApprovalID(approvalId),
|
|
Self.execApprovalIDKey(approvalID) != nil
|
|
else { return }
|
|
self.markExecApprovalOwnerTerminal(
|
|
approvalId: approvalID,
|
|
gatewayStableID: gatewayStableID)
|
|
let message = OpenClawWatchExecApprovalResolvedMessage(
|
|
approvalId: approvalID,
|
|
gatewayStableID: gatewayStableID,
|
|
decision: decision,
|
|
outcome: outcome,
|
|
resolvedAtMs: resolvedAtMs ?? Int64(Date().timeIntervalSince1970 * 1000),
|
|
source: source,
|
|
outcomeText: outcomeText)
|
|
do {
|
|
_ = try await self.watchMessagingService.sendExecApprovalResolved(message)
|
|
} catch {
|
|
self.watchExecApprovalLogger
|
|
.error(
|
|
"watch approval resolve failed id=\(approvalID, privacy: .public)")
|
|
self.watchExecApprovalLogger.error(
|
|
"watch approval resolve error=\(error.localizedDescription, privacy: .public)")
|
|
}
|
|
if syncSnapshots {
|
|
await self.syncWatchAppSnapshot(reason: "resolved_app")
|
|
await self.syncWatchExecApprovalSnapshot(reason: "resolved_snapshot")
|
|
}
|
|
}
|
|
|
|
private func publishWatchExecApprovalTerminal(
|
|
_ terminal: ExecApprovalTerminalResult,
|
|
gatewayStableID: String,
|
|
source: String,
|
|
syncSnapshots: Bool = true) async
|
|
{
|
|
if let outcome = Self.watchExecApprovalOutcome(for: terminal.verdict) {
|
|
await self.publishWatchExecApprovalResolved(
|
|
approvalId: terminal.id,
|
|
gatewayStableID: gatewayStableID,
|
|
decision: terminal.decision.flatMap(OpenClawWatchExecApprovalDecision.init(rawValue:)),
|
|
outcome: outcome,
|
|
outcomeText: Self.execApprovalTerminalText(
|
|
terminal,
|
|
alreadyResolved: source == "another-reviewer"),
|
|
resolvedAtMs: terminal.resolvedAtMs,
|
|
source: source,
|
|
syncSnapshots: syncSnapshots)
|
|
return
|
|
}
|
|
switch terminal.verdict {
|
|
case .allowOnce, .allowAlways, .deny:
|
|
preconditionFailure("terminal decision outcome must be mapped")
|
|
case .expired:
|
|
await self.publishWatchExecApprovalExpired(
|
|
approvalId: terminal.id,
|
|
gatewayStableID: gatewayStableID,
|
|
reason: .expired,
|
|
syncSnapshots: syncSnapshots)
|
|
case .cancelled:
|
|
await self.publishWatchExecApprovalExpired(
|
|
approvalId: terminal.id,
|
|
gatewayStableID: gatewayStableID,
|
|
reason: .unavailable,
|
|
syncSnapshots: syncSnapshots)
|
|
case .resolvedUnknown:
|
|
await self.publishWatchExecApprovalExpired(
|
|
approvalId: terminal.id,
|
|
gatewayStableID: gatewayStableID,
|
|
reason: .resolved,
|
|
syncSnapshots: syncSnapshots)
|
|
}
|
|
}
|
|
|
|
private static func watchExecApprovalOutcome(
|
|
for verdict: ExecApprovalTerminalVerdict) -> OpenClawWatchExecApprovalOutcome?
|
|
{
|
|
switch verdict {
|
|
case .allowOnce:
|
|
.allowedOnce
|
|
case .allowAlways:
|
|
.allowedAlways
|
|
case .deny:
|
|
.denied
|
|
case .expired, .cancelled, .resolvedUnknown:
|
|
nil
|
|
}
|
|
}
|
|
|
|
private func publishWatchExecApprovalExpired(
|
|
approvalId: String,
|
|
gatewayStableID: String,
|
|
reason: OpenClawWatchExecApprovalCloseReason,
|
|
syncSnapshots: Bool = true) async
|
|
{
|
|
guard let approvalID = Self.validatedApprovalID(approvalId),
|
|
Self.execApprovalIDKey(approvalID) != nil
|
|
else { return }
|
|
self.markExecApprovalOwnerTerminal(
|
|
approvalId: approvalID,
|
|
gatewayStableID: gatewayStableID)
|
|
let message = OpenClawWatchExecApprovalExpiredMessage(
|
|
approvalId: approvalID,
|
|
gatewayStableID: gatewayStableID,
|
|
reason: reason,
|
|
expiredAtMs: Int64(Date().timeIntervalSince1970 * 1000))
|
|
do {
|
|
_ = try await self.watchMessagingService.sendExecApprovalExpired(message)
|
|
} catch {
|
|
self.watchExecApprovalLogger
|
|
.error(
|
|
"watch approval expiry failed id=\(approvalID, privacy: .public)")
|
|
self.watchExecApprovalLogger.error(
|
|
"watch approval expiry error=\(error.localizedDescription, privacy: .public)")
|
|
}
|
|
if syncSnapshots {
|
|
await self.syncWatchAppSnapshot(reason: "expired_\(reason.rawValue)_app")
|
|
await self.syncWatchExecApprovalSnapshot(reason: "expired_\(reason.rawValue)")
|
|
}
|
|
}
|
|
|
|
private func syncWatchExecApprovalSnapshot(
|
|
reason: String,
|
|
requestId: String? = nil,
|
|
requestGatewayStableID: String? = nil,
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
guard shouldContinue() else { return }
|
|
let deliveryGeneration = self.gatewayConnectGeneration
|
|
self.pruneExpiredWatchExecApprovalPrompts()
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: sync snapshot start "
|
|
+ "reason=\(reason) cacheCount=\(self.watchExecApprovalPromptsByID.count) "
|
|
+ "backgrounded=\(self.isBackgrounded)")
|
|
let approvals = self.watchExecApprovalPromptsByID.values
|
|
.filter(self.isExecApprovalPromptCurrent)
|
|
.sorted { lhs, rhs in
|
|
let lhsExpires = lhs.expiresAtMs ?? Int64.max
|
|
let rhsExpires = rhs.expiresAtMs ?? Int64.max
|
|
if lhsExpires != rhsExpires {
|
|
return lhsExpires < rhsExpires
|
|
}
|
|
return Self.approvalIDSortsBefore(lhs.id, rhs.id)
|
|
}
|
|
.map(Self.makeWatchExecApprovalItem)
|
|
let gatewayStableID = self.currentExecApprovalGatewayStableID()
|
|
let exactRequestGatewayStableID = GatewayStableIdentifier.exact(requestGatewayStableID)
|
|
let requestOwnerMatches = if let gatewayStableID, let exactRequestGatewayStableID {
|
|
GatewayStableIdentifier.matches(gatewayStableID, exactRequestGatewayStableID)
|
|
} else {
|
|
false
|
|
}
|
|
let canAcknowledgeRequest = requestId?.isEmpty == false && requestOwnerMatches
|
|
let message = OpenClawWatchExecApprovalSnapshotMessage(
|
|
approvals: approvals,
|
|
gatewayStableID: gatewayStableID,
|
|
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
|
|
snapshotId: UUID().uuidString,
|
|
requestId: canAcknowledgeRequest ? requestId : nil,
|
|
requestGatewayStableID: canAcknowledgeRequest ? exactRequestGatewayStableID : nil)
|
|
do {
|
|
guard shouldContinue() else { return }
|
|
_ = try await self.watchMessagingService.syncExecApprovalSnapshot(message)
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: sync snapshot sent reason=\(reason) count=\(approvals.count)")
|
|
self.watchExecApprovalLogger
|
|
.debug("watch approval snapshot reason=\(reason, privacy: .public)")
|
|
self.watchExecApprovalLogger.debug(
|
|
"watch approval snapshot count=\(approvals.count, privacy: .public)")
|
|
if deliveryGeneration != self.gatewayConnectGeneration {
|
|
await self.syncWatchExecApprovalSnapshot(reason: "\(reason)_route_repair")
|
|
}
|
|
} catch {
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: sync snapshot failed reason=\(reason) error=\(error.localizedDescription)")
|
|
self.watchExecApprovalLogger
|
|
.error(
|
|
"watch approval snapshot failed reason=\(reason, privacy: .public)")
|
|
self.watchExecApprovalLogger.error(
|
|
"watch approval snapshot error=\(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
private func makeWatchChatPreview() async -> WatchChatPreview {
|
|
do {
|
|
let payload: OpenClawChatHistoryPayload
|
|
if self.isAppleReviewDemoModeEnabled {
|
|
payload = try await self.appleReviewDemoChatTransport.requestHistory(sessionKey: self.chatSessionKey)
|
|
} else {
|
|
guard self.isOperatorGatewayConnected else {
|
|
return WatchChatPreview(
|
|
items: [],
|
|
status: OpenClawWatchAppStatus(code: .chatConnectIPhone),
|
|
statusText: "Connect iPhone chat to read messages")
|
|
}
|
|
payload = try await IOSGatewayChatTransport(gateway: self.operatorSession)
|
|
.requestHistory(sessionKey: self.chatSessionKey)
|
|
}
|
|
|
|
let items = Self.makeWatchChatItems(from: payload.messages ?? [])
|
|
return WatchChatPreview(
|
|
items: items,
|
|
status: items.isEmpty
|
|
? OpenClawWatchAppStatus(code: .chatNoMessages)
|
|
: nil,
|
|
statusText: items.isEmpty ? "No chat messages yet" : nil)
|
|
} catch {
|
|
GatewayDiagnostics.log("watch app snapshot: chat preview failed error=\(error.localizedDescription)")
|
|
return WatchChatPreview(
|
|
items: [],
|
|
status: OpenClawWatchAppStatus(code: .chatUnavailable),
|
|
statusText: "Chat unavailable")
|
|
}
|
|
}
|
|
|
|
private nonisolated static func watchChatReplyText(
|
|
from raw: [OpenClawKit.AnyCodable],
|
|
runId: String,
|
|
submittedText: String,
|
|
submittedAtMs: Int64) -> String?
|
|
{
|
|
let entries = raw.compactMap(self.decodeWatchChatMessage)
|
|
if let directReply = entries.last(where: {
|
|
self.isTerminalWatchAssistant($0) && $0.message.idempotencyKey == runId
|
|
}) {
|
|
return directReply.text
|
|
}
|
|
|
|
let userIdempotencyKey = "\(runId):user"
|
|
let exactUserIndex = entries.lastIndex(where: {
|
|
$0.message.role.lowercased() == "user" &&
|
|
$0.message.idempotencyKey == userIdempotencyKey
|
|
})
|
|
let queuedUserIndex = entries.lastIndex(where: { entry in
|
|
guard entry.message.role.lowercased() == "user",
|
|
let timestampMs = self.watchTimestampMs(entry.message.timestamp),
|
|
timestampMs >= submittedAtMs
|
|
else {
|
|
return false
|
|
}
|
|
return entry.text.contains(submittedText)
|
|
})
|
|
guard let userIndex = exactUserIndex ?? queuedUserIndex else { return nil }
|
|
return entries[(userIndex + 1)...].first(where: {
|
|
self.isTerminalWatchAssistant($0)
|
|
})?.text
|
|
}
|
|
|
|
private nonisolated static func isTerminalWatchAssistant(_ entry: WatchChatMessageEntry) -> Bool {
|
|
guard entry.message.role.lowercased() == "assistant" else { return false }
|
|
if entry.isMessageToolMirror {
|
|
return true
|
|
}
|
|
guard let stopReason = entry.message.stopReason?.lowercased() else { return false }
|
|
// Tool-use rows can contain visible progress text, but a later assistant row owns the final reply.
|
|
return stopReason != "tooluse" && stopReason != "tool_use" && stopReason != "tool_calls"
|
|
}
|
|
|
|
private nonisolated static func decodeWatchChatMessage(
|
|
_ raw: OpenClawKit.AnyCodable) -> WatchChatMessageEntry?
|
|
{
|
|
guard let data = try? JSONEncoder().encode(raw),
|
|
let message = try? JSONDecoder().decode(OpenClawChatMessage.self, from: data),
|
|
let text = nonEmptyWatchChatText(watchChatText(from: message))
|
|
else {
|
|
return nil
|
|
}
|
|
let metadata = try? JSONDecoder().decode(WatchChatMetadataEnvelope.self, from: data)
|
|
return WatchChatMessageEntry(
|
|
message: message,
|
|
text: text,
|
|
serverId: metadata?.metadata?.id,
|
|
isMessageToolMirror: metadata?.messageToolMirror != nil)
|
|
}
|
|
|
|
private nonisolated static func makeWatchChatItems(
|
|
from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem]
|
|
{
|
|
let readableMessages = raw.compactMap(self.decodeWatchChatMessage)
|
|
var idOccurrences: [String: Int] = [:]
|
|
let identified = readableMessages.map { entry -> (WatchChatMessageEntry, String) in
|
|
let baseId = entry.serverId.map { "\(entry.message.role)-\($0)" }
|
|
?? self.watchChatFallbackKey(entry)
|
|
idOccurrences[baseId, default: 0] += 1
|
|
let stableId = "\(baseId)-\(idOccurrences[baseId]!)"
|
|
return (entry, stableId)
|
|
}
|
|
return identified.suffix(self.watchChatPreviewItemLimit).map { entry, stableId in
|
|
let timestampMs = self.watchTimestampMs(entry.message.timestamp)
|
|
return OpenClawWatchChatItem(
|
|
id: stableId,
|
|
role: entry.message.role,
|
|
text: self.truncatedWatchChatText(entry.text),
|
|
timestampMs: timestampMs)
|
|
}
|
|
}
|
|
|
|
private nonisolated static func watchChatFallbackKey(_ entry: WatchChatMessageEntry) -> String {
|
|
let timestamp = self.watchTimestampMs(entry.message.timestamp).map(String.init) ?? "missing"
|
|
let source = "\(entry.message.role)\u{0}\(timestamp)\u{0}\(entry.text)"
|
|
let digest = SHA256.hash(data: Data(source.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
return "\(entry.message.role)-\(digest)"
|
|
}
|
|
|
|
private nonisolated static func watchChatText(from message: OpenClawChatMessage) -> String {
|
|
let parts = message.content.compactMap { content -> String? in
|
|
let kind = (content.type ?? "text").lowercased()
|
|
guard kind.isEmpty || kind == "text" || kind == "output_text" else { return nil }
|
|
if let text = self.nonEmptyWatchChatText(content.text) {
|
|
return text
|
|
}
|
|
if let text = self.nonEmptyWatchChatText(content.content?.value as? String) {
|
|
return text
|
|
}
|
|
if let dict = content.content?.value as? [String: OpenClawKit.AnyCodable],
|
|
let text = self.nonEmptyWatchChatText(dict["text"]?.value as? String)
|
|
{
|
|
return text
|
|
}
|
|
return nil
|
|
}
|
|
let contentText = parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !contentText.isEmpty {
|
|
return contentText
|
|
}
|
|
return message.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
}
|
|
|
|
private nonisolated static func nonEmptyWatchChatText(_ text: String?) -> String? {
|
|
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private nonisolated static func truncatedWatchChatText(_ text: String) -> String {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard trimmed.count > 240 else { return trimmed }
|
|
return "\(trimmed.prefix(237))..."
|
|
}
|
|
|
|
private nonisolated static func watchTimestampMs(_ timestamp: Double?) -> Int64? {
|
|
guard let timestamp, timestamp.isFinite, timestamp >= 0 else { return nil }
|
|
let milliseconds = timestamp > 100_000_000_000 ? timestamp : timestamp * 1000
|
|
let maxReasonableEpochMs: Double = 32_503_680_000_000
|
|
guard milliseconds.isFinite,
|
|
milliseconds >= 0,
|
|
milliseconds <= maxReasonableEpochMs
|
|
else {
|
|
return nil
|
|
}
|
|
return Int64(milliseconds)
|
|
}
|
|
|
|
private func makeWatchAppSnapshot(
|
|
chatPreview: WatchChatPreview? = nil) -> OpenClawWatchAppSnapshotMessage
|
|
{
|
|
self.pruneExpiredWatchExecApprovalPrompts()
|
|
let watchGatewayConnected = self.isAppleReviewDemoModeEnabled
|
|
|| (self.gatewayConnected && self.operatorConnected)
|
|
let displayStatusText = self.gatewayDisplayStatusText
|
|
let watchGatewayStatusText = watchGatewayConnected || displayStatusText != "Connected"
|
|
? displayStatusText
|
|
: self.operatorStatusText
|
|
return OpenClawWatchAppSnapshotMessage(
|
|
gatewayStatus: self.makeWatchGatewayStatus(connected: watchGatewayConnected),
|
|
gatewayStatusText: watchGatewayStatusText,
|
|
gatewayConnected: watchGatewayConnected,
|
|
agentName: self.chatAgentName,
|
|
agentAvatarURL: self.chatAgentAvatarURL,
|
|
agentAvatarText: self.chatAgentAvatarText,
|
|
sessionKey: self.chatSessionKey,
|
|
gatewayStableID: self.currentWatchChatGatewayStableID(),
|
|
talkStatus: self.makeWatchTalkStatus(),
|
|
talkStatusText: self.talkMode.statusText,
|
|
talkEnabled: self.talkMode.isEnabled,
|
|
talkListening: self.talkMode.isListening,
|
|
talkSpeaking: self.talkMode.isSpeaking,
|
|
pendingApprovalCount: self.watchExecApprovalPromptsByID.count,
|
|
chatItems: chatPreview?.items,
|
|
chatStatus: chatPreview?.status,
|
|
chatStatusText: chatPreview?.statusText,
|
|
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
|
|
snapshotId: UUID().uuidString)
|
|
}
|
|
|
|
private func makeWatchGatewayStatus(connected: Bool) -> OpenClawWatchAppStatus {
|
|
if connected {
|
|
return OpenClawWatchAppStatus(code: .gatewayConnected)
|
|
}
|
|
if let problem = self.lastGatewayProblem {
|
|
return Self.makeWatchGatewayProblemStatus(problem)
|
|
}
|
|
if let watchGatewayConnectionStatus {
|
|
return OpenClawWatchAppStatus(code: watchGatewayConnectionStatus)
|
|
}
|
|
let statusText = self.gatewayStatusText == "Connected"
|
|
? self.operatorStatusText
|
|
: self.gatewayStatusText
|
|
if statusText == "Offline" {
|
|
return OpenClawWatchAppStatus(code: .gatewayOffline)
|
|
}
|
|
return OpenClawWatchAppStatus(code: .legacy, verbatim: statusText)
|
|
}
|
|
|
|
func setGatewayConnectionProgress(reconnecting: Bool) {
|
|
self.gatewayStatusText = reconnecting ? "Reconnecting…" : "Connecting…"
|
|
self.watchGatewayConnectionStatus = reconnecting
|
|
? .gatewayReconnecting
|
|
: .gatewayConnecting
|
|
}
|
|
|
|
private static func makeWatchGatewayProblemStatus(
|
|
_ problem: GatewayConnectionProblem) -> OpenClawWatchAppStatus
|
|
{
|
|
let requestID: String? = switch problem.kind {
|
|
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired,
|
|
.pairingMetadataUpgradeRequired, .protocolMismatch:
|
|
problem.requestId
|
|
default:
|
|
nil
|
|
}
|
|
let code: OpenClawWatchAppStatusCode = requestID != nil
|
|
? .gatewayProblemWithRequestID
|
|
: .gatewayProblem
|
|
let requestArguments = requestID.map { [$0] } ?? []
|
|
return switch problem.titlePresentation {
|
|
case let .localized(key):
|
|
OpenClawWatchAppStatus(
|
|
code: code,
|
|
localizationKey: key,
|
|
arguments: requestArguments)
|
|
case let .localizedFormat(key, arguments):
|
|
OpenClawWatchAppStatus(
|
|
code: code,
|
|
localizationKey: key,
|
|
arguments: arguments + requestArguments)
|
|
case let .verbatim(value):
|
|
OpenClawWatchAppStatus(
|
|
code: code,
|
|
arguments: requestArguments,
|
|
verbatim: value)
|
|
}
|
|
}
|
|
|
|
private func makeWatchTalkStatus() -> OpenClawWatchAppStatus {
|
|
if self.talkMode.isSpeaking {
|
|
return OpenClawWatchAppStatus(code: .talkSpeaking)
|
|
}
|
|
if self.talkMode.isListening {
|
|
return OpenClawWatchAppStatus(code: .talkListening)
|
|
}
|
|
if self.talkMode.hasActivePushToTalkSession {
|
|
return self.makeWatchTalkPresentationStatus()
|
|
}
|
|
switch self.talkMode.watchPresentation {
|
|
case .localized, .verbatim:
|
|
return self.makeWatchTalkPresentationStatus()
|
|
case .phase:
|
|
break
|
|
}
|
|
if !self.talkMode.isEnabled {
|
|
return OpenClawWatchAppStatus(code: .talkOff)
|
|
}
|
|
if !self.talkMode.isGatewayConnected {
|
|
return OpenClawWatchAppStatus(code: .talkOffline)
|
|
}
|
|
switch self.talkMode.gatewayTalkPermissionState {
|
|
case .unknown, .ready:
|
|
break
|
|
case let .missingScope(scope):
|
|
return OpenClawWatchAppStatus(code: .talkPermissionRequired, arguments: [scope])
|
|
case .requestingUpgrade:
|
|
return OpenClawWatchAppStatus(code: .talkRequestingApproval)
|
|
case .upgradeRequested:
|
|
return OpenClawWatchAppStatus(code: .talkApprovalRequested)
|
|
case let .requestFailed(message), let .loadFailed(message):
|
|
return OpenClawWatchAppStatus(code: .talkFailure, verbatim: message)
|
|
case .apiKeyMissing:
|
|
return OpenClawWatchAppStatus(code: .talkAPIKeyMissing)
|
|
}
|
|
return self.makeWatchTalkPresentationStatus()
|
|
}
|
|
|
|
private func makeWatchTalkPresentationStatus() -> OpenClawWatchAppStatus {
|
|
switch self.talkMode.watchPresentation {
|
|
case let .localized(key):
|
|
return OpenClawWatchAppStatus(code: .talkFailure, localizationKey: key)
|
|
case .phase:
|
|
break
|
|
case let .verbatim(value):
|
|
return OpenClawWatchAppStatus(code: .talkFailure, verbatim: value)
|
|
}
|
|
return switch self.talkMode.phase {
|
|
case .connecting:
|
|
OpenClawWatchAppStatus(code: .talkConnecting)
|
|
case .thinking:
|
|
OpenClawWatchAppStatus(code: .talkThinking)
|
|
case .listening:
|
|
OpenClawWatchAppStatus(code: .talkListening)
|
|
case .speaking:
|
|
OpenClawWatchAppStatus(code: .talkSpeaking)
|
|
case .idle:
|
|
OpenClawWatchAppStatus(code: .talkReady)
|
|
}
|
|
}
|
|
|
|
private func handleWatchAppCommand(_ event: WatchAppCommandEvent) async {
|
|
GatewayDiagnostics.log(
|
|
"watch app command: handle id=\(event.commandId) command=\(event.command.rawValue)")
|
|
if event.command != .sendChat,
|
|
!self.watchAppCommandTargetsCurrentGatewayIfTagged(event)
|
|
{
|
|
GatewayDiagnostics.log("watch app command skipped: stale gateway target")
|
|
await self.syncWatchAppSnapshot(reason: "watch_command_stale_gateway", includeChat: true)
|
|
return
|
|
}
|
|
switch event.command {
|
|
case .refresh:
|
|
break
|
|
case .openChat:
|
|
self.openChat(sessionKey: event.sessionKey ?? self.chatSessionKey)
|
|
case .sendChat:
|
|
await self.handleWatchChatCommand(event)
|
|
return
|
|
case .startTalk:
|
|
guard !self.isAppleReviewDemoModeEnabled else { break }
|
|
self.synchronizeTalkSessionKey(event.sessionKey ?? self.chatSessionKey)
|
|
self.setTalkEnabled(true)
|
|
case .stopTalk:
|
|
self.setTalkEnabled(false)
|
|
}
|
|
await self.syncWatchAppSnapshot(
|
|
reason: "watch_command_\(event.command.rawValue)",
|
|
includeChat: true)
|
|
}
|
|
|
|
private func handleWatchChatCommand(_ event: WatchAppCommandEvent) async {
|
|
if self.currentWatchChatGatewayStableID() == nil {
|
|
// Startup may deliver a route-tagged Watch action before restoring that route.
|
|
// Queue it without publishing an ownerless snapshot that would erase Watch routing.
|
|
await self.handleWatchMessage(event)
|
|
return
|
|
}
|
|
guard self.watchMessageTargetsCurrentGateway(event) else {
|
|
GatewayDiagnostics.log("watch chat send skipped: stale gateway target")
|
|
await self.syncWatchAppSnapshot(reason: "watch_chat_stale_gateway", includeChat: true)
|
|
return
|
|
}
|
|
await self.handleWatchMessage(event)
|
|
}
|
|
|
|
private func handleWatchMessage(_ event: WatchAppCommandEvent) async {
|
|
let eventGatewayID = self.normalizedWatchMessageGatewayStableID(event)
|
|
let isAvailable = self.isWatchMessageSendAvailable()
|
|
if isAvailable, !self.watchMessageTargetsCurrentGateway(event) {
|
|
GatewayDiagnostics.log("watch message send skipped: stale gateway target")
|
|
return
|
|
}
|
|
switch self.watchMessageOutbox.ingest(
|
|
event,
|
|
isAvailable: isAvailable,
|
|
gatewayStableID: eventGatewayID)
|
|
{
|
|
case .dropMissingFields:
|
|
GatewayDiagnostics.log("watch message send skipped: missing id/text")
|
|
case .dropMissingTarget:
|
|
GatewayDiagnostics.log("watch message send skipped: missing gateway target")
|
|
case let .deduped(messageID):
|
|
GatewayDiagnostics.log("watch message send deduped id=\(messageID)")
|
|
case let .queue(messageID):
|
|
GatewayDiagnostics.log("watch message send queued id=\(messageID)")
|
|
if self.watchMessageKind(event) == .chat,
|
|
self.currentWatchChatGatewayStableID() != nil
|
|
{
|
|
await self.syncWatchAppSnapshot(reason: "watch_chat_queued", includeChat: true)
|
|
}
|
|
case .forward:
|
|
switch await self.forwardWatchMessage(event, requeueOnFailure: true) {
|
|
case .sent, .discard:
|
|
self.watchMessageOutbox.removeQueuedMessage(
|
|
messageID: event.commandId,
|
|
gatewayStableID: eventGatewayID)
|
|
self.watchMessageRetryAttempts[event.commandId] = nil
|
|
case .retry:
|
|
self.scheduleWatchMessageRetry(messageID: event.commandId)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func flushQueuedWatchMessagesIfAvailable() async {
|
|
guard !self.watchMessageFlushInFlight else { return }
|
|
self.watchMessageFlushInFlight = true
|
|
defer { self.watchMessageFlushInFlight = false }
|
|
guard let gatewayStableID = currentWatchChatGatewayStableID() else { return }
|
|
while GatewayStableIdentifier.matches(
|
|
self.currentWatchChatGatewayStableID(),
|
|
gatewayStableID)
|
|
{
|
|
guard let event = watchMessageOutbox.nextQueuedMessage(
|
|
isAvailable: isWatchMessageSendAvailable(),
|
|
gatewayStableID: gatewayStableID)
|
|
else { return }
|
|
guard self.watchMessageTargetsCurrentGateway(event) else { return }
|
|
switch await self.forwardWatchMessage(event, requeueOnFailure: false) {
|
|
case .sent, .discard:
|
|
self.watchMessageOutbox.removeQueuedMessage(
|
|
messageID: event.commandId,
|
|
gatewayStableID: gatewayStableID)
|
|
self.watchMessageRetryAttempts[event.commandId] = nil
|
|
case .retry:
|
|
self.scheduleWatchMessageRetry(messageID: event.commandId)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private func scheduleWatchMessageRetry(messageID: String) {
|
|
guard self.isWatchMessageSendAvailable(), self.watchMessageRetryTask == nil else { return }
|
|
let attempt = (watchMessageRetryAttempts[messageID] ?? 0) + 1
|
|
guard attempt <= Self.watchMessageMaxImmediateRetryAttempts else {
|
|
GatewayDiagnostics.log("watch message retry deferred until reconnect id=\(messageID)")
|
|
return
|
|
}
|
|
self.watchMessageRetryAttempts[messageID] = attempt
|
|
let delayNanoseconds = UInt64(500 * (1 << (attempt - 1))) * 1_000_000
|
|
self.watchMessageRetryTask = Task { [weak self] in
|
|
try? await Task.sleep(nanoseconds: delayNanoseconds)
|
|
guard let self else { return }
|
|
self.watchMessageRetryTask = nil
|
|
await self.flushQueuedWatchMessagesIfAvailable()
|
|
}
|
|
}
|
|
|
|
private func isWatchMessageSendAvailable() -> Bool {
|
|
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
|
|
}
|
|
|
|
private func currentWatchChatGatewayStableID() -> String? {
|
|
GatewayStableIdentifier.exact(self.connectedGatewayID)
|
|
}
|
|
|
|
private func normalizedWatchMessageGatewayStableID(_ event: WatchAppCommandEvent) -> String? {
|
|
GatewayStableIdentifier.exact(event.gatewayStableID)
|
|
}
|
|
|
|
private func watchMessageTargetsCurrentGateway(_ event: WatchAppCommandEvent) -> Bool {
|
|
guard let eventGatewayID = self.normalizedWatchMessageGatewayStableID(event),
|
|
let currentGatewayID = self.currentWatchChatGatewayStableID()
|
|
else { return false }
|
|
return GatewayStableIdentifier.matches(eventGatewayID, currentGatewayID)
|
|
}
|
|
|
|
private func watchAppCommandTargetsCurrentGatewayIfTagged(_ event: WatchAppCommandEvent) -> Bool {
|
|
guard let eventGatewayID = normalizedWatchMessageGatewayStableID(event) else {
|
|
// Ownerless commands predate route tagging and remain valid for compatibility.
|
|
return true
|
|
}
|
|
return GatewayStableIdentifier.matches(
|
|
eventGatewayID,
|
|
self.currentWatchChatGatewayStableID())
|
|
}
|
|
|
|
private func watchMessageKind(_ event: WatchAppCommandEvent) -> WatchMessageKind {
|
|
event.messageKind ?? .chat
|
|
}
|
|
|
|
private func forwardWatchMessage(
|
|
_ event: WatchAppCommandEvent,
|
|
requeueOnFailure: Bool) async -> WatchMessageSendOutcome
|
|
{
|
|
guard self.watchMessageTargetsCurrentGateway(event) else {
|
|
GatewayDiagnostics.log("watch message send skipped: stale gateway target")
|
|
return .retry
|
|
}
|
|
let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !text.isEmpty else {
|
|
GatewayDiagnostics.log("watch chat send skipped: empty text")
|
|
return .discard
|
|
}
|
|
|
|
let messageKind = self.watchMessageKind(event)
|
|
let fallbackSessionKey = messageKind == .quickReply ? self.mainSessionKey : self.chatSessionKey
|
|
let sessionKey = (event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
|
? event.sessionKey!
|
|
: fallbackSessionKey
|
|
if messageKind == .chat {
|
|
self.focusChatSession(sessionKey)
|
|
}
|
|
let thinking = messageKind == .quickReply ? "low" : "auto"
|
|
|
|
do {
|
|
let submittedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
if self.isAppleReviewDemoModeEnabled {
|
|
let response = try await appleReviewDemoChatTransport.sendMessage(
|
|
sessionKey: sessionKey,
|
|
message: text,
|
|
thinking: thinking,
|
|
idempotencyKey: event.commandId,
|
|
attachments: [])
|
|
if messageKind == .quickReply {
|
|
await self.finishForwardedWatchMessage(event)
|
|
return .sent
|
|
}
|
|
let history = try await appleReviewDemoChatTransport.requestHistory(sessionKey: sessionKey)
|
|
if let replyText = Self.watchChatReplyText(
|
|
from: history.messages ?? [],
|
|
runId: response.runId,
|
|
submittedText: text,
|
|
submittedAtMs: submittedAtMs)
|
|
{
|
|
await self.sendWatchChatCompletion(commandId: event.commandId, replyText: replyText)
|
|
}
|
|
await self.syncWatchAppSnapshot(reason: "watch_chat_completed", includeChat: true)
|
|
return .sent
|
|
}
|
|
|
|
guard self.isOperatorGatewayConnected else {
|
|
GatewayDiagnostics.log("watch chat send skipped: operator gateway disconnected")
|
|
if requeueOnFailure {
|
|
self.watchMessageOutbox.requeueFront(
|
|
event,
|
|
gatewayStableID: self.normalizedWatchMessageGatewayStableID(event))
|
|
}
|
|
return .retry
|
|
}
|
|
guard self.watchMessageTargetsCurrentGateway(event),
|
|
let operatorRoute = await operatorSession.currentRoute(),
|
|
isOperatorGatewayConnected,
|
|
watchMessageTargetsCurrentGateway(event)
|
|
else {
|
|
GatewayDiagnostics.log("watch chat send skipped: gateway route changed before dispatch")
|
|
return .retry
|
|
}
|
|
|
|
let transport = IOSGatewayChatTransport(gateway: operatorSession)
|
|
let completionDeadline = Date().addingTimeInterval(
|
|
Double(Self.watchChatCompletionWaitMs) / 1000)
|
|
let response = try await transport.sendMessage(
|
|
sessionKey: sessionKey,
|
|
message: text,
|
|
thinking: thinking,
|
|
idempotencyKey: event.commandId,
|
|
attachments: [],
|
|
ifCurrentRoute: operatorRoute)
|
|
if messageKind == .quickReply {
|
|
await self.finishForwardedWatchMessage(event)
|
|
return .sent
|
|
}
|
|
await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true)
|
|
_ = await transport.waitForRunCompletion(
|
|
runId: response.runId,
|
|
timeoutMs: Self.watchChatRunWaitSliceMs,
|
|
ifCurrentRoute: operatorRoute)
|
|
if let replyText = await waitForWatchChatReply(
|
|
transport: transport,
|
|
sessionKey: sessionKey,
|
|
runId: response.runId,
|
|
submittedText: text,
|
|
submittedAtMs: submittedAtMs,
|
|
deadline: completionDeadline,
|
|
expectedRoute: operatorRoute)
|
|
{
|
|
guard self.watchMessageTargetsCurrentGateway(event),
|
|
await self.operatorSession.currentRoute() == operatorRoute
|
|
else {
|
|
GatewayDiagnostics.log("watch chat completion skipped: gateway route changed")
|
|
return .discard
|
|
}
|
|
await self.sendWatchChatCompletion(commandId: event.commandId, replyText: replyText)
|
|
}
|
|
await self.syncWatchAppSnapshot(
|
|
reason: "watch_chat_completed",
|
|
includeChat: true,
|
|
shouldContinue: { self.watchMessageTargetsCurrentGateway(event) })
|
|
return .sent
|
|
} catch is CancellationError {
|
|
if !self.watchMessageTargetsCurrentGateway(event) {
|
|
GatewayDiagnostics.log("watch chat send canceled: gateway target changed")
|
|
return .discard
|
|
}
|
|
GatewayDiagnostics.log("watch chat send canceled before dispatch")
|
|
if requeueOnFailure {
|
|
self.watchMessageOutbox.requeueFront(
|
|
event,
|
|
gatewayStableID: self.normalizedWatchMessageGatewayStableID(event))
|
|
}
|
|
return .retry
|
|
} catch {
|
|
GatewayDiagnostics.log("watch chat send failed error=\(error.localizedDescription)")
|
|
if Self.shouldDiscardFailedWatchMessage(error) {
|
|
GatewayDiagnostics.log("watch message discarded after permanent send failure id=\(event.commandId)")
|
|
return .discard
|
|
}
|
|
if requeueOnFailure {
|
|
self.watchMessageOutbox.requeueFront(
|
|
event,
|
|
gatewayStableID: self.normalizedWatchMessageGatewayStableID(event))
|
|
}
|
|
return .retry
|
|
}
|
|
}
|
|
|
|
private func waitForWatchChatReply(
|
|
transport: IOSGatewayChatTransport,
|
|
sessionKey: String,
|
|
runId: String,
|
|
submittedText: String,
|
|
submittedAtMs: Int64,
|
|
deadline: Date,
|
|
expectedRoute: GatewayNodeSessionRoute) async -> String?
|
|
{
|
|
repeat {
|
|
guard await self.operatorSession.currentRoute() == expectedRoute else { return nil }
|
|
if let payload = try? await transport.requestHistory(
|
|
sessionKey: sessionKey,
|
|
ifCurrentRoute: expectedRoute),
|
|
let replyText = Self.watchChatReplyText(
|
|
from: payload.messages ?? [],
|
|
runId: runId,
|
|
submittedText: submittedText,
|
|
submittedAtMs: submittedAtMs)
|
|
{
|
|
return replyText
|
|
}
|
|
guard Date() < deadline else { return nil }
|
|
try? await Task.sleep(for: .seconds(1))
|
|
} while !Task.isCancelled
|
|
return nil
|
|
}
|
|
|
|
private func sendWatchChatCompletion(commandId: String, replyText: String) async {
|
|
do {
|
|
_ = try await self.watchMessagingService.sendChatCompletion(
|
|
OpenClawWatchChatCompletionMessage(
|
|
commandId: commandId,
|
|
replyText: replyText,
|
|
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000)))
|
|
} catch {
|
|
GatewayDiagnostics.log(
|
|
"watch chat completion failed commandId=\(commandId) error=\(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private nonisolated static func shouldDiscardFailedWatchMessage(_ error: Error) -> Bool {
|
|
guard let gatewayError = error as? GatewayResponseError else { return false }
|
|
guard gatewayError.code == "INVALID_REQUEST" else { return false }
|
|
return !gatewayError.message.lowercased().hasSuffix("retry.")
|
|
}
|
|
|
|
private func finishForwardedWatchMessage(_ event: WatchAppCommandEvent) async {
|
|
if self.watchMessageKind(event) == .chat {
|
|
await self.syncWatchAppSnapshot(reason: "watch_chat_sent", includeChat: true)
|
|
return
|
|
}
|
|
self.watchReplyLogger.info(
|
|
"watch reply forwarded replyId=\(event.commandId, privacy: .public)")
|
|
self.openChatRequestID &+= 1
|
|
}
|
|
|
|
private func syncWatchAppSnapshot(
|
|
reason: String,
|
|
includeChat: Bool = false,
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
guard shouldContinue() else { return }
|
|
let deliveryGeneration = self.gatewayConnectGeneration
|
|
let chatPreview = includeChat ? await makeWatchChatPreview() : nil
|
|
guard shouldContinue() else { return }
|
|
guard deliveryGeneration == self.gatewayConnectGeneration else {
|
|
await self.syncWatchAppSnapshot(reason: "\(reason)_route_repair")
|
|
return
|
|
}
|
|
let message = self.makeWatchAppSnapshot(chatPreview: chatPreview)
|
|
do {
|
|
guard shouldContinue() else { return }
|
|
_ = try await self.watchMessagingService.syncAppSnapshot(message)
|
|
GatewayDiagnostics.log(
|
|
"watch app snapshot: sent reason=\(reason) "
|
|
+ "connected=\(message.gatewayConnected) approvals=\(message.pendingApprovalCount) "
|
|
+ "chatItems=\(message.chatItems?.count ?? -1)")
|
|
if deliveryGeneration != self.gatewayConnectGeneration {
|
|
await self.syncWatchAppSnapshot(reason: "\(reason)_route_repair")
|
|
}
|
|
} catch {
|
|
GatewayDiagnostics.log(
|
|
"watch app snapshot: failed reason=\(reason) error=\(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func refreshWatchExecApprovalSnapshotOnDemand(
|
|
reason: String,
|
|
requestId: String?,
|
|
requestGatewayStableID: String?,
|
|
heldApprovals: [WatchExecApprovalSnapshotRequestItem]) async
|
|
{
|
|
GatewayDiagnostics.log("watch exec approval: refresh on demand start reason=\(reason)")
|
|
let currentGatewayStableID = self.currentExecApprovalGatewayStableID()
|
|
let requestOwnerMatches = GatewayStableIdentifier.matches(
|
|
currentGatewayStableID,
|
|
requestGatewayStableID)
|
|
let heldApprovalsToRead = requestOwnerMatches ? heldApprovals : []
|
|
let hydrationWasAuthoritative = await self.hydrateWatchExecApprovalCacheIfNeeded(
|
|
reason: reason,
|
|
syncSnapshots: false)
|
|
let reconciliationWasAuthoritative = await self.reconcileWatchExecApprovalCache(
|
|
reason: reason,
|
|
heldApprovals: heldApprovalsToRead,
|
|
syncSnapshots: false)
|
|
guard hydrationWasAuthoritative, reconciliationWasAuthoritative else {
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: refresh on demand withheld snapshot reason=\(reason)")
|
|
return
|
|
}
|
|
await self.syncWatchExecApprovalSnapshot(
|
|
reason: reason,
|
|
requestId: requestOwnerMatches ? requestId : nil,
|
|
requestGatewayStableID: requestOwnerMatches ? requestGatewayStableID : nil)
|
|
await self.syncWatchAppSnapshot(reason: "\(reason)_app", includeChat: true)
|
|
GatewayDiagnostics.log("watch exec approval: refresh on demand end reason=\(reason)")
|
|
}
|
|
|
|
@discardableResult
|
|
private func reconcileWatchExecApprovalCache(
|
|
reason: String,
|
|
heldApprovals: [WatchExecApprovalSnapshotRequestItem] = [],
|
|
syncSnapshots: Bool = true) async -> Bool
|
|
{
|
|
guard let gatewayStableID = self.currentExecApprovalGatewayStableID() else { return false }
|
|
var heldApprovalsByID: [ExecApprovalIdentifier.Key: WatchExecApprovalSnapshotRequestItem] = [:]
|
|
for heldApproval in heldApprovals {
|
|
guard let approvalID = Self.execApprovalIDKey(heldApproval.approvalId) else { continue }
|
|
if heldApprovalsByID[approvalID] == nil {
|
|
heldApprovalsByID[approvalID] = heldApproval
|
|
}
|
|
}
|
|
let prompts = self.watchExecApprovalPromptsByID.values
|
|
.filter(self.isExecApprovalPromptCurrent)
|
|
.sorted { Self.approvalIDSortsBefore($0.id, $1.id) }
|
|
let cachedApprovalIDs = Set(prompts.compactMap { Self.execApprovalIDKey($0.id) })
|
|
let persistedReadbacks = self.pendingPersistedExecApprovalReadbacks.filter {
|
|
GatewayStableIdentifier.matches($0.gatewayStableID, gatewayStableID) &&
|
|
Self.execApprovalIDKey($0.approvalId).map(cachedApprovalIDs.contains) != true
|
|
}
|
|
guard !prompts.isEmpty || !persistedReadbacks.isEmpty || !heldApprovalsByID.isEmpty else {
|
|
return true
|
|
}
|
|
|
|
let visiblePromptAtStart = self.pendingExecApprovalPrompt
|
|
let visiblePromptWasResolving = self.pendingExecApprovalPromptResolving
|
|
let surfaceGenerationAtStart = self.pendingExecApprovalPromptSurfaceGeneration
|
|
|
|
let cachedPass = await self.readBackCachedWatchExecApprovalPrompts(
|
|
prompts,
|
|
gatewayStableID: gatewayStableID,
|
|
reason: reason,
|
|
syncSnapshots: syncSnapshots)
|
|
let persistedPass = await self.readBackPersistedWatchExecApprovalReadbacks(
|
|
persistedReadbacks,
|
|
gatewayStableID: gatewayStableID,
|
|
syncSnapshots: syncSnapshots)
|
|
var classifiedApprovalIDs = cachedApprovalIDs
|
|
classifiedApprovalIDs.formUnion(persistedReadbacks.compactMap {
|
|
Self.execApprovalIDKey($0.approvalId)
|
|
})
|
|
let heldPass = await self.readBackHeldWatchExecApprovals(
|
|
heldApprovalsByID,
|
|
alreadyClassifiedApprovalIDs: classifiedApprovalIDs,
|
|
gatewayStableID: gatewayStableID,
|
|
reason: reason,
|
|
syncSnapshots: syncSnapshots)
|
|
// Concatenation order mirrors the original readback order: cached prompts,
|
|
// persisted readbacks, then held Watch approvals.
|
|
var loadedPrompts = cachedPass.loadedPrompts + persistedPass.loadedPrompts + heldPass.loadedPrompts
|
|
let allReadbacksWereAuthoritative = cachedPass.allReadbacksWereAuthoritative &&
|
|
persistedPass.allReadbacksWereAuthoritative &&
|
|
heldPass.allReadbacksWereAuthoritative
|
|
|
|
guard allReadbacksWereAuthoritative else { return false }
|
|
|
|
// Readbacks can interleave with terminal events while awaiting other owners.
|
|
// Re-check the live owner table instead of replaying the stale local array.
|
|
loadedPrompts = loadedPrompts.filter { prompt in
|
|
guard let key = Self.execApprovalInboxKey(prompt),
|
|
!self.terminalExecApprovalKeys.contains(key)
|
|
else { return false }
|
|
return self.execApprovalInboxPromptsByKey[key] == prompt
|
|
}
|
|
|
|
for prompt in loadedPrompts {
|
|
guard let approvalID = Self.execApprovalIDKey(prompt.id),
|
|
let heldAttemptID = heldApprovalsByID[approvalID]?.activeResolutionAttemptId,
|
|
let resetResolutionAttemptId = self.resettableWatchResolutionAttemptID(
|
|
for: prompt,
|
|
heldAttemptID: heldAttemptID)
|
|
else { continue }
|
|
await self.publishWatchExecApprovalPrompt(
|
|
prompt,
|
|
reason: "resolve_retry",
|
|
resetResolutionAttemptId: resetResolutionAttemptId,
|
|
syncSnapshots: syncSnapshots)
|
|
}
|
|
|
|
let visiblePromptNow = self.pendingExecApprovalPrompt
|
|
let phoneSurfaceUnchanged = self.pendingExecApprovalPromptSurfaceGeneration == surfaceGenerationAtStart
|
|
let matchingVisiblePrompt = phoneSurfaceUnchanged ? visiblePromptNow.flatMap { visiblePrompt in
|
|
loadedPrompts.first { prompt in
|
|
Self.approvalIDsMatch(prompt.id, visiblePrompt.id) &&
|
|
GatewayStableIdentifier.matches(
|
|
prompt.gatewayStableID,
|
|
visiblePrompt.gatewayStableID)
|
|
}
|
|
} : nil
|
|
let shouldRestorePhonePrompt = reason == "watch_request" || reason == "operator_reconnected"
|
|
let phoneSurfaceStayedEmpty = visiblePromptAtStart == nil &&
|
|
visiblePromptNow == nil &&
|
|
phoneSurfaceUnchanged
|
|
let firstUndismissedPrompt = loadedPrompts.first { prompt in
|
|
Self.execApprovalInboxKey(prompt).map {
|
|
!self.dismissedExecApprovalPresentationKeys.contains($0)
|
|
} == true
|
|
}
|
|
let selectedPhonePrompt = matchingVisiblePrompt ??
|
|
(phoneSurfaceStayedEmpty && shouldRestorePhonePrompt ? firstUndismissedPrompt : nil)
|
|
|
|
for prompt in loadedPrompts where selectedPhonePrompt.map({
|
|
Self.approvalIDsMatch($0.id, prompt.id) &&
|
|
GatewayStableIdentifier.matches($0.gatewayStableID, prompt.gatewayStableID)
|
|
}) != true && Self.execApprovalIDKey(prompt.id).flatMap({
|
|
heldApprovalsByID[$0]?.activeResolutionAttemptId
|
|
}) == nil {
|
|
// A Watch-only resolve can lose its response while the iPhone has no visible
|
|
// prompt. Every canonical pending row must unlock its matching Watch card.
|
|
await self.publishWatchExecApprovalPrompt(
|
|
prompt,
|
|
reason: "resolve_retry",
|
|
syncSnapshots: syncSnapshots)
|
|
}
|
|
|
|
guard let selectedPhonePrompt else { return allReadbacksWereAuthoritative }
|
|
let selectedPromptWasResolving = visiblePromptWasResolving &&
|
|
visiblePromptAtStart.map { Self.approvalIDsMatch($0.id, selectedPhonePrompt.id) } == true &&
|
|
visiblePromptNow.map { Self.approvalIDsMatch($0.id, selectedPhonePrompt.id) } == true &&
|
|
phoneSurfaceUnchanged
|
|
let selectedPromptWriteIsInFlight = self.isExecApprovalResolutionWriteInFlight(
|
|
approvalID: selectedPhonePrompt.id,
|
|
gatewayStableID: selectedPhonePrompt.gatewayStableID)
|
|
self.presentFetchedExecApprovalPrompt(selectedPhonePrompt, publishReason: "resolve_retry")
|
|
if selectedPromptWasResolving, !selectedPromptWriteIsInFlight {
|
|
self.pendingExecApprovalPromptErrorText =
|
|
"The previous decision was not recorded. Review and try again."
|
|
}
|
|
return allReadbacksWereAuthoritative
|
|
}
|
|
|
|
private struct WatchExecApprovalReadbackPass {
|
|
var loadedPrompts: [ExecApprovalPrompt] = []
|
|
var allReadbacksWereAuthoritative = true
|
|
}
|
|
|
|
private func readBackCachedWatchExecApprovalPrompts(
|
|
_ prompts: [ExecApprovalPrompt],
|
|
gatewayStableID: String,
|
|
reason: String,
|
|
syncSnapshots: Bool) async -> WatchExecApprovalReadbackPass
|
|
{
|
|
var pass = WatchExecApprovalReadbackPass()
|
|
for cachedPrompt in prompts {
|
|
let persistedReadback = PersistedExecApprovalReadback(
|
|
approvalId: cachedPrompt.id,
|
|
gatewayStableID: cachedPrompt.gatewayStableID)
|
|
let readback = await self.fetchExecApprovalPrompt(
|
|
approvalId: cachedPrompt.id,
|
|
sourceReason: reason)
|
|
switch readback {
|
|
case let .loaded(prompt):
|
|
self.upsertWatchExecApprovalPrompt(prompt)
|
|
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
|
pass.loadedPrompts.append(prompt)
|
|
case let .terminal(terminal):
|
|
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
|
let outcome = await self.applyCanonicalExecApprovalTerminal(
|
|
terminal,
|
|
appliedHere: false,
|
|
gatewayStableID: gatewayStableID,
|
|
syncSnapshots: syncSnapshots)
|
|
if case .failed = outcome {
|
|
pass.allReadbacksWereAuthoritative = false
|
|
}
|
|
case .stale:
|
|
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
|
self.markPendingExecApprovalTerminal(
|
|
approvalId: cachedPrompt.id,
|
|
outcome: ExecApprovalOutcome(
|
|
text: "This approval is no longer available.",
|
|
tone: .warning))
|
|
await self.publishWatchExecApprovalExpired(
|
|
approvalId: cachedPrompt.id,
|
|
gatewayStableID: gatewayStableID,
|
|
reason: .notFound,
|
|
syncSnapshots: syncSnapshots)
|
|
case .failed:
|
|
pass.allReadbacksWereAuthoritative = false
|
|
}
|
|
}
|
|
return pass
|
|
}
|
|
|
|
private func readBackPersistedWatchExecApprovalReadbacks(
|
|
_ persistedReadbacks: [PersistedExecApprovalReadback],
|
|
gatewayStableID: String,
|
|
syncSnapshots: Bool) async -> WatchExecApprovalReadbackPass
|
|
{
|
|
var pass = WatchExecApprovalReadbackPass()
|
|
for persistedReadback in persistedReadbacks {
|
|
let readback = await self.fetchExecApprovalPrompt(
|
|
approvalId: persistedReadback.approvalId,
|
|
sourceReason: "persisted_upgrade")
|
|
switch readback {
|
|
case let .loaded(prompt):
|
|
self.upsertWatchExecApprovalPrompt(prompt)
|
|
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
|
pass.loadedPrompts.append(prompt)
|
|
case let .terminal(terminal):
|
|
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
|
let outcome = await self.applyCanonicalExecApprovalTerminal(
|
|
terminal,
|
|
appliedHere: false,
|
|
gatewayStableID: gatewayStableID,
|
|
syncSnapshots: syncSnapshots)
|
|
if case .failed = outcome {
|
|
pass.allReadbacksWereAuthoritative = false
|
|
}
|
|
case .stale:
|
|
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
|
await self.publishWatchExecApprovalExpired(
|
|
approvalId: persistedReadback.approvalId,
|
|
gatewayStableID: gatewayStableID,
|
|
reason: .notFound,
|
|
syncSnapshots: syncSnapshots)
|
|
case .failed:
|
|
pass.allReadbacksWereAuthoritative = false
|
|
}
|
|
}
|
|
return pass
|
|
}
|
|
|
|
private func readBackHeldWatchExecApprovals(
|
|
_ heldApprovalsByID: [ExecApprovalIdentifier.Key: WatchExecApprovalSnapshotRequestItem],
|
|
alreadyClassifiedApprovalIDs: Set<ExecApprovalIdentifier.Key>,
|
|
gatewayStableID: String,
|
|
reason: String,
|
|
syncSnapshots: Bool) async -> WatchExecApprovalReadbackPass
|
|
{
|
|
var pass = WatchExecApprovalReadbackPass()
|
|
var classifiedApprovalIDs = alreadyClassifiedApprovalIDs
|
|
for heldApproval in heldApprovalsByID.values.sorted(by: {
|
|
Self.approvalIDSortsBefore($0.approvalId, $1.approvalId)
|
|
}) {
|
|
guard let approvalID = Self.execApprovalIDKey(heldApproval.approvalId),
|
|
classifiedApprovalIDs.insert(approvalID).inserted
|
|
else { continue }
|
|
switch await self.fetchExecApprovalPrompt(
|
|
approvalId: heldApproval.approvalId,
|
|
sourceReason: reason)
|
|
{
|
|
case let .loaded(prompt):
|
|
self.upsertWatchExecApprovalPrompt(prompt)
|
|
pass.loadedPrompts.append(prompt)
|
|
case let .terminal(terminal):
|
|
let outcome = await self.applyCanonicalExecApprovalTerminal(
|
|
terminal,
|
|
appliedHere: false,
|
|
gatewayStableID: gatewayStableID,
|
|
syncSnapshots: syncSnapshots)
|
|
if case .failed = outcome {
|
|
pass.allReadbacksWereAuthoritative = false
|
|
}
|
|
case .stale:
|
|
await self.publishWatchExecApprovalExpired(
|
|
approvalId: heldApproval.approvalId,
|
|
gatewayStableID: gatewayStableID,
|
|
reason: .notFound,
|
|
syncSnapshots: syncSnapshots)
|
|
case .failed:
|
|
pass.allReadbacksWereAuthoritative = false
|
|
}
|
|
}
|
|
return pass
|
|
}
|
|
|
|
private nonisolated static func watchExecApprovalIDsNeedingFetch(
|
|
candidateIDs: [String],
|
|
cachedApprovalIDs: [String]) -> [String]
|
|
{
|
|
let cachedIDs = Set(cachedApprovalIDs.compactMap(Self.execApprovalIDKey))
|
|
var idsToFetch: [String] = []
|
|
var seen = Set<ExecApprovalIdentifier.Key>()
|
|
for candidateID in candidateIDs {
|
|
guard let approvalID = Self.execApprovalIDKey(candidateID) else { continue }
|
|
guard seen.insert(approvalID).inserted else { continue }
|
|
guard !cachedIDs.contains(approvalID) else { continue }
|
|
idsToFetch.append(approvalID.rawValue)
|
|
}
|
|
return idsToFetch
|
|
}
|
|
|
|
@discardableResult
|
|
private func hydrateWatchExecApprovalCacheIfNeeded(
|
|
reason: String,
|
|
syncSnapshots: Bool = true) async -> Bool
|
|
{
|
|
self.pruneExpiredWatchExecApprovalPrompts()
|
|
|
|
let approvalPushes = await pendingExecApprovalPushesForWatchRecovery()
|
|
let missingApprovalIDs = Set(Self.watchExecApprovalIDsNeedingFetch(
|
|
candidateIDs: approvalPushes.map(\.approvalId),
|
|
cachedApprovalIDs: self.watchExecApprovalPromptsByID.keys.map(\.rawValue))
|
|
.compactMap(Self.execApprovalIDKey))
|
|
let missingApprovalIDText = missingApprovalIDs
|
|
.map(\.rawValue)
|
|
.sorted(by: Self.approvalIDSortsBefore)
|
|
.joined(separator: ",")
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: hydrate candidates "
|
|
+ "reason=\(reason) ids=\(approvalPushes.map(\.approvalId).joined(separator: ",")) "
|
|
+ "missing=\(missingApprovalIDText) "
|
|
+ "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 true
|
|
}
|
|
|
|
var allReadbacksWereAuthoritative = true
|
|
for push in approvalPushes
|
|
where Self.execApprovalIDKey(push.approvalId).map(missingApprovalIDs.contains) == true
|
|
{
|
|
let approvalId = push.approvalId
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: hydrate fetch start id=\(approvalId) reason=\(reason)")
|
|
let operatorRoute: GatewayNodeSessionRoute
|
|
switch await self.validateExecApprovalPushRoute(push, sourceReason: reason) {
|
|
case let .validated(context):
|
|
operatorRoute = context.route
|
|
case .unavailable:
|
|
allReadbacksWereAuthoritative = false
|
|
continue
|
|
case .mismatchedOwner:
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: self.notificationCenter)
|
|
self.removePendingWatchExecApprovalRecoveryPush(push)
|
|
continue
|
|
}
|
|
let outcome = await fetchExecApprovalPrompt(
|
|
approvalId: approvalId,
|
|
sourceReason: reason,
|
|
expectedOperatorRoute: operatorRoute)
|
|
switch outcome {
|
|
case let .loaded(prompt):
|
|
GatewayDiagnostics.log("watch exec approval: hydrate fetch loaded id=\(approvalId)")
|
|
self.upsertWatchExecApprovalPrompt(prompt)
|
|
case let .terminal(terminal):
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: hydrate fetch terminal id=\(approvalId) status=\(terminal.status)")
|
|
self.removePendingWatchExecApprovalRecoveryPush(push)
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: self.notificationCenter)
|
|
if let gatewayStableID = self.currentExecApprovalGatewayStableID() {
|
|
await self.publishWatchExecApprovalTerminal(
|
|
terminal,
|
|
gatewayStableID: gatewayStableID,
|
|
source: "gateway",
|
|
syncSnapshots: syncSnapshots)
|
|
}
|
|
case .stale:
|
|
GatewayDiagnostics.log("watch exec approval: hydrate fetch stale id=\(approvalId)")
|
|
self.removePendingWatchExecApprovalRecoveryPush(push)
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: self.notificationCenter)
|
|
case let .failed(message):
|
|
allReadbacksWereAuthoritative = false
|
|
self.watchExecApprovalLogger
|
|
.error("watch approval hydrate failed id=\(approvalId, privacy: .public)")
|
|
self.watchExecApprovalLogger.error("watch approval hydrate reason=\(reason, privacy: .public)")
|
|
self.watchExecApprovalLogger.error("watch approval hydrate error=\(message, privacy: .public)")
|
|
}
|
|
}
|
|
return allReadbacksWereAuthoritative
|
|
}
|
|
|
|
private func pendingExecApprovalPushesForWatchRecovery() async -> [ExecApprovalNotificationPrompt] {
|
|
var pushes = self.pendingWatchExecApprovalRecoveryPushes
|
|
var seen = Set(pushes.compactMap(Self.execApprovalPushKey))
|
|
|
|
let delivered = await notificationCenter.deliveredNotifications()
|
|
GatewayDiagnostics.log("watch exec approval: delivered notifications count=\(delivered.count)")
|
|
for snapshot in delivered {
|
|
guard let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: snapshot.userInfo),
|
|
let pushKey = Self.execApprovalPushKey(push),
|
|
seen.insert(pushKey).inserted
|
|
else { continue }
|
|
pushes.append(push)
|
|
// Notification Center may be the only surviving source after relaunch.
|
|
// Persist its owner tag so later route invalidation can remove only this alert.
|
|
self.appendPendingWatchExecApprovalRecoveryPush(push)
|
|
}
|
|
|
|
return pushes
|
|
}
|
|
|
|
@discardableResult
|
|
private func handleWatchExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) async -> Bool {
|
|
guard let approvalID = Self.validatedApprovalID(event.approvalId) else { return true }
|
|
guard let routedEvent = ownerScopedWatchExecApprovalEvent(
|
|
event,
|
|
approvalID: approvalID)
|
|
else {
|
|
await self.syncWatchExecApprovalSnapshot(reason: "legacy_watch_reply_rejected")
|
|
return true
|
|
}
|
|
guard let currentGatewayStableID = currentExecApprovalGatewayStableID() else {
|
|
self.enqueuePendingWatchExecApprovalResolution(routedEvent)
|
|
return false
|
|
}
|
|
guard GatewayStableIdentifier.matches(
|
|
routedEvent.gatewayStableID,
|
|
currentGatewayStableID)
|
|
else {
|
|
// Watch replies can arrive after a gateway switch. Reassert the current
|
|
// snapshot instead of allowing an old same-ID prompt to target the new gateway.
|
|
await self.syncWatchExecApprovalSnapshot(reason: "stale_gateway_reply")
|
|
return true
|
|
}
|
|
let routeGeneration = self.gatewayRouteGeneration
|
|
let prompt: ExecApprovalPrompt
|
|
if let cachedPrompt = Self.execApprovalIDKey(approvalID)
|
|
.flatMap({ watchExecApprovalPromptsByID[$0] }),
|
|
GatewayStableIdentifier.matches(
|
|
cachedPrompt.gatewayStableID,
|
|
currentGatewayStableID),
|
|
isExecApprovalPromptCurrent(cachedPrompt)
|
|
{
|
|
prompt = cachedPrompt
|
|
} else {
|
|
switch await self.readBackWatchExecApprovalPromptForResolve(
|
|
approvalID: approvalID,
|
|
routedEvent: routedEvent,
|
|
currentGatewayStableID: currentGatewayStableID,
|
|
routeGeneration: routeGeneration)
|
|
{
|
|
case let .prompt(loadedPrompt):
|
|
prompt = loadedPrompt
|
|
case let .handled(completed):
|
|
return completed
|
|
}
|
|
}
|
|
guard prompt.allowedDecisions.contains(routedEvent.decision.rawValue) else {
|
|
self.markWatchResolutionAttemptResettable(routedEvent)
|
|
let resetResolutionAttemptId = self.resettableWatchResolutionAttemptID(
|
|
for: prompt,
|
|
heldAttemptID: routedEvent.replyId)
|
|
await self.publishWatchExecApprovalPrompt(
|
|
prompt,
|
|
reason: "resolve_retry",
|
|
resetResolutionAttemptId: resetResolutionAttemptId)
|
|
return true
|
|
}
|
|
guard let resolutionAttempt = self.beginExecApprovalResolutionAttempt(
|
|
approvalID: prompt.id,
|
|
gatewayStableID: prompt.gatewayStableID)
|
|
else {
|
|
// Serialize phone and Watch writes by exact owner + ID. The delivered Watch
|
|
// action remains durable until the active contender releases this lease.
|
|
self.enqueuePendingWatchExecApprovalResolution(routedEvent)
|
|
return false
|
|
}
|
|
defer { self.finishExecApprovalResolutionAttempt(resolutionAttempt) }
|
|
|
|
if self.pendingExecApprovalPrompt.map({ Self.approvalIDsMatch($0.id, approvalID) }) == true,
|
|
GatewayStableIdentifier.matches(
|
|
self.pendingExecApprovalPrompt?.gatewayStableID,
|
|
prompt.gatewayStableID)
|
|
{
|
|
self.pendingExecApprovalPromptResolving = true
|
|
self.pendingExecApprovalPromptErrorText = nil
|
|
}
|
|
let outcome = await resolveExecApprovalNotificationDecision(
|
|
approvalId: approvalID,
|
|
approvalKind: prompt.kind,
|
|
decision: routedEvent.decision.rawValue,
|
|
expectedGatewayStableID: prompt.gatewayStableID,
|
|
sourceReason: "watch_resolve",
|
|
resolutionAttempt: resolutionAttempt)
|
|
if case let .uncertain(message) = outcome {
|
|
// Same contract as the phone path: a gateway switch invalidates the attempt,
|
|
// but the owner-scoped uncertainty + readback record must persist so the
|
|
// delivered Watch decision is never silently dropped without a trace.
|
|
self.markExecApprovalResolutionUncertain(
|
|
approvalID: approvalID,
|
|
gatewayStableID: prompt.gatewayStableID,
|
|
message: message)
|
|
}
|
|
guard self.isActiveExecApprovalResolutionAttempt(resolutionAttempt) else { return true }
|
|
switch outcome {
|
|
case .resolved, .stale:
|
|
return true
|
|
case let .pendingRetry(message):
|
|
self.markWatchResolutionAttemptResettable(routedEvent)
|
|
self.finishExecApprovalResolutionAttempt(resolutionAttempt)
|
|
// Readback definitively classified the approval as still pending. The
|
|
// lease-wide presentation fence left any re-presented phone card resolving,
|
|
// so releasing the lease must also unlock this exact owner's card.
|
|
self.unlockPendingExecApprovalPromptForRetry(
|
|
approvalID: approvalID,
|
|
gatewayStableID: prompt.gatewayStableID,
|
|
message: message)
|
|
await self.republishCachedWatchExecApprovalPromptForRetry(
|
|
approvalID: approvalID,
|
|
heldAttemptID: routedEvent.replyId)
|
|
return true
|
|
case .uncertain:
|
|
// Recorded above, before the attempt gate.
|
|
return true
|
|
case let .failed(message):
|
|
self.markWatchResolutionAttemptResettable(routedEvent)
|
|
self.finishExecApprovalResolutionAttempt(resolutionAttempt)
|
|
self.unlockPendingExecApprovalPromptForRetry(
|
|
approvalID: approvalID,
|
|
gatewayStableID: prompt.gatewayStableID,
|
|
message: message)
|
|
await self.republishCachedWatchExecApprovalPromptForRetry(
|
|
approvalID: approvalID,
|
|
heldAttemptID: routedEvent.replyId)
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// Mirrors the phone path's settled non-terminal handling: when a lease releases
|
|
/// with the approval still pending (or the write failed), the presented card for
|
|
/// this exact owner becomes actionable again with the retry message.
|
|
private func unlockPendingExecApprovalPromptForRetry(
|
|
approvalID: String,
|
|
gatewayStableID: String,
|
|
message: String)
|
|
{
|
|
guard self.pendingExecApprovalPrompt.map({ Self.approvalIDsMatch($0.id, approvalID) }) == true,
|
|
GatewayStableIdentifier.matches(
|
|
self.pendingExecApprovalPrompt?.gatewayStableID,
|
|
gatewayStableID)
|
|
else { return }
|
|
self.pendingExecApprovalPromptResolving = false
|
|
self.pendingExecApprovalPromptErrorText = message
|
|
}
|
|
|
|
private enum WatchExecApprovalResolveReadback {
|
|
case prompt(ExecApprovalPrompt)
|
|
case handled(completed: Bool)
|
|
}
|
|
|
|
private func readBackWatchExecApprovalPromptForResolve(
|
|
approvalID: String,
|
|
routedEvent: WatchExecApprovalResolveEvent,
|
|
currentGatewayStableID: String,
|
|
routeGeneration: UInt64) async -> WatchExecApprovalResolveReadback
|
|
{
|
|
let readback = await self.fetchExecApprovalPrompt(
|
|
approvalId: approvalID,
|
|
sourceReason: "watch_resolve")
|
|
guard self.isCurrentExecApprovalReadbackRoute(
|
|
generation: routeGeneration,
|
|
stableID: currentGatewayStableID)
|
|
else {
|
|
await self.syncWatchExecApprovalSnapshot(reason: "watch_resolve_route_changed")
|
|
return .handled(completed: true)
|
|
}
|
|
switch readback {
|
|
case let .loaded(loadedPrompt):
|
|
guard self.isExecApprovalPromptCurrent(loadedPrompt) else {
|
|
await self.syncWatchExecApprovalSnapshot(reason: "watch_resolve_owner_changed")
|
|
return .handled(completed: true)
|
|
}
|
|
self.upsertWatchExecApprovalPrompt(loadedPrompt)
|
|
return .prompt(loadedPrompt)
|
|
case let .terminal(terminal):
|
|
_ = await self.applyCanonicalExecApprovalTerminal(
|
|
terminal,
|
|
appliedHere: false,
|
|
gatewayStableID: currentGatewayStableID)
|
|
return .handled(completed: true)
|
|
case .stale:
|
|
await self.publishWatchExecApprovalExpired(
|
|
approvalId: approvalID,
|
|
gatewayStableID: currentGatewayStableID,
|
|
reason: .notFound)
|
|
return .handled(completed: true)
|
|
case .failed:
|
|
// No write was attempted. Retain the owner-bound action for the next
|
|
// operator connection instead of falsely reporting it as unavailable.
|
|
self.enqueuePendingWatchExecApprovalResolution(routedEvent)
|
|
await self.syncWatchExecApprovalSnapshot(reason: "watch_resolve_readback_failed")
|
|
return .handled(completed: false)
|
|
}
|
|
}
|
|
|
|
private func republishCachedWatchExecApprovalPromptForRetry(
|
|
approvalID: String,
|
|
heldAttemptID: String) async
|
|
{
|
|
guard let prompt = Self.execApprovalIDKey(approvalID)
|
|
.flatMap({ self.watchExecApprovalPromptsByID[$0] })
|
|
else { return }
|
|
await self.publishWatchExecApprovalPrompt(
|
|
prompt,
|
|
reason: "resolve_retry",
|
|
resetResolutionAttemptId: self.resettableWatchResolutionAttemptID(
|
|
for: prompt,
|
|
heldAttemptID: heldAttemptID))
|
|
}
|
|
|
|
private func ownerScopedWatchExecApprovalEvent(
|
|
_ event: WatchExecApprovalResolveEvent,
|
|
approvalID: String) -> WatchExecApprovalResolveEvent?
|
|
{
|
|
if GatewayStableIdentifier.exact(event.gatewayStableID) != nil {
|
|
return event
|
|
}
|
|
guard let approvalKey = Self.execApprovalIDKey(approvalID),
|
|
let prompt = watchExecApprovalPromptsByID[approvalKey]
|
|
else { return nil }
|
|
// A shipped Watch binary can omit the owner field. Bind only to the prompt that
|
|
// originally supplied this approval ID; never infer ownership from a later route.
|
|
var routedEvent = event
|
|
routedEvent.gatewayStableID = prompt.gatewayStableID
|
|
return routedEvent
|
|
}
|
|
|
|
private func enqueuePendingWatchExecApprovalResolution(_ event: WatchExecApprovalResolveEvent) {
|
|
guard let replyID = ExactOpaqueIdentifier.key(event.replyId),
|
|
!self.pendingWatchExecApprovalResolutions.contains(where: {
|
|
ExactOpaqueIdentifier.key($0.replyId) == replyID
|
|
})
|
|
else { return }
|
|
// transferUserInfo is durable only until delivery. Retain the delivered action until
|
|
// startup restores a route, while bounding malformed or replayed Watch traffic.
|
|
self.pendingWatchExecApprovalResolutions.append(event)
|
|
if self.pendingWatchExecApprovalResolutions.count > 32 {
|
|
self.pendingWatchExecApprovalResolutions.removeFirst()
|
|
}
|
|
self.persistWatchExecApprovalBridgeState()
|
|
}
|
|
|
|
private func removePendingWatchExecApprovalResolution(replyID: String) {
|
|
guard let replyKey = ExactOpaqueIdentifier.key(replyID) else { return }
|
|
let originalCount = self.pendingWatchExecApprovalResolutions.count
|
|
self.pendingWatchExecApprovalResolutions.removeAll {
|
|
ExactOpaqueIdentifier.key($0.replyId) == replyKey
|
|
}
|
|
guard self.pendingWatchExecApprovalResolutions.count != originalCount else { return }
|
|
self.persistWatchExecApprovalBridgeState()
|
|
}
|
|
|
|
private func flushPendingWatchExecApprovalResolutions(
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
guard shouldContinue(),
|
|
!self.pendingWatchExecApprovalResolutions.isEmpty,
|
|
!self.pendingWatchExecApprovalResolutionFlushInFlight
|
|
else { return }
|
|
self.pendingWatchExecApprovalResolutionFlushInFlight = true
|
|
defer { self.pendingWatchExecApprovalResolutionFlushInFlight = false }
|
|
await self.hydrateWatchExecApprovalCacheIfNeeded(reason: "queued_watch_resolve")
|
|
guard shouldContinue(), let currentGatewayStableID = currentExecApprovalGatewayStableID() else { return }
|
|
let pending = self.pendingWatchExecApprovalResolutions
|
|
var discardedMismatchedOwner = false
|
|
for event in pending {
|
|
guard shouldContinue() else { return }
|
|
guard GatewayStableIdentifier.matches(
|
|
event.gatewayStableID,
|
|
currentGatewayStableID)
|
|
else {
|
|
discardedMismatchedOwner = true
|
|
self.removePendingWatchExecApprovalResolution(replyID: event.replyId)
|
|
continue
|
|
}
|
|
let completed = await handleWatchExecApprovalResolve(event)
|
|
if completed {
|
|
self.removePendingWatchExecApprovalResolution(replyID: event.replyId)
|
|
}
|
|
}
|
|
if discardedMismatchedOwner, shouldContinue() {
|
|
await self.syncWatchExecApprovalSnapshot(reason: "queued_stale_gateway_reply")
|
|
}
|
|
}
|
|
|
|
func handleExecApprovalRequestedRemotePush(_ push: ExecApprovalNotificationPrompt) async -> Bool {
|
|
guard let approvalID = Self.validatedApprovalID(push.approvalId) else { return false }
|
|
let operatorRoute: GatewayNodeSessionRoute
|
|
switch await self.validateExecApprovalPushRoute(push, sourceReason: "push_request") {
|
|
case let .validated(context):
|
|
operatorRoute = context.route
|
|
case .unavailable:
|
|
// APNs delivery is one-shot. Retain the owner-tagged request until its route
|
|
// returns so Watch recovery cannot lose an approval during a reconnect.
|
|
self.appendPendingWatchExecApprovalRecoveryPush(push)
|
|
return true
|
|
case .mismatchedOwner:
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: self.notificationCenter)
|
|
self.removePendingWatchExecApprovalRecoveryPush(push)
|
|
return true
|
|
}
|
|
self.appendPendingWatchExecApprovalRecoveryPush(push)
|
|
guard let gatewayStableID = currentExecApprovalGatewayStableID() else { return true }
|
|
let fetchedPrompt = await fetchExecApprovalPrompt(
|
|
approvalId: approvalID,
|
|
sourceReason: "push_request",
|
|
expectedOperatorRoute: operatorRoute)
|
|
switch fetchedPrompt {
|
|
case let .loaded(prompt):
|
|
self.upsertWatchExecApprovalPrompt(prompt)
|
|
await self.publishWatchExecApprovalPrompt(prompt, reason: "push_request")
|
|
return true
|
|
case let .terminal(terminal):
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: self.notificationCenter)
|
|
self.removePendingWatchExecApprovalRecoveryPush(push)
|
|
self.clearPendingExecApprovalPromptIfMatches(approvalID)
|
|
await self.publishWatchExecApprovalTerminal(
|
|
terminal,
|
|
gatewayStableID: gatewayStableID,
|
|
source: "gateway")
|
|
return true
|
|
case .stale:
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: self.notificationCenter)
|
|
self.removePendingWatchExecApprovalRecoveryPush(push)
|
|
self.clearPendingExecApprovalPromptIfMatches(approvalID)
|
|
await self.publishWatchExecApprovalExpired(
|
|
approvalId: approvalID,
|
|
gatewayStableID: gatewayStableID,
|
|
reason: .notFound)
|
|
return true
|
|
case let .failed(message):
|
|
self.watchExecApprovalLogger
|
|
.error(
|
|
"watch approval push fetch failed id=\(approvalID, privacy: .public)")
|
|
self.watchExecApprovalLogger.error("watch approval push fetch error=\(message, privacy: .public)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func handleExecApprovalResolvedForCurrentGateway(
|
|
approvalId: String,
|
|
recoveryPushGatewayDeviceID: String? = nil,
|
|
routeContext: GatewaySessionRouteContext? = nil,
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
|
|
-> Bool
|
|
{
|
|
guard let approvalID = Self.validatedApprovalID(approvalId),
|
|
await self.canApplyExecApprovalResolvedState(
|
|
routeContext: routeContext,
|
|
shouldContinue: shouldContinue)
|
|
else { return false }
|
|
|
|
let currentGatewayStableID = self.currentExecApprovalGatewayStableID()
|
|
let hadWatchPrompt = if let currentGatewayStableID,
|
|
let approvalKey = Self.execApprovalIDKey(approvalID),
|
|
let watchPrompt = self.watchExecApprovalPromptsByID[approvalKey]
|
|
{
|
|
GatewayStableIdentifier.matches(
|
|
watchPrompt.gatewayStableID,
|
|
currentGatewayStableID)
|
|
} else {
|
|
false
|
|
}
|
|
let hadPendingPrompt = if let currentGatewayStableID {
|
|
self.pendingExecApprovalPrompt.map { Self.approvalIDsMatch($0.id, approvalID) } == true &&
|
|
GatewayStableIdentifier.matches(
|
|
self.pendingExecApprovalPrompt?.gatewayStableID,
|
|
currentGatewayStableID)
|
|
} else {
|
|
false
|
|
}
|
|
let recoveryPushes: [ExecApprovalNotificationPrompt] = if let recoveryPushGatewayDeviceID =
|
|
GatewayStableIdentifier.key(recoveryPushGatewayDeviceID)
|
|
{
|
|
self.pendingWatchExecApprovalRecoveryPushes.filter { push in
|
|
Self.approvalIDsMatch(push.approvalId, approvalID) &&
|
|
GatewayStableIdentifier.key(push.gatewayDeviceId) == recoveryPushGatewayDeviceID
|
|
}
|
|
} else {
|
|
[]
|
|
}
|
|
let hadPendingRecoveryID = !recoveryPushes.isEmpty
|
|
let hadGuidancePrompt = self.pendingNotificationPermissionGuidancePrompt.map {
|
|
Self.approvalIDsMatch($0.approvalId, approvalID)
|
|
} == true
|
|
let hadApprovalSurface = hadWatchPrompt || hadPendingPrompt || hadPendingRecoveryID
|
|
guard hadApprovalSurface || hadGuidancePrompt else {
|
|
return true
|
|
}
|
|
|
|
guard let currentGatewayStableID else { return false }
|
|
let readback = await self.fetchExecApprovalPrompt(
|
|
approvalId: approvalID,
|
|
sourceReason: "resolved_event",
|
|
expectedOperatorRoute: routeContext?.route,
|
|
shouldContinue: shouldContinue)
|
|
guard await self.canApplyExecApprovalResolvedState(
|
|
routeContext: routeContext,
|
|
shouldContinue: shouldContinue)
|
|
else { return false }
|
|
|
|
switch readback {
|
|
case let .terminal(terminal):
|
|
self.markPendingExecApprovalTerminal(
|
|
terminal,
|
|
alreadyResolved: true)
|
|
if hadApprovalSurface {
|
|
await self.publishWatchExecApprovalTerminal(
|
|
terminal,
|
|
gatewayStableID: currentGatewayStableID,
|
|
source: "another-reviewer")
|
|
}
|
|
case let .loaded(prompt):
|
|
// A delayed or duplicate resolved signal cannot override the canonical
|
|
// pending row. Re-publish it and re-enable only after this readback.
|
|
if let currentPrompt = self.pendingExecApprovalPrompt,
|
|
!Self.approvalIDsMatch(currentPrompt.id, prompt.id) ||
|
|
!GatewayStableIdentifier.matches(
|
|
currentPrompt.gatewayStableID,
|
|
prompt.gatewayStableID)
|
|
{
|
|
self.upsertWatchExecApprovalPrompt(prompt)
|
|
await self.publishWatchExecApprovalPrompt(prompt, reason: "resolve_retry")
|
|
} else {
|
|
self.presentFetchedExecApprovalPrompt(prompt, publishReason: "resolve_retry")
|
|
}
|
|
return true
|
|
case .stale:
|
|
let terminal = ExecApprovalTerminalResult(
|
|
id: approvalID,
|
|
verdict: .resolvedUnknown,
|
|
resolvedAtMs: Int64(Date().timeIntervalSince1970 * 1000))
|
|
self.markPendingExecApprovalTerminal(
|
|
terminal,
|
|
alreadyResolved: true)
|
|
if hadApprovalSurface {
|
|
await self.publishWatchExecApprovalTerminal(
|
|
terminal,
|
|
gatewayStableID: currentGatewayStableID,
|
|
source: "another-reviewer")
|
|
}
|
|
case let .failed(message):
|
|
self.watchExecApprovalLogger.error(
|
|
"approval terminal readback failed id=\(approvalID, privacy: .public)")
|
|
self.watchExecApprovalLogger.error(
|
|
"approval terminal readback error=\(message, privacy: .public)")
|
|
return false
|
|
}
|
|
guard await self.canApplyExecApprovalResolvedState(
|
|
routeContext: routeContext,
|
|
shouldContinue: shouldContinue)
|
|
else { return false }
|
|
for push in recoveryPushes {
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: self.notificationCenter)
|
|
guard await self.canApplyExecApprovalResolvedState(
|
|
routeContext: routeContext,
|
|
shouldContinue: shouldContinue)
|
|
else { return false }
|
|
self.removePendingWatchExecApprovalRecoveryPush(push)
|
|
}
|
|
guard await self.canApplyExecApprovalResolvedState(
|
|
routeContext: routeContext,
|
|
shouldContinue: shouldContinue)
|
|
else { return false }
|
|
self.clearNotificationPermissionGuidancePromptIfMatches(approvalID)
|
|
return true
|
|
}
|
|
|
|
private func canApplyExecApprovalResolvedState(
|
|
routeContext: GatewaySessionRouteContext?,
|
|
shouldContinue: @MainActor @Sendable () -> Bool) async -> Bool
|
|
{
|
|
guard shouldContinue() else { return false }
|
|
guard let routeContext else { return true }
|
|
return await self.isCurrentGatewaySessionRoute(
|
|
routeContext,
|
|
session: self.operatorGateway,
|
|
shouldContinue: shouldContinue)
|
|
}
|
|
|
|
func handleExecApprovalResolvedRemotePush(_ push: ExecApprovalNotificationPrompt) async -> Bool {
|
|
switch await self.validateExecApprovalPushRoute(push, sourceReason: "push_resolved") {
|
|
case let .validated(context):
|
|
let applied = await self.applyValidatedExecApprovalResolvedPush(push, context: context)
|
|
if !applied {
|
|
self.appendPendingExecApprovalResolvedPush(push)
|
|
}
|
|
case .unavailable:
|
|
self.appendPendingExecApprovalResolvedPush(push)
|
|
if GatewayStableIdentifier.exact(push.gatewayDeviceId) != nil {
|
|
// The terminal push already identifies its notification owner. Remove that
|
|
// exact alert now while retaining durable state for route-bound Watch cleanup.
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: self.notificationCenter)
|
|
}
|
|
case .mismatchedOwner:
|
|
// The payload names another gateway. Exact owner matching makes cleanup safe,
|
|
// but it must not mutate approval state for the active gateway.
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: self.notificationCenter)
|
|
self.removePendingWatchExecApprovalRecoveryPush(push)
|
|
self.removePendingExecApprovalResolvedPush(push)
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
private func applyValidatedExecApprovalResolvedPush(
|
|
_ push: ExecApprovalNotificationPrompt,
|
|
context: GatewaySessionRouteContext) async -> Bool
|
|
{
|
|
let routeIsCurrent: @MainActor @Sendable () -> Bool = { [weak self] in
|
|
self?.isCurrentGatewayRoute(
|
|
generation: context.routeGeneration,
|
|
stableID: context.gatewayStableID) == true
|
|
}
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
context,
|
|
session: self.operatorGateway,
|
|
shouldContinue: routeIsCurrent)
|
|
else { return false }
|
|
guard await self.handleExecApprovalResolvedForCurrentGateway(
|
|
approvalId: push.approvalId,
|
|
recoveryPushGatewayDeviceID: push.gatewayDeviceId,
|
|
routeContext: context,
|
|
shouldContinue: routeIsCurrent)
|
|
else { return false }
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
context,
|
|
session: self.operatorGateway,
|
|
shouldContinue: routeIsCurrent)
|
|
else { return false }
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: self.notificationCenter,
|
|
includingLegacyOwnerless: true)
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
context,
|
|
session: self.operatorGateway,
|
|
shouldContinue: routeIsCurrent)
|
|
else { return false }
|
|
self.removePendingWatchExecApprovalRecoveryPush(push)
|
|
self.removePendingExecApprovalResolvedPush(push)
|
|
return true
|
|
}
|
|
|
|
private func flushPendingExecApprovalResolvedPushes(
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
guard shouldContinue(), !self.pendingExecApprovalResolvedPushes.isEmpty else { return }
|
|
for push in self.pendingExecApprovalResolvedPushes {
|
|
guard shouldContinue() else { return }
|
|
switch await self.validateExecApprovalPushRoute(
|
|
push,
|
|
sourceReason: "push_resolved",
|
|
shouldContinue: shouldContinue)
|
|
{
|
|
case let .validated(context):
|
|
guard await self.applyValidatedExecApprovalResolvedPush(push, context: context) else {
|
|
return
|
|
}
|
|
case .unavailable:
|
|
return
|
|
case .mismatchedOwner:
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: self.notificationCenter)
|
|
self.removePendingWatchExecApprovalRecoveryPush(push)
|
|
self.removePendingExecApprovalResolvedPush(push)
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool {
|
|
let wakeId = Self.makePushWakeAttemptID()
|
|
guard Self.isSilentPushPayload(userInfo) else {
|
|
self.pushWakeLogger.info("Ignored APNs payload wakeId=\(wakeId, privacy: .public): not silent push")
|
|
return false
|
|
}
|
|
let pushKind = Self.openclawPushKind(userInfo)
|
|
let receivedMessage =
|
|
"Silent push received wakeId=\(wakeId) "
|
|
+ "kind=\(pushKind) "
|
|
+ "backgrounded=\(isBackgrounded) "
|
|
+ "autoReconnect=\(gatewayAutoReconnectEnabled)"
|
|
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
|
|
|
if let push = ExecApprovalNotificationBridge.parseResolvedPush(userInfo: userInfo) {
|
|
let handled = await handleExecApprovalResolvedRemotePush(push)
|
|
let cleanupMessage =
|
|
"Handled exec approval cleanup push wakeId=\(wakeId) "
|
|
+ "handled=\(handled)"
|
|
self.execApprovalNotificationLogger.info(
|
|
"\(cleanupMessage, privacy: .public)")
|
|
return handled
|
|
}
|
|
|
|
if let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: userInfo) {
|
|
let handled = await handleExecApprovalRequestedRemotePush(push)
|
|
if handled {
|
|
let handledMessage =
|
|
"handled approval push wakeId=\(wakeId) "
|
|
+ "id=\(push.approvalId)"
|
|
self.execApprovalNotificationLogger
|
|
.info("\(handledMessage, privacy: .public)")
|
|
}
|
|
return handled
|
|
}
|
|
|
|
let result = await performBackgroundAliveBeaconIfNeeded(
|
|
wakeId: wakeId,
|
|
trigger: .silentPush)
|
|
let outcomeMessage =
|
|
"Silent push outcome wakeId=\(wakeId) "
|
|
+ "applied=\(result.applied) "
|
|
+ "handled=\(result.handled) "
|
|
+ "reason=\(result.reason) "
|
|
+ "durationMs=\(result.durationMs)"
|
|
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
|
return result.handled
|
|
}
|
|
|
|
func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool {
|
|
let wakeId = Self.makePushWakeAttemptID()
|
|
let normalizedTrigger = BackgroundAliveBeacon.normalizeTrigger(trigger)
|
|
let receivedMessage =
|
|
"Background refresh wake received wakeId=\(wakeId) "
|
|
+ "trigger=\(normalizedTrigger.rawValue) "
|
|
+ "backgrounded=\(self.isBackgrounded) "
|
|
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
|
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
|
let result = await performBackgroundAliveBeaconIfNeeded(
|
|
wakeId: wakeId,
|
|
trigger: normalizedTrigger)
|
|
let outcomeMessage =
|
|
"Background refresh wake outcome wakeId=\(wakeId) "
|
|
+ "applied=\(result.applied) "
|
|
+ "handled=\(result.handled) "
|
|
+ "reason=\(result.reason) "
|
|
+ "durationMs=\(result.durationMs)"
|
|
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
|
return result.handled
|
|
}
|
|
|
|
func handleSignificantLocationWakeIfNeeded() async {
|
|
let wakeId = Self.makePushWakeAttemptID()
|
|
let now = Date()
|
|
let throttleWindowSeconds: TimeInterval = 180
|
|
|
|
if await isGatewayConnected() {
|
|
self.locationWakeLogger.info(
|
|
"Location wake no-op wakeId=\(wakeId, privacy: .public): already connected")
|
|
return
|
|
}
|
|
if let last = lastSignificantLocationWakeAt,
|
|
now.timeIntervalSince(last) < throttleWindowSeconds
|
|
{
|
|
let throttledMessage =
|
|
"Location wake throttled wakeId=\(wakeId) "
|
|
+ "elapsedSec=\(now.timeIntervalSince(last))"
|
|
self.locationWakeLogger.info("\(throttledMessage, privacy: .public)")
|
|
return
|
|
}
|
|
self.lastSignificantLocationWakeAt = now
|
|
|
|
let beginMessage =
|
|
"Location wake begin wakeId=\(wakeId) "
|
|
+ "backgrounded=\(isBackgrounded) "
|
|
+ "autoReconnect=\(gatewayAutoReconnectEnabled)"
|
|
self.locationWakeLogger.info("\(beginMessage, privacy: .public)")
|
|
let result = await performBackgroundAliveBeaconIfNeeded(
|
|
wakeId: wakeId,
|
|
trigger: .significantLocation)
|
|
let triggerMessage =
|
|
"Location wake trigger wakeId=\(wakeId) "
|
|
+ "applied=\(result.applied) "
|
|
+ "handled=\(result.handled) "
|
|
+ "reason=\(result.reason) "
|
|
+ "durationMs=\(result.durationMs)"
|
|
self.locationWakeLogger.info("\(triggerMessage, privacy: .public)")
|
|
|
|
guard result.applied else { return }
|
|
let connected = await waitForGatewayConnection(timeoutMs: 5000, pollMs: 250)
|
|
self.locationWakeLogger.info(
|
|
"Location wake post-check wakeId=\(wakeId, privacy: .public) connected=\(connected, privacy: .public)")
|
|
}
|
|
|
|
func updateAPNsDeviceToken(_ tokenData: Data) {
|
|
let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
|
|
let trimmed = tokenHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
self.apnsDeviceTokenHex = trimmed
|
|
UserDefaults.standard.set(trimmed, forKey: Self.apnsDeviceTokenUserDefaultsKey)
|
|
Task { [weak self] in
|
|
await self?.registerAPNsTokenIfNeeded()
|
|
}
|
|
}
|
|
|
|
private func registerAPNsTokenIfNeeded(
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
guard shouldContinue() else { return }
|
|
let usesRelayTransport = await pushRegistrationManager.usesRelayTransport
|
|
guard shouldContinue() else { return }
|
|
guard await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport) else { return }
|
|
guard shouldContinue() else { return }
|
|
guard self.gatewayConnected else {
|
|
if usesRelayTransport {
|
|
GatewayDiagnostics.pushRelay.skipped("gateway_offline")
|
|
}
|
|
return
|
|
}
|
|
guard let nodeRoute = await nodeGateway.currentRoute(), shouldContinue() else { return }
|
|
guard let context = self.makeAPNsRegistrationContext(
|
|
usesRelayTransport: usesRelayTransport,
|
|
nodeRoute: nodeRoute)
|
|
else { return }
|
|
|
|
do {
|
|
let gatewayIdentity: PushRelayGatewayIdentity?
|
|
if context.usesRelayTransport {
|
|
guard self.operatorConnected else {
|
|
GatewayDiagnostics.pushRelay.skipped("operator_offline")
|
|
return
|
|
}
|
|
GatewayDiagnostics.pushRelay.stage("gateway identity request start")
|
|
gatewayIdentity = try await self.fetchPushRelayGatewayIdentity()
|
|
guard shouldContinue() else { return }
|
|
GatewayDiagnostics.pushRelay.stage("gateway identity request complete")
|
|
} else {
|
|
gatewayIdentity = nil
|
|
}
|
|
if context.usesRelayTransport {
|
|
GatewayDiagnostics.pushRelay.stage("gateway registration payload start")
|
|
}
|
|
let payloadJSON = try await pushRegistrationManager.makeGatewayRegistrationPayload(
|
|
apnsTokenHex: context.token,
|
|
topic: context.topic,
|
|
gatewayIdentity: gatewayIdentity)
|
|
guard shouldContinue() else { return }
|
|
let published = await nodeGateway.sendEvent(
|
|
event: "push.apns.register",
|
|
payloadJSON: payloadJSON,
|
|
ifCurrentRoute: context.nodeRoute)
|
|
guard published, shouldContinue() else { return }
|
|
self.apnsLastRegisteredTokenHex = context.token
|
|
self.apnsLastRegisteredGatewayStableID = context.gatewayStableID
|
|
if context.usesRelayTransport {
|
|
GatewayDiagnostics.pushRelay.stage("gateway registration event published")
|
|
}
|
|
} catch {
|
|
self.pushWakeLogger.error(
|
|
"APNs registration publish failed: \(error.localizedDescription, privacy: .public)")
|
|
if context.usesRelayTransport {
|
|
GatewayDiagnostics.pushRelay.failed("registration", error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func makeAPNsRegistrationContext(
|
|
usesRelayTransport: Bool,
|
|
nodeRoute: GatewayNodeSessionRoute) -> APNsRegistrationContext?
|
|
{
|
|
guard let token = apnsDeviceTokenHex?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!token.isEmpty
|
|
else {
|
|
if usesRelayTransport {
|
|
GatewayDiagnostics.pushRelay.skipped("missing_apns_token")
|
|
}
|
|
return nil
|
|
}
|
|
let gatewayStableID = self.activeGatewayConnectConfig?.effectiveStableID
|
|
?? self.connectedGatewayID
|
|
?? ""
|
|
if !usesRelayTransport,
|
|
!Self.shouldPublishDirectAPNsRegistration(
|
|
token: token,
|
|
gatewayStableID: gatewayStableID,
|
|
lastToken: self.apnsLastRegisteredTokenHex,
|
|
lastGatewayStableID: self.apnsLastRegisteredGatewayStableID)
|
|
{
|
|
return nil
|
|
}
|
|
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!topic.isEmpty
|
|
else {
|
|
if usesRelayTransport {
|
|
GatewayDiagnostics.pushRelay.skipped("missing_topic")
|
|
}
|
|
return nil
|
|
}
|
|
return APNsRegistrationContext(
|
|
usesRelayTransport: usesRelayTransport,
|
|
nodeRoute: nodeRoute,
|
|
token: token,
|
|
gatewayStableID: gatewayStableID,
|
|
topic: topic)
|
|
}
|
|
|
|
private func canPublishAPNsRegistration(usesRelayTransport: Bool) async -> Bool {
|
|
if usesRelayTransport, !PushEnrollmentConsent.disclosureAccepted {
|
|
GatewayDiagnostics.pushRelay.skipped("enrollment_disclosure_not_accepted")
|
|
return false
|
|
}
|
|
guard NotificationServingPreference.isEnabled() else {
|
|
if usesRelayTransport {
|
|
GatewayDiagnostics.pushRelay.skipped("notification_serving_disabled")
|
|
}
|
|
return false
|
|
}
|
|
let status = await notificationAuthorizationStatus()
|
|
guard Self.isNotificationAuthorizationAllowed(status) else {
|
|
if usesRelayTransport {
|
|
GatewayDiagnostics.pushRelay.skipped("notifications_not_authorized")
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
nonisolated static func shouldPublishDirectAPNsRegistration(
|
|
token: String,
|
|
gatewayStableID: String,
|
|
lastToken: String?,
|
|
lastGatewayStableID: String?) -> Bool
|
|
{
|
|
token != lastToken || !GatewayStableIdentifier.matches(gatewayStableID, lastGatewayStableID)
|
|
}
|
|
|
|
private func fetchPushRelayGatewayIdentity(
|
|
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute? = nil) async throws -> PushRelayGatewayIdentity
|
|
{
|
|
let response = try await operatorGateway.request(
|
|
method: "gateway.identity.get",
|
|
paramsJSON: "{}",
|
|
timeoutSeconds: 8,
|
|
ifCurrentRoute: expectedRoute)
|
|
if let expectedRoute,
|
|
await self.operatorGateway.currentRoute() != expectedRoute
|
|
{
|
|
throw PushRelayError.relayMisconfigured("Gateway identity route changed during readback")
|
|
}
|
|
return try Self.decodePushRelayGatewayIdentity(response)
|
|
}
|
|
|
|
private nonisolated static func decodePushRelayGatewayIdentity(
|
|
_ response: Data) throws -> PushRelayGatewayIdentity
|
|
{
|
|
let decoded = try JSONDecoder().decode(GatewayRelayIdentityResponse.self, from: response)
|
|
let deviceId = GatewayStableIdentifier.exact(decoded.deviceId)
|
|
let publicKey = decoded.publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let deviceId, !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"
|
|
}
|
|
|
|
func presentExecApprovalNotificationPrompt(
|
|
_ prompt: ExecApprovalNotificationPrompt,
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
guard shouldContinue(), let approvalId = Self.validatedApprovalID(prompt.approvalId) else { return }
|
|
let operatorRoute: GatewayNodeSessionRoute
|
|
switch await self.validateExecApprovalPushRoute(
|
|
prompt,
|
|
sourceReason: "notification_action",
|
|
shouldContinue: shouldContinue)
|
|
{
|
|
case let .validated(context):
|
|
operatorRoute = context.route
|
|
case .unavailable:
|
|
guard shouldContinue() else { return }
|
|
self.appendPendingWatchExecApprovalRecoveryPush(prompt)
|
|
return
|
|
case .mismatchedOwner:
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: prompt,
|
|
notificationCenter: self.notificationCenter)
|
|
self.removePendingWatchExecApprovalRecoveryPush(prompt)
|
|
return
|
|
}
|
|
self.appendPendingWatchExecApprovalRecoveryPush(prompt)
|
|
await self.presentExecApprovalPrompt(
|
|
approvalId: approvalId,
|
|
notificationPush: prompt,
|
|
expectedOperatorRoute: operatorRoute,
|
|
shouldContinue: shouldContinue)
|
|
}
|
|
|
|
private func presentExecApprovalGatewayEventPrompt(
|
|
approvalId: String,
|
|
expectedOperatorRoute: GatewayNodeSessionRoute? = nil,
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
await self.presentExecApprovalPrompt(
|
|
approvalId: approvalId,
|
|
notificationPush: nil,
|
|
expectedOperatorRoute: expectedOperatorRoute,
|
|
shouldContinue: shouldContinue)
|
|
}
|
|
|
|
private func presentExecApprovalPrompt(
|
|
approvalId: String,
|
|
notificationPush: ExecApprovalNotificationPrompt?,
|
|
expectedOperatorRoute: GatewayNodeSessionRoute?,
|
|
shouldContinue: @MainActor @Sendable () -> Bool) async
|
|
{
|
|
guard shouldContinue(), Self.validatedApprovalID(approvalId) != nil else { return }
|
|
let persistedReadback = self.currentExecApprovalGatewayStableID().map {
|
|
PersistedExecApprovalReadback(
|
|
approvalId: approvalId,
|
|
gatewayStableID: $0)
|
|
}
|
|
|
|
self.pendingExecApprovalPromptRequestGeneration &+= 1
|
|
let requestGeneration = self.pendingExecApprovalPromptRequestGeneration
|
|
let visiblePromptAtStart = self.pendingExecApprovalPrompt
|
|
let surfaceGenerationAtStart = self.pendingExecApprovalPromptSurfaceGeneration
|
|
if self.canMutatePendingExecApprovalPromptState(for: approvalId) {
|
|
self.pendingExecApprovalPromptResolving = true
|
|
self.pendingExecApprovalPromptErrorText = nil
|
|
self.pendingExecApprovalPromptOutcome = nil
|
|
}
|
|
|
|
let fetchedPrompt = await fetchExecApprovalPrompt(
|
|
approvalId: approvalId,
|
|
expectedOperatorRoute: expectedOperatorRoute,
|
|
shouldContinue: shouldContinue)
|
|
guard shouldContinue(), self.pendingExecApprovalPromptRequestGeneration == requestGeneration else {
|
|
if self.pendingExecApprovalPromptRequestGeneration == requestGeneration,
|
|
self.canMutatePendingExecApprovalPromptState(for: approvalId)
|
|
{
|
|
self.pendingExecApprovalPromptResolving = false
|
|
}
|
|
return
|
|
}
|
|
if self.canMutatePendingExecApprovalPromptState(for: approvalId) {
|
|
self.pendingExecApprovalPromptResolving = false
|
|
}
|
|
switch fetchedPrompt {
|
|
case let .loaded(fetchedPrompt):
|
|
if let persistedReadback {
|
|
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
|
}
|
|
let visiblePromptNow = self.pendingExecApprovalPrompt
|
|
let phoneSurfaceUnchanged = self.pendingExecApprovalPromptSurfaceGeneration == surfaceGenerationAtStart
|
|
// A notification tap explicitly selects a review surface. Passive events may
|
|
// warm Watch state, but must not replace another visible phone approval.
|
|
let explicitlySelectedFromNotification = notificationPush != nil
|
|
let canPresentLoadedPrompt = phoneSurfaceUnchanged &&
|
|
(explicitlySelectedFromNotification ||
|
|
visiblePromptNow.map { Self.approvalIDsMatch($0.id, approvalId) } == true ||
|
|
(visiblePromptAtStart == nil && visiblePromptNow == nil))
|
|
if canPresentLoadedPrompt {
|
|
self.presentFetchedExecApprovalPrompt(fetchedPrompt)
|
|
} else {
|
|
self.upsertWatchExecApprovalPrompt(fetchedPrompt)
|
|
await self.publishWatchExecApprovalPrompt(fetchedPrompt, reason: "present_prompt")
|
|
}
|
|
case let .terminal(terminal):
|
|
if let persistedReadback {
|
|
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
|
}
|
|
if let notificationPush {
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: notificationPush,
|
|
notificationCenter: self.notificationCenter)
|
|
self.removePendingWatchExecApprovalRecoveryPush(notificationPush)
|
|
}
|
|
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
|
if let gatewayStableID = currentExecApprovalGatewayStableID() {
|
|
await self.publishWatchExecApprovalTerminal(
|
|
terminal,
|
|
gatewayStableID: gatewayStableID,
|
|
source: "gateway")
|
|
}
|
|
case .stale:
|
|
if let persistedReadback {
|
|
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
|
}
|
|
if let notificationPush {
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: notificationPush,
|
|
notificationCenter: self.notificationCenter)
|
|
self.removePendingWatchExecApprovalRecoveryPush(notificationPush)
|
|
}
|
|
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
|
if let gatewayStableID = currentExecApprovalGatewayStableID() {
|
|
await self.publishWatchExecApprovalExpired(
|
|
approvalId: approvalId,
|
|
gatewayStableID: gatewayStableID,
|
|
reason: .notFound)
|
|
}
|
|
case let .failed(message):
|
|
self.execApprovalNotificationLogger
|
|
.error("approval prompt fetch failed id=\(approvalId, privacy: .public)")
|
|
self.execApprovalNotificationLogger.error("approval prompt fetch reason=\(message, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
private func canMutatePendingExecApprovalPromptState(for approvalId: String) -> Bool {
|
|
guard let prompt = self.pendingExecApprovalPrompt else { return true }
|
|
return Self.approvalIDsMatch(prompt.id, approvalId)
|
|
}
|
|
|
|
private enum ExecApprovalPromptFetchOutcome {
|
|
case loaded(ExecApprovalPrompt)
|
|
case terminal(ExecApprovalTerminalResult)
|
|
case stale
|
|
case failed(message: String)
|
|
}
|
|
|
|
private func presentFetchedExecApprovalPrompt(
|
|
_ prompt: ExecApprovalPrompt,
|
|
publishReason: String = "present_prompt")
|
|
{
|
|
guard self.isExecApprovalPromptCurrent(prompt),
|
|
let inboxKey = Self.execApprovalInboxKey(prompt),
|
|
!self.terminalExecApprovalKeys.contains(inboxKey)
|
|
else { return }
|
|
let uncertainResolutionMessage = self.execApprovalUncertainties[inboxKey]?.message
|
|
// Attempt presence, not writeInFlight: resolution paths settle the write before
|
|
// awaiting readback classification, and taps stay fenced until the lease drops.
|
|
let preserveActiveResolution = uncertainResolutionMessage != nil || self.hasActiveExecApprovalResolutionAttempt(
|
|
approvalID: prompt.id,
|
|
gatewayStableID: prompt.gatewayStableID)
|
|
self.pendingExecApprovalPromptSurfaceGeneration &+= 1
|
|
self.dismissedExecApprovalPresentationKeys.remove(inboxKey)
|
|
self.pendingExecApprovalPrompt = prompt
|
|
if let uncertainResolutionMessage {
|
|
self.pendingExecApprovalPromptResolving = true
|
|
self.pendingExecApprovalPromptErrorText = uncertainResolutionMessage
|
|
self.pendingExecApprovalPromptOutcome = nil
|
|
} else if preserveActiveResolution {
|
|
// Re-presenting while the owner write fence is held (e.g. after a gateway
|
|
// round-trip cleared the surface flags) must render as resolving, or the
|
|
// card would look actionable while the fence rejects new attempts.
|
|
self.pendingExecApprovalPromptResolving = true
|
|
} else {
|
|
self.pendingExecApprovalPromptResolving = false
|
|
self.pendingExecApprovalPromptErrorText = nil
|
|
self.pendingExecApprovalPromptOutcome = nil
|
|
}
|
|
self.upsertWatchExecApprovalPrompt(prompt)
|
|
Task { @MainActor [weak self] in
|
|
await self?.publishWatchExecApprovalPrompt(prompt, reason: publishReason)
|
|
}
|
|
}
|
|
|
|
private static func makeExecApprovalPrompt(
|
|
from snapshot: PendingApprovalSnapshot,
|
|
expectedApprovalID: String,
|
|
gatewayStableID: String) -> ExecApprovalPrompt?
|
|
{
|
|
guard self.approvalIDsMatch(snapshot.id, expectedApprovalID),
|
|
!snapshot.urlpath.isEmpty,
|
|
snapshot.createdatms >= 0,
|
|
snapshot.expiresatms >= 0,
|
|
case let .exec(presentation) = snapshot.presentation,
|
|
self.isValidExecApprovalPresentation(presentation)
|
|
else {
|
|
return nil
|
|
}
|
|
return self.makeExecApprovalPrompt(ExecApprovalPrompt(
|
|
id: snapshot.id,
|
|
kind: presentation.kind,
|
|
gatewayStableID: gatewayStableID,
|
|
commandText: presentation.commandtext,
|
|
commandPreview: self.approvalPresentationString(presentation.commandpreview),
|
|
warningText: self.approvalPresentationString(presentation.warningtext),
|
|
allowedDecisions: presentation.alloweddecisions.map(\.rawValue),
|
|
host: self.approvalPresentationString(presentation.host),
|
|
nodeId: self.approvalPresentationString(presentation.nodeid),
|
|
agentId: self.approvalPresentationString(presentation.agentid),
|
|
expiresAtMs: Int64(snapshot.expiresatms)))
|
|
}
|
|
|
|
private static func makeExecApprovalPrompt(
|
|
from result: LegacyExecApprovalGetResult,
|
|
expectedApprovalID: String,
|
|
gatewayStableID: String) -> ExecApprovalPrompt?
|
|
{
|
|
guard self.approvalIDsMatch(result.id, expectedApprovalID) else { return nil }
|
|
return self.makeExecApprovalPrompt(ExecApprovalPrompt(
|
|
id: result.id,
|
|
kind: ApprovalKind.exec.rawValue,
|
|
gatewayStableID: gatewayStableID,
|
|
commandText: result.commandText,
|
|
commandPreview: result.commandPreview,
|
|
warningText: result.warningText,
|
|
allowedDecisions: result.allowedDecisions,
|
|
host: result.host,
|
|
nodeId: result.nodeId,
|
|
agentId: result.agentId,
|
|
expiresAtMs: result.expiresAtMs))
|
|
}
|
|
|
|
private static func makeExecApprovalPrompt(_ input: ExecApprovalPrompt) -> ExecApprovalPrompt? {
|
|
guard let approvalId = self.validatedApprovalID(input.id) else { return nil }
|
|
let approvalKind = input.kind ?? ""
|
|
let normalizedCommandText = input.commandText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let exactGatewayStableID = GatewayStableIdentifier.exact(input.gatewayStableID)
|
|
guard approvalKind == ApprovalKind.exec.rawValue,
|
|
!normalizedCommandText.isEmpty,
|
|
let exactGatewayStableID
|
|
else {
|
|
return nil
|
|
}
|
|
let decisions = input.allowedDecisions
|
|
guard decisions.count == Set(decisions).count,
|
|
decisions.allSatisfy({ ApprovalDecision(rawValue: $0) != nil }),
|
|
decisions.contains(ApprovalDecision.deny.rawValue)
|
|
else {
|
|
return nil
|
|
}
|
|
return ExecApprovalPrompt(
|
|
id: approvalId,
|
|
kind: approvalKind,
|
|
gatewayStableID: exactGatewayStableID,
|
|
commandText: normalizedCommandText,
|
|
commandPreview: self.trimmedOrNil(input.commandPreview),
|
|
warningText: self.trimmedOrNil(input.warningText),
|
|
allowedDecisions: decisions,
|
|
host: self.trimmedOrNil(input.host),
|
|
nodeId: self.trimmedOrNil(input.nodeId),
|
|
agentId: self.trimmedOrNil(input.agentId),
|
|
expiresAtMs: input.expiresAtMs)
|
|
}
|
|
|
|
private static func approvalPresentationString(_ value: AnyCodable?) -> String? {
|
|
guard let raw = value?.value as? String else { return nil }
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private static func isValidOptionalApprovalPresentationString(
|
|
_ value: AnyCodable?,
|
|
requiresNonEmpty: Bool = false) -> Bool
|
|
{
|
|
guard let value else { return true }
|
|
if value.value is NSNull { return true }
|
|
guard let text = value.value as? String else { return false }
|
|
return !requiresNonEmpty || !text.isEmpty
|
|
}
|
|
|
|
private static func isValidExecApprovalPresentation(
|
|
_ presentation: ExecApprovalPresentation,
|
|
terminalDecision: String? = nil) -> Bool
|
|
{
|
|
let decisions = presentation.alloweddecisions.map(\.rawValue)
|
|
guard presentation.kind == ApprovalKind.exec.rawValue,
|
|
!presentation.commandtext.isEmpty,
|
|
(1...3).contains(decisions.count),
|
|
decisions.count == Set(decisions).count,
|
|
decisions.contains(ApprovalDecision.deny.rawValue),
|
|
self.isValidOptionalApprovalPresentationString(presentation.commandpreview),
|
|
self.isValidOptionalApprovalPresentationString(presentation.warningtext),
|
|
self.isValidOptionalApprovalPresentationString(presentation.host),
|
|
self.isValidOptionalApprovalPresentationString(
|
|
presentation.nodeid,
|
|
requiresNonEmpty: true),
|
|
self.isValidOptionalApprovalPresentationString(
|
|
presentation.agentid,
|
|
requiresNonEmpty: true),
|
|
terminalDecision.map(decisions.contains) != false
|
|
else { return false }
|
|
return true
|
|
}
|
|
|
|
private struct ExecApprovalTerminalSnapshotFields {
|
|
let id: String
|
|
let urlPath: String
|
|
let createdAtMs: Int
|
|
let expiresAtMs: Int
|
|
let presentation: ApprovalPresentation
|
|
let resolvedAtMs: Int
|
|
|
|
init(_ value: AllowedApprovalSnapshot) {
|
|
self.init(
|
|
id: value.id,
|
|
urlPath: value.urlpath,
|
|
createdAtMs: value.createdatms,
|
|
expiresAtMs: value.expiresatms,
|
|
presentation: value.presentation,
|
|
resolvedAtMs: value.resolvedatms)
|
|
}
|
|
|
|
init(_ value: DeniedApprovalSnapshot) {
|
|
self.init(
|
|
id: value.id,
|
|
urlPath: value.urlpath,
|
|
createdAtMs: value.createdatms,
|
|
expiresAtMs: value.expiresatms,
|
|
presentation: value.presentation,
|
|
resolvedAtMs: value.resolvedatms)
|
|
}
|
|
|
|
init(_ value: ExpiredApprovalSnapshot) {
|
|
self.init(
|
|
id: value.id,
|
|
urlPath: value.urlpath,
|
|
createdAtMs: value.createdatms,
|
|
expiresAtMs: value.expiresatms,
|
|
presentation: value.presentation,
|
|
resolvedAtMs: value.resolvedatms)
|
|
}
|
|
|
|
init(_ value: CancelledApprovalSnapshot) {
|
|
self.init(
|
|
id: value.id,
|
|
urlPath: value.urlpath,
|
|
createdAtMs: value.createdatms,
|
|
expiresAtMs: value.expiresatms,
|
|
presentation: value.presentation,
|
|
resolvedAtMs: value.resolvedatms)
|
|
}
|
|
|
|
private init(
|
|
id: String,
|
|
urlPath: String,
|
|
createdAtMs: Int,
|
|
expiresAtMs: Int,
|
|
presentation: ApprovalPresentation,
|
|
resolvedAtMs: Int)
|
|
{
|
|
self.id = id
|
|
self.urlPath = urlPath
|
|
self.createdAtMs = createdAtMs
|
|
self.expiresAtMs = expiresAtMs
|
|
self.presentation = presentation
|
|
self.resolvedAtMs = resolvedAtMs
|
|
}
|
|
}
|
|
|
|
private static func makeExecApprovalTerminalResult(
|
|
fields: ExecApprovalTerminalSnapshotFields,
|
|
expectedApprovalID: String,
|
|
verdict: ExecApprovalTerminalVerdict) -> ExecApprovalTerminalResult?
|
|
{
|
|
guard self.approvalIDsMatch(fields.id, expectedApprovalID),
|
|
!fields.urlPath.isEmpty,
|
|
fields.createdAtMs >= 0,
|
|
fields.expiresAtMs >= 0,
|
|
fields.resolvedAtMs >= 0,
|
|
case let .exec(execPresentation) = fields.presentation,
|
|
self.isValidExecApprovalPresentation(
|
|
execPresentation,
|
|
terminalDecision: verdict.decision)
|
|
else {
|
|
return nil
|
|
}
|
|
return ExecApprovalTerminalResult(
|
|
id: fields.id,
|
|
verdict: verdict,
|
|
resolvedAtMs: Int64(fields.resolvedAtMs))
|
|
}
|
|
|
|
private static func makeExecApprovalTerminalResult(
|
|
from snapshot: TerminalApprovalSnapshot,
|
|
expectedApprovalID: String) -> ExecApprovalTerminalResult?
|
|
{
|
|
switch snapshot {
|
|
case let .allowed(value):
|
|
let verdict: ExecApprovalTerminalVerdict
|
|
switch value.decision.rawValue {
|
|
case ApprovalDecision.allowOnce.rawValue:
|
|
verdict = .allowOnce
|
|
case ApprovalDecision.allowAlways.rawValue:
|
|
verdict = .allowAlways
|
|
default:
|
|
return nil
|
|
}
|
|
return self.makeExecApprovalTerminalResult(
|
|
fields: ExecApprovalTerminalSnapshotFields(value),
|
|
expectedApprovalID: expectedApprovalID,
|
|
verdict: verdict)
|
|
case let .denied(value):
|
|
guard value.decision == ApprovalDecision.deny.rawValue else { return nil }
|
|
return self.makeExecApprovalTerminalResult(
|
|
fields: ExecApprovalTerminalSnapshotFields(value),
|
|
expectedApprovalID: expectedApprovalID,
|
|
verdict: .deny)
|
|
case let .expired(value):
|
|
return self.makeExecApprovalTerminalResult(
|
|
fields: ExecApprovalTerminalSnapshotFields(value),
|
|
expectedApprovalID: expectedApprovalID,
|
|
verdict: .expired)
|
|
case let .cancelled(value):
|
|
return self.makeExecApprovalTerminalResult(
|
|
fields: ExecApprovalTerminalSnapshotFields(value),
|
|
expectedApprovalID: expectedApprovalID,
|
|
verdict: .cancelled)
|
|
}
|
|
}
|
|
|
|
private static func makeExecApprovalTerminalResult(
|
|
from snapshot: ApprovalSnapshot,
|
|
expectedApprovalID: String) -> ExecApprovalTerminalResult?
|
|
{
|
|
switch snapshot {
|
|
case .pending:
|
|
nil
|
|
case let .allowed(value):
|
|
self.makeExecApprovalTerminalResult(
|
|
from: TerminalApprovalSnapshot.allowed(value),
|
|
expectedApprovalID: expectedApprovalID)
|
|
case let .denied(value):
|
|
self.makeExecApprovalTerminalResult(
|
|
from: TerminalApprovalSnapshot.denied(value),
|
|
expectedApprovalID: expectedApprovalID)
|
|
case let .expired(value):
|
|
self.makeExecApprovalTerminalResult(
|
|
from: TerminalApprovalSnapshot.expired(value),
|
|
expectedApprovalID: expectedApprovalID)
|
|
case let .cancelled(value):
|
|
self.makeExecApprovalTerminalResult(
|
|
from: TerminalApprovalSnapshot.cancelled(value),
|
|
expectedApprovalID: expectedApprovalID)
|
|
}
|
|
}
|
|
|
|
private static func execApprovalTerminalText(
|
|
_ terminal: ExecApprovalTerminalResult,
|
|
alreadyResolved: Bool) -> String
|
|
{
|
|
let prefix = alreadyResolved ? "This approval was already" : "Approval"
|
|
switch terminal.verdict {
|
|
case .allowOnce:
|
|
return "\(prefix) allowed once."
|
|
case .allowAlways:
|
|
return alreadyResolved
|
|
? "This approval was already set to Always Allow."
|
|
: "Approval set to Always Allow."
|
|
case .deny:
|
|
return "\(prefix) denied."
|
|
case .expired:
|
|
return "Approval expired before this decision was applied."
|
|
case .cancelled:
|
|
return "Approval was cancelled before this decision was applied."
|
|
case .resolvedUnknown:
|
|
return "Approval was resolved elsewhere."
|
|
}
|
|
}
|
|
|
|
private nonisolated static func shouldUseBackgroundAwareExecApprovalReconnect(
|
|
sourceReason: String,
|
|
isBackgrounded: Bool) -> Bool
|
|
{
|
|
guard isBackgrounded else { return false }
|
|
switch sourceReason {
|
|
case "watch_request", "push_request", "push_resolved", "watch_resolve", "notification_action":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func operatorRouteForExecApproval(
|
|
sourceReason: String,
|
|
expectedOperatorRoute: GatewayNodeSessionRoute? = nil,
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> GatewaySessionRouteContext?
|
|
{
|
|
guard shouldContinue(), let gatewayStableID = currentExecApprovalGatewayStableID() else {
|
|
return nil
|
|
}
|
|
let routeGeneration = self.gatewayRouteGeneration
|
|
let connected: Bool = if expectedOperatorRoute != nil {
|
|
self.operatorConnected
|
|
} else if Self.shouldUseBackgroundAwareExecApprovalReconnect(
|
|
sourceReason: sourceReason,
|
|
isBackgrounded: self.isBackgrounded)
|
|
{
|
|
await self.ensureOperatorApprovalConnectionForWatchReview(
|
|
timeoutMs: 12000,
|
|
reason: sourceReason)
|
|
} else {
|
|
await self.ensureOperatorApprovalConnection(timeoutMs: 12000)
|
|
}
|
|
guard shouldContinue(), connected,
|
|
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: gatewayStableID)
|
|
else {
|
|
return nil
|
|
}
|
|
let route: GatewayNodeSessionRoute? = if let expectedOperatorRoute {
|
|
expectedOperatorRoute
|
|
} else {
|
|
await self.operatorGateway.currentRoute()
|
|
}
|
|
guard let route,
|
|
shouldContinue(),
|
|
self.isCurrentGatewayRoute(generation: routeGeneration, stableID: gatewayStableID)
|
|
else {
|
|
return nil
|
|
}
|
|
return GatewaySessionRouteContext(
|
|
route: route,
|
|
gatewayStableID: gatewayStableID,
|
|
routeGeneration: routeGeneration)
|
|
}
|
|
|
|
private func validatedExecApprovalPushRoute(
|
|
_ push: ExecApprovalNotificationPrompt,
|
|
sourceReason: String,
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> GatewayNodeSessionRoute?
|
|
{
|
|
guard case let .validated(context) = await validateExecApprovalPushRoute(
|
|
push,
|
|
sourceReason: sourceReason,
|
|
shouldContinue: shouldContinue)
|
|
else {
|
|
return nil
|
|
}
|
|
return context.route
|
|
}
|
|
|
|
private func validateExecApprovalPushRoute(
|
|
_ push: ExecApprovalNotificationPrompt,
|
|
sourceReason: String,
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> ExecApprovalPushRouteValidation
|
|
{
|
|
guard let context = await operatorRouteForExecApproval(
|
|
sourceReason: sourceReason,
|
|
shouldContinue: shouldContinue)
|
|
else {
|
|
return .unavailable
|
|
}
|
|
// Gateways shipped before owner-tagged APNs payloads are still safe when the
|
|
// approval is resolved only through the currently authenticated operator route.
|
|
guard let rawExpectedGatewayDeviceID = push.gatewayDeviceId else {
|
|
return .validated(context)
|
|
}
|
|
guard let expectedGatewayDeviceID = GatewayStableIdentifier.exact(rawExpectedGatewayDeviceID) else {
|
|
return .mismatchedOwner
|
|
}
|
|
do {
|
|
let identity = try await fetchPushRelayGatewayIdentity(ifCurrentRoute: context.route)
|
|
guard shouldContinue(),
|
|
self.isCurrentGatewayRoute(
|
|
generation: context.routeGeneration,
|
|
stableID: context.gatewayStableID)
|
|
else {
|
|
return .unavailable
|
|
}
|
|
guard GatewayStableIdentifier.matches(identity.deviceId, expectedGatewayDeviceID) else {
|
|
return .mismatchedOwner
|
|
}
|
|
return .validated(context)
|
|
} catch {
|
|
return .unavailable
|
|
}
|
|
}
|
|
|
|
private func fetchExecApprovalPrompt(
|
|
approvalId: String,
|
|
sourceReason: String? = nil,
|
|
expectedOperatorRoute: GatewayNodeSessionRoute? = nil,
|
|
shouldContinue: @MainActor @Sendable () -> Bool = { true }) async -> ExecApprovalPromptFetchOutcome
|
|
{
|
|
guard Self.validatedApprovalID(approvalId) != nil else {
|
|
return .failed(message: "invalid_approval_id")
|
|
}
|
|
let readbackFence = self.execApprovalReadbackFence(approvalID: approvalId)
|
|
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let fetchReason: String = if let normalizedSourceReason, !normalizedSourceReason.isEmpty {
|
|
normalizedSourceReason
|
|
} else {
|
|
"direct"
|
|
}
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: fetch prompt start id=\(approvalId) reason=\(fetchReason)")
|
|
#if DEBUG
|
|
if let testExecApprovalPromptFetchHandler,
|
|
let gatewayStableID = self.currentExecApprovalGatewayStableID()
|
|
{
|
|
let routeGeneration = self.gatewayRouteGeneration
|
|
let outcome = await testExecApprovalPromptFetchHandler(approvalId, gatewayStableID)
|
|
guard shouldContinue(),
|
|
self.isCurrentExecApprovalReadbackRoute(
|
|
generation: routeGeneration,
|
|
stableID: gatewayStableID)
|
|
else {
|
|
return .failed(message: "gateway_changed")
|
|
}
|
|
return self.recordCanonicalExecApprovalFetchOutcome(outcome, fence: readbackFence)
|
|
}
|
|
#endif
|
|
guard let context = await operatorRouteForExecApproval(
|
|
sourceReason: fetchReason,
|
|
expectedOperatorRoute: expectedOperatorRoute,
|
|
shouldContinue: shouldContinue)
|
|
else {
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: fetch prompt operator not connected id=\(approvalId) reason=\(fetchReason)")
|
|
return .failed(message: "operator_not_connected")
|
|
}
|
|
|
|
let rpcFamily = await self.execApprovalRPCFamily(route: context.route)
|
|
if rpcFamily == .legacy {
|
|
let outcome = await self.fetchLegacyExecApprovalPrompt(
|
|
approvalId: approvalId,
|
|
context: context,
|
|
fetchReason: fetchReason,
|
|
shouldContinue: shouldContinue)
|
|
return self.recordCanonicalExecApprovalFetchOutcome(outcome, fence: readbackFence)
|
|
}
|
|
guard rpcFamily == .unified else {
|
|
return .failed(message: "approval_methods_unavailable")
|
|
}
|
|
|
|
do {
|
|
let payloadJSON = try Self.encodePayload(ApprovalGetParams(id: approvalId))
|
|
let response = try await operatorGateway.request(
|
|
method: "approval.get",
|
|
paramsJSON: payloadJSON,
|
|
timeoutSeconds: 12,
|
|
ifCurrentRoute: context.route)
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
context,
|
|
session: self.operatorGateway,
|
|
shouldContinue: shouldContinue)
|
|
else {
|
|
return .failed(message: "route_changed")
|
|
}
|
|
let outcome = Self.decodeUnifiedExecApprovalGet(
|
|
response,
|
|
approvalId: approvalId,
|
|
gatewayStableID: context.gatewayStableID,
|
|
fetchReason: fetchReason)
|
|
return self.recordCanonicalExecApprovalFetchOutcome(outcome, fence: readbackFence)
|
|
} catch is CancellationError {
|
|
return .failed(message: "route_changed")
|
|
} catch {
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
context,
|
|
session: self.operatorGateway,
|
|
shouldContinue: shouldContinue)
|
|
else {
|
|
return .failed(message: "route_changed")
|
|
}
|
|
if Self.isApprovalNotificationStaleError(error) {
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: fetch prompt stale id=\(approvalId) reason=\(fetchReason)")
|
|
return .stale
|
|
}
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: fetch prompt failed "
|
|
+ "id=\(approvalId) reason=\(fetchReason) "
|
|
+ "error=\(error.localizedDescription)")
|
|
return .failed(message: error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private static func decodeUnifiedExecApprovalGet(
|
|
_ response: Data,
|
|
approvalId: String,
|
|
gatewayStableID: String,
|
|
fetchReason: String) -> ExecApprovalPromptFetchOutcome
|
|
{
|
|
do {
|
|
let result = try JSONDecoder().decode(ApprovalGetResult.self, from: response)
|
|
switch result.approval {
|
|
case let .pending(snapshot):
|
|
guard let prompt = Self.makeExecApprovalPrompt(
|
|
from: snapshot,
|
|
expectedApprovalID: approvalId,
|
|
gatewayStableID: gatewayStableID)
|
|
else {
|
|
return .failed(message: "invalid_prompt_payload")
|
|
}
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: fetch prompt loaded id=\(approvalId) reason=\(fetchReason)")
|
|
return .loaded(prompt)
|
|
case .allowed, .denied, .expired, .cancelled:
|
|
guard let terminal = Self.makeExecApprovalTerminalResult(
|
|
from: result.approval,
|
|
expectedApprovalID: approvalId)
|
|
else {
|
|
return .failed(message: "invalid_terminal_payload")
|
|
}
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: fetch terminal id=\(approvalId) "
|
|
+ "status=\(terminal.status) reason=\(fetchReason)")
|
|
return .terminal(terminal)
|
|
}
|
|
} catch {
|
|
return .failed(message: "invalid_approval_payload")
|
|
}
|
|
}
|
|
|
|
private func fetchLegacyExecApprovalPrompt(
|
|
approvalId: String,
|
|
context: GatewaySessionRouteContext,
|
|
fetchReason: String,
|
|
shouldContinue: @MainActor @Sendable () -> Bool) async -> ExecApprovalPromptFetchOutcome
|
|
{
|
|
do {
|
|
let payloadJSON = try Self.encodePayload(ExecApprovalGetParams(id: approvalId))
|
|
let response = try await self.operatorGateway.request(
|
|
method: "exec.approval.get",
|
|
paramsJSON: payloadJSON,
|
|
timeoutSeconds: 12,
|
|
ifCurrentRoute: context.route)
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
context,
|
|
session: self.operatorGateway,
|
|
shouldContinue: shouldContinue)
|
|
else {
|
|
return .failed(message: "route_changed")
|
|
}
|
|
let result = try JSONDecoder().decode(LegacyExecApprovalGetResult.self, from: response)
|
|
guard let prompt = Self.makeExecApprovalPrompt(
|
|
from: result,
|
|
expectedApprovalID: approvalId,
|
|
gatewayStableID: context.gatewayStableID)
|
|
else {
|
|
return .failed(message: "invalid_prompt_payload")
|
|
}
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: legacy fetch loaded id=\(approvalId) reason=\(fetchReason)")
|
|
return .loaded(prompt)
|
|
} catch is CancellationError {
|
|
return .failed(message: "route_changed")
|
|
} catch {
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
context,
|
|
session: self.operatorGateway,
|
|
shouldContinue: shouldContinue)
|
|
else {
|
|
return .failed(message: "route_changed")
|
|
}
|
|
if Self.isApprovalNotificationStaleError(error) {
|
|
return .stale
|
|
}
|
|
return .failed(message: error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
func dismissPendingExecApprovalPrompt() {
|
|
if let inboxKey = Self.execApprovalInboxKey(self.pendingExecApprovalPrompt),
|
|
self.execApprovalInboxPromptsByKey[inboxKey] != nil
|
|
{
|
|
self.dismissedExecApprovalPresentationKeys.insert(inboxKey)
|
|
}
|
|
self.pendingExecApprovalPromptSurfaceGeneration &+= 1
|
|
self.pendingExecApprovalPrompt = nil
|
|
self.pendingExecApprovalPromptResolving = false
|
|
self.pendingExecApprovalPromptErrorText = nil
|
|
self.pendingExecApprovalPromptOutcome = nil
|
|
}
|
|
|
|
func presentPendingExecApprovalFromInbox(_ key: ExecApprovalInboxKey) {
|
|
guard let prompt = self.execApprovalInboxPromptsByKey[key],
|
|
!self.terminalExecApprovalKeys.contains(key)
|
|
else { return }
|
|
self.presentFetchedExecApprovalPrompt(prompt, publishReason: "inbox_review")
|
|
}
|
|
|
|
func dismissPendingExecApprovalPrompt(approvalId: String) {
|
|
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
|
}
|
|
|
|
func resolvePendingExecApprovalPrompt(decision: String) async {
|
|
guard let prompt = pendingExecApprovalPrompt else { return }
|
|
guard self.pendingExecApprovalPromptResolvedText == nil else { return }
|
|
guard self.isExecApprovalPromptCurrent(prompt) else {
|
|
self.dismissPendingExecApprovalPrompt()
|
|
return
|
|
}
|
|
guard prompt.allowedDecisions.contains(decision) else { return }
|
|
guard let resolutionAttempt = self.beginExecApprovalResolutionAttempt(
|
|
approvalID: prompt.id,
|
|
gatewayStableID: prompt.gatewayStableID)
|
|
else { return }
|
|
defer { self.finishExecApprovalResolutionAttempt(resolutionAttempt) }
|
|
|
|
self.pendingExecApprovalPromptResolving = true
|
|
self.pendingExecApprovalPromptErrorText = nil
|
|
let outcome = await resolveExecApprovalNotificationDecision(
|
|
approvalId: prompt.id,
|
|
approvalKind: prompt.kind,
|
|
decision: decision,
|
|
expectedGatewayStableID: prompt.gatewayStableID,
|
|
resolutionAttempt: resolutionAttempt)
|
|
if case let .uncertain(message) = outcome {
|
|
// A gateway switch invalidates this attempt mid-flight, but a lost write
|
|
// outcome is owner-scoped durable state: record it before the attempt gate
|
|
// or switching back would offer a fresh actionable card for a decision that
|
|
// may already be applied. UI mutations stay keyed to the exact owner inside.
|
|
self.markExecApprovalResolutionUncertain(
|
|
approvalID: prompt.id,
|
|
gatewayStableID: prompt.gatewayStableID,
|
|
message: message)
|
|
}
|
|
guard self.isActiveExecApprovalResolutionAttempt(resolutionAttempt) else { return }
|
|
guard self.pendingExecApprovalPrompt.map({ Self.approvalIDsMatch($0.id, prompt.id) }) == true,
|
|
GatewayStableIdentifier.matches(
|
|
self.pendingExecApprovalPrompt?.gatewayStableID,
|
|
prompt.gatewayStableID)
|
|
else {
|
|
return
|
|
}
|
|
switch outcome {
|
|
case .resolved:
|
|
break
|
|
case let .pendingRetry(message):
|
|
self.pendingExecApprovalPromptResolving = false
|
|
self.pendingExecApprovalPromptErrorText = message
|
|
case .stale:
|
|
break
|
|
case .uncertain:
|
|
break
|
|
case let .failed(message):
|
|
self.pendingExecApprovalPromptResolving = false
|
|
self.pendingExecApprovalPromptErrorText = message
|
|
}
|
|
}
|
|
|
|
private func resolveExecApprovalNotificationDecision(
|
|
approvalId: String,
|
|
approvalKind: String?,
|
|
decision: String,
|
|
expectedGatewayStableID: String,
|
|
sourceReason: String? = nil,
|
|
resolutionAttempt: ExecApprovalResolutionAttempt? = nil) async -> ExecApprovalResolutionOutcome
|
|
{
|
|
guard let approvalID = Self.validatedApprovalID(approvalId) else {
|
|
return .failed(message: "Invalid approval request.")
|
|
}
|
|
let rawApprovalKind = approvalKind ?? ""
|
|
let normalizedSourceReason = sourceReason?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let resolutionReason = (normalizedSourceReason?.isEmpty == false) ? normalizedSourceReason! : "direct"
|
|
guard let approvalKind = ApprovalKind(rawValue: rawApprovalKind),
|
|
approvalKind.rawValue == ApprovalKind.exec.rawValue,
|
|
let approvalDecision = ApprovalDecision(rawValue: decision)
|
|
else {
|
|
return .failed(message: "Invalid approval request.")
|
|
}
|
|
guard GatewayStableIdentifier.matches(
|
|
self.currentExecApprovalGatewayStableID(),
|
|
expectedGatewayStableID)
|
|
else {
|
|
return .failed(message: "This approval belongs to a different gateway.")
|
|
}
|
|
|
|
#if DEBUG
|
|
if let outcome = await self.testExecApprovalResolutionOutcome(
|
|
approvalID: approvalID,
|
|
decision: decision,
|
|
expectedGatewayStableID: expectedGatewayStableID,
|
|
resolutionAttempt: resolutionAttempt)
|
|
{
|
|
return outcome
|
|
}
|
|
#endif
|
|
|
|
guard let context = await self.operatorRouteForExecApproval(sourceReason: resolutionReason),
|
|
GatewayStableIdentifier.matches(context.gatewayStableID, expectedGatewayStableID)
|
|
else {
|
|
self.execApprovalNotificationLogger.error(
|
|
"Exec approval action failed id=\(approvalID, privacy: .public): operator not connected")
|
|
return .failed(message: "OpenClaw couldn't connect to the gateway operator session.")
|
|
}
|
|
|
|
let rpcFamily = await self.execApprovalRPCFamily(route: context.route)
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
context,
|
|
session: self.operatorGateway,
|
|
shouldContinue: { true })
|
|
else {
|
|
return .failed(message: "The gateway operator route changed before the approval response was applied.")
|
|
}
|
|
if rpcFamily == .legacy {
|
|
return await self.resolveLegacyExecApproval(
|
|
approvalId: approvalID,
|
|
decision: approvalDecision,
|
|
context: context,
|
|
resolutionAttempt: resolutionAttempt)
|
|
}
|
|
guard rpcFamily == .unified else {
|
|
return .failed(message: "This gateway does not advertise a complete approval API.")
|
|
}
|
|
|
|
do {
|
|
let payloadJSON = try Self.encodePayload(
|
|
ApprovalResolveParams(
|
|
id: approvalID,
|
|
kind: approvalKind,
|
|
decision: approvalDecision))
|
|
let response = try await self.operatorGateway.request(
|
|
method: "approval.resolve",
|
|
paramsJSON: payloadJSON,
|
|
timeoutSeconds: 12,
|
|
ifCurrentRoute: context.route,
|
|
distinguishPreDispatchRouteChange: true)
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
context,
|
|
session: self.operatorGateway,
|
|
shouldContinue: { true })
|
|
else {
|
|
if let resolutionAttempt {
|
|
self.markExecApprovalResolutionWriteSettled(resolutionAttempt)
|
|
}
|
|
return .uncertain(
|
|
message: "Decision status is unknown after the gateway operator route changed.")
|
|
}
|
|
if let resolutionAttempt {
|
|
self.markExecApprovalResolutionWriteSettled(resolutionAttempt)
|
|
}
|
|
guard let result = try? JSONDecoder().decode(ApprovalResolveResult.self, from: response),
|
|
let terminal = Self.makeExecApprovalTerminalResult(
|
|
from: result.approval,
|
|
expectedApprovalID: approvalID)
|
|
else {
|
|
return await self.reconcileUnknownExecApprovalResolution(
|
|
approvalId: approvalID,
|
|
gatewayStableID: context.gatewayStableID,
|
|
operatorRoute: context.route)
|
|
}
|
|
if !Self.isValidUnifiedExecApprovalResolveAck(
|
|
result: result,
|
|
terminal: terminal,
|
|
attemptedDecision: approvalDecision)
|
|
{
|
|
return await self.reconcileUnknownExecApprovalResolution(
|
|
approvalId: approvalID,
|
|
gatewayStableID: context.gatewayStableID,
|
|
operatorRoute: context.route)
|
|
}
|
|
return await self.applyCanonicalExecApprovalTerminal(
|
|
terminal,
|
|
appliedHere: result.applied,
|
|
gatewayStableID: context.gatewayStableID)
|
|
} catch {
|
|
if let requestError = error as? GatewayNodeSessionRequestError,
|
|
case .routeChangedBeforeDispatch = requestError
|
|
{
|
|
if let resolutionAttempt {
|
|
self.markExecApprovalResolutionWriteSettled(resolutionAttempt)
|
|
}
|
|
return .failed(message: "The gateway operator route changed before the decision was sent.")
|
|
}
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
context,
|
|
session: self.operatorGateway,
|
|
shouldContinue: { true })
|
|
else {
|
|
if let resolutionAttempt {
|
|
self.markExecApprovalResolutionWriteSettled(resolutionAttempt)
|
|
}
|
|
return .uncertain(
|
|
message: "Decision status is unknown after the gateway operator route changed.")
|
|
}
|
|
if let resolutionAttempt {
|
|
self.markExecApprovalResolutionWriteSettled(resolutionAttempt)
|
|
}
|
|
let logMessage =
|
|
"Exec approval action response unknown id=\(approvalID) "
|
|
+ "error=\(error.localizedDescription)"
|
|
self.execApprovalNotificationLogger.error("\(logMessage, privacy: .public)")
|
|
return await self.reconcileUnknownExecApprovalResolution(
|
|
approvalId: approvalID,
|
|
gatewayStableID: context.gatewayStableID,
|
|
operatorRoute: context.route)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
/// Stubbed resolve transport for tests; nil when no handler is installed.
|
|
private func testExecApprovalResolutionOutcome(
|
|
approvalID: String,
|
|
decision: String,
|
|
expectedGatewayStableID: String,
|
|
resolutionAttempt: ExecApprovalResolutionAttempt?) async -> ExecApprovalResolutionOutcome?
|
|
{
|
|
guard let testExecApprovalResolutionHandler else { return nil }
|
|
let outcome = await testExecApprovalResolutionHandler(
|
|
approvalID,
|
|
decision,
|
|
expectedGatewayStableID)
|
|
if let resolutionAttempt {
|
|
self.markExecApprovalResolutionWriteSettled(resolutionAttempt)
|
|
}
|
|
if self.testExecApprovalResolutionReconcilesUnknownAck {
|
|
// Mirror the production unknown-ack path: the settled write's outcome is
|
|
// classified by canonical readback while the attempt lease stays active.
|
|
return await self.reconcileUnknownExecApprovalResolution(
|
|
approvalId: approvalID,
|
|
gatewayStableID: expectedGatewayStableID,
|
|
operatorRoute: nil)
|
|
}
|
|
return outcome
|
|
}
|
|
#endif
|
|
|
|
private func execApprovalRPCFamily(route: GatewayNodeSessionRoute) async -> ExecApprovalRPCFamily {
|
|
let unifiedGet = await self.operatorGateway.supportsServerMethod(
|
|
"approval.get",
|
|
ifCurrentRoute: route)
|
|
let unifiedResolve = await self.operatorGateway.supportsServerMethod(
|
|
"approval.resolve",
|
|
ifCurrentRoute: route)
|
|
let legacyGet = await self.operatorGateway.supportsServerMethod(
|
|
"exec.approval.get",
|
|
ifCurrentRoute: route)
|
|
let legacyResolve = await self.operatorGateway.supportsServerMethod(
|
|
"exec.approval.resolve",
|
|
ifCurrentRoute: route)
|
|
return Self.selectExecApprovalRPCFamily(
|
|
unifiedGet: unifiedGet,
|
|
unifiedResolve: unifiedResolve,
|
|
legacyGet: legacyGet,
|
|
legacyResolve: legacyResolve)
|
|
}
|
|
|
|
/// Legacy exec.approval.* fallback serves shipped Gateway v4 peers; remove when the
|
|
/// minimum supported gateway advertises approval.get/approval.resolve.
|
|
private nonisolated static func selectExecApprovalRPCFamily(
|
|
unifiedGet: Bool?,
|
|
unifiedResolve: Bool?,
|
|
legacyGet: Bool?,
|
|
legacyResolve: Bool?) -> ExecApprovalRPCFamily
|
|
{
|
|
if unifiedGet == true, unifiedResolve == true {
|
|
return .unified
|
|
}
|
|
if unifiedGet == false,
|
|
unifiedResolve == false,
|
|
legacyGet == true,
|
|
legacyResolve == true
|
|
{
|
|
return .legacy
|
|
}
|
|
return .unavailable
|
|
}
|
|
|
|
private func resolveLegacyExecApproval(
|
|
approvalId: String,
|
|
decision: ApprovalDecision,
|
|
context: GatewaySessionRouteContext,
|
|
resolutionAttempt: ExecApprovalResolutionAttempt?) async -> ExecApprovalResolutionOutcome
|
|
{
|
|
struct LegacyResolveResult: Decodable { let ok: Bool }
|
|
|
|
do {
|
|
let payloadJSON = try Self.encodePayload(ExecApprovalResolveParams(
|
|
id: approvalId,
|
|
decision: decision.rawValue))
|
|
let response = try await self.operatorGateway.request(
|
|
method: "exec.approval.resolve",
|
|
paramsJSON: payloadJSON,
|
|
timeoutSeconds: 12,
|
|
ifCurrentRoute: context.route,
|
|
distinguishPreDispatchRouteChange: true)
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
context,
|
|
session: self.operatorGateway,
|
|
shouldContinue: { true })
|
|
else {
|
|
if let resolutionAttempt {
|
|
self.markExecApprovalResolutionWriteSettled(resolutionAttempt)
|
|
}
|
|
return .uncertain(
|
|
message: "Decision status is unknown after the gateway operator route changed.")
|
|
}
|
|
if let resolutionAttempt {
|
|
self.markExecApprovalResolutionWriteSettled(resolutionAttempt)
|
|
}
|
|
guard (try? JSONDecoder().decode(LegacyResolveResult.self, from: response))?.ok == true else {
|
|
return await self.reconcileUnknownExecApprovalResolution(
|
|
approvalId: approvalId,
|
|
gatewayStableID: context.gatewayStableID,
|
|
operatorRoute: context.route)
|
|
}
|
|
let terminal = ExecApprovalTerminalResult(
|
|
id: approvalId,
|
|
verdict: Self.execApprovalVerdict(for: decision),
|
|
resolvedAtMs: Int64(Date().timeIntervalSince1970 * 1000))
|
|
return await self.applyLegacyExecApprovalTerminal(
|
|
terminal,
|
|
gatewayStableID: context.gatewayStableID)
|
|
} catch {
|
|
if let requestError = error as? GatewayNodeSessionRequestError,
|
|
case .routeChangedBeforeDispatch = requestError
|
|
{
|
|
if let resolutionAttempt {
|
|
self.markExecApprovalResolutionWriteSettled(resolutionAttempt)
|
|
}
|
|
return .failed(message: "The gateway operator route changed before the decision was sent.")
|
|
}
|
|
guard await self.isCurrentGatewaySessionRoute(
|
|
context,
|
|
session: self.operatorGateway,
|
|
shouldContinue: { true })
|
|
else {
|
|
if let resolutionAttempt {
|
|
self.markExecApprovalResolutionWriteSettled(resolutionAttempt)
|
|
}
|
|
return .uncertain(
|
|
message: "Decision status is unknown after the gateway operator route changed.")
|
|
}
|
|
if let resolutionAttempt {
|
|
self.markExecApprovalResolutionWriteSettled(resolutionAttempt)
|
|
}
|
|
if Self.isApprovalAlreadyResolvedError(error) {
|
|
let terminal = ExecApprovalTerminalResult(
|
|
id: approvalId,
|
|
verdict: .resolvedUnknown,
|
|
resolvedAtMs: Int64(Date().timeIntervalSince1970 * 1000))
|
|
return await self.applyCanonicalExecApprovalTerminal(
|
|
terminal,
|
|
appliedHere: false,
|
|
gatewayStableID: context.gatewayStableID)
|
|
}
|
|
return await self.reconcileUnknownExecApprovalResolution(
|
|
approvalId: approvalId,
|
|
gatewayStableID: context.gatewayStableID,
|
|
operatorRoute: context.route)
|
|
}
|
|
}
|
|
|
|
/// `operatorRoute` is nil only from the DEBUG unknown-ack seam, where the stubbed
|
|
/// fetch handler owns route admission instead of an operator session lease.
|
|
private func reconcileUnknownExecApprovalResolution(
|
|
approvalId: String,
|
|
gatewayStableID: String,
|
|
operatorRoute: GatewayNodeSessionRoute?) async -> ExecApprovalResolutionOutcome
|
|
{
|
|
switch await self.fetchExecApprovalPrompt(
|
|
approvalId: approvalId,
|
|
sourceReason: "resolve_reconcile",
|
|
expectedOperatorRoute: operatorRoute)
|
|
{
|
|
case let .terminal(terminal):
|
|
return await self.applyCanonicalExecApprovalTerminal(
|
|
terminal,
|
|
appliedHere: false,
|
|
gatewayStableID: gatewayStableID)
|
|
case let .loaded(prompt):
|
|
if self.pendingExecApprovalPrompt.map({ Self.approvalIDsMatch($0.id, approvalId) }) == true,
|
|
GatewayStableIdentifier.matches(
|
|
self.pendingExecApprovalPrompt?.gatewayStableID,
|
|
gatewayStableID)
|
|
{
|
|
self.presentFetchedExecApprovalPrompt(prompt, publishReason: "resolve_retry")
|
|
} else {
|
|
self.upsertWatchExecApprovalPrompt(prompt)
|
|
await self.publishWatchExecApprovalPrompt(prompt, reason: "resolve_retry")
|
|
}
|
|
return .pendingRetry(message: "The previous decision was not recorded. Review and try again.")
|
|
case .stale:
|
|
// This readback follows a dispatched write whose response was lost or malformed.
|
|
// Legacy get removes committed rows, so not-found cannot distinguish success from
|
|
// expiry. Keep every surface frozen until an explicit terminal event/reconnect.
|
|
return .uncertain(
|
|
message: "Decision status is unknown. Actions remain locked until OpenClaw reconnects.")
|
|
case .failed:
|
|
return .uncertain(message: "Decision status is unknown. Actions remain locked until OpenClaw reconnects.")
|
|
}
|
|
}
|
|
|
|
private func applyCanonicalExecApprovalTerminal(
|
|
_ terminal: ExecApprovalTerminalResult,
|
|
appliedHere: Bool,
|
|
gatewayStableID: String,
|
|
syncSnapshots: Bool = true) async -> ExecApprovalResolutionOutcome
|
|
{
|
|
guard GatewayStableIdentifier.matches(
|
|
self.currentExecApprovalGatewayStableID(),
|
|
gatewayStableID)
|
|
else {
|
|
return .failed(message: "This approval belongs to a different gateway.")
|
|
}
|
|
// Record the owner tombstone before any suspension point. A concurrent pending
|
|
// readback must not resurrect this exact approval after canonical terminal truth.
|
|
self.markExecApprovalOwnerTerminal(
|
|
approvalId: terminal.id,
|
|
gatewayStableID: gatewayStableID)
|
|
self.markPendingExecApprovalTerminal(
|
|
terminal,
|
|
alreadyResolved: !appliedHere)
|
|
await self.removeCurrentGatewayExecApprovalNotifications(approvalId: terminal.id)
|
|
await self.publishWatchExecApprovalTerminal(
|
|
terminal,
|
|
gatewayStableID: gatewayStableID,
|
|
source: appliedHere ? "iphone" : "another-reviewer",
|
|
syncSnapshots: syncSnapshots)
|
|
return .resolved(terminal, applied: appliedHere)
|
|
}
|
|
|
|
private func applyLegacyExecApprovalTerminal(
|
|
_ terminal: ExecApprovalTerminalResult,
|
|
gatewayStableID: String) async -> ExecApprovalResolutionOutcome
|
|
{
|
|
guard GatewayStableIdentifier.matches(
|
|
self.currentExecApprovalGatewayStableID(),
|
|
gatewayStableID)
|
|
else {
|
|
return .failed(message: "This approval belongs to a different gateway.")
|
|
}
|
|
self.markExecApprovalOwnerTerminal(
|
|
approvalId: terminal.id,
|
|
gatewayStableID: gatewayStableID)
|
|
self.markPendingExecApprovalTerminal(
|
|
terminal,
|
|
alreadyResolved: false)
|
|
await self.removeCurrentGatewayExecApprovalNotifications(approvalId: terminal.id)
|
|
// Legacy {ok:true} proves terminal acceptance, but not which surface won.
|
|
// Attribute the canonical result to the gateway and keep its wording neutral.
|
|
await self.publishWatchExecApprovalTerminal(
|
|
terminal,
|
|
gatewayStableID: gatewayStableID,
|
|
source: "gateway")
|
|
return .resolved(terminal, applied: false)
|
|
}
|
|
|
|
private func markPendingExecApprovalTerminal(
|
|
_ terminal: ExecApprovalTerminalResult,
|
|
alreadyResolved: Bool)
|
|
{
|
|
let tone: ExecApprovalOutcomeTone = switch terminal.verdict {
|
|
case .allowOnce, .allowAlways:
|
|
.success
|
|
case .deny:
|
|
.danger
|
|
case .expired, .cancelled:
|
|
.warning
|
|
case .resolvedUnknown:
|
|
.neutral
|
|
}
|
|
self.markPendingExecApprovalTerminal(
|
|
approvalId: terminal.id,
|
|
outcome: ExecApprovalOutcome(
|
|
text: Self.execApprovalTerminalText(terminal, alreadyResolved: alreadyResolved),
|
|
tone: tone))
|
|
}
|
|
|
|
private func markPendingExecApprovalTerminal(
|
|
approvalId: String,
|
|
outcome: ExecApprovalOutcome)
|
|
{
|
|
self.clearNotificationPermissionGuidancePromptIfMatches(approvalId)
|
|
guard self.pendingExecApprovalPrompt.map({ Self.approvalIDsMatch($0.id, approvalId) }) == true else {
|
|
return
|
|
}
|
|
self.pendingExecApprovalPromptSurfaceGeneration &+= 1
|
|
self.pendingExecApprovalPromptResolving = false
|
|
self.pendingExecApprovalPromptErrorText = nil
|
|
self.pendingExecApprovalPromptOutcome = outcome
|
|
}
|
|
|
|
private static func execApprovalVerdict(for decision: ApprovalDecision) -> ExecApprovalTerminalVerdict {
|
|
switch decision {
|
|
case .allowOnce:
|
|
.allowOnce
|
|
case .allowAlways:
|
|
.allowAlways
|
|
case .deny:
|
|
.deny
|
|
}
|
|
}
|
|
|
|
private static func isValidUnifiedExecApprovalResolveAck(
|
|
result: ApprovalResolveResult,
|
|
terminal: ExecApprovalTerminalResult,
|
|
attemptedDecision: ApprovalDecision) -> Bool
|
|
{
|
|
!result.applied || terminal.decision == attemptedDecision.rawValue
|
|
}
|
|
|
|
private func clearPendingExecApprovalPromptIfMatches(_ approvalId: String) {
|
|
guard let approvalID = Self.validatedApprovalID(approvalId) else { return }
|
|
self.clearNotificationPermissionGuidancePromptIfMatches(approvalID)
|
|
guard self.pendingExecApprovalPrompt.map({ Self.approvalIDsMatch($0.id, approvalID) }) == true else {
|
|
return
|
|
}
|
|
self.dismissPendingExecApprovalPrompt()
|
|
}
|
|
|
|
private func removeCurrentGatewayExecApprovalNotifications(approvalId: String) async {
|
|
let delivered = await notificationCenter.deliveredNotifications()
|
|
var seen = Set<ExecApprovalPushKey>()
|
|
for snapshot in delivered {
|
|
guard let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: snapshot.userInfo),
|
|
let pushKey = Self.execApprovalPushKey(push),
|
|
Self.approvalIDsMatch(push.approvalId, approvalId),
|
|
seen.insert(pushKey).inserted,
|
|
await validatedExecApprovalPushRoute(
|
|
push,
|
|
sourceReason: "notification_action") != nil
|
|
else {
|
|
continue
|
|
}
|
|
await ExecApprovalNotificationBridge.removeNotifications(
|
|
for: push,
|
|
notificationCenter: self.notificationCenter)
|
|
}
|
|
}
|
|
|
|
private func clearNotificationPermissionGuidancePromptIfMatches(_ approvalId: String) {
|
|
guard let approvalID = Self.validatedApprovalID(approvalId) else { return }
|
|
guard self.pendingNotificationPermissionGuidancePrompt.map({
|
|
Self.approvalIDsMatch($0.approvalId, approvalID)
|
|
}) == true else { return }
|
|
self.pendingNotificationPermissionGuidancePrompt = nil
|
|
}
|
|
|
|
private nonisolated static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
|
|
guard let gatewayError = error as? GatewayResponseError else { return false }
|
|
if gatewayError.code != "INVALID_REQUEST" {
|
|
return false
|
|
}
|
|
if gatewayError.detailsReason == "APPROVAL_NOT_FOUND" {
|
|
return true
|
|
}
|
|
return gatewayError.message.lowercased().contains("unknown or expired approval id")
|
|
}
|
|
|
|
private nonisolated static func isApprovalAlreadyResolvedError(_ error: Error) -> Bool {
|
|
guard let gatewayError = error as? GatewayResponseError else { return false }
|
|
return gatewayError.code == "INVALID_REQUEST" &&
|
|
gatewayError.detailsReason == "APPROVAL_ALREADY_RESOLVED"
|
|
}
|
|
|
|
private struct BackgroundAliveWakeAttemptResult {
|
|
var applied: Bool
|
|
var handled: Bool
|
|
var reason: String
|
|
var durationMs: Int
|
|
}
|
|
|
|
private func waitForGatewayConnection(timeoutMs: Int, pollMs: Int) async -> Bool {
|
|
let clampedTimeoutMs = max(0, timeoutMs)
|
|
let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000
|
|
let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0)
|
|
while Date() < deadline {
|
|
if Task.isCancelled {
|
|
return false
|
|
}
|
|
if await isGatewayConnected() {
|
|
return true
|
|
}
|
|
do {
|
|
try await Task.sleep(nanoseconds: pollIntervalNs)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
return await isGatewayConnected()
|
|
}
|
|
|
|
private func waitForOperatorConnection(timeoutMs: Int, pollMs: Int) async -> Bool {
|
|
let clampedTimeoutMs = max(0, timeoutMs)
|
|
let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000
|
|
let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0)
|
|
while Date() < deadline {
|
|
if Task.isCancelled {
|
|
return false
|
|
}
|
|
if await self.isOperatorConnected() {
|
|
return true
|
|
}
|
|
do {
|
|
try await Task.sleep(nanoseconds: pollIntervalNs)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
return await self.isOperatorConnected()
|
|
}
|
|
|
|
private func ensureOperatorReconnectLoopIfNeeded() {
|
|
guard let cfg = activeGatewayConnectConfig else {
|
|
return
|
|
}
|
|
guard self.operatorGatewayTask == nil else {
|
|
return
|
|
}
|
|
let 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)
|
|
}
|
|
|
|
private func ensureOperatorApprovalConnectionForWatchReview(timeoutMs: Int, reason: String) async -> Bool {
|
|
let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let reconnectReason = normalizedReason.isEmpty ? "watch_request" : normalizedReason
|
|
if await self.isOperatorConnected() {
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_connected "
|
|
+ "reason=\(reconnectReason) phase=already_connected")
|
|
return true
|
|
}
|
|
|
|
guard self.isBackgrounded else {
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_begin "
|
|
+ "reason=\(reconnectReason) backgrounded=false strategy=default")
|
|
let connected = await ensureOperatorApprovalConnection(timeoutMs: timeoutMs)
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") "
|
|
+ "reason=\(reconnectReason) phase=foreground_delegate")
|
|
return connected
|
|
}
|
|
|
|
guard self.gatewayAutoReconnectEnabled else {
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_timeout "
|
|
+ "reason=\(reconnectReason) phase=auto_reconnect_disabled")
|
|
return false
|
|
}
|
|
|
|
guard let cfg = activeGatewayConnectConfig else {
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_timeout "
|
|
+ "reason=\(reconnectReason) phase=no_active_gateway_config")
|
|
return false
|
|
}
|
|
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_begin reason=\(reconnectReason) backgrounded=true")
|
|
let leaseSeconds = min(45.0, max(15.0, Double(max(timeoutMs, 1000)) / 1000.0 + 8.0))
|
|
self.grantBackgroundReconnectLease(seconds: leaseSeconds, reason: "watch_review_\(reconnectReason)")
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_lease_granted "
|
|
+ "reason=\(reconnectReason) seconds=\(leaseSeconds)")
|
|
|
|
let hadReconnectLoop = self.operatorGatewayTask != nil
|
|
let canStartReconnectLoop = hadReconnectLoop || self.shouldStartOperatorGatewayLoop(
|
|
token: cfg.token,
|
|
bootstrapToken: cfg.bootstrapToken,
|
|
password: cfg.password,
|
|
deviceAuthGatewayID: cfg.nodeOptions.deviceAuthGatewayID ?? cfg.effectiveStableID,
|
|
allowStoredDeviceAuth: cfg.nodeOptions.allowStoredDeviceAuth)
|
|
guard canStartReconnectLoop else {
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_timeout "
|
|
+ "reason=\(reconnectReason) phase=no_operator_reconnect_auth")
|
|
return false
|
|
}
|
|
|
|
self.ensureOperatorReconnectLoopIfNeeded()
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_loop_\(hadReconnectLoop ? "reused" : "started") "
|
|
+ "reason=\(reconnectReason)")
|
|
|
|
let initialWaitMs = min(2500, max(750, timeoutMs / 4))
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_wait "
|
|
+ "reason=\(reconnectReason) phase=initial timeoutMs=\(initialWaitMs)")
|
|
if await self.waitForOperatorConnection(timeoutMs: initialWaitMs, pollMs: 200) {
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_connected "
|
|
+ "reason=\(reconnectReason) phase=initial")
|
|
return true
|
|
}
|
|
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_restart reason=\(reconnectReason)")
|
|
self.operatorGatewayTask?.cancel()
|
|
self.operatorGatewayTask = nil
|
|
await self.operatorGateway.disconnect()
|
|
self.setOperatorConnected(false)
|
|
self.talkMode.updateGatewayConnected(false)
|
|
self.stopGatewayHealthMonitor()
|
|
|
|
let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
|
|
self.startOperatorGatewayLoop(
|
|
url: cfg.url,
|
|
stableID: cfg.effectiveStableID,
|
|
token: cfg.token,
|
|
bootstrapToken: cfg.bootstrapToken,
|
|
password: cfg.password,
|
|
nodeOptions: cfg.nodeOptions,
|
|
sessionBox: sessionBox)
|
|
|
|
let remainingWaitMs = max(250, timeoutMs - initialWaitMs)
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_wait "
|
|
+ "reason=\(reconnectReason) phase=restart timeoutMs=\(remainingWaitMs)")
|
|
let connected = await waitForOperatorConnection(timeoutMs: remainingWaitMs, pollMs: 200)
|
|
GatewayDiagnostics.log(
|
|
"watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") "
|
|
+ "reason=\(reconnectReason) phase=restart")
|
|
return connected
|
|
}
|
|
|
|
private func ensureOperatorApprovalConnection(timeoutMs: Int) async -> Bool {
|
|
if await self.isOperatorConnected() {
|
|
return true
|
|
}
|
|
self.ensureOperatorReconnectLoopIfNeeded()
|
|
return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250)
|
|
}
|
|
|
|
private func performBackgroundAliveBeaconIfNeeded(
|
|
wakeId: String,
|
|
trigger: BackgroundAliveBeacon.Trigger) async -> BackgroundAliveWakeAttemptResult
|
|
{
|
|
let startedAt = Date()
|
|
let makeResult: (Bool, Bool, String) -> BackgroundAliveWakeAttemptResult = { applied, handled, reason in
|
|
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
|
return BackgroundAliveWakeAttemptResult(
|
|
applied: applied,
|
|
handled: handled,
|
|
reason: reason,
|
|
durationMs: max(0, durationMs))
|
|
}
|
|
|
|
guard self.isBackgrounded else {
|
|
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): app not backgrounded")
|
|
return makeResult(false, false, "not_backgrounded")
|
|
}
|
|
guard self.gatewayAutoReconnectEnabled else {
|
|
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): auto reconnect disabled")
|
|
return makeResult(false, false, "auto_reconnect_disabled")
|
|
}
|
|
let now = Date()
|
|
let gatewayConnected = await isGatewayConnected()
|
|
|
|
var appliedReconnect = false
|
|
if !gatewayConnected {
|
|
guard let cfg = activeGatewayConnectConfig else {
|
|
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): no active gateway config")
|
|
return makeResult(false, false, "no_active_gateway_config")
|
|
}
|
|
let generation = self.gatewayConnectGeneration
|
|
self.pushWakeLogger.info(
|
|
"Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)")
|
|
self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)")
|
|
await self.resetGatewaySessionsForForcedReconnect()
|
|
guard generation == self.gatewayConnectGeneration,
|
|
self.gatewayAutoReconnectEnabled,
|
|
self.activeGatewayConnectConfig?.hasSameConnectionInputs(as: cfg) == true
|
|
else {
|
|
return makeResult(false, false, "reconnect_superseded")
|
|
}
|
|
self.setOperatorConnected(false)
|
|
self.gatewayConnected = false
|
|
self.setGatewayConnectionProgress(reconnecting: true)
|
|
self.talkMode.updateGatewayConnected(false)
|
|
self.applyGatewayConnectConfig(cfg, expectedGeneration: generation)
|
|
appliedReconnect = true
|
|
self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)")
|
|
|
|
let connected = await waitForGatewayConnection(timeoutMs: 12000, pollMs: 250)
|
|
guard connected else {
|
|
return makeResult(appliedReconnect, false, "connect_timeout")
|
|
}
|
|
guard generation == self.gatewayConnectGeneration else {
|
|
return makeResult(appliedReconnect, false, "reconnect_superseded")
|
|
}
|
|
} else if BackgroundAliveBeacon.shouldSkipRecentSuccess(
|
|
isGatewayConnected: true,
|
|
now: now,
|
|
lastSuccessAtMs: UserDefaults.standard.object(forKey: Self.backgroundAliveLastSuccessAtMsKey) as? Double)
|
|
{
|
|
return makeResult(false, true, "recent_success")
|
|
}
|
|
|
|
let beacon = await publishBackgroundAliveBeacon(trigger: trigger)
|
|
if beacon.handled {
|
|
let successAtMs = Date().timeIntervalSince1970 * 1000
|
|
UserDefaults.standard.set(successAtMs, forKey: Self.backgroundAliveLastSuccessAtMsKey)
|
|
UserDefaults.standard.set(trigger.rawValue, forKey: Self.backgroundAliveLastTriggerKey)
|
|
return makeResult(appliedReconnect, true, beacon.reason)
|
|
}
|
|
return makeResult(appliedReconnect, false, beacon.reason)
|
|
}
|
|
|
|
private func publishBackgroundAliveBeacon(
|
|
trigger: BackgroundAliveBeacon.Trigger) async -> (handled: Bool, reason: String)
|
|
{
|
|
do {
|
|
let pushTransport = await pushRegistrationManager.usesRelayTransport ? "relay" : "direct"
|
|
let displayName = NodeDisplayName.resolve(
|
|
existing: UserDefaults.standard.string(forKey: "node.displayName"),
|
|
deviceName: UIDevice.current.name,
|
|
interfaceIdiom: UIDevice.current.userInterfaceIdiom)
|
|
let payload = BackgroundAliveBeacon.makePayload(
|
|
trigger: trigger,
|
|
displayName: displayName,
|
|
pushTransport: pushTransport)
|
|
let paramsJSON = try BackgroundAliveBeacon.makeNodeEventRequestPayloadJSON(payload: payload)
|
|
let response = try await nodeGateway.request(
|
|
method: "node.event",
|
|
paramsJSON: paramsJSON,
|
|
timeoutSeconds: 8)
|
|
guard let decoded = BackgroundAliveBeacon.decodeResponse(response) else {
|
|
return (false, "invalid_response")
|
|
}
|
|
if decoded.handled == true {
|
|
return (true, decoded.reason ?? "beacon_persisted")
|
|
}
|
|
return (false, decoded.reason ?? "unsupported")
|
|
} catch {
|
|
return (false, "beacon_failed")
|
|
}
|
|
}
|
|
}
|
|
|
|
extension NodeAppModel {
|
|
private func refreshWakeWordsFromGateway(
|
|
shouldApply: @escaping @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
do {
|
|
let data = try await operatorGateway.request(
|
|
method: "voicewake.get",
|
|
paramsJSON: "{}",
|
|
timeoutSeconds: 8)
|
|
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
|
|
guard shouldApply() else { return }
|
|
VoiceWakePreferences.saveTriggerWords(triggers)
|
|
} catch {
|
|
guard shouldApply() else { return }
|
|
if let gatewayError = error as? GatewayResponseError {
|
|
let lower = gatewayError.message.lowercased()
|
|
if lower.contains("unauthorized role") || lower.contains("missing scope") {
|
|
self.setGatewayHealthMonitorDisabled(true)
|
|
return
|
|
}
|
|
}
|
|
// Best-effort only.
|
|
}
|
|
}
|
|
|
|
private func isGatewayHealthMonitorDisabled() -> Bool {
|
|
self.gatewayHealthMonitorDisabled
|
|
}
|
|
|
|
private func setGatewayHealthMonitorDisabled(_ disabled: Bool) {
|
|
self.gatewayHealthMonitorDisabled = disabled
|
|
}
|
|
|
|
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
|
try Task.checkCancellation()
|
|
let routeGeneration = self.gatewayRouteGeneration
|
|
let gatewayStableID = self.connectedGatewayID
|
|
if await !self.isGatewayConnected() {
|
|
throw NSError(domain: "Gateway", code: 10, userInfo: [
|
|
NSLocalizedDescriptionKey: "Gateway not connected",
|
|
])
|
|
}
|
|
try Task.checkCancellation()
|
|
guard let gatewayStableID,
|
|
let nodeRoute = await self.nodeGateway.currentRoute(),
|
|
self.isCurrentGatewayRoute(
|
|
generation: routeGeneration,
|
|
stableID: gatewayStableID)
|
|
else { throw CancellationError() }
|
|
if let sessionKey, sessionKey != self.mainSessionKey {
|
|
throw CancellationError()
|
|
}
|
|
try Task.checkCancellation()
|
|
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",
|
|
])
|
|
}
|
|
// Voice Wake suppression cancels the owning command task. Check at the
|
|
// dispatch boundary so a PTT/background takeover cannot send stale audio intent.
|
|
try Task.checkCancellation()
|
|
let sent = await self.nodeGateway.sendEvent(
|
|
event: "voice.transcript",
|
|
payloadJSON: json,
|
|
ifCurrentRoute: nodeRoute)
|
|
guard sent else { throw CancellationError() }
|
|
}
|
|
|
|
func handleDeepLink(url: URL) async {
|
|
guard let route = DeepLinkParser.parse(url) else { return }
|
|
|
|
switch route {
|
|
case let .agent(link):
|
|
await self.handleAgentDeepLink(link, originalURL: url)
|
|
case let .gateway(link):
|
|
self.stageGatewaySetupLink(link)
|
|
case .dashboard:
|
|
break
|
|
}
|
|
}
|
|
|
|
func stageGatewaySetupLink(_ link: GatewayConnectDeepLink) {
|
|
self.pendingGatewaySetupLink = link
|
|
self.gatewaySetupRequestID &+= 1
|
|
}
|
|
|
|
func consumePendingGatewaySetupLink() -> GatewayConnectDeepLink? {
|
|
defer { self.pendingGatewaySetupLink = nil }
|
|
return self.pendingGatewaySetupLink
|
|
}
|
|
|
|
private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async {
|
|
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !message.isEmpty else { return }
|
|
self.deepLinkLogger.info(
|
|
"agent deep link messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)")
|
|
|
|
if message.count > IOSDeepLinkAgentPolicy.maxMessageChars {
|
|
self.screen.errorText = "Deep link too large (message exceeds "
|
|
+ "\(IOSDeepLinkAgentPolicy.maxMessageChars) characters)."
|
|
self.recordShareEvent("Rejected: message too large (\(message.count) chars).")
|
|
return
|
|
}
|
|
|
|
guard await self.isGatewayConnected() else {
|
|
self.screen.errorText = "Gateway not connected (cannot forward deep link)."
|
|
self.recordShareEvent("Failed: gateway not connected.")
|
|
self.deepLinkLogger.error("agent deep link rejected: gateway not connected")
|
|
return
|
|
}
|
|
|
|
let allowUnattended = self.isUnattendedDeepLinkAllowed(link.key)
|
|
if !allowUnattended {
|
|
if message.count > IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars {
|
|
self.screen.errorText = "Deep link blocked (message too long without key)."
|
|
self.recordShareEvent(
|
|
"Rejected: deep link over \(IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars) chars without key.")
|
|
self.deepLinkLogger.error(
|
|
"agent deep link rejected: unkeyed message too long chars=\(message.count, privacy: .public)")
|
|
return
|
|
}
|
|
let urlText = originalURL.absoluteString
|
|
let prompt = AgentDeepLinkPrompt(
|
|
id: UUID().uuidString,
|
|
messagePreview: message,
|
|
urlPreview: urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText,
|
|
request: self.effectiveAgentDeepLinkForPrompt(link))
|
|
|
|
let promptIntervalSeconds = 5.0
|
|
let elapsed = Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt)
|
|
if elapsed < promptIntervalSeconds {
|
|
if self.pendingAgentDeepLinkPrompt != nil {
|
|
self.pendingAgentDeepLinkPrompt = prompt
|
|
self.recordShareEvent("Updated local confirmation request (\(message.count) chars).")
|
|
self.deepLinkLogger.debug("agent deep link prompt coalesced into active confirmation")
|
|
return
|
|
}
|
|
|
|
let remaining = max(0, promptIntervalSeconds - elapsed)
|
|
self.queueAgentDeepLinkPrompt(prompt, initialDelaySeconds: remaining)
|
|
self.recordShareEvent("Queued local confirmation (\(message.count) chars).")
|
|
self.deepLinkLogger.debug("agent deep link prompt queued due to rate limit")
|
|
return
|
|
}
|
|
|
|
self.presentAgentDeepLinkPrompt(prompt)
|
|
self.recordShareEvent("Awaiting local confirmation (\(message.count) chars).")
|
|
self.deepLinkLogger.info("agent deep link requires local confirmation")
|
|
return
|
|
}
|
|
|
|
await self.submitAgentDeepLink(link, messageCharCount: message.count)
|
|
}
|
|
|
|
private func sendAgentRequest(
|
|
link: AgentDeepLink,
|
|
expectedNodeRoute: GatewayNodeSessionRoute? = nil) async throws
|
|
{
|
|
if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
throw NSError(domain: "DeepLink", code: 1, userInfo: [
|
|
NSLocalizedDescriptionKey: "invalid agent message",
|
|
])
|
|
}
|
|
|
|
#if DEBUG
|
|
if let testAgentRequestHandler {
|
|
try await testAgentRequestHandler(link)
|
|
return
|
|
}
|
|
#endif
|
|
|
|
let data = try JSONEncoder().encode(link)
|
|
guard let json = String(bytes: data, encoding: .utf8) else {
|
|
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
|
|
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
|
|
])
|
|
}
|
|
let requestData = try JSONEncoder().encode(NodeEventRequestPayload(event: "agent.request", payloadJSON: json))
|
|
guard let requestJSON = String(bytes: requestData, encoding: .utf8) else {
|
|
throw NSError(domain: "NodeAppModel", code: 3, userInfo: [
|
|
NSLocalizedDescriptionKey: "Failed to encode agent request node event as UTF-8",
|
|
])
|
|
}
|
|
_ = try await self.nodeGateway.request(
|
|
method: "node.event",
|
|
paramsJSON: requestJSON,
|
|
timeoutSeconds: Self.agentRequestNodeEventTimeoutSeconds,
|
|
ifCurrentRoute: expectedNodeRoute)
|
|
}
|
|
|
|
private func isGatewayConnected() async -> Bool {
|
|
self.gatewayConnected
|
|
}
|
|
|
|
private func applyMainSessionKey(_ key: String?) {
|
|
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed == current { return }
|
|
self.mainSessionBaseKey = trimmed
|
|
self.synchronizeTalkSessionKey()
|
|
}
|
|
|
|
func approvePendingAgentDeepLinkPrompt() async {
|
|
guard let prompt = pendingAgentDeepLinkPrompt else { return }
|
|
self.pendingAgentDeepLinkPrompt = nil
|
|
guard await self.isGatewayConnected() else {
|
|
self.screen.errorText = "Gateway not connected (cannot forward deep link)."
|
|
self.recordShareEvent("Failed: gateway not connected.")
|
|
self.deepLinkLogger.error("agent deep link approval failed: gateway not connected")
|
|
return
|
|
}
|
|
await self.submitAgentDeepLink(prompt.request, messageCharCount: prompt.messagePreview.count)
|
|
}
|
|
|
|
func declinePendingAgentDeepLinkPrompt() {
|
|
guard self.pendingAgentDeepLinkPrompt != nil else { return }
|
|
self.pendingAgentDeepLinkPrompt = nil
|
|
self.screen.errorText = "Deep link cancelled."
|
|
self.recordShareEvent("Cancelled: deep link confirmation declined.")
|
|
self.deepLinkLogger.info("agent deep link cancelled by local user")
|
|
}
|
|
|
|
private func presentAgentDeepLinkPrompt(_ prompt: AgentDeepLinkPrompt) {
|
|
self.lastAgentDeepLinkPromptAt = Date()
|
|
self.pendingAgentDeepLinkPrompt = prompt
|
|
}
|
|
|
|
private func queueAgentDeepLinkPrompt(_ prompt: AgentDeepLinkPrompt, initialDelaySeconds: TimeInterval) {
|
|
self.queuedAgentDeepLinkPrompt = prompt
|
|
guard self.queuedAgentDeepLinkPromptTask == nil else { return }
|
|
|
|
self.queuedAgentDeepLinkPromptTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
let delayNs = UInt64(max(0, initialDelaySeconds) * 1_000_000_000)
|
|
if delayNs > 0 {
|
|
do {
|
|
try await Task.sleep(nanoseconds: delayNs)
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
await self.deliverQueuedAgentDeepLinkPrompt()
|
|
}
|
|
}
|
|
|
|
private func deliverQueuedAgentDeepLinkPrompt() async {
|
|
defer { self.queuedAgentDeepLinkPromptTask = nil }
|
|
let promptIntervalSeconds = 5.0
|
|
while let prompt = queuedAgentDeepLinkPrompt {
|
|
if self.pendingAgentDeepLinkPrompt != nil {
|
|
do {
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
} catch {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
|
|
let elapsed = Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt)
|
|
if elapsed < promptIntervalSeconds {
|
|
let remaining = max(0, promptIntervalSeconds - elapsed)
|
|
do {
|
|
try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
|
} catch {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
|
|
self.queuedAgentDeepLinkPrompt = nil
|
|
self.presentAgentDeepLinkPrompt(prompt)
|
|
self.recordShareEvent("Awaiting local confirmation (\(prompt.messagePreview.count) chars).")
|
|
self.deepLinkLogger.info("agent deep link queued prompt delivered")
|
|
}
|
|
}
|
|
|
|
private func submitAgentDeepLink(_ link: AgentDeepLink, messageCharCount: Int) async {
|
|
do {
|
|
try await self.sendAgentRequest(link: link)
|
|
self.screen.errorText = nil
|
|
self.recordShareEvent("Sent to gateway (\(messageCharCount) chars).")
|
|
self.deepLinkLogger.info("agent deep link forwarded to gateway")
|
|
self.openChatRequestID &+= 1
|
|
} catch {
|
|
self.screen.errorText = "Agent request failed: \(error.localizedDescription)"
|
|
self.recordShareEvent("Failed: \(error.localizedDescription)")
|
|
self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
private func effectiveAgentDeepLinkForPrompt(_ link: AgentDeepLink) -> AgentDeepLink {
|
|
// Without a trusted key, strip delivery/routing knobs to reduce exfiltration risk.
|
|
AgentDeepLink(
|
|
message: link.message,
|
|
sessionKey: link.sessionKey,
|
|
thinking: link.thinking,
|
|
deliver: false,
|
|
to: nil,
|
|
channel: nil,
|
|
timeoutSeconds: link.timeoutSeconds,
|
|
key: link.key)
|
|
}
|
|
|
|
private func isUnattendedDeepLinkAllowed(_ key: String?) -> Bool {
|
|
let normalizedKey = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !normalizedKey.isEmpty else { return false }
|
|
return normalizedKey == Self.canvasUnattendedDeepLinkKey || normalizedKey == Self.expectedDeepLinkKey()
|
|
}
|
|
|
|
private static func expectedDeepLinkKey() -> String {
|
|
let defaults = UserDefaults.standard
|
|
if let key = defaults.string(forKey: deepLinkKeyUserDefaultsKey), !key.isEmpty {
|
|
return key
|
|
}
|
|
let key = self.generateDeepLinkKey()
|
|
defaults.set(key, forKey: self.deepLinkKeyUserDefaultsKey)
|
|
return key
|
|
}
|
|
|
|
private static func generateDeepLinkKey() -> String {
|
|
var bytes = [UInt8](repeating: 0, count: 32)
|
|
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
|
let data = Data(bytes)
|
|
return data
|
|
.base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
}
|
|
}
|
|
|
|
extension NodeAppModel {
|
|
func _bridgeConsumeMirroredWatchReply(_ event: WatchQuickReplyEvent) async {
|
|
await self.handleWatchQuickReply(event)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
extension NodeAppModel {
|
|
func _test_handleInvoke(
|
|
_ req: BridgeInvokeRequest,
|
|
gatewayStableID: String? = nil) async -> BridgeInvokeResponse
|
|
{
|
|
await self.handleInvoke(req, gatewayStableID: gatewayStableID)
|
|
}
|
|
|
|
func _test_acquirePttVoiceWakeLease(captureId: String) {
|
|
self.acquirePttVoiceWakeLease(for: captureId)
|
|
}
|
|
|
|
func _test_releasePttVoiceWakeLease(captureId: String) {
|
|
self.releasePttVoiceWakeLease(for: captureId)
|
|
}
|
|
|
|
func _test_setTalkCapturePreparationHandler(_ handler: (() async -> Void)?) {
|
|
self.testTalkCapturePreparationHandler = handler
|
|
}
|
|
|
|
func _test_setTalkCaptureStartedHandler(_ handler: (() async -> Void)?) {
|
|
self.testTalkCaptureStartedHandler = handler
|
|
}
|
|
|
|
func _test_setChatSessionRoutingRestoreHandler(_ handler: (() async -> Void)?) {
|
|
self.testChatSessionRoutingRestoreHandler = handler
|
|
}
|
|
|
|
func _test_hasChatSessionRoutingRestoreTask() -> Bool {
|
|
self.chatSessionRoutingRestoreTask != nil
|
|
}
|
|
|
|
func _test_talkPreparationWaiterCount() -> Int {
|
|
self.talkPreparationWaiters.count
|
|
}
|
|
|
|
func _test_talkPttCommandEpoch() -> UInt64 {
|
|
self.talkPttCommandEpoch
|
|
}
|
|
|
|
func _test_pttVoiceWakeLeaseCaptureIds() -> Set<String> {
|
|
self.pttVoiceWakeLeaseCaptureId.map { [$0] } ?? []
|
|
}
|
|
|
|
func _test_invalidateNodePushToTalkRoute() {
|
|
self.invalidateNodePushToTalkRoute()
|
|
}
|
|
|
|
func _test_invalidateOperatorTalkRoute() {
|
|
self.invalidateOperatorTalkRoute()
|
|
}
|
|
|
|
func _test_applyMainSessionKey(_ key: String?) {
|
|
self.applyMainSessionKey(key)
|
|
}
|
|
|
|
func _test_prepareForGatewayConnect(
|
|
stableID: String,
|
|
preservingGatewayProblem: Bool = false)
|
|
{
|
|
self.prepareForGatewayConnect(
|
|
stableID: stableID,
|
|
preservingGatewayProblem: preservingGatewayProblem)
|
|
}
|
|
|
|
func _test_admitTalkAfterSessionHydration() async {
|
|
if let chatSessionRoutingRestoreTask {
|
|
await chatSessionRoutingRestoreTask.value
|
|
}
|
|
self.chatSessionRoutingRestoreTask = nil
|
|
self.admitTalkAfterSessionHydration()
|
|
}
|
|
|
|
static func _test_decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
|
try self.decodeParams(type, from: json)
|
|
}
|
|
|
|
static func _test_encodePayload(_ obj: some Encodable) throws -> String {
|
|
try self.encodePayload(obj)
|
|
}
|
|
|
|
func _test_handleCanvasA2UIAction(body: [String: Any]) async {
|
|
await self.handleCanvasA2UIAction(body: body)
|
|
}
|
|
|
|
func _test_queuedWatchReplyCount() -> Int {
|
|
self.watchMessageOutbox.queuedCount(kind: .quickReply)
|
|
}
|
|
|
|
func _test_setWatchMessageRetryAttempts(_ attempts: Int, messageID: String) {
|
|
self.watchMessageRetryAttempts[messageID] = attempts
|
|
}
|
|
|
|
func _test_watchMessageRetryAttempts(messageID: String) -> Int? {
|
|
self.watchMessageRetryAttempts[messageID]
|
|
}
|
|
|
|
func _test_queuedWatchChatCommandCount() -> Int {
|
|
self.watchMessageOutbox.queuedCount(kind: .chat)
|
|
}
|
|
|
|
func _test_queuedWatchChatCommandIds() -> [String] {
|
|
self.watchMessageOutbox.queuedMessageIDs(kind: .chat)
|
|
}
|
|
|
|
func _test_recordWatchPromptRoute(promptID: String, gatewayStableID: String) {
|
|
self.watchMessageOutbox.recordPromptRoute(
|
|
promptID: promptID,
|
|
gatewayStableID: gatewayStableID)
|
|
}
|
|
|
|
func _test_setConnectedGatewayID(_ gatewayID: String?) {
|
|
self.connectedGatewayID = gatewayID
|
|
}
|
|
|
|
func _test_setAgentRequestHandler(_ handler: @escaping (AgentDeepLink) async throws -> Void) {
|
|
self.testAgentRequestHandler = handler
|
|
}
|
|
|
|
static func _test_resetPersistedWatchChatQueueState() {
|
|
WatchMessageOutbox.resetPersistedQueue()
|
|
}
|
|
|
|
static func _test_resetPersistedWatchReplyQueueState() {
|
|
WatchMessageOutbox.resetPersistedQueue()
|
|
}
|
|
|
|
func _test_setGatewayConnected(_ connected: Bool) {
|
|
self.gatewayConnected = connected
|
|
}
|
|
|
|
func _test_setOperatorConnected(_ connected: Bool) {
|
|
self.setOperatorConnected(connected)
|
|
}
|
|
|
|
func _test_canPublishAPNsRegistration(usesRelayTransport: Bool = true) async -> Bool {
|
|
await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport)
|
|
}
|
|
|
|
nonisolated static func _test_makeWatchChatItems(from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] {
|
|
self.makeWatchChatItems(from: raw)
|
|
}
|
|
|
|
nonisolated static func _test_watchChatReplyText(
|
|
from raw: [OpenClawKit.AnyCodable],
|
|
runId: String,
|
|
submittedText: String,
|
|
submittedAtMs: Int64) -> String?
|
|
{
|
|
self.watchChatReplyText(
|
|
from: raw,
|
|
runId: runId,
|
|
submittedText: submittedText,
|
|
submittedAtMs: submittedAtMs)
|
|
}
|
|
|
|
func _test_isGatewayConnected() -> Bool {
|
|
self.gatewayConnected
|
|
}
|
|
|
|
func _test_refreshOperatorAdminScopeFromStore() {
|
|
self.refreshOperatorAdminScopeFromStore()
|
|
}
|
|
|
|
func _test_applyPendingForegroundNodeActions(
|
|
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
|
|
{
|
|
let mapped = actions.map { action in
|
|
PendingForegroundNodeAction(
|
|
id: action.id,
|
|
command: action.command,
|
|
paramsJSON: action.paramsJSON,
|
|
enqueuedAtMs: nil)
|
|
}
|
|
await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
|
|
}
|
|
|
|
func _test_makeOperatorConnectOptions(
|
|
clientId: String,
|
|
displayName: String?,
|
|
includeAdminScope: Bool = false,
|
|
includeApprovalScope: Bool,
|
|
forceExplicitScopes: Bool = false) -> GatewayConnectOptions
|
|
{
|
|
self.makeOperatorConnectOptions(
|
|
clientId: clientId,
|
|
displayName: displayName,
|
|
includeAdminScope: includeAdminScope,
|
|
includeApprovalScope: includeApprovalScope,
|
|
forceExplicitScopes: forceExplicitScopes)
|
|
}
|
|
|
|
func _test_presentExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
|
|
if self.currentExecApprovalGatewayStableID() == nil {
|
|
self.connectedGatewayID = prompt.gatewayStableID
|
|
}
|
|
self.presentFetchedExecApprovalPrompt(prompt)
|
|
}
|
|
|
|
func _test_dismissPendingExecApprovalPrompt() {
|
|
self.dismissPendingExecApprovalPrompt()
|
|
}
|
|
|
|
func _test_applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
|
self.applyOperatorGatewayConnectionProblem(problem)
|
|
}
|
|
|
|
func _test_clearOperatorGatewayConnectionProblemIfCurrent() {
|
|
self.clearOperatorGatewayConnectionProblemIfCurrent()
|
|
}
|
|
|
|
func _test_clearGatewayConnectionProblem() {
|
|
self.clearGatewayConnectionProblem()
|
|
}
|
|
|
|
func _test_mapNodeGatewayConnectionError(_ error: Error) -> GatewayConnectionProblem? {
|
|
self.mapNodeGatewayConnectionError(error)
|
|
}
|
|
|
|
func _test_applyNodeGatewayConnectionError(_ error: Error) -> GatewayConnectionProblem? {
|
|
let nextProblem = self.mapNodeGatewayConnectionError(error)
|
|
self.recordNodeGatewayConnectionError(nextProblem, error: error)
|
|
return nextProblem
|
|
}
|
|
|
|
func _test_pendingExecApprovalPrompt() -> ExecApprovalPrompt? {
|
|
self.pendingExecApprovalPrompt
|
|
}
|
|
|
|
func _test_pendingExecApprovalInboxItems() -> [(id: String, gatewayStableID: String)] {
|
|
self.pendingExecApprovalInboxItems.map {
|
|
(id: $0.prompt.id, gatewayStableID: $0.prompt.gatewayStableID)
|
|
}
|
|
}
|
|
|
|
func _test_presentPendingExecApprovalFromInbox(
|
|
approvalID: String,
|
|
gatewayStableID: String)
|
|
{
|
|
guard let key = Self.execApprovalInboxKey(
|
|
approvalID: approvalID,
|
|
gatewayStableID: gatewayStableID)
|
|
else { return }
|
|
self.presentPendingExecApprovalFromInbox(key)
|
|
}
|
|
|
|
struct PendingExecApprovalStateSnapshot {
|
|
let resolving: Bool
|
|
let canDismiss: Bool
|
|
let error: String?
|
|
let resolved: String?
|
|
let tone: ExecApprovalOutcomeTone?
|
|
}
|
|
|
|
func _test_pendingExecApprovalState() -> PendingExecApprovalStateSnapshot {
|
|
PendingExecApprovalStateSnapshot(
|
|
resolving: self.pendingExecApprovalPromptResolving,
|
|
canDismiss: self.pendingExecApprovalPromptCanDismiss,
|
|
error: self.pendingExecApprovalPromptErrorText,
|
|
resolved: self.pendingExecApprovalPromptResolvedText,
|
|
tone: self.pendingExecApprovalPromptOutcome?.tone)
|
|
}
|
|
|
|
nonisolated static func _test_decodePushRelayGatewayIdentity(_ json: String) throws -> PushRelayGatewayIdentity {
|
|
try self.decodePushRelayGatewayIdentity(Data(json.utf8))
|
|
}
|
|
|
|
func _test_setPendingExecApprovalPromptUncertain(_ message: String) {
|
|
guard let prompt = self.pendingExecApprovalPrompt else { return }
|
|
self.markExecApprovalResolutionUncertain(
|
|
approvalID: prompt.id,
|
|
gatewayStableID: prompt.gatewayStableID,
|
|
message: message)
|
|
}
|
|
|
|
func _test_pendingNotificationPermissionGuidancePrompt() -> NotificationPermissionGuidancePrompt? {
|
|
self.pendingNotificationPermissionGuidancePrompt
|
|
}
|
|
|
|
func _debug_presentNotificationPermissionGuidancePromptForScreenshot() {
|
|
self.resetExecApprovalNotificationGuidanceSuppression()
|
|
self.pendingNotificationPermissionGuidancePrompt =
|
|
NotificationPermissionGuidancePrompt(approvalId: "screenshot-exec-approval")
|
|
}
|
|
|
|
func _test_resetExecApprovalNotificationGuidanceSuppression() {
|
|
self.resetExecApprovalNotificationGuidanceSuppression()
|
|
}
|
|
|
|
func _test_recordPendingWatchExecApprovalRecoveryID(
|
|
_ approvalId: String,
|
|
gatewayDeviceId: String = "test-gateway-device")
|
|
{
|
|
self.appendPendingWatchExecApprovalRecoveryPush(ExecApprovalNotificationPrompt(
|
|
approvalId: approvalId,
|
|
gatewayDeviceId: gatewayDeviceId))
|
|
}
|
|
|
|
func _test_removePendingWatchExecApprovalRecoveryPush(_ push: ExecApprovalNotificationPrompt) {
|
|
self.removePendingWatchExecApprovalRecoveryPush(push)
|
|
}
|
|
|
|
func _test_removePendingExecApprovalResolvedPush(_ push: ExecApprovalNotificationPrompt) {
|
|
self.removePendingExecApprovalResolvedPush(push)
|
|
}
|
|
|
|
func _test_pendingWatchExecApprovalRecoveryIDs() -> [String] {
|
|
self.pendingWatchExecApprovalRecoveryPushes.map(\.approvalId)
|
|
}
|
|
|
|
func _test_pendingWatchExecApprovalRecoveryPushes() -> [ExecApprovalNotificationPrompt] {
|
|
self.pendingWatchExecApprovalRecoveryPushes
|
|
}
|
|
|
|
func _test_pendingPersistedExecApprovalReadbacks()
|
|
-> [(approvalId: String, gatewayStableID: String)]
|
|
{
|
|
self.pendingPersistedExecApprovalReadbacks.map {
|
|
(approvalId: $0.approvalId, gatewayStableID: $0.gatewayStableID)
|
|
}
|
|
}
|
|
|
|
func _test_watchExecApprovalCacheIDs() -> [String] {
|
|
self.watchExecApprovalPromptsByID.keys
|
|
.map(\.rawValue)
|
|
.sorted(by: Self.approvalIDSortsBefore)
|
|
}
|
|
|
|
func _test_handleExecApprovalResolvedForCurrentGateway(
|
|
approvalId: String,
|
|
recoveryPushGatewayDeviceID: String?) async
|
|
{
|
|
await self.handleExecApprovalResolvedForCurrentGateway(
|
|
approvalId: approvalId,
|
|
recoveryPushGatewayDeviceID: recoveryPushGatewayDeviceID)
|
|
}
|
|
|
|
func _test_handleWatchExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) async -> Bool {
|
|
await self.handleWatchExecApprovalResolve(event)
|
|
}
|
|
|
|
func _test_refreshWatchExecApprovalSnapshotOnDemand(
|
|
_ event: WatchExecApprovalSnapshotRequestEvent) async
|
|
{
|
|
await self.refreshWatchExecApprovalSnapshotOnDemand(
|
|
reason: "watch_request",
|
|
requestId: event.requestId,
|
|
requestGatewayStableID: event.gatewayStableID,
|
|
heldApprovals: event.heldApprovals)
|
|
}
|
|
|
|
@discardableResult
|
|
func _test_reconcileWatchExecApprovalCache(reason: String) async -> Bool {
|
|
await self.reconcileWatchExecApprovalCache(reason: reason)
|
|
}
|
|
|
|
func _test_setUnifiedExecApprovalGetResponse(
|
|
_ json: String?,
|
|
beforeResponse: (@Sendable () async -> Void)? = nil)
|
|
{
|
|
guard let json else {
|
|
self.testExecApprovalPromptFetchHandler = nil
|
|
return
|
|
}
|
|
let response = Data(json.utf8)
|
|
self.testExecApprovalPromptFetchHandler = { approvalID, gatewayStableID in
|
|
await beforeResponse?()
|
|
return Self.decodeUnifiedExecApprovalGet(
|
|
response,
|
|
approvalId: approvalID,
|
|
gatewayStableID: gatewayStableID,
|
|
fetchReason: "test")
|
|
}
|
|
}
|
|
|
|
func _test_setExecApprovalPromptFetchStale() {
|
|
self.testExecApprovalPromptFetchHandler = { _, _ in .stale }
|
|
}
|
|
|
|
func _test_setExecApprovalPromptFetchFailure(_ message: String) {
|
|
self.testExecApprovalPromptFetchHandler = { _, _ in .failed(message: message) }
|
|
}
|
|
|
|
func _test_setExecApprovalResolutionFailureHandler(
|
|
_ handler: @escaping @Sendable (String, String, String) async -> String)
|
|
{
|
|
self.testExecApprovalResolutionHandler = { approvalID, decision, gatewayStableID in
|
|
let message = await handler(approvalID, decision, gatewayStableID)
|
|
return .failed(message: message)
|
|
}
|
|
}
|
|
|
|
func _test_setExecApprovalResolutionUncertainHandler(
|
|
_ handler: @escaping @Sendable (String, String, String) async -> String)
|
|
{
|
|
self.testExecApprovalResolutionHandler = { approvalID, decision, gatewayStableID in
|
|
let message = await handler(approvalID, decision, gatewayStableID)
|
|
return .uncertain(message: message)
|
|
}
|
|
}
|
|
|
|
/// Routes DEBUG resolves through the production unknown-ack path: the write settles
|
|
/// immediately, then canonical readback (the DEBUG fetch handler) classifies the
|
|
/// outcome while the attempt lease is still active.
|
|
func _test_setExecApprovalResolutionUnknownAck() {
|
|
self.testExecApprovalResolutionReconcilesUnknownAck = true
|
|
self.testExecApprovalResolutionHandler = { _, _, _ in
|
|
.failed(message: "unknown_ack_outcome_replaced_by_readback")
|
|
}
|
|
}
|
|
|
|
func _test_setUnifiedExecApprovalGetResponses(
|
|
_ responses: [(approvalID: String, json: String)],
|
|
beforeResponse: (@Sendable (String) async -> Void)? = nil)
|
|
{
|
|
let keyedResponses = responses.compactMap { response -> (ExecApprovalIdentifier.Key, Data)? in
|
|
guard let approvalID = Self.execApprovalIDKey(response.approvalID) else { return nil }
|
|
return (approvalID, Data(response.json.utf8))
|
|
}
|
|
self.testExecApprovalPromptFetchHandler = { approvalID, gatewayStableID in
|
|
await beforeResponse?(approvalID)
|
|
guard let approvalKey = Self.execApprovalIDKey(approvalID),
|
|
let response = keyedResponses.first(where: { $0.0 == approvalKey })?.1
|
|
else {
|
|
return .failed(message: "missing_test_response")
|
|
}
|
|
return Self.decodeUnifiedExecApprovalGet(
|
|
response,
|
|
approvalId: approvalID,
|
|
gatewayStableID: gatewayStableID,
|
|
fetchReason: "test")
|
|
}
|
|
}
|
|
|
|
func _test_presentExecApprovalGatewayEventPrompt(_ approvalID: String) async {
|
|
await self.presentExecApprovalGatewayEventPrompt(approvalId: approvalID)
|
|
}
|
|
|
|
func _test_presentExecApprovalNotificationPrompt(_ push: ExecApprovalNotificationPrompt) async {
|
|
await self.presentExecApprovalPrompt(
|
|
approvalId: push.approvalId,
|
|
notificationPush: push,
|
|
expectedOperatorRoute: nil,
|
|
shouldContinue: { true })
|
|
}
|
|
|
|
@discardableResult
|
|
func _test_applyLegacyExecApprovalTerminal(
|
|
approvalID: String,
|
|
decision: ApprovalDecision,
|
|
expectedGatewayStableID: String? = nil) async -> Bool
|
|
{
|
|
guard let gatewayStableID = expectedGatewayStableID ?? self.currentExecApprovalGatewayStableID() else {
|
|
return false
|
|
}
|
|
let terminal = ExecApprovalTerminalResult(
|
|
id: approvalID,
|
|
verdict: Self.execApprovalVerdict(for: decision),
|
|
resolvedAtMs: 1)
|
|
let outcome = await self.applyLegacyExecApprovalTerminal(
|
|
terminal,
|
|
gatewayStableID: gatewayStableID)
|
|
if case .resolved = outcome {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func _test_pendingExecApprovalResolvedPushes() -> [ExecApprovalNotificationPrompt] {
|
|
self.pendingExecApprovalResolvedPushes
|
|
}
|
|
|
|
func _test_pendingExecApprovalIDsForWatchRecovery() async -> [String] {
|
|
await self.pendingExecApprovalPushesForWatchRecovery().map(\.approvalId)
|
|
}
|
|
|
|
nonisolated static func _test_isApprovalNotificationStaleError(_ error: Error) -> Bool {
|
|
self.isApprovalNotificationStaleError(error)
|
|
}
|
|
|
|
nonisolated static func _test_shouldUseBackgroundAwareExecApprovalReconnect(
|
|
sourceReason: String,
|
|
isBackgrounded: Bool) -> Bool
|
|
{
|
|
self.shouldUseBackgroundAwareExecApprovalReconnect(
|
|
sourceReason: sourceReason,
|
|
isBackgrounded: isBackgrounded)
|
|
}
|
|
|
|
nonisolated static func _test_execApprovalEventID(from payload: AnyCodable) -> String? {
|
|
self.execApprovalEventID(from: payload)
|
|
}
|
|
|
|
func _test_handleOperatorGatewayServerEvent(_ event: EventFrame) async {
|
|
await self.handleOperatorGatewayServerEvent(event)
|
|
}
|
|
|
|
func _test_handleOperatorGatewayServerEvent(
|
|
_ event: EventFrame,
|
|
shouldContinue: @escaping @MainActor @Sendable () -> Bool) async
|
|
{
|
|
await self.handleOperatorGatewayServerEvent(event, shouldContinue: shouldContinue)
|
|
}
|
|
|
|
nonisolated static func _test_watchExecApprovalIDsNeedingFetch(
|
|
candidateIDs: [String],
|
|
cachedApprovalIDs: [String]) -> [String]
|
|
{
|
|
self.watchExecApprovalIDsNeedingFetch(
|
|
candidateIDs: candidateIDs,
|
|
cachedApprovalIDs: cachedApprovalIDs)
|
|
}
|
|
|
|
static func _test_makeExecApprovalPrompt(
|
|
id: String,
|
|
gatewayStableID: String = "test-gateway",
|
|
commandText: String,
|
|
warningText: String? = nil,
|
|
allowedDecisions: [String] = ["allow-once", "deny"],
|
|
host: String? = "gateway",
|
|
nodeId: String? = nil,
|
|
agentId: String? = "main",
|
|
expiresAtMs: Int64?) -> ExecApprovalPrompt?
|
|
{
|
|
self.makeExecApprovalPrompt(ExecApprovalPrompt(
|
|
id: id,
|
|
kind: ApprovalKind.exec.rawValue,
|
|
gatewayStableID: gatewayStableID,
|
|
commandText: commandText,
|
|
commandPreview: nil,
|
|
warningText: warningText,
|
|
allowedDecisions: allowedDecisions,
|
|
host: host,
|
|
nodeId: nodeId,
|
|
agentId: agentId,
|
|
expiresAtMs: expiresAtMs))
|
|
}
|
|
|
|
static func _test_decodeUnifiedExecApprovalPrompt(
|
|
_ json: String,
|
|
approvalID: String,
|
|
gatewayStableID: String = "test-gateway") throws -> ExecApprovalPrompt?
|
|
{
|
|
let result = try JSONDecoder().decode(ApprovalGetResult.self, from: Data(json.utf8))
|
|
guard case let .pending(snapshot) = result.approval else { return nil }
|
|
return self.makeExecApprovalPrompt(
|
|
from: snapshot,
|
|
expectedApprovalID: approvalID,
|
|
gatewayStableID: gatewayStableID)
|
|
}
|
|
|
|
static func _test_decodeUnifiedExecApprovalResolution(
|
|
_ json: String,
|
|
approvalID: String) throws
|
|
-> (applied: Bool, status: String, decision: String?, text: String)?
|
|
{
|
|
let result = try JSONDecoder().decode(ApprovalResolveResult.self, from: Data(json.utf8))
|
|
guard let terminal = self.makeExecApprovalTerminalResult(
|
|
from: result.approval,
|
|
expectedApprovalID: approvalID)
|
|
else {
|
|
return nil
|
|
}
|
|
return (
|
|
applied: result.applied,
|
|
status: terminal.status,
|
|
decision: terminal.decision,
|
|
text: self.execApprovalTerminalText(terminal, alreadyResolved: !result.applied))
|
|
}
|
|
|
|
static func _test_isValidUnifiedExecApprovalResolveAck(
|
|
_ json: String,
|
|
approvalID: String,
|
|
attemptedDecision: ApprovalDecision) throws -> Bool
|
|
{
|
|
let result = try JSONDecoder().decode(ApprovalResolveResult.self, from: Data(json.utf8))
|
|
guard let terminal = self.makeExecApprovalTerminalResult(
|
|
from: result.approval,
|
|
expectedApprovalID: approvalID)
|
|
else { return false }
|
|
return self.isValidUnifiedExecApprovalResolveAck(
|
|
result: result,
|
|
terminal: terminal,
|
|
attemptedDecision: attemptedDecision)
|
|
}
|
|
|
|
func _test_applyUnifiedExecApprovalResolveResult(
|
|
_ json: String,
|
|
approvalID: String,
|
|
attemptedDecision: ApprovalDecision) async throws -> Bool
|
|
{
|
|
let result = try JSONDecoder().decode(ApprovalResolveResult.self, from: Data(json.utf8))
|
|
guard let terminal = Self.makeExecApprovalTerminalResult(
|
|
from: result.approval,
|
|
expectedApprovalID: approvalID)
|
|
else { return false }
|
|
guard Self.isValidUnifiedExecApprovalResolveAck(
|
|
result: result,
|
|
terminal: terminal,
|
|
attemptedDecision: attemptedDecision)
|
|
else { return false }
|
|
guard let gatewayStableID = self.currentExecApprovalGatewayStableID() else { return false }
|
|
_ = await self.applyCanonicalExecApprovalTerminal(
|
|
terminal,
|
|
appliedHere: result.applied,
|
|
gatewayStableID: gatewayStableID)
|
|
return true
|
|
}
|
|
|
|
nonisolated static func _test_execApprovalRPCFamily(
|
|
unifiedGet: Bool?,
|
|
unifiedResolve: Bool?,
|
|
legacyGet: Bool?,
|
|
legacyResolve: Bool?) -> String
|
|
{
|
|
switch self.selectExecApprovalRPCFamily(
|
|
unifiedGet: unifiedGet,
|
|
unifiedResolve: unifiedResolve,
|
|
legacyGet: legacyGet,
|
|
legacyResolve: legacyResolve)
|
|
{
|
|
case .unified:
|
|
"unified"
|
|
case .legacy:
|
|
"legacy"
|
|
case .unavailable:
|
|
"unavailable"
|
|
}
|
|
}
|
|
|
|
static func _test_currentDeepLinkKey() -> String {
|
|
self.expectedDeepLinkKey()
|
|
}
|
|
|
|
nonisolated static func _test_shouldDiscardFailedWatchMessage(
|
|
code: String,
|
|
message: String = "test") -> Bool
|
|
{
|
|
self.shouldDiscardFailedWatchMessage(
|
|
GatewayResponseError(method: "chat.send", code: code, message: message, details: nil))
|
|
}
|
|
|
|
static func _test_resetPersistedWatchExecApprovalBridgeState() {
|
|
UserDefaults.standard.removeObject(forKey: self.watchExecApprovalBridgeStateKey)
|
|
}
|
|
|
|
static func _test_setPersistedWatchExecApprovalBridgeStateJSON(_ json: String) {
|
|
UserDefaults.standard.set(
|
|
Data(json.utf8),
|
|
forKey: self.watchExecApprovalBridgeStateKey)
|
|
}
|
|
|
|
nonisolated static func _test_shouldStartOperatorGatewayLoop(
|
|
token: String?,
|
|
bootstrapToken: String?,
|
|
password: String?,
|
|
hasStoredOperatorToken: Bool) -> Bool
|
|
{
|
|
self.shouldStartOperatorGatewayLoop(
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password,
|
|
hasStoredOperatorToken: hasStoredOperatorToken)
|
|
}
|
|
|
|
nonisolated static func _test_usesBootstrapCredential(
|
|
token: String?,
|
|
bootstrapToken: String?,
|
|
password: String?) -> Bool
|
|
{
|
|
self.usesBootstrapCredential(
|
|
token: token,
|
|
bootstrapToken: bootstrapToken,
|
|
password: password)
|
|
}
|
|
|
|
nonisolated static func _test_shouldRequestOperatorApprovalScope(
|
|
token: String?,
|
|
password: String?,
|
|
storedOperatorScopes: [String],
|
|
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
|
|
{
|
|
self.shouldRequestOperatorApprovalScope(
|
|
token: token,
|
|
password: password,
|
|
storedOperatorScopes: storedOperatorScopes,
|
|
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
|
|
}
|
|
|
|
func _test_shouldRequestStoredOperatorApprovalScope(
|
|
gatewayID: String,
|
|
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
|
|
{
|
|
self.shouldRequestOperatorApprovalScope(
|
|
gatewayID: gatewayID,
|
|
token: nil,
|
|
password: nil,
|
|
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
|
|
}
|
|
|
|
nonisolated static func _test_shouldRequestOperatorAdminScope(
|
|
token: String?,
|
|
password: String?,
|
|
storedOperatorScopes: [String],
|
|
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
|
|
{
|
|
self.shouldRequestOperatorAdminScope(
|
|
token: token,
|
|
password: password,
|
|
storedOperatorScopes: storedOperatorScopes,
|
|
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
|
|
}
|
|
|
|
func _test_shouldRequestStoredOperatorAdminScope(gatewayID: String) -> Bool {
|
|
self.shouldRequestOperatorAdminScope(gatewayID: gatewayID, token: nil, password: nil)
|
|
}
|
|
|
|
func _test_completeSuccessfulGatewayAuthHandoff(
|
|
issuedRoles: Set<String>,
|
|
nodeOptions: GatewayConnectOptions) -> GatewayConnectOptions?
|
|
{
|
|
guard let stableID = activeGatewayConnectConfig?.effectiveStableID else { return nil }
|
|
return self.completeSuccessfulGatewayAuthHandoff(
|
|
stableID: stableID,
|
|
routeGeneration: self.gatewayRouteGeneration,
|
|
issuedRoles: issuedRoles,
|
|
nodeOptions: nodeOptions)
|
|
}
|
|
|
|
func _test_currentGatewayReconnectOptions(
|
|
stableID: String,
|
|
fallback: GatewayConnectOptions) -> GatewayConnectOptions
|
|
{
|
|
self.currentGatewayReconnectOptions(stableID: stableID, fallback: fallback)
|
|
}
|
|
|
|
func _test_hasGatewayLoopTasks() -> (node: Bool, operator: Bool) {
|
|
(self.nodeGatewayTask != nil, self.operatorGatewayTask != nil)
|
|
}
|
|
|
|
func _test_setGatewayLoopTasks(
|
|
node: Task<Void, Never>?,
|
|
operator: Task<Void, Never>? = nil)
|
|
{
|
|
self.nodeGatewayTask = node
|
|
self.operatorGatewayTask = `operator`
|
|
}
|
|
|
|
func _test_setGatewaySessionResetTask(_ task: Task<Void, Never>?) {
|
|
self.gatewaySessionResetGeneration &+= 1
|
|
let resetGeneration = self.gatewaySessionResetGeneration
|
|
guard let task else {
|
|
self.gatewaySessionResetTask = nil
|
|
return
|
|
}
|
|
self.gatewaySessionResetTask = Task {
|
|
await task.value
|
|
if self.gatewaySessionResetGeneration == resetGeneration {
|
|
self.gatewaySessionResetTask = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func _test_restartGatewaySessionsAfterForegroundStaleConnection() async {
|
|
await self.restartGatewaySessionsAfterForegroundStaleConnection()
|
|
}
|
|
}
|
|
#endif
|
|
|
|
extension NodeAppModel {
|
|
private func clearGatewayProblemForCommittedTargetSwitch(to stableID: String) {
|
|
guard let currentStableID = self.activeGatewayConnectConfig?.effectiveStableID
|
|
?? self.connectedGatewayID,
|
|
!GatewayStableIdentifier.matches(currentStableID, stableID)
|
|
else { return }
|
|
// This runs only when the replacement config commits, without a suspension before the
|
|
// route generation advances. Preflight retains the prior snapshot until this boundary.
|
|
self.operatorGatewayProblem = nil
|
|
self.clearGatewayConnectionProblem()
|
|
self.setGatewayConnectionProgress(reconnecting: false)
|
|
}
|
|
}
|
|
|
|
// swiftlint:enable type_body_length file_length
|