Add desktop background-run lifecycle hooks

This commit is contained in:
Haitao Pan 2026-03-26 18:42:11 +08:00
parent 68cdbe4e4b
commit 041fb8869e
8 changed files with 227 additions and 12 deletions

View File

@ -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<XWorkmateApp> {
static const MethodChannel _appLifecycleChannel = MethodChannel(
'plus.svc.xworkmate/app_lifecycle',
);
late final AppController _controller;
@override
@ -26,14 +32,48 @@ class _XWorkmateAppState extends State<XWorkmateApp> {
_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<Object?> _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(

View File

@ -2045,6 +2045,15 @@ class AppController extends ChangeNotifier {
await _chatController.abortRun();
}
Future<void> prepareForExit() async {
try {
await abortRun();
} catch (_) {
// Best effort only. Native termination still proceeds.
}
await _flushAssistantThreadPersistence();
}
Future<void> setAssistantExecutionTarget(
AssistantExecutionTarget target,
) async {

View File

@ -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<void> prepareForExit() async {
try {
await abortRun();
} catch (_) {
// Web and placeholder desktop hooks only need a best-effort cancel.
}
}
Future<void> 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,

View File

@ -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));
}

View File

@ -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":

View File

@ -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
)

View File

@ -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;

View File

@ -23,6 +23,8 @@ class FlutterWindow : public Win32Window {
LPARAM const lparam) noexcept override;
private:
void RegisterDesktopLifecycleHooks();
// The project to run.
flutter::DartProject project_;