// system_tray.dart — 桌面端系统托盘 + 关闭即最小化到托盘。 // // macOS/Windows/Linux:关闭主窗口不退出,而是隐藏到托盘;托盘图标点击/菜单 // 可重新显示或真正退出。移动端/web 不涉及。 import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:tray_manager/tray_manager.dart'; 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._(); /// 真正退出前的清理钩子(由 app 注册,用于先停内核拆隧道再退出)。 Future Function()? onBeforeQuit; /// 在 runApp 前调用(仅桌面端)。设置窗口拦截关闭 + 托盘图标与菜单。 Future init() async { await windowManager.ensureInitialized(); // 关闭窗口转为隐藏到托盘(见 onWindowClose)。 await windowManager.setPreventClose(true); windowManager.addListener(this); trayManager.addListener(this); // Windows 托盘走 Win32 Shell_NotifyIcon,只认 .ico(传 PNG 会显示空白); // macOS/Linux 用 PNG。 await trayManager.setIcon( Platform.isWindows ? 'assets/tray_icon.ico' : 'assets/tray_icon.png', ); await trayManager.setToolTip('穿山甲 Pangolin'); await trayManager.setContextMenu(Menu(items: [ MenuItem(key: 'show', label: '显示主界面'), MenuItem.separator(), MenuItem(key: 'quit', label: '退出'), ])); } Future _show() async { await windowManager.show(); await windowManager.focus(); } // ── 托盘事件 ────────────────────────────────────────────────── @override void onTrayIconMouseDown() => _show(); @override void onTrayIconRightMouseDown() => trayManager.popUpContextMenu(); @override void onTrayMenuItemClick(MenuItem menuItem) async { switch (menuItem.key) { case 'show': _show(); case 'quit': // 退出前先停内核拆隧道(避免 sudo sing-box 孤儿 + TUN 残留)。 await onBeforeQuit?.call(); // 不要用 windowManager.destroy():它在托盘回调里触发 DestroyWindow/WM_CLOSE // 回调链(还会回调 onWindowClose → await isPreventClose),异常穿过 Win32 回调 // 边界 → 0xC000041D(STATUS_FATAL_USER_CALLBACK_EXCEPTION)崩溃。内核已停、隧道 // 已拆,先摘掉托盘图标避免残留,再直接干净退出进程。 try { await trayManager.destroy(); } catch (_) {} exit(0); } } // ── 窗口事件 ────────────────────────────────────────────────── @override void onWindowClose() async { // 点关闭 → 隐藏到托盘而非退出(常驻保持隧道)。 if (await windowManager.isPreventClose()) { await windowManager.hide(); } } }