diff --git a/CHANGELOG.md b/CHANGELOG.md index 72667265681..8207a77a49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Docs/security hardening guidance: document Docker `DOCKER-USER` + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan. - iOS/Voice timing safety: guard system speech start/finish callbacks to the active utterance to avoid misattributed start events during rapid stop/restart cycles. (#33304) thanks @mbelinky; original implementation direction by @ngutman. - iOS/Talk incremental speech pacing: allow long punctuation-free assistant chunks to start speaking at safe whitespace boundaries so voice responses begin sooner instead of waiting for terminal punctuation. (#33305) thanks @mbelinky; original implementation by @ngutman. +- iOS/Watch reply reliability: make watch session activation waiters robust under concurrent requests so status/send calls no longer hang intermittently, and align delegate callbacks with Swift 6 actor safety. (#33306) thanks @mbelinky; original implementation by @Rocuts. - Docs/tool-loop detection config keys: align `docs/tools/loop-detection.md` examples and field names with the current `tools.loopDetection` schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd. - Gateway/session agent discovery: include disk-scanned agent IDs in `listConfiguredAgentIds` even when `agents.list` is configured, so disk-only/ACP agent sessions remain visible in gateway session aggregation and listings. (#32831) thanks @Sid-Qin. - Discord/inbound debouncer: skip bot-own MESSAGE_CREATE events before they reach the debounce queue to avoid self-triggered slowdowns in busy servers. Thanks @thewilloftheshadow. diff --git a/apps/ios/Sources/Services/WatchMessagingService.swift b/apps/ios/Sources/Services/WatchMessagingService.swift index e173a63c8e2..3db866b98f1 100644 --- a/apps/ios/Sources/Services/WatchMessagingService.swift +++ b/apps/ios/Sources/Services/WatchMessagingService.swift @@ -20,10 +20,11 @@ enum WatchMessagingError: LocalizedError { } } -final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable { - private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging") +@MainActor +final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServicing { + nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging") private let session: WCSession? - private let replyHandlerLock = NSLock() + private var pendingActivationContinuations: [CheckedContinuation] = [] private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? override init() { @@ -39,11 +40,11 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked } } - static func isSupportedOnDevice() -> Bool { + nonisolated static func isSupportedOnDevice() -> Bool { WCSession.isSupported() } - static func currentStatusSnapshot() -> WatchMessagingStatus { + nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus { guard WCSession.isSupported() else { return WatchMessagingStatus( supported: false, @@ -70,9 +71,7 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked } func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { - self.replyHandlerLock.lock() self.replyHandler = handler - self.replyHandlerLock.unlock() } func sendNotification( @@ -161,19 +160,15 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked } private func emitReply(_ event: WatchQuickReplyEvent) { - let handler: ((WatchQuickReplyEvent) -> Void)? - self.replyHandlerLock.lock() - handler = self.replyHandler - self.replyHandlerLock.unlock() - handler?(event) + self.replyHandler?(event) } - private static func nonEmpty(_ value: String?) -> String? { + nonisolated private static func nonEmpty(_ value: String?) -> String? { let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } - private static func parseQuickReplyPayload( + nonisolated private static func parseQuickReplyPayload( _ payload: [String: Any], transport: String) -> WatchQuickReplyEvent? { @@ -205,13 +200,12 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked guard let session = self.session else { return } if session.activationState == .activated { return } session.activate() - for _ in 0..<8 { - if session.activationState == .activated { return } - try? await Task.sleep(nanoseconds: 100_000_000) + await withCheckedContinuation { continuation in + self.pendingActivationContinuations.append(continuation) } } - private static func status(for session: WCSession) -> WatchMessagingStatus { + nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus { WatchMessagingStatus( supported: true, paired: session.isPaired, @@ -220,7 +214,7 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked activationState: activationStateLabel(session.activationState)) } - private static func activationStateLabel(_ state: WCSessionActivationState) -> String { + nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String { switch state { case .notActivated: "notActivated" @@ -235,32 +229,42 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked } extension WatchMessagingService: WCSessionDelegate { - func session( + nonisolated func session( _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { if let error { Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)") - return + } else { + Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)") + } + // Always resume all waiters so callers never hang, even on error. + Task { @MainActor in + let waiters = self.pendingActivationContinuations + self.pendingActivationContinuations.removeAll() + for continuation in waiters { + continuation.resume() + } } - Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)") } - func sessionDidBecomeInactive(_ session: WCSession) {} + nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} - func sessionDidDeactivate(_ session: WCSession) { + nonisolated func sessionDidDeactivate(_ session: WCSession) { session.activate() } - func session(_: WCSession, didReceiveMessage message: [String: Any]) { + nonisolated func session(_: WCSession, didReceiveMessage message: [String: Any]) { guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else { return } - self.emitReply(event) + Task { @MainActor in + self.emitReply(event) + } } - func session( + nonisolated func session( _: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) @@ -270,15 +274,19 @@ extension WatchMessagingService: WCSessionDelegate { return } replyHandler(["ok": true]) - self.emitReply(event) + Task { @MainActor in + self.emitReply(event) + } } - func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + nonisolated func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else { return } - self.emitReply(event) + Task { @MainActor in + self.emitReply(event) + } } - func sessionReachabilityDidChange(_ session: WCSession) {} + nonisolated func sessionReachabilityDidChange(_ session: WCSession) {} }