From 5957b0dc7ad07d709970ae3c990e61515e211321 Mon Sep 17 00:00:00 2001 From: tocer Date: Thu, 5 Mar 2026 15:31:47 +0800 Subject: [PATCH 1/4] Add cross-platform support with pynput for Linux compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加跨平台支持:Linux 使用 pynput,Windows/macOS 使用 keyboard - 重构热键管理系统:根据平台自动选择合适的后端 - 重构文本输出系统:Linux 支持剪贴板模式,Windows 支持多种输入方式 - 添加配置文件示例:config.example.json - 更新依赖:添加 pynput 1.8.1 - 添加 Linux 系统依赖安装说明 Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 1 + app/hotkeys.py | 167 ++++++++++++++++--- app/output.py | 387 ++++++++++++++++++++++---------------------- config.example.json | 36 +++++ main.py | 2 +- requirements.txt | 6 + 6 files changed, 386 insertions(+), 213 deletions(-) create mode 100644 config.example.json diff --git a/.gitignore b/.gitignore index ac1c547..4197050 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ todo.md /models /scripts +/config.json diff --git a/app/hotkeys.py b/app/hotkeys.py index f7890ad..d440c9c 100644 --- a/app/hotkeys.py +++ b/app/hotkeys.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import platform import threading from typing import Callable @@ -11,41 +12,169 @@ logger = logging.getLogger(__name__) +# 检测当前平台 +CURRENT_PLATFORM = platform.system().lower() # "linux", "windows", "darwin" + + +def _convert_keyboard_to_pynput(combo: str) -> str: + """将 keyboard 库的热键格式转换为 pynput GlobalHotKeys 格式。 + + keyboard 格式: "f2", "ctrl+shift+h", "alt+f4" + pynput 格式: "", "++h", "+" + + Args: + combo: keyboard 格式的热键字符串 + + Returns: + pynput 格式的热键字符串 + """ + # 定义需要用 <> 包裹的键 + special_keys = { + 'ctrl', 'shift', 'alt', 'cmd', 'win', + 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', + 'f10', 'f11', 'f12', 'f13', 'f14', 'f15', 'f16', 'f17', + 'f18', 'f19', 'f20', + 'up', 'down', 'left', 'right', + 'enter', 'space', 'tab', 'esc', 'escape', 'backspace', + 'delete', 'del', 'home', 'end', 'page_up', 'page_down', + 'pgup', 'pgdn', 'insert', 'caps_lock', 'num_lock', + 'scroll_lock', 'print_screen', 'pause', 'break', + } + + parts = combo.lower().replace(' ', '').split('+') + converted = [] + + for part in parts: + part = part.strip() + if part in special_keys: + converted.append(f'<{part}>') + else: + converted.append(part) + + return '+'.join(converted) + class HotkeyManager: def __init__(self) -> None: self._lock = threading.Lock() self._registrations = {} + # 根据平台选择实现:Linux 使用 pynput,其他平台使用 keyboard + self._use_pynput = CURRENT_PLATFORM == "linux" + + if self._use_pynput: + self._pynput_listener = None + self._pynput_callbacks = {} + self._pynput_wait_event = threading.Event() + logger.info("使用 pynput 库进行热键管理 (平台: %s)", CURRENT_PLATFORM) + else: + logger.info("使用 keyboard 库进行热键管理 (平台: %s)", CURRENT_PLATFORM) + + def _init_pynput(self) -> None: + """初始化 pynput 库(延迟导入以避免不必要的依赖)。""" + if self._pynput_listener is not None: + return + + try: + from pynput.keyboard import GlobalHotKeys + self._GlobalHotKeys = GlobalHotKeys + except ImportError as exc: + logger.error("pynput 库未安装,请运行: pip install pynput") + raise RuntimeError("Linux 平台需要 pynput 库支持") from exc + + def _pynput_register(self, combo: str, callback: Callable[[], None]) -> None: + """使用 pynput 注册热键。""" + self._init_pynput() + + # 转换热键格式 + pynput_combo = _convert_keyboard_to_pynput(combo) + + # 存储回调 + self._pynput_callbacks[pynput_combo] = callback + + # 重建监听器(pynput 不支持动态添加,需要重建) + if self._pynput_listener is not None: + self._pynput_listener.stop() + + hotkeys_map = {combo: cb for combo, cb in self._pynput_callbacks.items()} + self._pynput_listener = self._GlobalHotKeys(hotkeys_map) + self._pynput_listener.start() + + logger.info("已注册热键 %s (pynput 格式: %s)", combo, pynput_combo) + + def _pynput_unregister_all(self) -> None: + """移除所有 pynput 热键。""" + if self._pynput_listener is not None: + self._pynput_listener.stop() + self._pynput_listener = None + + self._pynput_callbacks.clear() + logger.info("已移除所有 pynput 热键") def register(self, combo: str, callback: Callable[[], None]) -> None: with self._lock: - if combo in self._registrations: - logger.warning("热键 %s 已注册,覆盖旧的回调", combo) - keyboard.remove_hotkey(self._registrations[combo]) + if self._use_pynput: + # Linux 平台使用 pynput + if combo in self._registrations: + logger.warning("热键 %s 已注册,覆盖旧的回调", combo) - try: - hotkey_id = keyboard.add_hotkey(combo, callback) - except Exception as exc: # noqa: BLE001 - logger.error("注册热键 %s 失败: %s", combo, exc) - raise + try: + self._pynput_register(combo, callback) + except Exception as exc: # noqa: BLE001 + logger.error("注册热键 %s 失败: %s", combo, exc) + raise - self._registrations[combo] = hotkey_id - logger.info("已注册热键 %s", combo) + self._registrations[combo] = callback + else: + # Windows/macOS 平台使用 keyboard + if combo in self._registrations: + logger.warning("热键 %s 已注册,覆盖旧的回调", combo) + keyboard.remove_hotkey(self._registrations[combo]) + + try: + hotkey_id = keyboard.add_hotkey(combo, callback) + except Exception as exc: # noqa: BLE001 + logger.error("注册热键 %s 失败: %s", combo, exc) + raise + + self._registrations[combo] = hotkey_id + logger.info("已注册热键 %s", combo) def unregister_all(self) -> None: with self._lock: - for combo, hotkey_id in list(self._registrations.items()): - keyboard.remove_hotkey(hotkey_id) - logger.info("已移除热键 %s", combo) + if self._use_pynput: + # Linux 平台使用 pynput + self._pynput_unregister_all() + else: + # Windows/macOS 平台使用 keyboard + for combo, hotkey_id in list(self._registrations.items()): + keyboard.remove_hotkey(hotkey_id) + logger.info("已移除热键 %s", combo) + self._registrations.clear() def cleanup(self) -> None: self.unregister_all() - # 彻底停止 keyboard 库的所有钩子和监听线程 - try: - keyboard.unhook_all() - logger.info("已停止 keyboard 监听线程") - except Exception as exc: - logger.warning("停止 keyboard 监听线程失败: %s", exc) + + if self._use_pynput: + # pynput 清理 + self._pynput_unregister_all() + else: + # keyboard 清理:彻底停止 keyboard 库的所有钩子和监听线程 + try: + keyboard.unhook_all() + logger.info("已停止 keyboard 监听线程") + except Exception as exc: + logger.warning("停止 keyboard 监听线程失败: %s", exc) + + def wait(self) -> None: + """保持程序运行,等待热键触发。""" + if self._use_pynput: + # pynput: 使用 threading.Event 阻塞 + logger.debug("使用 pynput 等待模式") + self._pynput_wait_event.wait() + else: + # keyboard: 使用 keyboard.wait() + logger.debug("使用 keyboard 等待模式") + keyboard.wait() diff --git a/app/output.py b/app/output.py index f84dac6..9742c67 100644 --- a/app/output.py +++ b/app/output.py @@ -1,224 +1,225 @@ -"""Text injection utilities for Windows.""" +"""Text injection utilities for cross-platform support.""" from __future__ import annotations -import ctypes -import ctypes.wintypes as wintypes import logging - +import platform +import time logger = logging.getLogger(__name__) -SendInput = ctypes.windll.user32.SendInput -GetMessageExtraInfo = ctypes.windll.user32.GetMessageExtraInfo - -INPUT_KEYBOARD = 1 -KEYEVENTF_KEYUP = 0x0002 -KEYEVENTF_UNICODE = 0x0004 -VK_CONTROL = 0x11 -VK_V = 0x56 - - -if hasattr(wintypes, "ULONG_PTR"): - ULONG_PTR = wintypes.ULONG_PTR # type: ignore[attr-defined] -else: # Fallback for Python builds lacking ULONG_PTR in wintypes - if ctypes.sizeof(ctypes.c_void_p) == ctypes.sizeof(ctypes.c_uint64): - ULONG_PTR = ctypes.c_uint64 - else: - ULONG_PTR = ctypes.c_uint32 - - -class KeyboardInput(ctypes.Structure): - _fields_ = [ - ("wVk", wintypes.WORD), - ("wScan", wintypes.WORD), - ("dwFlags", wintypes.DWORD), - ("time", wintypes.DWORD), - ("dwExtraInfo", ULONG_PTR), - ] - - -class InputUnion(ctypes.Union): - _fields_ = [("ki", KeyboardInput)] - - -class INPUT(ctypes.Structure): - _fields_ = [("type", wintypes.DWORD), ("union", InputUnion)] - - -def _emit_unicode_char(char: str) -> bool: - code_point = ord(char) - input_array_type = INPUT * 2 - inputs = input_array_type( - INPUT( - type=INPUT_KEYBOARD, - union=InputUnion( - ki=KeyboardInput( - wVk=0, - wScan=code_point, - dwFlags=KEYEVENTF_UNICODE, - time=0, - dwExtraInfo=GetMessageExtraInfo(), - ) - ), - ), - INPUT( - type=INPUT_KEYBOARD, - union=InputUnion( - ki=KeyboardInput( - wVk=0, - wScan=code_point, - dwFlags=KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, - time=0, - dwExtraInfo=GetMessageExtraInfo(), - ) - ), - ), - ) - pointer = ctypes.byref(inputs[0]) - sent = SendInput(len(inputs), pointer, ctypes.sizeof(INPUT)) - if sent != len(inputs): - logger.warning("SendInput 发送字符失败,char=%s,返回值=%s", char, sent) - return False - return True +# 当前操作系统 +CURRENT_PLATFORM = platform.system().lower() def type_text(text: str, append_newline: bool = False, method: str = "auto") -> None: + """跨平台文本注入函数。 + + Args: + text: 要输入的文本 + append_newline: 是否在文本末尾添加换行 + method: 输入方法 ("auto", "type", "clipboard", "legacy") + """ if not text: return - payload = text + ("\r\n" if append_newline else "") - logger.debug("注入文本: %s", payload) + payload = text + ("\n" if append_newline else "") + logger.debug("📝 准备输出文本: %s", payload[:50] + "..." if len(payload) > 50 else payload) method = (method or "auto").lower() - if method == "type": - order = ["type", "clipboard", "unicode"] + logger.debug("🔧 输出方法: %s", method) + + # Linux 平台:强制使用剪贴板模式 + if CURRENT_PLATFORM == "linux": + if method != "clipboard" and method != "auto": + logger.warning("⚠️ Linux 平台仅支持剪贴板输出,method='%s' 配置已忽略", method) + order = ["clipboard"] + elif method == "type": + order = ["type", "clipboard"] elif method == "clipboard": - order = ["clipboard", "type", "unicode"] - elif method == "unicode": - order = ["unicode"] - else: - order = ["type", "clipboard", "unicode"] - - for mode in order: - if mode == "type" and _type_with_keyboard(payload): - return - if mode == "clipboard" and _try_clipboard_injection(payload): - return - if mode == "unicode" and _type_with_unicode(payload): - return - - logger.error("所有文本注入方式均失败: %s", payload) + order = ["clipboard", "type"] + elif method == "legacy": + order = ["legacy"] if CURRENT_PLATFORM == "windows" else ["type", "clipboard"] + else: # auto + if CURRENT_PLATFORM == "windows": + order = ["type", "clipboard", "legacy"] + else: + order = ["type", "clipboard"] + logger.debug("🔄 尝试顺序: %s", order) -def _type_with_keyboard(payload: str) -> bool: + for mode in order: + try: + logger.debug(" → 尝试模式: %s", mode) + if mode == "type" and CURRENT_PLATFORM == "windows" and _type_with_legacy(payload): + logger.info("✅ 使用 Windows legacy 输入成功") + return + if mode == "clipboard" and _try_clipboard_injection(payload): + logger.debug("✅ 使用剪贴板输出成功") + return + if mode == "legacy" and CURRENT_PLATFORM == "windows" and _type_with_legacy(payload): + logger.info("✅ 使用 legacy 方式成功") + return + except Exception as exc: + logger.debug("模式 %s 失败: %s", mode, exc) + continue + + logger.error("❌ 所有文本注入方式均失败: %s", payload[:50]) + +def _try_clipboard_injection(text: str) -> bool: + """使用剪贴板进行文本注入。""" try: - import keyboard + import pyperclip + except ImportError: + logger.debug("pyperclip 未安装") + return False - keyboard.write(payload, delay=0) - return True - except Exception as exc: # noqa: BLE001 - logger.warning("keyboard.write 失败: %s", exc) + try: + # 复制文本到剪贴板 + logger.debug("复制文本到剪贴板: %s", text[:50] + "..." if len(text) > 50 else text) + pyperclip.copy(text) + + # 给剪贴板操作一点时间,确保复制完成 + time.sleep(0.15) + + # 验证剪贴板内容 + current_clip = pyperclip.paste() + if current_clip != text: + logger.warning("剪贴板验证失败: 期望 '%s', 实际 '%s'", text[:20], current_clip[:20]) + # 继续尝试,可能只是显示问题 + + # 模拟粘贴操作 + logger.debug("模拟粘贴操作...") + success = _simulate_ctrl_v() + + if success: + logger.debug("✓ 剪贴板粘贴成功: %s", text[:30] + "..." if len(text) > 30 else text) + else: + logger.warning("剪贴板粘贴失败") + + # 不再恢复旧剪贴板内容,保持当前文本在剪贴板中 + # 这样用户可以手动粘贴,也不会影响下次使用 + + return success + except Exception as exc: + logger.debug("剪贴板注入失败: %s", exc) return False -def _type_with_unicode(payload: str) -> bool: - success = True - for char in payload: - if not _emit_unicode_char(char): - success = False - break - return success +def _simulate_ctrl_v() -> bool: + """模拟粘贴操作。 + 终端使用 Ctrl+Shift+V,其他应用使用 Ctrl+V。 -def _try_clipboard_injection(payload: str) -> bool: + 注意:此函数使用 pynput 进行按键模拟,仅用于剪贴板粘贴功能, + 与热键监听功能共用 pynput 依赖。 + """ try: - import pyperclip - except ImportError: + from pynput.keyboard import Controller, Key + + keyboard = Controller() + + # 先尝试 Ctrl+Shift+V(终端通用) + # 如果失败,回退到 Ctrl+V(GUI 应用) + try: + # 尝试 Ctrl+Shift+V + with keyboard.pressed(Key.ctrl, Key.shift): + keyboard.press('v') + keyboard.release('v') + time.sleep(0.05) + logger.debug("使用 Ctrl+Shift+V 模拟粘贴(终端)") + return True + except Exception as e: + # 回退到 Ctrl+V + with keyboard.pressed(Key.ctrl): + keyboard.press('v') + keyboard.release('v') + time.sleep(0.05) + logger.debug("使用 Ctrl+V 模拟粘贴(GUI 应用)") + return True + + except Exception as exc: + logger.debug("模拟粘贴失败: %s", exc) return False - try: - prev_clip = pyperclip.paste() - except Exception: - prev_clip = None + +# ==================== Windows 特定实现 ==================== + +def _type_with_legacy(text: str) -> bool: + """Windows 传统 ctypes 实现(保留作为备选)。""" + if CURRENT_PLATFORM != "windows": + return False try: - pyperclip.copy(payload) - success = _emit_ctrl_v() - except Exception as exc: # noqa: BLE001 - logger.debug("剪贴板注入失败,退回逐字符输入: %s", exc) - success = False - finally: - if prev_clip is not None: - try: - pyperclip.copy(prev_clip) - except Exception: - pass - - return success - - -def _emit_ctrl_v() -> bool: - input_array_type = INPUT * 4 - inputs = input_array_type( - INPUT( - type=INPUT_KEYBOARD, - union=InputUnion( - ki=KeyboardInput( - wVk=VK_CONTROL, - wScan=0, - dwFlags=0, - time=0, - dwExtraInfo=GetMessageExtraInfo(), - ) - ), - ), - INPUT( - type=INPUT_KEYBOARD, - union=InputUnion( - ki=KeyboardInput( - wVk=VK_V, - wScan=0, - dwFlags=0, - time=0, - dwExtraInfo=GetMessageExtraInfo(), - ) - ), - ), - INPUT( - type=INPUT_KEYBOARD, - union=InputUnion( - ki=KeyboardInput( - wVk=VK_V, - wScan=0, - dwFlags=KEYEVENTF_KEYUP, - time=0, - dwExtraInfo=GetMessageExtraInfo(), - ) - ), - ), - INPUT( - type=INPUT_KEYBOARD, - union=InputUnion( - ki=KeyboardInput( - wVk=VK_CONTROL, - wScan=0, - dwFlags=KEYEVENTF_KEYUP, - time=0, - dwExtraInfo=GetMessageExtraInfo(), - ) - ), - ), - ) - pointer = ctypes.byref(inputs[0]) - sent = SendInput(len(inputs), pointer, ctypes.sizeof(INPUT)) - if sent != len(inputs): - logger.warning("SendInput Ctrl+V 失败,返回值=%s", sent) - # 尝试一次退避后再次发送 - sent_retry = SendInput(len(inputs), pointer, ctypes.sizeof(INPUT)) - if sent_retry != len(inputs): - logger.warning("SendInput Ctrl+V 第二次重试失败,返回值=%s", sent_retry) - return False - - return True + import ctypes + import ctypes.wintypes as wintypes + + SendInput = ctypes.windll.user32.SendInput + GetMessageExtraInfo = ctypes.windll.user32.GetMessageExtraInfo + + INPUT_KEYBOARD = 1 + KEYEVENTF_KEYUP = 0x0002 + KEYEVENTF_UNICODE = 0x0004 + + if hasattr(wintypes, "ULONG_PTR"): + ULONG_PTR = wintypes.ULONG_PTR + else: + if ctypes.sizeof(ctypes.c_void_p) == ctypes.sizeof(ctypes.c_uint64): + ULONG_PTR = ctypes.c_uint64 + else: + ULONG_PTR = ctypes.c_uint32 + + class KeyboardInput(ctypes.Structure): + _fields_ = [ + ("wVk", wintypes.WORD), + ("wScan", wintypes.WORD), + ("dwFlags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", ULONG_PTR), + ] + + class InputUnion(ctypes.Union): + _fields_ = [("ki", KeyboardInput)] + + class INPUT(ctypes.Structure): + _fields_ = [("type", wintypes.DWORD), ("union", InputUnion)] + + # 使用 Unicode 输入 + for char in text: + code_point = ord(char) + input_array_type = INPUT * 2 + inputs = input_array_type( + INPUT( + type=INPUT_KEYBOARD, + union=InputUnion( + ki=KeyboardInput( + wVk=0, + wScan=code_point, + dwFlags=KEYEVENTF_UNICODE, + time=0, + dwExtraInfo=GetMessageExtraInfo(), + ) + ), + ), + INPUT( + type=INPUT_KEYBOARD, + union=InputUnion( + ki=KeyboardInput( + wVk=0, + wScan=code_point, + dwFlags=KEYEVENTF_UNICODE | KEYEVENTF_KEYUP, + time=0, + dwExtraInfo=GetMessageExtraInfo(), + ) + ), + ), + ) + pointer = ctypes.byref(inputs[0]) + SendInput(len(inputs), pointer, ctypes.sizeof(INPUT)) + time.sleep(0.01) + + return True + except Exception as exc: + logger.debug("Windows 传统输入失败: %s", exc) + return False diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..ada2bf7 --- /dev/null +++ b/config.example.json @@ -0,0 +1,36 @@ +{ + "hotkeys": { + "toggle": "f2" + }, + "audio": { + "sample_rate": 16000, + "block_ms": 20, + "device": null, + "max_session_bytes": 20971520 + }, + "vad": { + "start_threshold": 0.02, + "stop_threshold": 0.01, + "min_speech_ms": 300, + "min_silence_ms": 200, + "pad_ms": 200 + }, + "asr": { + "use_vad": false, + "use_punc": true, + "language": "zh", + "hotword": "", + "batch_size_s": 60.0 + }, + "output": { + "dedupe": true, + "max_history": 5, + "min_chars": 1, + "method": "clipboard", + "append_newline": false + }, + "logging": { + "dir": "logs", + "level": "INFO" + } +} diff --git a/main.py b/main.py index 3e72f80..a35b2ca 100644 --- a/main.py +++ b/main.py @@ -74,7 +74,7 @@ def main() -> None: input("按 Enter 停止并退出...") _toggle(worker) else: - keyboard.wait() + hotkeys.wait() except KeyboardInterrupt: logger.info("用户中断,正在退出...") finally: diff --git a/requirements.txt b/requirements.txt index 0800b12..be208d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,10 +6,16 @@ soundfile==0.13.1 funasr_onnx==0.4.1 jieba==0.42.1 +# 跨平台键盘输入模拟 +pynput==1.8.1 + # Optional backends for model downloading (recommended) # 选其一或都装:用于 AutoModel 拉取/缓存模型 modelscope==1.30.0 # 说明: # - 切换为 ONNX 路线(onnxruntime);如需 GPU/PyTorch,请安装 torch(+cu) 与 torchaudio。 +# - Linux 用户可能需要安装系统依赖: +# - Debian/Ubuntu: sudo apt-get install portaudio19-dev libx11-dev libxtst-dev +# - Fedora: sudo dnf install portaudio-devel libX11-devel libXtst-devel From a52ec173651d08f05ff5134f97c670452eeeeda9 Mon Sep 17 00:00:00 2001 From: tocer Date: Thu, 5 Mar 2026 16:49:10 +0800 Subject: [PATCH 2/4] =?UTF-8?q?Fix:=20=E4=BF=AE=E5=A4=8D=E5=89=AA=E8=B4=B4?= =?UTF-8?q?=E6=9D=BF=E7=B2=98=E8=B4=B4=E9=87=8D=E5=A4=8D=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E4=B8=A4=E6=AC=A1=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 _simulate_ctrl_v 中的双重粘贴逻辑 - 统一使用 Ctrl+V 进行跨平台粘贴 - 修复了文本会被粘贴两次的 bug Co-Authored-By: Claude Sonnet 4.5 --- app/output.py | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/app/output.py b/app/output.py index 9742c67..57fa8f3 100644 --- a/app/output.py +++ b/app/output.py @@ -119,24 +119,13 @@ def _simulate_ctrl_v() -> bool: keyboard = Controller() - # 先尝试 Ctrl+Shift+V(终端通用) - # 如果失败,回退到 Ctrl+V(GUI 应用) - try: - # 尝试 Ctrl+Shift+V - with keyboard.pressed(Key.ctrl, Key.shift): - keyboard.press('v') - keyboard.release('v') - time.sleep(0.05) - logger.debug("使用 Ctrl+Shift+V 模拟粘贴(终端)") - return True - except Exception as e: - # 回退到 Ctrl+V - with keyboard.pressed(Key.ctrl): - keyboard.press('v') - keyboard.release('v') - time.sleep(0.05) - logger.debug("使用 Ctrl+V 模拟粘贴(GUI 应用)") - return True + # 使用 Ctrl+V(跨平台通用) + with keyboard.pressed(Key.ctrl): + keyboard.press('v') + keyboard.release('v') + time.sleep(0.05) + logger.debug("使用 Ctrl+V 模拟粘贴") + return True except Exception as exc: logger.debug("模拟粘贴失败: %s", exc) From 2b435975d8ab56f9e4d67ea4ac6873484e7b3979 Mon Sep 17 00:00:00 2001 From: tocer Date: Fri, 6 Mar 2026 09:33:55 +0800 Subject: [PATCH 3/4] =?UTF-8?q?Fix:=20=E6=94=B9=E8=BF=9B=E7=BB=88=E7=AB=AF?= =?UTF-8?q?/GUI=E7=8E=AF=E5=A2=83=E6=A3=80=E6=B5=8B=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=87=8D=E5=A4=8D=E7=B2=98=E8=B4=B4=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 基于 TERM 环境变量检测是否为终端环境 - 终端环境使用 Ctrl+Shift+V - GUI 应用使用 Ctrl+V - 避免同时执行两种粘贴方式 Co-Authored-By: Claude Sonnet 4.5 --- app/output.py | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/app/output.py b/app/output.py index 57fa8f3..4505f0d 100644 --- a/app/output.py +++ b/app/output.py @@ -111,20 +111,43 @@ def _simulate_ctrl_v() -> bool: 终端使用 Ctrl+Shift+V,其他应用使用 Ctrl+V。 + 检测逻辑: + 1. 检查 TERM 环境变量,如果设置且不是 'dumb',可能是终端环境 + 2. 默认使用 Ctrl+V(GUI 应用) + 注意:此函数使用 pynput 进行按键模拟,仅用于剪贴板粘贴功能, 与热键监听功能共用 pynput 依赖。 """ try: + import os from pynput.keyboard import Controller, Key keyboard = Controller() - # 使用 Ctrl+V(跨平台通用) - with keyboard.pressed(Key.ctrl): - keyboard.press('v') - keyboard.release('v') - time.sleep(0.05) - logger.debug("使用 Ctrl+V 模拟粘贴") + # 检测是否可能是终端环境 + is_terminal = False + term = os.environ.get('TERM', '') + if term and term.lower() not in ('dumb', 'xterm', ''): + # 设置了 TERM 环境变量,可能是在终端环境 + is_terminal = True + logger.debug("检测到 TERM=%s,使用终端粘贴模式", term) + + # 根据环境选择粘贴方式 + if is_terminal: + # 终端环境:使用 Ctrl+Shift+V + with keyboard.pressed(Key.ctrl, Key.shift): + keyboard.press('v') + keyboard.release('v') + time.sleep(0.05) + logger.debug("使用 Ctrl+Shift+V 模拟粘贴(终端)") + else: + # GUI 环境:使用 Ctrl+V + with keyboard.pressed(Key.ctrl): + keyboard.press('v') + keyboard.release('v') + time.sleep(0.05) + logger.debug("使用 Ctrl+V 模拟粘贴(GUI)") + return True except Exception as exc: From 5cf7186339df64af1cf0b59ee0b919766e37158b Mon Sep 17 00:00:00 2001 From: tocer Date: Thu, 12 Mar 2026 14:48:24 +0800 Subject: [PATCH 4/4] =?UTF-8?q?Feat:=20=E6=B7=BB=E5=8A=A0=20--config=20?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E9=BB=98=E8=AE=A4=E5=80=BC=EF=BC=8C=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=AF=BB=E5=8F=96=20config.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现在运行程序时无需手动指定配置文件路径,直接使用 config.json Co-Authored-By: Claude Sonnet 4.5 --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index a35b2ca..ccd2a72 100644 --- a/main.py +++ b/main.py @@ -24,7 +24,7 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Speak Keyboard prototype") - parser.add_argument("--config", help="Path to config JSON") + parser.add_argument("--config", default="config.json", help="Path to config JSON") parser.add_argument( "--once", action="store_true",