mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 05:38:34 +00:00
iOS/watch: add actionable watch approvals and quick replies (#21996)
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
This commit is contained in:
@@ -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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user