mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 23:40:45 +00:00
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 3c2a01f903
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
237 lines
8.1 KiB
Swift
237 lines
8.1 KiB
Swift
import Foundation
|
|
import WatchConnectivity
|
|
|
|
struct WatchReplyDraft: Sendable {
|
|
var replyId: String
|
|
var promptId: String
|
|
var actionId: String
|
|
var actionLabel: String?
|
|
var sessionKey: String?
|
|
var note: String?
|
|
var sentAtMs: Int
|
|
}
|
|
|
|
struct WatchReplySendResult: Sendable, Equatable {
|
|
var deliveredImmediately: Bool
|
|
var queuedForDelivery: Bool
|
|
var transport: String
|
|
var errorMessage: String?
|
|
}
|
|
|
|
final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
|
private let store: WatchInboxStore
|
|
private let session: WCSession?
|
|
|
|
init(store: WatchInboxStore) {
|
|
self.store = store
|
|
if WCSession.isSupported() {
|
|
self.session = WCSession.default
|
|
} else {
|
|
self.session = nil
|
|
}
|
|
super.init()
|
|
}
|
|
|
|
func activate() {
|
|
guard let session = self.session else { return }
|
|
session.delegate = self
|
|
session.activate()
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func sendReply(_ draft: WatchReplyDraft) async -> WatchReplySendResult {
|
|
await self.ensureActivated()
|
|
guard let session = self.session else {
|
|
return WatchReplySendResult(
|
|
deliveredImmediately: false,
|
|
queuedForDelivery: false,
|
|
transport: "none",
|
|
errorMessage: "watch session unavailable")
|
|
}
|
|
|
|
var payload: [String: Any] = [
|
|
"type": "watch.reply",
|
|
"replyId": draft.replyId,
|
|
"promptId": draft.promptId,
|
|
"actionId": draft.actionId,
|
|
"sentAtMs": draft.sentAtMs,
|
|
]
|
|
if let actionLabel = draft.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!actionLabel.isEmpty
|
|
{
|
|
payload["actionLabel"] = actionLabel
|
|
}
|
|
if let sessionKey = draft.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!sessionKey.isEmpty
|
|
{
|
|
payload["sessionKey"] = sessionKey
|
|
}
|
|
if let note = draft.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
|
|
payload["note"] = note
|
|
}
|
|
|
|
if session.isReachable {
|
|
do {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
session.sendMessage(payload, replyHandler: { _ in
|
|
continuation.resume()
|
|
}, errorHandler: { error in
|
|
continuation.resume(throwing: error)
|
|
})
|
|
}
|
|
return WatchReplySendResult(
|
|
deliveredImmediately: true,
|
|
queuedForDelivery: false,
|
|
transport: "sendMessage",
|
|
errorMessage: nil)
|
|
} catch {
|
|
// Fall through to queued delivery below.
|
|
}
|
|
}
|
|
|
|
_ = session.transferUserInfo(payload)
|
|
return WatchReplySendResult(
|
|
deliveredImmediately: false,
|
|
queuedForDelivery: true,
|
|
transport: "transferUserInfo",
|
|
errorMessage: nil)
|
|
}
|
|
|
|
private static func normalizeObject(_ value: Any) -> [String: Any]? {
|
|
if let object = value as? [String: Any] {
|
|
return object
|
|
}
|
|
if let object = value as? [AnyHashable: Any] {
|
|
var normalized: [String: Any] = [:]
|
|
normalized.reserveCapacity(object.count)
|
|
for (key, item) in object {
|
|
guard let stringKey = key as? String else {
|
|
continue
|
|
}
|
|
normalized[stringKey] = item
|
|
}
|
|
return normalized
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func parseActions(_ value: Any?) -> [WatchPromptAction] {
|
|
guard let raw = value as? [Any] else {
|
|
return []
|
|
}
|
|
return raw.compactMap { item in
|
|
guard let obj = Self.normalizeObject(item) else {
|
|
return nil
|
|
}
|
|
let id = (obj["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let label = (obj["label"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !id.isEmpty, !label.isEmpty else {
|
|
return nil
|
|
}
|
|
let style = (obj["style"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return WatchPromptAction(id: id, label: label, style: style)
|
|
}
|
|
}
|
|
|
|
private static func parseNotificationPayload(_ payload: [String: Any]) -> WatchNotifyMessage? {
|
|
guard let type = payload["type"] as? String, type == "watch.notify" else {
|
|
return nil
|
|
}
|
|
|
|
let title = (payload["title"] as? String)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let body = (payload["body"] as? String)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
guard title.isEmpty == false || body.isEmpty == false else {
|
|
return nil
|
|
}
|
|
|
|
let id = (payload["id"] as? String)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
|
let promptId = (payload["promptId"] as? String)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let sessionKey = (payload["sessionKey"] as? String)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let kind = (payload["kind"] as? String)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let details = (payload["details"] as? String)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue
|
|
let risk = (payload["risk"] as? String)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let actions = Self.parseActions(payload["actions"])
|
|
|
|
return WatchNotifyMessage(
|
|
id: id,
|
|
title: title,
|
|
body: body,
|
|
sentAtMs: sentAtMs,
|
|
promptId: promptId,
|
|
sessionKey: sessionKey,
|
|
kind: kind,
|
|
details: details,
|
|
expiresAtMs: expiresAtMs,
|
|
risk: risk,
|
|
actions: actions)
|
|
}
|
|
}
|
|
|
|
extension WatchConnectivityReceiver: WCSessionDelegate {
|
|
func session(
|
|
_: WCSession,
|
|
activationDidCompleteWith _: WCSessionActivationState,
|
|
error _: (any Error)?)
|
|
{}
|
|
|
|
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
|
guard let incoming = Self.parseNotificationPayload(message) else { return }
|
|
Task { @MainActor in
|
|
self.store.consume(message: incoming, transport: "sendMessage")
|
|
}
|
|
}
|
|
|
|
func session(
|
|
_: WCSession,
|
|
didReceiveMessage message: [String: Any],
|
|
replyHandler: @escaping ([String: Any]) -> Void)
|
|
{
|
|
guard let incoming = Self.parseNotificationPayload(message) else {
|
|
replyHandler(["ok": false])
|
|
return
|
|
}
|
|
replyHandler(["ok": true])
|
|
Task { @MainActor in
|
|
self.store.consume(message: incoming, transport: "sendMessage")
|
|
}
|
|
}
|
|
|
|
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
|
guard let incoming = Self.parseNotificationPayload(userInfo) else { return }
|
|
Task { @MainActor in
|
|
self.store.consume(message: incoming, transport: "transferUserInfo")
|
|
}
|
|
}
|
|
|
|
func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
|
guard let incoming = Self.parseNotificationPayload(applicationContext) else { return }
|
|
Task { @MainActor in
|
|
self.store.consume(message: incoming, transport: "applicationContext")
|
|
}
|
|
}
|
|
}
|