Refactor macOS status bar background runtime
This commit is contained in:
parent
cb8b11fe5b
commit
2e9e82540b
@ -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}',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 */,
|
||||
|
||||
@ -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":
|
||||
|
||||
242
macos/Runner/AppStatusViewModel.swift
Normal file
242
macos/Runner/AppStatusViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
125
macos/Runner/StatusBarController.swift
Normal file
125
macos/Runner/StatusBarController.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
43
test/runtime/app_controller_status_snapshot_suite.dart
Normal file
43
test/runtime/app_controller_status_snapshot_suite.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
7
test/runtime/app_controller_status_snapshot_test.dart
Normal file
7
test/runtime/app_controller_status_snapshot_test.dart
Normal 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();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user