mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 20:21:35 +00:00
1092 lines
38 KiB
Swift
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)
|
|
}
|
|
}
|