mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-14 10:41:23 +00:00
* fix(ios): harden watch exec approval review * fix(ios): address watch approval review feedback * fix(ios): finalize watch approval background recovery * fix(ios): finalize watch approval background recovery (#61757) (thanks @ngutman)
364 lines
13 KiB
Swift
364 lines
13 KiB
Swift
import Foundation
|
|
import OSLog
|
|
@preconcurrency import WatchConnectivity
|
|
|
|
private struct WatchConnectivityTransportCallbacks {
|
|
var statusUpdateHandler: (@Sendable (WatchMessagingStatus) -> Void)?
|
|
var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
|
var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
|
|
var execApprovalSnapshotRequestHandler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
|
|
}
|
|
|
|
private func sendReachableWatchMessage(_ payload: [String: Any], with session: WCSession) async throws {
|
|
// WatchConnectivity replies arrive on its own queue. Keep this continuation explicitly
|
|
// nonisolated so Swift 6 does not inherit a caller actor (for example MainActor) into the
|
|
// Objective-C callback boundary and trap on the reply callback executor check.
|
|
try await withCheckedThrowingContinuation(isolation: nil) {
|
|
(continuation: CheckedContinuation<Void, Error>) in
|
|
session.sendMessage(
|
|
payload,
|
|
replyHandler: { _ in
|
|
continuation.resume(returning: ())
|
|
},
|
|
errorHandler: { error in
|
|
continuation.resume(throwing: error)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
|
|
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
|
|
|
private let session: WCSession?
|
|
private let callbacksLock = NSLock()
|
|
private var callbacks = WatchConnectivityTransportCallbacks()
|
|
|
|
override init() {
|
|
if WCSession.isSupported() {
|
|
self.session = WCSession.default
|
|
} else {
|
|
self.session = nil
|
|
}
|
|
super.init()
|
|
if let session = self.session {
|
|
session.delegate = self
|
|
session.activate()
|
|
}
|
|
}
|
|
|
|
nonisolated static func isSupportedOnDevice() -> Bool {
|
|
WCSession.isSupported()
|
|
}
|
|
|
|
nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus {
|
|
guard WCSession.isSupported() else {
|
|
return WatchMessagingStatus(
|
|
supported: false,
|
|
paired: false,
|
|
appInstalled: false,
|
|
reachable: false,
|
|
activationState: "unsupported")
|
|
}
|
|
return self.status(for: WCSession.default)
|
|
}
|
|
|
|
func status() async -> WatchMessagingStatus {
|
|
await self.ensureActivated()
|
|
return self.currentStatusSnapshot()
|
|
}
|
|
|
|
func currentStatusSnapshot() -> WatchMessagingStatus {
|
|
guard let session = self.session else {
|
|
return WatchMessagingStatus(
|
|
supported: false,
|
|
paired: false,
|
|
appInstalled: false,
|
|
reachable: false,
|
|
activationState: "unsupported")
|
|
}
|
|
return Self.status(for: session)
|
|
}
|
|
|
|
func setStatusUpdateHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) {
|
|
self.updateCallbacks { $0.statusUpdateHandler = handler }
|
|
}
|
|
|
|
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
|
self.updateCallbacks { $0.replyHandler = handler }
|
|
}
|
|
|
|
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) {
|
|
self.updateCallbacks { $0.execApprovalResolveHandler = handler }
|
|
}
|
|
|
|
func setExecApprovalSnapshotRequestHandler(
|
|
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
|
|
{
|
|
self.updateCallbacks { $0.execApprovalSnapshotRequestHandler = handler }
|
|
}
|
|
|
|
func sendPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult {
|
|
await self.ensureActivated()
|
|
let session = try self.requireReadySession()
|
|
if session.isReachable {
|
|
do {
|
|
try await sendReachableWatchMessage(payload, with: session)
|
|
return WatchNotificationSendResult(
|
|
deliveredImmediately: true,
|
|
queuedForDelivery: false,
|
|
transport: "sendMessage")
|
|
} catch {
|
|
Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
_ = session.transferUserInfo(payload)
|
|
return WatchNotificationSendResult(
|
|
deliveredImmediately: false,
|
|
queuedForDelivery: true,
|
|
transport: "transferUserInfo")
|
|
}
|
|
|
|
func sendSnapshotPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult {
|
|
await self.ensureActivated()
|
|
let session = try self.requireReadySession()
|
|
if session.isReachable {
|
|
do {
|
|
try await sendReachableWatchMessage(payload, with: session)
|
|
return WatchNotificationSendResult(
|
|
deliveredImmediately: true,
|
|
queuedForDelivery: false,
|
|
transport: "sendMessage")
|
|
} catch {
|
|
Self.logger.error(
|
|
"watch snapshot sendMessage failed: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
do {
|
|
try session.updateApplicationContext(payload)
|
|
return WatchNotificationSendResult(
|
|
deliveredImmediately: false,
|
|
queuedForDelivery: true,
|
|
transport: "applicationContext")
|
|
} catch {
|
|
Self.logger.error(
|
|
"watch updateApplicationContext failed: \(error.localizedDescription, privacy: .public)")
|
|
_ = session.transferUserInfo(payload)
|
|
return WatchNotificationSendResult(
|
|
deliveredImmediately: false,
|
|
queuedForDelivery: true,
|
|
transport: "transferUserInfo")
|
|
}
|
|
}
|
|
|
|
private func updateCallbacks(_ update: (inout WatchConnectivityTransportCallbacks) -> Void) {
|
|
self.callbacksLock.lock()
|
|
defer { self.callbacksLock.unlock() }
|
|
update(&self.callbacks)
|
|
}
|
|
|
|
private func callbacksSnapshot() -> WatchConnectivityTransportCallbacks {
|
|
self.callbacksLock.lock()
|
|
defer { self.callbacksLock.unlock() }
|
|
return self.callbacks
|
|
}
|
|
|
|
private func requireReadySession() throws -> WCSession {
|
|
guard let session = self.session else {
|
|
throw WatchMessagingError.unsupported
|
|
}
|
|
let snapshot = Self.status(for: session)
|
|
guard snapshot.paired else {
|
|
throw WatchMessagingError.notPaired
|
|
}
|
|
guard snapshot.appInstalled else {
|
|
throw WatchMessagingError.watchAppNotInstalled
|
|
}
|
|
return session
|
|
}
|
|
|
|
private func ensureActivated() async {
|
|
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)
|
|
}
|
|
}
|
|
|
|
private func emitStatusUpdate(_ snapshot: WatchMessagingStatus) {
|
|
guard let handler = self.callbacksSnapshot().statusUpdateHandler else {
|
|
return
|
|
}
|
|
Task { @MainActor in
|
|
handler(snapshot)
|
|
}
|
|
}
|
|
|
|
private func emitReply(_ event: WatchQuickReplyEvent) {
|
|
guard let handler = self.callbacksSnapshot().replyHandler else {
|
|
return
|
|
}
|
|
Task { @MainActor in
|
|
handler(event)
|
|
}
|
|
}
|
|
|
|
private func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) {
|
|
guard let handler = self.callbacksSnapshot().execApprovalResolveHandler else {
|
|
return
|
|
}
|
|
Task { @MainActor in
|
|
handler(event)
|
|
}
|
|
}
|
|
|
|
private func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) {
|
|
guard let handler = self.callbacksSnapshot().execApprovalSnapshotRequestHandler else {
|
|
return
|
|
}
|
|
Task { @MainActor in
|
|
handler(event)
|
|
}
|
|
}
|
|
|
|
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus {
|
|
WatchMessagingStatus(
|
|
supported: true,
|
|
paired: session.isPaired,
|
|
appInstalled: session.isWatchAppInstalled,
|
|
reachable: session.isReachable,
|
|
activationState: self.activationStateLabel(session.activationState))
|
|
}
|
|
|
|
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
|
switch state {
|
|
case .notActivated:
|
|
"notActivated"
|
|
case .inactive:
|
|
"inactive"
|
|
case .activated:
|
|
"activated"
|
|
@unknown default:
|
|
"unknown"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension WatchConnectivityTransport: WCSessionDelegate {
|
|
func session(
|
|
_ session: WCSession,
|
|
activationDidCompleteWith activationState: WCSessionActivationState,
|
|
error: (any Error)?)
|
|
{
|
|
GatewayDiagnostics.log(
|
|
"watch messaging: activation complete state=\(Self.activationStateLabel(activationState)) error=\(error?.localizedDescription ?? "none")")
|
|
if let error {
|
|
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
|
|
} else {
|
|
Self.logger.debug(
|
|
"watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
|
|
}
|
|
self.emitStatusUpdate(Self.status(for: session))
|
|
}
|
|
|
|
func sessionDidBecomeInactive(_: WCSession) {}
|
|
|
|
func sessionDidDeactivate(_ session: WCSession) {
|
|
GatewayDiagnostics.log("watch messaging: session did deactivate; reactivating")
|
|
session.activate()
|
|
self.emitStatusUpdate(Self.status(for: session))
|
|
}
|
|
|
|
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
|
let type = (message["type"] as? String) ?? "unknown"
|
|
GatewayDiagnostics.log("watch messaging: didReceiveMessage type=\(type)")
|
|
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(message, transport: "sendMessage") {
|
|
self.emitReply(event)
|
|
return
|
|
}
|
|
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
|
|
message,
|
|
transport: "sendMessage")
|
|
{
|
|
self.emitExecApprovalResolve(event)
|
|
return
|
|
}
|
|
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
|
|
message,
|
|
transport: "sendMessage")
|
|
{
|
|
self.emitExecApprovalSnapshotRequest(event)
|
|
}
|
|
}
|
|
|
|
func session(
|
|
_: WCSession,
|
|
didReceiveMessage message: [String: Any],
|
|
replyHandler: @escaping ([String: Any]) -> Void)
|
|
{
|
|
let type = (message["type"] as? String) ?? "unknown"
|
|
GatewayDiagnostics.log("watch messaging: didReceiveMessageWithReply type=\(type)")
|
|
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(message, transport: "sendMessage") {
|
|
replyHandler(["ok": true])
|
|
self.emitReply(event)
|
|
return
|
|
}
|
|
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
|
|
message,
|
|
transport: "sendMessage")
|
|
{
|
|
replyHandler(["ok": true])
|
|
self.emitExecApprovalResolve(event)
|
|
return
|
|
}
|
|
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
|
|
message,
|
|
transport: "sendMessage")
|
|
{
|
|
replyHandler(["ok": true])
|
|
self.emitExecApprovalSnapshotRequest(event)
|
|
return
|
|
}
|
|
replyHandler(["ok": false, "error": "unsupported_payload"])
|
|
}
|
|
|
|
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
|
let type = (userInfo["type"] as? String) ?? "unknown"
|
|
GatewayDiagnostics.log("watch messaging: didReceiveUserInfo type=\(type)")
|
|
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(
|
|
userInfo,
|
|
transport: "transferUserInfo")
|
|
{
|
|
self.emitReply(event)
|
|
return
|
|
}
|
|
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
|
|
userInfo,
|
|
transport: "transferUserInfo")
|
|
{
|
|
self.emitExecApprovalResolve(event)
|
|
return
|
|
}
|
|
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
|
|
userInfo,
|
|
transport: "transferUserInfo")
|
|
{
|
|
self.emitExecApprovalSnapshotRequest(event)
|
|
}
|
|
}
|
|
|
|
func sessionReachabilityDidChange(_ session: WCSession) {
|
|
GatewayDiagnostics.log(
|
|
"watch messaging: reachability changed reachable=\(session.isReachable) paired=\(session.isPaired) installed=\(session.isWatchAppInstalled)")
|
|
self.emitStatusUpdate(Self.status(for: session))
|
|
}
|
|
}
|