Parcourir la source

feat: 增加托盘图标和托盘菜单

Tony il y a 1 mois
Parent
commit
6b7f8a1998

+ 1 - 0
.gitignore

@@ -46,3 +46,4 @@ app.*.map.json
 
 cache.db
 *.lock
+*.dmp

BIN
assets/trayicon/dark/connected.ico


BIN
assets/trayicon/dark/unconnected.ico


BIN
assets/trayicon/light/connected.ico


BIN
assets/trayicon/light/unconnected.ico


+ 10 - 0
lib/app/constants/assets.dart

@@ -125,4 +125,14 @@ class Assets {
   static const String homePremium = 'assets/images/identity/premium.png';
   static const String homeTest = 'assets/images/identity/test.png';
   static const String homeFree = 'assets/images/identity/free.png';
+
+  // windows 托盘图标
+  static const String trayIconDarkConnectedWin =
+      'assets/trayicon/dark/connected.ico';
+  static const String trayIconDarkUnConnectedWin =
+      'assets/trayicon/dark/unconnected.ico';
+  static const String trayIconLightConnectedWin =
+      'assets/trayicon/light/connected.ico';
+  static const String trayIconLightUnConnectedWin =
+      'assets/trayicon/light/unconnected.ico';
 }

+ 0 - 1
lib/app/controllers/core_controller.dart

@@ -25,7 +25,6 @@ import '../data/models/vpn_message.dart';
 import '../data/sp/ix_sp.dart';
 import '../constants/sp_keys.dart';
 import '../dialog/error_dialog.dart';
-import '../dialog/feedback_bottom_sheet.dart';
 import 'api_controller.dart';
 
 class CoreController extends GetxService {

+ 4 - 0
lib/app/controllers/windows/menu_base/menu_base.dart

@@ -0,0 +1,4 @@
+export 'src/menu_behavior.dart';
+export 'src/menu_item.dart';
+export 'src/menu.dart';
+export 'src/placement.dart';

+ 39 - 0
lib/app/controllers/windows/menu_base/src/menu.dart

@@ -0,0 +1,39 @@
+import 'menu_item.dart';
+
+class Menu {
+  List<MenuItem>? items;
+
+  Menu({
+    this.items,
+  });
+
+  MenuItem? getMenuItem(String key) {
+    for (MenuItem menuItem in (items ?? [])) {
+      if (menuItem.key == key) {
+        return menuItem;
+      }
+      if (menuItem.submenu?.getMenuItem(key) != null) {
+        return menuItem.submenu?.getMenuItem(key);
+      }
+    }
+    return null;
+  }
+
+  MenuItem? getMenuItemById(int id) {
+    for (MenuItem menuItem in (items ?? [])) {
+      if (menuItem.id == id) {
+        return menuItem;
+      }
+      if (menuItem.submenu?.getMenuItemById(id) != null) {
+        return menuItem.submenu?.getMenuItemById(id);
+      }
+    }
+    return null;
+  }
+
+  Map<String, dynamic> toJson() {
+    return {
+      'items': items?.map((e) => e.toJson()).toList(),
+    }..removeWhere((key, value) => value == null);
+  }
+}

+ 12 - 0
lib/app/controllers/windows/menu_base/src/menu_behavior.dart

@@ -0,0 +1,12 @@
+import 'dart:ui';
+
+import 'menu.dart';
+import 'placement.dart';
+
+abstract class MenuBehavior {
+  Future<void> popUp(
+    Menu menu, {
+    Offset? position,
+    Placement placement = Placement.topLeft,
+  });
+}

+ 101 - 0
lib/app/controllers/windows/menu_base/src/menu_item.dart

@@ -0,0 +1,101 @@
+import 'dart:math' as math;
+
+import 'menu.dart';
+
+// Max value for a 16-bit unsigned integer. Chosen because it is the lowest
+// common denominator for menu item ids between Linux, Windows, and macOS.
+const int _maxMenuItemId = 65535;
+// Some parts of the win32 API pass data that is ambiguous about whether it is
+// the id of a menu item or its index in the menu. This sets a reasonable floor
+// to distinguish between the two by assuming that no menu will have more than
+// 1024 items in it.
+const int _minMenuItemId = 1024;
+int _nextMenuItemId = _minMenuItemId;
+
+_generateMenuItemId() {
+  final newId = _nextMenuItemId;
+  _nextMenuItemId = math.max(
+    _minMenuItemId,
+    (_nextMenuItemId + 1) % _maxMenuItemId,
+  );
+  return newId;
+}
+
+class MenuItem {
+  int id = -1;
+  String? key;
+  String type;
+  String? label;
+  String? sublabel;
+  String? toolTip;
+  String? icon;
+  bool? checked;
+  bool disabled;
+  Menu? submenu;
+
+  void Function(MenuItem menuItem)? onClick;
+  void Function(MenuItem menuItem)? onHighlight;
+  void Function(MenuItem menuItem)? onLoseHighlight;
+
+  MenuItem.separator()
+      : id = _generateMenuItemId(),
+        type = 'separator',
+        disabled = true;
+
+  MenuItem.submenu({
+    this.key,
+    this.label,
+    this.sublabel,
+    this.toolTip,
+    this.icon,
+    this.disabled = false,
+    this.submenu,
+    this.onClick,
+    this.onHighlight,
+    this.onLoseHighlight,
+  })  : id = _generateMenuItemId(),
+        type = 'submenu';
+
+  MenuItem.checkbox({
+    this.key,
+    this.label,
+    this.sublabel,
+    this.toolTip,
+    this.icon,
+    required this.checked,
+    this.disabled = false,
+    this.onClick,
+    this.onHighlight,
+    this.onLoseHighlight,
+  })  : id = _generateMenuItemId(),
+        type = 'checkbox';
+
+  MenuItem({
+    this.key,
+    this.type = 'normal',
+    this.label,
+    this.sublabel,
+    this.toolTip,
+    this.icon,
+    this.checked,
+    this.disabled = false,
+    this.submenu,
+    this.onClick,
+    this.onHighlight,
+    this.onLoseHighlight,
+  }) : id = _generateMenuItemId();
+
+  Map<String, dynamic> toJson() {
+    return {
+      'id': id,
+      'key': key,
+      'type': type,
+      'label': label ?? '',
+      'toolTip': toolTip,
+      'icon': icon,
+      'checked': checked,
+      'disabled': disabled,
+      'submenu': submenu?.toJson(),
+    }..removeWhere((key, value) => value == null);
+  }
+}

+ 6 - 0
lib/app/controllers/windows/menu_base/src/placement.dart

@@ -0,0 +1,6 @@
+enum Placement {
+  topLeft,
+  topRight,
+  bottomLeft,
+  bottomRight,
+}

+ 131 - 0
lib/app/controllers/windows/tray_controller.dart

@@ -0,0 +1,131 @@
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:shortid/shortid.dart';
+import 'package:path/path.dart' as path;
+
+import '../../constants/assets.dart';
+import 'menu_base/menu_base.dart';
+import 'tray_listener.dart';
+
+const kEventOnTrayIconLButtonDown = 'onTrayIconLButtonDown';
+const kEventOnTrayIconLButtonUp = 'onTrayIconLButtonUp';
+const kEventOnTrayIconRButtonDown = 'onTrayIconRButtonDown';
+const kEventOnTrayIconRButtonUp = 'onTrayIconRButtonUp';
+const kEventOnTrayMenuItemClick = 'onTrayMenuItemClick';
+
+class TrayController {
+  final _channel = const MethodChannel("app.nomo/tray");
+  final ObserverList<TrayListener> _listeners = ObserverList<TrayListener>();
+  Menu? _trayMenu;
+
+  TrayController() {
+    _channel.setMethodCallHandler(_methodCallHandler);
+  }
+
+  Future<void> _methodCallHandler(MethodCall call) async {
+    for (final TrayListener listener in _listeners) {
+      switch (call.method) {
+        case kEventOnTrayIconLButtonDown:
+          listener.onTrayIconLButtonDown();
+          break;
+        case kEventOnTrayIconLButtonUp:
+          listener.onTrayIconLButtonUp();
+          break;
+        case kEventOnTrayIconRButtonDown:
+          listener.onTrayIconRButtonDown();
+          break;
+        case kEventOnTrayIconRButtonUp:
+          listener.onTrayIconRButtonUp();
+          break;
+        case kEventOnTrayMenuItemClick:
+          int id = call.arguments['id'];
+          MenuItem? menuItem = _trayMenu?.getMenuItemById(id);
+          if (menuItem != null) {
+            bool? oldChecked = menuItem.checked;
+            if (menuItem.onClick != null) {
+              menuItem.onClick!(menuItem);
+            }
+            listener.onTrayMenuItemClick(menuItem);
+
+            bool? newChecked = menuItem.checked;
+            if (oldChecked != newChecked) {
+              await _setContextMenu(_trayMenu!);
+            }
+          }
+          break;
+      }
+    }
+  }
+
+  Future<void> _setContextMenu(Menu menu) {
+    _trayMenu = menu;
+    final Map<String, dynamic> arguments = {'menu': menu.toJson()};
+    return _channel.invokeMethod('setContextMenu', arguments);
+  }
+
+  Future<void> _setSystemTrayWindows(String iconPath, String? tooltip) {
+    final Map<String, dynamic> arguments = {
+      'id': shortid.generate(),
+      'icon': path.joinAll([
+        path.dirname(Platform.resolvedExecutable),
+        'data/flutter_assets',
+        iconPath,
+      ]),
+      'tooltip': tooltip ?? '',
+    };
+    return _channel.invokeMethod('setSystemTray', arguments);
+  }
+
+  /// 设置托盘图标
+  Future<void> setSystemTray(
+    bool isConnected,
+    bool isDark, {
+    String? title,
+    String? tooltip,
+  }) {
+    String iconPath;
+    if (isConnected) {
+      iconPath = isDark
+          ? Assets.trayIconDarkConnectedWin
+          : Assets.trayIconLightConnectedWin;
+    } else {
+      iconPath = isDark
+          ? Assets.trayIconDarkUnConnectedWin
+          : Assets.trayIconLightUnConnectedWin;
+    }
+    if (Platform.isWindows) {
+      return _setSystemTrayWindows(iconPath, tooltip);
+    } else {
+      throw UnimplementedError();
+    }
+  }
+
+  /// 设置托盘菜单
+  Future<void> setContextMenu(Menu trayMenu) {
+    return _setContextMenu(trayMenu);
+  }
+
+  /// 弹出系统托盘菜单
+  Future<void> popUpContextMenu() {
+    return _channel.invokeMethod('popupContextMenu');
+  }
+
+  /// 销毁系统托盘
+  Future<void> resetSystemTray() {
+    return _channel.invokeMethod('resetSystemTray');
+  }
+
+  bool get hasListeners {
+    return _listeners.isNotEmpty;
+  }
+
+  void addListener(TrayListener listener) {
+    _listeners.add(listener);
+  }
+
+  void removeListener(TrayListener listener) {
+    _listeners.remove(listener);
+  }
+}

+ 9 - 0
lib/app/controllers/windows/tray_listener.dart

@@ -0,0 +1,9 @@
+import 'menu_base/menu_base.dart';
+
+abstract class TrayListener {
+  void onTrayIconLButtonDown() {}
+  void onTrayIconLButtonUp() {}
+  void onTrayIconRButtonDown() {}
+  void onTrayIconRButtonUp() {}
+  void onTrayMenuItemClick(MenuItem menuItem) {}
+}

+ 8 - 8
lib/app/controllers/windows/vpn_windows_service.dart → lib/app/controllers/windows/vpn_service.dart

@@ -20,10 +20,10 @@ class _RpcResult {
   _RpcResult(this.success, this.message, this.data);
 }
 
-class VpnWindowsService {
-  static const _tag = 'VpnWindowsService';
+class VpnService {
+  static const _tag = 'VpnService';
 
-  static const _rpcPort = 29764;
+  static const _rpcPort = 25364;
   static const _rpcFileName = 'ixrpc.exe';
   static const _rpcHeartbeatTimeout = 5;
   static const _rpcBaseUrl = 'http://127.0.0.1:$_rpcPort';
@@ -94,8 +94,8 @@ class VpnWindowsService {
         _accessKeySecret,
       );
 
-      log(_tag, '[VpnWindowsService] url:$url');
-      log(_tag, '[VpnWindowsService] >>:$data');
+      log(_tag, '[VpnService] url:$url');
+      log(_tag, '[VpnService] >>:$data');
 
       final response = await _rpcDio.post(
         url,
@@ -109,7 +109,7 @@ class VpnWindowsService {
         ),
       );
 
-      log(_tag, '[VpnWindowsService] <<:${response.data}');
+      log(_tag, '[VpnService] <<:${response.data}');
 
       if (response.statusCode != 200) {
         log(_tag, 'rpcPost HTTP error: ${response.statusCode}');
@@ -161,14 +161,14 @@ class VpnWindowsService {
   }
 
   void _killProcess() {
-    log(_tag, 'VpnWindowsService kill process');
+    log(_tag, 'VpnService kill process');
     _process?.kill();
     _process = null;
     _setIsOnline(false);
   }
 
   void dispose() async {
-    log(_tag, 'VpnWindowsService dispose start.');
+    log(_tag, 'VpnService dispose start.');
     await _shutdown();
     _ecStatus.close();
     _ecOnline.close();

+ 62 - 0
lib/app/controllers/windows/window_controller.dart

@@ -0,0 +1,62 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+import 'window_listener.dart';
+
+const kEventOnWindowShow = 'onWindowShow';
+const kEventOnWindowHide = 'onWindowHide';
+
+class WindowController {
+  final _channel = const MethodChannel("app.nomo/app");
+  final ObserverList<WindowListener> _listeners =
+      ObserverList<WindowListener>();
+
+  WindowController() {
+    _channel.setMethodCallHandler(_methodCallHandler);
+  }
+
+  Future<void> _methodCallHandler(MethodCall call) async {
+    for (final WindowListener listener in _listeners) {
+      switch (call.method) {
+        case kEventOnWindowShow:
+          listener.onWindowShow();
+          break;
+        case kEventOnWindowHide:
+          listener.onWindowHide();
+          break;
+      }
+    }
+  }
+
+  // 激活窗口
+  Future<void> activeWindow() {
+    return _channel.invokeMethod('activeWindow');
+  }
+
+  // 隐藏窗口
+  Future<void> hideWindow() async {
+    return _channel.invokeMethod('hideWindow');
+  }
+
+  // 切换窗口
+  Future<void> toggleWindow() {
+    return _channel.invokeMethod('toggleWindow');
+  }
+
+  // 退出程序
+  Future<void> quitApplication() {
+    return _channel.invokeMethod('destroyWindow');
+  }
+
+  bool get hasListeners {
+    return _listeners.isNotEmpty;
+  }
+
+  void addListener(WindowListener listener) {
+    _listeners.add(listener);
+  }
+
+  void removeListener(WindowListener listener) {
+    _listeners.remove(listener);
+  }
+}

+ 4 - 0
lib/app/controllers/windows/window_listener.dart

@@ -0,0 +1,4 @@
+abstract class WindowListener {
+  void onWindowShow() {}
+  void onWindowHide() {}
+}

+ 102 - 0
lib/app/controllers/windows/window_service.dart

@@ -0,0 +1,102 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'menu_base/menu_base.dart';
+import 'tray_controller.dart';
+import 'tray_listener.dart';
+import 'window_controller.dart';
+import 'window_listener.dart';
+
+class WindowService implements WindowListener, TrayListener {
+  final _trayController = TrayController();
+  final _windowController = WindowController();
+
+  void Function(MenuItem menu)? _onMenuItemClick;
+
+  void initialize() {
+    // 监听系统托盘和窗口事件
+    _trayController.addListener(this);
+    _windowController.addListener(this);
+  }
+
+  void dispose() {
+    // 取消监听
+    _trayController.removeListener(this);
+    _windowController.removeListener(this);
+  }
+
+  void terminate() async {
+    await resetSystemTray();
+    exit(0);
+  }
+
+  // 激活窗口
+  Future<void> activeWindow() {
+    return _windowController.activeWindow();
+  }
+
+  // 退出程序
+  Future<void> quitApplication() {
+    return _windowController.quitApplication();
+  }
+
+  // 设置托盘菜单
+  Future<void> setSystemTrayMenu(
+    Menu trayMenu,
+    void Function(MenuItem menuItem) onMenuItemClick,
+  ) {
+    _onMenuItemClick = onMenuItemClick;
+    return _trayController.setContextMenu(trayMenu);
+  }
+
+  // 设置托盘图标
+  Future<void> setSystemTrayIcon(
+    bool isConnected,
+    bool isDark,
+    String tooltip,
+  ) {
+    return _trayController.setSystemTray(isConnected, isDark, tooltip: tooltip);
+  }
+
+  // 销毁托盘图标
+  Future<void> resetSystemTray() {
+    return _trayController.resetSystemTray();
+  }
+
+  @override
+  void onTrayMenuItemClick(MenuItem menuItem) {
+    if (_onMenuItemClick != null) {
+      _onMenuItemClick!(menuItem);
+    }
+  }
+
+  @override
+  void onTrayIconLButtonUp() {
+    _windowController.toggleWindow();
+  }
+
+  @override
+  void onTrayIconRButtonUp() {
+    _trayController.popUpContextMenu();
+  }
+
+  @override
+  void onTrayIconLButtonDown() {
+    // 无响应
+  }
+
+  @override
+  void onTrayIconRButtonDown() {
+    // 无响应
+  }
+
+  @override
+  void onWindowHide() {
+    // 无响应
+  }
+
+  @override
+  void onWindowShow() {
+    // 无响应
+  }
+}

+ 49 - 4
lib/app/controllers/windows_core_api.dart

@@ -3,14 +3,20 @@ import 'dart:convert';
 import 'dart:io';
 
 import 'package:flutter/services.dart';
+import 'package:get/get.dart';
+import 'package:nomo/app/controllers/windows/window_service.dart';
 import 'package:path/path.dart' as path;
 import 'package:path_provider/path_provider.dart';
 
+import '../../config/translations/strings_enum.dart';
 import '../../utils/log/logger.dart';
+import '../constants/configs.dart';
 import '../constants/enums.dart';
 import '../constants/errors.dart';
 import 'base_core_api.dart';
-import 'windows/vpn_windows_service.dart';
+import 'windows/menu_base/src/menu.dart';
+import 'windows/menu_base/src/menu_item.dart';
+import 'windows/vpn_service.dart';
 
 /// Windows 实现
 class WindowsCoreApi implements BaseCoreApi {
@@ -21,10 +27,14 @@ class WindowsCoreApi implements BaseCoreApi {
     _initEventChannel();
     // 初始化vpn服务
     _initVpnService();
+    // 初始化窗口服务
+    _initWindowService();
   }
 
   // 创建vpn服务
-  static final _vpn = VpnWindowsService();
+  static final _vpn = VpnService();
+  // 创建窗口服务
+  static final _windowService = WindowService();
 
   // 检查定时器
   Timer? _checkTimer;
@@ -71,6 +81,13 @@ class WindowsCoreApi implements BaseCoreApi {
     }
   }
 
+  void _initWindowService() {
+    _windowService.initialize();
+
+    _updatTrayIcon();
+    _setTrayMenu();
+  }
+
   void _initVpnService() {
     if (_isVpnInited) {
       return;
@@ -187,6 +204,34 @@ class WindowsCoreApi implements BaseCoreApi {
     }
   }
 
+  void _updatTrayIcon() {
+    final isDark = Get.theme.brightness == Brightness.dark;
+    // 更新提示栏
+    _windowService.setSystemTrayIcon(
+      _vpn.status == ConnectionState.connected,
+      isDark,
+      Configs.appName,
+    );
+  }
+
+  void _setTrayMenu() {
+    final trayMenu = Menu(
+      items: [
+        MenuItem(label: Strings.showWindow.tr, key: 'active'),
+        MenuItem.separator(),
+        MenuItem(label: Strings.quitApp.tr, key: 'quit'),
+      ],
+    );
+
+    _windowService.setSystemTrayMenu(trayMenu, (menuItem) {
+      if (menuItem.key == 'quit') {
+        _windowService.quitApplication();
+      } else if (menuItem.key == 'active') {
+        _windowService.activeWindow();
+      }
+    });
+  }
+
   @override
   Future<String?> getApps() async {
     // Windows 不需要获取应用列表
@@ -282,13 +327,12 @@ class WindowsCoreApi implements BaseCoreApi {
 
   @override
   Future<String?> getChannel() async {
-    // TODO: 实现 Windows 渠道获取
     return 'windows';
   }
 
   @override
   Future<void> openPackage(String packageName) async {
-    // TODO: 实现 Windows 打开应用
+    // Windows 不支持打开应用
   }
 
   /// 发送事件(供 Windows 实现内部使用)
@@ -317,6 +361,7 @@ class WindowsCoreApi implements BaseCoreApi {
   /// 释放资源
   static void dispose() {
     _vpn.dispose();
+    _windowService.dispose();
     _eventController.close();
   }
 

+ 7 - 0
lib/config/translations/strings_enum.dart

@@ -454,4 +454,11 @@ class Strings {
   static const String remainTime = 'Remain time';
   static const String remainTimeEnded = 'Your available time has ended';
   static const String expired = 'Expired';
+
+  // windows tray icon hints
+  static const String showWindow = "Show Window";
+  static const String quitApp = "Quit";
+  static const String vpnConnected = "VPN Connected";
+  static const String vpnConnecting = "VPN Connecting";
+  static const String vpnDisconnected = "VPN Disconnected";
 }

+ 3 - 0
pubspec.yaml

@@ -88,6 +88,7 @@ dependencies:
   shelf_web_socket: ^3.0.0 # windows 进程通信
   shelf: ^1.4.2 # windows 进程通信
   ffi: ^2.1.5 # FFI
+  shortid: ^0.1.2 # windows 菜单id
   # hive_ce: ^2.17.0 # 本地缓存
 
 dev_dependencies:
@@ -135,6 +136,8 @@ flutter:
     - assets/
     - assets/html/
     - assets/md/
+    - assets/trayicon/dark/
+    - assets/trayicon/light/
   fonts:
     - family: FiraSans
       fonts: