mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Gateway/iOS: replay queued foreground actions safely after resume (#40281)
Merged via squash. - Local validation: `pnpm exec vitest run --config vitest.gateway.config.ts src/gateway/server-methods/nodes.invoke-wake.test.ts` - Local validation: `pnpm build` - mb-server validation: `pnpm exec vitest run --config vitest.gateway.config.ts src/gateway/server-methods/nodes.invoke-wake.test.ts` - mb-server validation: `pnpm build` - mb-server validation: `pnpm protocol:check`
This commit is contained in:
@@ -57,6 +57,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 pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
|
||||
enum CameraHUDKind {
|
||||
@@ -130,6 +131,7 @@ final class NodeAppModel {
|
||||
private var backgroundReconnectLeaseUntil: Date?
|
||||
private var lastSignificantLocationWakeAt: Date?
|
||||
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
|
||||
private var pendingForegroundActionDrainInFlight = false
|
||||
|
||||
private var gatewayConnected = false
|
||||
private var operatorConnected = false
|
||||
@@ -329,6 +331,9 @@ final class NodeAppModel {
|
||||
}
|
||||
await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive)
|
||||
}
|
||||
Task { [weak self] in
|
||||
await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "scene_active")
|
||||
}
|
||||
}
|
||||
if phase == .active, self.reconnectAfterBackgroundArmed {
|
||||
self.reconnectAfterBackgroundArmed = false
|
||||
@@ -2098,6 +2103,22 @@ private extension NodeAppModel {
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
private struct PendingForegroundNodeAction: Decodable {
|
||||
var id: String
|
||||
var command: String
|
||||
var paramsJSON: String?
|
||||
var enqueuedAtMs: Int?
|
||||
}
|
||||
|
||||
private struct PendingForegroundNodeActionsResponse: Decodable {
|
||||
var nodeId: String?
|
||||
var actions: [PendingForegroundNodeAction]
|
||||
}
|
||||
|
||||
private struct PendingForegroundNodeActionsAckRequest: Encodable {
|
||||
var ids: [String]
|
||||
}
|
||||
|
||||
private func refreshShareRouteFromGateway() async {
|
||||
struct Params: Codable {
|
||||
var includeGlobal: Bool
|
||||
@@ -2195,6 +2216,83 @@ extension NodeAppModel {
|
||||
func onNodeGatewayConnected() async {
|
||||
await self.registerAPNsTokenIfNeeded()
|
||||
await self.flushQueuedWatchRepliesIfConnected()
|
||||
await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
|
||||
}
|
||||
|
||||
private func resumePendingForegroundNodeActionsIfNeeded(trigger: String) async {
|
||||
guard !self.isBackgrounded else { return }
|
||||
guard await self.isGatewayConnected() else { return }
|
||||
guard !self.pendingForegroundActionDrainInFlight else { return }
|
||||
|
||||
self.pendingForegroundActionDrainInFlight = true
|
||||
defer { self.pendingForegroundActionDrainInFlight = false }
|
||||
|
||||
do {
|
||||
let payload = try await self.nodeGateway.request(
|
||||
method: "node.pending.pull",
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 6)
|
||||
let decoded = try JSONDecoder().decode(
|
||||
PendingForegroundNodeActionsResponse.self,
|
||||
from: payload)
|
||||
guard !decoded.actions.isEmpty else { return }
|
||||
self.pendingActionLogger.info(
|
||||
"Pending actions pulled trigger=\(trigger, privacy: .public) "
|
||||
+ "count=\(decoded.actions.count, privacy: .public)")
|
||||
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPendingForegroundNodeActions(
|
||||
_ actions: [PendingForegroundNodeAction],
|
||||
trigger: String) async
|
||||
{
|
||||
for action in actions {
|
||||
guard !self.isBackgrounded else {
|
||||
self.pendingActionLogger.info(
|
||||
"Pending action replay paused trigger=\(trigger, privacy: .public): app backgrounded")
|
||||
return
|
||||
}
|
||||
let req = BridgeInvokeRequest(
|
||||
id: action.id,
|
||||
command: action.command,
|
||||
paramsJSON: action.paramsJSON)
|
||||
let result = await self.handleInvoke(req)
|
||||
self.pendingActionLogger.info(
|
||||
"Pending action replay trigger=\(trigger, privacy: .public) "
|
||||
+ "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) "
|
||||
+ "ok=\(result.ok, privacy: .public)")
|
||||
guard result.ok else { return }
|
||||
let acked = await self.ackPendingForegroundNodeAction(
|
||||
id: action.id,
|
||||
trigger: trigger,
|
||||
command: action.command)
|
||||
guard acked else { return }
|
||||
}
|
||||
}
|
||||
|
||||
private func ackPendingForegroundNodeAction(
|
||||
id: String,
|
||||
trigger: String,
|
||||
command: String) async -> Bool
|
||||
{
|
||||
do {
|
||||
let payload = try JSONEncoder().encode(PendingForegroundNodeActionsAckRequest(ids: [id]))
|
||||
let paramsJSON = String(decoding: payload, as: UTF8.self)
|
||||
_ = try await self.nodeGateway.request(
|
||||
method: "node.pending.ack",
|
||||
paramsJSON: paramsJSON,
|
||||
timeoutSeconds: 6)
|
||||
return true
|
||||
} catch {
|
||||
self.pendingActionLogger.error(
|
||||
"Pending action ack failed trigger=\(trigger, privacy: .public) "
|
||||
+ "id=\(id, privacy: .public) command=\(command, privacy: .public) "
|
||||
+ "error=\(String(describing: error), privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
|
||||
@@ -2843,6 +2941,19 @@ extension NodeAppModel {
|
||||
self.gatewayConnected = connected
|
||||
}
|
||||
|
||||
func _test_applyPendingForegroundNodeActions(
|
||||
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
|
||||
{
|
||||
let mapped = actions.map { action in
|
||||
PendingForegroundNodeAction(
|
||||
id: action.id,
|
||||
command: action.command,
|
||||
paramsJSON: action.paramsJSON,
|
||||
enqueuedAtMs: nil)
|
||||
}
|
||||
await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
|
||||
}
|
||||
|
||||
static func _test_currentDeepLinkKey() -> String {
|
||||
self.expectedDeepLinkKey()
|
||||
}
|
||||
|
||||
@@ -179,6 +179,41 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
#expect(payload?["result"] as? String == "2")
|
||||
}
|
||||
|
||||
@Test @MainActor func pendingForegroundActionsReplayCanvasNavigate() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
|
||||
let navData = try JSONEncoder().encode(navigateParams)
|
||||
let navJSON = String(decoding: navData, as: UTF8.self)
|
||||
|
||||
await appModel._test_applyPendingForegroundNodeActions([
|
||||
(
|
||||
id: "pending-nav-1",
|
||||
command: OpenClawCanvasCommand.navigate.rawValue,
|
||||
paramsJSON: navJSON
|
||||
),
|
||||
])
|
||||
|
||||
#expect(appModel.screen.urlString == "http://example.com/")
|
||||
}
|
||||
|
||||
@Test @MainActor func pendingForegroundActionsDoNotApplyWhileBackgrounded() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.setScenePhase(.background)
|
||||
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
|
||||
let navData = try JSONEncoder().encode(navigateParams)
|
||||
let navJSON = String(decoding: navData, as: UTF8.self)
|
||||
|
||||
await appModel._test_applyPendingForegroundNodeActions([
|
||||
(
|
||||
id: "pending-nav-bg",
|
||||
command: OpenClawCanvasCommand.navigate.rawValue,
|
||||
paramsJSON: navJSON
|
||||
),
|
||||
])
|
||||
|
||||
#expect(appModel.screen.urlString.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
|
||||
|
||||
@@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable {
|
||||
|
||||
public struct NodeListParams: Codable, Sendable {}
|
||||
|
||||
public struct NodePendingAckParams: Codable, Sendable {
|
||||
public let ids: [String]
|
||||
|
||||
public init(
|
||||
ids: [String])
|
||||
{
|
||||
self.ids = ids
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ids
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeDescribeParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
|
||||
|
||||
@@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable {
|
||||
|
||||
public struct NodeListParams: Codable, Sendable {}
|
||||
|
||||
public struct NodePendingAckParams: Codable, Sendable {
|
||||
public let ids: [String]
|
||||
|
||||
public init(
|
||||
ids: [String])
|
||||
{
|
||||
self.ids = ids
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ids
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeDescribeParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
|
||||
|
||||
Reference in New Issue
Block a user