From 041fb8869ec13121f643fbeac45d11dbea9c7f2b Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 26 Mar 2026 18:42:11 +0800 Subject: [PATCH] Add desktop background-run lifecycle hooks --- lib/app/app.dart | 40 ++++++++ lib/app/app_controller_desktop.dart | 9 ++ lib/app/app_controller_web.dart | 29 ++++-- linux/runner/my_application.cc | 8 ++ macos/Runner/AppDelegate.swift | 143 ++++++++++++++++++++++++++- macos/Runner/MainFlutterWindow.swift | 2 +- windows/runner/flutter_window.cpp | 6 ++ windows/runner/flutter_window.h | 2 + 8 files changed, 227 insertions(+), 12 deletions(-) diff --git a/lib/app/app.dart b/lib/app/app.dart index 41565ea7..13ed1759 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,5 +1,7 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter/services.dart'; import '../i18n/app_language.dart'; import '../theme/app_theme.dart'; @@ -18,6 +20,10 @@ class XWorkmateApp extends StatefulWidget { } class _XWorkmateAppState extends State { + static const MethodChannel _appLifecycleChannel = MethodChannel( + 'plus.svc.xworkmate/app_lifecycle', + ); + late final AppController _controller; @override @@ -26,14 +32,48 @@ class _XWorkmateAppState extends State { _controller = AppController( uiFeatureManifest: widget.featureManifest ?? UiFeatureManifest.fallback(), ); + if (_supportsDesktopLifecycleChannel) { + _appLifecycleChannel.setMethodCallHandler(_handleAppLifecycleCall); + } } @override void dispose() { + if (_supportsDesktopLifecycleChannel) { + _appLifecycleChannel.setMethodCallHandler(null); + } _controller.dispose(); super.dispose(); } + bool get _supportsDesktopLifecycleChannel { + if (kIsWeb) { + return false; + } + switch (defaultTargetPlatform) { + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + return true; + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + return false; + } + } + + Future _handleAppLifecycleCall(MethodCall call) async { + switch (call.method) { + case 'prepareForExit': + await _controller.prepareForExit(); + return null; + default: + throw MissingPluginException( + 'Unhandled app lifecycle method: ${call.method}', + ); + } + } + @override Widget build(BuildContext context) { return AnimatedBuilder( diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 8e2c07b6..1ac33ce2 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -2045,6 +2045,15 @@ class AppController extends ChangeNotifier { await _chatController.abortRun(); } + Future prepareForExit() async { + try { + await abortRun(); + } catch (_) { + // Best effort only. Native termination still proceeds. + } + await _flushAssistantThreadPersistence(); + } + Future setAssistantExecutionTarget( AssistantExecutionTarget target, ) async { diff --git a/lib/app/app_controller_web.dart b/lib/app/app_controller_web.dart index 890da598..1255bca8 100644 --- a/lib/app/app_controller_web.dart +++ b/lib/app/app_controller_web.dart @@ -135,6 +135,7 @@ class AppController extends ChangeNotifier { } return capabilities.supportsDestination(WorkspaceDestination.settings); } + GatewayConnectionSnapshot get connection => _relayClient.snapshot; bool get relayBusy => _relayBusy; bool get aiGatewayBusy => _aiGatewayBusy; @@ -2172,6 +2173,14 @@ class AppController extends ChangeNotifier { } } + Future prepareForExit() async { + try { + await abortRun(); + } catch (_) { + // Web and placeholder desktop hooks only need a best-effort cancel. + } + } + Future selectDirectModel(String model) async { final trimmed = model.trim(); if (trimmed.isEmpty) { @@ -2348,14 +2357,18 @@ class AppController extends ChangeNotifier { ); final assistantNavigationDestinations = normalizeAssistantNavigationDestinations( - snapshot.assistantNavigationDestinations, - ).where((entry) { - final destination = entry.destination; - if (destination != null) { - return allowedDestinations.contains(destination); - } - return allowedDestinations.contains(WorkspaceDestination.settings); - }).toList(growable: false); + snapshot.assistantNavigationDestinations, + ) + .where((entry) { + final destination = entry.destination; + if (destination != null) { + return allowedDestinations.contains(destination); + } + return allowedDestinations.contains( + WorkspaceDestination.settings, + ); + }) + .toList(growable: false); final normalizedSessionBaseUrl = RemoteWebSessionRepository.normalizeBaseUrl( snapshot.webSessionPersistence.remoteBaseUrl, diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index cd8fba85..b034f722 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -21,6 +21,13 @@ static void first_frame_cb(MyApplication* self, FlView* view) { gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); } +static void my_application_register_app_lifecycle_hooks(MyApplication* self, + FlView* view) { + (void)self; + (void)view; + // Reserved for future GNOME/KDE tray and close-to-background integration. +} + // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); @@ -79,6 +86,7 @@ static void my_application_activate(GApplication* application) { fl_register_plugins(FL_PLUGIN_REGISTRY(view)); self->desktop_platform_channel = desktop_platform_channel_new(self, window, view); + my_application_register_app_lifecycle_hooks(self, view); gtk_widget_grab_focus(GTK_WIDGET(view)); } diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 47234c81..042276dc 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -3,29 +3,77 @@ import Darwin import FlutterMacOS @main -class AppDelegate: FlutterAppDelegate { +class AppDelegate: FlutterAppDelegate, NSWindowDelegate { + private let appLifecycleChannelName = "plus.svc.xworkmate/app_lifecycle" private let skillDirectoryChannelName = "plus.svc.xworkmate/skill_directory_access" 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 var terminationInFlight = false + private var terminationTimeoutWorkItem: DispatchWorkItem? override func applicationDidFinishLaunching(_ notification: Notification) { super.applicationDidFinishLaunching(notification) + mainFlutterWindow?.delegate = self + guard let controller = mainFlutterWindow?.contentViewController as? FlutterViewController else { + setUpStatusItem() return } - registerSkillDirectoryChannel(for: controller) + registerApplicationChannels(for: controller) + setUpStatusItem() } override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true + return false } override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } + override func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard !terminationInFlight else { + return .terminateLater + } + guard let channel = appLifecycleChannel else { + return .terminateNow + } + + terminationInFlight = true + var hasReplied = false + let finishTermination: () -> Void = { [weak self] in + guard let self else { + return + } + guard !hasReplied else { + return + } + hasReplied = true + self.terminationTimeoutWorkItem?.cancel() + self.terminationTimeoutWorkItem = nil + self.terminationInFlight = false + sender.reply(toApplicationShouldTerminate: true) + } + + let timeoutWorkItem = DispatchWorkItem(block: finishTermination) + terminationTimeoutWorkItem = timeoutWorkItem + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: timeoutWorkItem) + channel.invokeMethod("prepareForExit", arguments: nil) { _ in + finishTermination() + } + return .terminateLater + } + + override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + showMainWindow() + return true + } + override func applicationWillTerminate(_ notification: Notification) { for (_, url) in directoryAccessSessions { url.stopAccessingSecurityScopedResource() @@ -34,6 +82,11 @@ class AppDelegate: FlutterAppDelegate { super.applicationWillTerminate(notification) } + func registerApplicationChannels(for controller: FlutterViewController) { + registerAppLifecycleChannel(for: controller) + registerSkillDirectoryChannel(for: controller) + } + func registerSkillDirectoryChannel(for controller: FlutterViewController) { let messengerObject = controller.engine.binaryMessenger as AnyObject let messengerId = ObjectIdentifier(messengerObject) @@ -51,6 +104,90 @@ class AppDelegate: FlutterAppDelegate { skillDirectoryMessengerId = messengerId } + func registerAppLifecycleChannel(for controller: FlutterViewController) { + let messengerObject = controller.engine.binaryMessenger as AnyObject + let messengerId = ObjectIdentifier(messengerObject) + if appLifecycleMessengerId == messengerId { + return + } + appLifecycleChannel = FlutterMethodChannel( + name: appLifecycleChannelName, + binaryMessenger: controller.engine.binaryMessenger + ) + appLifecycleMessengerId = messengerId + } + + private func setUpStatusItem() { + guard statusItem == 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" + } + 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) + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + sender.orderOut(nil) + NSApp.hide(nil) + return false + } + + private func showMainWindow() { + guard let window = mainFlutterWindow else { + return + } + NSApp.unhide(nil) + if window.isMiniaturized { + window.deminiaturize(nil) + } + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + NSApp.activate(ignoringOtherApps: true) + } + private func handleSkillDirectoryCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "resolveUserHomeDirectory": diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 9ca46cfb..9e69a222 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -9,7 +9,7 @@ class MainFlutterWindow: NSWindow { self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) - (NSApp.delegate as? AppDelegate)?.registerSkillDirectoryChannel( + (NSApp.delegate as? AppDelegate)?.registerApplicationChannels( for: flutterViewController ) diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 955ee303..2ee51d47 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -25,6 +25,7 @@ bool FlutterWindow::OnCreate() { return false; } RegisterPlugins(flutter_controller_->engine()); + RegisterDesktopLifecycleHooks(); SetChildContent(flutter_controller_->view()->GetNativeWindow()); flutter_controller_->engine()->SetNextFrameCallback([&]() { @@ -39,6 +40,11 @@ bool FlutterWindow::OnCreate() { return true; } +void FlutterWindow::RegisterDesktopLifecycleHooks() { + (void)flutter_controller_; + // Reserved for future Windows tray and close-to-background integration. +} + void FlutterWindow::OnDestroy() { if (flutter_controller_) { flutter_controller_ = nullptr; diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h index 6da0652f..9c5fff64 100644 --- a/windows/runner/flutter_window.h +++ b/windows/runner/flutter_window.h @@ -23,6 +23,8 @@ class FlutterWindow : public Win32Window { LPARAM const lparam) noexcept override; private: + void RegisterDesktopLifecycleHooks(); + // The project to run. flutter::DartProject project_;