Add desktop background-run lifecycle hooks
This commit is contained in:
parent
68cdbe4e4b
commit
041fb8869e
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -23,6 +23,8 @@ class FlutterWindow : public Win32Window {
|
||||
LPARAM const lparam) noexcept override;
|
||||
|
||||
private:
|
||||
void RegisterDesktopLifecycleHooks();
|
||||
|
||||
// The project to run.
|
||||
flutter::DartProject project_;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user