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 {