mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 11:26:04 +00:00
fix(ios): harden gateway and watch state handling
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
21
apps/ios/Tests/AgentOverviewRefreshGateTests.swift
Normal file
21
apps/ios/Tests/AgentOverviewRefreshGateTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
24
apps/ios/Tests/OnboardingDiscoveredGatewayTests.swift
Normal file
24
apps/ios/Tests/OnboardingDiscoveredGatewayTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
|
||||
97
apps/ios/Tests/PrivacyAccessRegistrationTests.swift
Normal file
97
apps/ios/Tests/PrivacyAccessRegistrationTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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\")"))
|
||||
|
||||
@@ -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)"))
|
||||
|
||||
@@ -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\")"))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user