iOS: add Apple Watch companion message MVP (#20054)

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
This commit is contained in:
Mariano
2026-02-18 13:37:41 +00:00
committed by GitHub
parent e71e9a55ab
commit 57083e4220
16 changed files with 831 additions and 1 deletions

View File

@@ -0,0 +1,20 @@
import SwiftUI
@main
struct OpenClawWatchApp: App {
@State private var inboxStore = WatchInboxStore()
@State private var receiver: WatchConnectivityReceiver?
var body: some Scene {
WindowGroup {
WatchInboxView(store: self.inboxStore)
.task {
if self.receiver == nil {
let receiver = WatchConnectivityReceiver(store: self.inboxStore)
receiver.activate()
self.receiver = receiver
}
}
}
}
}

View File

@@ -0,0 +1,92 @@
import Foundation
import WatchConnectivity
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 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
return WatchNotifyMessage(
id: id,
title: title,
body: body,
sentAtMs: sentAtMs)
}
}
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
}
Task { @MainActor in
self.store.consume(message: incoming, transport: "sendMessage")
replyHandler(["ok": true])
}
}
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")
}
}
}

View File

@@ -0,0 +1,124 @@
import Foundation
import Observation
import UserNotifications
import WatchKit
struct WatchNotifyMessage: Sendable {
var id: String?
var title: String
var body: String
var sentAtMs: Int?
}
@MainActor @Observable final class WatchInboxStore {
private struct PersistedState: Codable {
var title: String
var body: String
var transport: String
var updatedAt: Date
var lastDeliveryKey: String?
}
private static let persistedStateKey = "watch.inbox.state.v1"
private let defaults: UserDefaults
var title = "OpenClaw"
var body = "Waiting for messages from your iPhone."
var transport = "none"
var updatedAt: Date?
private var lastDeliveryKey: String?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.restorePersistedState()
Task {
await self.ensureNotificationAuthorization()
}
}
func consume(message: WatchNotifyMessage, transport: String) {
let messageID = message.id?
.trimmingCharacters(in: .whitespacesAndNewlines)
let deliveryKey = self.deliveryKey(
messageID: messageID,
title: message.title,
body: message.body,
sentAtMs: message.sentAtMs)
guard deliveryKey != self.lastDeliveryKey else { return }
let normalizedTitle = message.title.isEmpty ? "OpenClaw" : message.title
self.title = normalizedTitle
self.body = message.body
self.transport = transport
self.updatedAt = Date()
self.lastDeliveryKey = deliveryKey
self.persistState()
Task {
await self.postLocalNotification(
identifier: deliveryKey,
title: normalizedTitle,
body: message.body)
}
}
private func restorePersistedState() {
guard let data = self.defaults.data(forKey: Self.persistedStateKey),
let state = try? JSONDecoder().decode(PersistedState.self, from: data)
else {
return
}
self.title = state.title
self.body = state.body
self.transport = state.transport
self.updatedAt = state.updatedAt
self.lastDeliveryKey = state.lastDeliveryKey
}
private func persistState() {
guard let updatedAt = self.updatedAt else { return }
let state = PersistedState(
title: self.title,
body: self.body,
transport: self.transport,
updatedAt: updatedAt,
lastDeliveryKey: self.lastDeliveryKey)
guard let data = try? JSONEncoder().encode(state) else { return }
self.defaults.set(data, forKey: Self.persistedStateKey)
}
private func deliveryKey(messageID: String?, title: String, body: String, sentAtMs: Int?) -> String {
if let messageID, messageID.isEmpty == false {
return "id:\(messageID)"
}
return "content:\(title)|\(body)|\(sentAtMs ?? 0)"
}
private func ensureNotificationAuthorization() async {
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
switch settings.authorizationStatus {
case .notDetermined:
_ = try? await center.requestAuthorization(options: [.alert, .sound])
default:
break
}
}
private func postLocalNotification(identifier: String, title: String, body: String) async {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.threadIdentifier = "openclaw-watch"
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false))
_ = try? await UNUserNotificationCenter.current().add(request)
WKInterfaceDevice.current().play(.notification)
}
}

View File

@@ -0,0 +1,27 @@
import SwiftUI
struct WatchInboxView: View {
@Bindable var store: WatchInboxStore
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text(store.title)
.font(.headline)
.lineLimit(2)
Text(store.body)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
if let updatedAt = store.updatedAt {
Text("Updated \(updatedAt.formatted(date: .omitted, time: .shortened))")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
}