Files
openclaw/apps/ios/WatchApp/Sources/OpenClawWatchApp.swift
Peter Steinberger ac89350327 feat(apps): review durable approvals on mobile (#104913)
* feat(apps): Android, iPhone, and Watch approval clients

Squash-rebased #103912 segment onto the deep-links tip on current main.
Native approval surfaces: iOS approval presentation with gateway-switch
lease preservation and resolution fencing, watchOS inbox + approval
actions with shipped-shape payload codec, Android approval notices with
publication-tokened dismissal. Native i18n inventory regenerated.

(cherry picked from commit 428a76670ffeede54248b7bd7aa4438e2589851b)
(cherry picked from commit 80225d5707c3645eeea5435f266131037b50ede6)
(cherry picked from commit 2a23b714dc30d773a294aa45adc617e46b80732e)
(cherry picked from commit 9ff1153827769e2cbab7c11c153f0f8634662c28)
(cherry picked from commit 5b25723525bd562e5f97478af492f7b46ad30fd1)
(cherry picked from commit 8c80e8467b5fe89aad0d4c74a0573f1600ed3af9)
(cherry picked from commit ad4037bc9846bf6f082b41c129ee68aa344576c3)
(cherry picked from commit fdf767dd662cff3c8a5a6d571f38f153410651ca)
(cherry picked from commit 00c120376ffd992ea68ce29254a9bc0a25ed1740)
(cherry picked from commit f36a95213e)
(cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac)
(cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235)
(cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1)
(cherry picked from commit 2a60619722)
(cherry picked from commit 6f0c386567)
(cherry picked from commit 784a5857b7)
(cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db)

* fix(apps): harden approval reconciliation and watch states
2026-07-11 19:59:07 -07:00

294 lines
13 KiB
Swift

import SwiftUI
import UserNotifications
private final class WatchNotificationPresentationDelegate: NSObject, UNUserNotificationCenterDelegate,
@unchecked Sendable
{
func userNotificationCenter(
_: UNUserNotificationCenter,
willPresent _: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
{
completionHandler([.banner, .list, .sound])
}
}
enum WatchScreenshotMode {
private static let defaultsKey = "openclaw.watch.screenshotMode"
static let approvals = ProcessInfo.processInfo.arguments.contains(
"--openclaw-watch-approval-screenshot-mode")
|| ProcessInfo.processInfo.environment["OPENCLAW_WATCH_APPROVAL_SCREENSHOT_MODE"] == "1"
static let enabled = ProcessInfo.processInfo.arguments.contains("--openclaw-watch-screenshot-mode")
|| ProcessInfo.processInfo.environment["OPENCLAW_WATCH_SCREENSHOT_MODE"] == "1"
|| UserDefaults.standard.bool(forKey: WatchScreenshotMode.defaultsKey)
|| WatchScreenshotMode.approvals
}
@main
struct OpenClawWatchApp: App {
@Environment(\.scenePhase) private var scenePhase
@State private var inboxStore = WatchInboxStore(
requestNotificationAuthorization: !WatchScreenshotMode.enabled)
@State private var directNode = WatchDirectNode()
@State private var notificationDelegate = WatchNotificationPresentationDelegate()
@State private var receiver: WatchConnectivityReceiver?
@State private var execApprovalRefreshTask: Task<Void, Never>?
var body: some Scene {
WindowGroup {
WatchInboxView(
store: self.inboxStore,
directNode: self.directNode,
onAction: { 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)
}
},
onExecApprovalDecision: { approvalId, gatewayStableID, decision in
guard let receiver = self.receiver else { return }
guard let attemptID = self.inboxStore.beginExecApprovalDecision(
approvalId: approvalId,
gatewayStableID: gatewayStableID,
decision: decision)
else { return }
Task { @MainActor in
let result = await receiver.sendExecApprovalResolve(
approvalId: approvalId,
gatewayStableID: gatewayStableID,
attemptID: attemptID,
decision: decision)
self.inboxStore.completeExecApprovalDecision(
approvalId: approvalId,
gatewayStableID: gatewayStableID,
attemptID: attemptID,
decision: decision,
result: result)
if result.requiresCanonicalReadback {
// WatchConnectivity errors can race successful delivery. Keep
// actions frozen while the iPhone reads canonical gateway state.
self.refreshExecApprovalReview(force: true)
}
}
},
onRefreshExecApprovalReview: {
self.refreshExecApprovalReview(force: true)
},
onRefreshAppSnapshot: {
self.refreshAppSnapshot()
},
onAppCommand: { command in
self.sendAppCommand(command)
},
onSendChatMessage: { text in
self.sendChatMessage(text)
})
.task {
UNUserNotificationCenter.current().delegate = self.notificationDelegate
if WatchScreenshotMode.enabled {
self.inboxStore.configureScreenshotFixture(
includeApproval: WatchScreenshotMode.approvals)
return
}
if self.receiver == nil {
let receiver = WatchConnectivityReceiver(
store: self.inboxStore,
directNodeSetupHandler: { [weak directNode] setupCode, sentAtMs in
directNode?.configure(setupCode: setupCode, sentAtMs: sentAtMs)
})
receiver.activate()
self.receiver = receiver
}
if self.scenePhase == .active {
self.directNode.connectForForeground()
}
self.refreshAppSnapshot()
self.refreshExecApprovalReview()
}
.onChange(of: self.scenePhase) { _, newPhase in
switch newPhase {
case .active:
self.directNode.connectForForeground()
self.refreshAppSnapshot()
self.refreshExecApprovalReview()
case .inactive, .background:
self.directNode.disconnectForBackground()
@unknown default:
break
}
}
}
}
private func refreshAppSnapshot() {
guard let receiver else { return }
self.inboxStore.markAppSnapshotRequestStarted()
Task { @MainActor in
let result = await receiver.requestAppSnapshot()
self.inboxStore.markAppSnapshotRequestResult(result)
}
}
private func sendAppCommand(_ command: WatchAppCommand) {
guard let receiver else { return }
let message = self.inboxStore.makeAppCommand(command)
self.inboxStore.markAppCommandSending(command)
Task { @MainActor in
let result = await receiver.sendAppCommand(message)
self.inboxStore.markAppCommandResult(result, command: command)
}
}
private func sendChatMessage(_ text: String) -> String? {
guard let receiver else { return nil }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard self.inboxStore.hasGatewayTaggedAppSnapshot else {
self.inboxStore.markAppCommandBlocked(.sendChat, reason: "refreshing iPhone state")
self.refreshAppSnapshot()
return nil
}
let message = self.inboxStore.makeAppCommand(.sendChat, text: trimmed)
self.inboxStore.markAppCommandSending(.sendChat)
Task { @MainActor in
let result = await receiver.sendAppCommand(message)
self.inboxStore.markAppCommandResult(result, command: .sendChat)
try? await Task.sleep(nanoseconds: 900_000_000)
self.refreshAppSnapshot()
}
return message.commandId
}
private func refreshExecApprovalReview(force: Bool = false) {
guard let receiver else { return }
guard force || self.inboxStore.shouldAutoRequestExecApprovalSnapshot else { return }
self.execApprovalRefreshTask?.cancel()
self.execApprovalRefreshTask = Task { @MainActor in
var requestTokens: [WatchExecApprovalSnapshotRequestToken] = []
func consumeCurrentOwnerAcknowledgment(gatewayStableID: String?) -> Bool {
var received = false
var retainedTokens: [WatchExecApprovalSnapshotRequestToken] = []
for token in requestTokens where token.matchesGatewayStableID(gatewayStableID) {
if receiver.consumeExecApprovalSnapshotAcknowledgment(for: token) {
received = true
} else {
retainedTokens.append(token)
}
}
requestTokens = retainedTokens
return received
}
self.inboxStore.beginExecApprovalReviewLoading()
for attempt in 0..<5 {
if Task.isCancelled {
return
}
let gatewayStableID = self.inboxStore.execApprovalReviewGatewayStableID
receiver.discardExecApprovalSnapshotAcknowledgments(
exceptGatewayStableID: gatewayStableID)
let receivedBeforeRequest = consumeCurrentOwnerAcknowledgment(
gatewayStableID: gatewayStableID)
let reviewAlreadyAvailable = !force
&& !self.inboxStore.execApprovals.contains(where: \.isResolving)
&& (!self.inboxStore.execApprovals.isEmpty
|| self.inboxStore.hasCompletedExecApprovalSnapshotRefresh)
if receivedBeforeRequest || reviewAlreadyAvailable {
self.inboxStore.markExecApprovalReviewLoaded()
return
}
if let token = await receiver.requestExecApprovalSnapshot(
gatewayStableID: gatewayStableID,
heldApprovals: self.inboxStore.execApprovalSnapshotRequestItems(
gatewayStableID: gatewayStableID))
{
let currentGatewayStableID = self.inboxStore.execApprovalReviewGatewayStableID
if token.matchesGatewayStableID(currentGatewayStableID) {
requestTokens.append(token)
}
}
if consumeCurrentOwnerAcknowledgment(
gatewayStableID: self.inboxStore.execApprovalReviewGatewayStableID)
{
self.inboxStore.markExecApprovalReviewLoaded()
return
}
if attempt < 4 {
try? await Task.sleep(nanoseconds: 700_000_000)
}
}
if self.inboxStore.execApprovals.isEmpty {
self.inboxStore.markExecApprovalReviewUnavailable(
"Couldn't load approval from your iPhone yet.")
}
}
}
}
@MainActor
extension WatchInboxStore {
fileprivate func configureScreenshotFixture(includeApproval: Bool = false) {
let sentAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let approvals: [WatchExecApprovalItem] = if includeApproval {
[
WatchExecApprovalItem(
id: "watch-screenshot-approval",
gatewayStableID: "watch-screenshot-gateway",
commandText: "curl --request POST https://deploy.example.invalid/releases",
commandPreview: "Deploy the latest release",
warningText: "This command can change a production service.",
host: "deploy-runner",
nodeId: "release-node",
agentId: "main",
expiresAtMs: sentAtMs + 10 * 60 * 1000,
allowedDecisions: [.allowOnce, .deny],
risk: .high),
]
} else {
[]
}
greetingTextOverride = "Good morning"
self.consume(
execApprovalSnapshot: WatchExecApprovalSnapshotMessage(
approvals: approvals,
gatewayStableID: "watch-screenshot-gateway",
sentAtMs: sentAtMs,
snapshotId: includeApproval ? "watch-screenshot-approval-face" : nil),
transport: "screenshot")
self.consume(
appSnapshot: WatchAppSnapshotMessage(
gatewayStatusText: "Connected",
gatewayConnected: true,
agentName: "Molty",
agentAvatarURL: nil,
agentAvatarText: "M",
sessionKey: "watch-screenshot-session",
gatewayStableID: "watch-screenshot-gateway",
talkStatusText: "Ready",
talkEnabled: true,
talkListening: false,
talkSpeaking: false,
pendingApprovalCount: approvals.count,
chatItems: [
WatchChatItem(
id: "watch-screenshot-user-chat",
role: "user",
text: "What's on deck?",
timestampMs: sentAtMs - 90000),
WatchChatItem(
id: "watch-screenshot-molty-chat",
role: "assistant",
text: "Gateway is online and ready.",
timestampMs: sentAtMs - 30000),
],
chatStatusText: "Live gateway conversation",
sentAtMs: sentAtMs,
snapshotId: "watch-screenshot-now-face"))
}
}