fix(ios): harden gateway and watch state handling

This commit is contained in:
Vincent Koc
2026-07-13 03:39:59 +02:00
parent b9d53d3cce
commit 5e9bc0916f
28 changed files with 516 additions and 84 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 = ""

View File

@@ -874,7 +874,7 @@ extension SettingsProTab {
}
.contentShape(Rectangle())
}
.accessibilityLabel(Text(title))
.accessibilityLabel(title)
}
func toggleCard(title: LocalizedStringKey, isOn: Binding<Bool>) -> 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"))

View File

@@ -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() {

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -18,8 +18,13 @@ private func sendReachableWatchMessage(_ payload: [String: Any], with session: W
try await withCheckedThrowingContinuation(isolation: nil) { (continuation: CheckedContinuation<Void, Error>) 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()

View File

@@ -1,6 +1,33 @@
import Foundation
import OpenClawKit
struct WatchMessagingStartupBuffer<Event> {
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<StartupEvent>(
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)
}
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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))
}
}

View File

@@ -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",

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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..<source.endIndex))
let connectDiscoveredGateway = String(source[start.lowerBound..<end.lowerBound])
#expect(connectDiscoveredGateway.contains(
"await self.gatewayController.connectWithDiagnostics(gateway)"))
#expect(connectDiscoveredGateway.contains("self.setConnectionFailure(message)"))
#expect(!connectDiscoveredGateway.contains("await self.gatewayController.connect(gateway)"))
}
private static func sourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Onboarding/OnboardingWizardView.swift")
}
}

View File

@@ -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)")

View File

@@ -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..<source.endIndex))
let refreshAll = String(source[start.lowerBound..<end.lowerBound])
let refreshCall = "self.gatewayController.refreshActiveGatewayRegistrationFromSettings()"
#expect(refreshAll.contains("let previousPermissions = PrivacyGatewayPermissionSnapshot("))
#expect(refreshAll.contains("let currentPermissions = PrivacyGatewayPermissionSnapshot("))
#expect(refreshAll.contains("if previousPermissions != currentPermissions"))
#expect(refreshAll.components(separatedBy: refreshCall).count == 2)
#expect(!refreshAll.contains("self.updatePhotosStatus("))
}
@Test func `advertised permissions include granted system access`() {
let snapshot = PrivacyGatewayPermissionSnapshot(
contactsStatus: .limited,
photosStatus: .limited,
calendarStatus: .fullAccess,
remindersStatus: .fullAccess)
#expect(snapshot.contacts)
#expect(snapshot.photos)
#expect(snapshot.calendar)
#expect(snapshot.reminders)
}
@Test func `advertised permissions exclude unavailable system access`() {
let snapshot = PrivacyGatewayPermissionSnapshot(
contactsStatus: .denied,
photosStatus: .restricted,
calendarStatus: .notDetermined,
remindersStatus: .denied)
#expect(!snapshot.contacts)
#expect(!snapshot.photos)
#expect(!snapshot.calendar)
#expect(!snapshot.reminders)
}
@Test func `write only event access is not advertised as readable`() {
let snapshot = PrivacyGatewayPermissionSnapshot(
contactsStatus: .authorized,
photosStatus: .authorized,
calendarStatus: .writeOnly,
remindersStatus: .writeOnly)
#expect(!snapshot.calendar)
#expect(!snapshot.reminders)
}
@Test func `equivalent authorization states do not change advertised snapshot`() {
let first = PrivacyGatewayPermissionSnapshot(
contactsStatus: .denied,
photosStatus: .denied,
calendarStatus: .writeOnly,
remindersStatus: .notDetermined)
let second = PrivacyGatewayPermissionSnapshot(
contactsStatus: .restricted,
photosStatus: .restricted,
calendarStatus: .denied,
remindersStatus: .writeOnly)
#expect(first == second)
}
@Test func `permission grant changes advertised snapshot`() {
let denied = PrivacyGatewayPermissionSnapshot(
contactsStatus: .notDetermined,
photosStatus: .notDetermined,
calendarStatus: .notDetermined,
remindersStatus: .notDetermined)
let granted = PrivacyGatewayPermissionSnapshot(
contactsStatus: .authorized,
photosStatus: .authorized,
calendarStatus: .fullAccess,
remindersStatus: .fullAccess)
#expect(denied != granted)
}
private static func sourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Settings/PrivacyAccessSectionView.swift")
}
}

View File

@@ -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"))

View File

@@ -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\")"))

View File

@@ -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)"))

View File

@@ -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\")"))
}

View File

@@ -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<String>(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)
}
}

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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)