mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 10:01:34 +00:00
fix(ios): suppress stale delayed UI actions (#113062)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
61
apps/ios/Sources/Services/DelayedActionGate.swift
Normal file
61
apps/ios/Sources/Services/DelayedActionGate.swift
Normal 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
|
||||
}
|
||||
}
|
||||
92
apps/ios/Tests/DelayedActionGateTests.swift
Normal file
92
apps/ios/Tests/DelayedActionGateTests.swift
Normal 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"])
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user