fix(ios): suppress stale delayed UI actions (#113062)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
metaforismo
2026-07-28 05:19:07 +02:00
committed by GitHub
parent b168e4b5ed
commit a84ea8b732
6 changed files with 233 additions and 32 deletions

View File

@@ -3295,8 +3295,10 @@ jobs:
-destination "platform=iOS Simulator,id=${simulator_id}" \
-resultBundlePath "$result_bundle" \
-parallel-testing-enabled NO \
-only-testing:OpenClawTests/DelayedActionGateTests \
-only-testing:OpenClawTests/NodeAppModelInvokeTests \
-only-testing:OpenClawTests/NotificationServingPreferenceTests \
-only-testing:OpenClawTests/RootTabsSourceGuardTests \
test
- name: Upload iOS lifecycle simulator evidence

View File

@@ -27163,7 +27163,7 @@
},
{
"kind": "conditional-branch",
"line": 870,
"line": 873,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Gateway needs attention",
"surface": "apple",
@@ -27171,7 +27171,7 @@
},
{
"kind": "conditional-branch",
"line": 870,
"line": 873,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "OpenClaw iOS",
"surface": "apple",
@@ -27179,7 +27179,7 @@
},
{
"kind": "conditional-branch",
"line": 906,
"line": 909,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Available",
"surface": "apple",
@@ -27187,7 +27187,7 @@
},
{
"kind": "conditional-branch",
"line": 906,
"line": 909,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Gateway default",
"surface": "apple",
@@ -27195,7 +27195,7 @@
},
{
"kind": "conditional-branch",
"line": 906,
"line": 909,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Routed on this phone",
"surface": "apple",

View File

@@ -45,7 +45,7 @@ struct RootTabs: View {
@State private var isSidebarDrawerLayout: Bool = false
@State private var didResolveSidebarLayout: Bool = false
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@State private var toastDismissGate = DelayedActionGate()
@State private var presentedSheet: PresentedSheet?
@State private var showGatewayProblemDetails: Bool = false
@State private var gatewayToastDragOffset: CGFloat = 0
@@ -520,9 +520,9 @@ struct RootTabs: View {
}
.overlay {
if self.appModel.cameraFlashNonce != 0 {
RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
}
// Keep the observer mounted so the first 0 -> 1 capture transition
// flashes without treating a later remount as a new capture.
RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
}
.overlay {
if self.appModel.screen.isCanvasPresented {
@@ -624,17 +624,13 @@ struct RootTabs: View {
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.toastDismissTask?.cancel()
withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
self.voiceWakeToastText = trimmed
}
self.toastDismissTask = Task {
try? await Task.sleep(nanoseconds: 2_300_000_000)
await MainActor.run {
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
self.voiceWakeToastText = nil
}
self.toastDismissGate.schedule(after: .milliseconds(2300)) {
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
self.voiceWakeToastText = nil
}
}
}
@@ -654,7 +650,10 @@ struct RootTabs: View {
.onChange(of: self.scenePhase) { _, newValue in
self.updateIdleTimer()
self.updateHomeCanvasState()
guard newValue == .active else { return }
guard newValue == .active else {
self.clearVoiceWakeToast()
return
}
self.maybeRequestLocalNetworkAccess(reason: "scene_active")
Task {
await self.appModel.refreshGatewayOverviewIfConnected()
@@ -665,11 +664,15 @@ struct RootTabs: View {
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
self.toastDismissTask?.cancel()
self.toastDismissTask = nil
self.clearVoiceWakeToast()
}
}
private func clearVoiceWakeToast() {
self.voiceWakeToastText = nil
self.toastDismissGate.cancel()
}
private func rootGatewayProblemLifecycle(_ content: some View) -> some View {
content
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
@@ -1182,10 +1185,12 @@ extension RootTabs {
}
private struct RootCameraFlashOverlay: View {
@Environment(\.scenePhase) private var scenePhase
var nonce: Int
@State private var opacity: CGFloat = 0
@State private var task: Task<Void, Never>?
@State private var dismissGate = DelayedActionGate()
var body: some View {
Color.white
@@ -1193,21 +1198,33 @@ private struct RootCameraFlashOverlay: View {
.ignoresSafeArea()
.allowsHitTesting(false)
.onChange(of: self.nonce) { _, _ in
self.task?.cancel()
self.task = Task { @MainActor in
withAnimation(.easeOut(duration: 0.08)) {
self.opacity = 0.85
}
try? await Task.sleep(nanoseconds: 110_000_000)
withAnimation(.easeOut(duration: 0.32)) {
self.opacity = 0
}
guard self.scenePhase == .active else {
self.clearFlash()
return
}
self.showFlash()
}
.onDisappear {
self.task?.cancel()
self.task = nil
.onChange(of: self.scenePhase) { _, newValue in
guard newValue != .active else { return }
self.clearFlash()
}
.onDisappear { self.clearFlash() }
}
private func showFlash() {
withAnimation(.easeOut(duration: 0.08)) {
self.opacity = 0.85
}
self.dismissGate.schedule(after: .milliseconds(110)) {
withAnimation(.easeOut(duration: 0.32)) {
self.opacity = 0
}
}
}
private func clearFlash() {
self.opacity = 0
self.dismissGate.cancel()
}
}

View File

@@ -0,0 +1,61 @@
import Foundation
/// Owns one delayed MainActor action and invalidates it by generation.
///
/// Cancellation alone is not an action-time guarantee: a delay can finish
/// before a replacement cancels the task. The generation check therefore runs
/// on the MainActor immediately before the action is delivered.
@MainActor
final class DelayedActionGate {
typealias Sleeper = @Sendable (Duration) async throws -> Void
typealias DeliveryBarrier = @Sendable () async -> Void
private let sleeper: Sleeper
private let deliveryBarrier: DeliveryBarrier
private var generation: UInt64 = 0
private var task: Task<Void, Never>?
init(
sleeper: @escaping Sleeper = { try await Task.sleep(for: $0) },
deliveryBarrier: @escaping DeliveryBarrier = {})
{
self.sleeper = sleeper
self.deliveryBarrier = deliveryBarrier
}
@discardableResult
func schedule(
after duration: Duration,
action: @escaping @MainActor () async -> Void) -> Task<Void, Never>
{
self.task?.cancel()
self.generation &+= 1
let generation = self.generation
let sleeper = self.sleeper
let deliveryBarrier = self.deliveryBarrier
let task = Task { @MainActor [weak self] in
do {
try await sleeper(duration)
} catch {
return
}
await deliveryBarrier()
guard let self,
!Task.isCancelled,
self.generation == generation
else { return }
await action()
if self.generation == generation {
self.task = nil
}
}
self.task = task
return task
}
func cancel() {
self.generation &+= 1
self.task?.cancel()
self.task = nil
}
}

View File

@@ -0,0 +1,92 @@
import Foundation
import Testing
@testable import OpenClaw
private actor DelayDeliveryBarrier {
private var deliveryCount = 0
private var firstDeliveryPaused = false
private var pauseWaiters: [CheckedContinuation<Void, Never>] = []
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
func pauseFirstDelivery() async {
self.deliveryCount += 1
guard self.deliveryCount == 1 else { return }
self.firstDeliveryPaused = true
let pauseWaiters = self.pauseWaiters
self.pauseWaiters.removeAll()
for waiter in pauseWaiters {
waiter.resume()
}
await withCheckedContinuation { continuation in
self.releaseWaiters.append(continuation)
}
}
func waitUntilFirstDeliveryIsPaused() async {
if self.firstDeliveryPaused { return }
await withCheckedContinuation { continuation in
self.pauseWaiters.append(continuation)
}
}
func releaseFirstDelivery() {
let releaseWaiters = self.releaseWaiters
self.releaseWaiters.removeAll()
for waiter in releaseWaiters {
waiter.resume()
}
}
}
struct DelayedActionGateTests {
@Test @MainActor func `completed delay delivers its action`() async {
var actions: [String] = []
let gate = DelayedActionGate(sleeper: { _ in })
let task = gate.schedule(after: .zero) {
actions.append("current")
}
await task.value
#expect(actions == ["current"])
}
@Test @MainActor func `cancellation after delay return suppresses delivery`() async {
var actions: [String] = []
let barrier = DelayDeliveryBarrier()
let gate = DelayedActionGate(
sleeper: { _ in },
deliveryBarrier: { await barrier.pauseFirstDelivery() })
let task = gate.schedule(after: .zero) {
actions.append("stale")
}
await barrier.waitUntilFirstDeliveryIsPaused()
gate.cancel()
await barrier.releaseFirstDelivery()
await task.value
#expect(actions.isEmpty)
}
@Test @MainActor func `replacement action wins after stale delay returns`() async {
var actions: [String] = []
let barrier = DelayDeliveryBarrier()
let gate = DelayedActionGate(
sleeper: { _ in },
deliveryBarrier: { await barrier.pauseFirstDelivery() })
let staleTask = gate.schedule(after: .zero) {
actions.append("stale")
}
await barrier.waitUntilFirstDeliveryIsPaused()
let currentTask = gate.schedule(after: .zero) {
actions.append("current")
}
await currentTask.value
await barrier.releaseFirstDelivery()
await staleTask.value
#expect(actions == ["current"])
}
}

View File

@@ -4,6 +4,35 @@ import Testing
@testable import OpenClaw
struct RootTabsSourceGuardTests {
@Test func `inactive scenes clear voice wake toast and camera flash`() throws {
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let rootAppearLifecycle = try Self.extract(
source,
from: "private func rootAppearLifecycle(_ content: some View) -> some View",
to: "private func rootGatewayProblemLifecycle(_ content: some View) -> some View")
let rootVoiceWakeLifecycle = try Self.extract(
source,
from: "private func rootVoiceWakeLifecycle(_ content: some View) -> some View",
to: "private func rootAppearLifecycle(_ content: some View) -> some View")
let cameraFlashOverlay = try Self.extract(
source,
from: "private struct RootCameraFlashOverlay: View",
to: "#if DEBUG")
#expect(source.contains("@State private var toastDismissGate = DelayedActionGate()"))
#expect(rootVoiceWakeLifecycle.contains("self.toastDismissGate.schedule"))
#expect(!source.contains("toastDismissTask"))
#expect(rootAppearLifecycle.contains("guard newValue == .active else {"))
#expect(rootAppearLifecycle.contains("self.clearVoiceWakeToast()"))
#expect(cameraFlashOverlay.contains("@Environment(\\.scenePhase) private var scenePhase"))
#expect(cameraFlashOverlay.contains("@State private var dismissGate = DelayedActionGate()"))
#expect(cameraFlashOverlay.contains("self.dismissGate.schedule"))
#expect(!cameraFlashOverlay.contains("Task {"))
#expect(cameraFlashOverlay.contains("guard self.scenePhase == .active else {"))
#expect(cameraFlashOverlay.contains("guard newValue != .active else { return }"))
#expect(cameraFlashOverlay.contains("self.clearFlash()"))
}
@Test func `app applies initial scene phase before gateway admission`() throws {
let source = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8)
let startupTask = try Self.extract(