Files
openclaw/apps/ios/WatchApp/Sources/WatchInboxMessages.swift
2026-07-12 18:33:47 +02:00

1092 lines
38 KiB
Swift

import Foundation
import OpenClawKit
// Canonical transport leaf models live in OpenClawKit. The local envelope types
// below retain the existing Watch inbox persistence shape without `type`.
typealias WatchPayloadType = OpenClawWatchPayloadType
typealias WatchRiskLevel = OpenClawWatchRisk
typealias WatchExecApprovalDecision = OpenClawWatchExecApprovalDecision
typealias WatchExecApprovalTransportOutcome = OpenClawWatchExecApprovalOutcome
typealias WatchExecApprovalCloseReason = OpenClawWatchExecApprovalCloseReason
typealias WatchOpaqueUTF8Key = ExactOpaqueIdentifierKey
typealias WatchApprovalID = ExecApprovalIdentifier
typealias WatchGatewayID = GatewayStableIdentifier
struct WatchExecApprovalIdentityKey: Hashable, Sendable {
var gatewayID: WatchGatewayID.Key
var approvalID: WatchApprovalID.Key
}
typealias WatchExecApprovalItem = OpenClawWatchExecApprovalItem
struct WatchExecApprovalPromptMessage: Codable, Equatable {
var approval: WatchExecApprovalItem
var sentAtMs: Int64?
var resetResolutionAttemptId: String?
}
struct WatchExecApprovalResolvedMessage: Codable, Equatable {
var approvalId: String
var gatewayStableID: String?
var decision: WatchExecApprovalDecision?
var outcome: WatchExecApprovalTransportOutcome?
var resolvedAtMs: Int64?
var source: String?
var outcomeText: String?
static func parseTransportOutcome(_ value: Any?) -> WatchExecApprovalTransportOutcome? {
guard let rawValue = value as? String else { return nil }
return WatchExecApprovalTransportOutcome(rawValue: rawValue)
}
}
struct WatchExecApprovalExpiredMessage: Codable, Equatable {
var approvalId: String
var gatewayStableID: String?
var reason: WatchExecApprovalCloseReason
var expiredAtMs: Int64?
}
struct WatchExecApprovalSnapshotMessage: Codable, Equatable {
var approvals: [WatchExecApprovalItem]
var gatewayStableID: String?
var sentAtMs: Int64?
var snapshotId: String?
var requestId: String?
var requestGatewayStableID: String?
init(
approvals: [WatchExecApprovalItem],
gatewayStableID: String? = nil,
sentAtMs: Int64? = nil,
snapshotId: String? = nil,
requestId: String? = nil,
requestGatewayStableID: String? = nil)
{
self.approvals = approvals
self.gatewayStableID = gatewayStableID
self.sentAtMs = sentAtMs
self.snapshotId = snapshotId
self.requestId = requestId
self.requestGatewayStableID = requestGatewayStableID
}
}
typealias WatchExecApprovalSnapshotRequestMessage = OpenClawWatchExecApprovalSnapshotRequestMessage
typealias WatchExecApprovalSnapshotRequestItem = OpenClawWatchExecApprovalSnapshotRequestItem
typealias WatchExecApprovalResolveMessage = OpenClawWatchExecApprovalResolveMessage
typealias WatchAppCommand = OpenClawWatchAppCommand
enum WatchStatusLocalizationKey: String {
case connected
case reconnecting
case offline
case gatewayProblemRequestIDFormat
case ready
case connecting
case listening
case thinking
case speaking
case off
case missingFormat
case requestingApproval
case approvalRequested
case apiKeyMissing
case unavailable
case connectIPhoneChat
case noChatMessages
case chatUnavailable
case noMessagesSynced
case waitingForIPhone
case refresh
case openChat
case chat
case startTalk
case stopTalk
case sendingFormat
case failedFormat
case sentFormat
case queuedFormat
case refreshingFromIPhone
case allowOnce
case deny
case retryApproval
case allowedOnce
case approvalSetToAlwaysAllow
case denied
case approvalResolved
case approvalExpired
case approvalNoLongerAvailable
case approvalResolvedElsewhere
case approvalReplaced
case approvalUnavailable
var localized: String {
switch self {
case .connected:
String(localized: "Connected")
case .reconnecting:
String(localized: "Reconnecting…")
case .offline:
String(localized: "Offline")
case .gatewayProblemRequestIDFormat:
String(localized: "%@ (request ID: %@)")
case .ready:
String(localized: "Ready")
case .connecting:
String(localized: "Connecting")
case .listening:
String(localized: "Listening")
case .thinking:
String(localized: "Thinking")
case .speaking:
String(localized: "Speaking")
case .off:
String(localized: "Off")
case .missingFormat:
String(localized: "Missing %@")
case .requestingApproval:
String(localized: "Requesting approval")
case .approvalRequested:
String(localized: "Approval requested")
case .apiKeyMissing:
String(localized: "API key missing")
case .unavailable:
String(localized: "Unavailable")
case .connectIPhoneChat:
String(localized: "Connect iPhone chat to read messages")
case .noChatMessages:
String(localized: "No chat messages yet")
case .chatUnavailable:
String(localized: "Chat unavailable")
case .noMessagesSynced:
String(localized: "No messages synced")
case .waitingForIPhone:
String(localized: "Waiting for iPhone")
case .refresh:
String(localized: "Refresh")
case .openChat:
String(localized: "Open Chat")
case .chat:
String(localized: "Chat")
case .startTalk:
String(localized: "Start Talk")
case .stopTalk:
String(localized: "Stop Talk")
case .sendingFormat:
String(localized: "Sending %@…")
case .failedFormat:
String(localized: "%@ failed: %@")
case .sentFormat:
String(localized: "%@: sent")
case .queuedFormat:
String(localized: "%@: queued")
case .refreshingFromIPhone:
String(localized: "Refreshing from iPhone…")
case .allowOnce:
String(localized: "Allow Once")
case .deny:
String(localized: "Deny")
case .retryApproval:
String(localized: "Couldn't reach iPhone. Tap to retry.")
case .allowedOnce:
String(localized: "Allowed once")
case .approvalSetToAlwaysAllow:
String(localized: "Approval set to Always Allow.")
case .denied:
String(localized: "Denied")
case .approvalResolved:
String(localized: "Approval resolved")
case .approvalExpired:
String(localized: "Approval expired")
case .approvalNoLongerAvailable:
String(localized: "Approval no longer available")
case .approvalResolvedElsewhere:
String(localized: "Approval resolved elsewhere")
case .approvalReplaced:
String(localized: "Approval replaced")
case .approvalUnavailable:
String(localized: "Approval unavailable")
}
}
}
enum WatchDeliveryStatusCode: String, Codable, Equatable {
case sending
case sent
case queued
case failed
case blocked
}
struct WatchAppCommandStatus: Codable, Equatable {
var command: WatchAppCommand
var code: WatchDeliveryStatusCode
var detail: String?
var legacyVerbatim: String?
func localizedText(
localize: (WatchStatusLocalizationKey) -> String = { $0.localized }) -> String
{
if let legacyVerbatim {
return legacyVerbatim
}
let label = self.command.localizedLabel(localize: localize)
return switch self.code {
case .sending:
String(format: localize(.sendingFormat), label)
case .sent:
String(format: localize(.sentFormat), label)
case .queued:
String(format: localize(.queuedFormat), label)
case .failed:
String(format: localize(.failedFormat), label, self.detail ?? localize(.unavailable))
case .blocked:
self.detail ?? localize(.refreshingFromIPhone)
}
}
static func decodeLegacyLocalizedText(_ text: String) -> Self? {
guard !text.isEmpty else { return nil }
return Self(
command: .refresh,
code: .failed,
detail: nil,
legacyVerbatim: text)
}
}
struct WatchReplyStatus: Codable, Equatable {
var code: WatchDeliveryStatusCode
var actionLabel: String
var detail: String?
var legacyVerbatim: String?
func localizedText(
localize: (WatchStatusLocalizationKey) -> String = { $0.localized }) -> String
{
if let legacyVerbatim {
return legacyVerbatim
}
return switch self.code {
case .sending:
String(format: localize(.sendingFormat), self.actionLabel)
case .sent:
String(format: localize(.sentFormat), self.actionLabel)
case .queued:
String(format: localize(.queuedFormat), self.actionLabel)
case .failed:
String(
format: localize(.failedFormat),
self.actionLabel,
self.detail ?? localize(.unavailable))
case .blocked:
localize(.refreshingFromIPhone)
}
}
static func decodeLegacyLocalizedText(_ text: String) -> Self? {
guard !text.isEmpty else { return nil }
return Self(
code: .failed,
actionLabel: "",
detail: nil,
legacyVerbatim: text)
}
}
enum WatchExecApprovalStatusCode: String, Codable, Equatable {
case sending
case sent
case queued
case retry
case legacy
}
struct WatchExecApprovalStatus: Codable, Equatable {
var code: WatchExecApprovalStatusCode
var decision: WatchExecApprovalDecision?
var legacyVerbatim: String?
func localizedText(
localize: (WatchStatusLocalizationKey) -> String = { $0.localized }) -> String
{
if let legacyVerbatim {
return legacyVerbatim
}
guard self.code != .retry else {
return localize(.retryApproval)
}
let label = (self.decision ?? .allowOnce).localizedLabel(localize: localize)
return switch self.code {
case .sending:
String(format: localize(.sendingFormat), label)
case .sent:
String(format: localize(.sentFormat), label)
case .queued:
String(format: localize(.queuedFormat), label)
case .retry:
localize(.retryApproval)
case .legacy:
legacyVerbatim ?? localize(.unavailable)
}
}
static func decodeLegacyLocalizedText(_ text: String) -> Self? {
guard !text.isEmpty else { return nil }
return Self(code: .legacy, legacyVerbatim: text)
}
}
enum WatchExecApprovalOutcomeCode: String, Codable, Equatable {
case allowedOnce
case allowedAlways
case denied
case resolved
case expired
case notFound
case resolvedElsewhere
case replaced
case unavailable
case verbatim
}
struct WatchExecApprovalOutcome: Codable, Equatable {
var code: WatchExecApprovalOutcomeCode
var verbatim: String?
func localizedText(
localize: (WatchStatusLocalizationKey) -> String = { $0.localized }) -> String
{
if let verbatim {
return verbatim
}
return switch self.code {
case .allowedOnce:
localize(.allowedOnce)
case .allowedAlways:
localize(.approvalSetToAlwaysAllow)
case .denied:
localize(.denied)
case .resolved:
localize(.approvalResolved)
case .expired:
localize(.approvalExpired)
case .notFound:
localize(.approvalNoLongerAvailable)
case .resolvedElsewhere:
localize(.approvalResolvedElsewhere)
case .replaced:
localize(.approvalReplaced)
case .unavailable:
localize(.approvalUnavailable)
case .verbatim:
localize(.unavailable)
}
}
static func decodeLegacyLocalizedText(_ text: String) -> Self? {
guard !text.isEmpty else { return nil }
return switch text {
case "Allowed once", "Approval allowed once.", "This approval was already allowed once.":
Self(code: .allowedOnce)
case "Approval set to Always Allow.", "This approval was already set to Always Allow.":
Self(code: .allowedAlways)
case "Denied", "Approval denied.", "This approval was already denied.":
Self(code: .denied)
case "Approval resolved":
Self(code: .resolved)
case "Approval expired":
Self(code: .expired)
case "Approval no longer available":
Self(code: .notFound)
case "Approval resolved elsewhere", "This approval was already resolved elsewhere.":
Self(code: .resolvedElsewhere)
case "Approval replaced":
Self(code: .replaced)
case "Approval unavailable":
Self(code: .unavailable)
default:
Self(code: .verbatim, verbatim: text)
}
}
static func resolved(
outcome: WatchExecApprovalTransportOutcome?,
legacyText: String?,
decision: WatchExecApprovalDecision?,
source: String?) -> Self
{
if let outcome {
return switch outcome {
case .allowedOnce:
Self(code: .allowedOnce)
case .allowedAlways:
Self(code: .allowedAlways)
case .denied:
Self(code: .denied)
}
}
let normalizedLegacyText = legacyText?
.trimmingCharacters(in: .whitespacesAndNewlines)
.prefix(160)
if let normalizedLegacyText, !normalizedLegacyText.isEmpty {
return Self.decodeLegacyLocalizedText(String(normalizedLegacyText))
?? Self(code: .unavailable)
}
if let decision {
return switch decision {
case .allowOnce:
Self(code: .allowedOnce)
case .deny:
Self(code: .denied)
}
}
return Self(code: source == "another-reviewer" ? .resolvedElsewhere : .resolved)
}
}
extension WatchAppCommand {
fileprivate func localizedLabel(
localize: (WatchStatusLocalizationKey) -> String) -> String
{
switch self {
case .refresh:
localize(.refresh)
case .openChat:
localize(.openChat)
case .sendChat:
localize(.chat)
case .startTalk:
localize(.startTalk)
case .stopTalk:
localize(.stopTalk)
}
}
}
extension WatchExecApprovalDecision {
fileprivate func localizedLabel(
localize: (WatchStatusLocalizationKey) -> String) -> String
{
switch self {
case .allowOnce:
localize(.allowOnce)
case .deny:
localize(.deny)
}
}
}
struct WatchAppSnapshotMessage: Codable, Equatable {
var gatewayStatus: OpenClawWatchAppStatus
var gatewayConnected: Bool
var agentName: String
var agentAvatarURL: String?
var agentAvatarText: String?
var sessionKey: String
var gatewayStableID: String?
var talkStatus: OpenClawWatchAppStatus
var talkEnabled: Bool
var talkListening: Bool
var talkSpeaking: Bool
var pendingApprovalCount: Int
var chatItems: [WatchChatItem]?
var chatStatus: OpenClawWatchAppStatus?
var sentAtMs: Int64?
var snapshotId: String?
init(
gatewayStatus: OpenClawWatchAppStatus,
gatewayConnected: Bool,
agentName: String,
agentAvatarURL: String?,
agentAvatarText: String?,
sessionKey: String,
gatewayStableID: String?,
talkStatus: OpenClawWatchAppStatus,
talkEnabled: Bool,
talkListening: Bool,
talkSpeaking: Bool,
pendingApprovalCount: Int,
chatItems: [WatchChatItem]?,
chatStatus: OpenClawWatchAppStatus?,
sentAtMs: Int64?,
snapshotId: String?)
{
self.gatewayStatus = gatewayStatus
self.gatewayConnected = gatewayConnected
self.agentName = agentName
self.agentAvatarURL = agentAvatarURL
self.agentAvatarText = agentAvatarText
self.sessionKey = sessionKey
self.gatewayStableID = gatewayStableID
self.talkStatus = talkStatus
self.talkEnabled = talkEnabled
self.talkListening = talkListening
self.talkSpeaking = talkSpeaking
self.pendingApprovalCount = pendingApprovalCount
self.chatItems = chatItems
self.chatStatus = chatStatus
self.sentAtMs = sentAtMs
self.snapshotId = snapshotId
}
static func parsePayload(_ payload: [String: Any]) -> Self? {
guard let type = payload["type"] as? String,
type == WatchPayloadType.appSnapshot.rawValue
else {
return nil
}
let gatewayConnected = Self.boolValue(payload["gatewayConnected"])
let agentName = (payload["agentName"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentAvatarURL = (payload["agentAvatarURL"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let agentAvatarText = (payload["agentAvatarText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let sessionKey = (payload["sessionKey"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let gatewayStableID = WatchGatewayID.exact(payload["gatewayStableID"] as? String)
let talkEnabled = Self.boolValue(payload["talkEnabled"])
let talkListening = Self.boolValue(payload["talkListening"])
let talkSpeaking = Self.boolValue(payload["talkSpeaking"])
let pendingApprovalCount = (payload["pendingApprovalCount"] as? Int)
?? (payload["pendingApprovalCount"] as? NSNumber)?.intValue
?? 0
let sentAtMs = (payload["sentAtMs"] as? NSNumber)?.int64Value
let snapshotId = (payload["snapshotId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let chatItems = (payload["chatItems"] as? [Any])?.compactMap(Self.parseChatItem)
return Self(
gatewayStatus: Self.parseStatus(
payload["gatewayStatus"],
fallbackText: payload["gatewayStatusText"] as? String)
?? Self.decodeLegacyGatewayStatus(
text: payload["gatewayStatusText"] as? String,
connected: gatewayConnected),
gatewayConnected: gatewayConnected,
agentName: agentName.isEmpty ? "Main" : agentName,
agentAvatarURL: agentAvatarURL?.isEmpty == false ? agentAvatarURL : nil,
agentAvatarText: agentAvatarText?.isEmpty == false ? agentAvatarText : nil,
sessionKey: sessionKey.isEmpty ? "main" : sessionKey,
gatewayStableID: gatewayStableID,
talkStatus: Self.parseStatus(
payload["talkStatus"],
fallbackText: payload["talkStatusText"] as? String)
?? Self.decodeLegacyTalkStatus(
text: payload["talkStatusText"] as? String,
enabled: talkEnabled,
listening: talkListening,
speaking: talkSpeaking),
talkEnabled: talkEnabled,
talkListening: talkListening,
talkSpeaking: talkSpeaking,
pendingApprovalCount: max(0, pendingApprovalCount),
chatItems: chatItems,
chatStatus: Self.parseStatus(
payload["chatStatus"],
fallbackText: payload["chatStatusText"] as? String)
?? Self.decodeLegacyChatStatus(
code: payload["chatStatusCode"] as? String,
text: payload["chatStatusText"] as? String),
sentAtMs: sentAtMs,
snapshotId: snapshotId)
}
static func localizedChatStatusText(
status: OpenClawWatchAppStatus?,
chatCount: Int,
hasAppSnapshot: Bool,
localize: (WatchStatusLocalizationKey) -> String = { $0.localized }) -> String
{
if let status {
return status.localizedText(localize: localize)
}
if chatCount > 0 {
return String(
AttributedString(localized: "^[\(chatCount) recent message](inflect: true)").characters)
}
return hasAppSnapshot
? localize(.noMessagesSynced)
: localize(.waitingForIPhone)
}
private enum CodingKeys: String, CodingKey {
case gatewayStatus
case gatewayStatusText
case gatewayConnected
case agentName
case agentAvatarURL
case agentAvatarText
case sessionKey
case gatewayStableID
case talkStatus
case talkStatusText
case talkEnabled
case talkListening
case talkSpeaking
case pendingApprovalCount
case chatItems
case chatStatus
case chatStatusCode
case chatStatusText
case sentAtMs
case snapshotId
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.gatewayConnected = try container.decode(Bool.self, forKey: .gatewayConnected)
self.agentName = try container.decode(String.self, forKey: .agentName)
self.agentAvatarURL = try container.decodeIfPresent(String.self, forKey: .agentAvatarURL)
self.agentAvatarText = try container.decodeIfPresent(String.self, forKey: .agentAvatarText)
self.sessionKey = try container.decode(String.self, forKey: .sessionKey)
self.gatewayStableID = try container.decodeIfPresent(String.self, forKey: .gatewayStableID)
self.talkEnabled = try container.decode(Bool.self, forKey: .talkEnabled)
self.talkListening = try container.decode(Bool.self, forKey: .talkListening)
self.talkSpeaking = try container.decode(Bool.self, forKey: .talkSpeaking)
self.pendingApprovalCount = try container.decode(Int.self, forKey: .pendingApprovalCount)
self.chatItems = try container.decodeIfPresent([WatchChatItem].self, forKey: .chatItems)
self.sentAtMs = try container.decodeIfPresent(Int64.self, forKey: .sentAtMs)
self.snapshotId = try container.decodeIfPresent(String.self, forKey: .snapshotId)
let gatewayStatusText = try container.decodeIfPresent(String.self, forKey: .gatewayStatusText)
let talkStatusText = try container.decodeIfPresent(String.self, forKey: .talkStatusText)
let chatStatusCode = try container.decodeIfPresent(String.self, forKey: .chatStatusCode)
let chatStatusText = try container.decodeIfPresent(String.self, forKey: .chatStatusText)
if let gatewayStatus = try? container.decode(
OpenClawWatchAppStatus.self,
forKey: .gatewayStatus)
{
self.gatewayStatus = gatewayStatus
} else if container.contains(.gatewayStatus),
let gatewayStatusText,
!gatewayStatusText.isEmpty
{
self.gatewayStatus = OpenClawWatchAppStatus(
code: .legacy,
verbatim: gatewayStatusText)
} else {
self.gatewayStatus = Self.decodeLegacyGatewayStatus(
text: gatewayStatusText,
connected: self.gatewayConnected)
}
if let talkStatus = try? container.decode(
OpenClawWatchAppStatus.self,
forKey: .talkStatus)
{
self.talkStatus = talkStatus
} else if container.contains(.talkStatus),
let talkStatusText,
!talkStatusText.isEmpty
{
self.talkStatus = OpenClawWatchAppStatus(
code: .legacy,
verbatim: talkStatusText)
} else {
self.talkStatus = Self.decodeLegacyTalkStatus(
text: talkStatusText,
enabled: self.talkEnabled,
listening: self.talkListening,
speaking: self.talkSpeaking)
}
self.chatStatus = (try? container.decode(
OpenClawWatchAppStatus.self,
forKey: .chatStatus)) ?? Self.decodeLegacyChatStatus(
code: chatStatusCode,
text: chatStatusText)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.gatewayStatus, forKey: .gatewayStatus)
try container.encode(self.gatewayConnected, forKey: .gatewayConnected)
try container.encode(self.agentName, forKey: .agentName)
try container.encodeIfPresent(self.agentAvatarURL, forKey: .agentAvatarURL)
try container.encodeIfPresent(self.agentAvatarText, forKey: .agentAvatarText)
try container.encode(self.sessionKey, forKey: .sessionKey)
try container.encodeIfPresent(self.gatewayStableID, forKey: .gatewayStableID)
try container.encode(self.talkStatus, forKey: .talkStatus)
try container.encode(self.talkEnabled, forKey: .talkEnabled)
try container.encode(self.talkListening, forKey: .talkListening)
try container.encode(self.talkSpeaking, forKey: .talkSpeaking)
try container.encode(self.pendingApprovalCount, forKey: .pendingApprovalCount)
try container.encodeIfPresent(self.chatItems, forKey: .chatItems)
try container.encodeIfPresent(self.chatStatus, forKey: .chatStatus)
try container.encodeIfPresent(self.sentAtMs, forKey: .sentAtMs)
try container.encodeIfPresent(self.snapshotId, forKey: .snapshotId)
}
private static func parseStatus(
_ value: Any?,
fallbackText: String? = nil) -> OpenClawWatchAppStatus?
{
guard let payload = value as? [String: Any],
let rawCode = payload["code"] as? String
else {
return nil
}
let verbatim = payload["verbatim"] as? String
guard let code = OpenClawWatchAppStatusCode(rawValue: rawCode) else {
let legacyText = verbatim?.isEmpty == false ? verbatim : fallbackText
guard let legacyText, !legacyText.isEmpty else { return nil }
return OpenClawWatchAppStatus(code: .legacy, verbatim: legacyText)
}
return OpenClawWatchAppStatus(
code: code,
localizationKey: payload["localizationKey"] as? String,
arguments: payload["arguments"] as? [String] ?? [],
verbatim: verbatim)
}
private static func decodeLegacyGatewayStatus(
text: String?,
connected: Bool) -> OpenClawWatchAppStatus
{
if connected {
return OpenClawWatchAppStatus(code: .gatewayConnected)
}
guard let text, !text.isEmpty else {
return OpenClawWatchAppStatus(code: .gatewayOffline)
}
return OpenClawWatchAppStatus(code: .legacy, verbatim: text)
}
private static func decodeLegacyTalkStatus(
text: String?,
enabled: Bool,
listening: Bool,
speaking: Bool) -> OpenClawWatchAppStatus
{
if speaking {
return OpenClawWatchAppStatus(code: .talkSpeaking)
}
if listening {
return OpenClawWatchAppStatus(code: .talkListening)
}
if !enabled {
return OpenClawWatchAppStatus(code: .talkOff)
}
guard let text, !text.isEmpty else {
return OpenClawWatchAppStatus(code: .talkReady)
}
return OpenClawWatchAppStatus(code: .legacy, verbatim: text)
}
private static func decodeLegacyChatStatus(
code: String?,
text: String?) -> OpenClawWatchAppStatus?
{
let statusCode: OpenClawWatchAppStatusCode? = switch code {
case "connectIPhone":
OpenClawWatchAppStatusCode.chatConnectIPhone
case "noMessages":
OpenClawWatchAppStatusCode.chatNoMessages
case "unavailable":
OpenClawWatchAppStatusCode.chatUnavailable
default:
nil
}
if let statusCode {
return OpenClawWatchAppStatus(code: statusCode)
}
guard let text, !text.isEmpty else { return nil }
return OpenClawWatchAppStatus(code: .legacy, verbatim: text)
}
private static func parseChatItem(_ item: Any) -> WatchChatItem? {
guard let dict = item as? [String: Any] else { return nil }
guard let id = (dict["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!id.isEmpty
else {
return nil
}
let trimmedRole = (dict["role"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let text, !text.isEmpty else { return nil }
let timestampMs = (dict["timestampMs"] as? NSNumber)?.int64Value
return WatchChatItem(
id: id,
role: trimmedRole.isEmpty ? "assistant" : trimmedRole,
text: text,
timestampMs: timestampMs)
}
private static func boolValue(_ value: Any?) -> Bool {
if let bool = value as? Bool {
return bool
}
if let number = value as? NSNumber {
return number.boolValue
}
return false
}
}
typealias WatchChatCompletionMessage = OpenClawWatchChatCompletionMessage
typealias WatchChatItem = OpenClawWatchChatItem
typealias WatchAppSnapshotRequestMessage = OpenClawWatchAppSnapshotRequestMessage
typealias WatchAppCommandMessage = OpenClawWatchAppCommandMessage
typealias WatchPromptAction = OpenClawWatchAction
struct WatchNotifyMessage: Codable {
var id: String?
var title: String
var body: String
var sentAtMs: Int64?
var promptId: String?
var sessionKey: String?
var gatewayStableID: String?
var kind: String?
var details: String?
var expiresAtMs: Int64?
var risk: String?
var actions: [WatchPromptAction]
}
struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var approval: WatchExecApprovalItem
var transport: String
var sourceSentAtMs: Int64?
var updatedAt: Date
var isResolving: Bool
var pendingDecision: WatchExecApprovalDecision?
var activeResolutionAttemptID: String?
var status: WatchExecApprovalStatus?
var statusAt: Date?
var id: WatchExecApprovalIdentityKey {
WatchExecApprovalIdentityKey(
gatewayID: WatchOpaqueUTF8Key(self.approval.gatewayStableID ?? ""),
approvalID: WatchOpaqueUTF8Key(self.approval.id))
}
var approvalID: String {
self.approval.id
}
private enum CodingKeys: String, CodingKey {
case approval
case transport
case sourceSentAtMs
case updatedAt
case isResolving
case pendingDecision
case activeResolutionAttemptID
case status
case statusText
case statusAt
}
init(
approval: WatchExecApprovalItem,
transport: String,
sourceSentAtMs: Int64?,
updatedAt: Date,
isResolving: Bool,
pendingDecision: WatchExecApprovalDecision?,
activeResolutionAttemptID: String?,
status: WatchExecApprovalStatus?,
statusAt: Date?)
{
self.approval = approval
self.transport = transport
self.sourceSentAtMs = sourceSentAtMs
self.updatedAt = updatedAt
self.isResolving = isResolving
self.pendingDecision = pendingDecision
self.activeResolutionAttemptID = activeResolutionAttemptID
self.status = status
self.statusAt = statusAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.approval = try container.decode(WatchExecApprovalItem.self, forKey: .approval)
self.transport = try container.decode(String.self, forKey: .transport)
self.sourceSentAtMs = try container.decodeIfPresent(Int64.self, forKey: .sourceSentAtMs)
self.updatedAt = try container.decode(Date.self, forKey: .updatedAt)
self.isResolving = try container.decode(Bool.self, forKey: .isResolving)
self.pendingDecision = try container.decodeIfPresent(
WatchExecApprovalDecision.self,
forKey: .pendingDecision)
self.activeResolutionAttemptID = try container.decodeIfPresent(
String.self,
forKey: .activeResolutionAttemptID)
self.status = try container.decodeIfPresent(
WatchExecApprovalStatus.self,
forKey: .status) ?? Self.decodeLegacyStatus(
container.decodeIfPresent(String.self, forKey: .statusText))
self.statusAt = try container.decodeIfPresent(Date.self, forKey: .statusAt)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.approval, forKey: .approval)
try container.encode(self.transport, forKey: .transport)
try container.encodeIfPresent(self.sourceSentAtMs, forKey: .sourceSentAtMs)
try container.encode(self.updatedAt, forKey: .updatedAt)
try container.encode(self.isResolving, forKey: .isResolving)
try container.encodeIfPresent(self.pendingDecision, forKey: .pendingDecision)
try container.encodeIfPresent(
self.activeResolutionAttemptID,
forKey: .activeResolutionAttemptID)
try container.encodeIfPresent(self.status, forKey: .status)
try container.encodeIfPresent(self.statusAt, forKey: .statusAt)
}
private static func decodeLegacyStatus(_ text: String?) -> WatchExecApprovalStatus? {
text.flatMap(WatchExecApprovalStatus.decodeLegacyLocalizedText)
}
}
extension OpenClawWatchAppStatus {
func localizedText(
localize: (WatchStatusLocalizationKey) -> String = { $0.localized },
localizePresentation: (String, [String]) -> String = { key, arguments in
String(
format: String(localized: String.LocalizationValue(key)),
locale: .current,
arguments: arguments.map { $0 as CVarArg })
}) -> String
{
switch self.code {
case .gatewayConnected,
.gatewayConnecting,
.gatewayReconnecting,
.gatewayOffline,
.gatewayProblem,
.gatewayProblemWithRequestID:
self.localizedGatewayText(
localize: localize,
localizePresentation: localizePresentation)
case .talkOff,
.talkReady,
.talkConnecting,
.talkListening,
.talkThinking,
.talkSpeaking,
.talkOffline,
.talkPermissionRequired,
.talkRequestingApproval,
.talkApprovalRequested,
.talkAPIKeyMissing,
.talkFailure:
self.localizedTalkText(
localize: localize,
localizePresentation: localizePresentation)
case .chatConnectIPhone, .chatNoMessages, .chatUnavailable:
self.localizedChatText(localize: localize)
case .legacy:
self.verbatim ?? localize(.unavailable)
}
}
private func localizedGatewayText(
localize: (WatchStatusLocalizationKey) -> String,
localizePresentation: (String, [String]) -> String) -> String
{
switch self.code {
case .gatewayConnected:
localize(.connected)
case .gatewayConnecting:
localize(.connecting)
case .gatewayReconnecting:
localize(.reconnecting)
case .gatewayOffline:
localize(.offline)
case .gatewayProblem:
self.localizedPresentation(localize: localizePresentation)
case .gatewayProblemWithRequestID:
self.localizedGatewayProblemWithRequestID(
localize: localize,
localizePresentation: localizePresentation)
default:
localize(.unavailable)
}
}
private func localizedTalkText(
localize: (WatchStatusLocalizationKey) -> String,
localizePresentation: (String, [String]) -> String) -> String
{
switch self.code {
case .talkOff:
localize(.off)
case .talkReady:
localize(.ready)
case .talkConnecting:
localize(.connecting)
case .talkListening:
localize(.listening)
case .talkThinking:
localize(.thinking)
case .talkSpeaking:
localize(.speaking)
case .talkOffline:
localize(.offline)
case .talkPermissionRequired:
self.arguments.first.map { String(format: localize(.missingFormat), $0) }
?? localize(.unavailable)
case .talkRequestingApproval:
localize(.requestingApproval)
case .talkApprovalRequested:
localize(.approvalRequested)
case .talkAPIKeyMissing:
localize(.apiKeyMissing)
case .talkFailure:
self.localizedPresentation(localize: localizePresentation)
default:
localize(.unavailable)
}
}
private func localizedChatText(
localize: (WatchStatusLocalizationKey) -> String) -> String
{
switch self.code {
case .chatConnectIPhone:
localize(.connectIPhoneChat)
case .chatNoMessages:
localize(.noChatMessages)
case .chatUnavailable:
localize(.chatUnavailable)
default:
localize(.unavailable)
}
}
private func localizedPresentation(
localize: (String, [String]) -> String) -> String
{
if let verbatim {
return verbatim
}
guard let localizationKey else {
return WatchStatusLocalizationKey.unavailable.localized
}
return localize(localizationKey, self.arguments)
}
private func localizedGatewayProblemWithRequestID(
localize: (WatchStatusLocalizationKey) -> String,
localizePresentation: (String, [String]) -> String) -> String
{
guard let requestID = self.arguments.last else {
return self.localizedPresentation(localize: localizePresentation)
}
let title = if let verbatim {
verbatim
} else if let localizationKey {
localizePresentation(localizationKey, Array(self.arguments.dropLast()))
} else {
localize(.unavailable)
}
return String(
format: localize(.gatewayProblemRequestIDFormat),
title,
requestID)
}
}