mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 18:16:07 +00:00
* 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 commitf36a95213e) (cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac) (cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235) (cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1) (cherry picked from commit2a60619722) (cherry picked from commit6f0c386567) (cherry picked from commit784a5857b7) (cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db) * fix(apps): harden approval reconciliation and watch states
1557 lines
65 KiB
Swift
1557 lines
65 KiB
Swift
import Foundation
|
|
import Observation
|
|
import UserNotifications
|
|
import WatchKit
|
|
|
|
@MainActor @Observable final class WatchInboxStore {
|
|
private typealias ExecApprovalOwnerKey = WatchExecApprovalIdentityKey
|
|
|
|
private struct ExecApprovalTerminalTombstone: Codable, Equatable {
|
|
var approvalId: String
|
|
var gatewayStableID: String
|
|
var outcomeText: String
|
|
var outcomeIsAuthoritative: Bool?
|
|
var recordedAt: Date
|
|
}
|
|
|
|
private enum DeferredGatewayPayload: Codable {
|
|
case notification(message: WatchNotifyMessage, transport: String)
|
|
case execApprovalPrompt(message: WatchExecApprovalPromptMessage, transport: String)
|
|
case execApprovalResolved(message: WatchExecApprovalResolvedMessage)
|
|
case execApprovalExpired(message: WatchExecApprovalExpiredMessage)
|
|
case execApprovalSnapshot(message: WatchExecApprovalSnapshotMessage, transport: String)
|
|
|
|
var gatewayStableID: String? {
|
|
switch self {
|
|
case let .notification(message, _):
|
|
message.gatewayStableID
|
|
case let .execApprovalPrompt(message, _):
|
|
message.approval.gatewayStableID
|
|
case let .execApprovalResolved(message):
|
|
message.gatewayStableID
|
|
case let .execApprovalExpired(message):
|
|
message.gatewayStableID
|
|
case let .execApprovalSnapshot(message, _):
|
|
if let gatewayStableID = WatchInboxStore.normalizedGatewayID(message.gatewayStableID) {
|
|
gatewayStableID
|
|
} else {
|
|
WatchInboxStore.onlyGatewayStableID(in: message.approvals)
|
|
}
|
|
}
|
|
}
|
|
|
|
var sentAtMs: Int64? {
|
|
switch self {
|
|
case let .notification(message, _):
|
|
message.sentAtMs
|
|
case let .execApprovalPrompt(message, _):
|
|
message.sentAtMs
|
|
case let .execApprovalResolved(message):
|
|
message.resolvedAtMs
|
|
case let .execApprovalExpired(message):
|
|
message.expiredAtMs
|
|
case let .execApprovalSnapshot(message, _):
|
|
message.sentAtMs
|
|
}
|
|
}
|
|
|
|
var expiresAtMs: Int64? {
|
|
switch self {
|
|
case let .notification(message, _):
|
|
message.expiresAtMs
|
|
case let .execApprovalPrompt(message, _):
|
|
message.approval.expiresAtMs
|
|
case .execApprovalResolved, .execApprovalExpired, .execApprovalSnapshot:
|
|
nil
|
|
}
|
|
}
|
|
|
|
var approvalPrompt: WatchExecApprovalItem? {
|
|
guard case let .execApprovalPrompt(message, _) = self else { return nil }
|
|
return message.approval
|
|
}
|
|
|
|
var isFullyRepresentedByExecApprovalSnapshot: Bool {
|
|
switch self {
|
|
case .execApprovalResolved, .execApprovalExpired, .execApprovalSnapshot:
|
|
true
|
|
case .notification, .execApprovalPrompt:
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PersistedState: Codable {
|
|
var title: String
|
|
var body: String
|
|
var transport: String
|
|
var updatedAt: Date
|
|
var lastDeliveryKey: String?
|
|
var promptId: String?
|
|
var sessionKey: String?
|
|
var gatewayStableID: String?
|
|
var kind: String?
|
|
var details: String?
|
|
var expiresAtMs: Int64?
|
|
var risk: String?
|
|
var actions: [WatchPromptAction]?
|
|
var replyStatusText: String?
|
|
var replyStatusAt: Date?
|
|
var execApprovals: [WatchExecApprovalRecord]
|
|
var selectedExecApprovalID: String?
|
|
var selectedExecApprovalGatewayStableID: String?
|
|
var lastExecApprovalSnapshotID: String?
|
|
var lastExecApprovalSnapshotGatewayStableID: String?
|
|
var lastExecApprovalSnapshotSentAtMs: Int64?
|
|
var lastExecApprovalOutcomeText: String?
|
|
var lastExecApprovalOutcomeAt: Date?
|
|
var appSnapshot: WatchAppSnapshotMessage?
|
|
var appSnapshotUpdatedAt: Date?
|
|
var appSnapshotStatusText: String?
|
|
var appCommandStatusText: String?
|
|
var deferredGatewayPayloads: [DeferredGatewayPayload]?
|
|
var execApprovalTerminalTombstones: [ExecApprovalTerminalTombstone]?
|
|
}
|
|
|
|
private static let persistedStateKey = "watch.inbox.state.v2"
|
|
private static let maxDeferredGatewayPayloads = 32
|
|
private static let maxExecApprovalTerminalTombstones = 128
|
|
private static let maxExecApprovalTerminalOutcomeCharacters = 160
|
|
private static let execApprovalTerminalTombstoneLifetime: TimeInterval = 24 * 60 * 60
|
|
private static let defaultTitle = "OpenClaw"
|
|
private static let defaultBody = "Waiting for messages from your iPhone."
|
|
private let defaults: UserDefaults
|
|
|
|
var title = WatchInboxStore.defaultTitle
|
|
var body = WatchInboxStore.defaultBody
|
|
var transport = "none"
|
|
var updatedAt: Date?
|
|
var promptId: String?
|
|
var sessionKey: String?
|
|
var gatewayStableID: String?
|
|
var kind: String?
|
|
var details: String?
|
|
var expiresAtMs: Int64?
|
|
var risk: String?
|
|
var actions: [WatchPromptAction] = []
|
|
var replyStatusText: String?
|
|
var replyStatusAt: Date?
|
|
var isReplySending = false
|
|
var execApprovals: [WatchExecApprovalRecord] = []
|
|
var selectedExecApprovalID: String?
|
|
var selectedExecApprovalGatewayStableID: String?
|
|
var lastExecApprovalOutcomeText: String?
|
|
var lastExecApprovalOutcomeAt: Date?
|
|
var appSnapshot: WatchAppSnapshotMessage?
|
|
var appSnapshotUpdatedAt: Date?
|
|
var appSnapshotStatusText: String?
|
|
var appCommandStatusText: String?
|
|
var chatCompletion: WatchChatCompletionMessage?
|
|
var greetingTextOverride: String?
|
|
var isExecApprovalReviewLoading = false
|
|
var execApprovalReviewStatusText: String?
|
|
var execApprovalReviewStatusAt: Date?
|
|
private var lastExecApprovalSnapshotID: String?
|
|
private var lastExecApprovalSnapshotGatewayStableID: String?
|
|
private var lastExecApprovalSnapshotSentAtMs: Int64?
|
|
private var hasCompletedExecApprovalSnapshotRefreshInSession = false
|
|
private var lastDeliveryKey: String?
|
|
/// WatchConnectivity does not order application-context updates against user-info
|
|
/// transfers. Persist a bounded handoff queue so a new route's alert is not lost
|
|
/// before its owner snapshot arrives.
|
|
private var deferredGatewayPayloads: [DeferredGatewayPayload] = []
|
|
/// Terminal events can race older prompts and snapshots across WatchConnectivity
|
|
/// transports. Keep a short owner-scoped history so stale deliveries cannot restore
|
|
/// live decision buttons after the canonical approval has closed.
|
|
private var execApprovalTerminalTombstones: [ExecApprovalTerminalTombstone] = []
|
|
|
|
init(
|
|
defaults: UserDefaults = .standard,
|
|
requestNotificationAuthorization: Bool = true)
|
|
{
|
|
self.defaults = defaults
|
|
self.restorePersistedState()
|
|
self.pruneExecApprovalTerminalTombstones(now: Date())
|
|
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
|
|
if requestNotificationAuthorization {
|
|
Task {
|
|
await self.ensureNotificationAuthorization()
|
|
}
|
|
}
|
|
}
|
|
|
|
var sortedExecApprovals: [WatchExecApprovalRecord] {
|
|
self.execApprovals.sorted { lhs, rhs in
|
|
let lhsExpires = lhs.approval.expiresAtMs ?? Int64.max
|
|
let rhsExpires = rhs.approval.expiresAtMs ?? Int64.max
|
|
if lhsExpires != rhsExpires {
|
|
return lhsExpires < rhsExpires
|
|
}
|
|
return lhs.updatedAt > rhs.updatedAt
|
|
}
|
|
}
|
|
|
|
var activeExecApproval: WatchExecApprovalRecord? {
|
|
if let selectedKey = Self.execApprovalOwnerKey(
|
|
approvalId: self.selectedExecApprovalID ?? "",
|
|
gatewayStableID: self.selectedExecApprovalGatewayStableID),
|
|
let selected = execApprovals.first(where: { $0.id == selectedKey })
|
|
{
|
|
return selected
|
|
}
|
|
return self.sortedExecApprovals.first
|
|
}
|
|
|
|
var shouldAutoRequestExecApprovalSnapshot: Bool {
|
|
self.execApprovals.contains(where: \.isResolving)
|
|
|| (self.execApprovals.isEmpty
|
|
&& self.actions.isEmpty
|
|
&& self.title == Self.defaultTitle
|
|
&& self.body == Self.defaultBody
|
|
&& !self.hasCompletedExecApprovalSnapshotRefreshInSession)
|
|
}
|
|
|
|
var hasCompletedExecApprovalSnapshotRefresh: Bool {
|
|
self.hasCompletedExecApprovalSnapshotRefreshInSession
|
|
}
|
|
|
|
var execApprovalReviewGatewayStableID: String? {
|
|
WatchGatewayID.exact(self.activeExecApproval?.approval.gatewayStableID)
|
|
?? WatchGatewayID.exact(self.appSnapshot?.gatewayStableID)
|
|
}
|
|
|
|
func execApprovalSnapshotRequestItems(
|
|
gatewayStableID: String?) -> [WatchExecApprovalSnapshotRequestItem]
|
|
{
|
|
guard let gatewayKey = WatchGatewayID.key(gatewayStableID) else { return [] }
|
|
return self.execApprovals.compactMap { record in
|
|
guard WatchGatewayID.key(record.approval.gatewayStableID) == gatewayKey,
|
|
let approvalID = WatchApprovalID.exact(record.approvalID)
|
|
else { return nil }
|
|
let activeAttemptID = record.activeResolutionAttemptID.flatMap { attemptID in
|
|
attemptID.isEmpty ? nil : attemptID
|
|
}
|
|
return WatchExecApprovalSnapshotRequestItem(
|
|
approvalId: approvalID,
|
|
activeResolutionAttemptId: activeAttemptID)
|
|
}.sorted { lhs, rhs in
|
|
Array(lhs.approvalId.utf8).lexicographicallyPrecedes(Array(rhs.approvalId.utf8))
|
|
}
|
|
}
|
|
|
|
var shouldShowExecApprovalReviewStatus: Bool {
|
|
self.execApprovals.isEmpty && !(self.execApprovalReviewStatusText?.isEmpty ?? true)
|
|
}
|
|
|
|
var hasAppSnapshot: Bool {
|
|
self.appSnapshot != nil
|
|
}
|
|
|
|
var hasMessagePrompt: Bool {
|
|
self.title != Self.defaultTitle
|
|
|| self.body != Self.defaultBody
|
|
|| !self.actions.isEmpty
|
|
}
|
|
|
|
var gatewaySummaryText: String {
|
|
guard let appSnapshot else { return "Waiting for iPhone" }
|
|
return appSnapshot.gatewayConnected ? "Connected" : appSnapshot.gatewayStatusText
|
|
}
|
|
|
|
var talkSummaryText: String {
|
|
guard let appSnapshot else { return "Not synced" }
|
|
if appSnapshot.talkListening {
|
|
return "Listening"
|
|
}
|
|
if appSnapshot.talkSpeaking {
|
|
return "Speaking"
|
|
}
|
|
if appSnapshot.talkEnabled {
|
|
return appSnapshot.talkStatusText.isEmpty ? "Ready" : appSnapshot.talkStatusText
|
|
}
|
|
return "Off"
|
|
}
|
|
|
|
func beginExecApprovalReviewLoading() {
|
|
guard self.execApprovals.isEmpty else {
|
|
self.markExecApprovalReviewLoaded()
|
|
return
|
|
}
|
|
self.isExecApprovalReviewLoading = true
|
|
self.execApprovalReviewStatusText = "Loading approval from iPhone…"
|
|
self.execApprovalReviewStatusAt = Date()
|
|
}
|
|
|
|
func markExecApprovalReviewLoaded() {
|
|
self.isExecApprovalReviewLoading = false
|
|
self.execApprovalReviewStatusText = nil
|
|
self.execApprovalReviewStatusAt = nil
|
|
}
|
|
|
|
func markExecApprovalReviewUnavailable(_ message: String) {
|
|
guard self.execApprovals.isEmpty else {
|
|
self.markExecApprovalReviewLoaded()
|
|
return
|
|
}
|
|
self.isExecApprovalReviewLoading = false
|
|
self.execApprovalReviewStatusText = message
|
|
self.execApprovalReviewStatusAt = Date()
|
|
}
|
|
|
|
func consume(message: WatchNotifyMessage, transport: String) {
|
|
guard self.routeGatewayPayload(.notification(message: message, transport: transport)) else { return }
|
|
let messageID = message.id?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let deliveryKey = self.deliveryKey(
|
|
messageID: messageID,
|
|
title: message.title,
|
|
body: message.body,
|
|
sentAtMs: message.sentAtMs)
|
|
guard deliveryKey != self.lastDeliveryKey else { return }
|
|
|
|
let normalizedTitle = message.title.isEmpty ? "OpenClaw" : message.title
|
|
self.title = normalizedTitle
|
|
self.body = message.body
|
|
self.transport = transport
|
|
self.markExecApprovalReviewLoaded()
|
|
self.updatedAt = Date()
|
|
self.promptId = message.promptId
|
|
self.sessionKey = message.sessionKey
|
|
self.gatewayStableID = message.gatewayStableID
|
|
self.kind = message.kind
|
|
self.details = message.details
|
|
self.expiresAtMs = message.expiresAtMs
|
|
self.risk = message.risk
|
|
self.actions = message.actions
|
|
self.lastDeliveryKey = deliveryKey
|
|
self.replyStatusText = nil
|
|
self.replyStatusAt = nil
|
|
self.isReplySending = false
|
|
self.persistState()
|
|
|
|
Task {
|
|
await self.postLocalNotification(
|
|
identifier: deliveryKey,
|
|
title: normalizedTitle,
|
|
body: message.body,
|
|
risk: message.risk,
|
|
stillCurrent: { self.lastDeliveryKey == deliveryKey })
|
|
}
|
|
}
|
|
|
|
func consume(
|
|
execApprovalPrompt message: WatchExecApprovalPromptMessage,
|
|
transport: String)
|
|
{
|
|
guard self.routeGatewayPayload(.execApprovalPrompt(message: message, transport: transport)) else { return }
|
|
guard WatchApprovalID.exact(message.approval.id) != nil else { return }
|
|
self.pruneExecApprovalTerminalTombstones(now: Date())
|
|
guard !self.isExecApprovalTerminal(
|
|
approvalId: message.approval.id,
|
|
gatewayStableID: message.approval.gatewayStableID)
|
|
else {
|
|
self.removeExecApprovalNotifications(approvals: [message.approval])
|
|
self.markExecApprovalReviewLoaded()
|
|
self.persistState()
|
|
return
|
|
}
|
|
let nowMs = Self.nowMs()
|
|
self.pruneExpiredExecApprovals(nowMs: nowMs)
|
|
if let expiresAtMs = message.approval.expiresAtMs, expiresAtMs <= nowMs {
|
|
self.removeExecApprovalNotifications(approvals: [message.approval])
|
|
self.markExecApprovalReviewLoaded()
|
|
self.persistState()
|
|
return
|
|
}
|
|
if self.isExecApprovalPromptSupersededBySnapshot(message) {
|
|
self.removeExecApprovalNotifications(approvals: [message.approval])
|
|
self.markExecApprovalReviewLoaded()
|
|
self.persistState()
|
|
return
|
|
}
|
|
guard self.upsertExecApproval(
|
|
message.approval,
|
|
transport: transport,
|
|
sourceSentAtMs: message.sentAtMs,
|
|
keepSelectionIfPossible: true,
|
|
resetResolutionAttemptID: message.resetResolutionAttemptId)
|
|
else { return }
|
|
guard let approvalOwnerKey = Self.execApprovalOwnerKey(
|
|
approvalId: message.approval.id,
|
|
gatewayStableID: message.approval.gatewayStableID)
|
|
else { return }
|
|
guard let notificationIdentifier = Self.execApprovalNotificationIdentifier(for: message.approval) else {
|
|
return
|
|
}
|
|
self.markExecApprovalReviewLoaded()
|
|
self.lastExecApprovalOutcomeText = nil
|
|
self.lastExecApprovalOutcomeAt = nil
|
|
if let legacyNotificationIdentifier = Self.legacyExecApprovalNotificationIdentifier(
|
|
for: message.approval),
|
|
!self.hasLiveLegacyNotificationCollision(
|
|
identifier: legacyNotificationIdentifier,
|
|
excluding: message.approval)
|
|
{
|
|
self.removeLocalNotifications(identifiers: [legacyNotificationIdentifier])
|
|
}
|
|
|
|
Task {
|
|
await self.postLocalNotification(
|
|
identifier: notificationIdentifier,
|
|
title: "Exec approval required",
|
|
body: message.approval.commandPreview ?? message.approval.commandText,
|
|
risk: message.approval.risk?.rawValue,
|
|
stillCurrent: {
|
|
self.execApprovals.contains { record in
|
|
Self.execApprovalOwnerKey(
|
|
approvalId: record.approvalID,
|
|
gatewayStableID: record.approval.gatewayStableID) == approvalOwnerKey
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Returns true only after this owner snapshot is applied; forced refresh uses it as its retry acknowledgment.
|
|
@discardableResult
|
|
func consume(
|
|
execApprovalSnapshot message: WatchExecApprovalSnapshotMessage,
|
|
transport: String) -> Bool
|
|
{
|
|
let deferredPayload = DeferredGatewayPayload.execApprovalSnapshot(
|
|
message: message,
|
|
transport: transport)
|
|
if deferredPayload.gatewayStableID != nil {
|
|
guard self.routeGatewayPayload(deferredPayload) else { return false }
|
|
}
|
|
guard let snapshotGatewayID = Self.normalizedGatewayID(deferredPayload.gatewayStableID) else {
|
|
return false
|
|
}
|
|
let previousSnapshotGatewayID = Self.normalizedGatewayID(
|
|
self.lastExecApprovalSnapshotGatewayStableID)
|
|
let hasSameSnapshotOwner = Self.gatewayIDsMatch(snapshotGatewayID, previousSnapshotGatewayID)
|
|
let hasCanonicalRequestCorrelation = message.requestId?.isEmpty == false
|
|
&& Self.gatewayIDsMatch(message.requestGatewayStableID, snapshotGatewayID)
|
|
if hasCanonicalRequestCorrelation {
|
|
// A correlated snapshot may authoritatively close omitted rows. Reject the
|
|
// whole response when any item is ownerless or belongs to another gateway;
|
|
// filtering those items first would turn malformed input into false omissions.
|
|
let allApprovalOwnersMatch = message.approvals.allSatisfy { approval in
|
|
WatchApprovalID.exact(approval.id) != nil
|
|
&& Self.gatewayIDsMatch(approval.gatewayStableID, snapshotGatewayID)
|
|
}
|
|
guard allApprovalOwnersMatch else { return false }
|
|
}
|
|
let snapshotID = message.snapshotId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if hasSameSnapshotOwner,
|
|
let snapshotID,
|
|
!snapshotID.isEmpty,
|
|
snapshotID == lastExecApprovalSnapshotID
|
|
{
|
|
return false
|
|
}
|
|
if hasSameSnapshotOwner,
|
|
let sentAtMs = message.sentAtMs,
|
|
let lastSentAtMs = lastExecApprovalSnapshotSentAtMs,
|
|
sentAtMs < lastSentAtMs
|
|
{
|
|
return false
|
|
}
|
|
|
|
let existingRecords = self.execApprovals
|
|
var existingRecordsByOwner: [ExecApprovalOwnerKey: WatchExecApprovalRecord] = [:]
|
|
for record in existingRecords {
|
|
guard let key = Self.execApprovalOwnerKey(
|
|
approvalId: record.approvalID,
|
|
gatewayStableID: record.approval.gatewayStableID)
|
|
else {
|
|
continue
|
|
}
|
|
existingRecordsByOwner[key] = record
|
|
}
|
|
self.pruneExecApprovalTerminalTombstones(now: Date())
|
|
let incomingApprovals = message.approvals.filter { approval in
|
|
WatchApprovalID.exact(approval.id) != nil
|
|
&& Self.gatewayIDsMatch(approval.gatewayStableID, snapshotGatewayID)
|
|
&& self.acceptsGatewayOwner(approval.gatewayStableID)
|
|
&& !self.isExecApprovalTerminal(
|
|
approvalId: approval.id,
|
|
gatewayStableID: approval.gatewayStableID)
|
|
}
|
|
let incomingApprovalKeys = Set(incomingApprovals.compactMap { approval in
|
|
Self.execApprovalOwnerKey(
|
|
approvalId: approval.id,
|
|
gatewayStableID: approval.gatewayStableID)
|
|
})
|
|
let retainedNewerRecords = existingRecords.filter { record in
|
|
guard let recordKey = Self.execApprovalOwnerKey(
|
|
approvalId: record.approvalID,
|
|
gatewayStableID: record.approval.gatewayStableID),
|
|
recordKey.gatewayID == WatchGatewayID.key(snapshotGatewayID)
|
|
else {
|
|
return true
|
|
}
|
|
guard !incomingApprovalKeys.contains(recordKey) else { return false }
|
|
// Unsolicited snapshots can come from an iPhone cache that has not yet
|
|
// read Watch-held IDs. Only the response to a canonical request may close
|
|
// approvals omitted from the snapshot.
|
|
guard hasCanonicalRequestCorrelation else { return true }
|
|
guard Self.snapshotCanReplace(
|
|
record: record,
|
|
snapshotSentAtMs: message.sentAtMs)
|
|
else {
|
|
return true
|
|
}
|
|
_ = self.recordExecApprovalTerminal(
|
|
approvalId: record.approvalID,
|
|
gatewayStableID: record.approval.gatewayStableID,
|
|
outcomeText: "Approval resolved elsewhere",
|
|
authoritativeOutcome: false)
|
|
return false
|
|
}
|
|
let mergedIncomingRecords = incomingApprovals.map { approval in
|
|
let approvalKey = Self.execApprovalOwnerKey(
|
|
approvalId: approval.id,
|
|
gatewayStableID: approval.gatewayStableID)
|
|
let existingRecord = approvalKey.flatMap { existingRecordsByOwner[$0] }
|
|
guard Self.snapshotCanReplace(
|
|
record: existingRecord,
|
|
snapshotSentAtMs: message.sentAtMs)
|
|
else {
|
|
return existingRecord!
|
|
}
|
|
return self.mergedExecApprovalRecord(
|
|
approval: approval,
|
|
transport: transport,
|
|
sourceSentAtMs: message.sentAtMs,
|
|
existingRecord: existingRecord)
|
|
}
|
|
self.execApprovals = retainedNewerRecords + mergedIncomingRecords
|
|
if hasSameSnapshotOwner {
|
|
if let snapshotID, !snapshotID.isEmpty {
|
|
self.lastExecApprovalSnapshotID = snapshotID
|
|
}
|
|
if let sentAtMs = message.sentAtMs {
|
|
self.lastExecApprovalSnapshotSentAtMs = sentAtMs
|
|
}
|
|
} else {
|
|
self.lastExecApprovalSnapshotID = snapshotID
|
|
self.lastExecApprovalSnapshotSentAtMs = message.sentAtMs
|
|
}
|
|
self.lastExecApprovalSnapshotGatewayStableID = snapshotGatewayID
|
|
self.hasCompletedExecApprovalSnapshotRefreshInSession = true
|
|
self.ensureValidExecApprovalSelection()
|
|
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
|
|
let currentNotificationIdentifiers = Set(execApprovals.compactMap { record in
|
|
Self.execApprovalNotificationIdentifier(for: record.approval)
|
|
})
|
|
let removedApprovals = existingRecords.map(\.approval).filter { approval in
|
|
guard let identifier = Self.execApprovalNotificationIdentifier(for: approval) else { return false }
|
|
return !currentNotificationIdentifiers.contains(identifier)
|
|
}
|
|
self.removeExecApprovalNotifications(approvals: removedApprovals)
|
|
self.markExecApprovalReviewLoaded()
|
|
self.persistState()
|
|
return true
|
|
}
|
|
|
|
func consume(appSnapshot message: WatchAppSnapshotMessage) {
|
|
let snapshotID = message.snapshotId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let snapshotID, !snapshotID.isEmpty, snapshotID == appSnapshot?.snapshotId {
|
|
return
|
|
}
|
|
if let sentAtMs = message.sentAtMs,
|
|
let currentSentAtMs = appSnapshot?.sentAtMs,
|
|
sentAtMs < currentSentAtMs
|
|
{
|
|
return
|
|
}
|
|
let hasExistingAppSnapshot = self.appSnapshot != nil
|
|
let previousGatewayID = Self.normalizedGatewayID(self.appSnapshot?.gatewayStableID)
|
|
let nextGatewayID = Self.normalizedGatewayID(message.gatewayStableID)
|
|
var merged = message
|
|
if hasExistingAppSnapshot, Self.gatewayIDsMatch(previousGatewayID, nextGatewayID) {
|
|
if merged.chatItems == nil {
|
|
merged.chatItems = self.appSnapshot?.chatItems
|
|
}
|
|
if merged.chatStatusText == nil {
|
|
merged.chatStatusText = self.appSnapshot?.chatStatusText
|
|
}
|
|
}
|
|
self.appSnapshot = merged
|
|
self.appSnapshotUpdatedAt = Date()
|
|
self.appSnapshotStatusText = nil
|
|
if !hasExistingAppSnapshot || !Self.gatewayIDsMatch(previousGatewayID, nextGatewayID) {
|
|
self.hasCompletedExecApprovalSnapshotRefreshInSession = false
|
|
if !Self.gatewayIDsMatch(self.gatewayStableID, nextGatewayID) {
|
|
self.clearMessagePrompt()
|
|
}
|
|
let invalidatedApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in
|
|
guard let nextGatewayID else { return record.approval }
|
|
return Self.gatewayIDsMatch(record.approval.gatewayStableID, nextGatewayID)
|
|
? nil
|
|
: record.approval
|
|
}
|
|
self.execApprovals.removeAll { record in
|
|
guard let nextGatewayID else { return true }
|
|
return !Self.gatewayIDsMatch(record.approval.gatewayStableID, nextGatewayID)
|
|
}
|
|
self.removeExecApprovalNotifications(approvals: invalidatedApprovals)
|
|
self.ensureValidExecApprovalSelection()
|
|
}
|
|
self.persistState()
|
|
}
|
|
|
|
func consume(chatCompletion message: WatchChatCompletionMessage) {
|
|
self.chatCompletion = message
|
|
}
|
|
|
|
func markAppSnapshotRequestStarted() {
|
|
self.appSnapshotStatusText = "Refreshing from iPhone…"
|
|
self.persistState()
|
|
}
|
|
|
|
func markAppSnapshotRequestResult(_ result: WatchReplySendResult) {
|
|
if let errorMessage = result.errorMessage, !errorMessage.isEmpty {
|
|
self.appSnapshotStatusText = "Refresh failed: \(errorMessage)"
|
|
} else if result.deliveredImmediately {
|
|
self.appSnapshotStatusText = "Refresh requested"
|
|
} else if result.queuedForDelivery {
|
|
self.appSnapshotStatusText = "Refresh queued"
|
|
} else {
|
|
self.appSnapshotStatusText = nil
|
|
}
|
|
self.persistState()
|
|
}
|
|
|
|
func makeAppCommand(_ command: WatchAppCommand, text: String? = nil) -> WatchAppCommandMessage {
|
|
let snapshotSessionKey = self.appSnapshot?.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return WatchAppCommandMessage(
|
|
command: command,
|
|
commandId: UUID().uuidString,
|
|
sessionKey: (snapshotSessionKey?.isEmpty == false) ? snapshotSessionKey : self.sessionKey,
|
|
gatewayStableID: self.appSnapshot?.gatewayStableID,
|
|
text: text,
|
|
sentAtMs: Self.nowMs())
|
|
}
|
|
|
|
var hasGatewayTaggedAppSnapshot: Bool {
|
|
WatchGatewayID.exact(self.appSnapshot?.gatewayStableID) != nil
|
|
}
|
|
|
|
func markAppCommandSending(_ command: WatchAppCommand) {
|
|
self.appCommandStatusText = "Sending \(Self.commandLabel(command))…"
|
|
self.persistState()
|
|
}
|
|
|
|
func markAppCommandBlocked(_ command: WatchAppCommand, reason: String) {
|
|
self.appCommandStatusText = "\(Self.commandLabel(command)): \(reason)"
|
|
self.persistState()
|
|
}
|
|
|
|
func markAppCommandResult(_ result: WatchReplySendResult, command: WatchAppCommand) {
|
|
let label = Self.commandLabel(command)
|
|
if let errorMessage = result.errorMessage, !errorMessage.isEmpty {
|
|
self.appCommandStatusText = "\(label) failed: \(errorMessage)"
|
|
} else if result.deliveredImmediately {
|
|
self.appCommandStatusText = "\(label): sent"
|
|
} else if result.queuedForDelivery {
|
|
self.appCommandStatusText = "\(label): queued"
|
|
} else {
|
|
self.appCommandStatusText = "\(label): sent"
|
|
}
|
|
self.persistState()
|
|
}
|
|
}
|
|
|
|
// MARK: - Exec approvals
|
|
|
|
extension WatchInboxStore {
|
|
func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) {
|
|
guard self.routeGatewayPayload(.execApprovalResolved(message: message)) else { return }
|
|
let normalizedOutcomeText = message.outcomeText?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let statusText = if let normalizedOutcomeText, !normalizedOutcomeText.isEmpty {
|
|
normalizedOutcomeText
|
|
} else {
|
|
switch message.decision {
|
|
case .allowOnce:
|
|
"Allowed once"
|
|
case .deny:
|
|
"Denied"
|
|
case nil:
|
|
"Approval resolved"
|
|
}
|
|
}
|
|
let terminalOutcomeText = self.recordExecApprovalTerminal(
|
|
approvalId: message.approvalId,
|
|
gatewayStableID: message.gatewayStableID,
|
|
outcomeText: statusText) ?? statusText
|
|
self.removeExecApproval(id: message.approvalId, gatewayStableID: message.gatewayStableID)
|
|
self.markExecApprovalReviewLoaded()
|
|
self.lastExecApprovalOutcomeText = terminalOutcomeText
|
|
self.lastExecApprovalOutcomeAt = Date()
|
|
self.persistState()
|
|
}
|
|
|
|
func consume(execApprovalExpired message: WatchExecApprovalExpiredMessage) {
|
|
guard self.routeGatewayPayload(.execApprovalExpired(message: message)) else { return }
|
|
let statusText = switch message.reason {
|
|
case .expired:
|
|
"Approval expired"
|
|
case .notFound:
|
|
"Approval no longer available"
|
|
case .resolved:
|
|
"Approval resolved elsewhere"
|
|
case .replaced:
|
|
"Approval replaced"
|
|
case .unavailable:
|
|
"Approval unavailable"
|
|
}
|
|
let terminalOutcomeText = self.recordExecApprovalTerminal(
|
|
approvalId: message.approvalId,
|
|
gatewayStableID: message.gatewayStableID,
|
|
outcomeText: statusText) ?? statusText
|
|
self.removeExecApproval(id: message.approvalId, gatewayStableID: message.gatewayStableID)
|
|
self.markExecApprovalReviewLoaded()
|
|
self.lastExecApprovalOutcomeText = terminalOutcomeText
|
|
self.lastExecApprovalOutcomeAt = Date()
|
|
self.persistState()
|
|
}
|
|
|
|
/// Returns owner-scoped terminal truth for a detail screen whose live record was removed.
|
|
func terminalExecApprovalOutcomeText(
|
|
approvalId: String,
|
|
gatewayStableID: String?) -> String?
|
|
{
|
|
guard let key = Self.execApprovalOwnerKey(
|
|
approvalId: approvalId,
|
|
gatewayStableID: gatewayStableID)
|
|
else {
|
|
return nil
|
|
}
|
|
let cutoff = Date().addingTimeInterval(-Self.execApprovalTerminalTombstoneLifetime)
|
|
return self.execApprovalTerminalTombstones.last { tombstone in
|
|
tombstone.recordedAt >= cutoff
|
|
&& WatchApprovalID.key(tombstone.approvalId) == key.approvalID
|
|
&& WatchGatewayID.key(tombstone.gatewayStableID) == key.gatewayID
|
|
}?.outcomeText
|
|
}
|
|
|
|
func selectExecApproval(id: String, gatewayStableID: String?) {
|
|
guard let exactKey = Self.execApprovalOwnerKey(
|
|
approvalId: id,
|
|
gatewayStableID: gatewayStableID),
|
|
let record = self.execApprovals.first(where: { $0.id == exactKey })
|
|
else { return }
|
|
self.selectedExecApprovalID = record.approvalID
|
|
self.selectedExecApprovalGatewayStableID = record.approval.gatewayStableID
|
|
self.persistState()
|
|
}
|
|
|
|
func beginExecApprovalDecision(
|
|
approvalId: String,
|
|
gatewayStableID: String?,
|
|
decision: WatchExecApprovalDecision) -> String?
|
|
{
|
|
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
|
|
guard let ownerKey = Self.execApprovalOwnerKey(
|
|
approvalId: approvalId,
|
|
gatewayStableID: gatewayStableID),
|
|
!self.isExecApprovalTerminal(
|
|
approvalId: approvalId,
|
|
gatewayStableID: gatewayStableID),
|
|
let index = execApprovals.firstIndex(where: { record in
|
|
Self.execApprovalOwnerKey(
|
|
approvalId: record.approvalID,
|
|
gatewayStableID: record.approval.gatewayStableID) == ownerKey
|
|
}),
|
|
!self.execApprovals[index].isResolving,
|
|
execApprovals[index].approval.allowedDecisions.contains(decision)
|
|
else { return nil }
|
|
|
|
let attemptID = UUID().uuidString
|
|
self.execApprovals[index].isResolving = true
|
|
self.execApprovals[index].pendingDecision = decision
|
|
self.execApprovals[index].activeResolutionAttemptID = attemptID
|
|
self.execApprovals[index].statusText = "Sending \(Self.decisionLabel(decision))…"
|
|
self.execApprovals[index].statusAt = Date()
|
|
self.persistState()
|
|
return attemptID
|
|
}
|
|
|
|
func completeExecApprovalDecision(
|
|
approvalId: String,
|
|
gatewayStableID: String?,
|
|
attemptID: String,
|
|
decision: WatchExecApprovalDecision,
|
|
result: WatchReplySendResult)
|
|
{
|
|
guard let ownerKey = Self.execApprovalOwnerKey(
|
|
approvalId: approvalId,
|
|
gatewayStableID: gatewayStableID),
|
|
let index = execApprovals.firstIndex(where: { record in
|
|
Self.execApprovalOwnerKey(
|
|
approvalId: record.approvalID,
|
|
gatewayStableID: record.approval.gatewayStableID) == ownerKey
|
|
}),
|
|
let activeResolutionAttemptID = execApprovals[index].activeResolutionAttemptID,
|
|
WatchOpaqueUTF8Key(activeResolutionAttemptID) == WatchOpaqueUTF8Key(attemptID),
|
|
execApprovals[index].pendingDecision == decision
|
|
else { return }
|
|
|
|
switch result.delivery {
|
|
case .delivered:
|
|
self.execApprovals[index].isResolving = true
|
|
self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): sent"
|
|
case .queued:
|
|
self.execApprovals[index].isResolving = true
|
|
self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): queued"
|
|
case .notSent:
|
|
// Only a definitive pre-dispatch failure unlocks locally. Uncertain sends stay
|
|
// frozen until a canonical retry reset or terminal event arrives.
|
|
self.execApprovals[index].isResolving = false
|
|
self.execApprovals[index].activeResolutionAttemptID = nil
|
|
self.execApprovals[index].statusText = "Couldn't reach iPhone. Tap to retry."
|
|
}
|
|
self.execApprovals[index].pendingDecision = result.delivery == .notSent ? nil : decision
|
|
self.execApprovals[index].statusAt = Date()
|
|
self.persistState()
|
|
}
|
|
|
|
private func upsertExecApproval(
|
|
_ approval: WatchExecApprovalItem,
|
|
transport: String,
|
|
sourceSentAtMs: Int64?,
|
|
keepSelectionIfPossible: Bool,
|
|
resetResolutionAttemptID: String? = nil) -> Bool
|
|
{
|
|
guard let ownerKey = Self.execApprovalOwnerKey(
|
|
approvalId: approval.id,
|
|
gatewayStableID: approval.gatewayStableID)
|
|
else { return false }
|
|
if let index = execApprovals.firstIndex(where: { record in
|
|
Self.execApprovalOwnerKey(
|
|
approvalId: record.approvalID,
|
|
gatewayStableID: record.approval.gatewayStableID) == ownerKey
|
|
}) {
|
|
guard Self.snapshotCanReplace(
|
|
record: self.execApprovals[index],
|
|
snapshotSentAtMs: sourceSentAtMs)
|
|
else { return false }
|
|
let resetResolvingState = if let resetResolutionAttemptID,
|
|
let activeResolutionAttemptID =
|
|
self.execApprovals[index].activeResolutionAttemptID
|
|
{
|
|
WatchOpaqueUTF8Key(resetResolutionAttemptID) == WatchOpaqueUTF8Key(activeResolutionAttemptID)
|
|
} else {
|
|
false
|
|
}
|
|
self.execApprovals[index] = self.mergedExecApprovalRecord(
|
|
approval: approval,
|
|
transport: transport,
|
|
sourceSentAtMs: sourceSentAtMs,
|
|
existingRecord: self.execApprovals[index],
|
|
resetResolvingState: resetResolvingState)
|
|
} else {
|
|
self.execApprovals.append(
|
|
self.mergedExecApprovalRecord(
|
|
approval: approval,
|
|
transport: transport,
|
|
sourceSentAtMs: sourceSentAtMs,
|
|
existingRecord: nil))
|
|
}
|
|
if !keepSelectionIfPossible || Self.execApprovalOwnerKey(
|
|
approvalId: self.selectedExecApprovalID ?? "",
|
|
gatewayStableID: self.selectedExecApprovalGatewayStableID) == nil
|
|
{
|
|
self.selectedExecApprovalID = approval.id
|
|
self.selectedExecApprovalGatewayStableID = approval.gatewayStableID
|
|
}
|
|
self.persistState()
|
|
return true
|
|
}
|
|
|
|
private func mergedExecApprovalRecord(
|
|
approval: WatchExecApprovalItem,
|
|
transport: String,
|
|
sourceSentAtMs: Int64?,
|
|
existingRecord: WatchExecApprovalRecord?,
|
|
resetResolvingState: Bool = false) -> WatchExecApprovalRecord
|
|
{
|
|
// Preserve in-flight state across ordinary snapshot/prompt refreshes so duplicate
|
|
// submissions stay disabled. Only the iPhone readback for the same attempt may clear it.
|
|
let isResolving = resetResolvingState ? false : (existingRecord?.isResolving ?? false)
|
|
let pendingDecision = resetResolvingState ? nil : existingRecord?.pendingDecision
|
|
let activeResolutionAttemptID = resetResolvingState ? nil : existingRecord?.activeResolutionAttemptID
|
|
let statusText = resetResolvingState ? nil : existingRecord?.statusText
|
|
let statusAt = resetResolvingState ? nil : existingRecord?.statusAt
|
|
return WatchExecApprovalRecord(
|
|
approval: approval,
|
|
transport: transport,
|
|
sourceSentAtMs: sourceSentAtMs ?? existingRecord?.sourceSentAtMs,
|
|
updatedAt: Date(),
|
|
isResolving: isResolving,
|
|
pendingDecision: pendingDecision,
|
|
activeResolutionAttemptID: activeResolutionAttemptID,
|
|
statusText: statusText,
|
|
statusAt: statusAt)
|
|
}
|
|
|
|
private static func snapshotCanReplace(
|
|
record: WatchExecApprovalRecord?,
|
|
snapshotSentAtMs: Int64?) -> Bool
|
|
{
|
|
guard let record else { return true }
|
|
guard let snapshotSentAtMs else {
|
|
// Missing cross-transport ordering evidence cannot safely remove or replace a
|
|
// live prompt. Its expiry or a terminal event will eventually close it.
|
|
return false
|
|
}
|
|
// Records persisted before source timestamps were added yield to a timestamped
|
|
// canonical snapshot instead of remaining actionable indefinitely.
|
|
guard let recordSentAtMs = record.sourceSentAtMs else { return true }
|
|
return snapshotSentAtMs >= recordSentAtMs
|
|
}
|
|
|
|
private func isExecApprovalPromptSupersededBySnapshot(
|
|
_ message: WatchExecApprovalPromptMessage) -> Bool
|
|
{
|
|
let promptGatewayID = Self.normalizedGatewayID(message.approval.gatewayStableID)
|
|
let snapshotGatewayID = Self.normalizedGatewayID(
|
|
self.lastExecApprovalSnapshotGatewayStableID)
|
|
guard Self.gatewayIDsMatch(promptGatewayID, snapshotGatewayID),
|
|
let snapshotSentAtMs = lastExecApprovalSnapshotSentAtMs
|
|
else {
|
|
return false
|
|
}
|
|
let hasLiveRecord = self.execApprovals.contains { record in
|
|
Self.execApprovalOwnerKey(
|
|
approvalId: record.approvalID,
|
|
gatewayStableID: record.approval.gatewayStableID) == Self.execApprovalOwnerKey(
|
|
approvalId: message.approval.id,
|
|
gatewayStableID: message.approval.gatewayStableID)
|
|
}
|
|
guard !hasLiveRecord else { return false }
|
|
guard let promptSentAtMs = message.sentAtMs else {
|
|
// Once an owner snapshot has closed an ID, an undated prompt cannot prove it is newer.
|
|
return true
|
|
}
|
|
return promptSentAtMs <= snapshotSentAtMs
|
|
}
|
|
|
|
private func removeExecApproval(id: String, gatewayStableID: String?) {
|
|
guard let exactKey = Self.execApprovalOwnerKey(
|
|
approvalId: id,
|
|
gatewayStableID: gatewayStableID)
|
|
else { return }
|
|
let removedApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in
|
|
record.id == exactKey ? record.approval : nil
|
|
}
|
|
self.execApprovals.removeAll { record in
|
|
record.id == exactKey
|
|
}
|
|
self.removeExecApprovalNotifications(approvals: removedApprovals)
|
|
if Self.execApprovalOwnerKey(
|
|
approvalId: self.selectedExecApprovalID ?? "",
|
|
gatewayStableID: self.selectedExecApprovalGatewayStableID) == exactKey
|
|
{
|
|
self.selectedExecApprovalID = self.sortedExecApprovals.first?.approvalID
|
|
self.selectedExecApprovalGatewayStableID = self.sortedExecApprovals.first?.approval.gatewayStableID
|
|
}
|
|
self.persistState()
|
|
}
|
|
}
|
|
|
|
// MARK: - Gateway routing and persistence
|
|
|
|
extension WatchInboxStore {
|
|
private func routeGatewayPayload(_ payload: DeferredGatewayPayload) -> Bool {
|
|
guard let incomingGatewayID = Self.normalizedGatewayID(payload.gatewayStableID) else {
|
|
return false
|
|
}
|
|
guard let activeSnapshot = appSnapshot else { return true }
|
|
let activeGatewayID = Self.normalizedGatewayID(activeSnapshot.gatewayStableID)
|
|
guard !Self.gatewayIDsMatch(incomingGatewayID, activeGatewayID) else { return true }
|
|
if let payloadSentAtMs = payload.sentAtMs,
|
|
let snapshotSentAtMs = activeSnapshot.sentAtMs,
|
|
payloadSentAtMs <= snapshotSentAtMs
|
|
{
|
|
return false
|
|
}
|
|
if WatchDeferredPayloadOrdering.isExpired(
|
|
expiresAtMs: payload.expiresAtMs,
|
|
nowMs: Self.nowMs())
|
|
{
|
|
return false
|
|
}
|
|
|
|
self.deferredGatewayPayloads.append(payload)
|
|
if self.deferredGatewayPayloads.count > Self.maxDeferredGatewayPayloads {
|
|
self.deferredGatewayPayloads.removeFirst(
|
|
self.deferredGatewayPayloads.count - Self.maxDeferredGatewayPayloads)
|
|
}
|
|
self.persistState()
|
|
return false
|
|
}
|
|
|
|
private func acceptsGatewayOwner(_ gatewayStableID: String?) -> Bool {
|
|
guard let incomingGatewayID = Self.normalizedGatewayID(gatewayStableID) else { return false }
|
|
guard let activeSnapshot = appSnapshot else { return true }
|
|
guard let activeGatewayID = Self.normalizedGatewayID(activeSnapshot.gatewayStableID) else { return false }
|
|
return Self.gatewayIDsMatch(incomingGatewayID, activeGatewayID)
|
|
}
|
|
|
|
@discardableResult
|
|
func replayDeferredGatewayPayloads() -> [WatchExecApprovalSnapshotMessage] {
|
|
guard let activeGatewayID = Self.normalizedGatewayID(appSnapshot?.gatewayStableID) else {
|
|
let snapshotSentAtMs = self.appSnapshot?.sentAtMs
|
|
let nowMs = Self.nowMs()
|
|
self.deferredGatewayPayloads.removeAll { payload in
|
|
WatchDeferredPayloadOrdering.isExpired(
|
|
expiresAtMs: payload.expiresAtMs,
|
|
nowMs: nowMs)
|
|
|| !WatchDeferredPayloadOrdering.isNewerThanSnapshot(
|
|
payloadSentAtMs: payload.sentAtMs,
|
|
snapshotSentAtMs: snapshotSentAtMs)
|
|
}
|
|
self.persistState()
|
|
return []
|
|
}
|
|
|
|
let snapshotSentAtMs = self.appSnapshot?.sentAtMs
|
|
let approvalSnapshotGatewayID = Self.normalizedGatewayID(
|
|
self.lastExecApprovalSnapshotGatewayStableID)
|
|
let nowMs = Self.nowMs()
|
|
var ready: [DeferredGatewayPayload] = []
|
|
var future: [DeferredGatewayPayload] = []
|
|
for payload in self.deferredGatewayPayloads {
|
|
if WatchDeferredPayloadOrdering.isExpired(
|
|
expiresAtMs: payload.expiresAtMs,
|
|
nowMs: nowMs)
|
|
{
|
|
continue
|
|
}
|
|
if Self.gatewayIDsMatch(payload.gatewayStableID, activeGatewayID) {
|
|
let isPreexistingApprovalPayload = Self.gatewayIDsMatch(
|
|
approvalSnapshotGatewayID,
|
|
activeGatewayID)
|
|
&& WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot(
|
|
payloadSentAtMs: payload.sentAtMs,
|
|
snapshotSentAtMs: self.lastExecApprovalSnapshotSentAtMs)
|
|
if isPreexistingApprovalPayload,
|
|
payload.isFullyRepresentedByExecApprovalSnapshot
|
|
{
|
|
continue
|
|
}
|
|
if isPreexistingApprovalPayload,
|
|
let approval = payload.approvalPrompt,
|
|
let approvalOwnerKey = Self.execApprovalOwnerKey(
|
|
approvalId: approval.id,
|
|
gatewayStableID: approval.gatewayStableID),
|
|
!self.execApprovals.contains(where: { record in
|
|
Self.execApprovalOwnerKey(
|
|
approvalId: record.approvalID,
|
|
gatewayStableID: record.approval.gatewayStableID) == approvalOwnerKey
|
|
})
|
|
{
|
|
continue
|
|
}
|
|
ready.append(payload)
|
|
} else if let payloadSentAtMs = payload.sentAtMs,
|
|
let snapshotSentAtMs,
|
|
payloadSentAtMs > snapshotSentAtMs
|
|
{
|
|
future.append(payload)
|
|
}
|
|
}
|
|
self.deferredGatewayPayloads = future
|
|
self.persistState()
|
|
|
|
let replayOrder = WatchDeferredPayloadOrdering.indicesOldestFirst(
|
|
for: ready.map(\.sentAtMs))
|
|
var appliedExecApprovalSnapshots: [WatchExecApprovalSnapshotMessage] = []
|
|
for index in replayOrder {
|
|
let payload = ready[index]
|
|
switch payload {
|
|
case let .notification(message, transport):
|
|
self.consume(message: message, transport: transport)
|
|
case let .execApprovalPrompt(message, transport):
|
|
self.consume(execApprovalPrompt: message, transport: transport)
|
|
case let .execApprovalResolved(message):
|
|
self.consume(execApprovalResolved: message)
|
|
case let .execApprovalExpired(message):
|
|
self.consume(execApprovalExpired: message)
|
|
case let .execApprovalSnapshot(message, transport):
|
|
if self.consume(execApprovalSnapshot: message, transport: transport) {
|
|
appliedExecApprovalSnapshots.append(message)
|
|
}
|
|
}
|
|
}
|
|
return appliedExecApprovalSnapshots
|
|
}
|
|
}
|
|
|
|
// MARK: - State persistence and notifications
|
|
|
|
extension WatchInboxStore {
|
|
private func clearMessagePrompt() {
|
|
let notificationIdentifier = self.lastDeliveryKey
|
|
self.title = Self.defaultTitle
|
|
self.body = Self.defaultBody
|
|
self.transport = "none"
|
|
self.updatedAt = nil
|
|
self.lastDeliveryKey = nil
|
|
self.promptId = nil
|
|
self.sessionKey = nil
|
|
self.gatewayStableID = nil
|
|
self.kind = nil
|
|
self.details = nil
|
|
self.expiresAtMs = nil
|
|
self.risk = nil
|
|
self.actions = []
|
|
self.replyStatusText = nil
|
|
self.replyStatusAt = nil
|
|
self.isReplySending = false
|
|
|
|
guard let notificationIdentifier else { return }
|
|
self.removeLocalNotifications(identifiers: [notificationIdentifier])
|
|
}
|
|
|
|
private func removeExecApprovalNotifications(approvals: [WatchExecApprovalItem]) {
|
|
self.removeLocalNotifications(identifiers: approvals.flatMap { approval in
|
|
var identifiers = Self.execApprovalNotificationIdentifier(for: approval).map { [$0] } ?? []
|
|
if let legacyIdentifier = Self.legacyExecApprovalNotificationIdentifier(for: approval),
|
|
!self.hasLiveLegacyNotificationCollision(
|
|
identifier: legacyIdentifier,
|
|
excluding: approval)
|
|
{
|
|
identifiers.append(legacyIdentifier)
|
|
}
|
|
return identifiers
|
|
})
|
|
}
|
|
|
|
private func hasLiveLegacyNotificationCollision(
|
|
identifier: String,
|
|
excluding approval: WatchExecApprovalItem) -> Bool
|
|
{
|
|
let identifierKey = WatchOpaqueUTF8Key(identifier)
|
|
let excludedKey = Self.execApprovalOwnerKey(
|
|
approvalId: approval.id,
|
|
gatewayStableID: approval.gatewayStableID)
|
|
return self.execApprovals.contains { record in
|
|
let recordKey = Self.execApprovalOwnerKey(
|
|
approvalId: record.approvalID,
|
|
gatewayStableID: record.approval.gatewayStableID)
|
|
guard recordKey != excludedKey,
|
|
let candidate = Self.legacyExecApprovalNotificationIdentifier(for: record.approval)
|
|
else { return false }
|
|
return WatchOpaqueUTF8Key(candidate) == identifierKey
|
|
}
|
|
}
|
|
|
|
private func removeLocalNotifications(identifiers: [String]) {
|
|
guard !identifiers.isEmpty else { return }
|
|
let center = UNUserNotificationCenter.current()
|
|
center.removePendingNotificationRequests(withIdentifiers: identifiers)
|
|
center.removeDeliveredNotifications(withIdentifiers: identifiers)
|
|
}
|
|
|
|
private nonisolated static func normalizedGatewayID(_ gatewayStableID: String?) -> String? {
|
|
WatchGatewayID.exact(gatewayStableID)
|
|
}
|
|
|
|
private nonisolated static func gatewayIDsMatch(_ lhs: String?, _ rhs: String?) -> Bool {
|
|
WatchGatewayID.key(lhs) == WatchGatewayID.key(rhs)
|
|
}
|
|
|
|
private nonisolated static func onlyGatewayStableID(in approvals: [WatchExecApprovalItem]) -> String? {
|
|
var gatewaysByKey: [WatchGatewayID.Key: String] = [:]
|
|
for approval in approvals {
|
|
guard let gatewayID = self.normalizedGatewayID(approval.gatewayStableID),
|
|
let gatewayKey = WatchGatewayID.key(gatewayID)
|
|
else { continue }
|
|
gatewaysByKey[gatewayKey] = gatewayID
|
|
}
|
|
return gatewaysByKey.count == 1 ? gatewaysByKey.values.first : nil
|
|
}
|
|
|
|
private static func execApprovalNotificationIdentifier(for approval: WatchExecApprovalItem) -> String? {
|
|
guard let gatewayKey = WatchGatewayID.key(approval.gatewayStableID) else { return nil }
|
|
guard let approvalKey = WatchApprovalID.key(approval.id) else { return nil }
|
|
return "watch.execApproval.\(gatewayKey.notificationComponent).\(approvalKey.notificationComponent)"
|
|
}
|
|
|
|
private static func legacyExecApprovalNotificationIdentifier(for approval: WatchExecApprovalItem) -> String? {
|
|
guard let gatewayStableID = WatchGatewayID.exact(approval.gatewayStableID),
|
|
let approvalID = WatchApprovalID.exact(approval.id)
|
|
else { return nil }
|
|
return "watch.execApproval.\(gatewayStableID.utf8.count):\(gatewayStableID)\(approvalID)"
|
|
}
|
|
|
|
private static func execApprovalOwnerKey(
|
|
approvalId: String,
|
|
gatewayStableID: String?) -> ExecApprovalOwnerKey?
|
|
{
|
|
guard let approvalKey = WatchApprovalID.key(approvalId),
|
|
let gatewayKey = WatchGatewayID.key(gatewayStableID)
|
|
else {
|
|
return nil
|
|
}
|
|
return ExecApprovalOwnerKey(
|
|
gatewayID: gatewayKey,
|
|
approvalID: approvalKey)
|
|
}
|
|
|
|
private func isExecApprovalTerminal(approvalId: String, gatewayStableID: String?) -> Bool {
|
|
guard let key = Self.execApprovalOwnerKey(
|
|
approvalId: approvalId,
|
|
gatewayStableID: gatewayStableID)
|
|
else {
|
|
return false
|
|
}
|
|
return self.execApprovalTerminalTombstones.contains { tombstone in
|
|
WatchApprovalID.key(tombstone.approvalId) == key.approvalID
|
|
&& WatchGatewayID.key(tombstone.gatewayStableID) == key.gatewayID
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func recordExecApprovalTerminal(
|
|
approvalId: String,
|
|
gatewayStableID: String?,
|
|
outcomeText: String,
|
|
authoritativeOutcome: Bool = true) -> String?
|
|
{
|
|
guard let exactApprovalID = WatchApprovalID.exact(approvalId),
|
|
let exactGatewayID = WatchGatewayID.exact(gatewayStableID),
|
|
let key = Self.execApprovalOwnerKey(
|
|
approvalId: approvalId,
|
|
gatewayStableID: gatewayStableID)
|
|
else {
|
|
return nil
|
|
}
|
|
self.pruneExecApprovalTerminalTombstones(now: Date())
|
|
let normalizedOutcomeText = outcomeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let boundedOutcomeText = String(
|
|
normalizedOutcomeText.prefix(Self.maxExecApprovalTerminalOutcomeCharacters))
|
|
guard !boundedOutcomeText.isEmpty else { return nil }
|
|
if let existingIndex = execApprovalTerminalTombstones.lastIndex(where: { tombstone in
|
|
WatchApprovalID.key(tombstone.approvalId) == key.approvalID
|
|
&& WatchGatewayID.key(tombstone.gatewayStableID) == key.gatewayID
|
|
}) {
|
|
if authoritativeOutcome,
|
|
self.execApprovalTerminalTombstones[existingIndex].outcomeIsAuthoritative != true
|
|
{
|
|
var upgraded = self.execApprovalTerminalTombstones.remove(at: existingIndex)
|
|
upgraded.outcomeText = boundedOutcomeText
|
|
upgraded.outcomeIsAuthoritative = true
|
|
upgraded.recordedAt = Date()
|
|
self.execApprovalTerminalTombstones.append(upgraded)
|
|
return upgraded.outcomeText
|
|
}
|
|
return self.execApprovalTerminalTombstones[existingIndex].outcomeText
|
|
}
|
|
self.execApprovalTerminalTombstones.append(ExecApprovalTerminalTombstone(
|
|
approvalId: exactApprovalID,
|
|
gatewayStableID: exactGatewayID,
|
|
outcomeText: boundedOutcomeText,
|
|
outcomeIsAuthoritative: authoritativeOutcome,
|
|
recordedAt: Date()))
|
|
self.pruneExecApprovalTerminalTombstones(now: Date())
|
|
return boundedOutcomeText
|
|
}
|
|
|
|
private func pruneExecApprovalTerminalTombstones(now: Date) {
|
|
let cutoff = now.addingTimeInterval(-Self.execApprovalTerminalTombstoneLifetime)
|
|
let retained = self.execApprovalTerminalTombstones.filter { tombstone in
|
|
tombstone.recordedAt >= cutoff
|
|
}
|
|
self.execApprovalTerminalTombstones = Array(
|
|
retained.suffix(Self.maxExecApprovalTerminalTombstones))
|
|
}
|
|
|
|
private func pruneExpiredExecApprovals(nowMs: Int64) {
|
|
let expiredApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in
|
|
guard let expiresAtMs = record.approval.expiresAtMs, expiresAtMs <= nowMs else { return nil }
|
|
return record.approval
|
|
}
|
|
self.execApprovals.removeAll { record in
|
|
guard let expiresAtMs = record.approval.expiresAtMs else { return false }
|
|
return expiresAtMs <= nowMs
|
|
}
|
|
self.removeExecApprovalNotifications(approvals: expiredApprovals)
|
|
self.ensureValidExecApprovalSelection()
|
|
self.persistState()
|
|
}
|
|
|
|
private func ensureValidExecApprovalSelection() {
|
|
if let selectedKey = Self.execApprovalOwnerKey(
|
|
approvalId: self.selectedExecApprovalID ?? "",
|
|
gatewayStableID: self.selectedExecApprovalGatewayStableID),
|
|
self.execApprovals.contains(where: { $0.id == selectedKey })
|
|
{
|
|
return
|
|
}
|
|
self.selectedExecApprovalID = self.sortedExecApprovals.first?.approvalID
|
|
self.selectedExecApprovalGatewayStableID = self.sortedExecApprovals.first?.approval.gatewayStableID
|
|
}
|
|
|
|
private func restorePersistedState() {
|
|
guard let data = defaults.data(forKey: Self.persistedStateKey),
|
|
let state = try? JSONDecoder().decode(PersistedState.self, from: data)
|
|
else {
|
|
return
|
|
}
|
|
|
|
self.title = state.title
|
|
self.body = state.body
|
|
self.transport = state.transport
|
|
self.updatedAt = state.updatedAt
|
|
self.lastDeliveryKey = state.lastDeliveryKey
|
|
self.promptId = state.promptId
|
|
self.sessionKey = state.sessionKey
|
|
self.gatewayStableID = state.gatewayStableID
|
|
self.kind = state.kind
|
|
self.details = state.details
|
|
self.expiresAtMs = state.expiresAtMs
|
|
self.risk = state.risk
|
|
self.actions = state.actions ?? []
|
|
self.replyStatusText = state.replyStatusText
|
|
self.replyStatusAt = state.replyStatusAt
|
|
let validApprovals = state.execApprovals.filter { record in
|
|
WatchApprovalID.exact(record.approvalID) != nil
|
|
}
|
|
let ownerlessApprovals = validApprovals.filter { record in
|
|
Self.normalizedGatewayID(record.approval.gatewayStableID) == nil
|
|
}
|
|
let taggedApprovals = validApprovals.filter { record in
|
|
Self.normalizedGatewayID(record.approval.gatewayStableID) != nil
|
|
}
|
|
let activeGatewayID = state.appSnapshot.flatMap { snapshot in
|
|
Self.normalizedGatewayID(snapshot.gatewayStableID)
|
|
}
|
|
let invalidatedApprovals: [WatchExecApprovalRecord]
|
|
if state.appSnapshot != nil {
|
|
self.execApprovals = taggedApprovals.filter { record in
|
|
Self.gatewayIDsMatch(record.approval.gatewayStableID, activeGatewayID)
|
|
}
|
|
invalidatedApprovals = taggedApprovals.filter { record in
|
|
!Self.gatewayIDsMatch(record.approval.gatewayStableID, activeGatewayID)
|
|
}
|
|
} else {
|
|
self.execApprovals = taggedApprovals
|
|
invalidatedApprovals = []
|
|
}
|
|
self.selectedExecApprovalID = state.selectedExecApprovalID
|
|
self.selectedExecApprovalGatewayStableID = state.selectedExecApprovalGatewayStableID
|
|
self.lastExecApprovalSnapshotID = state.lastExecApprovalSnapshotID
|
|
self.lastExecApprovalSnapshotGatewayStableID = state.lastExecApprovalSnapshotGatewayStableID
|
|
self.lastExecApprovalSnapshotSentAtMs = state.lastExecApprovalSnapshotSentAtMs
|
|
self.lastExecApprovalOutcomeText = state.lastExecApprovalOutcomeText
|
|
self.lastExecApprovalOutcomeAt = state.lastExecApprovalOutcomeAt
|
|
self.appSnapshot = state.appSnapshot
|
|
self.appSnapshotUpdatedAt = state.appSnapshotUpdatedAt
|
|
self.appSnapshotStatusText = state.appSnapshotStatusText
|
|
self.appCommandStatusText = state.appCommandStatusText
|
|
self.deferredGatewayPayloads = Array(
|
|
(state.deferredGatewayPayloads ?? []).suffix(Self.maxDeferredGatewayPayloads))
|
|
self.execApprovalTerminalTombstones = state.execApprovalTerminalTombstones ?? []
|
|
self.pruneExecApprovalTerminalTombstones(now: Date())
|
|
let restoredTerminalApprovals = self.execApprovals.compactMap { record in
|
|
self.isExecApprovalTerminal(
|
|
approvalId: record.approvalID,
|
|
gatewayStableID: record.approval.gatewayStableID)
|
|
? record.approval
|
|
: nil
|
|
}
|
|
self.execApprovals.removeAll { record in
|
|
self.isExecApprovalTerminal(
|
|
approvalId: record.approvalID,
|
|
gatewayStableID: record.approval.gatewayStableID)
|
|
}
|
|
self.removeExecApprovalNotifications(approvals: restoredTerminalApprovals)
|
|
|
|
if state.appSnapshot != nil,
|
|
!Self.gatewayIDsMatch(self.lastExecApprovalSnapshotGatewayStableID, activeGatewayID)
|
|
{
|
|
self.lastExecApprovalSnapshotID = nil
|
|
self.lastExecApprovalSnapshotGatewayStableID = nil
|
|
self.lastExecApprovalSnapshotSentAtMs = nil
|
|
}
|
|
self.ensureValidExecApprovalSelection()
|
|
self.removeExecApprovalNotifications(approvals: invalidatedApprovals.map(\.approval))
|
|
|
|
guard !ownerlessApprovals.isEmpty else { return }
|
|
// Older Watch state has no gateway owner and cannot be resolved safely after
|
|
// gateway switches. Drop it, clear its old alert keys, and force a fresh snapshot.
|
|
self.lastExecApprovalSnapshotID = nil
|
|
self.lastExecApprovalSnapshotGatewayStableID = nil
|
|
self.lastExecApprovalSnapshotSentAtMs = nil
|
|
self.removeLocalNotifications(identifiers: ownerlessApprovals.flatMap { record -> [String] in
|
|
guard let approvalKey = WatchApprovalID.key(record.approvalID) else { return [] }
|
|
return [
|
|
"watch.execApproval.\(approvalKey.notificationComponent)",
|
|
"watch.execApproval.\(record.approvalID)",
|
|
]
|
|
})
|
|
}
|
|
|
|
private func persistState() {
|
|
self.pruneExecApprovalTerminalTombstones(now: Date())
|
|
let updatedAt = self.updatedAt ?? self.lastExecApprovalOutcomeAt ?? Date()
|
|
let state = PersistedState(
|
|
title: title,
|
|
body: body,
|
|
transport: transport,
|
|
updatedAt: updatedAt,
|
|
lastDeliveryKey: lastDeliveryKey,
|
|
promptId: promptId,
|
|
sessionKey: sessionKey,
|
|
gatewayStableID: gatewayStableID,
|
|
kind: kind,
|
|
details: details,
|
|
expiresAtMs: expiresAtMs,
|
|
risk: risk,
|
|
actions: actions,
|
|
replyStatusText: replyStatusText,
|
|
replyStatusAt: replyStatusAt,
|
|
execApprovals: execApprovals,
|
|
selectedExecApprovalID: selectedExecApprovalID,
|
|
selectedExecApprovalGatewayStableID: selectedExecApprovalGatewayStableID,
|
|
lastExecApprovalSnapshotID: lastExecApprovalSnapshotID,
|
|
lastExecApprovalSnapshotGatewayStableID: lastExecApprovalSnapshotGatewayStableID,
|
|
lastExecApprovalSnapshotSentAtMs: lastExecApprovalSnapshotSentAtMs,
|
|
lastExecApprovalOutcomeText: lastExecApprovalOutcomeText,
|
|
lastExecApprovalOutcomeAt: lastExecApprovalOutcomeAt,
|
|
appSnapshot: appSnapshot,
|
|
appSnapshotUpdatedAt: appSnapshotUpdatedAt,
|
|
appSnapshotStatusText: appSnapshotStatusText,
|
|
appCommandStatusText: appCommandStatusText,
|
|
deferredGatewayPayloads: deferredGatewayPayloads,
|
|
execApprovalTerminalTombstones: execApprovalTerminalTombstones)
|
|
guard let data = try? JSONEncoder().encode(state) else { return }
|
|
self.defaults.set(data, forKey: Self.persistedStateKey)
|
|
}
|
|
|
|
private func deliveryKey(messageID: String?, title: String, body: String, sentAtMs: Int64?) -> String {
|
|
if let messageID, messageID.isEmpty == false {
|
|
return "id:\(messageID)"
|
|
}
|
|
return "content:\(title)|\(body)|\(sentAtMs ?? 0)"
|
|
}
|
|
|
|
private func ensureNotificationAuthorization() async {
|
|
let center = UNUserNotificationCenter.current()
|
|
let settings = await center.notificationSettings()
|
|
switch settings.authorizationStatus {
|
|
case .notDetermined:
|
|
_ = try? await center.requestAuthorization(options: [.alert, .sound])
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func mapHapticRisk(_ risk: String?) -> WKHapticType {
|
|
switch risk?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
|
case "high":
|
|
.failure
|
|
case "medium":
|
|
.notification
|
|
default:
|
|
.click
|
|
}
|
|
}
|
|
|
|
func makeReplyDraft(action: WatchPromptAction) -> WatchReplyDraft {
|
|
let prompt = self.promptId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return WatchReplyDraft(
|
|
replyId: UUID().uuidString,
|
|
promptId: (prompt?.isEmpty == false) ? prompt! : "unknown",
|
|
actionId: action.id,
|
|
actionLabel: action.label,
|
|
sessionKey: self.sessionKey,
|
|
gatewayStableID: self.gatewayStableID,
|
|
note: nil,
|
|
sentAtMs: Self.nowMs())
|
|
}
|
|
|
|
func markReplySending(actionLabel: String) {
|
|
self.isReplySending = true
|
|
self.replyStatusText = "Sending \(actionLabel)…"
|
|
self.replyStatusAt = Date()
|
|
self.persistState()
|
|
}
|
|
|
|
func markReplyResult(_ result: WatchReplySendResult, actionLabel: String) {
|
|
self.isReplySending = false
|
|
if let errorMessage = result.errorMessage, !errorMessage.isEmpty {
|
|
self.replyStatusText = "Failed: \(errorMessage)"
|
|
} else if result.deliveredImmediately {
|
|
self.replyStatusText = "\(actionLabel): sent"
|
|
} else if result.queuedForDelivery {
|
|
self.replyStatusText = "\(actionLabel): queued"
|
|
} else {
|
|
self.replyStatusText = "\(actionLabel): sent"
|
|
}
|
|
self.replyStatusAt = Date()
|
|
self.persistState()
|
|
}
|
|
|
|
private func postLocalNotification(
|
|
identifier: String,
|
|
title: String,
|
|
body: String,
|
|
risk: String?,
|
|
stillCurrent: @MainActor @Sendable () -> Bool = { true }) async
|
|
{
|
|
guard stillCurrent() else { return }
|
|
let content = UNMutableNotificationContent()
|
|
content.title = title
|
|
content.body = body
|
|
content.sound = .default
|
|
content.threadIdentifier = "openclaw-watch"
|
|
|
|
let request = UNNotificationRequest(
|
|
identifier: identifier,
|
|
content: content,
|
|
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false))
|
|
|
|
let center = UNUserNotificationCenter.current()
|
|
_ = try? await center.add(request)
|
|
guard stillCurrent() else {
|
|
self.removeLocalNotifications(identifiers: [identifier])
|
|
return
|
|
}
|
|
WKInterfaceDevice.current().play(self.mapHapticRisk(risk))
|
|
}
|
|
|
|
private static func decisionLabel(_ decision: WatchExecApprovalDecision) -> String {
|
|
switch decision {
|
|
case .allowOnce:
|
|
"Allow Once"
|
|
case .deny:
|
|
"Deny"
|
|
}
|
|
}
|
|
|
|
private static func commandLabel(_ command: WatchAppCommand) -> String {
|
|
switch command {
|
|
case .refresh:
|
|
"Refresh"
|
|
case .openChat:
|
|
"Open Chat"
|
|
case .sendChat:
|
|
"Chat"
|
|
case .startTalk:
|
|
"Start Talk"
|
|
case .stopTalk:
|
|
"Stop Talk"
|
|
}
|
|
}
|
|
|
|
private static func nowMs() -> Int64 {
|
|
Int64(Date().timeIntervalSince1970 * 1000)
|
|
}
|
|
}
|