fix(client): 关窗口缩托盘不退出 + 单实例(一机一进程)
ci-pangolin / Lint — shellcheck (push) Has been cancelled
ci-pangolin / OpenAPI Sync Check (push) Has been cancelled
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Has been cancelled
ci-pangolin / Flutter — analyze + test (push) Has been cancelled

- 关窗口托盘也消失:根因是 AppDelegate.applicationShouldTerminateAfterLast
  WindowClosed 返回 true → 关窗口即终止 app。改 false:关窗口只隐藏到托盘
  (配合 window_manager preventClose+hide),app 留在托盘。
- 单实例:main 启动先 ensureSingleInstance()——loopback 47654 端口锁;已有实例
  时连过去唤起其窗口显示,本进程 exit(0)。保证一机仅一个 pangolin 进程,
  重复启动只会前置已有实例。

flutter analyze 0 error;测试通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-19 09:34:12 +08:00
parent 31443d0fe7
commit 3f62d54d23
3 changed files with 35 additions and 2 deletions
+5 -1
View File
@@ -1,5 +1,6 @@
// main.dart — 入口(演示态)
// 串接:登录 → 引导 → 主框架(4 Tab);状态层统一走 Riverpod。
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
@@ -18,8 +19,11 @@ import 'widgets/pangolin_logo.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 桌面端:常驻系统托盘,关闭窗口隐藏到托盘而非退出。
// 桌面端:单实例 + 常驻系统托盘(关闭窗口隐藏到托盘而非退出)
if (isDesktop) {
if (!await ensureSingleInstance()) {
exit(0); // 已有实例在跑(已唤起其窗口),本进程退出。
}
await TrayService.instance.init();
}
runApp(const ProviderScope(child: PangolinApp()));
+27
View File
@@ -11,6 +11,33 @@ import 'package:window_manager/window_manager.dart';
bool get isDesktop =>
!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux);
// 单实例锁端口(仅 loopback)。第二个实例绑定失败 → 通知首实例显示窗口后退出。
const _singleInstancePort = 47654;
/// 保证一台机器只有一个 Pangolin 进程。返回 false 表示已有实例在跑
/// (本进程应退出,首实例已被唤起显示)。
Future<bool> ensureSingleInstance() async {
if (!isDesktop) return true;
try {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, _singleInstancePort);
// 首实例:监听后续实例的"唤起"请求 → 显示并聚焦窗口。
server.listen((socket) {
socket.destroy();
windowManager.show();
windowManager.focus();
});
return true;
} on SocketException {
// 端口被占 = 已有实例 → 连过去让它显示,然后本进程退出。
try {
final s = await Socket.connect(InternetAddress.loopbackIPv4, _singleInstancePort,
timeout: const Duration(seconds: 1));
s.destroy();
} catch (_) {}
return false;
}
}
class TrayService with TrayListener, WindowListener {
TrayService._();
static final TrayService instance = TrayService._();
+3 -1
View File
@@ -3,8 +3,10 @@ import FlutterMacOS
@main
class AppDelegate: FlutterAppDelegate {
// :, app(window_manager preventClose + hide
// ; false ,便 app )
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
return false
}
override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {