fix(ios): preserve runtime localization semantics

This commit is contained in:
Vincent Koc
2026-07-12 13:37:51 +02:00
parent d2f5008939
commit f3000a953f
6 changed files with 336 additions and 59 deletions
@@ -12,7 +12,7 @@ struct OpenClawLiveActivity: Widget {
self.statusDot(state: context.state)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.statusText)
self.statusText(state: context.state)
.font(OpenClawActivityType.subheadSemiBold)
.lineLimit(1)
.minimumScaleFactor(0.8)
@@ -39,7 +39,7 @@ struct OpenClawLiveActivity: Widget {
Text("OpenClaw")
.font(OpenClawActivityType.subheadBold)
.lineLimit(1)
Text(context.state.statusText)
self.statusText(state: context.state)
.font(OpenClawActivityType.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -72,26 +72,47 @@ struct OpenClawLiveActivity: Widget {
@ViewBuilder
private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
if state.isConnecting {
switch state.status {
case .connecting, .reconnecting:
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(OpenClawActivityStyle.info)
} else if state.isDisconnected {
case .disconnected:
Image(systemName: "wifi.slash")
.foregroundStyle(OpenClawActivityStyle.danger)
} else if state.isIdle {
case .idle:
Image(systemName: "checkmark")
.foregroundStyle(OpenClawActivityStyle.ok)
} else {
case .approvalNeeded, .actionRequired, .attention:
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(OpenClawActivityStyle.warn)
}
}
private func statusText(state: OpenClawActivityAttributes.ContentState) -> Text {
if let detail = state.verbatimDetail {
return Text(verbatim: detail)
}
return switch state.status {
case .connecting: Text("Connecting...")
case .reconnecting: Text("Reconnecting...")
case .approvalNeeded: Text("Approval needed")
case .actionRequired, .attention: Text("Action required")
case .idle: Text("Connected")
case .disconnected: Text("Disconnected")
}
}
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
if state.isDisconnected { return OpenClawActivityStyle.danger }
if state.isConnecting { return OpenClawActivityStyle.info }
if state.isIdle { return OpenClawActivityStyle.ok }
return OpenClawActivityStyle.warn
switch state.status {
case .connecting, .reconnecting:
OpenClawActivityStyle.info
case .disconnected:
OpenClawActivityStyle.danger
case .idle:
OpenClawActivityStyle.ok
case .approvalNeeded, .actionRequired, .attention:
OpenClawActivityStyle.warn
}
}
}
@@ -86,7 +86,7 @@ struct AgentProDreamingDestination: View {
self
}
var title: String {
var title: LocalizedStringResource {
switch self {
case .backfill: "Backfill"
case .repair: "Repair"
@@ -194,8 +194,12 @@ struct AgentProDreamingDestination: View {
Button {
Task { await self.runDreamAction(action) }
} label: {
Label(action.title, systemImage: self.dreamActionBusy == action ? "hourglass" : action.icon)
.font(OpenClawType.captionSemiBold)
Label {
Text(action.title)
.font(OpenClawType.captionSemiBold)
} icon: {
Image(systemName: self.dreamActionBusy == action ? "hourglass" : action.icon)
}
}
.buttonStyle(.bordered)
.controlSize(.small)
@@ -430,7 +434,7 @@ struct AgentProDreamingDestination: View {
let phases = self.overview?.dreaming?.phases ?? [:]
return phaseOrder.compactMap { id in
guard let phase = phases[id] else { return nil }
return DreamingPhaseRow(id: id, title: id.capitalized, status: phase)
return DreamingPhaseRow(id: id, title: Self.dreamingPhaseTitle(id), status: phase)
}
}
@@ -501,33 +505,49 @@ struct AgentProDreamingDestination: View {
}
private func dreamingEntryDetail(_ entry: DreamingEntryLite) -> String {
let recallCount = entry.recallCount
let groundedCount = entry.groundedCount
let parts = [
entry.promotedAt.map { "promoted \($0)" },
entry.lastRecalledAt.map { "recalled \($0)" },
"\(entry.recallCount) recalls",
"\(entry.groundedCount) grounded",
entry.promotedAt.map {
String(format: String(localized: "promoted %@"), $0)
},
entry.lastRecalledAt.map {
String(format: String(localized: "recalled %@"), $0)
},
String(AttributedString(localized: "^[\(recallCount) recall](inflect: true)").characters),
String(
format: String(localized: "%@ grounded"),
groundedCount.formatted()),
].compactMap(\.self)
return parts.joined(separator: "")
}
private func dreamingPhaseDetail(_ phase: DreamingPhaseStatusLite) -> String {
if let nextRunAtMs = phase.nextRunAtMs {
return "Next cycle \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
return String(
format: String(localized: "Next cycle %@"),
Self.relativeTime(fromMilliseconds: nextRunAtMs))
}
if phase.managedCronPresent == true {
return "Managed cron is installed."
return String(localized: "Managed cron is installed.")
}
return "Managed cron is not installed."
return String(localized: "Managed cron is not installed.")
}
private func dreamingPhaseState(_ phase: DreamingPhaseStatusLite) -> String {
if phase.enabled == false { return "off" }
return phase.managedCronPresent == true ? "scheduled" : "setup"
if phase.enabled == false { return String(localized: "off") }
return phase.managedCronPresent == true
? String(localized: "scheduled")
: String(localized: "setup")
}
private func dreamDiaryUpdatedLabel(_ diary: DreamDiaryLite) -> String {
guard let updatedAtMs = diary.updatedAtMs else { return "No update timestamp" }
return "Updated \(Self.relativeTime(fromMilliseconds: updatedAtMs))"
guard let updatedAtMs = diary.updatedAtMs else {
return String(localized: "No update timestamp")
}
return String(
format: String(localized: "Updated %@"),
Self.relativeTime(fromMilliseconds: updatedAtMs))
}
@MainActor
@@ -550,23 +570,46 @@ struct AgentProDreamingDestination: View {
}
private static func dreamActionSummary(action: DreamAction, data: Data) -> String {
let actionTitle = String(localized: action.title)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return "\(action.title) complete."
return String(format: String(localized: "%@ complete."), actionTitle)
}
let written = json["written"] as? Int
let replaced = json["replaced"] as? Int
let removed = json["removedEntries"] as? Int
let changed = json["changed"] as? Bool
let parts = [
written.map { "\($0) written" },
replaced.map { "\($0) replaced" },
removed.map { "\($0) removed" },
changed.map { $0 ? "artifacts repaired" : "no repair needed" },
written.map { count in
String(format: String(localized: "%@ written"), count.formatted())
},
replaced.map { count in
String(format: String(localized: "%@ replaced"), count.formatted())
},
removed.map { count in
String(format: String(localized: "%@ removed"), count.formatted())
},
changed.map {
$0
? String(localized: "artifacts repaired")
: String(localized: "no repair needed")
},
].compactMap(\.self)
if parts.isEmpty {
return "\(action.title) complete."
return String(format: String(localized: "%@ complete."), actionTitle)
}
return String(
format: String(localized: "%@: %@."),
actionTitle,
parts.formatted(.list(type: .and, width: .short)))
}
private static func dreamingPhaseTitle(_ id: String) -> String {
switch id {
case "light": String(localized: "Light")
case "deep": String(localized: "Deep")
case "rem": String(localized: "REM")
default: id
}
return "\(action.title): \(parts.joined(separator: ", "))."
}
private func normalized(_ value: String?) -> String? {
@@ -22,10 +22,11 @@ final class LiveActivityManager {
agentName: String,
sessionKey: String)
{
let presentation = Self.connectingPresentation(statusText: statusText)
self.hydrateCurrentAndPruneDuplicates()
if self.currentActivity != nil {
self.handleConnecting(statusText: statusText)
self.handleConnecting(presentation: presentation)
return
}
@@ -37,7 +38,7 @@ final class LiveActivityManager {
self.activityStartDate = .now
let attributes = OpenClawActivityAttributes(agentName: agentName, sessionKey: sessionKey)
let state = self.connectingState(statusText: statusText)
let state = self.connectingState(presentation: presentation)
do {
let activity = try Activity.request(
@@ -54,6 +55,7 @@ final class LiveActivityManager {
}
func showAttention(statusText: String, agentName: String, sessionKey: String) {
let presentation = Self.attentionPresentation(statusText: statusText)
self.hydrateCurrentAndPruneDuplicates()
if self.currentActivity == nil {
@@ -67,7 +69,7 @@ final class LiveActivityManager {
do {
let activity = try Activity.request(
attributes: attributes,
content: ActivityContent(state: self.attentionState(statusText: statusText), staleDate: nil),
content: ActivityContent(state: self.attentionState(presentation: presentation), staleDate: nil),
pushType: nil)
self.currentActivity = activity
self.logger.info("started attention live activity id=\(activity.id, privacy: .public)")
@@ -78,12 +80,16 @@ final class LiveActivityManager {
return
}
self.updateCurrent(state: self.attentionState(statusText: statusText), staleDate: nil)
self.updateCurrent(state: self.attentionState(presentation: presentation), staleDate: nil)
}
func handleConnecting(statusText: String = String(localized: "Connecting...")) {
self.handleConnecting(presentation: Self.connectingPresentation(statusText: statusText))
}
private func handleConnecting(presentation: StatusPresentation) {
self.updateCurrent(
state: self.connectingState(statusText: statusText),
state: self.connectingState(presentation: presentation),
staleDate: Date().addingTimeInterval(self.connectingStaleSeconds))
}
@@ -113,7 +119,7 @@ final class LiveActivityManager {
let candidates = active.filter { activity in
let state = activity.content.state
guard activity.activityState == .active else { return false }
guard !state.isIdle, !state.isDisconnected else { return false }
guard state.status != .idle, state.status != .disconnected else { return false }
return now.timeIntervalSince(state.startedAt) < self.hydrationStaleSeconds
}
@@ -156,30 +162,56 @@ final class LiveActivityManager {
}
}
private func connectingState(statusText: String = "Connecting...") -> OpenClawActivityAttributes.ContentState {
private struct StatusPresentation {
let status: OpenClawActivityAttributes.ContentState.Status
let verbatimDetail: String?
}
// Existing callers still pass rendered app copy. Collapse known values here so
// ActivityKit persists semantics; only unknown external detail remains verbatim.
private static func connectingPresentation(statusText: String) -> StatusPresentation {
if statusText == String(localized: "Connecting...") || statusText == "Connecting..." {
return StatusPresentation(status: .connecting, verbatimDetail: nil)
}
if statusText == String(localized: "Reconnecting...") || statusText == "Reconnecting..." {
return StatusPresentation(status: .reconnecting, verbatimDetail: nil)
}
return StatusPresentation(status: .connecting, verbatimDetail: self.normalizedDetail(statusText))
}
private static func attentionPresentation(statusText: String) -> StatusPresentation {
if statusText == String(localized: "Approval needed") || statusText == "Approval needed" {
return StatusPresentation(status: .approvalNeeded, verbatimDetail: nil)
}
if statusText == String(localized: "Action required") || statusText == "Action required" {
return StatusPresentation(status: .actionRequired, verbatimDetail: nil)
}
return StatusPresentation(status: .attention, verbatimDetail: self.normalizedDetail(statusText))
}
private static func normalizedDetail(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func connectingState(presentation: StatusPresentation) -> OpenClawActivityAttributes.ContentState {
OpenClawActivityAttributes.ContentState(
statusText: statusText,
isIdle: false,
isDisconnected: false,
isConnecting: true,
status: presentation.status,
verbatimDetail: presentation.verbatimDetail,
startedAt: self.activityStartDate)
}
private func attentionState(statusText: String) -> OpenClawActivityAttributes.ContentState {
private func attentionState(presentation: StatusPresentation) -> OpenClawActivityAttributes.ContentState {
OpenClawActivityAttributes.ContentState(
statusText: statusText,
isIdle: false,
isDisconnected: false,
isConnecting: false,
status: presentation.status,
verbatimDetail: presentation.verbatimDetail,
startedAt: self.activityStartDate)
}
private func disconnectedState() -> OpenClawActivityAttributes.ContentState {
OpenClawActivityAttributes.ContentState(
statusText: String(localized: "Disconnected"),
isIdle: false,
isDisconnected: true,
isConnecting: false,
status: .disconnected,
verbatimDetail: nil,
startedAt: self.activityStartDate)
}
}
@@ -7,10 +7,100 @@ struct OpenClawActivityAttributes: ActivityAttributes {
var sessionKey: String
struct ContentState: Codable, Hashable {
var statusText: String
var isIdle: Bool
var isDisconnected: Bool
var isConnecting: Bool
enum Status: String, CaseIterable, Codable, Hashable {
case connecting
case reconnecting
case approvalNeeded
case actionRequired
case attention
case idle
case disconnected
}
var status: Status
var verbatimDetail: String?
var startedAt: Date
private enum CodingKeys: String, CodingKey {
case status
case verbatimDetail
case startedAt
}
private enum LegacyCodingKeys: String, CodingKey {
case statusText
case isIdle
case isDisconnected
case isConnecting
}
init(status: Status, verbatimDetail: String?, startedAt: Date) {
self.status = status
self.verbatimDetail = verbatimDetail
self.startedAt = startedAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.startedAt = try container.decode(Date.self, forKey: .startedAt)
if let status = try container.decodeIfPresent(Status.self, forKey: .status) {
self.status = status
self.verbatimDetail = try container.decodeIfPresent(String.self, forKey: .verbatimDetail)
return
}
// Live Activities can outlive an app update. Decode the shipped boolean
// schema once, then all new writes use the semantic status shape.
let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self)
let statusText = try legacy.decodeIfPresent(String.self, forKey: .statusText)
let presentation = Self.legacyPresentation(
statusText: statusText,
isIdle: try legacy.decodeIfPresent(Bool.self, forKey: .isIdle) ?? false,
isDisconnected: try legacy.decodeIfPresent(Bool.self, forKey: .isDisconnected) ?? false,
isConnecting: try legacy.decodeIfPresent(Bool.self, forKey: .isConnecting) ?? false)
self.status = presentation.status
self.verbatimDetail = presentation.verbatimDetail
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.status, forKey: .status)
try container.encodeIfPresent(self.verbatimDetail, forKey: .verbatimDetail)
try container.encode(self.startedAt, forKey: .startedAt)
}
private static func legacyPresentation(
statusText: String?,
isIdle: Bool,
isDisconnected: Bool,
isConnecting: Bool) -> (status: Status, verbatimDetail: String?)
{
if isDisconnected {
return (.disconnected, nil)
}
if isIdle {
return (.idle, nil)
}
let trimmed = statusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let detail = trimmed.isEmpty ? nil : trimmed
if isConnecting {
if detail == String(localized: "Reconnecting...") || detail == "Reconnecting..." {
return (.reconnecting, nil)
}
if detail == String(localized: "Connecting...") || detail == "Connecting..." {
return (.connecting, nil)
}
return (.connecting, detail)
}
if detail == String(localized: "Approval needed") || detail == "Approval needed" {
return (.approvalNeeded, nil)
}
if detail == String(localized: "Action required") || detail == "Action required" {
return (.actionRequired, nil)
}
return (.attention, detail)
}
}
}
@@ -0,0 +1,91 @@
import Foundation
import Testing
@testable import OpenClaw
struct RuntimeLocalizationSourceGuardTests {
@Test func liveActivityStatePersistsSemanticsAndExternalDetail() throws {
for status in OpenClawActivityAttributes.ContentState.Status.allCases {
let state = OpenClawActivityAttributes.ContentState(
status: status,
verbatimDetail: status == .attention ? "Backend supplied detail" : nil,
startedAt: Date(timeIntervalSince1970: 1_234))
let data = try JSONEncoder().encode(state)
let decoded = try JSONDecoder().decode(OpenClawActivityAttributes.ContentState.self, from: data)
#expect(decoded == state)
}
}
@Test func liveActivityStateDecodesShippedLegacyPayloads() throws {
let cases: [(LegacyContentState, OpenClawActivityAttributes.ContentState.Status, String?)] = [
(LegacyContentState(statusText: "Disconnected", isDisconnected: true), .disconnected, nil),
(LegacyContentState(statusText: "Idle", isIdle: true), .idle, nil),
(LegacyContentState(statusText: "Reconnecting...", isConnecting: true), .reconnecting, nil),
(LegacyContentState(statusText: "Approval needed"), .approvalNeeded, nil),
(LegacyContentState(statusText: "Backend supplied attention"), .attention, "Backend supplied attention"),
(
LegacyContentState(statusText: "Backend supplied connection detail", isConnecting: true),
.connecting,
"Backend supplied connection detail"),
]
for (legacy, expectedStatus, expectedDetail) in cases {
let data = try JSONEncoder().encode(legacy)
let decoded = try JSONDecoder().decode(OpenClawActivityAttributes.ContentState.self, from: data)
#expect(decoded.status == expectedStatus)
#expect(decoded.verbatimDetail == expectedDetail)
#expect(decoded.startedAt == legacy.startedAt)
}
}
@Test func runtimeOwnedCopyRemainsLocalizableAtRenderTime() throws {
let attributes = try Self.source("Sources/LiveActivity/OpenClawActivityAttributes.swift")
let manager = try Self.source("Sources/LiveActivity/LiveActivityManager.swift")
let widget = try Self.source("ActivityWidget/OpenClawLiveActivity.swift")
let dreaming = try Self.source("Sources/Design/AgentProDreamingDestination.swift")
let chat = try Self.sharedSource("OpenClawChatUI/ChatMessageViews.swift")
#expect(!attributes.contains("var statusText"))
#expect(attributes.contains("var status: Status"))
#expect(attributes.contains("var verbatimDetail: String?"))
#expect(attributes.contains("private enum LegacyCodingKeys"))
#expect(manager.contains("status: .disconnected"))
#expect(!manager.contains("statusText: String(localized: \"Disconnected\")"))
#expect(widget.contains("Text(verbatim: detail)"))
#expect(widget.contains("case .reconnecting: Text(\"Reconnecting...\")"))
#expect(dreaming.contains("AttributedString(localized: \"^[\\(recallCount) recall](inflect: true)\""))
#expect(dreaming.contains("format: String(localized: \"%@ grounded\")"))
#expect(dreaming.contains("parts.formatted(.list(type: .and, width: .short))"))
#expect(chat.contains("private var title: LocalizedStringResource"))
#expect(chat.contains("private var accessibilityText: LocalizedStringResource"))
#expect(chat.contains(".accessibilityLabel(Text(self.accessibilityText))"))
}
private static func source(_ path: String) throws -> String {
try String(
contentsOf: Self.iosRoot.appendingPathComponent(path),
encoding: .utf8)
}
private static func sharedSource(_ path: String) throws -> String {
try String(
contentsOf: Self.iosRoot
.deletingLastPathComponent()
.appendingPathComponent("shared/OpenClawKit/Sources")
.appendingPathComponent(path),
encoding: .utf8)
}
private static let iosRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
private struct LegacyContentState: Encodable {
let statusText: String
var isIdle = false
var isDisconnected = false
var isConnecting = false
var startedAt = Date(timeIntervalSince1970: 1_234)
}
}
@@ -714,10 +714,10 @@ struct ChatOutboxStatusLabel: View {
}
.foregroundStyle(self.state.isFailed ? AnyShapeStyle(OpenClawChatTheme.danger) : AnyShapeStyle(.secondary))
.accessibilityElement(children: .combine)
.accessibilityLabel(self.accessibilityText)
.accessibilityLabel(Text(self.accessibilityText))
}
private var title: String {
private var title: LocalizedStringResource {
switch self.state {
case .queued:
"Queued"
@@ -747,7 +747,7 @@ struct ChatOutboxStatusLabel: View {
}
}
private var accessibilityText: String {
private var accessibilityText: LocalizedStringResource {
switch self.state {
case .queued:
"Queued, sends when reconnected"