From 2e9e82540b510e1f411ccf34bbf348a0820c8cb7 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 26 Mar 2026 19:13:05 +0800 Subject: [PATCH] Refactor macOS status bar background runtime --- lib/app/app.dart | 2 + lib/app/app_controller_desktop.dart | 47 ++++ lib/app/app_controller_web.dart | 47 ++++ macos/Runner.xcodeproj/project.pbxproj | 8 + macos/Runner/AppDelegate.swift | 91 +++---- macos/Runner/AppStatusViewModel.swift | 242 ++++++++++++++++++ macos/Runner/StatusBarController.swift | 125 +++++++++ .../app_controller_status_snapshot_suite.dart | 43 ++++ .../app_controller_status_snapshot_test.dart | 7 + 9 files changed, 554 insertions(+), 58 deletions(-) create mode 100644 macos/Runner/AppStatusViewModel.swift create mode 100644 macos/Runner/StatusBarController.swift create mode 100644 test/runtime/app_controller_status_snapshot_suite.dart create mode 100644 test/runtime/app_controller_status_snapshot_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 13ed1759..cf3acbca 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -67,6 +67,8 @@ class _XWorkmateAppState extends State { case 'prepareForExit': await _controller.prepareForExit(); return null; + case 'desktopStatusSnapshot': + return _controller.desktopStatusSnapshot(); default: throw MissingPluginException( 'Unhandled app lifecycle method: ${call.method}', diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 1ac33ce2..4fdd78c6 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -2054,6 +2054,53 @@ class AppController extends ChangeNotifier { await _flushAssistantThreadPersistence(); } + Map 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 { + '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 setAssistantExecutionTarget( AssistantExecutionTarget target, ) async { diff --git a/lib/app/app_controller_web.dart b/lib/app/app_controller_web.dart index 1255bca8..0c23c8f3 100644 --- a/lib/app/app_controller_web.dart +++ b/lib/app/app_controller_web.dart @@ -2181,6 +2181,53 @@ class AppController extends ChangeNotifier { } } + Map 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 { + '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 selectDirectModel(String model) async { final trimmed = model.trim(); if (trimmed.isEmpty) { diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 010c637d..b64a3b42 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; 8E6F4A7C31A1A10100A1B2C3 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Runner/PrivacyInfo.xcprivacy; sourceTree = ""; }; + A1B2C3024F0A000100000001 /* AppStatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStatusViewModel.swift; sourceTree = ""; }; + A1B2C3044F0A000100000001 /* StatusBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarController.swift; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; 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 = ""; }; @@ -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 */, diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 042276dc..9cc0648d 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -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": diff --git a/macos/Runner/AppStatusViewModel.swift b/macos/Runner/AppStatusViewModel.swift new file mode 100644 index 00000000..2b5e8ce2 --- /dev/null +++ b/macos/Runner/AppStatusViewModel.swift @@ -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 + } + } +} diff --git a/macos/Runner/StatusBarController.swift b/macos/Runner/StatusBarController.swift new file mode 100644 index 00000000..a12ee490 --- /dev/null +++ b/macos/Runner/StatusBarController.swift @@ -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() + + 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() + } +} diff --git a/test/runtime/app_controller_status_snapshot_suite.dart b/test/runtime/app_controller_status_snapshot_suite.dart new file mode 100644 index 00000000..831b1017 --- /dev/null +++ b/test/runtime/app_controller_status_snapshot_suite.dart @@ -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({}); + final controller = AppController(store: createIsolatedTestStore()); + addTearDown(controller.dispose); + + await _waitFor(() => !controller.initializing); + + final snapshot = controller.desktopStatusSnapshot(); + expect(snapshot['connectionStatus'], 'disconnected'); + expect(snapshot['connectionLabel'], isA()); + 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 _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.delayed(const Duration(milliseconds: 20)); + } +} diff --git a/test/runtime/app_controller_status_snapshot_test.dart b/test/runtime/app_controller_status_snapshot_test.dart new file mode 100644 index 00000000..618c4ec4 --- /dev/null +++ b/test/runtime/app_controller_status_snapshot_test.dart @@ -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(); +}