Refactor macOS status bar background runtime

This commit is contained in:
Haitao Pan 2026-03-26 19:13:05 +08:00
parent cb8b11fe5b
commit 2e9e82540b
9 changed files with 554 additions and 58 deletions

View File

@ -67,6 +67,8 @@ class _XWorkmateAppState extends State<XWorkmateApp> {
case 'prepareForExit':
await _controller.prepareForExit();
return null;
case 'desktopStatusSnapshot':
return _controller.desktopStatusSnapshot();
default:
throw MissingPluginException(
'Unhandled app lifecycle method: ${call.method}',

View File

@ -2054,6 +2054,53 @@ class AppController extends ChangeNotifier {
await _flushAssistantThreadPersistence();
}
Map<String, dynamic> desktopStatusSnapshot() {
final pausedTasks = _tasksController.scheduled
.where((item) => item.status == 'Disabled')
.length;
final timedOutTasks = _tasksController.failed
.where(_looksLikeTimedOutTask)
.length;
final failedTasks = _tasksController.failed.length;
final queuedTasks = _tasksController.queue.length;
final runningTasks = _tasksController.running.length;
final scheduledTasks = _tasksController.scheduled.length;
final badgeCount = runningTasks + pausedTasks + timedOutTasks;
return <String, dynamic>{
'connectionStatus': _desktopConnectionStatusValue(connection.status),
'connectionLabel': connection.status.label,
'runningTasks': runningTasks,
'pausedTasks': pausedTasks,
'timedOutTasks': timedOutTasks,
'queuedTasks': queuedTasks,
'scheduledTasks': scheduledTasks,
'failedTasks': failedTasks,
'totalTasks': _tasksController.totalCount,
'badgeCount': badgeCount > 0 ? badgeCount : runningTasks + queuedTasks,
};
}
bool _looksLikeTimedOutTask(DerivedTaskItem item) {
final haystack = '${item.status} ${item.title} ${item.summary}'
.toLowerCase();
return haystack.contains('timed out') ||
haystack.contains('timeout') ||
haystack.contains('超时');
}
String _desktopConnectionStatusValue(RuntimeConnectionStatus status) {
switch (status) {
case RuntimeConnectionStatus.connected:
return 'connected';
case RuntimeConnectionStatus.connecting:
return 'connecting';
case RuntimeConnectionStatus.error:
return 'error';
case RuntimeConnectionStatus.offline:
return 'disconnected';
}
}
Future<void> setAssistantExecutionTarget(
AssistantExecutionTarget target,
) async {

View File

@ -2181,6 +2181,53 @@ class AppController extends ChangeNotifier {
}
}
Map<String, dynamic> desktopStatusSnapshot() {
final pausedTasks = _tasksController.scheduled
.where((item) => item.status == 'Disabled')
.length;
final timedOutTasks = _tasksController.failed
.where(_looksLikeTimedOutTask)
.length;
final failedTasks = _tasksController.failed.length;
final queuedTasks = _tasksController.queue.length;
final runningTasks = _tasksController.running.length;
final scheduledTasks = _tasksController.scheduled.length;
final badgeCount = runningTasks + pausedTasks + timedOutTasks;
return <String, dynamic>{
'connectionStatus': _desktopConnectionStatusValue(connection.status),
'connectionLabel': connection.status.label,
'runningTasks': runningTasks,
'pausedTasks': pausedTasks,
'timedOutTasks': timedOutTasks,
'queuedTasks': queuedTasks,
'scheduledTasks': scheduledTasks,
'failedTasks': failedTasks,
'totalTasks': _tasksController.totalCount,
'badgeCount': badgeCount > 0 ? badgeCount : runningTasks + queuedTasks,
};
}
bool _looksLikeTimedOutTask(DerivedTaskItem item) {
final haystack = '${item.status} ${item.title} ${item.summary}'
.toLowerCase();
return haystack.contains('timed out') ||
haystack.contains('timeout') ||
haystack.contains('超时');
}
String _desktopConnectionStatusValue(RuntimeConnectionStatus status) {
switch (status) {
case RuntimeConnectionStatus.connected:
return 'connected';
case RuntimeConnectionStatus.connecting:
return 'connecting';
case RuntimeConnectionStatus.error:
return 'error';
case RuntimeConnectionStatus.offline:
return 'disconnected';
}
}
Future<void> selectDirectModel(String model) async {
final trimmed = model.trim();
if (trimmed.isEmpty) {

View File

@ -28,6 +28,8 @@
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
8E6F4A7D31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8E6F4A7C31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy */; };
A1B2C3034F0A000100000001 /* AppStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3024F0A000100000001 /* AppStatusViewModel.swift */; };
A1B2C3054F0A000100000001 /* StatusBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3044F0A000100000001 /* StatusBarController.swift */; };
A96EF8FFA0E80B16252FE834 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5F4830709C3A67EC8096888 /* Pods_RunnerTests.framework */; };
F02922E20E15948F8CE5469F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2099D82E31DC5912EA477802 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
@ -85,6 +87,8 @@
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
890E8B245EC31E1C0F085895 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
8E6F4A7C31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Runner/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
A1B2C3024F0A000100000001 /* AppStatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStatusViewModel.swift; sourceTree = "<group>"; };
A1B2C3044F0A000100000001 /* StatusBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarController.swift; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
B5F4830709C3A67EC8096888 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D8467093D02A39550CF15067 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
@ -179,7 +183,9 @@
isa = PBXGroup;
children = (
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
A1B2C3024F0A000100000001 /* AppStatusViewModel.swift */,
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
A1B2C3044F0A000100000001 /* StatusBarController.swift */,
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
33E51914231749380026EE4D /* Release.entitlements */,
33CC11242044D66E0003C045 /* Resources */,
@ -440,6 +446,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3054F0A000100000001 /* StatusBarController.swift in Sources */,
A1B2C3034F0A000100000001 /* AppStatusViewModel.swift in Sources */,
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,

View File

@ -4,28 +4,31 @@ import FlutterMacOS
@main
class AppDelegate: FlutterAppDelegate, NSWindowDelegate {
private let appLifecycleChannelName = "plus.svc.xworkmate/app_lifecycle"
private let skillDirectoryChannelName = "plus.svc.xworkmate/skill_directory_access"
private let appLifecycleChannelName = "plus.svc.xworkmate/app_lifecycle"
private let dockDisplayMode: DockDisplayMode = .regular
private let statusProvider = FlutterAppStatusProvider()
private var directoryAccessSessions: [String: URL] = [:]
private var appLifecycleChannel: FlutterMethodChannel?
private var appLifecycleMessengerId: ObjectIdentifier?
private var skillDirectoryChannel: FlutterMethodChannel?
private var skillDirectoryMessengerId: ObjectIdentifier?
private var statusItem: NSStatusItem?
private lazy var statusViewModel = AppStatusViewModel(provider: statusProvider)
private var statusBarController: StatusBarController?
private var terminationInFlight = false
private var terminationTimeoutWorkItem: DispatchWorkItem?
override func applicationDidFinishLaunching(_ notification: Notification) {
super.applicationDidFinishLaunching(notification)
applyDockDisplayMode()
mainFlutterWindow?.delegate = self
guard let controller = mainFlutterWindow?.contentViewController as? FlutterViewController else {
setUpStatusItem()
return
if let controller = mainFlutterWindow?.contentViewController as? FlutterViewController {
registerApplicationChannels(for: controller)
}
registerApplicationChannels(for: controller)
setUpStatusItem()
setUpStatusBarController()
statusViewModel.startRefreshing()
}
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
@ -40,10 +43,6 @@ class AppDelegate: FlutterAppDelegate, NSWindowDelegate {
guard !terminationInFlight else {
return .terminateLater
}
guard let channel = appLifecycleChannel else {
return .terminateNow
}
terminationInFlight = true
var hasReplied = false
let finishTermination: () -> Void = { [weak self] in
@ -63,9 +62,7 @@ class AppDelegate: FlutterAppDelegate, NSWindowDelegate {
let timeoutWorkItem = DispatchWorkItem(block: finishTermination)
terminationTimeoutWorkItem = timeoutWorkItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: timeoutWorkItem)
channel.invokeMethod("prepareForExit", arguments: nil) { _ in
finishTermination()
}
statusViewModel.prepareForExit(completion: finishTermination)
return .terminateLater
}
@ -75,6 +72,7 @@ class AppDelegate: FlutterAppDelegate, NSWindowDelegate {
}
override func applicationWillTerminate(_ notification: Notification) {
statusViewModel.stopRefreshing()
for (_, url) in directoryAccessSessions {
url.stopAccessingSecurityScopedResource()
}
@ -115,58 +113,26 @@ class AppDelegate: FlutterAppDelegate, NSWindowDelegate {
binaryMessenger: controller.engine.binaryMessenger
)
appLifecycleMessengerId = messengerId
statusViewModel.bind(channel: appLifecycleChannel)
}
private func setUpStatusItem() {
guard statusItem == nil else {
private func setUpStatusBarController() {
guard statusBarController == nil else {
return
}
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = item.button {
if #available(macOS 11.0, *) {
if let image = NSImage(
systemSymbolName: "cpu",
accessibilityDescription: "XWorkmate"
) {
image.isTemplate = true
button.image = image
} else {
button.title = "XW"
}
} else {
button.title = "XW"
statusBarController = StatusBarController(
viewModel: statusViewModel,
openAppHandler: { [weak self] in
self?.showMainWindow()
},
quitAndPauseHandler: { [weak self] in
self?.requestTermination()
}
button.toolTip = "XWorkmate"
}
let menu = NSMenu()
let showItem = NSMenuItem(
title: "显示主窗口",
action: #selector(showMainWindowAction(_:)),
keyEquivalent: ""
)
showItem.target = self
menu.addItem(showItem)
let quitItem = NSMenuItem(
title: "退出并暂停任务",
action: #selector(quitAndPauseTasksAction(_:)),
keyEquivalent: ""
)
quitItem.target = self
menu.addItem(quitItem)
item.menu = menu
statusItem = item
}
@objc private func showMainWindowAction(_ sender: Any?) {
showMainWindow()
}
@objc private func quitAndPauseTasksAction(_ sender: Any?) {
NSApp.terminate(sender)
private func requestTermination() {
NSApp.terminate(nil)
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
@ -188,6 +154,15 @@ class AppDelegate: FlutterAppDelegate, NSWindowDelegate {
NSApp.activate(ignoringOtherApps: true)
}
private func applyDockDisplayMode() {
switch dockDisplayMode {
case .regular:
NSApp.setActivationPolicy(.regular)
case .menuBarOnly:
NSApp.setActivationPolicy(.accessory)
}
}
private func handleSkillDirectoryCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "resolveUserHomeDirectory":

View File

@ -0,0 +1,242 @@
import Combine
import Foundation
import FlutterMacOS
enum DockDisplayMode {
case regular
case menuBarOnly
}
enum MenuBarConnectionState: String {
case connected
case connecting
case disconnected
case error
init(rawValue: String, fallbackLabel: String) {
switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "connected":
self = .connected
case "connecting":
self = .connecting
case "error":
self = .error
case "disconnected":
self = .disconnected
default:
self = fallbackLabel.lowercased().contains("connect")
? .connecting
: .disconnected
}
}
var symbolName: String {
switch self {
case .connected:
return "checkmark.circle.fill"
case .connecting:
return "arrow.triangle.2.circlepath.circle"
case .disconnected:
return "xmark.circle"
case .error:
return "exclamationmark.triangle.fill"
}
}
}
struct AppStatusSnapshot {
let connectionState: MenuBarConnectionState
let connectionLabel: String
let runningTasks: Int
let pausedTasks: Int
let timedOutTasks: Int
let queuedTasks: Int
let scheduledTasks: Int
let failedTasks: Int
let totalTasks: Int
let badgeCount: Int
init(
connectionState: MenuBarConnectionState,
connectionLabel: String,
runningTasks: Int,
pausedTasks: Int,
timedOutTasks: Int,
queuedTasks: Int,
scheduledTasks: Int,
failedTasks: Int,
totalTasks: Int,
badgeCount: Int
) {
self.connectionState = connectionState
self.connectionLabel = connectionLabel
self.runningTasks = runningTasks
self.pausedTasks = pausedTasks
self.timedOutTasks = timedOutTasks
self.queuedTasks = queuedTasks
self.scheduledTasks = scheduledTasks
self.failedTasks = failedTasks
self.totalTasks = totalTasks
self.badgeCount = badgeCount
}
static let unavailable = AppStatusSnapshot(
connectionState: .disconnected,
connectionLabel: "Disconnected",
runningTasks: 0,
pausedTasks: 0,
timedOutTasks: 0,
queuedTasks: 0,
scheduledTasks: 0,
failedTasks: 0,
totalTasks: 0,
badgeCount: 0
)
init(payload: [String: Any]) {
let connectionLabel = (payload["connectionLabel"] as? String)?.trimmingCharacters(
in: .whitespacesAndNewlines
)
let rawStatus = (payload["connectionStatus"] as? String) ?? ""
self.connectionLabel = connectionLabel?.isEmpty == false ? connectionLabel! : "Disconnected"
connectionState = MenuBarConnectionState(
rawValue: rawStatus,
fallbackLabel: self.connectionLabel
)
runningTasks = Self.intValue(payload["runningTasks"])
pausedTasks = Self.intValue(payload["pausedTasks"])
timedOutTasks = Self.intValue(payload["timedOutTasks"])
queuedTasks = Self.intValue(payload["queuedTasks"])
scheduledTasks = Self.intValue(payload["scheduledTasks"])
failedTasks = Self.intValue(payload["failedTasks"])
totalTasks = Self.intValue(payload["totalTasks"])
let badgeCount = Self.intValue(payload["badgeCount"])
self.badgeCount = badgeCount > 0 ? badgeCount : runningTasks + queuedTasks
}
var menuTaskSummary: String {
"任务: 运行 \(runningTasks) | 暂停 \(pausedTasks) | 超时 \(timedOutTasks)"
}
var menuStatusSummary: String {
"当前状态: \(connectionLabel)"
}
var buttonBadgeText: String {
badgeCount > 0 ? " \(badgeCount)" : ""
}
private static func intValue(_ value: Any?) -> Int {
if let number = value as? NSNumber {
return number.intValue
}
if let value = value as? Int {
return value
}
if let value = value as? Double {
return Int(value)
}
if let value = value as? String, let parsed = Int(value) {
return parsed
}
return 0
}
}
protocol AppStatusSnapshotProviding: AnyObject {
func bind(channel: FlutterMethodChannel?)
func fetchSnapshot(completion: @escaping (AppStatusSnapshot) -> Void)
func prepareForExit(completion: @escaping () -> Void)
}
final class FlutterAppStatusProvider: AppStatusSnapshotProviding {
private weak var channel: FlutterMethodChannel?
func bind(channel: FlutterMethodChannel?) {
self.channel = channel
}
func fetchSnapshot(completion: @escaping (AppStatusSnapshot) -> Void) {
guard let channel else {
completion(.unavailable)
return
}
channel.invokeMethod("desktopStatusSnapshot", arguments: nil) { result in
guard let payload = result as? [String: Any] else {
completion(.unavailable)
return
}
completion(AppStatusSnapshot(payload: payload))
}
}
func prepareForExit(completion: @escaping () -> Void) {
guard let channel else {
completion()
return
}
channel.invokeMethod("prepareForExit", arguments: nil) { _ in
completion()
}
}
}
final class AppStatusViewModel: ObservableObject {
@Published var snapshot: AppStatusSnapshot = .unavailable
private let provider: AppStatusSnapshotProviding
private let refreshInterval: TimeInterval
private var refreshCancellable: AnyCancellable?
init(
provider: AppStatusSnapshotProviding,
refreshInterval: TimeInterval = 5
) {
self.provider = provider
self.refreshInterval = refreshInterval
}
func bind(channel: FlutterMethodChannel?) {
provider.bind(channel: channel)
refreshNow()
}
func startRefreshing() {
guard refreshCancellable == nil else {
return
}
refreshNow()
refreshCancellable = Timer.publish(
every: refreshInterval,
on: .main,
in: .common
)
.autoconnect()
.sink { [weak self] _ in
self?.refreshNow()
}
}
func stopRefreshing() {
refreshCancellable?.cancel()
refreshCancellable = nil
}
func refreshNow() {
provider.fetchSnapshot { [weak self] snapshot in
DispatchQueue.main.async {
self?.snapshot = snapshot
}
}
}
func prepareForExit(completion: @escaping () -> Void) {
provider.prepareForExit(completion: completion)
}
func applyRemoteSnapshot(_ snapshot: AppStatusSnapshot) {
DispatchQueue.main.async {
self.snapshot = snapshot
}
}
}

View File

@ -0,0 +1,125 @@
import Cocoa
import Combine
final class StatusBarController: NSObject {
private let viewModel: AppStatusViewModel
private let openAppHandler: () -> Void
private let quitAndPauseHandler: () -> Void
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
private let menu = NSMenu()
private let tasksMenuItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
private let statusMenuItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
private let openAppMenuItem = NSMenuItem(title: "Open App", action: nil, keyEquivalent: "")
private let quitMenuItem = NSMenuItem(title: "退出并暂停任务", action: nil, keyEquivalent: "")
private var cancellables = Set<AnyCancellable>()
init(
viewModel: AppStatusViewModel,
openAppHandler: @escaping () -> Void,
quitAndPauseHandler: @escaping () -> Void
) {
self.viewModel = viewModel
self.openAppHandler = openAppHandler
self.quitAndPauseHandler = quitAndPauseHandler
super.init()
setUpMenu()
bindViewModel()
render(snapshot: viewModel.snapshot)
}
private func setUpMenu() {
tasksMenuItem.isEnabled = false
statusMenuItem.isEnabled = false
openAppMenuItem.target = self
openAppMenuItem.action = #selector(openAppAction(_:))
quitMenuItem.target = self
quitMenuItem.action = #selector(quitAndPauseAction(_:))
menu.addItem(tasksMenuItem)
menu.addItem(statusMenuItem)
menu.addItem(.separator())
menu.addItem(openAppMenuItem)
menu.addItem(.separator())
menu.addItem(quitMenuItem)
statusItem.menu = menu
statusItem.button?.toolTip = "XWorkmate"
}
private func bindViewModel() {
viewModel.$snapshot
.receive(on: RunLoop.main)
.sink { [weak self] snapshot in
self?.render(snapshot: snapshot)
}
.store(in: &cancellables)
}
private func render(snapshot: AppStatusSnapshot) {
tasksMenuItem.title = snapshot.menuTaskSummary
statusMenuItem.title = snapshot.menuStatusSummary
updateStatusButton(snapshot: snapshot)
}
private func updateStatusButton(snapshot: AppStatusSnapshot) {
guard let button = statusItem.button else {
return
}
button.title = snapshot.buttonBadgeText
button.toolTip = """
\(snapshot.connectionLabel)
: \(snapshot.runningTasks)
: \(snapshot.pausedTasks)
: \(snapshot.timedOutTasks)
"""
if #available(macOS 11.0, *) {
let symbolName = buttonSymbolName(for: snapshot)
let image = NSImage(
systemSymbolName: symbolName,
accessibilityDescription: snapshot.connectionLabel
)
image?.isTemplate = true
button.image = image
} else {
button.image = nil
button.title = fallbackPrefix(for: snapshot) + snapshot.buttonBadgeText
}
}
private func buttonSymbolName(for snapshot: AppStatusSnapshot) -> String {
if snapshot.runningTasks > 0 && snapshot.connectionState == .connected {
return "bolt.horizontal.circle.fill"
}
if snapshot.timedOutTasks > 0 || snapshot.connectionState == .error {
return "exclamationmark.triangle.fill"
}
return snapshot.connectionState.symbolName
}
private func fallbackPrefix(for snapshot: AppStatusSnapshot) -> String {
switch snapshot.connectionState {
case .connected:
return "[C]"
case .connecting:
return "[~]"
case .disconnected:
return "[D]"
case .error:
return "[!]"
}
}
@objc private func openAppAction(_ sender: Any?) {
openAppHandler()
}
@objc private func quitAndPauseAction(_ sender: Any?) {
quitAndPauseHandler()
}
}

View File

@ -0,0 +1,43 @@
@TestOn('vm')
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller.dart';
import '../test_support.dart';
void main() {
test(
'AppController exposes a stable desktop status snapshot shape',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final controller = AppController(store: createIsolatedTestStore());
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
final snapshot = controller.desktopStatusSnapshot();
expect(snapshot['connectionStatus'], 'disconnected');
expect(snapshot['connectionLabel'], isA<String>());
expect(snapshot['runningTasks'], 0);
expect(snapshot['pausedTasks'], 0);
expect(snapshot['timedOutTasks'], 0);
expect(snapshot['queuedTasks'], 0);
expect(snapshot['scheduledTasks'], 0);
expect(snapshot['failedTasks'], 0);
expect(snapshot['totalTasks'], 0);
expect(snapshot['badgeCount'], 0);
},
);
}
Future<void> _waitFor(bool Function() predicate) async {
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (!predicate()) {
if (DateTime.now().isAfter(deadline)) {
fail('condition not met before timeout');
}
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}

View File

@ -0,0 +1,7 @@
import '../test_suite_stub.dart'
if (dart.library.io) 'app_controller_status_snapshot_suite.dart'
as suite;
void main() {
suite.main();
}