mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 720791ae6b
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
177 lines
5.6 KiB
Swift
177 lines
5.6 KiB
Swift
import Foundation
|
|
import OpenClawKit
|
|
import OSLog
|
|
@preconcurrency import WatchConnectivity
|
|
|
|
enum WatchMessagingError: LocalizedError {
|
|
case unsupported
|
|
case notPaired
|
|
case watchAppNotInstalled
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .unsupported:
|
|
"WATCH_UNAVAILABLE: WatchConnectivity is not supported on this device"
|
|
case .notPaired:
|
|
"WATCH_UNAVAILABLE: no paired Apple Watch"
|
|
case .watchAppNotInstalled:
|
|
"WATCH_UNAVAILABLE: OpenClaw watch companion app is not installed"
|
|
}
|
|
}
|
|
}
|
|
|
|
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
|
|
private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
|
private let session: WCSession?
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
static func isSupportedOnDevice() -> Bool {
|
|
WCSession.isSupported()
|
|
}
|
|
|
|
static func currentStatusSnapshot() -> WatchMessagingStatus {
|
|
guard WCSession.isSupported() else {
|
|
return WatchMessagingStatus(
|
|
supported: false,
|
|
paired: false,
|
|
appInstalled: false,
|
|
reachable: false,
|
|
activationState: "unsupported")
|
|
}
|
|
let session = WCSession.default
|
|
return status(for: session)
|
|
}
|
|
|
|
func status() async -> WatchMessagingStatus {
|
|
await self.ensureActivated()
|
|
guard let session = self.session else {
|
|
return WatchMessagingStatus(
|
|
supported: false,
|
|
paired: false,
|
|
appInstalled: false,
|
|
reachable: false,
|
|
activationState: "unsupported")
|
|
}
|
|
return Self.status(for: session)
|
|
}
|
|
|
|
func sendNotification(
|
|
id: String,
|
|
title: String,
|
|
body: String,
|
|
priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult
|
|
{
|
|
await self.ensureActivated()
|
|
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 }
|
|
|
|
let payload: [String: Any] = [
|
|
"type": "watch.notify",
|
|
"id": id,
|
|
"title": title,
|
|
"body": body,
|
|
"priority": priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
|
|
"sentAtMs": Int(Date().timeIntervalSince1970 * 1000),
|
|
]
|
|
|
|
if snapshot.reachable {
|
|
do {
|
|
try await self.sendReachableMessage(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")
|
|
}
|
|
|
|
private func sendReachableMessage(_ payload: [String: Any], with session: WCSession) async throws {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
session.sendMessage(payload, replyHandler: { _ in
|
|
continuation.resume()
|
|
}, errorHandler: { error in
|
|
continuation.resume(throwing: error)
|
|
})
|
|
}
|
|
}
|
|
|
|
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 static func status(for session: WCSession) -> WatchMessagingStatus {
|
|
WatchMessagingStatus(
|
|
supported: true,
|
|
paired: session.isPaired,
|
|
appInstalled: session.isWatchAppInstalled,
|
|
reachable: session.isReachable,
|
|
activationState: activationStateLabel(session.activationState))
|
|
}
|
|
|
|
private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
|
|
switch state {
|
|
case .notActivated:
|
|
"notActivated"
|
|
case .inactive:
|
|
"inactive"
|
|
case .activated:
|
|
"activated"
|
|
@unknown default:
|
|
"unknown"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension WatchMessagingService: WCSessionDelegate {
|
|
func session(
|
|
_ session: WCSession,
|
|
activationDidCompleteWith activationState: WCSessionActivationState,
|
|
error: (any Error)?)
|
|
{
|
|
if let error {
|
|
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
|
|
return
|
|
}
|
|
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
|
|
}
|
|
|
|
func sessionDidBecomeInactive(_ session: WCSession) {}
|
|
|
|
func sessionDidDeactivate(_ session: WCSession) {
|
|
session.activate()
|
|
}
|
|
|
|
func sessionReachabilityDidChange(_ session: WCSession) {}
|
|
}
|