mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 09:01:46 +00:00
776 lines
31 KiB
Swift
776 lines
31 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 outcome = WatchExecApprovalResolvedMessage.parseTransportOutcome(payload["outcome"])
|
|
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,
|
|
outcome: outcome,
|
|
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? {
|
|
WatchAppSnapshotMessage.parsePayload(payload)
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
}
|
|
}
|
|
}
|