From 3f62d54d23471269777929d46fcef0767f7e3b73 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 19 Jun 2026 09:34:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(client):=20=E5=85=B3=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E7=BC=A9=E6=89=98=E7=9B=98=E4=B8=8D=E9=80=80=E5=87=BA=20+=20?= =?UTF-8?q?=E5=8D=95=E5=AE=9E=E4=BE=8B(=E4=B8=80=E6=9C=BA=E4=B8=80?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 关窗口托盘也消失:根因是 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 --- client/lib/main.dart | 6 +++++- client/lib/system_tray.dart | 27 +++++++++++++++++++++++++++ client/macos/Runner/AppDelegate.swift | 4 +++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/client/lib/main.dart b/client/lib/main.dart index 4522380..39b83b5 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -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 main() async { WidgetsFlutterBinding.ensureInitialized(); - // 桌面端:常驻系统托盘,关闭窗口隐藏到托盘而非退出。 + // 桌面端:单实例 + 常驻系统托盘(关闭窗口隐藏到托盘而非退出)。 if (isDesktop) { + if (!await ensureSingleInstance()) { + exit(0); // 已有实例在跑(已唤起其窗口),本进程退出。 + } await TrayService.instance.init(); } runApp(const ProviderScope(child: PangolinApp())); diff --git a/client/lib/system_tray.dart b/client/lib/system_tray.dart index 01c72fa..67edadb 100644 --- a/client/lib/system_tray.dart +++ b/client/lib/system_tray.dart @@ -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 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._(); diff --git a/client/macos/Runner/AppDelegate.swift b/client/macos/Runner/AppDelegate.swift index b3c1761..da810b5 100644 --- a/client/macos/Runner/AppDelegate.swift +++ b/client/macos/Runner/AppDelegate.swift @@ -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 {