mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
iOS/watch: add actionable watch approvals and quick replies openclaw#21996 thanks @mbelinky
This commit is contained in:
committed by
mbelinky
parent
8e4f6c0384
commit
3c2a01f903
@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Shared/Security: reject insecure deep links that use `ws://` non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky.
|
||||
- macOS/Security: reject non-loopback `ws://` remote gateway URLs in macOS remote config to block insecure plaintext websocket endpoints. (#21971) Thanks @mbelinky.
|
||||
- Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky.
|
||||
- iOS/Watch: add actionable watch approval/reject controls and quick-reply actions so watch-originated approvals and responses can be sent directly from notification flows. (#21996) Thanks @mbelinky.
|
||||
- CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate `/v1` paths during setup checks. (#21336) Thanks @17jmumford.
|
||||
- Security/Dependencies: bump transitive `hono` usage to `4.11.10` to incorporate timing-safe authentication comparison hardening for `basicAuth`/`bearerAuth` (`GHSA-gq3j-xvxp-8hrf`). Thanks @vincentkoc.
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ final class NodeAppModel {
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
|
||||
enum CameraHUDKind {
|
||||
case photo
|
||||
case recording
|
||||
@@ -109,6 +110,8 @@ final class NodeAppModel {
|
||||
private var backgroundReconnectSuppressed = false
|
||||
private var backgroundReconnectLeaseUntil: Date?
|
||||
private var lastSignificantLocationWakeAt: Date?
|
||||
private var queuedWatchReplies: [WatchQuickReplyEvent] = []
|
||||
private var seenWatchReplyIds = Set<String>()
|
||||
|
||||
private var gatewayConnected = false
|
||||
private var operatorConnected = false
|
||||
@@ -155,6 +158,11 @@ final class NodeAppModel {
|
||||
self.talkMode = talkMode
|
||||
self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey)
|
||||
GatewayDiagnostics.bootstrap()
|
||||
self.watchMessagingService.setReplyHandler { [weak self] event in
|
||||
Task { @MainActor in
|
||||
await self?.handleWatchQuickReply(event)
|
||||
}
|
||||
}
|
||||
|
||||
self.voiceWake.configure { [weak self] cmd in
|
||||
guard let self else { return }
|
||||
@@ -1608,9 +1616,7 @@ private extension NodeAppModel {
|
||||
do {
|
||||
let result = try await self.watchMessagingService.sendNotification(
|
||||
id: req.id,
|
||||
title: title,
|
||||
body: body,
|
||||
priority: params.priority)
|
||||
params: params)
|
||||
let payload = OpenClawWatchNotifyPayload(
|
||||
deliveredImmediately: result.deliveredImmediately,
|
||||
queuedForDelivery: result.queuedForDelivery,
|
||||
@@ -2255,6 +2261,90 @@ extension NodeAppModel {
|
||||
/// Back-compat hook retained for older gateway-connect flows.
|
||||
func onNodeGatewayConnected() async {
|
||||
await self.registerAPNsTokenIfNeeded()
|
||||
await self.flushQueuedWatchRepliesIfConnected()
|
||||
}
|
||||
|
||||
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
|
||||
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if replyId.isEmpty || actionId.isEmpty {
|
||||
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
|
||||
return
|
||||
}
|
||||
|
||||
if self.seenWatchReplyIds.contains(replyId) {
|
||||
self.watchReplyLogger.debug(
|
||||
"watch reply deduped replyId=\(replyId, privacy: .public)")
|
||||
return
|
||||
}
|
||||
self.seenWatchReplyIds.insert(replyId)
|
||||
|
||||
if await !self.isGatewayConnected() {
|
||||
self.queuedWatchReplies.append(event)
|
||||
self.watchReplyLogger.info(
|
||||
"watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
}
|
||||
|
||||
private func flushQueuedWatchRepliesIfConnected() async {
|
||||
guard await self.isGatewayConnected() else { return }
|
||||
guard !self.queuedWatchReplies.isEmpty else { return }
|
||||
|
||||
let pending = self.queuedWatchReplies
|
||||
self.queuedWatchReplies.removeAll()
|
||||
for event in pending {
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func forwardWatchReplyToAgent(_ event: WatchQuickReplyEvent) async {
|
||||
let sessionKey = event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let effectiveSessionKey = (sessionKey?.isEmpty == false) ? sessionKey : self.mainSessionKey
|
||||
let message = Self.makeWatchReplyAgentMessage(event)
|
||||
let link = AgentDeepLink(
|
||||
message: message,
|
||||
sessionKey: effectiveSessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: nil,
|
||||
timeoutSeconds: nil,
|
||||
key: event.replyId)
|
||||
do {
|
||||
try await self.sendAgentRequest(link: link)
|
||||
self.watchReplyLogger.info(
|
||||
"watch reply forwarded replyId=\(event.replyId, privacy: .public) action=\(event.actionId, privacy: .public)")
|
||||
self.openChatRequestID &+= 1
|
||||
} catch {
|
||||
self.watchReplyLogger.error(
|
||||
"watch reply forwarding failed replyId=\(event.replyId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
self.queuedWatchReplies.insert(event, at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeWatchReplyAgentMessage(_ event: WatchQuickReplyEvent) -> String {
|
||||
let actionLabel = event.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let promptId = event.promptId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let transport = event.transport.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let summary = actionLabel?.isEmpty == false ? actionLabel! : event.actionId
|
||||
var lines: [String] = []
|
||||
lines.append("Watch reply: \(summary)")
|
||||
lines.append("promptId=\(promptId.isEmpty ? "unknown" : promptId)")
|
||||
lines.append("actionId=\(event.actionId)")
|
||||
lines.append("replyId=\(event.replyId)")
|
||||
if !transport.isEmpty {
|
||||
lines.append("transport=\(transport)")
|
||||
}
|
||||
if let sentAtMs = event.sentAtMs {
|
||||
lines.append("sentAtMs=\(sentAtMs)")
|
||||
}
|
||||
if let note = event.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
|
||||
lines.append("note=\(note)")
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool {
|
||||
@@ -2497,5 +2587,9 @@ extension NodeAppModel {
|
||||
func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
|
||||
self.applyTalkModeSync(enabled: enabled, phase: phase)
|
||||
}
|
||||
|
||||
func _test_queuedWatchReplyCount() -> Int {
|
||||
self.queuedWatchReplies.count
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -73,6 +73,17 @@ struct WatchMessagingStatus: Sendable, Equatable {
|
||||
var activationState: String
|
||||
}
|
||||
|
||||
struct WatchQuickReplyEvent: Sendable, Equatable {
|
||||
var replyId: String
|
||||
var promptId: String
|
||||
var actionId: String
|
||||
var actionLabel: String?
|
||||
var sessionKey: String?
|
||||
var note: String?
|
||||
var sentAtMs: Int?
|
||||
var transport: String
|
||||
}
|
||||
|
||||
struct WatchNotificationSendResult: Sendable, Equatable {
|
||||
var deliveredImmediately: Bool
|
||||
var queuedForDelivery: Bool
|
||||
@@ -81,11 +92,10 @@ struct WatchNotificationSendResult: Sendable, Equatable {
|
||||
|
||||
protocol WatchMessagingServicing: AnyObject, Sendable {
|
||||
func status() async -> WatchMessagingStatus
|
||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?)
|
||||
func sendNotification(
|
||||
id: String,
|
||||
title: String,
|
||||
body: String,
|
||||
priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult
|
||||
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
|
||||
}
|
||||
|
||||
extension CameraController: CameraServicing {}
|
||||
|
||||
@@ -23,6 +23,8 @@ enum WatchMessagingError: LocalizedError {
|
||||
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
|
||||
private let session: WCSession?
|
||||
private let replyHandlerLock = NSLock()
|
||||
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||
|
||||
override init() {
|
||||
if WCSession.isSupported() {
|
||||
@@ -67,11 +69,15 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
||||
return Self.status(for: session)
|
||||
}
|
||||
|
||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
||||
self.replyHandlerLock.lock()
|
||||
self.replyHandler = handler
|
||||
self.replyHandlerLock.unlock()
|
||||
}
|
||||
|
||||
func sendNotification(
|
||||
id: String,
|
||||
title: String,
|
||||
body: String,
|
||||
priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult
|
||||
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
await self.ensureActivated()
|
||||
guard let session = self.session else {
|
||||
@@ -82,14 +88,44 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
||||
guard snapshot.paired else { throw WatchMessagingError.notPaired }
|
||||
guard snapshot.appInstalled else { throw WatchMessagingError.watchAppNotInstalled }
|
||||
|
||||
let payload: [String: Any] = [
|
||||
var payload: [String: Any] = [
|
||||
"type": "watch.notify",
|
||||
"id": id,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"priority": priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
|
||||
"title": params.title,
|
||||
"body": params.body,
|
||||
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
|
||||
"sentAtMs": Int(Date().timeIntervalSince1970 * 1000),
|
||||
]
|
||||
if let promptId = Self.nonEmpty(params.promptId) {
|
||||
payload["promptId"] = promptId
|
||||
}
|
||||
if let sessionKey = Self.nonEmpty(params.sessionKey) {
|
||||
payload["sessionKey"] = sessionKey
|
||||
}
|
||||
if let kind = Self.nonEmpty(params.kind) {
|
||||
payload["kind"] = kind
|
||||
}
|
||||
if let details = Self.nonEmpty(params.details) {
|
||||
payload["details"] = details
|
||||
}
|
||||
if let expiresAtMs = params.expiresAtMs {
|
||||
payload["expiresAtMs"] = expiresAtMs
|
||||
}
|
||||
if let risk = params.risk {
|
||||
payload["risk"] = risk.rawValue
|
||||
}
|
||||
if let actions = params.actions, !actions.isEmpty {
|
||||
payload["actions"] = actions.map { action in
|
||||
var encoded: [String: Any] = [
|
||||
"id": action.id,
|
||||
"label": action.label,
|
||||
]
|
||||
if let style = Self.nonEmpty(action.style) {
|
||||
encoded["style"] = style
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.reachable {
|
||||
do {
|
||||
@@ -120,6 +156,47 @@ final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked
|
||||
}
|
||||
}
|
||||
|
||||
private func emitReply(_ event: WatchQuickReplyEvent) {
|
||||
let handler: ((WatchQuickReplyEvent) -> Void)?
|
||||
self.replyHandlerLock.lock()
|
||||
handler = self.replyHandler
|
||||
self.replyHandlerLock.unlock()
|
||||
handler?(event)
|
||||
}
|
||||
|
||||
private static func nonEmpty(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func parseQuickReplyPayload(
|
||||
_ payload: [String: Any],
|
||||
transport: String) -> WatchQuickReplyEvent?
|
||||
{
|
||||
guard (payload["type"] as? String) == "watch.reply" else {
|
||||
return nil
|
||||
}
|
||||
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
|
||||
return nil
|
||||
}
|
||||
let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown"
|
||||
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
|
||||
let actionLabel = nonEmpty(payload["actionLabel"] as? String)
|
||||
let sessionKey = nonEmpty(payload["sessionKey"] as? String)
|
||||
let note = nonEmpty(payload["note"] as? String)
|
||||
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
|
||||
|
||||
return WatchQuickReplyEvent(
|
||||
replyId: replyId,
|
||||
promptId: promptId,
|
||||
actionId: actionId,
|
||||
actionLabel: actionLabel,
|
||||
sessionKey: sessionKey,
|
||||
note: note,
|
||||
sentAtMs: sentAtMs,
|
||||
transport: transport)
|
||||
}
|
||||
|
||||
private func ensureActivated() async {
|
||||
guard let session = self.session else { return }
|
||||
if session.activationState == .activated { return }
|
||||
@@ -172,5 +249,32 @@ extension WatchMessagingService: WCSessionDelegate {
|
||||
session.activate()
|
||||
}
|
||||
|
||||
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
|
||||
return
|
||||
}
|
||||
self.emitReply(event)
|
||||
}
|
||||
|
||||
func session(
|
||||
_: WCSession,
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void)
|
||||
{
|
||||
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
|
||||
replyHandler(["ok": false, "error": "unsupported_payload"])
|
||||
return
|
||||
}
|
||||
replyHandler(["ok": true])
|
||||
self.emitReply(event)
|
||||
}
|
||||
|
||||
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else {
|
||||
return
|
||||
}
|
||||
self.emitReply(event)
|
||||
}
|
||||
|
||||
func sessionReachabilityDidChange(_ session: WCSession) {}
|
||||
}
|
||||
|
||||
@@ -42,24 +42,28 @@ private final class MockWatchMessagingService: WatchMessagingServicing, @uncheck
|
||||
queuedForDelivery: false,
|
||||
transport: "sendMessage")
|
||||
var sendError: Error?
|
||||
var lastSent: (id: String, title: String, body: String, priority: OpenClawNotificationPriority?)?
|
||||
var lastSent: (id: String, params: OpenClawWatchNotifyParams)?
|
||||
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||
|
||||
func status() async -> WatchMessagingStatus {
|
||||
self.currentStatus
|
||||
}
|
||||
|
||||
func sendNotification(
|
||||
id: String,
|
||||
title: String,
|
||||
body: String,
|
||||
priority: OpenClawNotificationPriority?) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
self.lastSent = (id: id, title: title, body: body, priority: priority)
|
||||
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
|
||||
self.replyHandler = handler
|
||||
}
|
||||
|
||||
func sendNotification(id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult {
|
||||
self.lastSent = (id: id, params: params)
|
||||
if let sendError = self.sendError {
|
||||
throw sendError
|
||||
}
|
||||
return self.nextSendResult
|
||||
}
|
||||
|
||||
func emitReply(_ event: WatchQuickReplyEvent) {
|
||||
self.replyHandler?(event)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct NodeAppModelInvokeTests {
|
||||
@@ -243,9 +247,9 @@ private final class MockWatchMessagingService: WatchMessagingServicing, @uncheck
|
||||
|
||||
let res = await appModel._test_handleInvoke(req)
|
||||
#expect(res.ok == true)
|
||||
#expect(watchService.lastSent?.title == "OpenClaw")
|
||||
#expect(watchService.lastSent?.body == "Meeting with Peter is at 4pm")
|
||||
#expect(watchService.lastSent?.priority == .timeSensitive)
|
||||
#expect(watchService.lastSent?.params.title == "OpenClaw")
|
||||
#expect(watchService.lastSent?.params.body == "Meeting with Peter is at 4pm")
|
||||
#expect(watchService.lastSent?.params.priority == .timeSensitive)
|
||||
|
||||
let payloadData = try #require(res.payloadJSON?.data(using: .utf8))
|
||||
let payload = try JSONDecoder().decode(OpenClawWatchNotifyPayload.self, from: payloadData)
|
||||
@@ -292,6 +296,22 @@ private final class MockWatchMessagingService: WatchMessagingServicing, @uncheck
|
||||
#expect(res.error?.message.contains("WATCH_UNAVAILABLE") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func watchReplyQueuesWhenGatewayOffline() async {
|
||||
let watchService = MockWatchMessagingService()
|
||||
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||
watchService.emitReply(
|
||||
WatchQuickReplyEvent(
|
||||
replyId: "reply-offline-1",
|
||||
promptId: "prompt-1",
|
||||
actionId: "approve",
|
||||
actionLabel: "Approve",
|
||||
sessionKey: "ios",
|
||||
note: nil,
|
||||
sentAtMs: 1234,
|
||||
transport: "transferUserInfo"))
|
||||
#expect(appModel._test_queuedWatchReplyCount() == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async {
|
||||
let appModel = NodeAppModel()
|
||||
let url = URL(string: "openclaw://agent?message=hello")!
|
||||
|
||||
@@ -7,7 +7,15 @@ struct OpenClawWatchApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchInboxView(store: self.inboxStore)
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
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?
|
||||
@@ -21,6 +38,114 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
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
|
||||
@@ -38,12 +163,31 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
|
||||
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)
|
||||
sentAtMs: sentAtMs,
|
||||
promptId: promptId,
|
||||
sessionKey: sessionKey,
|
||||
kind: kind,
|
||||
details: details,
|
||||
expiresAtMs: expiresAtMs,
|
||||
risk: risk,
|
||||
actions: actions)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,24 @@ 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 {
|
||||
@@ -17,6 +30,15 @@ struct WatchNotifyMessage: Sendable {
|
||||
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"
|
||||
@@ -26,6 +48,16 @@ struct WatchNotifyMessage: Sendable {
|
||||
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) {
|
||||
@@ -51,14 +83,25 @@ struct WatchNotifyMessage: Sendable {
|
||||
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)
|
||||
body: message.body,
|
||||
risk: message.risk)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +117,15 @@ struct WatchNotifyMessage: Sendable {
|
||||
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() {
|
||||
@@ -83,7 +135,16 @@ struct WatchNotifyMessage: Sendable {
|
||||
body: self.body,
|
||||
transport: self.transport,
|
||||
updatedAt: updatedAt,
|
||||
lastDeliveryKey: self.lastDeliveryKey)
|
||||
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)
|
||||
}
|
||||
@@ -106,7 +167,52 @@ struct WatchNotifyMessage: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private func postLocalNotification(identifier: String, title: String, body: String) async {
|
||||
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
|
||||
@@ -119,6 +225,6 @@ struct WatchNotifyMessage: Sendable {
|
||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false))
|
||||
|
||||
_ = try? await UNUserNotificationCenter.current().add(request)
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
WKInterfaceDevice.current().play(self.mapHapticRisk(risk))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,18 @@ 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 {
|
||||
@@ -14,6 +26,31 @@ struct WatchInboxView: View {
|
||||
.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)
|
||||
|
||||
@@ -5,6 +5,24 @@ public enum OpenClawWatchCommand: String, Codable, Sendable {
|
||||
case notify = "watch.notify"
|
||||
}
|
||||
|
||||
public enum OpenClawWatchRisk: String, Codable, Sendable, Equatable {
|
||||
case low
|
||||
case medium
|
||||
case high
|
||||
}
|
||||
|
||||
public struct OpenClawWatchAction: Codable, Sendable, Equatable {
|
||||
public var id: String
|
||||
public var label: String
|
||||
public var style: String?
|
||||
|
||||
public init(id: String, label: String, style: String? = nil) {
|
||||
self.id = id
|
||||
self.label = label
|
||||
self.style = style
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawWatchStatusPayload: Codable, Sendable, Equatable {
|
||||
public var supported: Bool
|
||||
public var paired: Bool
|
||||
@@ -31,11 +49,36 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable {
|
||||
public var title: String
|
||||
public var body: String
|
||||
public var priority: OpenClawNotificationPriority?
|
||||
public var promptId: String?
|
||||
public var sessionKey: String?
|
||||
public var kind: String?
|
||||
public var details: String?
|
||||
public var expiresAtMs: Int?
|
||||
public var risk: OpenClawWatchRisk?
|
||||
public var actions: [OpenClawWatchAction]?
|
||||
|
||||
public init(title: String, body: String, priority: OpenClawNotificationPriority? = nil) {
|
||||
public init(
|
||||
title: String,
|
||||
body: String,
|
||||
priority: OpenClawNotificationPriority? = nil,
|
||||
promptId: String? = nil,
|
||||
sessionKey: String? = nil,
|
||||
kind: String? = nil,
|
||||
details: String? = nil,
|
||||
expiresAtMs: Int? = nil,
|
||||
risk: OpenClawWatchRisk? = nil,
|
||||
actions: [OpenClawWatchAction]? = nil)
|
||||
{
|
||||
self.title = title
|
||||
self.body = body
|
||||
self.priority = priority
|
||||
self.promptId = promptId
|
||||
self.sessionKey = sessionKey
|
||||
self.kind = kind
|
||||
self.details = details
|
||||
self.expiresAtMs = expiresAtMs
|
||||
self.risk = risk
|
||||
self.actions = actions
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user