fix(hotkey): Wayland 下用 CLI 触发录音,修复 #420

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 路径零触碰。
This commit is contained in:
baiqing committed 2026-05-13 12:34:52 +08:00
1 parent c5bdb02595
commit effa12e5f1
13 files changed
+925 -6

No files matched your search

+401
View File
@@ -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>, 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<Arc<Coordinator>> = 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<S: AsRef<str>>(args: &[S]) -> Option<CliIntent> {
// 跳过 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 '<Super>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"`)
+119
View File
@@ -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::<Vec<_>>()` 或 single-instance 回调里
/// 传入的 `Vec<String>`;两条路径走同一份解析。
pub fn parse_cli_intent<S: AsRef<str>>(args: &[S]) -> Option<CliIntent> {
// 跳过 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));
}
}
@@ -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);
@@ -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
+60 -5
View File
@@ -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<String>) -> 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<HotkeyEvent>,
) -> Result<Box<dyn HotkeyAdapter>, 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<HotkeyEvent>,
}
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<HotkeyTrigger>,
_translation_trigger: Option<HotkeyTrigger>,
) {
// 同上 — modifier-only 修饰键在 Wayland 上也走不通,留空。
}
fn reset_held_state(&self) {}
}
struct RdevHotkeyAdapter {
shared: Arc<Shared>,
}
+94 -1
View File
@@ -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<String> = 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<R: Runtime>(app: &AppHandle<R>) {
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<R: Runtime>(app: &AppHandle<R>, intent: cli::CliIntent) {
let coordinator = app
.try_state::<Arc<coordinator::Coordinator>>()
.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<R: Runtime>(
app: &AppHandle<R>,
) -> permissions::PermissionStatus {
+15
View File
@@ -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)',
+15
View File
@@ -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 モデル(整文)',
+15
View File
@@ -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 모델(정리)',
+15
View File
@@ -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 模型(润色)',
+15
View File
@@ -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 模型(潤色)',
+6
View File
@@ -206,6 +206,12 @@ export function getHotkeyCapability(): Promise<HotkeyCapability> {
return invokeOrMock('get_hotkey_capability', undefined, () => mockHotkeyCapability);
}
// Linux/Wayland 检测:rdev 监听在 Wayland 协议层面失败(issue #420),需引导用户
// 把 `openless --toggle-dictation` 绑到桌面环境快捷键。浏览器 / 非 Tauri 环境下永远 false。
export function isWaylandCliMode(): Promise<boolean> {
return invokeOrMock('is_wayland_cli_mode', undefined, () => false);
}
export function getWindowsImeStatus(): Promise<WindowsImeStatus> {
return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus);
}
+142
View File
@@ -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<string | null>(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() {
</div>
</div>
)}
{waylandCliMode && <WaylandHotkeyCallout />}
<SettingRow label={t('settings.recording.hotkeyLabel')} desc={hotkeyDesc}>
<ShortcutRecorder
value={prefs.dictationHotkey}
@@ -530,6 +550,128 @@ function RecordingSection() {
);
}
/// Wayland 引导 callout:告诉用户绑桌面环境快捷键到 `openless --toggle-dictation`。
/// 显示条件由父组件控制(mount 时 invoke `is_wayland_cli_mode` 拉状态后渲染)。
/// 文案逐桌面环境列出步骤,每段 ≤ 5 行,详细的命令在 README 里。
function WaylandHotkeyCallout() {
const { t } = useTranslation();
const [helpOpen, setHelpOpen] = useState(false);
const [copied, setCopied] = useState(false);
const command = 'openless --toggle-dictation';
const onCopy = useCallback(async () => {
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<readonly [string, string]> = [
[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 (
<div
style={{
marginTop: 10,
marginBottom: 8,
padding: '12px 14px',
borderRadius: 10,
background: 'rgba(217,119,6,0.08)',
border: '0.5px solid rgba(217,119,6,0.22)',
}}
>
<div style={{ fontSize: 12.5, fontWeight: 600, color: '#b45309', marginBottom: 4 }}>
{t('settings.recording.wayland.calloutTitle')}
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.55, marginBottom: 8 }}>
{t('settings.recording.wayland.calloutBody')}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 10px',
borderRadius: 8,
background: 'rgba(255,255,255,0.7)',
border: '0.5px solid rgba(0,0,0,0.08)',
marginBottom: 8,
}}
>
<code
style={{
flex: 1,
fontSize: 12,
fontFamily: 'ui-monospace, SF Mono, Menlo, Consolas, monospace',
color: 'var(--ol-ink)',
userSelect: 'all',
}}
>
{command}
</code>
<button
type="button"
onClick={onCopy}
style={{
padding: '4px 10px',
fontSize: 11,
fontWeight: 500,
border: '0.5px solid rgba(0,0,0,0.12)',
borderRadius: 6,
background: '#fff',
color: 'var(--ol-ink-2)',
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
{copied
? t('settings.recording.wayland.copyButtonCopied')
: t('settings.recording.wayland.copyButton')}
</button>
</div>
<button
type="button"
onClick={() => setHelpOpen(prev => !prev)}
style={{
padding: 0,
background: 'transparent',
border: 0,
color: '#b45309',
fontSize: 11.5,
fontWeight: 500,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
{helpOpen ? '▾ ' : '▸ '}
{t('settings.recording.wayland.helpToggle')}
</button>
{helpOpen && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
{helpEntries.map(([title, body]) => (
<div key={title}>
<div style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--ol-ink-2)', marginBottom: 2 }}>
{title}
</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-3)', lineHeight: 1.55 }}>
{body}
</div>
</div>
))}
</div>
)}
</div>
);
}
function HotkeyRecorder({
binding,
onCommit,