Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
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) { action in
|
||||
guard let receiver = self.receiver else { return }
|
||||
let draft = self.inboxStore.makeReplyDraft(action: action)
|
||||
self.inboxStore.markReplySending(actionLabel: action.label)
|
||||
Task { @MainActor in
|
||||
let result = await receiver.sendReply(draft)
|
||||
self.inboxStore.markReplyResult(result, actionLabel: action.label)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if self.receiver == nil {
|
||||
let receiver = WatchConnectivityReceiver(store: self.inboxStore)
|
||||
receiver.activate()
|
||||
self.receiver = receiver
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
230
openclaw/apps/ios/WatchExtension/Sources/WatchInboxStore.swift
Normal file
230
openclaw/apps/ios/WatchExtension/Sources/WatchInboxStore.swift
Normal file
@@ -0,0 +1,230 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import UserNotifications
|
||||
import WatchKit
|
||||
|
||||
struct WatchPromptAction: Codable, Sendable, Equatable, Identifiable {
|
||||
var id: String
|
||||
var label: String
|
||||
var style: String?
|
||||
}
|
||||
|
||||
struct WatchNotifyMessage: Sendable {
|
||||
var id: String?
|
||||
var title: String
|
||||
var body: String
|
||||
var sentAtMs: Int?
|
||||
var promptId: String?
|
||||
var sessionKey: String?
|
||||
var kind: String?
|
||||
var details: String?
|
||||
var expiresAtMs: Int?
|
||||
var risk: String?
|
||||
var actions: [WatchPromptAction]
|
||||
}
|
||||
|
||||
@MainActor @Observable final class WatchInboxStore {
|
||||
private struct PersistedState: Codable {
|
||||
var title: String
|
||||
var body: String
|
||||
var transport: String
|
||||
var updatedAt: Date
|
||||
var lastDeliveryKey: String?
|
||||
var promptId: String?
|
||||
var sessionKey: String?
|
||||
var kind: String?
|
||||
var details: String?
|
||||
var expiresAtMs: Int?
|
||||
var risk: String?
|
||||
var actions: [WatchPromptAction]?
|
||||
var replyStatusText: String?
|
||||
var replyStatusAt: Date?
|
||||
}
|
||||
|
||||
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?
|
||||
var promptId: String?
|
||||
var sessionKey: String?
|
||||
var kind: String?
|
||||
var details: String?
|
||||
var expiresAtMs: Int?
|
||||
var risk: String?
|
||||
var actions: [WatchPromptAction] = []
|
||||
var replyStatusText: String?
|
||||
var replyStatusAt: Date?
|
||||
var isReplySending = false
|
||||
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.promptId = message.promptId
|
||||
self.sessionKey = message.sessionKey
|
||||
self.kind = message.kind
|
||||
self.details = message.details
|
||||
self.expiresAtMs = message.expiresAtMs
|
||||
self.risk = message.risk
|
||||
self.actions = message.actions
|
||||
self.lastDeliveryKey = deliveryKey
|
||||
self.replyStatusText = nil
|
||||
self.replyStatusAt = nil
|
||||
self.isReplySending = false
|
||||
self.persistState()
|
||||
|
||||
Task {
|
||||
await self.postLocalNotification(
|
||||
identifier: deliveryKey,
|
||||
title: normalizedTitle,
|
||||
body: message.body,
|
||||
risk: message.risk)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
self.promptId = state.promptId
|
||||
self.sessionKey = state.sessionKey
|
||||
self.kind = state.kind
|
||||
self.details = state.details
|
||||
self.expiresAtMs = state.expiresAtMs
|
||||
self.risk = state.risk
|
||||
self.actions = state.actions ?? []
|
||||
self.replyStatusText = state.replyStatusText
|
||||
self.replyStatusAt = state.replyStatusAt
|
||||
}
|
||||
|
||||
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,
|
||||
promptId: self.promptId,
|
||||
sessionKey: self.sessionKey,
|
||||
kind: self.kind,
|
||||
details: self.details,
|
||||
expiresAtMs: self.expiresAtMs,
|
||||
risk: self.risk,
|
||||
actions: self.actions,
|
||||
replyStatusText: self.replyStatusText,
|
||||
replyStatusAt: self.replyStatusAt)
|
||||
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 mapHapticRisk(_ risk: String?) -> WKHapticType {
|
||||
switch risk?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "high":
|
||||
return .failure
|
||||
case "medium":
|
||||
return .notification
|
||||
default:
|
||||
return .click
|
||||
}
|
||||
}
|
||||
|
||||
func makeReplyDraft(action: WatchPromptAction) -> WatchReplyDraft {
|
||||
let prompt = self.promptId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return WatchReplyDraft(
|
||||
replyId: UUID().uuidString,
|
||||
promptId: (prompt?.isEmpty == false) ? prompt! : "unknown",
|
||||
actionId: action.id,
|
||||
actionLabel: action.label,
|
||||
sessionKey: self.sessionKey,
|
||||
note: nil,
|
||||
sentAtMs: Int(Date().timeIntervalSince1970 * 1000))
|
||||
}
|
||||
|
||||
func markReplySending(actionLabel: String) {
|
||||
self.isReplySending = true
|
||||
self.replyStatusText = "Sending \(actionLabel)…"
|
||||
self.replyStatusAt = Date()
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
func markReplyResult(_ result: WatchReplySendResult, actionLabel: String) {
|
||||
self.isReplySending = false
|
||||
if let errorMessage = result.errorMessage, !errorMessage.isEmpty {
|
||||
self.replyStatusText = "Failed: \(errorMessage)"
|
||||
} else if result.deliveredImmediately {
|
||||
self.replyStatusText = "\(actionLabel): sent"
|
||||
} else if result.queuedForDelivery {
|
||||
self.replyStatusText = "\(actionLabel): queued"
|
||||
} else {
|
||||
self.replyStatusText = "\(actionLabel): sent"
|
||||
}
|
||||
self.replyStatusAt = Date()
|
||||
self.persistState()
|
||||
}
|
||||
|
||||
private func postLocalNotification(identifier: String, title: String, body: String, risk: 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(self.mapHapticRisk(risk))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WatchInboxView: View {
|
||||
@Bindable var store: WatchInboxStore
|
||||
var onAction: ((WatchPromptAction) -> Void)?
|
||||
|
||||
private func role(for action: WatchPromptAction) -> ButtonRole? {
|
||||
switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "destructive":
|
||||
return .destructive
|
||||
case "cancel":
|
||||
return .cancel
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
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 details = store.details, !details.isEmpty {
|
||||
Text(details)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if !store.actions.isEmpty {
|
||||
ForEach(store.actions) { action in
|
||||
Button(role: self.role(for: action)) {
|
||||
self.onAction?(action)
|
||||
} label: {
|
||||
Text(action.label)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.disabled(store.isReplySending)
|
||||
}
|
||||
}
|
||||
|
||||
if let replyStatusText = store.replyStatusText, !replyStatusText.isEmpty {
|
||||
Text(replyStatusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let updatedAt = store.updatedAt {
|
||||
Text("Updated \(updatedAt.formatted(date: .omitted, time: .shortened))")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user