fix(ios): preserve localized runtime semantics

This commit is contained in:
Vincent Koc
2026-07-12 14:12:30 +02:00
parent 35a336f9db
commit cf8ae716ff
8 changed files with 270 additions and 117 deletions
@@ -82,6 +82,8 @@ extension AgentProTab {
}
private static func tokenCountText(_ count: Int) -> String {
String(AttributedString(localized: "^[\(count) token](inflect: true)").characters)
String(
format: String(localized: "%@ tokens"),
self.compactNumber(count))
}
}
@@ -1287,13 +1287,13 @@ extension GatewayConnectionController {
Verify Tailscale Serve is enabled and publishes this Gateway.
"""),
host,
port.formatted())
String(port))
} else {
String(
format: String(
localized: "Can't reach gateway at %1$@:%2$@. Check Tailscale or LAN."),
host,
port.formatted())
String(port))
}
case .tlsHandshakeTimeout:
String(
@@ -1302,7 +1302,7 @@ extension GatewayConnectionController {
Secure endpoint was reached, but TLS did not finish in time.
"""),
host,
port.formatted())
String(port))
case .tlsUnavailable:
String(
format: String(localized: """
@@ -1311,13 +1311,13 @@ extension GatewayConnectionController {
with Unencrypted selected.
"""),
host,
port.formatted())
String(port))
case .certificateUnavailable:
String(
format: String(
localized: "Could not read the TLS certificate from %1$@:%2$@."),
host,
port.formatted())
String(port))
}
}
+14
View File
@@ -6037,6 +6037,12 @@ extension NodeAppModel {
if let problem = self.lastGatewayProblem {
return Self.makeWatchGatewayProblemStatus(problem)
}
let statusText = self.gatewayStatusText == "Connected"
? self.operatorStatusText
: self.gatewayStatusText
if statusText != "Offline" {
return OpenClawWatchAppStatus(code: .legacy, verbatim: statusText)
}
return OpenClawWatchAppStatus(code: .gatewayOffline)
}
@@ -6100,6 +6106,14 @@ extension NodeAppModel {
case .apiKeyMissing:
return OpenClawWatchAppStatus(code: .talkAPIKeyMissing)
}
switch self.talkMode.watchPresentation {
case let .localized(key):
return OpenClawWatchAppStatus(code: .talkFailure, localizationKey: key)
case .phase:
break
case let .verbatim(value):
return OpenClawWatchAppStatus(code: .talkFailure, verbatim: value)
}
return switch self.talkMode.phase {
case .connecting:
OpenClawWatchAppStatus(code: .talkConnecting)
@@ -4,6 +4,37 @@ import Photos
import SwiftUI
import UIKit
private enum PrivacyPermissionStatus {
case addOnly
case allowed
case limited
case notAllowed
case notSet
case unknown
var resource: LocalizedStringResource {
switch self {
case .addOnly: LocalizedStringResource("Add-Only")
case .allowed: LocalizedStringResource("Allowed")
case .limited: LocalizedStringResource("Limited")
case .notAllowed: LocalizedStringResource("Not Allowed")
case .notSet: LocalizedStringResource("Not Set")
case .unknown: LocalizedStringResource("Unknown")
}
}
var tone: OpenClawStatusTone {
switch self {
case .allowed, .limited:
.ok
case .addOnly, .notSet:
.warn
case .notAllowed, .unknown:
.danger
}
}
}
struct PrivacyAccessSectionView: View {
@Environment(GatewayConnectionController.self) private var gatewayController
@State private var contactsStatus: CNAuthorizationStatus = CNContactStore.authorizationStatus(for: .contacts)
@@ -16,41 +47,46 @@ struct PrivacyAccessSectionView: View {
var body: some View {
DisclosureGroup {
self.permissionRow(
identifier: "contacts",
title: "Contacts",
icon: "person.crop.circle",
status: self.statusText(for: self.contactsStatus),
status: self.permissionStatus(for: self.contactsStatus),
detail: "Search and add contacts from the assistant.",
actionTitle: self.actionTitle(for: self.contactsStatus),
action: self.handleContactsAction)
self.permissionRow(
identifier: "photos",
title: "Photos",
icon: "photo.on.rectangle",
status: self.photosStatusText,
status: self.photosPermissionStatus,
detail: self.photosDetail,
actionTitle: self.photosActionTitle,
action: self.handlePhotosAction)
self.permissionRow(
identifier: "calendar-add",
title: "Calendar (Add Events)",
icon: "calendar.badge.plus",
status: self.calendarWriteStatusText,
status: self.calendarWritePermissionStatus,
detail: "Add events with least privilege.",
actionTitle: self.calendarWriteActionTitle,
action: self.handleCalendarWriteAction)
self.permissionRow(
identifier: "calendar-view",
title: "Calendar (View Events)",
icon: "calendar",
status: self.calendarReadStatusText,
status: self.calendarReadPermissionStatus,
detail: "List and read calendar events.",
actionTitle: self.calendarReadActionTitle,
action: self.handleCalendarReadAction)
self.permissionRow(
identifier: "reminders",
title: "Reminders",
icon: "checklist",
status: self.remindersStatusText,
status: self.remindersPermissionStatus,
detail: "List, add, and complete reminders.",
actionTitle: self.remindersActionTitle,
action: self.handleRemindersAction)
@@ -68,102 +104,96 @@ struct PrivacyAccessSectionView: View {
}
private func permissionRow(
title: String,
identifier: String,
title: LocalizedStringResource,
icon: String,
status: String,
detail: String,
actionTitle: String?,
status: PrivacyPermissionStatus,
detail: LocalizedStringResource,
actionTitle: LocalizedStringResource?,
action: (() -> Void)?) -> some View
{
VStack(alignment: .leading, spacing: 6) {
HStack {
Label(LocalizedStringKey(title), systemImage: icon)
Label {
Text(title)
} icon: {
Image(systemName: icon)
}
.font(OpenClawType.subheadSemiBold)
Spacer()
OpenClawStatusBadge(label: .localized(status), tone: self.statusTone(for: status))
.accessibilityIdentifier("privacy-access-\(title)-status")
OpenClawStatusBadge(
label: .verbatim(String(localized: status.resource)),
tone: status.tone)
.accessibilityIdentifier("privacy-access-\(identifier)-status")
}
Text(LocalizedStringKey(detail))
Text(detail)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
if let actionTitle, let action {
Button(action: action) {
Text(LocalizedStringKey(actionTitle))
Text(actionTitle)
.font(OpenClawType.footnoteSemiBold)
}
.buttonStyle(.bordered)
.accessibilityIdentifier("privacy-access-\(title)-action")
.accessibilityIdentifier("privacy-access-\(identifier)-action")
}
}
.padding(.vertical, 2)
}
private func statusTone(for status: String) -> OpenClawStatusTone {
switch status {
case "Allowed", "Limited":
.ok
case "Not Set":
.warn
case "Add-Only":
.warn
default:
.danger
}
}
private func statusText(for cnStatus: CNAuthorizationStatus) -> String {
private func permissionStatus(for cnStatus: CNAuthorizationStatus) -> PrivacyPermissionStatus {
switch cnStatus {
case .authorized, .limited:
"Allowed"
.allowed
case .notDetermined:
"Not Set"
.notSet
case .denied, .restricted:
"Not Allowed"
.notAllowed
@unknown default:
"Unknown"
.unknown
}
}
private func actionTitle(for cnStatus: CNAuthorizationStatus) -> String? {
private func actionTitle(for cnStatus: CNAuthorizationStatus) -> LocalizedStringResource? {
switch cnStatus {
case .notDetermined:
"Request Access"
LocalizedStringResource("Request Access")
case .denied, .restricted:
"Open Settings"
LocalizedStringResource("Open Settings")
default:
nil
}
}
private var photosStatusText: String {
private var photosPermissionStatus: PrivacyPermissionStatus {
switch self.photosStatus {
case .authorized:
"Allowed"
.allowed
case .limited:
"Limited"
.limited
case .notDetermined:
"Not Set"
.notSet
case .denied, .restricted:
"Not Allowed"
.notAllowed
@unknown default:
"Unknown"
.unknown
}
}
private var photosDetail: String {
private var photosDetail: LocalizedStringResource {
self.photosStatus == .limited
? "Read photos you select for the assistant."
: "Read recent photos for the assistant."
? LocalizedStringResource("Read photos you select for the assistant.")
: LocalizedStringResource("Read recent photos for the assistant.")
}
private var photosActionTitle: String? {
private var photosActionTitle: LocalizedStringResource? {
switch self.photosStatus {
case .notDetermined:
"Request Access"
LocalizedStringResource("Request Access")
case .limited:
"Manage Access"
LocalizedStringResource("Manage Access")
case .denied, .restricted:
"Open Settings"
LocalizedStringResource("Open Settings")
default:
nil
}
@@ -207,25 +237,25 @@ struct PrivacyAccessSectionView: View {
}
}
private var calendarWriteStatusText: String {
private var calendarWritePermissionStatus: PrivacyPermissionStatus {
switch self.calendarStatus {
case .authorized, .fullAccess, .writeOnly:
"Allowed"
.allowed
case .notDetermined:
"Not Set"
.notSet
case .denied, .restricted:
"Not Allowed"
.notAllowed
@unknown default:
"Unknown"
.unknown
}
}
private var calendarWriteActionTitle: String? {
private var calendarWriteActionTitle: LocalizedStringResource? {
switch self.calendarStatus {
case .notDetermined:
"Request Access"
LocalizedStringResource("Request Access")
case .denied, .restricted:
"Open Settings"
LocalizedStringResource("Open Settings")
default:
nil
}
@@ -250,29 +280,29 @@ struct PrivacyAccessSectionView: View {
}
}
private var calendarReadStatusText: String {
private var calendarReadPermissionStatus: PrivacyPermissionStatus {
switch self.calendarStatus {
case .authorized, .fullAccess:
"Allowed"
.allowed
case .writeOnly:
"Add-Only"
.addOnly
case .notDetermined:
"Not Set"
.notSet
case .denied, .restricted:
"Not Allowed"
.notAllowed
@unknown default:
"Unknown"
.unknown
}
}
private var calendarReadActionTitle: String? {
private var calendarReadActionTitle: LocalizedStringResource? {
switch self.calendarStatus {
case .notDetermined:
"Request Full Access"
LocalizedStringResource("Request Full Access")
case .writeOnly:
"Upgrade to Full Access"
LocalizedStringResource("Upgrade to Full Access")
case .denied, .restricted:
"Open Settings"
LocalizedStringResource("Open Settings")
default:
nil
}
@@ -297,29 +327,29 @@ struct PrivacyAccessSectionView: View {
}
}
private var remindersStatusText: String {
private var remindersPermissionStatus: PrivacyPermissionStatus {
switch self.remindersStatus {
case .authorized, .fullAccess:
"Allowed"
.allowed
case .writeOnly:
"Add-Only"
.addOnly
case .notDetermined:
"Not Set"
.notSet
case .denied, .restricted:
"Not Allowed"
.notAllowed
@unknown default:
"Unknown"
.unknown
}
}
private var remindersActionTitle: String? {
private var remindersActionTitle: LocalizedStringResource? {
switch self.remindersStatus {
case .notDetermined:
"Request Access"
LocalizedStringResource("Request Access")
case .writeOnly:
"Upgrade to Full Access"
LocalizedStringResource("Upgrade to Full Access")
case .denied, .restricted:
"Open Settings"
LocalizedStringResource("Open Settings")
default:
nil
}
+89 -33
View File
@@ -41,6 +41,12 @@ enum TalkPhase: Equatable {
}
}
enum TalkWatchPresentation: Equatable {
case localized(String)
case phase
case verbatim(String)
}
private struct FinishingPushToTalk {
let captureId: String
let generation: UInt64
@@ -68,7 +74,11 @@ private struct ChatCompletionResult {
private final class TranscriptStreamingOwner {
var task: Task<Void, Never>?
var speechGeneration: Int?
var terminalStatus: (text: String, phase: TalkPhase)?
var terminalStatus: (
text: String,
phase: TalkPhase,
watchPresentation: TalkWatchPresentation
)?
/// Subscribed before chat.send so a fast terminal cannot outrun its owner.
var completionEvents: AsyncStream<EventFrame>?
}
@@ -129,6 +139,7 @@ final class TalkModeManager: NSObject {
var isUserSpeechDetected: Bool = false
var isPushToTalkActive: Bool = false
private(set) var phase: TalkPhase = .idle
private(set) var watchPresentation: TalkWatchPresentation = .phase
var statusText: String = "Off" {
didSet {
self.statusRevision &+= 1
@@ -328,8 +339,13 @@ final class TalkModeManager: NSObject {
}
@discardableResult
private func setStatus(_ text: String, phase: TalkPhase) -> UInt64 {
private func setStatus(
_ text: String,
phase: TalkPhase,
watchPresentation: TalkWatchPresentation = .phase) -> UInt64
{
self.phase = phase
self.watchPresentation = watchPresentation
self.statusText = text
return self.statusRevision
}
@@ -639,7 +655,10 @@ final class TalkModeManager: NSObject {
+ "elapsedMs=\(Self.elapsedMs(since: permissionStartedAt))")
guard micOk else {
self.logger.warning("start blocked: microphone permission denied")
self.setStatus(String(localized: "Microphone permission denied"), phase: .idle)
self.setStatus(
String(localized: "Microphone permission denied"),
phase: .idle,
watchPresentation: .localized("Microphone permission denied"))
return
}
guard self.isCurrentStartAttempt(attemptID) else { return }
@@ -714,7 +733,10 @@ final class TalkModeManager: NSObject {
guard self.captureMode != .pushToTalk else { return false }
guard self.finishingPushToTalk == nil else { return false }
guard self.foregroundAudioCaptureAllowed else {
self.setStatus(String(localized: "Paused"), phase: .idle)
self.setStatus(
String(localized: "Paused"),
phase: .idle,
watchPresentation: .localized("Paused"))
GatewayDiagnostics.log("talk start ignored: app backgrounded")
return false
}
@@ -844,7 +866,10 @@ final class TalkModeManager: NSObject {
self.isListening = false
self.isPushToTalkActive = false
self.captureMode = .idle
self.setStatus(String(localized: "Paused"), phase: .idle)
self.setStatus(
String(localized: "Paused"),
phase: .idle,
watchPresentation: .localized("Paused"))
self.gatewayTalkActiveModeTitle = "Paused"
self.gatewayTalkActiveModeSubtitle = nil
self.lastTranscript = ""
@@ -974,7 +999,10 @@ final class TalkModeManager: NSObject {
let micOk = await Self.requestMicrophonePermission()
try self.ensurePushToTalkStartCurrent(captureId: captureId, canStartCapture: canStartCapture)
guard micOk else {
self.setStatus(String(localized: "Microphone permission denied"), phase: .idle)
self.setStatus(
String(localized: "Microphone permission denied"),
phase: .idle,
watchPresentation: .localized("Microphone permission denied"))
throw NSError(domain: "TalkMode", code: 4, userInfo: [
NSLocalizedDescriptionKey: "Microphone permission denied",
])
@@ -1016,11 +1044,13 @@ final class TalkModeManager: NSObject {
if isCancelled {
self.setStatus(String(localized: "Ready"), phase: .idle)
} else if !isPermissionError {
let status = String(
format: String(localized: "Start failed: %@"),
error.localizedDescription)
self.setStatus(
String(
format: String(localized: "Start failed: %@"),
error.localizedDescription),
phase: .idle)
status,
phase: .idle,
watchPresentation: .verbatim(status))
}
}
let shouldResume = self.isEnabled
@@ -1524,7 +1554,8 @@ final class TalkModeManager: NSObject {
msg)
self.speechErrorStatusRevisionPendingRestart = self.setStatus(
errorStatus,
phase: .idle)
phase: .idle,
watchPresentation: .verbatim(errorStatus))
}
} else {
let errorStatus = String(
@@ -1532,7 +1563,8 @@ final class TalkModeManager: NSObject {
msg)
self.speechErrorStatusRevisionPendingRestart = self.setStatus(
errorStatus,
phase: .idle)
phase: .idle,
watchPresentation: .verbatim(errorStatus))
}
}
self.logger.debug("speech recognition error: \(msg, privacy: .public)")
@@ -1797,7 +1829,10 @@ final class TalkModeManager: NSObject {
if self.isCurrentTranscriptProcessing(generation),
let terminalStatus = streamingOwner.terminalStatus
{
self.setStatus(terminalStatus.text, phase: terminalStatus.phase)
self.setStatus(
terminalStatus.text,
phase: terminalStatus.phase,
watchPresentation: terminalStatus.watchPresentation)
}
let shouldResume = restartAfter &&
self.isEnabled &&
@@ -1876,7 +1911,8 @@ final class TalkModeManager: NSObject {
normalizedStatus == "error"
? String(localized: "Chat error")
: String(localized: "Aborted"),
.idle)
.idle,
.localized(normalizedStatus == "error" ? "Chat error" : "Aborted"))
self.logger.warning(
"""
chat.send terminal ack runId=\(runId, privacy: .public) \
@@ -1897,17 +1933,16 @@ final class TalkModeManager: NSObject {
else { return }
guard self.isCurrentTranscriptProcessing(generation) else { return }
if completedSuccessfully, !self.isEnabled {
streamingOwner.terminalStatus = (String(localized: "Ready"), .idle)
streamingOwner.terminalStatus = (String(localized: "Ready"), .idle, .phase)
}
} catch is CancellationError {
return
} catch {
guard self.isCurrentTranscriptProcessing(generation) else { return }
streamingOwner.terminalStatus = (
String(
format: String(localized: "Talk failed: %@"),
error.localizedDescription),
.idle)
let status = String(
format: String(localized: "Talk failed: %@"),
error.localizedDescription)
streamingOwner.terminalStatus = (status, .idle, .verbatim(status))
self.logger.error("finalize failed: \(error.localizedDescription, privacy: .public)")
GatewayDiagnostics.log("talk: failed error=\(error.localizedDescription)")
}
@@ -1964,7 +1999,10 @@ final class TalkModeManager: NSObject {
streamingOwner.task?.cancel()
await self.finishIncrementalSpeech()
guard self.isCurrentTranscriptProcessing(generation) else { return nil }
streamingOwner.terminalStatus = (String(localized: "Aborted"), .idle)
streamingOwner.terminalStatus = (
String(localized: "Aborted"),
.idle,
.localized("Aborted"))
return nil
} else if completion.state == .error {
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
@@ -1972,7 +2010,10 @@ final class TalkModeManager: NSObject {
streamingOwner.task?.cancel()
await self.finishIncrementalSpeech()
guard self.isCurrentTranscriptProcessing(generation) else { return nil }
streamingOwner.terminalStatus = (String(localized: "Chat error"), .idle)
streamingOwner.terminalStatus = (
String(localized: "Chat error"),
.idle,
.localized("Chat error"))
return nil
}
}
@@ -2000,7 +2041,10 @@ final class TalkModeManager: NSObject {
streamingOwner.task?.cancel()
await self.finishIncrementalSpeech()
guard self.isCurrentTranscriptProcessing(generation) else { return nil }
streamingOwner.terminalStatus = (String(localized: "No reply"), .idle)
streamingOwner.terminalStatus = (
String(localized: "No reply"),
.idle,
.localized("No reply"))
return nil
}
self.logger.info("assistant text ok chars=\(assistantText.count, privacy: .public)")
@@ -2089,7 +2133,10 @@ final class TalkModeManager: NSObject {
return .unavailable(realtimeIssue(message: "Gateway not connected", phase: "start"))
}
guard self.foregroundAudioCaptureAllowed else {
self.setStatus(String(localized: "Paused"), phase: .idle)
self.setStatus(
String(localized: "Paused"),
phase: .idle,
watchPresentation: .localized("Paused"))
GatewayDiagnostics.log("talk realtime ignored: app backgrounded")
return .ignored
}
@@ -2539,11 +2586,13 @@ final class TalkModeManager: NSObject {
try await self.playSystemVoice(text: cleaned, language: language)
} catch {
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
let status = String(
format: String(localized: "Speak failed: %@"),
error.localizedDescription)
self.setStatus(
String(
format: String(localized: "Speak failed: %@"),
error.localizedDescription),
phase: .idle)
status,
phase: .idle,
watchPresentation: .verbatim(status))
self.logger.error("system voice failed: \(error.localizedDescription, privacy: .public)")
}
}
@@ -2642,11 +2691,13 @@ final class TalkModeManager: NSObject {
try await self.playSystemVoice(text: cleaned, language: language)
} catch {
guard !Task.isCancelled, self.speechGeneration == speechGeneration else { return }
let status = String(
format: String(localized: "Speak failed: %@"),
error.localizedDescription)
self.setStatus(
String(
format: String(localized: "Speak failed: %@"),
error.localizedDescription),
phase: .idle)
status,
phase: .idle,
watchPresentation: .verbatim(status))
self.logger.error("system voice failed: \(error.localizedDescription, privacy: .public)")
}
}
@@ -4538,7 +4589,12 @@ extension TalkModeManager {
}
func _test_markSpeechErrorStatusPendingRestart(_ text: String) {
self.speechErrorStatusRevisionPendingRestart = self.setStatus(text, phase: .idle)
self.isEnabled = true
self.gatewayConnected = true
self.speechErrorStatusRevisionPendingRestart = self.setStatus(
text,
phase: .idle,
watchPresentation: .verbatim(text))
}
func _test_restoreListeningStatusAfterSpeechErrorRestart() {
@@ -78,12 +78,19 @@ struct WatchChatStatusLocalizationTests {
let backendOverride = OpenClawWatchAppStatus(
code: .gatewayProblem,
verbatim: "Gateway says update channel beta")
let localizedTalkFailure = OpenClawWatchAppStatus(
code: .talkFailure,
localizationKey: "Paused")
#expect(localized.localizedText(
localizePresentation: { key, _ in
key == "Gateway update required" ? "Mise à jour requise" : key
}) == "Mise à jour requise")
#expect(backendOverride.localizedText() == "Gateway says update channel beta")
#expect(localizedTalkFailure.localizedText(
localizePresentation: { key, _ in
key == "Paused" ? "En pause" : key
}) == "En pause")
}
private static func semanticPayload() -> [String: Any] {
@@ -4712,6 +4712,50 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
#expect(watchService.lastSentAppSnapshot?.gatewayStatus.code == .gatewayOffline)
}
@Test @MainActor func `watch app snapshot preserves gateway connection progress`() async throws {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
appModel.gatewayStatusText = "Connecting…"
watchService.emitAppSnapshotRequest(
WatchAppSnapshotRequestEvent(
requestId: "app-snapshot-connecting",
sentAtMs: 123,
transport: "sendMessage"))
for _ in 0..<20 {
if watchService.lastSentAppSnapshot != nil {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
let status = try #require(watchService.lastSentAppSnapshot?.gatewayStatus)
#expect(status.code == .legacy)
#expect(status.verbatim == "Connecting…")
}
@Test @MainActor func `watch app snapshot preserves talk failures`() async throws {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
appModel.talkMode._test_markSpeechErrorStatusPendingRestart("Speech error: denied")
watchService.emitAppSnapshotRequest(
WatchAppSnapshotRequestEvent(
requestId: "app-snapshot-talk-failure",
sentAtMs: 123,
transport: "sendMessage"))
for _ in 0..<20 {
if watchService.lastSentAppSnapshot != nil {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
let status = try #require(watchService.lastSentAppSnapshot?.talkStatus)
#expect(status.code == .talkFailure)
#expect(status.verbatim == "Speech error: denied")
}
@Test @MainActor func `watch app snapshot publishes online when operator reconnects`() async {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
@@ -857,7 +857,7 @@ extension OpenClawWatchAppStatus {
case .talkAPIKeyMissing:
localize(.apiKeyMissing)
case .talkFailure:
self.verbatim ?? localize(.unavailable)
self.localizedPresentation(localize: localizePresentation)
case .chatConnectIPhone:
localize(.connectIPhoneChat)
case .chatNoMessages: