diff --git a/Package.swift b/Package.swift index ef516d2..bf68e88 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,7 @@ let package = Package( ), .testTarget( name: "ClaudeUsageTests", - dependencies: ["ClaudeUsageCore"], + dependencies: ["ClaudeUsageCore", "ClaudeUsageApp"], path: "Tests/ClaudeUsageTests" ), ] diff --git a/Sources/ClaudeUsageApp/StatusBarController.swift b/Sources/ClaudeUsageApp/StatusBarController.swift index df26a1d..66a1e2b 100644 --- a/Sources/ClaudeUsageApp/StatusBarController.swift +++ b/Sources/ClaudeUsageApp/StatusBarController.swift @@ -68,6 +68,7 @@ final class StatusBarController: NSObject { NotificationCenter.default .publisher(for: UserDefaults.didChangeNotification) + .receiveOnMainQueue() .sink { [weak self] _ in DispatchQueue.main.async { [weak self] in self?.updateStatusItemLength() @@ -102,3 +103,9 @@ final class StatusBarController: NSObject { popover.contentViewController?.view.window?.makeFirstResponder(nil) } } + +extension Publisher { + func receiveOnMainQueue() -> Publishers.ReceiveOn { + receive(on: DispatchQueue.main) + } +} diff --git a/Tests/ClaudeUsageTests/MainQueuePublisherTests.swift b/Tests/ClaudeUsageTests/MainQueuePublisherTests.swift new file mode 100644 index 0000000..cdc1c4a --- /dev/null +++ b/Tests/ClaudeUsageTests/MainQueuePublisherTests.swift @@ -0,0 +1,63 @@ +import Combine +import Foundation +import Testing +@testable import ClaudeUsageApp + +@Suite("Main queue publishers") +struct MainQueuePublisherTests { + @Test("receiveOnMainQueue delivers background notifications on main thread") + func receiveOnMainQueueDeliversOnMainThread() async throws { + let deliveredOnMainThread = ThreadFlag() + var cancellables = Set() + + NotificationCenter.default + .publisher(for: UserDefaults.didChangeNotification) + .receiveOnMainQueue() + .sink { _ in + deliveredOnMainThread.set(Thread.isMainThread) + } + .store(in: &cancellables) + + await Task.detached { + NotificationCenter.default.post(name: UserDefaults.didChangeNotification, object: nil) + }.value + + try await waitUntil(deliveredOnMainThread.hasValue) + #expect(deliveredOnMainThread.value == true) + } +} + +private final class ThreadFlag: @unchecked Sendable { + private let lock = NSLock() + private var storedValue: Bool? + + var value: Bool? { + lock.lock() + defer { lock.unlock() } + return storedValue + } + + func set(_ value: Bool) { + lock.lock() + defer { lock.unlock() } + storedValue = value + } + + func hasValue() -> Bool { + value != nil + } +} + +private func waitUntil( + _ condition: @escaping @Sendable () -> Bool, + timeout: Duration = .seconds(1) +) async throws { + let deadline = ContinuousClock.now + timeout + while !condition() { + if ContinuousClock.now >= deadline { + Issue.record("Timed out waiting for condition") + return + } + try await Task.sleep(for: .milliseconds(10)) + } +}