From f3000a953fc21e98371ee6ecdac873d4a3f65afd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 12 Jul 2026 13:37:51 +0200 Subject: [PATCH] fix(ios): preserve runtime localization semantics --- .../ActivityWidget/OpenClawLiveActivity.swift | 41 ++++++-- .../Design/AgentProDreamingDestination.swift | 87 +++++++++++----- .../LiveActivity/LiveActivityManager.swift | 72 ++++++++++---- .../OpenClawActivityAttributes.swift | 98 ++++++++++++++++++- .../RuntimeLocalizationSourceGuardTests.swift | 91 +++++++++++++++++ .../OpenClawChatUI/ChatMessageViews.swift | 6 +- 6 files changed, 336 insertions(+), 59 deletions(-) create mode 100644 apps/ios/Tests/RuntimeLocalizationSourceGuardTests.swift diff --git a/apps/ios/ActivityWidget/OpenClawLiveActivity.swift b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift index 863a5cf4fa57..377923fb4515 100644 --- a/apps/ios/ActivityWidget/OpenClawLiveActivity.swift +++ b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift @@ -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 + } } } diff --git a/apps/ios/Sources/Design/AgentProDreamingDestination.swift b/apps/ios/Sources/Design/AgentProDreamingDestination.swift index 2ae23fe30d9a..65147e8a0c6f 100644 --- a/apps/ios/Sources/Design/AgentProDreamingDestination.swift +++ b/apps/ios/Sources/Design/AgentProDreamingDestination.swift @@ -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? { diff --git a/apps/ios/Sources/LiveActivity/LiveActivityManager.swift b/apps/ios/Sources/LiveActivity/LiveActivityManager.swift index 6487042377cc..67d6138c9309 100644 --- a/apps/ios/Sources/LiveActivity/LiveActivityManager.swift +++ b/apps/ios/Sources/LiveActivity/LiveActivityManager.swift @@ -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) } } diff --git a/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift b/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift index c7c0143adcce..9ec347aac54a 100644 --- a/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift +++ b/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift @@ -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) + } } } diff --git a/apps/ios/Tests/RuntimeLocalizationSourceGuardTests.swift b/apps/ios/Tests/RuntimeLocalizationSourceGuardTests.swift new file mode 100644 index 000000000000..acf7373a237d --- /dev/null +++ b/apps/ios/Tests/RuntimeLocalizationSourceGuardTests.swift @@ -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) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index 2a0fda598f4c..dc0ab101017a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -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"