Files
openclaw/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift
Peter Steinberger ac89350327 feat(apps): review durable approvals on mobile (#104913)
* feat(apps): Android, iPhone, and Watch approval clients

Squash-rebased #103912 segment onto the deep-links tip on current main.
Native approval surfaces: iOS approval presentation with gateway-switch
lease preservation and resolution fencing, watchOS inbox + approval
actions with shipped-shape payload codec, Android approval notices with
publication-tokened dismissal. Native i18n inventory regenerated.

(cherry picked from commit 428a76670ffeede54248b7bd7aa4438e2589851b)
(cherry picked from commit 80225d5707c3645eeea5435f266131037b50ede6)
(cherry picked from commit 2a23b714dc30d773a294aa45adc617e46b80732e)
(cherry picked from commit 9ff1153827769e2cbab7c11c153f0f8634662c28)
(cherry picked from commit 5b25723525bd562e5f97478af492f7b46ad30fd1)
(cherry picked from commit 8c80e8467b5fe89aad0d4c74a0573f1600ed3af9)
(cherry picked from commit ad4037bc9846bf6f082b41c129ee68aa344576c3)
(cherry picked from commit fdf767dd662cff3c8a5a6d571f38f153410651ca)
(cherry picked from commit 00c120376ffd992ea68ce29254a9bc0a25ed1740)
(cherry picked from commit f36a95213e)
(cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac)
(cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235)
(cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1)
(cherry picked from commit 2a60619722)
(cherry picked from commit 6f0c386567)
(cherry picked from commit 784a5857b7)
(cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db)

* fix(apps): harden approval reconciliation and watch states
2026-07-11 19:59:07 -07:00

844 lines
34 KiB
Swift

import Foundation
import WatchConnectivity
struct WatchReplyDraft {
var replyId: String
var promptId: String
var actionId: String
var actionLabel: String?
var sessionKey: String?
var gatewayStableID: String?
var note: String?
var sentAtMs: Int64
}
enum WatchReplyDeliveryState: Equatable {
case delivered
case queued
case notSent
}
struct WatchReplySendResult: Equatable {
var delivery: WatchReplyDeliveryState
var transport: String
var errorMessage: String?
var requiresCanonicalReadback: Bool
var deliveredImmediately: Bool {
self.delivery == .delivered
}
var queuedForDelivery: Bool {
self.delivery == .queued
}
}
struct WatchExecApprovalSnapshotRequestToken: Hashable, Sendable {
let requestId: String
let gatewayStableID: String
private let requestKey: WatchOpaqueUTF8Key
private let gatewayKey: WatchOpaqueUTF8Key
init?(requestId: String, gatewayStableID: String?) {
guard !requestId.isEmpty,
let gatewayStableID = WatchGatewayID.exact(gatewayStableID)
else { return nil }
self.requestId = requestId
self.gatewayStableID = gatewayStableID
self.requestKey = WatchOpaqueUTF8Key(requestId)
self.gatewayKey = WatchOpaqueUTF8Key(gatewayStableID)
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.requestKey == rhs.requestKey && lhs.gatewayKey == rhs.gatewayKey
}
func hash(into hasher: inout Hasher) {
hasher.combine(self.requestKey)
hasher.combine(self.gatewayKey)
}
func matchesGatewayStableID(_ gatewayStableID: String?) -> Bool {
WatchGatewayID.key(gatewayStableID) == self.gatewayKey
}
}
final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
private typealias MessageSendContinuation = CheckedContinuation<Void, Error>
private static let maxAcceptedExecApprovalSnapshotRequests = 32
private let store: WatchInboxStore
private let session: WCSession?
private let activationGate = WatchSessionActivationGate()
private let execApprovalSnapshotAcknowledgmentLock = NSLock()
private var acceptedExecApprovalSnapshotRequests: Set<WatchExecApprovalSnapshotRequestToken> = []
private var acceptedExecApprovalSnapshotRequestOrder: [WatchExecApprovalSnapshotRequestToken] = []
private let directNodeSetupHandler: @MainActor @Sendable (String, Int64) -> Void
init(
store: WatchInboxStore,
directNodeSetupHandler: @escaping @MainActor @Sendable (String, Int64) -> Void)
{
self.store = store
self.directNodeSetupHandler = directNodeSetupHandler
if WCSession.isSupported() {
self.session = WCSession.default
} else {
self.session = nil
}
super.init()
}
func activate() {
guard let session else { return }
session.delegate = self
self.beginActivation(session)
}
private func beginActivation(_ session: WCSession) {
if self.activationGate.beginActivation() {
session.activate()
}
}
private func activatedSession() async throws -> WCSession {
guard let session else {
throw WatchSessionActivationError.failed("session unavailable")
}
if session.activationState == .activated {
self.activationGate.complete(activated: true, errorDescription: nil)
return session
}
self.beginActivation(session)
try await self.activationGate.waitUntilActivated()
guard session.activationState == .activated else {
throw WatchSessionActivationError.failed("session stayed inactive")
}
return session
}
@discardableResult
func requestExecApprovalSnapshot(
gatewayStableID: String? = nil,
heldApprovals: [WatchExecApprovalSnapshotRequestItem] = []) async
-> WatchExecApprovalSnapshotRequestToken?
{
guard let session = try? await activatedSession() else { return nil }
let requestId = UUID().uuidString
let exactGatewayStableID = WatchGatewayID.exact(gatewayStableID)
let request = WatchExecApprovalSnapshotRequestMessage(
requestId: requestId,
sentAtMs: Self.nowMs(),
gatewayStableID: exactGatewayStableID,
heldApprovals: heldApprovals)
let token = WatchExecApprovalSnapshotRequestToken(
requestId: requestId,
gatewayStableID: exactGatewayStableID)
let payload = Self.encodeSnapshotRequestPayload(request)
if session.isReachable {
do {
try await Self.sendMessage(payload, through: session)
return token
} catch {
// Fall through to queued delivery.
}
}
_ = session.transferUserInfo(payload)
return token
}
func requestAppSnapshot() async -> WatchReplySendResult {
let session: WCSession
do {
session = try await self.activatedSession()
} catch {
return Self.unavailableResult(error)
}
let request = WatchAppSnapshotRequestMessage(
requestId: UUID().uuidString,
sentAtMs: Self.nowMs())
let payload = Self.encodeAppSnapshotRequestPayload(request)
return await self.sendPayload(payload, session: session)
}
func sendReply(_ draft: WatchReplyDraft) async -> WatchReplySendResult {
let session: WCSession
do {
session = try await self.activatedSession()
} catch {
return Self.unavailableResult(error)
}
var payload: [String: Any] = [
"type": WatchPayloadType.reply.rawValue,
"replyId": draft.replyId,
"promptId": draft.promptId,
"actionId": draft.actionId,
"sentAtMs": draft.sentAtMs,
]
if let actionLabel = draft.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
!actionLabel.isEmpty
{
payload["actionLabel"] = actionLabel
}
if let sessionKey = draft.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines),
!sessionKey.isEmpty
{
payload["sessionKey"] = sessionKey
}
if let gatewayStableID = WatchGatewayID.exact(draft.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let note = draft.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
payload["note"] = note
}
return await self.sendPayload(payload, session: session)
}
func sendExecApprovalResolve(
approvalId: String,
gatewayStableID: String?,
attemptID: String,
decision: WatchExecApprovalDecision) async -> WatchReplySendResult
{
let session: WCSession
do {
session = try await self.activatedSession()
} catch {
return Self.unavailableResult(error)
}
let payload = Self.encodeExecApprovalResolvePayload(
WatchExecApprovalResolveMessage(
approvalId: approvalId,
gatewayStableID: gatewayStableID,
decision: decision,
replyId: attemptID,
sentAtMs: Self.nowMs()))
return await self.sendPayload(payload, session: session)
}
func sendAppCommand(_ message: WatchAppCommandMessage) async -> WatchReplySendResult {
let session: WCSession
do {
session = try await self.activatedSession()
} catch {
return Self.unavailableResult(error)
}
return await self.sendPayload(Self.encodeAppCommandPayload(message), session: session)
}
private func sendPayload(_ payload: [String: Any], session: WCSession) async -> WatchReplySendResult {
var requiresCanonicalReadback = false
if session.isReachable {
do {
try await Self.sendMessage(payload, through: session)
return WatchReplySendResult(
delivery: .delivered,
transport: "sendMessage",
errorMessage: nil,
requiresCanonicalReadback: false)
} catch {
// The immediate send may have reached the iPhone before its reply path
// failed. Queue a durable copy, but require canonical state readback.
requiresCanonicalReadback = true
// Fall through to queued delivery below.
}
}
_ = session.transferUserInfo(payload)
return WatchReplySendResult(
delivery: .queued,
transport: "transferUserInfo",
errorMessage: nil,
requiresCanonicalReadback: requiresCanonicalReadback)
}
private static func sendMessage(_ payload: [String: Any], through session: WCSession) async throws {
try await withCheckedThrowingContinuation(isolation: nil) { (continuation: MessageSendContinuation) in
session.sendMessage(
payload,
replyHandler: { _ in continuation.resume(returning: ()) },
errorHandler: { error in continuation.resume(throwing: error) })
}
}
private static func unavailableResult(_ error: any Error) -> WatchReplySendResult {
// Activation failed before a payload could be handed to WatchConnectivity.
// The closed notSent state lets callers safely offer an immediate retry.
WatchReplySendResult(
delivery: .notSent,
transport: "none",
errorMessage: error.localizedDescription,
requiresCanonicalReadback: false)
}
private static func nowMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
func consumeExecApprovalSnapshotAcknowledgment(
for token: WatchExecApprovalSnapshotRequestToken) -> Bool
{
self.execApprovalSnapshotAcknowledgmentLock.withLock {
guard self.acceptedExecApprovalSnapshotRequests.remove(token) != nil else { return false }
self.acceptedExecApprovalSnapshotRequestOrder.removeAll { $0 == token }
return true
}
}
func discardExecApprovalSnapshotAcknowledgments(exceptGatewayStableID gatewayStableID: String?) {
self.execApprovalSnapshotAcknowledgmentLock.withLock {
self.acceptedExecApprovalSnapshotRequestOrder.removeAll { token in
!token.matchesGatewayStableID(gatewayStableID)
}
self.acceptedExecApprovalSnapshotRequests = Set(
self.acceptedExecApprovalSnapshotRequestOrder)
}
}
private func recordAcceptedExecApprovalSnapshot(_ snapshot: WatchExecApprovalSnapshotMessage) {
guard let requestId = snapshot.requestId,
let token = WatchExecApprovalSnapshotRequestToken(
requestId: requestId,
gatewayStableID: snapshot.requestGatewayStableID),
WatchGatewayID.key(snapshot.gatewayStableID) == WatchGatewayID.key(token.gatewayStableID)
else { return }
self.execApprovalSnapshotAcknowledgmentLock.withLock {
guard self.acceptedExecApprovalSnapshotRequests.insert(token).inserted else { return }
self.acceptedExecApprovalSnapshotRequestOrder.append(token)
// Responses can arrive after their refresh task is cancelled. Bound retained
// acknowledgments while keeping enough room for WatchConnectivity reordering.
if self.acceptedExecApprovalSnapshotRequestOrder.count > Self.maxAcceptedExecApprovalSnapshotRequests {
let evicted = self.acceptedExecApprovalSnapshotRequestOrder.removeFirst()
self.acceptedExecApprovalSnapshotRequests.remove(evicted)
}
}
}
private static func normalizeObject(_ value: Any) -> [String: Any]? {
if let object = value as? [String: Any] {
return object
}
if let object = value as? [AnyHashable: Any] {
var normalized: [String: Any] = [:]
normalized.reserveCapacity(object.count)
for (key, item) in object {
guard let stringKey = key as? String else {
continue
}
normalized[stringKey] = item
}
return normalized
}
return nil
}
private static func parseActions(_ value: Any?) -> [WatchPromptAction] {
guard let raw = value as? [Any] else {
return []
}
return raw.compactMap { item in
guard let obj = Self.normalizeObject(item) else {
return nil
}
let id = (obj["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let label = (obj["label"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !id.isEmpty, !label.isEmpty else {
return nil
}
let style = (obj["style"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchPromptAction(id: id, label: label, style: style)
}
}
private static func parseNotificationPayload(_ payload: [String: Any]) -> WatchNotifyMessage? {
guard let type = payload["type"] as? String,
type == WatchPayloadType.notify.rawValue
else {
return nil
}
let title = (payload["title"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let body = (payload["body"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard title.isEmpty == false || body.isEmpty == false else {
return nil
}
let id = (payload["id"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
let promptId = (payload["promptId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let sessionKey = (payload["sessionKey"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
let kind = (payload["kind"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let details = (payload["details"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let expiresAtMs = (payload["expiresAtMs"] as? NSNumber)?.int64Value
let risk = (payload["risk"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let actions = Self.parseActions(payload["actions"])
return WatchNotifyMessage(
id: id,
title: title,
body: body,
sentAtMs: sentAtMs,
promptId: promptId,
sessionKey: sessionKey,
gatewayStableID: gatewayStableID,
kind: kind,
details: details,
expiresAtMs: expiresAtMs,
risk: risk,
actions: actions)
}
private static func parseExecApprovalDecision(_ value: Any?) -> WatchExecApprovalDecision? {
let raw = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return WatchExecApprovalDecision(rawValue: raw)
}
private static func parseExecApprovalItem(_ value: Any?) -> WatchExecApprovalItem? {
guard let payload = value.flatMap(normalizeObject) else {
return nil
}
guard let id = WatchApprovalID.exact(payload["id"] as? String) else { return nil }
let commandText = (payload["commandText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !commandText.isEmpty else { return nil }
let commandPreview = (payload["commandPreview"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let warningText = (payload["warningText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let host = (payload["host"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let nodeId = (payload["nodeId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let agentId = (payload["agentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
let expiresAtMs = (payload["expiresAtMs"] as? NSNumber)?.int64Value
let riskRaw = (payload["risk"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let risk = WatchRiskLevel(rawValue: riskRaw)
let allowedDecisions = (payload["allowedDecisions"] as? [Any] ?? []).compactMap {
Self.parseExecApprovalDecision($0)
}
return WatchExecApprovalItem(
id: id,
gatewayStableID: gatewayStableID,
commandText: commandText,
commandPreview: commandPreview,
warningText: warningText?.isEmpty == false ? warningText : nil,
host: host,
nodeId: nodeId,
agentId: agentId,
expiresAtMs: expiresAtMs,
allowedDecisions: allowedDecisions,
risk: risk)
}
private static func parseExecApprovalPromptPayload(
_ payload: [String: Any]) -> WatchExecApprovalPromptMessage?
{
guard let type = payload["type"] as? String,
type == WatchPayloadType.execApprovalPrompt.rawValue,
let approval = parseExecApprovalItem(payload["approval"])
else {
return nil
}
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
let resetResolutionAttemptId = (payload["resetResolutionAttemptId"] as? String)
.flatMap { $0.isEmpty ? nil : $0 }
return WatchExecApprovalPromptMessage(
approval: approval,
sentAtMs: sentAtMs,
resetResolutionAttemptId: resetResolutionAttemptId)
}
private static func parseExecApprovalResolvedPayload(
_ payload: [String: Any]) -> WatchExecApprovalResolvedMessage?
{
guard let type = payload["type"] as? String,
type == WatchPayloadType.execApprovalResolved.rawValue
else {
return nil
}
guard let approvalId = WatchApprovalID.exact(payload["approvalId"] as? String) else { return nil }
let decision = Self.parseExecApprovalDecision(payload["decision"])
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
let resolvedAtMs = (payload["resolvedAtMs"] as? NSNumber)?.int64Value
let source = (payload["source"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let outcomeText = (payload["outcomeText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchExecApprovalResolvedMessage(
approvalId: approvalId,
gatewayStableID: gatewayStableID,
decision: decision,
resolvedAtMs: resolvedAtMs,
source: source,
outcomeText: outcomeText?.isEmpty == false ? outcomeText : nil)
}
private static func parseExecApprovalExpiredPayload(
_ payload: [String: Any]) -> WatchExecApprovalExpiredMessage?
{
guard let type = payload["type"] as? String,
type == WatchPayloadType.execApprovalExpired.rawValue
else {
return nil
}
guard let approvalId = WatchApprovalID.exact(payload["approvalId"] as? String) else { return nil }
let rawReason = (payload["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard let reason = WatchExecApprovalCloseReason(rawValue: rawReason)
else {
return nil
}
let expiredAtMs = (payload["expiredAtMs"] as? NSNumber)?.int64Value
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
return WatchExecApprovalExpiredMessage(
approvalId: approvalId,
gatewayStableID: gatewayStableID,
reason: reason,
expiredAtMs: expiredAtMs)
}
private static func parseExecApprovalSnapshotPayload(
_ payload: [String: Any]) -> WatchExecApprovalSnapshotMessage?
{
guard let type = payload["type"] as? String,
type == WatchPayloadType.execApprovalSnapshot.rawValue
else {
return nil
}
guard let rawApprovals = payload["approvals"] as? [Any] else { return nil }
var approvals: [WatchExecApprovalItem] = []
approvals.reserveCapacity(rawApprovals.count)
for item in rawApprovals {
guard let approval = Self.parseExecApprovalItem(item) else { return nil }
approvals.append(approval)
}
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let requestId = (payload["requestId"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let requestGatewayStableID = WatchGatewayID.exact(payload["requestGatewayStableID"] as? String)
return WatchExecApprovalSnapshotMessage(
approvals: approvals,
gatewayStableID: gatewayStableID,
sentAtMs: sentAtMs,
snapshotId: snapshotId,
requestId: requestId,
requestGatewayStableID: requestGatewayStableID)
}
private static func parseAppSnapshotPayload(_ payload: [String: Any]) -> WatchAppSnapshotMessage? {
guard let type = payload["type"] as? String,
type == WatchPayloadType.appSnapshot.rawValue
else {
return nil
}
let gatewayStatusText = (payload["gatewayStatusText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentName = (payload["agentName"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentAvatarURL = (payload["agentAvatarUrl"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let agentAvatarText = (payload["agentAvatarText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let sessionKey = (payload["sessionKey"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
let talkStatusText = (payload["talkStatusText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let pendingApprovalCount = (payload["pendingApprovalCount"] as? Int)
?? (payload["pendingApprovalCount"] as? NSNumber)?.intValue
?? 0
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let chatItems = (payload["chatItems"] as? [Any])?.compactMap(Self.parseChatItem)
let chatStatusText = (payload["chatStatusText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchAppSnapshotMessage(
gatewayStatusText: gatewayStatusText.isEmpty ? "Unknown" : gatewayStatusText,
gatewayConnected: Self.boolValue(payload["gatewayConnected"]),
agentName: agentName.isEmpty ? "Main" : agentName,
agentAvatarURL: agentAvatarURL?.isEmpty == false ? agentAvatarURL : nil,
agentAvatarText: agentAvatarText?.isEmpty == false ? agentAvatarText : nil,
sessionKey: sessionKey.isEmpty ? "main" : sessionKey,
gatewayStableID: gatewayStableID,
talkStatusText: talkStatusText.isEmpty ? "Off" : talkStatusText,
talkEnabled: Self.boolValue(payload["talkEnabled"]),
talkListening: Self.boolValue(payload["talkListening"]),
talkSpeaking: Self.boolValue(payload["talkSpeaking"]),
pendingApprovalCount: max(0, pendingApprovalCount),
chatItems: chatItems,
chatStatusText: chatStatusText?.isEmpty == false ? chatStatusText : nil,
sentAtMs: sentAtMs,
snapshotId: snapshotId)
}
private static func parseChatCompletionPayload(
_ payload: [String: Any]) -> WatchChatCompletionMessage?
{
guard (payload["type"] as? String) == WatchPayloadType.chatCompletion.rawValue else {
return nil
}
let commandId = (payload["commandId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let replyText = (payload["replyText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !commandId.isEmpty, !replyText.isEmpty else { return nil }
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
return WatchChatCompletionMessage(
commandId: commandId,
replyText: replyText,
sentAtMs: sentAtMs)
}
private static func parseChatItem(_ item: Any) -> WatchChatItem? {
guard let dict = item as? [String: Any] else { return nil }
guard let id = (dict["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!id.isEmpty
else {
return nil
}
let trimmedRole = (dict["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let text, !text.isEmpty else { return nil }
let timestampMs = (dict["timestampMs"] as? NSNumber)?.int64Value
return WatchChatItem(
id: id,
role: trimmedRole.isEmpty ? "assistant" : trimmedRole,
text: text,
timestampMs: timestampMs)
}
private static func boolValue(_ value: Any?) -> Bool {
if let bool = value as? Bool {
return bool
}
if let number = value as? NSNumber {
return number.boolValue
}
return false
}
private static func encodeAppSnapshotRequestPayload(
_ request: WatchAppSnapshotRequestMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": WatchPayloadType.appSnapshotRequest.rawValue,
"requestId": request.requestId,
]
if let sentAtMs = request.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
return payload
}
private static func encodeAppCommandPayload(_ message: WatchAppCommandMessage) -> [String: Any] {
var payload: [String: Any] = [
"type": WatchPayloadType.appCommand.rawValue,
"command": message.command.rawValue,
"commandId": message.commandId,
]
if let sessionKey = message.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines),
!sessionKey.isEmpty
{
payload["sessionKey"] = sessionKey
}
if let gatewayStableID = WatchGatewayID.exact(message.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let text = message.text?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty
{
payload["text"] = text
}
if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
return payload
}
private static func encodeSnapshotRequestPayload(
_ request: WatchExecApprovalSnapshotRequestMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": WatchPayloadType.execApprovalSnapshotRequest.rawValue,
"requestId": request.requestId,
"heldApprovals": request.heldApprovals.map { item in
var encoded: [String: Any] = [
"approvalId": item.approvalId,
]
if let attemptID = item.activeResolutionAttemptId, !attemptID.isEmpty {
encoded["activeResolutionAttemptId"] = attemptID
}
return encoded
},
]
if let sentAtMs = request.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
if let gatewayStableID = WatchGatewayID.exact(request.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
return payload
}
private static func encodeExecApprovalResolvePayload(
_ message: WatchExecApprovalResolveMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": WatchPayloadType.execApprovalResolve.rawValue,
"approvalId": message.approvalId,
"decision": message.decision.rawValue,
"replyId": message.replyId,
]
if let gatewayStableID = WatchGatewayID.exact(message.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
return payload
}
}
extension WatchConnectivityReceiver: WCSessionDelegate {
func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: (any Error)?)
{
self.activationGate.complete(
activated: activationState == .activated,
errorDescription: error?.localizedDescription)
if activationState == .activated, !session.receivedApplicationContext.isEmpty {
self.consumeIncomingPayload(
session.receivedApplicationContext,
transport: "receivedApplicationContext")
}
Task { @MainActor in
let gatewayStableID = self.store.execApprovalReviewGatewayStableID
await self.requestExecApprovalSnapshot(
gatewayStableID: gatewayStableID,
heldApprovals: self.store.execApprovalSnapshotRequestItems(
gatewayStableID: gatewayStableID))
}
}
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
self.consumeIncomingPayload(message, transport: "sendMessage")
}
func session(
_: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void)
{
replyHandler(["ok": true])
self.consumeIncomingPayload(message, transport: "sendMessage")
}
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
self.consumeIncomingPayload(userInfo, transport: "transferUserInfo")
}
func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
self.consumeIncomingPayload(applicationContext, transport: "applicationContext")
}
private func consumeIncomingPayload(_ payload: [String: Any], transport: String) {
if let type = payload["type"] as? String,
type == WatchPayloadType.directNodeSetup.rawValue,
let setupCode = payload["setupCode"] as? String,
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
{
Task { @MainActor in
self.directNodeSetupHandler(setupCode, sentAtMs)
}
return
}
let appSnapshot = (payload[WatchPayloadType.appSnapshot.rawValue] as? [String: Any])
.flatMap(Self.parseAppSnapshotPayload)
let execApprovalSnapshot =
(payload[WatchPayloadType.execApprovalSnapshot.rawValue] as? [String: Any])
.flatMap(Self.parseExecApprovalSnapshotPayload)
if appSnapshot != nil || execApprovalSnapshot != nil {
// Owner state must land first so approvals are filtered against this context's route.
Task { @MainActor in
if let appSnapshot {
self.store.consume(appSnapshot: appSnapshot)
self.discardExecApprovalSnapshotAcknowledgments(
exceptGatewayStableID: appSnapshot.gatewayStableID)
}
if let execApprovalSnapshot {
if self.store.consume(execApprovalSnapshot: execApprovalSnapshot, transport: transport) {
self.recordAcceptedExecApprovalSnapshot(execApprovalSnapshot)
}
}
if appSnapshot != nil {
for snapshot in self.store.replayDeferredGatewayPayloads() {
self.recordAcceptedExecApprovalSnapshot(snapshot)
}
}
}
return
}
if let incoming = Self.parseNotificationPayload(payload) {
Task { @MainActor in
self.store.consume(message: incoming, transport: transport)
}
return
}
if let prompt = Self.parseExecApprovalPromptPayload(payload) {
Task { @MainActor in
self.store.consume(execApprovalPrompt: prompt, transport: transport)
}
return
}
if let resolved = Self.parseExecApprovalResolvedPayload(payload) {
Task { @MainActor in
self.store.consume(execApprovalResolved: resolved)
}
return
}
if let expired = Self.parseExecApprovalExpiredPayload(payload) {
Task { @MainActor in
self.store.consume(execApprovalExpired: expired)
}
return
}
if let snapshot = Self.parseExecApprovalSnapshotPayload(payload) {
Task { @MainActor in
if self.store.consume(execApprovalSnapshot: snapshot, transport: transport) {
self.recordAcceptedExecApprovalSnapshot(snapshot)
}
}
return
}
if let snapshot = Self.parseAppSnapshotPayload(payload) {
Task { @MainActor in
self.store.consume(appSnapshot: snapshot)
self.discardExecApprovalSnapshotAcknowledgments(
exceptGatewayStableID: snapshot.gatewayStableID)
for snapshot in self.store.replayDeferredGatewayPayloads() {
self.recordAcceptedExecApprovalSnapshot(snapshot)
}
}
return
}
if let completion = Self.parseChatCompletionPayload(payload) {
Task { @MainActor in
self.store.consume(chatCompletion: completion)
}
}
}
}