From effa12e5f17afc2aaeaf5f8e232f1101838a6777 Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 13 May 2026 12:34:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(hotkey):=20Wayland=20=E4=B8=8B=E7=94=A8=20C?= =?UTF-8?q?LI=20=E8=A7=A6=E5=8F=91=E5=BD=95=E9=9F=B3=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20#420?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wayland 协议禁止应用监听非聚焦窗口键盘事件,原 rdev 路径必然失效。 保留 X11 rdev 不动;Wayland 下走 stub HotkeyAdapter(不监听键盘), 通过 tauri-plugin-single-instance 把 `openless --toggle-dictation` 等 CLI 命令的 argv 转给主实例 coordinator,由桌面环境快捷键设置 绑定该命令实现全局触发。Settings → 录音 mount 时 invoke is_wayland_cli_mode 拉状态,显示绑命令引导 callout (GNOME / KDE / Hyprland / sway 四段步骤)。 新增: - src-tauri/src/cli.rs — CLI intent 解析 + 10 个单测 - docs/issue-420-wayland-hotkey-research.md — 方案调研文档 改动: - hotkey.rs Linux 分支:Wayland 检测命中走 WaylandCliAdapter stub (不再硬拒绝 wayland_unsupported) - lib.rs:single-instance 回调路由 argv → dispatch_cli_intent - commands.rs:新增 is_wayland_cli_mode IPC command - coordinator.rs:dictation_phase_for_cli + cli_toggle_qa_panel 暴露 CLI 入口,复用现有状态机 - Settings.tsx:WaylandHotkeyCallout 组件 + invoke pull 模型 - i18n × 5 locale:settings.recording.wayland.* 共 14 个键 macOS CGEventTap 和 Windows RegisterHotKey 路径零触碰。 --- docs/issue-420-wayland-hotkey-research.md | 401 ++++++++++++++++++ openless-all/app/src-tauri/src/cli.rs | 119 ++++++ openless-all/app/src-tauri/src/commands.rs | 12 + openless-all/app/src-tauri/src/coordinator.rs | 16 + openless-all/app/src-tauri/src/hotkey.rs | 65 ++- openless-all/app/src-tauri/src/lib.rs | 95 ++++- openless-all/app/src/i18n/en.ts | 15 + openless-all/app/src/i18n/ja.ts | 15 + openless-all/app/src/i18n/ko.ts | 15 + openless-all/app/src/i18n/zh-CN.ts | 15 + openless-all/app/src/i18n/zh-TW.ts | 15 + openless-all/app/src/lib/ipc.ts | 6 + openless-all/app/src/pages/Settings.tsx | 142 +++++++ 13 files changed, 925 insertions(+), 6 deletions(-) create mode 100644 docs/issue-420-wayland-hotkey-research.md create mode 100644 openless-all/app/src-tauri/src/cli.rs diff --git a/docs/issue-420-wayland-hotkey-research.md b/docs/issue-420-wayland-hotkey-research.md new file mode 100644 index 00000000..6f9c09de --- /dev/null +++ b/docs/issue-420-wayland-hotkey-research.md @@ -0,0 +1,401 @@ +# Issue #420 调研笔记:Wayland 下全局快捷键不可用 + +> 状态:调研稿(未实施任何代码改动) +> 范围:仅评估方案;落地方案以第 7 节为推荐基线。 +> 日期:2026-05-13 + +--- + +## 1. 问题与现状 + +OpenLess Linux 端的全局热键监听走 `rdev::listen`,实现在 `openless-all/app/src-tauri/src/hotkey.rs:1183-1530`。代码在启动时检查 `XDG_SESSION_TYPE`,命中 `wayland` 直接 `Err("wayland_unsupported", "Wayland 暂不支持全局热键,请切到 X11 session 后再试")`(`hotkey.rs:1204-1208`)。 + +Issue #420 用户 aeoform 与另一位评论者在 Debian Wayland 上看到这条错误,明确建议: + +> "建议补充对应的脚本或者命令让用户去系统设置中配置快捷键即可" + +也就是:**不要求 OpenLess 自己抓全局按键**,**让桌面环境的快捷键设置去调用 OpenLess 的命令**。这是一个常见的 Linux 端规避模式,已经被同领域产品(Murmure 等)当成默认实践,详见第 3.2 节与 [Murmure docs](https://docs.murmure.app/configure-shortcuts-on-linux/)。 + +仓库现有支点: +- `tauri-plugin-single-instance = "2"` 已在 `Cargo.toml:24` 启用,并在 `lib.rs:73` 注册了回调(目前仅用于聚焦主窗口)。 +- 可用 IPC 命令:`start_dictation` / `stop_dictation` / `cancel_dictation`(`commands.rs:1099-1110`),QA panel 控制(`commands.rs:1324-1330`),以及完整 hotkey 配置 surface。 + +--- + +## 2. 为什么 Wayland 不允许传统全局热键 + +X11 的设计里任何客户端都能 grab 整个键盘或注册全局快捷键 — 这同时让 X11 成了「天然的键盘记录器平台」。Wayland 协议在 2008 年重新设计时把这条路直接关掉:**键盘事件只在 surface 获得焦点时才送达对应客户端**。 + +权威表述出自 Wayland Book seat/keyboard 章节:"the server sends `wl_keyboard.enter` when a surface receives keyboard focus, and `wl_keyboard.leave` when it's lost" — 协议层面没有任何「未聚焦也能读键」的接口([wayland-book.com](https://wayland-book.com/seat/keyboard.html))。 + +`pynput` / `rdev` / 任何依赖 X11 keyboard grab 的库在 Wayland 上「故意」失效,原因即此([Wayland Fragmentation](https://www.semicomplete.com/blog/xdotool-and-exploring-wayland-fragmentation/)、[Vocalinux issue #80](https://github.com/jatinkrmalik/vocalinux/issues/80))。 + +只要应用要在「自己窗口没聚焦」时收到按键,就必须走以下「半民间」方案之一: + +| 方案 | 取舍 | +|------|------| +| **evdev/uinput** 直接读 `/dev/input/event*` | 绕过 Wayland 协议,X11/Wayland/TTY 都能用;**需要把用户加入 `input` group 或 setuid**,安全模型差 | +| **libei + xdg-desktop-portal RemoteDesktop** | 用户每次启动都要授权;文档稀少;只在做合成器自动化时合理 | +| **xdg-desktop-portal GlobalShortcuts** | 走门户协商;标准化但合成器实现不齐(见 3.1) | +| **合成器私有协议** | 如 `hyprland-global-shortcuts-v1`;只在单一合成器有效 | + +来源:[Wayland Fragmentation: xdotool adventure](https://www.semicomplete.com/blog/xdotool-and-exploring-wayland-fragmentation/)、[Wayland - keyboard-shortcuts-inhibit-unstable-v1](https://wayland.app/protocols/keyboard-shortcuts-inhibit-unstable-v1)。 + +--- + +## 3. 可选方案(含适配范围、成熟度、维护代价) + +### 3.1 xdg-desktop-portal GlobalShortcuts + +**协议**:`org.freedesktop.portal.GlobalShortcuts`([规范](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html))。 + +应用调用 `CreateSession → BindShortcuts`,门户弹出一个对话框让用户**给每个 shortcut 选实际按键**。之后通过 `Activated` / `Deactivated` 信号通知应用。`ConfigureShortcuts` 方法在 v2 加入,允许应用打开门户的修改 UI。 + +合成器实现状态(截至 2026-05): + +| 合成器 | 状态 | 备注 | +|--------|------|------| +| **KDE Plasma 6** | 已稳定 | xdg-desktop-portal-kde 自 MR !80 起原生支持,2024-2025 持续迭代([!368 改进流程](https://invent.kde.org/plasma/xdg-desktop-portal-kde/-/merge_requests/368)、[!449 记住拒绝项](https://invent.kde.org/plasma/xdg-desktop-portal-kde/-/merge_requests/449)) | +| **GNOME (Mutter)** | **尚未原生落地** | issue [GNOME/xdg-desktop-portal-gnome#47](https://gitlab.gnome.org/GNOME/xdg-desktop-portal-gnome/-/issues/47) 仍开放;Murmure 文档明确写「Mutter's XDG GlobalShortcuts portal is unreliable (latency, dropped events),GNOME 默认走 CLI 模式」([Murmure docs](https://docs.murmure.app/configure-shortcuts-on-linux/)) | +| **Hyprland** | 已支持 | 通过 `xdg-desktop-portal-hyprland`;同时还有合成器私有的 [`hyprland-global-shortcuts-v1`](https://wayland.app/protocols/hyprland-global-shortcuts-v1) | +| **sway / wlroots** | 已支持 | 通过 `xdg-desktop-portal-wlr` | +| **COSMIC** | 部分 | 实现质量随版本变化,未独立验证 | + +**关键缺陷**(多个合成器共有): +- 用户感受到的「再设置一次」:应用只能给 *preferred trigger*,最终键位由门户对话框决定。Hyprland 上甚至要求用户手改 config 文件 — 等于「应用申请,用户在 hyprland.conf 里实际绑」([dec05eba.com 分析](https://dec05eba.com/2024/03/29/wayland-global-hotkeys-shortcut-is-mostly-useless/))。 +- **GNOME 是最大盲区**。Issue #420 用户用的就是 Debian — Debian 默认 GNOME。在 GNOME 上跑 GlobalShortcuts 等于压根不能用。 +- 没有 push-to-talk:门户在 key-press 上触发事件,但是否传 release 事件、是否 dedupe,依赖合成器(OpenLess 当前依赖 hotkey 的 edge 来做 Toggle,需要稳定的成对事件)。 + +**维护代价**:新增 `ashpd` crate + DBus 异步流(参见 3.2 例外、4 节示例)。每个发行版/合成器组合都得人肉测一遍,bug 报告会按合成器分裂。 + +**结论**:现阶段加进来对 GNOME 用户毫无帮助,且会引入合成器分裂的支持负担。 + +### 3.2 CLI + single-instance 转发(推荐) + +把 OpenLess 二进制本身做成可被外部调起的「无 GUI 触发器」: + +``` +桌面环境快捷键 → 启动 openless --toggle-dictation + ↓ + tauri-plugin-single-instance 拦截 + ↓ + 已运行的 OpenLess 主实例从回调拿到 argv + ↓ + 解析 --toggle-dictation → 调用 coordinator.start/stop_dictation +``` + +适配范围:**所有 Linux 桌面环境**(GNOME / KDE / Hyprland / sway / Cosmic / XFCE / i3 / ...),因为它只依赖「桌面环境能绑定一个 shell 命令」这个最低公共能力。X11 / Wayland 都通杀。 + +成熟度:极高。这是 Linux 桌面集成的最普世做法(OBS、Mumble、1Password、Albert 等都同时支持),也是 Murmure 在 GNOME 上的默认模式([Murmure docs](https://docs.murmure.app/configure-shortcuts-on-linux/))。`tauri-plugin-single-instance` 2.x 已经在仓库里,回调拿 argv 是其官方设计([官方文档](https://v2.tauri.app/plugin/single-instance/))。 + +维护代价:低。代码改动集中在三处: +1. `main.rs` 早期解析一次 argv(在 Tauri Builder 之前不退出,只记下 intent); +2. `lib.rs:73` 的 single-instance 回调里识别 argv 并发往 coordinator; +3. README / Settings 页加一段文档教用户怎么绑桌面快捷键。 + +唯一已知限制:**桌面 OS 级快捷键大多只在 key-press 触发**(按键即 fire,不传 key-release)。这天然兼容 Toggle 模式,但不支持「按住说话 / 松开收尾」的 push-to-talk。OpenLess 默认就是 Toggle(`CLAUDE.md` 写明:「Hotkey is toggle-only, not press-and-hold」),所以不冲突。这一限制在 Murmure 文档里也明确写出:「Push-to-talk limitation — OS shortcuts only fire on key press」。 + +### 3.3 evdev/uinput 直接读 + +绕过 Wayland,直接打开 `/dev/input/event*` 读 scancode。 + +适配范围:所有 Linux(包括 TTY)。 +权限要求:用户必须在 `input` group,或二进制 setuid。**两条都是发行版会警告的安全降级**。 +成熟度:技术上稳定(`evdev-shortcut` crate 存在),但用户经验差:要手动 `usermod -aG input $USER` 然后注销重登 — 普通用户不会做。 +不推荐用于面向消费者的 OpenLess。 + +来源:[evremap (Wez)](https://github.com/wez/evremap)、[evdev_shortcut crate](https://docs.rs/evdev-shortcut/latest/evdev_shortcut/)。 + +### 3.4 libei + +libei + RemoteDesktop portal 是新一代「让应用模拟键盘鼠标」的官方路径,但目前主要用例是远程桌面 / 自动化测试。每次启动都要 portal 弹授权框,且 GNOME 实现仍在迭代。文档稀少。 + +不推荐用作快捷键触发路径。来源:[Sending keystrokes to Wayland — Medium](https://medium.com/@python-javascript-php-html-css/sending-keyboard-strokes-to-wayland-linux-windows-solutions-and-challenges-9319cf424d06)。 + +--- + +## 4. tauri-plugin-single-instance 2.x 最小示例 + +当前发布版本:**2.4.2**(2026-05-02)。仓库已锁 `tauri-plugin-single-instance = "2"`([crates.io](https://crates.io/crates/tauri-plugin-single-instance))。 + +回调签名:`Fn(&AppHandle, Vec, String) + Send + Sync + 'static` — 三个参数是 `app handle / argv / cwd`。来源:[Tauri 官方文档](https://v2.tauri.app/plugin/single-instance/)。 + +OpenLess 现有调用点(`lib.rs:73-78`)目前忽略 `argv` / `cwd`: + +```rust +.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + log::info!("[single-instance] another instance launched, focusing existing main window"); + show_main_window(app); +})) +``` + +改造后形态(示意,不在本调研里实施): + +```rust +.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + if let Some(intent) = parse_cli_intent(&argv) { + let coord: tauri::State> = app.state(); + dispatch_intent(coord.inner().clone(), intent); + return; // 不抢焦点 + } + show_main_window(app); // 无 intent → 退回原来的「聚焦主窗口」 +})) +``` + +注意点: +- 回调在 Tauri 主线程上执行,长任务必须 spawn 到 tokio runtime;OpenLess 的 coordinator 接口本来就异步。 +- 第二实例的进程**已经退出**,所以「不抢焦点」就是真不弹窗 — 体验上跟原生快捷键一致。 +- single-instance 插件必须**第一个**注册(早于 `tauri_plugin_shell` 等),这是官方文档强调的注意点。OpenLess 目前已经满足。 + +--- + +## 5. CLI 参数解析建议 + +**结论:用 `std::env::args()` 手写极简解析,不引入 clap。** + +理由: +- OpenLess 是 GUI app,CLI 入口只是「触发器」,参数集小(toggle-dictation / toggle-qa / cancel / show),没有子命令树。 +- 引入 `clap` 会让二进制体积涨一截(~200 KB),还要处理 `--help` 输出(GUI 程序输出帮助文本到 stderr,用户基本看不到,价值有限)。 +- 关键风险:**CLI 解析不能让 OpenLess panic 退出**。如果用户拖文件到 .desktop launcher 或者发行版包装传了奇怪参数,GUI 必须照常起来。`clap` 默认 `unwrap_or_else(|e| e.exit())` 会让进程退出,必须改成 `try_parse` + 静默忽略错误 — 那不如直接手写。 + +最小手写示意: + +```rust +// main.rs:在 Tauri Builder 之前 +#[derive(Clone, Copy)] +pub enum CliIntent { + ToggleDictation, + ToggleQa, + Cancel, + Show, +} + +fn parse_cli_intent>(args: &[S]) -> Option { + // 跳过 argv[0],逐项匹配;多余/未知参数静默忽略,绝不 panic + for arg in args.iter().skip(1) { + match arg.as_ref() { + "--toggle-dictation" => return Some(CliIntent::ToggleDictation), + "--toggle-qa" => return Some(CliIntent::ToggleQa), + "--cancel" => return Some(CliIntent::Cancel), + "--show" => return Some(CliIntent::Show), + _ => {} + } + } + None +} +``` + +把同样的 helper 在 `lib.rs:73` 的回调里复用 — 第一次进程启动(首实例)和 single-instance 转发走同一条解析路径。 + +`std::env::args()` 是 Rust 标准库,不引外部依赖。来源:[Rust by Example - std::env::args](https://doc.rust-lang.org/std/env/fn.args.html)、[Tauri CLI plugin(参考路径,本次不使用)](https://v2.tauri.app/plugin/cli/)。 + +--- + +## 6. 桌面环境配置自定义快捷键的步骤 + +OpenLess 在 Linux 安装后默认在 `$PATH` 里(或在 `.desktop` 旁边的 bin 目录)。下面假定二进制叫 `openless`。如果安装在非 PATH 路径(如 AppImage),文档里应同时写绝对路径。 + +### 6.1 GNOME (Wayland) + +**GUI 路径**([GNOME 官方帮助](https://help.gnome.org/gnome-help/keyboard-shortcuts-set.html)): + +1. Settings → Keyboard +2. Keyboard Shortcuts → View and Customize Shortcuts +3. Custom Shortcuts → Add Shortcut(+ 按钮) +4. Name: `OpenLess Dictate` +5. Command: `openless --toggle-dictation` +6. 点击 "Add Shortcut...",按下想绑的键(如 `Super+Y`) +7. 点 Add 保存 + +**CLI / 脚本化**([Programster's Blog](https://blog.programster.org/using-the-cli-to-set-custom-keyboard-shortcuts)、[Ubuntu Wiki - Keybindings](https://wiki.ubuntu.com/Keybindings))。注意 schema 是单数 `custom-keybinding`(不带 s),relocatable schema 需要带路径访问: + +```bash +KEYBIND_PATH="/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/openless0/" +gsettings set org.gnome.settings-daemon.plugins.media-keys custom-keybindings \ + "['$KEYBIND_PATH']" +gsettings set "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:$KEYBIND_PATH" \ + name 'OpenLess Dictate' +gsettings set "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:$KEYBIND_PATH" \ + command 'openless --toggle-dictation' +gsettings set "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:$KEYBIND_PATH" \ + binding 'y' +``` + +### 6.2 KDE Plasma 6 (Wayland) + +**GUI 路径**([KDE Discuss - Custom Shortcuts](https://discuss.kde.org/t/adding-shortcuts-to-systemsettings/15276)): + +1. System Settings → Keyboard → Shortcuts +2. "+ Add New" → Command/URL +3. Trigger: 录想绑的键 +4. Action: `openless --toggle-dictation` +5. Apply + +**CLI / 脚本化**([commandmasters.com](https://commandmasters.com/commands/kwriteconfig5-linux/)、[KDE Discuss - kglobalaccel](https://discuss.kde.org/t/plasma-6-method-to-refresh-kglobalaccel-shortcuts/17995)): + +Plasma 6 把 shortcut 存在 `~/.config/kglobalshortcutsrc`,工具改名为 `kwriteconfig6`。完整的 custom-shortcut 脚本化在 KDE 上比 GNOME 复杂(涉及 D-Bus 注册 + kglobalaccel 重载): + +```bash +# 写入声明 +kwriteconfig6 --file kglobalshortcutsrc \ + --group 'openless.desktop' --key '_k_friendly_name' 'OpenLess' +kwriteconfig6 --file kglobalshortcutsrc \ + --group 'openless.desktop' --key 'dictate' 'Meta+Y,none,Toggle Dictation' + +# 让 kglobalaccel 重载(必需,否则要重登) +qdbus org.kde.kglobalaccel /kglobalaccel reloadConfig +``` + +> 实践建议:KDE 上推荐**直接引导用户走 GUI**,因为 kglobalshortcutsrc 的 group 命名必须匹配 `.desktop` 文件 + 需要 service 注册,脚本化容易出错。 + +### 6.3 Hyprland + +**GUI 路径**:无。Hyprland 配置就是文本文件,没有图形化绑定。 + +**配置文件**([Hyprland Wiki - Binds](https://wiki.hypr.land/Configuring/Basics/Binds/)、[ArchWiki - Hyprland](https://wiki.archlinux.org/title/Hyprland)): + +文件位置 `~/.config/hypr/hyprland.conf`。Hyprland 0.54 及更早用传统 hyprlang 语法: + +``` +bind = SUPER, Y, exec, openless --toggle-dictation +bind = SUPER SHIFT, Y, exec, openless --toggle-qa +``` + +Hyprland 0.55+ 推荐用 Lua(hyprlang 已 deprecated): + +```lua +hl.bind({"SUPER"}, "y", "exec", "openless --toggle-dictation") +``` + +reload:`hyprctl reload`(或重启 hyprland)。 + +### 6.4 sway + +**GUI 路径**:无(同 Hyprland,纯文本配置)。 + +**配置文件**([sway(5) - ArchWiki](https://man.archlinux.org/man/sway.5)、[swaywm/sway Wiki - Shortcut handling](https://github.com/swaywm/sway/wiki/Shortcut-handling)): + +文件位置 `~/.config/sway/config`。语法: + +``` +bindsym $mod+y exec openless --toggle-dictation +bindsym $mod+Shift+y exec openless --toggle-qa +``` + +reload:`swaymsg reload`。 + +--- + +## 7. 推荐的最小修复方案(落地到 OpenLess) + +### 7.1 本期实现(Beta 1.3.x):CLI + single-instance 转发 + +理由: +1. **覆盖范围最大**:所有桌面环境直接可用,包括 Issue #420 用户的 Debian + GNOME(GNOME 是 portal 路线的最大盲区)。 +2. **改动量最小**:复用现有 `tauri-plugin-single-instance` 与 `coordinator::Coordinator` 公共接口,零新依赖。 +3. **与 toggle-only 设计契合**:OpenLess 现在就是 toggle-only(`CLAUDE.md` 已明确),不存在 push-to-talk 限制冲突。 +4. **故障面小**:CLI 解析 → IPC 命令链路是同步可测的,没有 D-Bus / 合成器版本依赖。 +5. **行业先例**:Murmure(同类产品)在 GNOME 上默认就用这条路径。 + +### 7.2 改动清单(**不在本调研中实施,仅作落地参考**) + +| 文件 | 改动 | 行数估计 | +|------|------|---------| +| `openless-all/app/src-tauri/src/cli.rs`(新) | `CliIntent` 枚举 + `parse_cli_intent` 函数 + 单元测试 | ~60 | +| `openless-all/app/src-tauri/src/lib.rs:73` | single-instance 回调里解析 argv,调度 intent | ~15 | +| `openless-all/app/src-tauri/src/lib.rs`(main 函数早期) | 首次启动也跑一遍 `parse_cli_intent`,记下首意图,coordinator 准备好后再触发;或简单约定「首次启动忽略 CLI intent,只起 GUI」 | ~5 | +| `openless-all/app/src-tauri/src/hotkey.rs:1204-1208` | 移除「wayland 报错」分支;改成 **info 级日志** + 不安装 rdev 监听(X11 仍走 rdev,Wayland 静默退出 listener) | ~10 | +| `openless-all/app/src/i18n/{zh-CN,en}.ts` | 新增 "Linux Wayland 下推荐通过桌面快捷键调用 `openless --toggle-dictation`" 引导文案 | ~10 | +| `README.md` / `README.zh.md` / `USAGE.md` | 把第 6 节四个 DE 的配置示例写进去 | ~50 | + +### 7.3 CLI 参数命名 + +按题面建议保留: + +``` +openless --toggle-dictation # 等价于按一次主热键 +openless --toggle-qa # 等价于按一次 QA 热键 +openless --cancel # 等价于 Esc +openless --show # 唤起主窗口(已有 single-instance 行为) +``` + +约定:所有 flag 在 Wayland 上是「唯一进入点」;X11 上仍然支持原 rdev 热键,CLI 是补充而非替代(用户可以同时用)。 + +### 7.4 Wayland 检测下的行为变化 + +`hotkey.rs:1204-1208` 当前的 `wayland_unsupported` 错误**不应再向上传**。改为: + +- 检测到 Wayland → 不安装 rdev listener,记一行 INFO log; +- 前端在 Settings → 热键页显示一行提示(i18n):「检测到 Wayland session。请在系统设置中将 `openless --toggle-dictation` 绑到一个快捷键。点这里查看说明 →」; +- 链接打开 README 中对应章节,按 DE 列出 6.1-6.4 的步骤。 + +这样既消除了 Issue #420 的报错,又主动告诉用户下一步该做什么,符合用户原始建议「补充对应脚本或命令让用户去系统设置中配置」。 + +### 7.5 后续路径(**留给单独 issue,本期不做**) + +- **xdg-desktop-portal GlobalShortcuts 集成**:等 GNOME 落地 issue [#47](https://gitlab.gnome.org/GNOME/xdg-desktop-portal/issues/47) 后再评估。届时 KDE + Hyprland + sway + GNOME 都成熟,可作为 CLI 路径的「升级版」(应用内绑定,无需用户去 DE 设置)。引入 `ashpd` crate(参考 4 节代码骨架与 [ashpd demo](https://github.com/bilelmoussaoui/ashpd/blob/master/demo/client/src/portals/desktop/global_shortcuts.rs))。 + - 现在不做的另一个理由:CLI 方案不会被 portal 方案取代 — 两者可共存。Portal 方案先在 KDE 上灰度也来得及。 +- **`hyprland-global-shortcuts-v1` 原生协议**:单合成器优化,优先级最低。 +- **Push-to-talk 模式**:如果未来想支持「按住录音」,OS 级快捷键路径会卡住(DE 只发 key-press),到那时再评估 portal / libei。 + +--- + +## 8. 参考资料 + +**Wayland 协议与安全模型** +- [The Wayland Protocol — seat/keyboard](https://wayland-book.com/seat/keyboard.html) +- [Wayland - keyboard-shortcuts-inhibit-unstable-v1](https://wayland.app/protocols/keyboard-shortcuts-inhibit-unstable-v1) +- [Exploring the Fragmentation of Wayland (semicomplete.com)](https://www.semicomplete.com/blog/xdotool-and-exploring-wayland-fragmentation/) +- [Sending Keyboard Strokes to Wayland (Medium)](https://medium.com/@python-javascript-php-html-css/sending-keyboard-strokes-to-wayland-linux-windows-solutions-and-challenges-9319cf424d06) +- [tauri-apps/global-hotkey issue #28 — Wayland support](https://github.com/tauri-apps/global-hotkey/issues/28) +- [dec05eba.com — Wayland global hotkeys is mostly useless](https://dec05eba.com/2024/03/29/wayland-global-hotkeys-shortcut-is-mostly-useless/) + +**xdg-desktop-portal GlobalShortcuts** +- [GlobalShortcuts 规范(flatpak.github.io)](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html) +- [KDE Portal MR !80 — Implementation of GlobalShortcuts](https://invent.kde.org/plasma/xdg-desktop-portal-kde/-/merge_requests/80) +- [KDE Portal MR !368 — Improve workflow](https://invent.kde.org/plasma/xdg-desktop-portal-kde/-/merge_requests/368) +- [KDE Portal MR !449 — Remember denied shortcuts](https://invent.kde.org/plasma/xdg-desktop-portal-kde/-/merge_requests/449) +- [GNOME xdg-desktop-portal-gnome issue #47 — GlobalShortcuts feature request](https://gitlab.gnome.org/GNOME/xdg-desktop-portal-gnome/-/issues/47) +- [GNOME Discourse — Feature request: GlobalShortcuts portal](https://discourse.gnome.org/t/feature-request-globalshortcuts-portal/15343) + +**ashpd(Rust 门户客户端)** +- [ashpd crate (docs.rs)](https://docs.rs/ashpd/latest/ashpd/) +- [ashpd repo — global_shortcuts.rs (client/src)](https://github.com/bilelmoussaoui/ashpd/blob/master/client/src/desktop/global_shortcuts.rs) +- [ashpd repo — demo global_shortcuts.rs (端到端示例)](https://github.com/bilelmoussaoui/ashpd/blob/master/demo/client/src/portals/desktop/global_shortcuts.rs) +- [ASHPD Demo on Flathub](https://flathub.org/en/apps/com.belmoussaoui.ashpd.demo) + +**Tauri single-instance** +- [tauri-plugin-single-instance — 官方文档](https://v2.tauri.app/plugin/single-instance/) +- [tauri-plugin-single-instance — crates.io(最新 2.4.2,2026-05-02)](https://crates.io/crates/tauri-plugin-single-instance) +- [tauri-plugin-single-instance — docs.rs/latest](https://docs.rs/crate/tauri-plugin-single-instance/latest) +- [Tauri v2 — Calling Rust from Frontend](https://v2.tauri.app/develop/calling-rust/) + +**桌面环境快捷键配置** +- [GNOME 帮助 — Set keyboard shortcuts](https://help.gnome.org/gnome-help/keyboard-shortcuts-set.html) +- [Programster — Using the CLI to Set Custom Keyboard Shortcuts](https://blog.programster.org/using-the-cli-to-set-custom-keyboard-shortcuts) +- [Ubuntu Wiki — Keybindings](https://wiki.ubuntu.com/Keybindings) +- [KDE Discuss — Adding shortcuts to Systemsettings](https://discuss.kde.org/t/adding-shortcuts-to-systemsettings/15276) +- [KDE Discuss — kglobalaccel reload (Plasma 6)](https://discuss.kde.org/t/plasma-6-method-to-refresh-kglobalaccel-shortcuts/17995) +- [commandmasters — kwriteconfig5 / kwriteconfig6](https://commandmasters.com/commands/kwriteconfig5-linux/) +- [Hyprland Wiki — Configuring/Basics/Binds](https://wiki.hypr.land/Configuring/Basics/Binds/) +- [ArchWiki — Hyprland](https://wiki.archlinux.org/title/Hyprland) +- [Hyprland Global Shortcuts protocol v1](https://wayland.app/protocols/hyprland-global-shortcuts-v1) +- [sway(5) — ArchWiki man page](https://man.archlinux.org/man/sway.5) +- [swaywm/sway Wiki — Shortcut handling](https://github.com/swaywm/sway/wiki/Shortcut-handling) +- [Mark Stosberg — Sway keybindings tips](https://mark.stosberg.com/sway-keybindings/) + +**同类产品参考(Murmure — 同样是 STT 应用)** +- [Murmure docs — Configure shortcuts on Linux](https://docs.murmure.app/configure-shortcuts-on-linux/) +- [Murmure repo — Kieirra/murmure](https://github.com/Kieirra/murmure) + +**evdev / 替代方案** +- [evdev_shortcut crate](https://docs.rs/evdev-shortcut/latest/evdev_shortcut/) +- [wez/evremap — Linux/Wayland keyboard remapper](https://github.com/wez/evremap) +- [xwaykeyz — X11 + Wayland keymapper](https://github.com/RedBearAK/xwaykeyz) +- [Vocalinux issue #80 — Wayland support via evdev](https://github.com/jatinkrmalik/vocalinux/issues/80) + +**OpenLess 仓库锚点** +- 当前实现:`openless-all/app/src-tauri/src/hotkey.rs:1183-1530`(Wayland 报错在 `:1204-1208`) +- single-instance 回调:`openless-all/app/src-tauri/src/lib.rs:73-78` +- IPC commands:`openless-all/app/src-tauri/src/commands.rs:1099-1110`(dictation)、`:1324-1330`(QA panel) +- Cargo deps:`openless-all/app/src-tauri/Cargo.toml:24`(`tauri-plugin-single-instance = "2"`) diff --git a/openless-all/app/src-tauri/src/cli.rs b/openless-all/app/src-tauri/src/cli.rs new file mode 100644 index 00000000..a5b11999 --- /dev/null +++ b/openless-all/app/src-tauri/src/cli.rs @@ -0,0 +1,119 @@ +//! 极简 CLI 参数解析 — 用于支持桌面环境快捷键调起 OpenLess 触发听写 / QA。 +//! +//! 这条路径的来历:Linux Wayland 协议层面禁止"应用监听全局键盘"(除了焦点窗口), +//! 因此 rdev 在 Wayland 上必然失效(issue #420)。本仓库不为 Wayland 引入门户 +//! GlobalShortcuts(GNOME 尚未原生落地,引入会增加合成器分裂的维护负担——见 +//! `docs/issue-420-wayland-hotkey-research.md` 3.1 节),改走桌面环境快捷键 → +//! `openless --toggle-dictation` → tauri-plugin-single-instance 转发的 CLI 路径。 +//! macOS / Windows 上仍走原生 hotkey 监听器,CLI 是补充而非替代。 +//! +//! 解析约束: +//! - **不依赖 clap**。CLI surface 极小(4 个 flag、无子命令),引入 clap 既增加二进制体积 +//! 也带来「未知参数即 panic exit」的风险——GUI app 必须吃下未知参数照常起来,否则 +//! .desktop launcher 或发行版包装传 dragged-in 文件路径就直接崩。 +//! - **未知参数静默忽略**。第一个能识别的 flag 即返回;其他参数(路径 / 自动注入的 +//! launcher 标志)不报错。 +//! - **同一份解析复用**首次启动 + single-instance 回调两个入口,行为完全一致。 + +/// 桌面环境快捷键能给 OpenLess 触发的动作集合。 +/// +/// 与 modifier-only / combo 热键对齐 — 只覆盖「单次触发」语义,不含 push-to-talk +/// (桌面 OS 级快捷键大多只在 key-press 触发,不传 key-release,无法支持「按住说话」)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CliIntent { + /// 等价于按一次主听写热键:Idle → 开始;Listening → 结束。 + ToggleDictation, + /// 等价于按一次 QA 热键:toggle QA 浮窗显隐。 + ToggleQa, + /// 等价于按 Esc:取消当前听写 session。 + CancelDictation, +} + +/// 扫描 argv 找第一个能识别的 intent。未知参数静默忽略,绝不 panic。 +/// +/// `args` 通常是 `std::env::args().collect::>()` 或 single-instance 回调里 +/// 传入的 `Vec`;两条路径走同一份解析。 +pub fn parse_cli_intent>(args: &[S]) -> Option { + // 跳过 argv[0](自身路径),逐项匹配。命中第一个就返回 — + // 多个 flag 时取首个,避免出现"toggle + cancel"这种自相矛盾组合。 + for arg in args.iter().skip(1) { + match arg.as_ref() { + "--toggle-dictation" => return Some(CliIntent::ToggleDictation), + "--toggle-qa" => return Some(CliIntent::ToggleQa), + "--cancel-dictation" | "--cancel" => return Some(CliIntent::CancelDictation), + _ => {} + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_returns_none_for_empty_argv() { + let args: Vec<&str> = vec![]; + assert_eq!(parse_cli_intent(&args), None); + } + + #[test] + fn parse_returns_none_when_only_argv0() { + // GUI 双击启动 / Tauri 默认启动场景:只有 argv[0],没有 intent。 + let args = vec!["openless"]; + assert_eq!(parse_cli_intent(&args), None); + } + + #[test] + fn parse_recognizes_toggle_dictation() { + let args = vec!["openless", "--toggle-dictation"]; + assert_eq!(parse_cli_intent(&args), Some(CliIntent::ToggleDictation)); + } + + #[test] + fn parse_recognizes_toggle_qa() { + let args = vec!["openless", "--toggle-qa"]; + assert_eq!(parse_cli_intent(&args), Some(CliIntent::ToggleQa)); + } + + #[test] + fn parse_recognizes_cancel_dictation() { + let args = vec!["openless", "--cancel-dictation"]; + assert_eq!(parse_cli_intent(&args), Some(CliIntent::CancelDictation)); + } + + #[test] + fn parse_accepts_cancel_alias() { + // --cancel 也接受(research doc 5 节里写成 --cancel;为兼容两种写法都收)。 + let args = vec!["openless", "--cancel"]; + assert_eq!(parse_cli_intent(&args), Some(CliIntent::CancelDictation)); + } + + #[test] + fn parse_ignores_unknown_args() { + // GUI app 必须吃下未知参数照常起来。 + let args = vec!["openless", "--unknown-flag", "/some/path"]; + assert_eq!(parse_cli_intent(&args), None); + } + + #[test] + fn parse_returns_first_matching_intent() { + // 多个 flag 时取首个,确定行为。 + let args = vec!["openless", "--toggle-dictation", "--toggle-qa"]; + assert_eq!(parse_cli_intent(&args), Some(CliIntent::ToggleDictation)); + } + + #[test] + fn parse_skips_argv0_even_if_it_looks_like_a_flag() { + // argv[0] 是进程路径,永远跳过。即便构造出诡异的"argv[0]=--toggle-dictation" + // 也不应被当作 intent —— skip(1) 已保证。 + let args = vec!["--toggle-dictation"]; + assert_eq!(parse_cli_intent(&args), None); + } + + #[test] + fn parse_finds_intent_among_unknown_args() { + let args = vec!["openless", "/path/to/file", "--toggle-dictation", "extra"]; + assert_eq!(parse_cli_intent(&args), Some(CliIntent::ToggleDictation)); + } +} diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index fc90a947..4c759ea5 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -299,6 +299,18 @@ pub fn get_hotkey_capability(coord: CoordinatorState<'_>) -> HotkeyCapability { coord.hotkey_capability() } +/// Pull-style 查询:当前是否处于 Linux/Wayland session(rdev 不可用、需要走 CLI 路径)。 +/// 前端 RecordingSection mount 时调一次拿状态,直接渲染 callout。 +/// +/// 用 pull 而不是单纯依赖 ready-time 的 `wayland_cli_mode` event:Settings 模态是 +/// 条件渲染(用户首次打开 Settings 才 mount RecordingSection),但 emit 发生在 setup +/// 末尾——一次性 event 不缓冲也不 replay,listener 99% 情况下错过事件 → callout +/// 永远不显示。XDG_SESSION_TYPE 本身在进程生命周期内不会变,多次调用结果一致。 +#[tauri::command] +pub fn is_wayland_cli_mode() -> bool { + crate::hotkey::is_wayland_session() +} + #[tauri::command] pub fn set_shortcut_recording_active(coord: CoordinatorState<'_>, active: bool) { coord.set_shortcut_recording_active(active); diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 1fc90a6c..cdff19a0 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -745,6 +745,22 @@ impl Coordinator { cancel_session(&self.inner); } + /// 返回当前听写阶段(read-only 快照),供 CLI 入口在 dispatch toggle 时决策。 + /// 与原热键边沿走的 `handle_pressed` 分支完全相同的判定逻辑:Idle → start, + /// Listening → stop。Linux/Wayland 下桌面快捷键 → CLI 转发是唯一触发路径, + /// 必须复用这套语义。 + pub fn dictation_phase_for_cli(&self) -> SessionPhase { + self.inner.state.lock().phase + } + + /// CLI 入口的 QA toggle:直接复用 modifier-only QA 热键边沿的处理函数。 + /// 与 `handle_qa_hotkey_pressed` 同语义 — Idle → 开浮窗 / Recording → 收尾 / + /// Processing → 忽略。Wayland 下没有 modifier-only / global-hotkey 监听,CLI + /// 是唯一进入点。 + pub async fn cli_toggle_qa_panel(&self) { + handle_qa_hotkey_pressed(&self.inner).await; + } + pub fn set_shortcut_recording_active(&self, active: bool) { self.inner .shortcut_recording_active diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 9be0abea..c93e7c05 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -173,6 +173,21 @@ impl Drop for HotkeyMonitor { } } +/// 是否处于 Wayland session。Linux 以外的平台恒返回 false。 +/// +/// 主用途:`lib.rs` 在 hotkey listener 起好后据此决定是否额外 emit +/// `wayland_cli_mode` 事件,让前端 Settings 面板展示「请绑桌面快捷键到 +/// `openless --toggle-dictation`」的引导文案。 +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +pub fn is_wayland_session() -> bool { + std::env::var("XDG_SESSION_TYPE").ok().as_deref() == Some("wayland") +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub fn is_wayland_session() -> bool { + false +} + fn install_error(code: &str, message: impl Into) -> HotkeyInstallError { HotkeyInstallError { code: code.into(), @@ -1197,15 +1212,26 @@ mod platform { }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; + /// X11 走 rdev 监听器;Wayland 协议层面禁止应用监听其他窗口的键盘事件 + /// (详见 `docs/issue-420-wayland-hotkey-research.md` 2 节),所以这里 + /// 返回一个"CLI 适配器"占位:不安装任何键盘 hook,但实现 HotkeyAdapter + /// trait 以让上层 `ensure_modifier_hotkey_monitor` 正常走 `Installed` 分支, + /// 不再把 Wayland 当成"安装失败"。 + /// + /// 用户实际的触发路径变成:桌面环境快捷键 → `openless --toggle-dictation` + /// → tauri-plugin-single-instance 拦截并把 argv 转给主实例 coordinator。 + /// 前端 Settings 面板会监听 `wayland_cli_mode` 事件并展示对应的引导文案。 pub fn start_adapter( binding: HotkeyBinding, tx: Sender, ) -> Result, HotkeyInstallError> { - if std::env::var("XDG_SESSION_TYPE").ok().as_deref() == Some("wayland") { - return Err(install_error( - "wayland_unsupported", - "Wayland 暂不支持全局热键,请切到 X11 session 后再试", - )); + if super::is_wayland_session() { + log::info!( + "[hotkey] Wayland session detected; rdev listener skipped — \ + use desktop shortcut → `openless --toggle-dictation` instead (issue #420)" + ); + // tx 在 stub adapter 下无人 push 事件 — 持有它直到 adapter 被 drop 即可。 + return Ok(Box::new(WaylandCliAdapter { _tx: tx })); } let listener = start_listener_thread( binding, @@ -1220,6 +1246,35 @@ mod platform { })) } + /// Wayland 下的占位 adapter:实现接口但不监听键盘。 + /// 上层 coordinator 仍会把它登记为 `Installed`(hotkey 状态显示正常), + /// 用户的触发路径由 CLI + single-instance 转发承担。 + struct WaylandCliAdapter { + _tx: Sender, + } + + impl HotkeyAdapter for WaylandCliAdapter { + fn kind(&self) -> HotkeyAdapterKind { + // 复用 Rdev kind 显示,避免新增枚举项波及整个序列化层。 + // 真实 adapter 状态由 `wayland_cli_mode` 事件在前端单独引导。 + HotkeyAdapterKind::Rdev + } + + fn update_binding(&self, _binding: HotkeyBinding) { + // Wayland 下绑定由桌面环境管理;忽略后端绑定变更,但不报错。 + } + + fn update_modifier_shortcuts( + &self, + _qa_trigger: Option, + _translation_trigger: Option, + ) { + // 同上 — modifier-only 修饰键在 Wayland 上也走不通,留空。 + } + + fn reset_held_state(&self) {} + } + struct RdevHotkeyAdapter { shared: Arc, } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 44e5b09e..99452110 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod asr; mod audio_mute; +mod cli; mod combo_hotkey; mod commands; mod coordinator; @@ -70,7 +71,18 @@ pub fn run() { // 单实例锁:第二个进程启动时立即退出,激活信号转给已运行实例的主窗口。 // 否则两份 OpenLess(如 /Applications/ + dev build)会各自抓全局热键, // 导致按一次键、两个进程同时跑流水线、文本被插入两遍。见 issue #50。 - .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + // + // 第二个进程的 argv 还有一个用处:作为 Linux/Wayland 下的「触发器入口」。 + // 桌面环境快捷键执行 `openless --toggle-dictation` 时,第二个进程被本插件 + // 拦截 → argv 直接转给主实例 coordinator。详见 issue #420 / `cli.rs`。 + .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + if let Some(intent) = cli::parse_cli_intent(&argv) { + log::info!( + "[single-instance] another instance launched with intent={intent:?}, dispatching" + ); + dispatch_cli_intent(app, intent); + return; + } log::info!( "[single-instance] another instance launched, focusing existing main window" ); @@ -233,6 +245,22 @@ pub fn run() { show_main_window(app.handle()); } + // Wayland 下没有可用的全局键盘监听(issue #420)。Coordinator 已通过 stub adapter + // 把 hotkey 状态标记为 Installed,整个应用照常起来。前端走 pull 模型:RecordingSection + // mount 时调 `is_wayland_cli_mode` 取状态再渲染 CLI 引导 callout。原本用一次性 event 通知 + // 行不通——Settings 模态是按需 mount,事件不缓冲不 replay,listener 几乎必然错过。 + if hotkey::is_wayland_session() { + log::info!("[startup] Wayland session — frontend will pull via is_wayland_cli_mode"); + } + + // 首次启动也可能带 CLI flag(用户双击 .desktop 之前先用 CLI 起一遍)。 + // 等 coordinator 准备好后再 dispatch;GUI 仍然照常起来。 + let first_run_args: Vec = std::env::args().collect(); + if let Some(intent) = cli::parse_cli_intent(&first_run_args) { + log::info!("[startup] first-run CLI intent={intent:?}, dispatching"); + dispatch_cli_intent(app.handle(), intent); + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -243,6 +271,7 @@ pub fn run() { commands::fetch_latest_beta_release, commands::get_hotkey_status, commands::get_hotkey_capability, + commands::is_wayland_cli_mode, commands::set_shortcut_recording_active, commands::get_windows_ime_status, commands::list_microphone_devices, @@ -755,6 +784,70 @@ pub(crate) fn show_main_window(app: &AppHandle) { activate_app(app); } +/// 把 CLI intent 路由到 coordinator。两个入口共用: +/// 1. 首次启动(lib.rs setup 末尾) +/// 2. single-instance 回调(第二个进程被拦截后转发 argv) +/// +/// 异步动作(start_dictation / stop_dictation 是 async)通过 tauri 自带 runtime spawn, +/// 不阻塞回调线程。所有动作都按 coordinator 当前状态自检: +/// - ToggleDictation 在 Idle → start,在 Listening → stop,Starting/Processing/Inserting 忽略并记日志 +/// - ToggleQa 直接转发到 handle_qa_hotkey_pressed(语义等同于按一次 QA 热键) +/// - CancelDictation 直接调 cancel(cancel 本身在非 Listening 时也安全) +fn dispatch_cli_intent(app: &AppHandle, intent: cli::CliIntent) { + let coordinator = app + .try_state::>() + .map(|s| Arc::clone(&*s)); + let Some(coordinator) = coordinator else { + log::warn!("[cli] coordinator not yet managed; dropping intent={intent:?}"); + return; + }; + match intent { + cli::CliIntent::ToggleDictation => { + let coord = Arc::clone(&coordinator); + tauri::async_runtime::spawn(async move { + let phase = coord.dictation_phase_for_cli(); + use coordinator_state::SessionPhase; + match phase { + SessionPhase::Idle => { + log::info!("[cli] toggle-dictation: Idle → start_dictation"); + if let Err(e) = coord.start_dictation().await { + log::warn!("[cli] start_dictation failed: {e}"); + } + } + SessionPhase::Listening => { + log::info!("[cli] toggle-dictation: Listening → stop_dictation"); + if let Err(e) = coord.stop_dictation().await { + log::warn!("[cli] stop_dictation failed: {e}"); + } + } + SessionPhase::Starting => { + // 复用 stop_dictation 自身的 Starting → pending_stop 处理, + // 与按一次主热键的行为对齐(issue #51)。 + log::info!("[cli] toggle-dictation: Starting → stop_dictation (pending)"); + if let Err(e) = coord.stop_dictation().await { + log::warn!("[cli] stop_dictation failed: {e}"); + } + } + other => { + log::info!("[cli] toggle-dictation ignored (phase={other:?})"); + } + } + }); + } + cli::CliIntent::ToggleQa => { + let coord = Arc::clone(&coordinator); + tauri::async_runtime::spawn(async move { + log::info!("[cli] toggle-qa: dispatching to qa hotkey handler"); + coord.cli_toggle_qa_panel().await; + }); + } + cli::CliIntent::CancelDictation => { + log::info!("[cli] cancel-dictation: invoking cancel"); + coordinator.cancel_dictation(); + } + } +} + pub(crate) fn request_microphone_from_foreground( app: &AppHandle, ) -> permissions::PermissionStatus { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 08475c58..71a0c593 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -352,6 +352,21 @@ export const en: typeof zhCN = { startupAtBoot: 'Launch at login', startupAtBootDesc: 'Start OpenLess automatically when you sign in.', startupAtBootError: 'Failed to toggle launch at login: {{message}}', + wayland: { + calloutTitle: 'Wayland desktop detected', + calloutBody: 'Wayland forbids apps from listening for global shortcuts. Please bind the following command to a custom shortcut in your system settings:', + copyButton: 'Copy', + copyButtonCopied: 'Copied', + helpToggle: 'Setup steps for each desktop environment', + gnomeTitle: 'GNOME', + gnomeSteps: 'Settings → Keyboard → View and Customize Shortcuts → Custom Shortcuts → Add Shortcut. Paste the command above and record the key combination you want.', + kdeTitle: 'KDE Plasma', + kdeSteps: 'System Settings → Keyboard → Shortcuts → Add New → Command/URL. Record any trigger keys you like, paste the command above, then Apply.', + hyprlandTitle: 'Hyprland', + hyprlandSteps: 'Edit ~/.config/hypr/hyprland.conf, add bind = SUPER, Y, exec, openless --toggle-dictation, then run hyprctl reload.', + swayTitle: 'sway', + swaySteps: 'Edit ~/.config/sway/config, add bindsym $mod+y exec openless --toggle-dictation, then run swaymsg reload.', + }, }, providers: { llmTitle: 'LLM (polishing)', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 091ae803..aae1e2b9 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -354,6 +354,21 @@ export const ja: typeof zhCN = { startupAtBoot: '起動時に自動起動', startupAtBootDesc: 'ログイン時に OpenLess を自動起動。', startupAtBootError: '自動起動の切り替えに失敗:{{message}}', + wayland: { + calloutTitle: 'Wayland デスクトップを検出', + calloutBody: 'Wayland はセキュリティ上、アプリのグローバルショートカット監視を許可していません。システム設定でカスタムショートカットを作成し、以下のコマンドにバインドしてください:', + copyButton: 'コピー', + copyButtonCopied: 'コピー済み', + helpToggle: '各デスクトップ環境の設定手順', + gnomeTitle: 'GNOME', + gnomeSteps: '設定 → キーボード → ショートカットの表示とカスタマイズ → カスタムショートカット → 追加。コマンド欄に上記コマンドを貼り付け、希望のキー組み合わせを入力。', + kdeTitle: 'KDE Plasma', + kdeSteps: 'システム設定 → キーボード → ショートカット → 新規追加 → コマンド/URL。任意のトリガーキーを記録し、アクションに上記コマンドを貼り付けて保存。', + hyprlandTitle: 'Hyprland', + hyprlandSteps: '~/.config/hypr/hyprland.conf に bind = SUPER, Y, exec, openless --toggle-dictation を追加し、hyprctl reload を実行。', + swayTitle: 'sway', + swaySteps: '~/.config/sway/config に bindsym $mod+y exec openless --toggle-dictation を追加し、swaymsg reload を実行。', + }, }, providers: { llmTitle: 'LLM モデル(整文)', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 8b3f9824..864c0aea 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -354,6 +354,21 @@ export const ko: typeof zhCN = { startupAtBoot: '부팅 시 자동 시작', startupAtBootDesc: '로그인 시 OpenLess 자동 시작.', startupAtBootError: '자동 시작 전환 실패: {{message}}', + wayland: { + calloutTitle: 'Wayland 데스크톱 환경 감지됨', + calloutBody: 'Wayland는 보안상 앱의 전역 단축키 감지를 허용하지 않습니다. 시스템 설정에서 사용자 지정 단축키를 만들고 다음 명령에 바인딩하세요:', + copyButton: '복사', + copyButtonCopied: '복사됨', + helpToggle: '각 데스크톱 환경별 설정 단계', + gnomeTitle: 'GNOME', + gnomeSteps: '설정 → 키보드 → 단축키 보기 및 사용자 지정 → 사용자 지정 단축키 → 추가. 명령 칸에 위 명령을 붙여넣고 원하는 키 조합을 기록.', + kdeTitle: 'KDE Plasma', + kdeSteps: '시스템 설정 → 키보드 → 단축키 → 새로 추가 → 명령/URL. 트리거 키를 기록하고 작업란에 위 명령을 붙여넣은 후 저장.', + hyprlandTitle: 'Hyprland', + hyprlandSteps: '~/.config/hypr/hyprland.conf 파일에 bind = SUPER, Y, exec, openless --toggle-dictation 을 추가하고 hyprctl reload 실행.', + swayTitle: 'sway', + swaySteps: '~/.config/sway/config 파일에 bindsym $mod+y exec openless --toggle-dictation 을 추가하고 swaymsg reload 실행.', + }, }, providers: { llmTitle: 'LLM 모델(정리)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index d3de7a43..646d2ebc 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -350,6 +350,21 @@ export const zhCN = { startupAtBoot: '开机自启', startupAtBootDesc: '登录系统时自动启动 OpenLess。', startupAtBootError: '开机自启切换失败:{{message}}', + wayland: { + calloutTitle: '检测到 Wayland 桌面环境', + calloutBody: 'Wayland 出于安全考虑不允许应用监听全局快捷键。请在系统设置中创建一个自定义快捷键,绑定下面这条命令:', + copyButton: '复制', + copyButtonCopied: '已复制', + helpToggle: '查看各桌面环境配置步骤', + gnomeTitle: 'GNOME', + gnomeSteps: '设置 → 键盘 → 查看和自定义快捷键 → 自定义快捷键 → 添加快捷键,命令处填入上方命令,再录入想用的按键组合。', + kdeTitle: 'KDE Plasma', + kdeSteps: '系统设置 → 键盘 → 快捷键 → 添加新的 → 命令/URL,触发按键随意录入,动作处粘贴上方命令并保存。', + hyprlandTitle: 'Hyprland', + hyprlandSteps: '编辑 ~/.config/hypr/hyprland.conf,加入 bind = SUPER, Y, exec, openless --toggle-dictation,然后执行 hyprctl reload。', + swayTitle: 'sway', + swaySteps: '编辑 ~/.config/sway/config,加入 bindsym $mod+y exec openless --toggle-dictation,然后执行 swaymsg reload。', + }, }, providers: { llmTitle: 'LLM 模型(润色)', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index da487567..3c872353 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -352,6 +352,21 @@ export const zhTW: typeof zhCN = { startupAtBoot: '開機自啓', startupAtBootDesc: '登錄系統時自動啓動 OpenLess。', startupAtBootError: '開機自啓切換失敗:{{message}}', + wayland: { + calloutTitle: '偵測到 Wayland 桌面環境', + calloutBody: 'Wayland 出於安全考慮不允許應用監聽全域快速鍵。請在系統設定中建立一個自訂快速鍵,繫結下面這條命令:', + copyButton: '複製', + copyButtonCopied: '已複製', + helpToggle: '查看各桌面環境設定步驟', + gnomeTitle: 'GNOME', + gnomeSteps: '設定 → 鍵盤 → 檢視並自訂快速鍵 → 自訂快速鍵 → 新增快速鍵,命令處填入上方命令,再錄入想用的按鍵組合。', + kdeTitle: 'KDE Plasma', + kdeSteps: '系統設定 → 鍵盤 → 快速鍵 → 新增 → 命令/URL,觸發按鍵隨意錄入,動作處貼上上方命令並儲存。', + hyprlandTitle: 'Hyprland', + hyprlandSteps: '編輯 ~/.config/hypr/hyprland.conf,加入 bind = SUPER, Y, exec, openless --toggle-dictation,然後執行 hyprctl reload。', + swayTitle: 'sway', + swaySteps: '編輯 ~/.config/sway/config,加入 bindsym $mod+y exec openless --toggle-dictation,然後執行 swaymsg reload。', + }, }, providers: { llmTitle: 'LLM 模型(潤色)', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index b8e14f7a..f700c9cc 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -206,6 +206,12 @@ export function getHotkeyCapability(): Promise { return invokeOrMock('get_hotkey_capability', undefined, () => mockHotkeyCapability); } +// Linux/Wayland 检测:rdev 监听在 Wayland 协议层面失败(issue #420),需引导用户 +// 把 `openless --toggle-dictation` 绑到桌面环境快捷键。浏览器 / 非 Tauri 环境下永远 false。 +export function isWaylandCliMode(): Promise { + return invokeOrMock('is_wayland_cli_mode', undefined, () => false); +} + export function getWindowsImeStatus(): Promise { return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus); } diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 0d27c9f6..72e89d36 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -16,6 +16,7 @@ import { import { createHotkeyRecorderState, orderHotkeyCodes, updateHotkeyRecorderState } from '../lib/hotkeyRecorder'; import { isTauri, + isWaylandCliMode, listMicrophoneDevices, openExternal, listProviderModels, @@ -199,6 +200,24 @@ function RecordingSection() { const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false); const [microphoneDevicesError, setMicrophoneDevicesError] = useState(null); const [microphonePickerOpen, setMicrophonePickerOpen] = useState(false); + // Wayland 下 rdev 监听不可用(issue #420)。改用 pull 模型:mount 时 invoke 拉状态。 + // 不能依赖一次性 event — Settings 模态是按需 mount,emit 早在 setup 阶段发完了。 + // XDG_SESSION_TYPE 在进程生命周期内不会变,拉一次即可,无需 polling 或 listener。 + const [waylandCliMode, setWaylandCliMode] = useState(false); + + useEffect(() => { + let cancelled = false; + void isWaylandCliMode() + .then(value => { + if (!cancelled) setWaylandCliMode(value); + }) + .catch((err: unknown) => { + console.warn('[settings] is_wayland_cli_mode query failed', err); + }); + return () => { + cancelled = true; + }; + }, []); const loadMicrophoneDevices = useCallback(async ( signal?: { cancelled: boolean }, @@ -340,6 +359,7 @@ function RecordingSection() { )} + {waylandCliMode && } { + try { + await navigator.clipboard.writeText(command); + setCopied(true); + // 1.5s 后还原按钮文案。用户可重复点击;状态机简单到不必上 useRef cleanup。 + setTimeout(() => setCopied(false), 1500); + } catch (err) { + console.warn('[wayland-callout] clipboard write failed', err); + } + }, []); + + const helpEntries: Array = [ + [t('settings.recording.wayland.gnomeTitle'), t('settings.recording.wayland.gnomeSteps')], + [t('settings.recording.wayland.kdeTitle'), t('settings.recording.wayland.kdeSteps')], + [t('settings.recording.wayland.hyprlandTitle'), t('settings.recording.wayland.hyprlandSteps')], + [t('settings.recording.wayland.swayTitle'), t('settings.recording.wayland.swaySteps')], + ]; + + return ( +
+
+ {t('settings.recording.wayland.calloutTitle')} +
+
+ {t('settings.recording.wayland.calloutBody')} +
+
+ + {command} + + +
+ + {helpOpen && ( +
+ {helpEntries.map(([title, body]) => ( +
+
+ {title} +
+
+ {body} +
+
+ ))} +
+ )} +
+ ); +} + function HotkeyRecorder({ binding, onCommit,