From fa0bced54462dfa462c8109d7829fc11ff8ecad5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 11:19:25 +0800 Subject: [PATCH] fix(macos): preserve voice ear boost expiry (#116196) --- apps/macos/Sources/OpenClaw/AppState.swift | 13 ++++++-- .../AppStateVoiceEarsTests.swift | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 apps/macos/Tests/OpenClawIPCTests/AppStateVoiceEarsTests.swift diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 4f96da2f4590..87ce9afe4046 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -329,7 +329,7 @@ final class AppState { didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } } } - private var earBoostTask: Task? + @ObservationIgnored private var earBoostTask: Task? init( preview: Bool = false, @@ -782,13 +782,20 @@ final class AppState { func triggerVoiceEars(ttl: TimeInterval? = 5) { self.earBoostTask?.cancel() + self.earBoostTask = nil self.earBoostActive = true guard let ttl else { return } self.earBoostTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(ttl * 1_000_000_000)) - await MainActor.run { [weak self] in self?.earBoostActive = false } + do { + try await Task.sleep(nanoseconds: UInt64(ttl * 1_000_000_000)) + } catch { + return + } + guard !Task.isCancelled, let self else { return } + self.earBoostTask = nil + self.earBoostActive = false } } diff --git a/apps/macos/Tests/OpenClawIPCTests/AppStateVoiceEarsTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppStateVoiceEarsTests.swift new file mode 100644 index 000000000000..7de8e30c4cd2 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateVoiceEarsTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct AppStateVoiceEarsTests { + @Test func `indefinite ear boost survives cancelled expiry`() async throws { + let state = AppState(preview: true) + state.triggerVoiceEars(ttl: 60) + state.triggerVoiceEars(ttl: nil) + + try await Task.sleep(for: .milliseconds(20)) + + #expect(state.earBoostActive) + state.stopVoiceEars() + } + + @Test func `replacement expiry owns ear boost lifetime`() async throws { + let state = AppState(preview: true) + state.triggerVoiceEars(ttl: 60) + state.triggerVoiceEars(ttl: 0.05) + + try await Task.sleep(for: .milliseconds(20)) + #expect(state.earBoostActive) + + try await Task.sleep(for: .milliseconds(60)) + #expect(!state.earBoostActive) + } +}