mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 03:51:38 +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
435 lines
17 KiB
Swift
435 lines
17 KiB
Swift
import Foundation
|
|
import OpenClawKit
|
|
|
|
enum WatchMessagingPayloadCodec {
|
|
private static let durableSnapshotTypes = [
|
|
OpenClawWatchPayloadType.appSnapshot.rawValue,
|
|
OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
|
|
]
|
|
|
|
static let completedChatReplyTextLimit = 4000
|
|
|
|
static func nowMs() -> Int64 {
|
|
Int64(Date().timeIntervalSince1970 * 1000)
|
|
}
|
|
|
|
static func nonEmpty(_ value: String?) -> String? {
|
|
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
static func exactNonEmpty(_ value: String?) -> String? {
|
|
guard let value, !value.isEmpty else { return nil }
|
|
return value
|
|
}
|
|
|
|
static func encodeNotificationPayload(
|
|
id: String,
|
|
params: OpenClawWatchNotifyParams,
|
|
gatewayStableID: String?) -> [String: Any]
|
|
{
|
|
var payload: [String: Any] = [
|
|
"type": OpenClawWatchPayloadType.notify.rawValue,
|
|
"id": id,
|
|
"title": params.title,
|
|
"body": params.body,
|
|
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
|
|
"sentAtMs": self.nowMs(),
|
|
]
|
|
if let promptId = nonEmpty(params.promptId) {
|
|
payload["promptId"] = promptId
|
|
}
|
|
if let sessionKey = nonEmpty(params.sessionKey) {
|
|
payload["sessionKey"] = sessionKey
|
|
}
|
|
if let gatewayStableID = GatewayStableIdentifier.exact(gatewayStableID) {
|
|
payload["gatewayStableID"] = gatewayStableID
|
|
}
|
|
if let kind = nonEmpty(params.kind) {
|
|
payload["kind"] = kind
|
|
}
|
|
if let details = nonEmpty(params.details) {
|
|
payload["details"] = details
|
|
}
|
|
if let expiresAtMs = params.expiresAtMs {
|
|
payload["expiresAtMs"] = expiresAtMs
|
|
}
|
|
if let risk = params.risk {
|
|
payload["risk"] = risk.rawValue
|
|
}
|
|
if let actions = params.actions, !actions.isEmpty {
|
|
payload["actions"] = actions.map { action in
|
|
var encoded: [String: Any] = [
|
|
"id": action.id,
|
|
"label": action.label,
|
|
]
|
|
if let style = nonEmpty(action.style) {
|
|
encoded["style"] = style
|
|
}
|
|
return encoded
|
|
}
|
|
}
|
|
return payload
|
|
}
|
|
|
|
static func encodeDirectNodeSetupPayload(setupCode: String) -> [String: Any] {
|
|
[
|
|
"type": OpenClawWatchPayloadType.directNodeSetup.rawValue,
|
|
"setupCode": setupCode,
|
|
"sentAtMs": self.nowMs(),
|
|
]
|
|
}
|
|
|
|
static func encodeExecApprovalItem(_ item: OpenClawWatchExecApprovalItem) -> [String: Any] {
|
|
var payload: [String: Any] = [
|
|
"id": item.id,
|
|
"commandText": item.commandText,
|
|
"allowedDecisions": item.allowedDecisions.map(\.rawValue),
|
|
]
|
|
if let gatewayStableID = GatewayStableIdentifier.exact(item.gatewayStableID) {
|
|
payload["gatewayStableID"] = gatewayStableID
|
|
}
|
|
if let commandPreview = nonEmpty(item.commandPreview) {
|
|
payload["commandPreview"] = commandPreview
|
|
}
|
|
if let warningText = nonEmpty(item.warningText) {
|
|
payload["warningText"] = warningText
|
|
}
|
|
if let host = nonEmpty(item.host) {
|
|
payload["host"] = host
|
|
}
|
|
if let nodeId = nonEmpty(item.nodeId) {
|
|
payload["nodeId"] = nodeId
|
|
}
|
|
if let agentId = nonEmpty(item.agentId) {
|
|
payload["agentId"] = agentId
|
|
}
|
|
if let expiresAtMs = item.expiresAtMs {
|
|
payload["expiresAtMs"] = expiresAtMs
|
|
}
|
|
if let risk = item.risk {
|
|
payload["risk"] = risk.rawValue
|
|
}
|
|
return payload
|
|
}
|
|
|
|
static func encodeExecApprovalPromptPayload(
|
|
_ message: OpenClawWatchExecApprovalPromptMessage) -> [String: Any]
|
|
{
|
|
var payload: [String: Any] = [
|
|
"type": OpenClawWatchPayloadType.execApprovalPrompt.rawValue,
|
|
"approval": self.encodeExecApprovalItem(message.approval),
|
|
]
|
|
if let sentAtMs = message.sentAtMs {
|
|
payload["sentAtMs"] = sentAtMs
|
|
}
|
|
if let resetResolutionAttemptId = exactNonEmpty(message.resetResolutionAttemptId) {
|
|
payload["resetResolutionAttemptId"] = resetResolutionAttemptId
|
|
}
|
|
return payload
|
|
}
|
|
|
|
static func encodeExecApprovalResolvedPayload(
|
|
_ message: OpenClawWatchExecApprovalResolvedMessage) -> [String: Any]
|
|
{
|
|
var payload: [String: Any] = [
|
|
"type": OpenClawWatchPayloadType.execApprovalResolved.rawValue,
|
|
"approvalId": message.approvalId,
|
|
]
|
|
if let gatewayStableID = GatewayStableIdentifier.exact(message.gatewayStableID) {
|
|
payload["gatewayStableID"] = gatewayStableID
|
|
}
|
|
if let decision = message.decision {
|
|
payload["decision"] = decision.rawValue
|
|
}
|
|
if let resolvedAtMs = message.resolvedAtMs {
|
|
payload["resolvedAtMs"] = resolvedAtMs
|
|
}
|
|
if let source = nonEmpty(message.source) {
|
|
payload["source"] = source
|
|
}
|
|
if let outcomeText = nonEmpty(message.outcomeText) {
|
|
payload["outcomeText"] = outcomeText
|
|
}
|
|
return payload
|
|
}
|
|
|
|
static func encodeExecApprovalExpiredPayload(
|
|
_ message: OpenClawWatchExecApprovalExpiredMessage) -> [String: Any]
|
|
{
|
|
var payload: [String: Any] = [
|
|
"type": OpenClawWatchPayloadType.execApprovalExpired.rawValue,
|
|
"approvalId": message.approvalId,
|
|
"reason": message.reason.rawValue,
|
|
]
|
|
if let gatewayStableID = GatewayStableIdentifier.exact(message.gatewayStableID) {
|
|
payload["gatewayStableID"] = gatewayStableID
|
|
}
|
|
if let expiredAtMs = message.expiredAtMs {
|
|
payload["expiredAtMs"] = expiredAtMs
|
|
}
|
|
return payload
|
|
}
|
|
|
|
static func encodeExecApprovalSnapshotPayload(
|
|
_ message: OpenClawWatchExecApprovalSnapshotMessage) -> [String: Any]
|
|
{
|
|
var payload: [String: Any] = [
|
|
"type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
|
|
"approvals": message.approvals.map(self.encodeExecApprovalItem),
|
|
]
|
|
if let gatewayStableID = GatewayStableIdentifier.exact(message.gatewayStableID) {
|
|
payload["gatewayStableID"] = gatewayStableID
|
|
}
|
|
if let sentAtMs = message.sentAtMs {
|
|
payload["sentAtMs"] = sentAtMs
|
|
}
|
|
if let snapshotId = nonEmpty(message.snapshotId) {
|
|
payload["snapshotId"] = snapshotId
|
|
}
|
|
if let requestId = exactNonEmpty(message.requestId) {
|
|
payload["requestId"] = requestId
|
|
}
|
|
if let requestGatewayStableID = GatewayStableIdentifier.exact(message.requestGatewayStableID) {
|
|
payload["requestGatewayStableID"] = requestGatewayStableID
|
|
}
|
|
return payload
|
|
}
|
|
|
|
static func encodeAppSnapshotPayload(
|
|
_ message: OpenClawWatchAppSnapshotMessage) -> [String: Any]
|
|
{
|
|
var payload: [String: Any] = [
|
|
"type": OpenClawWatchPayloadType.appSnapshot.rawValue,
|
|
"gatewayStatusText": message.gatewayStatusText,
|
|
"gatewayConnected": message.gatewayConnected,
|
|
"agentName": message.agentName,
|
|
"sessionKey": message.sessionKey,
|
|
"talkStatusText": message.talkStatusText,
|
|
"talkEnabled": message.talkEnabled,
|
|
"talkListening": message.talkListening,
|
|
"talkSpeaking": message.talkSpeaking,
|
|
"pendingApprovalCount": message.pendingApprovalCount,
|
|
]
|
|
if let agentAvatarURL = nonEmpty(message.agentAvatarURL) {
|
|
payload["agentAvatarUrl"] = agentAvatarURL
|
|
}
|
|
if let agentAvatarText = nonEmpty(message.agentAvatarText) {
|
|
payload["agentAvatarText"] = agentAvatarText
|
|
}
|
|
if let gatewayStableID = GatewayStableIdentifier.exact(message.gatewayStableID) {
|
|
payload["gatewayStableID"] = gatewayStableID
|
|
}
|
|
if let sentAtMs = message.sentAtMs {
|
|
payload["sentAtMs"] = sentAtMs
|
|
}
|
|
if let chatItems = message.chatItems {
|
|
payload["chatItems"] = chatItems.map { item in
|
|
var encoded: [String: Any] = [
|
|
"id": item.id,
|
|
"role": item.role,
|
|
"text": item.text,
|
|
]
|
|
if let timestampMs = item.timestampMs {
|
|
encoded["timestampMs"] = timestampMs
|
|
}
|
|
return encoded
|
|
}
|
|
}
|
|
if let chatStatusText = nonEmpty(message.chatStatusText) {
|
|
payload["chatStatusText"] = chatStatusText
|
|
}
|
|
if let snapshotId = nonEmpty(message.snapshotId) {
|
|
payload["snapshotId"] = snapshotId
|
|
}
|
|
return payload
|
|
}
|
|
|
|
static func encodeSnapshotApplicationContext(
|
|
_ payload: [String: Any],
|
|
merging existingContext: [String: Any]) -> [String: Any]
|
|
{
|
|
guard let payloadType = payload["type"] as? String,
|
|
self.durableSnapshotTypes.contains(payloadType)
|
|
else {
|
|
return payload
|
|
}
|
|
|
|
// updateApplicationContext retains one dictionary. Nest both logical snapshots while
|
|
// keeping the newest one at the top level for older Watch app versions.
|
|
var context = payload
|
|
for snapshotType in self.durableSnapshotTypes {
|
|
if snapshotType == payloadType {
|
|
context[snapshotType] = payload
|
|
} else if let previous = existingContext[snapshotType] as? [String: Any] {
|
|
context[snapshotType] = previous
|
|
} else if existingContext["type"] as? String == snapshotType {
|
|
context[snapshotType] = existingContext
|
|
}
|
|
}
|
|
return context
|
|
}
|
|
|
|
static func encodeChatCompletionPayload(
|
|
_ message: OpenClawWatchChatCompletionMessage) -> [String: Any]
|
|
{
|
|
[
|
|
"type": message.type.rawValue,
|
|
"commandId": message.commandId,
|
|
"replyText": self.truncatedCompletedChatReplyText(message.replyText),
|
|
"sentAtMs": message.sentAtMs ?? self.nowMs(),
|
|
]
|
|
}
|
|
|
|
private static func truncatedCompletedChatReplyText(_ text: String) -> String {
|
|
guard text.count > self.completedChatReplyTextLimit else { return text }
|
|
return "\(text.prefix(self.completedChatReplyTextLimit - 3))..."
|
|
}
|
|
|
|
static func parseQuickReplyPayload(
|
|
_ payload: [String: Any],
|
|
transport: String) -> WatchQuickReplyEvent?
|
|
{
|
|
guard (payload["type"] as? String) == OpenClawWatchPayloadType.reply.rawValue else {
|
|
return nil
|
|
}
|
|
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
|
|
return nil
|
|
}
|
|
let promptId = self.nonEmpty(payload["promptId"] as? String) ?? "unknown"
|
|
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
|
let actionLabel = self.nonEmpty(payload["actionLabel"] as? String)
|
|
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
|
|
let gatewayStableID = GatewayStableIdentifier.exact(payload["gatewayStableID"] as? String)
|
|
let note = self.nonEmpty(payload["note"] as? String)
|
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
|
|
|
return WatchQuickReplyEvent(
|
|
replyId: replyId,
|
|
promptId: promptId,
|
|
actionId: actionId,
|
|
actionLabel: actionLabel,
|
|
sessionKey: sessionKey,
|
|
gatewayStableID: gatewayStableID,
|
|
note: note,
|
|
sentAtMs: sentAtMs,
|
|
transport: transport)
|
|
}
|
|
|
|
static func parseExecApprovalResolvePayload(
|
|
_ payload: [String: Any],
|
|
transport: String) -> WatchExecApprovalResolveEvent?
|
|
{
|
|
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalResolve.rawValue else {
|
|
return nil
|
|
}
|
|
guard let approvalId = ExecApprovalIdentifier.exact(payload["approvalId"] as? String),
|
|
let rawDecision = nonEmpty(payload["decision"] as? String),
|
|
let decision = OpenClawWatchExecApprovalDecision(rawValue: rawDecision)
|
|
else {
|
|
return nil
|
|
}
|
|
let replyId = self.exactNonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
|
let gatewayStableID = GatewayStableIdentifier.exact(payload["gatewayStableID"] as? String)
|
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
|
return WatchExecApprovalResolveEvent(
|
|
replyId: replyId,
|
|
approvalId: approvalId,
|
|
gatewayStableID: gatewayStableID,
|
|
decision: decision,
|
|
sentAtMs: sentAtMs,
|
|
transport: transport)
|
|
}
|
|
|
|
static func parseExecApprovalSnapshotRequestPayload(
|
|
_ payload: [String: Any],
|
|
transport: String) -> WatchExecApprovalSnapshotRequestEvent?
|
|
{
|
|
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue else {
|
|
return nil
|
|
}
|
|
// Version-skew compat: shipped Watch binaries request snapshots without requestId or
|
|
// heldApprovals. A missing key decodes as the shipped shape (present-but-malformed
|
|
// still rejects); remove once the minimum paired Watch app version sends heldApprovals.
|
|
let requestId = self.exactNonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
|
|
let rawHeldApprovals: [Any]
|
|
if let rawHeldApprovalsValue = payload["heldApprovals"] {
|
|
guard let heldApprovalsArray = rawHeldApprovalsValue as? [Any] else { return nil }
|
|
rawHeldApprovals = heldApprovalsArray
|
|
} else {
|
|
rawHeldApprovals = []
|
|
}
|
|
var heldApprovals: [WatchExecApprovalSnapshotRequestItem] = []
|
|
heldApprovals.reserveCapacity(rawHeldApprovals.count)
|
|
for rawItem in rawHeldApprovals {
|
|
guard let item = rawItem as? [String: Any],
|
|
let approvalId = ExecApprovalIdentifier.exact(item["approvalId"] as? String)
|
|
else {
|
|
return nil
|
|
}
|
|
let activeResolutionAttemptId: String?
|
|
if let rawAttemptId = item["activeResolutionAttemptId"] {
|
|
guard let attemptId = exactNonEmpty(rawAttemptId as? String) else {
|
|
return nil
|
|
}
|
|
activeResolutionAttemptId = attemptId
|
|
} else {
|
|
activeResolutionAttemptId = nil
|
|
}
|
|
heldApprovals.append(WatchExecApprovalSnapshotRequestItem(
|
|
approvalId: approvalId,
|
|
activeResolutionAttemptId: activeResolutionAttemptId))
|
|
}
|
|
let gatewayStableID = GatewayStableIdentifier.exact(payload["gatewayStableID"] as? String)
|
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
|
return WatchExecApprovalSnapshotRequestEvent(
|
|
requestId: requestId,
|
|
gatewayStableID: gatewayStableID,
|
|
heldApprovals: heldApprovals,
|
|
sentAtMs: sentAtMs,
|
|
transport: transport)
|
|
}
|
|
|
|
static func parseAppSnapshotRequestPayload(
|
|
_ payload: [String: Any],
|
|
transport: String) -> WatchAppSnapshotRequestEvent?
|
|
{
|
|
guard (payload["type"] as? String) == OpenClawWatchPayloadType.appSnapshotRequest.rawValue else {
|
|
return nil
|
|
}
|
|
let requestId = self.nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
|
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
|
return WatchAppSnapshotRequestEvent(
|
|
requestId: requestId,
|
|
sentAtMs: sentAtMs,
|
|
transport: transport)
|
|
}
|
|
|
|
static func parseAppCommandPayload(
|
|
_ payload: [String: Any],
|
|
transport: String) -> WatchAppCommandEvent?
|
|
{
|
|
guard (payload["type"] as? String) == OpenClawWatchPayloadType.appCommand.rawValue else {
|
|
return nil
|
|
}
|
|
guard let rawCommand = nonEmpty(payload["command"] as? String),
|
|
let command = OpenClawWatchAppCommand(rawValue: rawCommand)
|
|
else {
|
|
return nil
|
|
}
|
|
let commandId = self.nonEmpty(payload["commandId"] as? String) ?? UUID().uuidString
|
|
let sessionKey = self.nonEmpty(payload["sessionKey"] as? String)
|
|
let gatewayStableID = GatewayStableIdentifier.exact(payload["gatewayStableID"] as? String)
|
|
let text = self.nonEmpty(payload["text"] as? String)
|
|
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
|
|
return WatchAppCommandEvent(
|
|
commandId: commandId,
|
|
command: command,
|
|
sessionKey: sessionKey,
|
|
gatewayStableID: gatewayStableID,
|
|
text: text,
|
|
sentAtMs: sentAtMs,
|
|
transport: transport)
|
|
}
|
|
}
|