From 5e9bc0916f92a598cc1e12a1622da4ebb7deeb6d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 13 Jul 2026 03:39:59 +0200 Subject: [PATCH] fix(ios): harden gateway and watch state handling --- apps/ios/Sources/Design/AgentProModels.swift | 13 +++ .../Design/AgentProTab+GatewayData.swift | 21 ++-- apps/ios/Sources/Design/AgentProTab.swift | 1 + .../Design/SettingsProTabSections.swift | 4 +- .../Design/SettingsProTabSupport.swift | 28 +++--- apps/ios/Sources/Design/TalkProTab.swift | 6 ++ ...wayConnectionController+Capabilities.swift | 22 +++-- .../Onboarding/OnboardingWizardView.swift | 4 +- .../Services/WatchConnectivityTransport.swift | 15 ++- .../Services/WatchMessagingService.swift | 90 ++++++++++++++++- .../Services/WatchSessionActivationGate.swift | 23 +++++ .../Settings/PrivacyAccessSectionView.swift | 49 +++++++++- apps/ios/Sources/Voice/TalkModeManager.swift | 8 +- .../Tests/AgentOverviewRefreshGateTests.swift | 21 ++++ .../GatewayConnectionControllerTests.swift | 14 +++ .../ios/Tests/HealthSummaryServiceTests.swift | 3 +- apps/ios/Tests/NodeAppModelInvokeTests.swift | 2 +- .../OnboardingDiscoveredGatewayTests.swift | 24 +++++ apps/ios/Tests/OpenClawTypographyTests.swift | 13 ++- .../PrivacyAccessRegistrationTests.swift | 97 +++++++++++++++++++ ...tTabsSourceGuardTests+GatewaySupport.swift | 2 +- apps/ios/Tests/RootTabsSourceGuardTests.swift | 26 ++--- .../RuntimeLocalizationSourceGuardTests.swift | 2 +- ...tchApprovalTransportSourceGuardTests.swift | 4 +- .../WatchSessionActivationGateTests.swift | 59 +++++++++++ .../Sources/WatchConnectivityReceiver.swift | 40 +++++--- .../OpenClawChatUI/ChatMessageViews.swift | 4 +- .../OpenClawChatUI/OpenClawMascotView.swift | 5 +- 28 files changed, 516 insertions(+), 84 deletions(-) create mode 100644 apps/ios/Tests/AgentOverviewRefreshGateTests.swift create mode 100644 apps/ios/Tests/OnboardingDiscoveredGatewayTests.swift create mode 100644 apps/ios/Tests/PrivacyAccessRegistrationTests.swift diff --git a/apps/ios/Sources/Design/AgentProModels.swift b/apps/ios/Sources/Design/AgentProModels.swift index 25178e31ef1a..894fa3b28d5d 100644 --- a/apps/ios/Sources/Design/AgentProModels.swift +++ b/apps/ios/Sources/Design/AgentProModels.swift @@ -2,6 +2,19 @@ import Foundation import OpenClawKit import OpenClawProtocol +struct AgentOverviewRefreshGate { + private var generation: UInt64 = 0 + + mutating func begin() -> UInt64 { + self.generation &+= 1 + return self.generation + } + + func isCurrent(_ generation: UInt64) -> Bool { + self.generation == generation + } +} + enum AgentProValueReader { static func intValue(_ value: AnyCodable?) -> Int? { switch value?.value { diff --git a/apps/ios/Sources/Design/AgentProTab+GatewayData.swift b/apps/ios/Sources/Design/AgentProTab+GatewayData.swift index a4cded7a9e40..1b4a5ed3accc 100644 --- a/apps/ios/Sources/Design/AgentProTab+GatewayData.swift +++ b/apps/ios/Sources/Design/AgentProTab+GatewayData.swift @@ -94,21 +94,23 @@ extension AgentProTab { } @MainActor - func refreshOverview(force: Bool) async { - guard self.scenePhase == .active else { return } - guard self.liveGatewayConnected else { + func refreshOverview(force _: Bool) async { + let generation = self.overviewRefreshGate.begin() + let requestContext = self.overviewTaskID + guard self.scenePhase == .active, self.liveGatewayConnected else { self.overview = nil self.overviewErrorText = nil self.overviewLoading = false return } - if self.overviewLoading, force == false { - return - } self.overviewLoading = true self.overviewErrorText = nil - defer { self.overviewLoading = false } + defer { + if self.overviewRefreshGate.isCurrent(generation) { + self.overviewLoading = false + } + } let activeAgentID = self.activeAgentID let skillsParams = Self.agentScopedParams(agentId: activeAgentID) @@ -151,6 +153,11 @@ extension AgentProTab { agentSkillFilter: loadedSkills?.agentSkillFilter ?? loadedConfig?.effectiveSkillFilter(agentId: activeAgentID)) + guard self.overviewRefreshGate.isCurrent(generation), + self.overviewTaskID == requestContext + else { + return + } if snapshot.hasAnyLiveData { self.overview = snapshot } else { diff --git a/apps/ios/Sources/Design/AgentProTab.swift b/apps/ios/Sources/Design/AgentProTab.swift index c4db6eb6598b..5088963b10f5 100644 --- a/apps/ios/Sources/Design/AgentProTab.swift +++ b/apps/ios/Sources/Design/AgentProTab.swift @@ -11,6 +11,7 @@ struct AgentProTab: View { @State var overview: AgentOverviewSnapshot? @State var overviewErrorText: String? @State var overviewLoading: Bool = false + @State var overviewRefreshGate = AgentOverviewRefreshGate() @State var agentRosterFilter: AgentRosterFilter = .all @State var agentSearchPresented = false @State var agentSearchText = "" diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 54988ec518f1..81919b349baf 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -874,7 +874,7 @@ extension SettingsProTab { } .contentShape(Rectangle()) } - .accessibilityLabel(Text(title)) + .accessibilityLabel(title) } func toggleCard(title: LocalizedStringKey, isOn: Binding) -> some View { @@ -1400,7 +1400,7 @@ extension SettingsProTab { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel(Text(title)) + .accessibilityLabel(title) .accessibilityValue(isOn.wrappedValue ? String(localized: "On") : String(localized: "Off")) diff --git a/apps/ios/Sources/Design/SettingsProTabSupport.swift b/apps/ios/Sources/Design/SettingsProTabSupport.swift index 928d715c83d0..04ab51306a47 100644 --- a/apps/ios/Sources/Design/SettingsProTabSupport.swift +++ b/apps/ios/Sources/Design/SettingsProTabSupport.swift @@ -92,12 +92,18 @@ struct SettingsBuildMetadataStrip: View { .accessibilityLabel(self.metadataAccessibilityLabel) .accessibilityActions { if self.metadata.gitCommit != nil { - Button("Copy full commit hash") { + Button { self.copyCommit() + } label: { + Text("Copy full commit hash") + .font(OpenClawType.subheadSemiBold) } } - Button("Copy build info") { + Button { self.copyBuildInfo() + } label: { + Text("Copy build info") + .font(OpenClawType.subheadSemiBold) } } .contextMenu { @@ -170,39 +176,39 @@ struct SettingsBuildMetadataStrip: View { } } - private var metadataAccessibilityLabel: Text { + private var metadataAccessibilityLabel: String { let version = self.metadata.versionDisplay let commit = self.metadata.spokenCommit let timestamp = self.metadata.buildTimestamp let built = self.metadata.localizedBuildDate() ?? timestamp if let commit, let timestamp, let built { - return Text(verbatim: String( + return String( format: String( localized: "Version %1$@, commit %2$@, built %3$@, timestamp %4$@"), version, commit, built, - timestamp)) + timestamp) } if let commit { - return Text(verbatim: String( + return String( format: String( localized: "Version %1$@, commit %2$@, build date unavailable"), version, - commit)) + commit) } if let timestamp, let built { - return Text(verbatim: String( + return String( format: String( localized: "Version %1$@, commit unavailable, built %2$@, timestamp %3$@"), version, built, - timestamp)) + timestamp) } - return Text(verbatim: String( + return String( format: String( localized: "Version %@, commit unavailable, build date unavailable"), - version)) + version) } private func copyCommit() { diff --git a/apps/ios/Sources/Design/TalkProTab.swift b/apps/ios/Sources/Design/TalkProTab.swift index f2a4e44d8609..783027f4ae77 100644 --- a/apps/ios/Sources/Design/TalkProTab.swift +++ b/apps/ios/Sources/Design/TalkProTab.swift @@ -223,12 +223,16 @@ struct TalkProTab: View { private var heroSubtitle: some View { if self.state.prefersPermissionCopy { Text("Gateway approval is required before this phone can capture voice.") + .font(OpenClawType.subhead) } else if self.appModel.isAppleReviewDemoModeEnabled { Text("Voice is disabled in Apple Review demo mode.") + .font(OpenClawType.subhead) } else if !self.gatewayConnected { Text("Connect to your gateway to start a voice conversation.") + .font(OpenClawType.subhead) } else if !self.appModel.talkMode.gatewayTalkConfigLoaded { Text("Open Voice settings after the gateway loads Talk configuration.") + .font(OpenClawType.subhead) } else { let subtitle = (appModel.talkMode.gatewayTalkVoiceModeSubtitle ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -236,8 +240,10 @@ struct TalkProTab: View { Text(verbatim: String( format: String(localized: "Routes voice to %@."), self.appModel.chatAgentName)) + .font(OpenClawType.subhead) } else { Text(verbatim: subtitle) + .font(OpenClawType.subhead) } } } diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift b/apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift index 2af3d3cd1dab..e144dfbee829 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift @@ -224,21 +224,15 @@ extension GatewayConnectionController { permissions["contacts"] = contactsStatus == .authorized || contactsStatus == .limited let calendarStatus = EKEventStore.authorizationStatus(for: .event) - permissions["calendar"] = Self.hasEventKitAccess(calendarStatus) + permissions["calendar"] = Self.hasEventKitReadAccess(calendarStatus) let remindersStatus = EKEventStore.authorizationStatus(for: .reminder) - permissions["reminders"] = Self.hasEventKitAccess(remindersStatus) + permissions["reminders"] = Self.hasEventKitReadAccess(remindersStatus) let motionStatus = CMMotionActivityManager.authorizationStatus() let pedometerStatus = CMPedometer.authorizationStatus() permissions["motion"] = motionStatus == .authorized || pedometerStatus == .authorized - let watchStatus = WatchMessagingService.currentStatusSnapshot() - permissions["watchSupported"] = watchStatus.supported - permissions["watchPaired"] = watchStatus.paired - permissions["watchAppInstalled"] = watchStatus.appInstalled - permissions["watchReachable"] = watchStatus.reachable - return permissions } @@ -258,8 +252,8 @@ extension GatewayConnectionController { } } - private static func hasEventKitAccess(_ status: EKAuthorizationStatus) -> Bool { - status == .fullAccess || status == .writeOnly + private static func hasEventKitReadAccess(_ status: EKAuthorizationStatus) -> Bool { + status == .fullAccess } private static func motionAvailable() -> Bool { @@ -281,6 +275,14 @@ extension GatewayConnectionController { self.currentCommands() } + func _test_currentPermissions() async -> [String: Bool] { + await self.currentPermissions() + } + + static func _test_hasEventKitReadAccess(_ status: EKAuthorizationStatus) -> Bool { + self.hasEventKitReadAccess(status) + } + static func _test_isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool { self.isLocationAvailable(servicesEnabled: servicesEnabled, status: status) } diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 3d0596b7134d..2e67f23cf9e6 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -1383,7 +1383,9 @@ extension OnboardingWizardView { self.connectMessage = "Connecting to \(gateway.name)…" self.statusLine = "Connecting to \(gateway.name)…" defer { self.connectingGateway = nil } - await self.gatewayController.connect(gateway) + if let message = await self.gatewayController.connectWithDiagnostics(gateway) { + self.setConnectionFailure(message) + } } private func selectMode(_ mode: OnboardingConnectionMode) { diff --git a/apps/ios/Sources/Services/WatchConnectivityTransport.swift b/apps/ios/Sources/Services/WatchConnectivityTransport.swift index ecd52849a2fd..b6740ae7186d 100644 --- a/apps/ios/Sources/Services/WatchConnectivityTransport.swift +++ b/apps/ios/Sources/Services/WatchConnectivityTransport.swift @@ -18,8 +18,13 @@ private func sendReachableWatchMessage(_ payload: [String: Any], with session: W try await withCheckedThrowingContinuation(isolation: nil) { (continuation: CheckedContinuation) in session.sendMessage( payload, - replyHandler: { _ in - continuation.resume(returning: ()) + replyHandler: { reply in + do { + try requireAcceptedWatchMessageReply(reply) + continuation.resume(returning: ()) + } catch { + continuation.resume(throwing: error) + } }, errorHandler: { error in continuation.resume(throwing: error) @@ -45,7 +50,6 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable { super.init() if let session = self.session { session.delegate = self - self.beginActivation(session) } } @@ -108,6 +112,11 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable { self.updateCallbacks { $0.appCommandHandler = handler } } + func activate() { + guard let session = self.session else { return } + self.beginActivation(session) + } + func sendPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult { try await self.ensureActivated() let session = try self.requireReadySession() diff --git a/apps/ios/Sources/Services/WatchMessagingService.swift b/apps/ios/Sources/Services/WatchMessagingService.swift index 5aa26c0ac490..6baa08c4f450 100644 --- a/apps/ios/Sources/Services/WatchMessagingService.swift +++ b/apps/ios/Sources/Services/WatchMessagingService.swift @@ -1,6 +1,33 @@ import Foundation import OpenClawKit +struct WatchMessagingStartupBuffer { + private let maxCount: Int + private var events: [Event] = [] + private(set) var isReady = false + + init(maxCount: Int) { + precondition(maxCount > 0) + self.maxCount = maxCount + } + + mutating func receive(_ event: Event) -> [Event] { + guard !self.isReady else { return [event] } + if self.events.count == self.maxCount { + self.events.removeFirst() + } + self.events.append(event) + return [] + } + + mutating func markReady() -> [Event] { + guard !self.isReady else { return [] } + self.isReady = true + defer { self.events.removeAll(keepingCapacity: false) } + return self.events + } +} + enum WatchMessagingError: LocalizedError { case unsupported case notPaired @@ -20,7 +47,19 @@ enum WatchMessagingError: LocalizedError { @MainActor final class WatchMessagingService: @preconcurrency WatchMessagingServicing { + private enum StartupEvent { + case reply(WatchQuickReplyEvent) + case execApprovalResolve(WatchExecApprovalResolveEvent) + case execApprovalSnapshotRequest(WatchExecApprovalSnapshotRequestEvent) + case appSnapshotRequest(WatchAppSnapshotRequestEvent) + case appCommand(WatchAppCommandEvent) + } + + private static let maxStartupEvents = 64 + private let transport: WatchConnectivityTransport + private var startupEvents = WatchMessagingStartupBuffer( + maxCount: WatchMessagingService.maxStartupEvents) private var statusHandler: (@Sendable (WatchMessagingStatus) -> Void)? private var lastEmittedStatus: WatchMessagingStatus? private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? @@ -39,29 +78,30 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing { } self.transport.setReplyHandler { [weak self] event in Task { @MainActor [weak self] in - self?.emitReply(event) + self?.receiveStartupEvent(.reply(event)) } } self.transport.setExecApprovalResolveHandler { [weak self] event in Task { @MainActor [weak self] in - self?.emitExecApprovalResolve(event) + self?.receiveStartupEvent(.execApprovalResolve(event)) } } self.transport.setExecApprovalSnapshotRequestHandler { [weak self] event in Task { @MainActor [weak self] in - self?.emitExecApprovalSnapshotRequest(event) + self?.receiveStartupEvent(.execApprovalSnapshotRequest(event)) } } self.transport.setAppSnapshotRequestHandler { [weak self] event in Task { @MainActor [weak self] in - self?.emitAppSnapshotRequest(event) + self?.receiveStartupEvent(.appSnapshotRequest(event)) } } self.transport.setAppCommandHandler { [weak self] event in Task { @MainActor [weak self] in - self?.emitAppCommand(event) + self?.receiveStartupEvent(.appCommand(event)) } } + self.transport.activate() } nonisolated static func isSupportedOnDevice() -> Bool { @@ -95,24 +135,29 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing { func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { self.replyHandler = handler + self.finishStartupRegistrationIfReady() } func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) { self.execApprovalResolveHandler = handler + self.finishStartupRegistrationIfReady() } func setExecApprovalSnapshotRequestHandler( _ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?) { self.execApprovalSnapshotRequestHandler = handler + self.finishStartupRegistrationIfReady() } func setAppSnapshotRequestHandler(_ handler: (@Sendable (WatchAppSnapshotRequestEvent) -> Void)?) { self.appSnapshotRequestHandler = handler + self.finishStartupRegistrationIfReady() } func setAppCommandHandler(_ handler: (@Sendable (WatchAppCommandEvent) -> Void)?) { self.appCommandHandler = handler + self.finishStartupRegistrationIfReady() } func sendNotification( @@ -218,4 +263,39 @@ final class WatchMessagingService: @preconcurrency WatchMessagingServicing { + "transport=\(event.transport)") self.appCommandHandler?(event) } + + private func receiveStartupEvent(_ event: StartupEvent) { + for event in self.startupEvents.receive(event) { + self.dispatchStartupEvent(event) + } + } + + private func finishStartupRegistrationIfReady() { + guard self.replyHandler != nil, + self.execApprovalResolveHandler != nil, + self.execApprovalSnapshotRequestHandler != nil, + self.appSnapshotRequestHandler != nil, + self.appCommandHandler != nil + else { + return + } + for event in self.startupEvents.markReady() { + self.dispatchStartupEvent(event) + } + } + + private func dispatchStartupEvent(_ event: StartupEvent) { + switch event { + case let .reply(event): + self.emitReply(event) + case let .execApprovalResolve(event): + self.emitExecApprovalResolve(event) + case let .execApprovalSnapshotRequest(event): + self.emitExecApprovalSnapshotRequest(event) + case let .appSnapshotRequest(event): + self.emitAppSnapshotRequest(event) + case let .appCommand(event): + self.emitAppCommand(event) + } + } } diff --git a/apps/ios/Sources/Services/WatchSessionActivationGate.swift b/apps/ios/Sources/Services/WatchSessionActivationGate.swift index 85b4a3c26308..3593bc518e08 100644 --- a/apps/ios/Sources/Services/WatchSessionActivationGate.swift +++ b/apps/ios/Sources/Services/WatchSessionActivationGate.swift @@ -1,5 +1,28 @@ import Foundation +enum WatchMessageAcknowledgmentError: LocalizedError { + case rejected(String) + + var errorDescription: String? { + switch self { + case let .rejected(reason): + "WATCH_DELIVERY_REJECTED: \(reason)" + } + } +} + +func requireAcceptedWatchMessageReply(_ reply: [String: Any]) throws { + guard let accepted = reply["ok"] as? Bool else { + throw WatchMessageAcknowledgmentError.rejected("malformed acknowledgment") + } + guard accepted else { + let reason = (reply["error"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + throw WatchMessageAcknowledgmentError.rejected( + reason.flatMap { $0.isEmpty ? nil : $0 } ?? "payload was rejected") + } +} + enum WatchSessionActivationError: LocalizedError { case failed(String) case timedOut diff --git a/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift b/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift index 0f53193ad0e8..5103e34a2e14 100644 --- a/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift +++ b/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift @@ -37,6 +37,29 @@ private enum PrivacyPermissionStatus { } } +struct PrivacyGatewayPermissionSnapshot: Equatable { + let contacts: Bool + let photos: Bool + let calendar: Bool + let reminders: Bool + + init( + contactsStatus: CNAuthorizationStatus, + photosStatus: PHAuthorizationStatus, + calendarStatus: EKAuthorizationStatus, + remindersStatus: EKAuthorizationStatus) + { + self.contacts = contactsStatus == .authorized || contactsStatus == .limited + self.photos = PhotoLibraryAccess.canRead(photosStatus) + self.calendar = Self.hasReadableEventKitAccess(calendarStatus) + self.reminders = Self.hasReadableEventKitAccess(remindersStatus) + } + + private static func hasReadableEventKitAccess(_ status: EKAuthorizationStatus) -> Bool { + status == .fullAccess + } +} + struct PrivacyAccessSectionView: View { @Environment(GatewayConnectionController.self) private var gatewayController @State private var contactsStatus: CNAuthorizationStatus = CNContactStore.authorizationStatus(for: .contacts) @@ -446,11 +469,29 @@ struct PrivacyAccessSectionView: View { } private func refreshAll() { - self.contactsStatus = CNContactStore.authorizationStatus(for: .contacts) - self.calendarStatus = EKEventStore.authorizationStatus(for: .event) - self.remindersStatus = EKEventStore.authorizationStatus(for: .reminder) - self.updatePhotosStatus(PhotoLibraryAccess.authorizationStatus()) + let previousPermissions = PrivacyGatewayPermissionSnapshot( + contactsStatus: self.contactsStatus, + photosStatus: self.photosStatus, + calendarStatus: self.calendarStatus, + remindersStatus: self.remindersStatus) + let contactsStatus = CNContactStore.authorizationStatus(for: .contacts) + let photosStatus = PhotoLibraryAccess.authorizationStatus() + let calendarStatus = EKEventStore.authorizationStatus(for: .event) + let remindersStatus = EKEventStore.authorizationStatus(for: .reminder) + let currentPermissions = PrivacyGatewayPermissionSnapshot( + contactsStatus: contactsStatus, + photosStatus: photosStatus, + calendarStatus: calendarStatus, + remindersStatus: remindersStatus) + + self.contactsStatus = contactsStatus + self.photosStatus = photosStatus + self.calendarStatus = calendarStatus + self.remindersStatus = remindersStatus self.healthEnabled = HealthAuthorization.isEnabled + if previousPermissions != currentPermissions { + self.gatewayController.refreshActiveGatewayRegistrationFromSettings() + } } private func updatePhotosStatus(_ status: PHAuthorizationStatus) { diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 813b1ce423a6..dc98cf6a58c6 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -529,13 +529,15 @@ final class TalkModeManager: NSObject { let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } if trimmed == self.mainSessionKey { return false } - let shouldRestartTalk = self.isEnabled && - (self.hasRealtimeOwnerOrStart || self.hasContinuousTalkOwner) + let hasTalkOwner = self.hasRealtimeOwnerOrStart || self.hasContinuousTalkOwner + let shouldRestartTalk = self.isEnabled && hasTalkOwner if let captureId = self.activePTTCaptureId { _ = self.cancelPushToTalk(captureId: captureId) } self.cancelFinishingPushToTalk() - if shouldRestartTalk { + if hasTalkOwner { + // Session identity owns every in-flight relay/capture generation, even while + // Talk is disabled. Leaving one alive can publish work into the replacement session. self.cancelPendingStart() self.resetRealtimeRestartState() self.stopRealtimeSession() diff --git a/apps/ios/Tests/AgentOverviewRefreshGateTests.swift b/apps/ios/Tests/AgentOverviewRefreshGateTests.swift new file mode 100644 index 000000000000..554d7031ccfb --- /dev/null +++ b/apps/ios/Tests/AgentOverviewRefreshGateTests.swift @@ -0,0 +1,21 @@ +import Testing +@testable import OpenClaw + +struct AgentOverviewRefreshGateTests { + @Test func `new overview refresh invalidates an older result`() { + var gate = AgentOverviewRefreshGate() + + let first = gate.begin() + let second = gate.begin() + + #expect(!gate.isCurrent(first)) + #expect(gate.isCurrent(second)) + } + + @Test func `current overview refresh remains accepted until superseded`() { + var gate = AgentOverviewRefreshGate() + let generation = gate.begin() + + #expect(gate.isCurrent(generation)) + } +} diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index acd8b6091334..948358c55fb4 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -202,6 +202,20 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn status: .denied)) } + @Test @MainActor func `registration permissions exclude watch availability`() async { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + let permissions = await controller._test_currentPermissions() + + #expect(!permissions.keys.contains(where: { $0.hasPrefix("watch") })) + } + + @Test @MainActor func `legacy EventKit permission means readable access`() { + #expect(GatewayConnectionController._test_hasEventKitReadAccess(.fullAccess)) + #expect(!GatewayConnectionController._test_hasEventKitReadAccess(.writeOnly)) + #expect(!GatewayConnectionController._test_hasEventKitReadAccess(.denied)) + } + @Test @MainActor func `current commands exclude dangerous system exec commands`() { withUserDefaults([ "node.instanceId": "ios-test", diff --git a/apps/ios/Tests/HealthSummaryServiceTests.swift b/apps/ios/Tests/HealthSummaryServiceTests.swift index e2d021549218..13edfdefe820 100644 --- a/apps/ios/Tests/HealthSummaryServiceTests.swift +++ b/apps/ios/Tests/HealthSummaryServiceTests.swift @@ -32,7 +32,8 @@ struct HealthSummaryServiceTests { end: #require(formatter.date(from: "2026-07-12T04:00:00Z"))), ] - #expect(HealthSummaryService.mergedDuration(intervals: intervals, clippedTo: range) == 3 * 60 * 60) + let duration = try #require(HealthSummaryService.mergedDuration(intervals: intervals, clippedTo: range)) + #expect(abs(duration - 3 * 60 * 60) < 0.001) #expect(HealthSummaryService.mergedDuration(intervals: [], clippedTo: range) == nil) } } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 7d216ef5d798..52b92a605538 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -3232,7 +3232,7 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi await waitForTalkCondition { startResumed } #expect(!talkMode.isListening) - #expect(talkMode.statusText == "Paused") + #expect(talkMode.statusText != "Listening") } @Test @MainActor func `gateway disconnect invalidates a suspended Talk start`() async { diff --git a/apps/ios/Tests/OnboardingDiscoveredGatewayTests.swift b/apps/ios/Tests/OnboardingDiscoveredGatewayTests.swift new file mode 100644 index 000000000000..37dec442ffd8 --- /dev/null +++ b/apps/ios/Tests/OnboardingDiscoveredGatewayTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing + +struct OnboardingDiscoveredGatewayTests { + @Test func `discovered gateway surfaces diagnostic connection failures`() throws { + let source = try String(contentsOf: Self.sourceURL(), encoding: .utf8) + let start = try #require(source.range(of: "private func connectDiscoveredGateway(")) + let end = try #require( + source.range(of: "private func selectMode(", range: start.upperBound.. URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Onboarding/OnboardingWizardView.swift") + } +} diff --git a/apps/ios/Tests/OpenClawTypographyTests.swift b/apps/ios/Tests/OpenClawTypographyTests.swift index 0f4339ec7d00..533ad828cdc6 100644 --- a/apps/ios/Tests/OpenClawTypographyTests.swift +++ b/apps/ios/Tests/OpenClawTypographyTests.swift @@ -201,8 +201,8 @@ struct OpenClawTypographyTests { encoding: .utf8) #expect(proComponents.contains(".font(OpenClawType.subheadSemiBold)")) - #expect(proComponents.contains("Text(primaryActionTitle)")) - #expect(proComponents.contains("Text(secondaryActionTitle)")) + #expect(proComponents.contains("primaryActionTitle.text")) + #expect(proComponents.contains("secondaryActionTitle.text")) #expect(chatTab.contains("Text(\"Export Transcript\")")) #expect(chatTab.contains(".font(OpenClawType.body)")) @@ -272,7 +272,7 @@ struct OpenClawTypographyTests { #expect(settingsSections.contains(".font(OpenClawType.subhead)")) #expect(settingsSections.contains("private struct AppearanceSettingsScreen")) #expect(settingsSections.contains("Section(\"Gateway\")")) - #expect(settingsSections.contains("SettingsDetailRow(\"Address\", value: self.gatewayAddress)")) + #expect(settingsSections.contains("SettingsDetailRow(\"Address\", value: .verbatim(self.gatewayAddress))")) #expect(settingsSections.contains("func gatewayActionButton")) #expect(settingsSections.contains("func settingsToggle")) #expect(settingsSections.contains(".font(OpenClawType.subheadSemiBold)")) @@ -289,7 +289,7 @@ struct OpenClawTypographyTests { settingsSections, from: "func gatewaySecureField", to: " var voiceFeatureCard") - #expect(gatewaySecureField.contains(".accessibilityLabel(placeholder)")) + #expect(gatewaySecureField.contains(".accessibilityLabel(Text(placeholder))")) #expect(gatewaySecureField.contains(".accessibilityHidden(true)")) #expect(gatewaySecureField.contains(".textInputAutocapitalization(.never)")) #expect(gatewaySecureField.contains(".autocorrectionDisabled()")) @@ -469,7 +469,10 @@ struct OpenClawTypographyTests { private static func hasAllowedBrandedFontParameter(_ window: String, line: String, in url: URL) -> Bool { switch self.relativePath(url) { case "apps/ios/Sources/Design/OpenClawProComponents.swift": - window.contains(".font(self.titleFont)") || window.contains(".font(self.subtitleFont)") + line.contains("Text(key)") || + line.contains("Text(verbatim: value)") || + window.contains(".font(self.titleFont)") || + window.contains(".font(self.subtitleFont)") case "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift": // Qualified values are composed here, then styled at the prose render boundary. line.contains("SwiftUI.Text(") || window.contains(".font(self.font)") diff --git a/apps/ios/Tests/PrivacyAccessRegistrationTests.swift b/apps/ios/Tests/PrivacyAccessRegistrationTests.swift new file mode 100644 index 000000000000..b589889c6a71 --- /dev/null +++ b/apps/ios/Tests/PrivacyAccessRegistrationTests.swift @@ -0,0 +1,97 @@ +import Contacts +import EventKit +import Foundation +import Photos +import Testing +@testable import OpenClaw + +struct PrivacyAccessRegistrationTests { + @Test func `refresh all reconnects once for advertised permission changes`() throws { + let source = try String(contentsOf: Self.sourceURL(), encoding: .utf8) + let start = try #require(source.range(of: "private func refreshAll()")) + let end = try #require( + source.range(of: "private func updatePhotosStatus(", range: start.upperBound.. URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Settings/PrivacyAccessSectionView.swift") + } +} diff --git a/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift b/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift index 21e961338f08..fa870b578c81 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests+GatewaySupport.swift @@ -222,7 +222,7 @@ extension RootTabsSourceGuardTests { #expect(connectionFailure.contains("self.statusLine = message")) #expect(onboardingSource.contains(".failedStatus(message: message, allowsRetry: false)")) #expect(onboardingSource.contains( - "primaryActionTitle: allowsRetry ? String(localized: \"Retry\") : nil")) + "primaryActionTitle: allowsRetry ? OpenClawTextValue.localized(\"Retry\") : nil")) #expect(onboardingSource.contains("onPrimaryAction: allowsRetry ? self.onRetry : nil")) #expect(stagedSetupClear.contains("self.localConnectionFailure = nil")) #expect(onboardingRetry.contains("self.localConnectionFailure = nil")) diff --git a/apps/ios/Tests/RootTabsSourceGuardTests.swift b/apps/ios/Tests/RootTabsSourceGuardTests.swift index 4ee23c8b6df6..edca8314c471 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests.swift @@ -176,7 +176,7 @@ struct RootTabsSourceGuardTests { #expect(sidebarDetail.contains("headerTitle: \"Cron Jobs\"")) #expect(!sidebarDetail.contains("headerTitle: \"OpenClaw\"")) #expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow(")) - #expect(agentOverviewSource.contains("title: self.headerTitle")) + #expect(agentOverviewSource.contains("title: .localized(self.headerTitle)")) #expect(!agentOverviewSource.contains("Text(\"OpenClaw\")")) #expect(docsSource.contains("OpenClawAdaptiveHeaderRow(")) #expect(docsSource.contains("title: \"Docs\"")) @@ -261,7 +261,7 @@ struct RootTabsSourceGuardTests { settingsSource, from: "private struct AppearanceSettingsScreen: View", to: "extension SettingsProTab") - #expect(gatewayStatus.contains("OpenClawStatusBadge(label: self.title, tone: self.tone)")) + #expect(gatewayStatus.contains("OpenClawStatusBadge(label: .verbatim(self.title), tone: self.tone)")) #expect(!gatewayStatus.contains("ProCapsule(")) #expect(!gatewayStatus.contains("Capsule()")) #expect(agentDestinationsSource.contains("List {")) @@ -311,9 +311,10 @@ struct RootTabsSourceGuardTests { #expect(aboutDestination.contains("detailListCard")) #expect(aboutDestination.contains("SettingsBuildMetadataStrip(metadata: DeviceInfoHelper.buildMetadata())")) #expect(!aboutDestination.contains("SettingsDetailRow(\"OpenClaw app version\"")) - #expect(aboutDestination.contains("SettingsDetailRow(\"Device\", value: DeviceInfoHelper.deviceFamily())")) - #expect(aboutDestination - .contains("SettingsDetailRow(\"iOS\", value: DeviceInfoHelper.iOSVersionStringForDisplay())")) + #expect(aboutDestination.contains( + "SettingsDetailRow(\"Device\", value: .verbatim(DeviceInfoHelper.deviceFamily()))")) + #expect(aboutDestination.contains( + "value: .verbatim(DeviceInfoHelper.iOSVersionStringForDisplay()))")) #expect(!aboutDestination.contains("SettingsDetailRow(\"Version\"")) #expect(!aboutDestination.contains("SettingsDetailRow(\"Platform\"")) #expect(!aboutDestination.contains("SettingsDetailRow(\"Model\"")) @@ -323,12 +324,12 @@ struct RootTabsSourceGuardTests { #expect(supportSource.contains("ViewThatFits(in: .horizontal)")) #expect(supportSource.contains("Text(\"Unavailable\")")) #expect(supportSource.contains(".textCase(.uppercase)")) - #expect(diagnosticsDestination - .contains("SettingsDetailRow(\"Device\", value: DeviceInfoHelper.deviceFamily())")) - #expect(diagnosticsDestination - .contains("SettingsDetailRow(\"Platform\", value: DeviceInfoHelper.platformStringForDisplay())")) - #expect(diagnosticsDestination - .contains("SettingsDetailRow(\"Model\", value: DeviceInfoHelper.modelIdentifier())")) + #expect(diagnosticsDestination.contains( + "SettingsDetailRow(\"Device\", value: .verbatim(DeviceInfoHelper.deviceFamily()))")) + #expect(diagnosticsDestination.contains( + "value: .verbatim(DeviceInfoHelper.platformStringForDisplay()))")) + #expect(diagnosticsDestination.contains( + "SettingsDetailRow(\"Model\", value: .verbatim(DeviceInfoHelper.modelIdentifier()))")) } @Test func `routed headers use shared adaptive layout`() throws { @@ -500,7 +501,8 @@ extension RootTabsSourceGuardTests { #expect(source.contains("self.newCardButton(expands: true)")) #expect(source.contains("Label(\"New Card\", systemImage: \"plus\")")) #expect(source.contains(".accessibilityHint(\"Opens card title and notes entry\")")) - #expect(source.contains(".accessibilityHint(self.createUnavailableMessage ?? \"Creates a workboard card\")")) + #expect(source.contains( + "self.createUnavailableMessage ?? String(localized: \"Creates a workboard card\"))")) #expect(source.contains("if await self.createCard()")) #expect(source.contains(".disabled(self.isCreatingCard)")) #expect(!source.contains("Button(\"Create\")")) diff --git a/apps/ios/Tests/RuntimeLocalizationSourceGuardTests.swift b/apps/ios/Tests/RuntimeLocalizationSourceGuardTests.swift index bdd83b6b47cc..9580109f44c3 100644 --- a/apps/ios/Tests/RuntimeLocalizationSourceGuardTests.swift +++ b/apps/ios/Tests/RuntimeLocalizationSourceGuardTests.swift @@ -77,7 +77,7 @@ struct RuntimeLocalizationSourceGuardTests { #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))")) + #expect(chat.contains("Text(self.accessibilityText)")) #expect(watchInbox.contains("case localized(LocalizedStringResource)")) #expect(!watchInbox.contains("WatchTextValue: ExpressibleByStringLiteral")) #expect(watchInbox.contains("accessory: .verbatim(self.store.talkSummaryText)")) diff --git a/apps/ios/Tests/WatchApprovalTransportSourceGuardTests.swift b/apps/ios/Tests/WatchApprovalTransportSourceGuardTests.swift index c5a871ce2c87..8b5cb3b29dae 100644 --- a/apps/ios/Tests/WatchApprovalTransportSourceGuardTests.swift +++ b/apps/ios/Tests/WatchApprovalTransportSourceGuardTests.swift @@ -17,10 +17,10 @@ struct WatchApprovalTransportSourceGuardTests { #expect(appSource.contains("id: \"watch-screenshot-approval\"")) #expect(appSource.contains("pendingApprovalCount: approvals.count")) #expect(approvalFace.contains("self.store.isExecApprovalReviewLoading")) - #expect(approvalFace.contains("title: \"Loading approval\"")) + #expect(approvalFace.contains("title: .localized(\"Loading approval\")")) #expect(approvalFace.contains( "self.approvalCount > 0 || self.store.shouldShowExecApprovalReviewStatus")) - #expect(approvalFace.contains("title: \"Approval not loaded\"")) + #expect(approvalFace.contains("title: .localized(\"Approval not loaded\")")) #expect(approvalFace.contains("Approval details have not loaded")) #expect(approvalFace.contains("WatchSecondaryButton(title: \"Review again\")")) } diff --git a/apps/ios/Tests/WatchSessionActivationGateTests.swift b/apps/ios/Tests/WatchSessionActivationGateTests.swift index 1c5e24080606..4f738ec7f832 100644 --- a/apps/ios/Tests/WatchSessionActivationGateTests.swift +++ b/apps/ios/Tests/WatchSessionActivationGateTests.swift @@ -3,6 +3,32 @@ import Testing @testable import OpenClaw struct WatchSessionActivationGateTests { + @Test func `reachable delivery requires an accepted acknowledgment`() throws { + try requireAcceptedWatchMessageReply(["ok": true]) + + #expect(throws: WatchMessageAcknowledgmentError.self) { + try requireAcceptedWatchMessageReply(["ok": false, "error": "unsupported_payload"]) + } + #expect(throws: WatchMessageAcknowledgmentError.self) { + try requireAcceptedWatchMessageReply(["ok": "true"]) + } + #expect(throws: WatchMessageAcknowledgmentError.self) { + try requireAcceptedWatchMessageReply([:]) + } + } + + @Test func `startup event buffering is ordered and bounded`() { + var buffer = WatchMessagingStartupBuffer(maxCount: 3) + + #expect(buffer.receive("first").isEmpty) + #expect(buffer.receive("second").isEmpty) + #expect(buffer.receive("third").isEmpty) + #expect(buffer.receive("fourth").isEmpty) + #expect(buffer.markReady() == ["second", "third", "fourth"]) + #expect(buffer.receive("live") == ["live"]) + #expect(buffer.markReady().isEmpty) + } + @Test func `iPhone observes watch pairing and install changes`() throws { let sourceURL = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -52,4 +78,37 @@ struct WatchSessionActivationGateTests { await #expect(throws: WatchSessionActivationError.self) { try await first.value } await #expect(throws: WatchSessionActivationError.self) { try await second.value } } + + @Test func `watch receiver acknowledges only accepted payloads and snapshots only after activation`() throws { + let iosRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let receiverSource = try String( + contentsOf: iosRoot.appendingPathComponent( + "WatchApp/Sources/WatchConnectivityReceiver.swift"), + encoding: .utf8) + let transportSource = try String( + contentsOf: iosRoot.appendingPathComponent( + "Sources/Services/WatchConnectivityTransport.swift"), + encoding: .utf8) + let serviceSource = try String( + contentsOf: iosRoot.appendingPathComponent( + "Sources/Services/WatchMessagingService.swift"), + encoding: .utf8) + + #expect(receiverSource.contains( + "let accepted = self.consumeIncomingPayload(message, transport: \"sendMessage\")")) + #expect(receiverSource.contains("accepted\n ? [\"ok\": true]")) + #expect(receiverSource.contains( + ": [\"ok\": false, \"error\": \"unsupported_payload\"]")) + #expect(receiverSource.contains( + "private func consumeIncomingPayload(_ payload: [String: Any], transport: String) -> Bool")) + #expect(receiverSource.contains("guard activationState == .activated else { return }")) + #expect(receiverSource.contains("try requireAcceptedWatchMessageReply(reply)")) + #expect(transportSource.contains("try requireAcceptedWatchMessageReply(reply)")) + let callbackRegistration = try #require( + serviceSource.range(of: "self.transport.setAppCommandHandler")) + let activation = try #require(serviceSource.range(of: "self.transport.activate()")) + #expect(callbackRegistration.lowerBound < activation.lowerBound) + } } diff --git a/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift b/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift index 04069b0e3bfe..05b941c56901 100644 --- a/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift +++ b/apps/ios/WatchApp/Sources/WatchConnectivityReceiver.swift @@ -259,7 +259,14 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { try await withCheckedThrowingContinuation(isolation: nil) { (continuation: MessageSendContinuation) in session.sendMessage( payload, - replyHandler: { _ in continuation.resume(returning: ()) }, + replyHandler: { reply in + do { + try requireAcceptedWatchMessageReply(reply) + continuation.resume(returning: ()) + } catch { + continuation.resume(throwing: error) + } + }, errorHandler: { error in continuation.resume(throwing: error) }) } } @@ -651,7 +658,8 @@ extension WatchConnectivityReceiver: WCSessionDelegate { self.activationGate.complete( activated: activationState == .activated, errorDescription: error?.localizedDescription) - if activationState == .activated, !session.receivedApplicationContext.isEmpty { + guard activationState == .activated else { return } + if !session.receivedApplicationContext.isEmpty { self.consumeIncomingPayload( session.receivedApplicationContext, transport: "receivedApplicationContext") @@ -674,8 +682,11 @@ extension WatchConnectivityReceiver: WCSessionDelegate { didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { - replyHandler(["ok": true]) - self.consumeIncomingPayload(message, transport: "sendMessage") + let accepted = self.consumeIncomingPayload(message, transport: "sendMessage") + replyHandler( + accepted + ? ["ok": true] + : ["ok": false, "error": "unsupported_payload"]) } func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { @@ -686,7 +697,8 @@ extension WatchConnectivityReceiver: WCSessionDelegate { self.consumeIncomingPayload(applicationContext, transport: "applicationContext") } - private func consumeIncomingPayload(_ payload: [String: Any], transport: String) { + @discardableResult + private func consumeIncomingPayload(_ payload: [String: Any], transport: String) -> Bool { if let type = payload["type"] as? String, type == WatchPayloadType.directNodeSetup.rawValue, let setupCode = payload["setupCode"] as? String, @@ -695,7 +707,7 @@ extension WatchConnectivityReceiver: WCSessionDelegate { Task { @MainActor in self.directNodeSetupHandler(setupCode, sentAtMs) } - return + return true } let appSnapshot = (payload[WatchPayloadType.appSnapshot.rawValue] as? [String: Any]) .flatMap(Self.parseAppSnapshotPayload) @@ -721,31 +733,31 @@ extension WatchConnectivityReceiver: WCSessionDelegate { } } } - return + return true } if let incoming = Self.parseNotificationPayload(payload) { Task { @MainActor in self.store.consume(message: incoming, transport: transport) } - return + return true } if let prompt = Self.parseExecApprovalPromptPayload(payload) { Task { @MainActor in self.store.consume(execApprovalPrompt: prompt, transport: transport) } - return + return true } if let resolved = Self.parseExecApprovalResolvedPayload(payload) { Task { @MainActor in self.store.consume(execApprovalResolved: resolved) } - return + return true } if let expired = Self.parseExecApprovalExpiredPayload(payload) { Task { @MainActor in self.store.consume(execApprovalExpired: expired) } - return + return true } if let snapshot = Self.parseExecApprovalSnapshotPayload(payload) { Task { @MainActor in @@ -753,7 +765,7 @@ extension WatchConnectivityReceiver: WCSessionDelegate { self.recordAcceptedExecApprovalSnapshot(snapshot) } } - return + return true } if let snapshot = Self.parseAppSnapshotPayload(payload) { Task { @MainActor in @@ -764,12 +776,14 @@ extension WatchConnectivityReceiver: WCSessionDelegate { self.recordAcceptedExecApprovalSnapshot(snapshot) } } - return + return true } if let completion = Self.parseChatCompletionPayload(payload) { Task { @MainActor in self.store.consume(chatCompletion: completion) } + return true } + return false } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index 057d020a2568..6db762408915 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -714,7 +714,9 @@ struct ChatOutboxStatusLabel: View { } .foregroundStyle(self.state.isFailed ? AnyShapeStyle(OpenClawChatTheme.danger) : AnyShapeStyle(.secondary)) .accessibilityElement(children: .combine) - .accessibilityLabel(Text(self.accessibilityText)) + .accessibilityLabel( + Text(self.accessibilityText) + .font(OpenClawChatTypography.caption)) } private var title: LocalizedStringResource { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift index 237923fd370d..473b12adc84d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/OpenClawMascotView.swift @@ -543,7 +543,10 @@ struct OpenClawMascotCanvas: View { y: 24 - 20 * phase) var text = context.resolve( Text("z") - .font(.system(size: 6 + 4 * phase, weight: .bold, design: .rounded))) + .font(OpenClawChatTypography.display( + size: 6 + 4 * phase, + weight: .bold, + relativeTo: .caption2))) text.shading = .color(self.eyeGlowColor) var zContext = context zContext.opacity = Double(alpha * 0.9)