| 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 { |
| |
| } |
| } |
|
|
| _ = 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") |
| } |
| } |
| } |
|
|