Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
401 changes: 401 additions & 0 deletions docs/issue-420-wayland-hotkey-research.md

Large diffs are not rendered by default.

119 changes: 119 additions & 0 deletions openless-all/app/src-tauri/src/cli.rs
Original file line number Diff line number Diff line change
@@ -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));
}
}
12 changes: 12 additions & 0 deletions openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 60 additions & 5 deletions openless-all/app/src-tauri/src/hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand All @@ -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>,
}
Expand Down
95 changes: 94 additions & 1 deletion openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

mod asr;
mod audio_mute;
mod cli;
mod combo_hotkey;
mod commands;
mod coordinator;
Expand Down Expand Up @@ -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"
);
Expand Down Expand Up @@ -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![
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading