From 01fcf2f2d81fbb30d65aa782aeafe7d03f2ebe4c Mon Sep 17 00:00:00 2001 From: jaceju Date: Sat, 25 Apr 2026 18:11:08 +0800 Subject: [PATCH 01/11] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20Linux=20=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE=E5=90=8E=E7=AB=AF=E4=B8=8E=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- util/client/shortcut/__init__.py | 13 +- util/client/shortcut/linux_key_mapper.py | 110 ++++++++ .../client/shortcut/linux_shortcut_manager.py | 256 ++++++++++++++++++ 3 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 util/client/shortcut/linux_key_mapper.py create mode 100644 util/client/shortcut/linux_shortcut_manager.py diff --git a/util/client/shortcut/__init__.py b/util/client/shortcut/__init__.py index bbd0173e..6c6a1f92 100644 --- a/util/client/shortcut/__init__.py +++ b/util/client/shortcut/__init__.py @@ -3,11 +3,22 @@ shortcut 子模块 包含快捷键处理相关功能,使用 ShortcutManager 统一管理所有快捷键(键盘和鼠标)。 +根据平台自动选择 Windows 或 Linux 实现。 """ +import platform + from .. import logger from util.client.shortcut.shortcut_config import Shortcut, CommonShortcuts -from util.client.shortcut.shortcut_manager import ShortcutManager + +if platform.system() == 'Linux': + from util.client.shortcut.linux_shortcut_manager import LinuxShortcutManager as ShortcutManager + from util.client.shortcut.linux_key_mapper import LinuxKeyMapper as KeyMapper + from util.client.shortcut.linux_key_mapper import RESTORABLE_KEYS +else: + from util.client.shortcut.shortcut_manager import ShortcutManager + from util.client.shortcut.key_mapper import KeyMapper + from util.client.shortcut.key_mapper import RESTORABLE_KEYS __all__ = [ 'logger', diff --git a/util/client/shortcut/linux_key_mapper.py b/util/client/shortcut/linux_key_mapper.py new file mode 100644 index 00000000..f801e31c --- /dev/null +++ b/util/client/shortcut/linux_key_mapper.py @@ -0,0 +1,110 @@ +# coding: utf-8 +""" +Linux 按键映射模块 + +将 pynput 在 Linux 上的按键对象转换为统一键名, +以及将统一键名转换回 pynput 按键对象。 + +与 Windows 版 key_mapper.py 不同,pynput 在 Linux 上 +直接提供 Key 枚举或 KeyCode(char),无需 X11 keycode 中转。 +""" + +from pynput import keyboard + +from . import logger + + +# 可恢复的切换键(录音完成后需要恢复状态的锁键) +RESTORABLE_KEYS = { + 'caps_lock', # 大写锁定 + 'num_lock', # 数字键盘锁定 + 'scroll_lock', # 滚动锁定 +} + + +class LinuxKeyMapper: + """Linux 按键映射器""" + + # pynput 特殊键对象缓存 + _SPECIAL_KEY_OBJECTS = None + + @classmethod + def _get_special_key_objects(cls): + """获取 pynput 特殊键对象(延迟初始化)""" + if cls._SPECIAL_KEY_OBJECTS is None: + cls._SPECIAL_KEY_OBJECTS = { + 'caps_lock': keyboard.Key.caps_lock, + 'space': keyboard.Key.space, + 'tab': keyboard.Key.tab, + 'enter': keyboard.Key.enter, + 'esc': keyboard.Key.esc, + 'delete': keyboard.Key.delete, + 'backspace': keyboard.Key.backspace, + 'shift': keyboard.Key.shift, + 'ctrl': keyboard.Key.ctrl, + 'alt': keyboard.Key.alt, + 'cmd': keyboard.Key.cmd, + 'f1': keyboard.Key.f1, 'f2': keyboard.Key.f2, 'f3': keyboard.Key.f3, 'f4': keyboard.Key.f4, + 'f5': keyboard.Key.f5, 'f6': keyboard.Key.f6, 'f7': keyboard.Key.f7, 'f8': keyboard.Key.f8, + 'f9': keyboard.Key.f9, 'f10': keyboard.Key.f10, 'f11': keyboard.Key.f11, 'f12': keyboard.Key.f12, + } + return cls._SPECIAL_KEY_OBJECTS + + @staticmethod + def key_to_name(key) -> str: + """ + 将 pynput 按键对象转换为统一键名 + + Args: + key: pynput.keyboard.Key 枚举 或 KeyCode 对象 + + Returns: + str: 统一键名(与 Shortcut.key 格式一致) + + Examples: + Key.caps_lock → 'caps_lock' + KeyCode(char='a') → 'a' + KeyCode(char='A') → 'a' + KeyCode(vk=65) → 'a' + """ + # pynput Key 枚举(特殊键) + if isinstance(key, keyboard.Key): + return key.name + + # KeyCode(字母、数字、符号键) + if isinstance(key, keyboard.KeyCode): + if key.char: + return key.char.lower() + if key.vk is not None: + # ASCII 范围内的虚拟键码 + if 0x41 <= key.vk <= 0x5A: # A-Z + return chr(key.vk).lower() + if 0x30 <= key.vk <= 0x39: # 0-9 + return chr(key.vk) + + # 无法识别 + logger.warning(f"未知的按键对象: {key}") + return str(key) + + @staticmethod + def name_to_key(key_name: str): + """ + 将按键名称转换为 pynput 按键对象 + + Args: + key_name: 按键名称 + + Returns: + pynput 按键对象或 None + """ + # 特殊按键 + special_keys = LinuxKeyMapper._get_special_key_objects() + if key_name in special_keys: + return special_keys[key_name] + + # 单个字符按键 + if len(key_name) == 1: + return keyboard.KeyCode.from_char(key_name) + + logger.warning(f"未知按键名称: {key_name}") + return None diff --git a/util/client/shortcut/linux_shortcut_manager.py b/util/client/shortcut/linux_shortcut_manager.py new file mode 100644 index 00000000..488c6959 --- /dev/null +++ b/util/client/shortcut/linux_shortcut_manager.py @@ -0,0 +1,256 @@ +# coding: utf-8 +""" +Linux 快捷键管理器 + +用 pynput 跨平台 API(on_press/on_release、on_click)替代 +Windows 版的 win32_event_filter,实现相同的快捷键管理功能。 + +与 Windows 版 ShortcutManager 功能一致,但不支持 suppress(拦截按键)。 +默认配置 suppress=False,通过录音结束后补发按键恢复状态。 +""" + +import time +from concurrent.futures import ThreadPoolExecutor +from typing import TYPE_CHECKING, Dict, List, Optional + +from pynput import keyboard, mouse + +from . import logger +from .linux_key_mapper import LinuxKeyMapper as KeyMapper +from .linux_key_mapper import RESTORABLE_KEYS +from .emulator import ShortcutEmulator +from .event_handler import ShortcutEventHandler +from .task import ShortcutTask + +if TYPE_CHECKING: + from .shortcut_config import Shortcut + from util.client.state import ClientState + + +class LinuxShortcutManager: + """ + Linux 快捷键管理器 + + 功能与 Windows 版 ShortcutManager 对齐: + - 多快捷键并发处理 + - 防止不同按键互相干扰 + - restore 功能(录音结束后恢复 CapsLock 状态) + - hold_mode 和 click_mode 支持 + + 不支持 suppress(pynput 在 Linux 上无法拦截按键)。 + """ + + def __init__(self, state: 'ClientState', shortcuts: List['Shortcut']): + self.state = state + self.shortcuts = shortcuts + + # 监听器 + self.keyboard_listener: Optional[keyboard.Listener] = None + self.mouse_listener: Optional[mouse.Listener] = None + + # 快捷键任务映射(key_name -> ShortcutTask) + self.tasks: Dict[str, ShortcutTask] = {} + + # 线程池 + self._pool = ThreadPoolExecutor(max_workers=4) + + # 按键模拟器 + self._emulator = ShortcutEmulator() + + # 按键恢复状态追踪 + self._restoring_keys = set() + + # 事件处理器 + self._event_handler = ShortcutEventHandler(self.tasks, self._pool, self._emulator) + + # 鼠标按键 → 任务 key 的映射 + # Linux 上 pynput 用 button8/button9 代替 Windows 的 x1/x2 + self._mouse_button_map = { + mouse.Button.button8: 'x1', + mouse.Button.button9: 'x2', + } + + self._init_tasks() + + def _init_tasks(self) -> None: + """初始化所有快捷键任务""" + from config_client import ClientConfig as Config + + for shortcut in self.shortcuts: + if not shortcut.enabled: + continue + + task = ShortcutTask(shortcut, self.state) + task._manager_ref = lambda: self + task.pool = self._pool + task.threshold = shortcut.get_threshold(Config.threshold) + self.tasks[shortcut.key] = task + + # ========== 键盘回调 ========== + + def _on_press(self, key) -> None: + """键盘按下回调""" + key_name = KeyMapper.key_to_name(key) + + # 防自捕获:模拟按键补发时忽略 + if self._emulator.is_emulating(key_name): + return + # 防自捕获:恢复按键时忽略 + if key_name in self._restoring_keys: + return + + if key_name not in self.tasks: + return + + task = self.tasks[key_name] + self._event_handler.handle_keydown(key_name, task) + + def _on_release(self, key) -> None: + """键盘释放回调""" + key_name = KeyMapper.key_to_name(key) + + # 模拟按键补发完成,清除标志 + if self._emulator.is_emulating(key_name): + self._emulator.clear_emulating_flag(key_name) + return + # 恢复按键完成,清除标志 + if key_name in self._restoring_keys: + self._restoring_keys.discard(key_name) + return + + if key_name not in self.tasks: + return + + task = self.tasks[key_name] + + # Linux 上 pynput 无法 suppress,系统已经处理了按键 + # 所以短按时不需要补发(补发会导致切换两次 = 没切换) + if task.shortcut.hold_mode and task.is_recording: + duration = time.time() - task.recording_start_time + if duration < task.threshold: + task.cancel() + return + + self._event_handler.handle_keyup(key_name, task) + + # ========== 鼠标回调 ========== + + def _on_click(self, x, y, button, pressed) -> None: + """鼠标按键回调""" + button_name = self._mouse_button_map.get(button) + if button_name is None: + return + + # 防自捕获:模拟按键补发时忽略 + if self._emulator.is_emulating(button_name): + if not pressed: + self._emulator.clear_emulating_flag(button_name) + return + + if button_name not in self.tasks: + return + + task = self.tasks[button_name] + + if pressed: + self._event_handler.handle_keydown(button_name, task) + else: + self._handle_mouse_keyup(button_name, task) + + def _handle_mouse_keyup(self, button_name: str, task) -> None: + """处理鼠标按键释放事件""" + # 单击模式 + if not task.shortcut.hold_mode: + if task.pressed: + task.pressed = False + task.released = True + task.event.set() + return + + # 长按模式 + if not task.is_recording: + return + + duration = time.time() - task.recording_start_time + logger.debug(f"[{button_name}] 松开按键,持续时间: {duration:.3f}s") + + if duration < task.threshold: + task.cancel() + if task.shortcut.suppress: + logger.debug(f"[{button_name}] 安排异步补发鼠标按键") + self._pool.submit(self._emulator.emulate_mouse_click, button_name) + else: + task.finish() + + # ========== 按键恢复管理 ========== + + def schedule_restore(self, key: str) -> None: + """ + 安排按键恢复(延迟执行,避免在事件处理中阻塞) + + Args: + key: 要恢复的按键 + """ + from pynput import keyboard + + self._restoring_keys.add(key) + + def do_restore(): + time.sleep(0.05) + if key == 'caps_lock': + controller = keyboard.Controller() + controller.press(keyboard.Key.caps_lock) + controller.release(keyboard.Key.caps_lock) + + self._pool.submit(do_restore) + + def is_restoring(self, key: str) -> bool: + return key in self._restoring_keys + + def clear_restoring_flag(self, key: str) -> None: + self._restoring_keys.discard(key) + + # ========== 公共接口 ========== + + def start(self) -> None: + """启动所有监听器""" + has_keyboard = any(s.type == 'keyboard' for s in self.shortcuts if s.enabled) + has_mouse = any(s.type == 'mouse' for s in self.shortcuts if s.enabled) + + if has_keyboard: + self.keyboard_listener = keyboard.Listener( + on_press=self._on_press, + on_release=self._on_release, + ) + self.keyboard_listener.start() + logger.info("键盘监听器已启动 (Linux)") + + if has_mouse: + self.mouse_listener = mouse.Listener( + on_click=self._on_click, + ) + self.mouse_listener.start() + logger.info("鼠标监听器已启动 (Linux)") + + for shortcut in self.shortcuts: + if shortcut.enabled: + mode = "长按" if shortcut.hold_mode else "单击" + toggle = "可恢复" if any(t in shortcut.key for t in RESTORABLE_KEYS) else "普通键" + logger.info(f" [{shortcut.key}] {mode}模式, {toggle}") + + def stop(self) -> None: + """停止所有监听器和清理资源""" + if self.keyboard_listener: + self.keyboard_listener.stop() + logger.debug("键盘监听器已停止") + + if self.mouse_listener: + self.mouse_listener.stop() + logger.debug("鼠标监听器已停止") + + for task in self.tasks.values(): + if task.is_recording: + task.cancel() + + self._pool.shutdown(wait=False) + logger.debug("快捷键管理器线程池已关闭") From c7206cf21b9462dc1744e1d44e7f2f712d6583a7 Mon Sep 17 00:00:00 2001 From: jaceju Date: Sat, 25 Apr 2026 18:11:19 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=E6=8E=A5=E9=80=9A=20Linux=20=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE=E5=90=8E=E7=AB=AF=E7=9A=84=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E8=B0=83=E7=94=A8=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- util/client/shortcut/emulator.py | 3 ++- util/client/shortcut/shortcut_config.py | 2 +- util/client/startup.py | 2 +- util/client/udp/udp_control.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/util/client/shortcut/emulator.py b/util/client/shortcut/emulator.py index a862db69..74b5e833 100644 --- a/util/client/shortcut/emulator.py +++ b/util/client/shortcut/emulator.py @@ -7,7 +7,6 @@ from pynput import keyboard, mouse from . import logger -from util.client.shortcut.key_mapper import KeyMapper @@ -39,6 +38,8 @@ def emulate_key(self, key_name: str) -> None: Args: key_name: 按键名称(如 'caps_lock', 'f12') """ + from util.client.shortcut import KeyMapper + self._emulating_keys.add(key_name) key_obj = KeyMapper.name_to_key(key_name) diff --git a/util/client/shortcut/shortcut_config.py b/util/client/shortcut/shortcut_config.py index 88eac49e..0ef18396 100644 --- a/util/client/shortcut/shortcut_config.py +++ b/util/client/shortcut/shortcut_config.py @@ -96,7 +96,7 @@ def is_toggle_key(self) -> bool: 注意:使用 RESTORABLE_KEYS 常量定义可恢复的按键 """ - from util.client.shortcut.key_mapper import RESTORABLE_KEYS + from util.client.shortcut import RESTORABLE_KEYS # 检查 key 是否包含可恢复的切换键 return any(toggle_key in self.key for toggle_key in RESTORABLE_KEYS) diff --git a/util/client/startup.py b/util/client/startup.py index 80f78d27..3f27741c 100644 --- a/util/client/startup.py +++ b/util/client/startup.py @@ -11,7 +11,7 @@ from util.llm.llm_handler import init_llm_system from util.client.audio import AudioStreamManager from util.client.shortcut.shortcut_config import Shortcut -from util.client.shortcut.shortcut_manager import ShortcutManager +from util.client.shortcut import ShortcutManager from util.tools.empty_working_set import empty_current_working_set diff --git a/util/client/udp/udp_control.py b/util/client/udp/udp_control.py index 599932ce..63897428 100644 --- a/util/client/udp/udp_control.py +++ b/util/client/udp/udp_control.py @@ -19,7 +19,7 @@ from . import logger if TYPE_CHECKING: - from util.client.shortcut.shortcut_manager import ShortcutManager + from util.client.shortcut import ShortcutManager From 4d3bc6914fc37fa09d89b8503f9717999310b54c Mon Sep 17 00:00:00 2001 From: jaceju Date: Sat, 25 Apr 2026 18:11:28 +0800 Subject: [PATCH 03/11] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20Linux=20=E6=89=98?= =?UTF-8?q?=E7=9B=98=E6=94=AF=E6=8C=81=E5=B9=B6=E6=8E=A5=E5=85=A5=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E4=BA=8B=E4=BB=B6=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- core_server.py | 3 +- util/ui/tray.py | 369 ++++++++++++++++++++++++++---------------------- 2 files changed, 205 insertions(+), 167 deletions(-) diff --git a/core_server.py b/core_server.py index 7929d53b..085aff09 100644 --- a/core_server.py +++ b/core_server.py @@ -41,6 +41,8 @@ async def run_websocket_server(): from util.concurrency.daemon_executor import SimpleDaemonExecutor loop.set_default_executor(SimpleDaemonExecutor()) + setup_tray() + # 清空物理内存工作集 # if system() == 'Windows': # empty_current_working_set() @@ -96,7 +98,6 @@ def init(): logger.info(f"版本: {__version__}") logger.info(f"日志级别: {Config.log_level}") - setup_tray() print_banner() try: diff --git a/util/ui/tray.py b/util/ui/tray.py index 1e950ad6..e1be218c 100644 --- a/util/ui/tray.py +++ b/util/ui/tray.py @@ -2,16 +2,8 @@ """ 托盘图标模块 -提供最小化到系统托盘的功能。 -仅在 Windows 平台有效。 - -功能: -- 禁用控制台窗口的关闭按钮(防止误关) -- 最小化时自动隐藏到托盘 -- 双击托盘图标显示/隐藏窗口 -- 托盘菜单退出程序 - -注意:pystray 在 Linux 无 GUI 环境下无法导入,因此采用延迟导入。 +Windows: pystray + Win32 API(窗口管理) +Linux: PySide6 QSystemTrayIcon(原生菜单) """ import os @@ -22,75 +14,79 @@ from typing import Optional from . import logger, set_ui_logger -# 退出回调函数(由主程序传入) -_exit_callback = None +_IS_WINDOWS = platform.system() == 'Windows' +_IS_LINUX = platform.system() == 'Linux' -# 是否可用(在 enable_min_to_tray 时检测) +_exit_callback = None _tray_available = None +_tray_instance = None +_lock = threading.Lock() + def _set_exit_callback(callback): - """设置退出回调函数""" global _exit_callback _exit_callback = callback + def _get_exit_callback(): return _exit_callback +# --------------------------------------------------------------------------- +# 可用性检测 +# --------------------------------------------------------------------------- + def _check_tray_available() -> bool: - """ - 检查托盘功能是否可用 - - Returns: - bool: 是否可用 - """ global _tray_available - + if _tray_available is not None: return _tray_available - - # 非 Windows 系统不支持 - if platform.system() != 'Windows': - _tray_available = False - return False - - # 尝试导入 pystray - try: - import pystray - from PIL import Image - _tray_available = True - except ImportError as e: - logger.warning(f"托盘功能不可用: {e}") - _tray_available = False - except Exception as e: - logger.warning(f"托盘功能检测失败: {e}") - _tray_available = False - + + if _IS_LINUX: + try: + from PySide6.QtWidgets import QSystemTrayIcon + _tray_available = True + except ImportError as e: + logger.warning(f"托盘功能不可用(Linux 需安装 PySide6): {e}") + _tray_available = False + except Exception as e: + logger.warning(f"托盘功能检测失败: {e}") + _tray_available = False + else: + try: + import pystray + from PIL import Image + _tray_available = True + except ImportError as e: + logger.warning(f"托盘功能不可用: {e}") + _tray_available = False + except Exception as e: + logger.warning(f"托盘功能检测失败: {e}") + _tray_available = False + return _tray_available -# Windows API(延迟初始化) +# --------------------------------------------------------------------------- +# Windows API(延迟初始化,仅 Windows) +# --------------------------------------------------------------------------- + _win_api_initialized = False user32 = None kernel32 = None SW_HIDE = 0 SW_RESTORE = 9 -SW_SHOW = 5 SC_CLOSE = 0xF060 MF_BYCOMMAND = 0x00000000 def _init_win_api(): - """初始化 Windows API""" global _win_api_initialized, user32, kernel32 - if _win_api_initialized: return - - if platform.system() != 'Windows': + if not _IS_WINDOWS: return - try: import ctypes user32 = ctypes.windll.user32 @@ -100,13 +96,7 @@ def _init_win_api(): logger.warning(f"Windows API 初始化失败: {e}") -# 全局变量 -_tray_instance: Optional['_TraySystem'] = None -_lock = threading.Lock() - - def _get_console_hwnd() -> int: - """获取控制台窗口句柄""" _init_win_api() if kernel32 is None: return 0 @@ -114,7 +104,6 @@ def _get_console_hwnd() -> int: def _disable_close_button(hwnd: int) -> None: - """禁用窗口的关闭按钮""" if user32 is None: return h_menu = user32.GetSystemMenu(hwnd, False) @@ -123,41 +112,30 @@ def _disable_close_button(hwnd: int) -> None: def _enable_close_button(hwnd: int) -> None: - """恢复窗口的关闭按钮""" if user32 is None: return user32.GetSystemMenu(hwnd, True) def _is_window_minimized(hwnd: int) -> bool: - """检查窗口是否最小化""" if user32 is None: return False return user32.IsIconic(hwnd) != 0 def _is_window_visible(hwnd: int) -> bool: - """检查窗口是否可见""" if user32 is None: return False return user32.IsWindowVisible(hwnd) != 0 -def _create_icon(icon_path: Optional[str] = None): - """ - 创建托盘图标 - - 优先从指定路径加载图标文件,如果不存在则动态生成。 - - Args: - icon_path: 图标文件路径 - - Returns: - PIL Image 对象 - """ +# --------------------------------------------------------------------------- +# 图标生成(PIL,Windows/Linux 共用) +# --------------------------------------------------------------------------- + +def _create_pil_icon(icon_path: Optional[str] = None): from PIL import Image, ImageDraw - - # 如果图标文件存在,直接加载 + if icon_path and os.path.exists(icon_path): try: image = Image.open(icon_path) @@ -165,9 +143,8 @@ def _create_icon(icon_path: Optional[str] = None): image = image.convert('RGBA') return image.resize((64, 64), Image.Resampling.LANCZOS) except Exception: - pass # 加载失败则使用动态生成 + pass - # 动态生成图标 size = 64 scale = 4 real_size = size * scale @@ -179,182 +156,242 @@ def _create_icon(icon_path: Optional[str] = None): yellow = (255, 211, 67) white = (255, 255, 255) - # 蓝色圆角背景 m = 2 * scale dc.rounded_rectangle( [m, m, real_size - m, real_size - m], radius=real_size // 4, fill=blue ) - - # 黄色圆圈 center = real_size // 2 r = real_size // 3.5 dc.ellipse([center - r, center - r, center + r, center + r], fill=yellow) - - # 白色圆点 r2 = r // 2 dc.ellipse([center - r2, center - r2, center + r2, center + r2], fill=white) return image.resize((size, size), Image.Resampling.LANCZOS) -class _TraySystem: - """托盘系统内部类""" - - def __init__(self, name: Optional[str] = None, icon_path: Optional[str] = None, more_options: list = None): - # 延迟导入 pystray +def _pil_to_qimage(pil_image): + from PySide6.QtGui import QImage + if pil_image.mode != 'RGBA': + pil_image = pil_image.convert('RGBA') + data = pil_image.tobytes() + w, h = pil_image.size + qimg = QImage(data, w, h, w * 4, QImage.Format.Format_RGBA8888).copy() + return qimg + + +# --------------------------------------------------------------------------- +# Windows 托盘(pystray) +# --------------------------------------------------------------------------- + +class _WindowsTray: + def __init__(self, name, icon_path, more_options): import pystray from pystray import MenuItem as item - + self.hwnd = _get_console_hwnd() self.should_exit = False - self.title = name if name else (os.path.basename(sys.argv[0]) or "Console App") + self.title = name or os.path.basename(sys.argv[0]) or "CapsWriter" - # 禁用关闭按钮 if self.hwnd: _disable_close_button(self.hwnd) - # 定义菜单 menu_items = [ item(f"{self.title}", lambda: None, enabled=False), - item('👁️ 显示/隐藏', self.toggle_window, default=True), + item('显示/隐藏', self.toggle_window, default=True), ] - - # 添加额外选项 if more_options: for opt_name, opt_func in more_options: menu_items.append(item(opt_name, opt_func)) - - menu_items.append(item('❌ 退出', self.on_exit)) + menu_items.append(item('退出', self.on_exit)) self.icon = pystray.Icon( "console_tray", - _create_icon(icon_path), - title=f"{self.title}", + _create_pil_icon(icon_path), + title=self.title, menu=tuple(menu_items) ) - def toggle_window(self) -> None: - """切换窗口显示状态""" + def toggle_window(self): if not self.hwnd or user32 is None: return - if _is_window_visible(self.hwnd): user32.ShowWindow(self.hwnd, SW_HIDE) else: user32.ShowWindow(self.hwnd, SW_RESTORE) user32.SetForegroundWindow(self.hwnd) - def monitor_loop(self) -> None: - """监控线程:检测最小化操作""" + def monitor_loop(self): while not self.should_exit: if self.hwnd and user32: - # 窗口可见且最小化 -> 隐藏到托盘 if _is_window_visible(self.hwnd) and _is_window_minimized(self.hwnd): user32.ShowWindow(self.hwnd, SW_HIDE) time.sleep(0.2) - def on_exit(self, icon, item) -> None: - """托盘退出处理""" - exit_callback = _get_exit_callback() - - logger.info("托盘退出: 用户点击退出菜单,准备清理资源并退出") - - # 1. 设置退出标志,停止监控循环 + def on_exit(self, icon, item): + logger.info("托盘退出: 用户点击退出菜单") self.should_exit = True - logger.debug("已设置托盘退出标志") - - # 2. 恢复窗口关闭按钮并显示窗口 if self.hwnd and user32: _enable_close_button(self.hwnd) user32.ShowWindow(self.hwnd, SW_RESTORE) - logger.debug("已恢复窗口显示") - - # 3. 调用退出回调函数,请求主程序退出 - if exit_callback: + cb = _get_exit_callback() + if cb: try: - logger.debug("正在调用退出回调函数...") - exit_callback() - logger.info("退出回调函数已调用") + cb() except Exception as e: - logger.error(f"调用退出回调函数时发生错误: {e}") - + logger.error(f"退出回调出错: {e}") + try: + self.icon.stop() + except Exception: + pass + def start(self): + threading.Thread(target=self.icon.run, daemon=False).start() + threading.Thread(target=self.monitor_loop, daemon=True).start() + self.toggle_window() - # 5. 停止托盘图标 + def stop(self): try: - logger.debug("正在停止托盘图标线程...") self.icon.stop() - logger.debug("托盘图标线程已停止") - except Exception as e: - logger.warning(f"停止托盘图标时发生错误: {e}") + except Exception: + pass - def start(self) -> None: - """启动托盘系统""" - # 托盘图标线程 - t_tray = threading.Thread(target=self.icon.run, daemon=False) - t_tray.start() - # 状态监控线程 - t_monitor = threading.Thread(target=self.monitor_loop, daemon=True) - t_monitor.start() +# --------------------------------------------------------------------------- +# Linux 托盘(PySide6 QSystemTrayIcon) +# --------------------------------------------------------------------------- + +class _LinuxTray: + def __init__(self, name, icon_path, more_options): + from PySide6.QtWidgets import QApplication, QSystemTrayIcon, QMenu + from PySide6.QtGui import QIcon, QPixmap, QFont, QFontDatabase + + self.title = name or os.path.basename(sys.argv[0]) or "CapsWriter" + self._loop = None + self._tick_handle = None + self._running = False + self._app = QApplication.instance() + if self._app is None: + self._app = QApplication([]) + self._app.setQuitOnLastWindowClosed(False) + + preferred = [ + "Noto Sans CJK SC", "Noto Sans SC", + "WenQuanYi Micro Hei", "WenQuanYi Zen Hei", + "Source Han Sans SC", "Droid Sans Fallback", + ] + families = QFontDatabase.families() + for font_name in preferred: + if any(font_name in f for f in families): + QApplication.setFont(QFont(font_name, 10)) + break + + pil_img = _create_pil_icon(icon_path) + qimg = _pil_to_qimage(pil_img) + self._qt_icon = QIcon(QPixmap.fromImage(qimg)) + + self._menu = QMenu() + title_action = self._menu.addAction(self.title) + title_action.setEnabled(False) + self._menu.addSeparator() + + for opt_name, opt_func in (more_options or []): + action = self._menu.addAction(opt_name) + action.triggered.connect(lambda checked, f=opt_func: f()) + + self._menu.addSeparator() + exit_action = self._menu.addAction('退出') + exit_action.triggered.connect(self._on_exit) + + self._tray_icon = QSystemTrayIcon(self._qt_icon) + self._tray_icon.setToolTip(self.title) + self._tray_icon.setContextMenu(self._menu) + self._tray_icon.show() + + def _on_exit(self): + logger.info("托盘退出: 用户点击退出菜单") + cb = _get_exit_callback() + if cb: + try: + cb() + except Exception as e: + logger.error(f"退出回调出错: {e}") + if self._tray_icon: + self._tray_icon.hide() - # 启动时隐藏窗口 - self.toggle_window() + def _tick(self): + if not self._running: + return + if self._app: + self._app.processEvents() + if self._loop: + self._tick_handle = self._loop.call_later(0.05, self._tick) + def start(self): + import asyncio + try: + self._loop = asyncio.get_running_loop() + except RuntimeError: + logger.warning("Linux Qt 托盘需要运行中的 asyncio 主循环,跳过事件泵") + return + self._running = True + self._tick() + + def stop(self): + self._running = False + if self._tick_handle: + self._tick_handle.cancel() + self._tick_handle = None + if self._tray_icon: + self._tray_icon.hide() + if self._app: + self._app.quit() -def enable_min_to_tray(name: Optional[str] = None, icon_path: Optional[str] = None, exit_callback=None, more_options: list = None) -> None: - """ - 启用最小化到托盘功能 - - 如果检测不到控制台窗口(如 .pyw 运行),则不执行任何操作。 - 如果在 Linux 等无 GUI 环境下运行,也会跳过。 - - Args: - name: 托盘图标显示的名称,默认使用程序名称 - icon_path: 图标文件路径,默认动态生成 - exit_callback: 退出回调函数,当用户点击托盘退出菜单时调用 - more_options: 额外菜单项列表,格式为 [(名称, 回调函数), ...] - """ - global _tray_instance +# --------------------------------------------------------------------------- +# 公共接口 +# --------------------------------------------------------------------------- + +def enable_min_to_tray(name: Optional[str] = None, icon_path: Optional[str] = None, exit_callback=None, more_options: list = None) -> None: global _tray_instance - # 设置退出回调函数 if exit_callback is not None: _set_exit_callback(exit_callback) - # 检查托盘功能是否可用 if not _check_tray_available(): logger.info("托盘功能不可用,跳过启用") return - # DPI 感知设置 - try: - import ctypes - ctypes.windll.shcore.SetProcessDpiAwareness(2) - except Exception: - pass + if _IS_WINDOWS: + try: + import ctypes + ctypes.windll.shcore.SetProcessDpiAwareness(2) + except Exception: + pass with _lock: if _tray_instance is not None: - return # 已启动 - - if not _get_console_hwnd(): - return # 没有控制台窗口 - - _tray_instance = _TraySystem(name, icon_path, more_options) - _tray_instance.start() + logger.debug(f"托盘已存在,跳过: {_tray_instance}") + return + try: + if _IS_LINUX: + _tray_instance = _LinuxTray(name, icon_path, more_options) + else: + _tray_instance = _WindowsTray(name, icon_path, more_options) + _tray_instance.start() + logger.info(f"托盘已创建并启动: {_tray_instance}") + except Exception as e: + logger.error(f"托盘创建失败: {e}", exc_info=True) + _tray_instance = None def stop_tray() -> None: - """停止托盘图标""" global _tray_instance - if _tray_instance and _tray_instance.icon: + if _tray_instance: try: - _tray_instance.icon.stop() + _tray_instance.stop() except Exception: pass _tray_instance = None @@ -362,6 +399,6 @@ def stop_tray() -> None: if __name__ == "__main__": enable_min_to_tray() - print("程序运行中... 你可以双击托盘图标隐藏我。") + print("程序运行中... 你可以右键托盘图标操作。") while True: time.sleep(1) From 6c22316b29629e157f3cb7a78e68431fee92b2fc Mon Sep 17 00:00:00 2001 From: jaceju Date: Sat, 25 Apr 2026 18:11:41 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Linux=20=E4=B8=8B=20?= =?UTF-8?q?Tk=20=E5=AF=B9=E8=AF=9D=E6=A1=86=E7=9A=84=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- util/ui/context_dialog.py | 13 ++++----- util/ui/dialogs.py | 3 ++- util/ui/hotword_dialog.py | 13 ++++----- util/ui/rectify_dialog.py | 15 ++++++----- util/ui/toast_constants.py | 54 +++++++++++++++++++++++++++++++++++++- 5 files changed, 77 insertions(+), 21 deletions(-) diff --git a/util/ui/context_dialog.py b/util/ui/context_dialog.py index f107d9f5..9148800a 100644 --- a/util/ui/context_dialog.py +++ b/util/ui/context_dialog.py @@ -15,7 +15,7 @@ DialogResult, wait_window, ) -from .toast_constants import DEFAULT_FONT_FAMILY +from .toast_constants import apply_tk_font_defaults from . import logger @@ -71,20 +71,21 @@ def show( resizable=True, withdraw=True ) + font_family = apply_tk_font_defaults(dialog) # 创建容器 main_frame = ttk.Frame(dialog, padding=(20, 15, 20, 20)) main_frame.pack(fill="both", expand=True) # 字体设置 - label_font = (DEFAULT_FONT_FAMILY, 10, "bold") - entry_font = (DEFAULT_FONT_FAMILY, 11) + label_font = (font_family, 10, "bold") + entry_font = (font_family, 11) # 说明 ttk.Label( main_frame, text="请输入提示词上下文(辅助 ASR 识别,如专有名词):", - font=(DEFAULT_FONT_FAMILY, 9), + font=(font_family, 9), foreground="#666666" ).pack(anchor="w", pady=(0, 10)) @@ -134,7 +135,7 @@ def on_cancel(): button_frame, text="确定 (Ctrl+Enter)", command=on_confirm, - font=(DEFAULT_FONT_FAMILY, 9), + font=(font_family, 9), bg="#4CAF50", fg="white", activebackground="#45a049", @@ -150,7 +151,7 @@ def on_cancel(): button_frame, text="取消 (Esc)", command=on_cancel, - font=(DEFAULT_FONT_FAMILY, 9), + font=(font_family, 9), bg="#f44336", fg="white", activebackground="#da190b", diff --git a/util/ui/dialogs.py b/util/ui/dialogs.py index a117b0f6..b31a668d 100644 --- a/util/ui/dialogs.py +++ b/util/ui/dialogs.py @@ -10,7 +10,7 @@ from tkinter import ttk from typing import Optional, Callable -from .toast_constants import DEFAULT_FONT_FAMILY +from .toast_constants import DEFAULT_FONT_FAMILY, apply_tk_font_defaults from . import logger # DPI 感知设置(与 toast_base.py 保持一致) @@ -46,6 +46,7 @@ def create_modal_dialog( """ # 创建 Toplevel 窗口 dialog = tk.Toplevel() + apply_tk_font_defaults(dialog) dialog.title(title) # 先隐藏窗口,避免闪烁 diff --git a/util/ui/hotword_dialog.py b/util/ui/hotword_dialog.py index 9af7a66a..42a098ed 100644 --- a/util/ui/hotword_dialog.py +++ b/util/ui/hotword_dialog.py @@ -14,7 +14,7 @@ DialogResult, wait_window, ) -from .toast_constants import DEFAULT_FONT_FAMILY +from .toast_constants import apply_tk_font_defaults from . import logger @@ -70,20 +70,21 @@ def show( resizable=False, withdraw=True ) + font_family = apply_tk_font_defaults(dialog) # 创建容器 main_frame = ttk.Frame(dialog, padding=(20, 15, 20, 20)) main_frame.pack(fill="both", expand=True) # 字体设置 - label_font = (DEFAULT_FONT_FAMILY, 10, "bold") - entry_font = (DEFAULT_FONT_FAMILY, 11) + label_font = (font_family, 10, "bold") + entry_font = (font_family, 11) # 说明 ttk.Label( main_frame, text="请输入要添加的热词(每行一个):", - font=(DEFAULT_FONT_FAMILY, 9), + font=(font_family, 9), foreground="#666666" ).pack(anchor="w", pady=(0, 10)) @@ -138,7 +139,7 @@ def on_cancel(): button_frame, text="确定 (Ctrl+Enter)", command=on_confirm, - font=(DEFAULT_FONT_FAMILY, 9), + font=(font_family, 9), bg="#4CAF50", fg="white", activebackground="#45a049", @@ -154,7 +155,7 @@ def on_cancel(): button_frame, text="取消 (Esc)", command=on_cancel, - font=(DEFAULT_FONT_FAMILY, 9), + font=(font_family, 9), bg="#f44336", fg="white", activebackground="#da190b", diff --git a/util/ui/rectify_dialog.py b/util/ui/rectify_dialog.py index 0eb8e0af..a28c8ee6 100644 --- a/util/ui/rectify_dialog.py +++ b/util/ui/rectify_dialog.py @@ -14,7 +14,7 @@ DialogResult, wait_window, ) -from .toast_constants import DEFAULT_FONT_FAMILY +from .toast_constants import apply_tk_font_defaults from . import logger @@ -76,6 +76,7 @@ def show( resizable=True, withdraw=True # 先隐藏窗口,避免闪烁 ) + font_family = apply_tk_font_defaults(dialog) # 创建容器 main_frame = ttk.Frame(dialog, padding=(20, 15, 20, 25)) @@ -94,13 +95,13 @@ def show( info_label = ttk.Label( main_frame, text=info_text, - font=(DEFAULT_FONT_FAMILY, 9), + font=(font_family, 9), foreground="#666666" ) # info_label.pack(anchor="w", pady=(0, 10)) # 字体设置 - text_font = (DEFAULT_FONT_FAMILY, 10) + text_font = (font_family, 10) # 动态高度调整函数 def auto_resize_textbox(text_widget): @@ -165,7 +166,7 @@ def _on_mod(event): widget.bind("<>", _on_mod) # 创建原始文本输入框(初始高度 1) - ttk.Label(main_frame, text="原始:", font=(DEFAULT_FONT_FAMILY, 10, "bold")).pack(anchor="w") + ttk.Label(main_frame, text="原始:", font=(font_family, 10, "bold")).pack(anchor="w") original_text_widget = tk.Text( main_frame, @@ -187,7 +188,7 @@ def _on_mod(event): # 创建纠错文本输入框(初始高度 1) - ttk.Label(main_frame, text="纠错:", font=(DEFAULT_FONT_FAMILY, 10, "bold")).pack(anchor="w") + ttk.Label(main_frame, text="纠错:", font=(font_family, 10, "bold")).pack(anchor="w") corrected_text_widget = tk.Text( main_frame, @@ -299,7 +300,7 @@ def on_cancel(): command=on_confirm, width=15, height=1, - font=(DEFAULT_FONT_FAMILY, 10), + font=(font_family, 10), bg="#4CAF50", fg="white", activebackground="#45a049", @@ -314,7 +315,7 @@ def on_cancel(): command=on_cancel, width=15, height=1, - font=(DEFAULT_FONT_FAMILY, 10), + font=(font_family, 10), bg="#f44336", fg="white", activebackground="#da190b", diff --git a/util/ui/toast_constants.py b/util/ui/toast_constants.py index be13cf52..f429c920 100644 --- a/util/ui/toast_constants.py +++ b/util/ui/toast_constants.py @@ -3,13 +3,65 @@ 集中管理所有 Toast 窗口相关的常量。 """ +import platform import tkinter as tk # ============================================================ # 字体和样式常量 # ============================================================ -DEFAULT_FONT_FAMILY = 'Microsoft YaHei UI' +LINUX_TK_CJK_FONT_CANDIDATES = ( + 'gothic', + 'song ti', + 'fangsong ti', + 'mincho', + 'Noto Sans CJK SC', + 'Noto Sans SC', + 'WenQuanYi Micro Hei', + 'Source Han Sans SC', + 'Droid Sans Fallback', +) + + +def get_tk_font_family(root=None) -> str: + if platform.system() != 'Linux': + return 'Microsoft YaHei UI' + try: + import tkinter.font as tkfont + families = set(tkfont.families(root)) + except Exception: + return 'song ti' + for family in LINUX_TK_CJK_FONT_CANDIDATES: + if family in families: + return family + return 'song ti' + + +def apply_tk_font_defaults(root=None, family: str = None) -> str: + import tkinter.font as tkfont + + selected = family or get_tk_font_family(root) + named_fonts = ( + 'TkDefaultFont', + 'TkTextFont', + 'TkMenuFont', + 'TkHeadingFont', + 'TkCaptionFont', + 'TkSmallCaptionFont', + 'TkIconFont', + 'TkTooltipFont', + ) + for name in named_fonts: + try: + font = tkfont.nametofont(name, root=root) + size = 9 if name in {'TkSmallCaptionFont', 'TkTooltipFont'} else 10 + font.configure(family=selected, size=size) + except Exception: + pass + return selected + + +DEFAULT_FONT_FAMILY = get_tk_font_family() DEFAULT_PADDING_X = 20 DEFAULT_PADDING_Y = 15 From f177515b8ed645521644213a1638d93df25a863c Mon Sep 17 00:00:00 2001 From: jaceju Date: Sat, 25 Apr 2026 18:11:59 +0800 Subject: [PATCH 05/11] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E7=9F=A5=E8=AF=86=E5=BA=93=E5=92=8C=E5=8A=A8=E6=80=81=E5=BA=93?= =?UTF-8?q?=E7=9A=84=E5=BF=BD=E7=95=A5=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .gitignore | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e22e5037..1262fbf8 100644 --- a/.gitignore +++ b/.gitignore @@ -166,6 +166,14 @@ logs/ +# AGENTS 知识库(本地参考用) +AGENTS.md +**/AGENTS.md + +# 本地 llama 动态库(Linux 测试环境) +util/llama/bin/*.so +util/llama/bin/*.so.* + # 本地配置文件 .claude .vscode @@ -187,4 +195,4 @@ file_*.txt release *.dll -*.exe \ No newline at end of file +*.exe From 444d5b0a05f3febb7131c84717a0ede847116a0e Mon Sep 17 00:00:00 2001 From: jaceju Date: Sat, 25 Apr 2026 18:15:04 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=E6=94=B6=E6=95=9B=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E6=89=98=E7=9B=98=E8=8F=9C=E5=8D=95=E4=B8=BA=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E6=8E=A7=E5=88=B6=E5=92=8C=E9=80=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- util/server/cleanup.py | 8 +++++++- util/ui/tray.py | 45 ++++++++++++++++++++++++++++++------------ 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/util/server/cleanup.py b/util/server/cleanup.py index 38210c63..24fbb60f 100644 --- a/util/server/cleanup.py +++ b/util/server/cleanup.py @@ -72,7 +72,13 @@ def setup_tray(): if Config.enable_tray: from util.server.ui import enable_min_to_tray icon_path = os.path.join(BASE_DIR, 'assets', 'icon.ico') - enable_min_to_tray('CapsWriter Server', icon_path, exit_callback=request_exit_from_tray) + enable_min_to_tray( + 'CapsWriter Server', + icon_path, + exit_callback=request_exit_from_tray, + show_title_item=False, + show_toggle_item=True, + ) logger.info("托盘图标已启用") diff --git a/util/ui/tray.py b/util/ui/tray.py index e1be218c..0357181b 100644 --- a/util/ui/tray.py +++ b/util/ui/tray.py @@ -186,7 +186,7 @@ def _pil_to_qimage(pil_image): # --------------------------------------------------------------------------- class _WindowsTray: - def __init__(self, name, icon_path, more_options): + def __init__(self, name, icon_path, more_options, show_title_item=True, show_toggle_item=True): import pystray from pystray import MenuItem as item @@ -197,10 +197,11 @@ def __init__(self, name, icon_path, more_options): if self.hwnd: _disable_close_button(self.hwnd) - menu_items = [ - item(f"{self.title}", lambda: None, enabled=False), - item('显示/隐藏', self.toggle_window, default=True), - ] + menu_items = [] + if show_title_item: + menu_items.append(item(f"{self.title}", lambda: None, enabled=False)) + if show_toggle_item: + menu_items.append(item('显示/隐藏窗口', self.toggle_window, default=True)) if more_options: for opt_name, opt_func in more_options: menu_items.append(item(opt_name, opt_func)) @@ -263,7 +264,7 @@ def stop(self): # --------------------------------------------------------------------------- class _LinuxTray: - def __init__(self, name, icon_path, more_options): + def __init__(self, name, icon_path, more_options, show_title_item=True, show_toggle_item=False): from PySide6.QtWidgets import QApplication, QSystemTrayIcon, QMenu from PySide6.QtGui import QIcon, QPixmap, QFont, QFontDatabase @@ -292,15 +293,23 @@ def __init__(self, name, icon_path, more_options): self._qt_icon = QIcon(QPixmap.fromImage(qimg)) self._menu = QMenu() - title_action = self._menu.addAction(self.title) - title_action.setEnabled(False) - self._menu.addSeparator() + if show_title_item: + title_action = self._menu.addAction(self.title) + title_action.setEnabled(False) + + if show_toggle_item: + toggle_action = self._menu.addAction('显示/隐藏窗口') + toggle_action.setEnabled(False) + + if show_title_item or show_toggle_item or more_options: + self._menu.addSeparator() for opt_name, opt_func in (more_options or []): action = self._menu.addAction(opt_name) action.triggered.connect(lambda checked, f=opt_func: f()) - self._menu.addSeparator() + if more_options: + self._menu.addSeparator() exit_action = self._menu.addAction('退出') exit_action.triggered.connect(self._on_exit) @@ -353,12 +362,22 @@ def stop(self): # 公共接口 # --------------------------------------------------------------------------- -def enable_min_to_tray(name: Optional[str] = None, icon_path: Optional[str] = None, exit_callback=None, more_options: list = None) -> None: +def enable_min_to_tray( + name: Optional[str] = None, + icon_path: Optional[str] = None, + exit_callback=None, + more_options: list = None, + show_title_item: bool = True, + show_toggle_item: Optional[bool] = None, +) -> None: global _tray_instance if exit_callback is not None: _set_exit_callback(exit_callback) + if show_toggle_item is None: + show_toggle_item = _IS_WINDOWS + if not _check_tray_available(): logger.info("托盘功能不可用,跳过启用") return @@ -377,9 +396,9 @@ def enable_min_to_tray(name: Optional[str] = None, icon_path: Optional[str] = No try: if _IS_LINUX: - _tray_instance = _LinuxTray(name, icon_path, more_options) + _tray_instance = _LinuxTray(name, icon_path, more_options, show_title_item=show_title_item, show_toggle_item=show_toggle_item) else: - _tray_instance = _WindowsTray(name, icon_path, more_options) + _tray_instance = _WindowsTray(name, icon_path, more_options, show_title_item=show_title_item, show_toggle_item=show_toggle_item) _tray_instance.start() logger.info(f"托盘已创建并启动: {_tray_instance}") except Exception as e: From b92f77f0eb7b76cd5bad952bbd861d196d3da78d Mon Sep 17 00:00:00 2001 From: jaceju Date: Sun, 26 Apr 2026 00:30:11 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=89=98?= =?UTF-8?q?=E7=9B=98=E5=A2=9E=E5=8A=A0=E6=9F=A5=E7=9C=8B=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- util/server/cleanup.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/util/server/cleanup.py b/util/server/cleanup.py index 24fbb60f..0804450c 100644 --- a/util/server/cleanup.py +++ b/util/server/cleanup.py @@ -9,6 +9,10 @@ import asyncio from rich.console import Console +import subprocess +import sys +from datetime import datetime + from config_server import ServerConfig as Config, __version__ from . import logger from util.common.lifecycle import lifecycle @@ -27,6 +31,23 @@ def request_exit_from_tray(icon=None, item=None): lifecycle.request_shutdown(reason="Tray Icon") +def open_log_file(): + """用系统默认编辑器打开当天日志""" + log_dir = os.path.join(BASE_DIR, 'logs') + log_file = os.path.join(log_dir, f'server_{datetime.now().strftime("%Y%m%d")}.log') + if not os.path.isfile(log_file): + logger.info(f"日志文件不存在: {log_file}") + return + try: + if sys.platform == 'win32': + os.startfile(log_file) + else: + subprocess.Popen(['xdg-open', log_file], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception as e: + logger.error(f"打开日志文件失败: {e}") + + def cleanup_server_resources(): """ 清理服务端资源 @@ -78,6 +99,7 @@ def setup_tray(): exit_callback=request_exit_from_tray, show_title_item=False, show_toggle_item=True, + more_options=[('查看日志', open_log_file)], ) logger.info("托盘图标已启用") From 98cd87a82585a6a5c0e338d6462220d0faf54316 Mon Sep 17 00:00:00 2001 From: jaceju Date: Sat, 25 Apr 2026 21:07:22 +0800 Subject: [PATCH 08/11] =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E4=BB=85=20Linux=20=E6=89=98=E7=9B=98=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E5=8F=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- util/server/cleanup.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/util/server/cleanup.py b/util/server/cleanup.py index 0804450c..512b721d 100644 --- a/util/server/cleanup.py +++ b/util/server/cleanup.py @@ -6,12 +6,11 @@ """ import os -import asyncio -from rich.console import Console - -import subprocess import sys +import subprocess +import asyncio from datetime import datetime +from rich.console import Console from config_server import ServerConfig as Config, __version__ from . import logger @@ -32,18 +31,14 @@ def request_exit_from_tray(icon=None, item=None): def open_log_file(): - """用系统默认编辑器打开当天日志""" log_dir = os.path.join(BASE_DIR, 'logs') log_file = os.path.join(log_dir, f'server_{datetime.now().strftime("%Y%m%d")}.log') if not os.path.isfile(log_file): logger.info(f"日志文件不存在: {log_file}") return try: - if sys.platform == 'win32': - os.startfile(log_file) - else: - subprocess.Popen(['xdg-open', log_file], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.Popen(['xdg-open', log_file], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except Exception as e: logger.error(f"打开日志文件失败: {e}") @@ -89,17 +84,19 @@ def cleanup_server_resources(): def setup_tray(): - """启用托盘图标""" if Config.enable_tray: from util.server.ui import enable_min_to_tray icon_path = os.path.join(BASE_DIR, 'assets', 'icon.ico') + more_options = [] + if sys.platform != 'win32': + more_options.append(('查看日志', open_log_file)) enable_min_to_tray( 'CapsWriter Server', icon_path, exit_callback=request_exit_from_tray, show_title_item=False, show_toggle_item=True, - more_options=[('查看日志', open_log_file)], + more_options=more_options or None, ) logger.info("托盘图标已启用") From 80c002ba73c11e47462ad2c0d6f67d5c6974da2a Mon Sep 17 00:00:00 2001 From: jaceju Date: Sat, 25 Apr 2026 23:44:33 +0800 Subject: [PATCH 09/11] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20Linux=20=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E7=B3=BB=E7=BB=9F=EF=BC=9APyInstaller=20=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E6=89=93=E5=8C=85=E3=80=81PySide6=20=E8=A3=81?= =?UTF-8?q?=E5=89=AA=E6=B3=A8=E5=85=A5=E3=80=81Linux=20=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build-icon.py | 6 ++ build-linux.sh | 52 ++++++++++++ requirements-client-linux.txt | 31 +++++++ requirements-server-linux.txt | 19 +++++ strip_pyside6.py | 156 ++++++++++++++++++++++++++++++++++ util/server/cleanup.py | 1 - 6 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 build-icon.py create mode 100755 build-linux.sh create mode 100644 requirements-client-linux.txt create mode 100644 requirements-server-linux.txt create mode 100644 strip_pyside6.py diff --git a/build-icon.py b/build-icon.py new file mode 100644 index 00000000..c5ef3844 --- /dev/null +++ b/build-icon.py @@ -0,0 +1,6 @@ +from PIL import Image +import sys + +img = Image.open('assets/icon.ico') +img = img.resize((256, 256), Image.LANCZOS) +img.save(sys.argv[1]) diff --git a/build-linux.sh b/build-linux.sh new file mode 100755 index 00000000..96671cf1 --- /dev/null +++ b/build-linux.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" + +PYTHON=".venv/bin/python" +DIST="$ROOT/dist" +RELEASE="$ROOT/release" +WITH_TRAY=false + +for arg in "$@"; do + case "$arg" in + --with-tray) WITH_TRAY=true ;; + esac +done + +mkdir -p "$RELEASE" + +echo "===== Building CapsWriter-Offline (Server + Client) =====" +$PYTHON -m PyInstaller --clean --noconfirm build-linux.spec + +name="CapsWriter-Offline" +src="$DIST/$name" + +if [ ! -d "$src" ]; then + echo "ERROR: $src not found" + exit 1 +fi + +rm -f "$src/models" 2>/dev/null + +if [ "$WITH_TRAY" = true ]; then + echo "" + echo "===== Stripping PySide6 for tray support =====" + $PYTHON "$ROOT/strip_pyside6.py" "$src/internal" +fi + +echo "" +echo "===== Creating release archive =====" + +archive="$RELEASE/${name}-Linux-$(date +%Y%m%d).zip" +rm -f "$archive" +cd "$DIST" +zip -r -9 "$archive" "$(basename "$src")" \ + -x "*/__pycache__/*" "*/.git/*" "*.pyc" +cd "$ROOT" + +echo "" +echo "===== Done =====" +echo " $(du -sh "$archive" | cut -f1) $archive" +ls -lh dist/CapsWriter-Offline/start_* diff --git a/requirements-client-linux.txt b/requirements-client-linux.txt new file mode 100644 index 00000000..abbf86b8 --- /dev/null +++ b/requirements-client-linux.txt @@ -0,0 +1,31 @@ +# Linux 客户端依赖 +# basic and cli +rich +typer +colorama +markdown + +# system, input, hardware +pynput +sounddevice +watchdog + +# network and api +websockets +openai +ollama +httpx + +# data process +numpy +pypinyin +srt + +# tray (Linux uses PySide6, not pystray) +PySide6-Essentials +shiboken6 +Pillow +tkhtmlview + +# build +pyinstaller diff --git a/requirements-server-linux.txt b/requirements-server-linux.txt new file mode 100644 index 00000000..172c6173 --- /dev/null +++ b/requirements-server-linux.txt @@ -0,0 +1,19 @@ +# Linux 服务端依赖 +# ASR core +sherpa-onnx +numpy +onnxruntime + +# basic +rich +websockets +watchdog +pypinyin + +# tray (Linux uses PySide6, not pystray) +PySide6-Essentials +shiboken6 +Pillow + +markdown +tkhtmlview diff --git a/strip_pyside6.py b/strip_pyside6.py new file mode 100644 index 00000000..75e188d7 --- /dev/null +++ b/strip_pyside6.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +裁剪 PySide6 到最小集:QtCore + QtGui + QtWidgets + X11 平台插件 +用于 --with-tray 构建模式,将裁剪后的 PySide6 注入 dist/internal/ + +用法: python strip_pyside6.py + target_dir: dist/CapsWriter-Offline/internal +""" + +import os +import shutil +import sys + + +KEEP_MODULES = {'QtCore', 'QtGui', 'QtWidgets'} + +KEEP_QT_LIBS = { + 'libQt6Core.so.6', + 'libQt6Gui.so.6', + 'libQt6Widgets.so.6', + 'libQt6XcbQpa.so.6', + 'libQt6DBus.so.6', + 'libQt6OpenGL.so.6', + 'libQt6Svg.so.6', +} + +KEEP_QT_LIB_PREFIXES = [ + 'libQt6Core', 'libQt6Gui', 'libQt6Widgets', + 'libQt6XcbQpa', 'libQt6DBus', 'libQt6OpenGL', 'libQt6Svg', +] + +KEEP_PLUGIN_DIRS = {'platforms', 'imageformats', 'iconengines'} + +KEEP_TOP_FILES = { + '__init__.py', '_config.py', '_git_pyside_version.py', + 'PySide6_Essentials.json', +} + +KEEP_TOP_DIRS = { + 'support', +} + + +def matches_prefix(filename, prefixes): + return any(filename.startswith(p) for p in prefixes) + + +def strip_pyside6(src_root, dst_root): + pyside6_src = os.path.join(src_root, 'PySide6') + shiboken6_src = os.path.join(src_root, 'shiboken6') + + if not os.path.isdir(pyside6_src): + print(f"ERROR: {pyside6_src} not found") + sys.exit(1) + + total_before = 0 + total_after = 0 + + for dp, dn, fns in os.walk(pyside6_src): + for f in fns: + total_before += os.path.getsize(os.path.join(dp, f)) + + # --- PySide6 --- + dst_pyside6 = os.path.join(dst_root, 'PySide6') + + # Copy only needed .abi3.so + .pyi + for mod in KEEP_MODULES: + for ext in ['.abi3.so', '.pyi']: + src = os.path.join(pyside6_src, f'{mod}{ext}') + if os.path.isfile(src): + os.makedirs(dst_pyside6, exist_ok=True) + shutil.copy2(src, os.path.join(dst_pyside6, f'{mod}{ext}')) + print(f' + {mod}{ext}') + + # Copy libpyside + for f in os.listdir(pyside6_src): + if f.startswith('libpyside6') and f.endswith('.so'): + shutil.copy2(os.path.join(pyside6_src, f), os.path.join(dst_pyside6, f)) + print(f' + {f}') + + # Copy top-level files + for f in os.listdir(pyside6_src): + if f in KEEP_TOP_FILES and os.path.isfile(os.path.join(pyside6_src, f)): + shutil.copy2(os.path.join(pyside6_src, f), os.path.join(dst_pyside6, f)) + print(f' + {f}') + + # Copy top-level dirs + for d in KEEP_TOP_DIRS: + src_d = os.path.join(pyside6_src, d) + if os.path.isdir(src_d): + shutil.copytree(src_d, os.path.join(dst_pyside6, d)) + print(f' + {d}/') + + # Copy Qt libs (only needed ones) + qt_lib_src = os.path.join(pyside6_src, 'Qt', 'lib') + qt_lib_dst = os.path.join(dst_pyside6, 'Qt', 'lib') + if os.path.isdir(qt_lib_src): + for f in os.listdir(qt_lib_src): + if matches_prefix(f, KEEP_QT_LIB_PREFIXES): + os.makedirs(qt_lib_dst, exist_ok=True) + shutil.copy2(os.path.join(qt_lib_src, f), os.path.join(qt_lib_dst, f)) + print(f' + Qt/lib/{f}') + + # Copy ICU (QtGui needs it) + for f in os.listdir(qt_lib_src): + if f.startswith('libicu'): + os.makedirs(qt_lib_dst, exist_ok=True) + shutil.copy2(os.path.join(qt_lib_src, f), os.path.join(qt_lib_dst, f)) + print(f' + Qt/lib/{f}') + + # Copy plugins (only needed dirs) + qt_plugins_src = os.path.join(pyside6_src, 'Qt', 'plugins') + qt_plugins_dst = os.path.join(dst_pyside6, 'Qt', 'plugins') + if os.path.isdir(qt_plugins_src): + for pd in KEEP_PLUGIN_DIRS: + src_pd = os.path.join(qt_plugins_src, pd) + if os.path.isdir(src_pd): + shutil.copytree(src_pd, os.path.join(qt_plugins_dst, pd)) + count = sum(len(fns) for _, _, fns in os.walk(src_pd)) + print(f' + Qt/plugins/{pd}/ ({count} files)') + + # Copy qt.conf if exists + qt_conf = os.path.join(pyside6_src, 'Qt', 'qt.conf') + if os.path.isfile(qt_conf): + os.makedirs(os.path.join(dst_pyside6, 'Qt'), exist_ok=True) + shutil.copy2(qt_conf, os.path.join(dst_pyside6, 'Qt', 'qt.conf')) + + # --- shiboken6 --- + if os.path.isdir(shiboken6_src): + dst_shiboken6 = os.path.join(dst_root, 'shiboken6') + shutil.copytree(shiboken6_src, dst_shiboken6) + print(f' + shiboken6/') + + # Calculate after size + for dp, dn, fns in os.walk(dst_pyside6): + for f in fns: + total_after += os.path.getsize(os.path.join(dp, f)) + + before_mb = total_before / 1024 / 1024 + after_mb = total_after / 1024 / 1024 + print(f'\nPySide6: {before_mb:.0f}MB → {after_mb:.0f}MB (stripped {before_mb - after_mb:.0f}MB)') + + +if __name__ == '__main__': + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + target = sys.argv[1] + src = os.path.join(os.path.dirname(__file__), '.venv', 'lib', 'python3.12', 'site-packages') + if not os.path.isdir(src): + print(f"ERROR: site-packages not found at {src}") + sys.exit(1) + + print(f"Stripping PySide6 → {target}") + strip_pyside6(src, target) diff --git a/util/server/cleanup.py b/util/server/cleanup.py index 512b721d..30101df4 100644 --- a/util/server/cleanup.py +++ b/util/server/cleanup.py @@ -20,7 +20,6 @@ console = Console(highlight=False) -# 计算项目根目录: util/server/cleanup.py -> util/server -> util -> root BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) From 8d89f63539437e8fa73cbb88e3d3bfb72ee38d0e Mon Sep 17 00:00:00 2001 From: jaceju Date: Sat, 25 Apr 2026 23:58:21 +0800 Subject: [PATCH 10/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20PySide6=20=E8=A3=81?= =?UTF-8?q?=E5=89=AA=E9=81=97=E6=BC=8F=20libpyside6=20=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- strip_pyside6.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/strip_pyside6.py b/strip_pyside6.py index 75e188d7..b0b709c1 100644 --- a/strip_pyside6.py +++ b/strip_pyside6.py @@ -72,9 +72,9 @@ def strip_pyside6(src_root, dst_root): shutil.copy2(src, os.path.join(dst_pyside6, f'{mod}{ext}')) print(f' + {mod}{ext}') - # Copy libpyside + # Copy libpyside (e.g. libpyside6.abi3.so.6.11) for f in os.listdir(pyside6_src): - if f.startswith('libpyside6') and f.endswith('.so'): + if f.startswith('libpyside6') and '.so' in f: shutil.copy2(os.path.join(pyside6_src, f), os.path.join(dst_pyside6, f)) print(f' + {f}') From 471f609cc5ba46c24d811c01e0d7847b9cd986c3 Mon Sep 17 00:00:00 2001 From: jaceju Date: Sun, 26 Apr 2026 11:01:54 +0800 Subject: [PATCH 11/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E8=BF=9E=E6=8E=A5=E5=A4=B1=E8=B4=A5=E6=97=B6=E6=97=A0?= =?UTF-8?q?=E9=80=80=E9=81=BF=E5=AF=BC=E8=87=B4=E6=97=A5=E5=BF=97=E5=88=B7?= =?UTF-8?q?=E7=88=86=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=9A=E9=80=80=E9=81=BF?= =?UTF-8?q?=E5=88=B0=E7=B3=BB=E7=BB=9F=E6=97=A5=E5=BF=97=EF=BC=8CLinux?= =?UTF-8?q?=E7=AB=AF=E5=8D=95=E4=B8=AA=E5=BA=94=E7=94=A8=E7=9A=84=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=97=A5=E5=BF=97=E6=97=A0=E4=B8=8A=E9=99=90=EF=BC=8C?= =?UTF-8?q?=E6=9B=BE=E5=88=B7=E5=87=BA138GB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core_client.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core_client.py b/core_client.py index 79a4307d..818d91f9 100644 --- a/core_client.py +++ b/core_client.py @@ -106,18 +106,19 @@ async def main_mic() -> None: break # 如果处理任务结束(无论是正常还是异常),继续下一轮 - # 但 ResultProcessor 应该是一个无限循环,除非出错 if process_task in done: + # 清理未完成的等待任务 + if not wait_shutdown.done(): + wait_shutdown.cancel() # 检查是否有关闭请求(可能是 processor 内部触发) if lifecycle.is_shutting_down: break - # 如果没有请求退出但任务结束了,可能是异常 + # process_loop 正常返回但未请求退出 → 连接失败,等待重试 try: await process_task except Exception as e: logger.error(f"处理循环异常: {e}") - # 防止死循环打印日志 - await asyncio.sleep(1) + await asyncio.sleep(3) except asyncio.CancelledError: logger.info("主任务被取消,正在退出...")