From 2912aefed50ec1eafce868863daab04050839bce Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Fri, 15 May 2026 23:43:37 +0800 Subject: [PATCH 01/15] feat(linux): replace enigo with fcitx5 plugin for Wayland input - fcitx5 C++ plugin (scripts/linux-fcitx5-plugin/) with DBus interface: CommitText, SetHotkey/SetHotkeyRaw, DictationKeyEvent signal - linux_fcitx.rs: DBus client to call plugin from Rust - coordinator/dictation.rs: Wayland insertion via fcitx5 commit_text, streaming insert enabled on Wayland - insertion.rs: fcitx5 commit_text on Linux with clipboard fallback - unicode_keystroke.rs: Linux path uses fcitx5 commit_text - Capsule window show-once on Linux to avoid stealing focus Co-Authored-By: Claude Opus 4.7 --- openless-all/app/src-tauri/Cargo.lock | 1 + openless-all/app/src-tauri/Cargo.toml | 2 + openless-all/app/src-tauri/src/coordinator.rs | 49 +++- .../src-tauri/src/coordinator/dictation.rs | 84 +++--- openless-all/app/src-tauri/src/insertion.rs | 15 + openless-all/app/src-tauri/src/lib.rs | 1 + openless-all/app/src-tauri/src/linux_fcitx.rs | 176 ++++++++++++ .../app/src-tauri/src/unicode_keystroke.rs | 33 +-- .../linux-fcitx5-plugin/CMakeLists.txt | 45 +++ .../scripts/linux-fcitx5-plugin/build.sh | 29 ++ .../linux-fcitx5-plugin/openless.conf.in | 15 + .../scripts/linux-fcitx5-plugin/openless.cpp | 264 ++++++++++++++++++ 12 files changed, 652 insertions(+), 62 deletions(-) create mode 100644 openless-all/app/src-tauri/src/linux_fcitx.rs create mode 100644 openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt create mode 100755 openless-all/scripts/linux-fcitx5-plugin/build.sh create mode 100644 openless-all/scripts/linux-fcitx5-plugin/openless.conf.in create mode 100644 openless-all/scripts/linux-fcitx5-plugin/openless.cpp diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 69333729..86f88cf4 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -3762,6 +3762,7 @@ dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", "cpal", + "dbus", "enigo", "env_logger", "ferrous-opencc", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 5600414e..822fc632 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -62,6 +62,8 @@ version = "3.6.3" default-features = false features = ["windows-native"] +[target.'cfg(target_os = "linux")'.dependencies] +dbus = "0.9" [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies.keyring] version = "3.6.3" default-features = false diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c2c68f3a..44855167 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -150,6 +150,10 @@ struct Inner { /// 最近一次应用到 capsule 窗口的几何状态。避免录音 level tick 反复触发 /// resize / reposition。 capsule_layout: Mutex>, + /// Linux: 胶囊窗口当前是否已 show 过。防止每次 emit_capsule 都调 window.show() + /// 抢走目标 app 的键盘焦点。首次 Idle→可见时 show 一次,后续可见→可见不重新 show。 + /// 回到 Idle 时 reset 为 false,下次 session 再 show。 + capsule_window_visible: AtomicBool, /// QA 用的 ASR 句柄(始终是 Volcengine 流式)。 qa_asr: Mutex>>, /// QA 用的 Recorder 句柄。 @@ -223,6 +227,7 @@ impl Coordinator { qa_hotkey: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), capsule_layout: Mutex::new(None), + capsule_window_visible: AtomicBool::new(false), qa_asr: Mutex::new(None), qa_recorder: Mutex::new(None), qa_stream_cancelled: Arc::new(AtomicBool::new(false)), @@ -273,6 +278,7 @@ impl Coordinator { qa_hotkey: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), capsule_layout: Mutex::new(None), + capsule_window_visible: AtomicBool::new(false), qa_asr: Mutex::new(None), qa_recorder: Mutex::new(None), qa_stream_cancelled: Arc::new(AtomicBool::new(false)), @@ -702,6 +708,8 @@ impl Coordinator { return; } let (tx, rx) = mpsc::channel::(); + #[cfg(target_os = "linux")] + let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone()); match HotkeyMonitor::start(binding, tx) { Ok(monitor) => { let adapter = monitor.kind(); @@ -717,6 +725,12 @@ impl Coordinator { .name("openless-hotkey-bridge".into()) .spawn(move || hotkey_bridge_loop(inner_clone, rx)) .ok(); + // Wayland: 启动 fcitx5 插件信号监听作为热键源(X11 走 rdev 避免双发)。 + #[cfg(target_os = "linux")] + if crate::hotkey::is_wayland_session() { + crate::linux_fcitx::start_dictation_signal_listener(fcitx_tx); + crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); + } } Err(e) => { *self.inner.hotkey_status.lock() = HotkeyStatus { @@ -938,7 +952,6 @@ fn hotkey_supervisor_loop(inner: Arc) { message: Some(format!("正在安装全局快捷键监听(第 {} 次)", attempts + 1)), last_error: None, }; - let (tx, rx) = mpsc::channel::(); let trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey) .unwrap_or(crate::types::HotkeyTrigger::Custom); let binding = crate::types::HotkeyBinding { @@ -946,6 +959,9 @@ fn hotkey_supervisor_loop(inner: Arc) { mode: prefs.hotkey.mode, keys: None, }; + let (tx, rx) = mpsc::channel::(); + #[cfg(target_os = "linux")] + let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone()); match HotkeyMonitor::start(binding, tx) { Ok(monitor) => { let adapter = monitor.kind(); @@ -969,6 +985,12 @@ fn hotkey_supervisor_loop(inner: Arc) { .name("openless-hotkey-bridge".into()) .spawn(move || hotkey_bridge_loop(inner_clone, rx)) .ok(); + // Wayland: 启动 fcitx5 插件信号监听作为热键源(X11 走 rdev 避免双发)。 + #[cfg(target_os = "linux")] + if crate::hotkey::is_wayland_session() { + crate::linux_fcitx::start_dictation_signal_listener(fcitx_tx); + crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); + } return; } Err(e) => { @@ -4037,7 +4059,15 @@ fn show_capsule_window_no_activate( // `window.show()` 直接显示,再用 restore_main_window_key_if_active 把焦点还给 // 主窗口。这是 1.2.11 的实现 — 单独走 orderFrontRegardless 会让胶囊在 webview // 未完整初始化时偶发不可见。 -#[cfg(not(target_os = "windows"))] +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn show_capsule_window_no_activate( + _app: &AppHandle, + _window: &tauri::WebviewWindow, +) -> bool { + false +} + +#[cfg(target_os = "macos")] fn show_capsule_window_no_activate( _app: &AppHandle, _window: &tauri::WebviewWindow, @@ -4129,6 +4159,17 @@ fn emit_capsule( maybe_position_capsule_bottom_center(&inner_for_main, &window, translation); if show_capsule && visible { if !show_capsule_window_no_activate(&app_for_main, &window) { + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + // Linux: 仅首次 Idle→可见时 show 窗口,避免每次 emit 都抢焦点。 + let was_visible = inner_for_main + .capsule_window_visible + .swap(true, std::sync::atomic::Ordering::SeqCst); + if !was_visible { + let _ = window.show(); + } + } + #[cfg(any(target_os = "macos", target_os = "windows"))] let _ = window.show(); } // macOS/Windows 优先走 no-activate show,避免录音胶囊抢走主窗口点击焦点。 @@ -4136,6 +4177,10 @@ fn emit_capsule( #[cfg(target_os = "macos")] crate::restore_main_window_key_if_active(&app_for_main); } else { + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + inner_for_main + .capsule_window_visible + .store(false, std::sync::atomic::Ordering::SeqCst); hide_capsule_window_if_present(); let _ = window.hide(); } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 0b74a106..4efeb421 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -329,23 +329,21 @@ fn streaming_insert_eligible( translation_active: bool, mode: PolishMode, raw_uses_llm: bool, - wayland_session: bool, ) -> bool { streaming_insert_enabled && !translation_active && (mode != PolishMode::Raw || raw_uses_llm) - && !wayland_session } -fn wayland_done_message(status: InsertStatus, polish_failed: bool) -> Option { +fn fcitx_fallback_done_message(status: InsertStatus, polish_failed: bool) -> Option { match status { InsertStatus::Inserted | InsertStatus::PasteSent => None, InsertStatus::CopiedFallback => Some(if polish_failed { - "Wayland 未启用自动输入,已复制原文到剪贴板,请手动粘贴".to_string() + "未检测到 fcitx5,已复制原文到剪贴板,请手动粘贴".to_string() } else { - "Wayland 未启用自动输入,已复制到剪贴板,请手动粘贴".to_string() + "未检测到 fcitx5,已复制到剪贴板,请手动粘贴".to_string() }), - InsertStatus::Failed => Some("Wayland 未启用自动输入,剪贴板写入失败".to_string()), + InsertStatus::Failed => Some("fcitx5 不可用,剪贴板写入失败".to_string()), } } @@ -1416,7 +1414,6 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { translation_active, mode, raw_uses_llm, - wayland_session, ); log::info!( "[coord] polish dispatch: translation={translation_active} mode={mode:?} wayland_session={wayland_session} streaming_eligible={streaming_eligible}" @@ -1524,23 +1521,40 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { ); InsertStatus::Inserted } else if wayland_session { - log::info!( - "[coord] Wayland session detected; skipping synthetic paste and attempting copy-only fallback ({} chars)", - polished.chars().count() - ); - let status = inner.inserter.copy_fallback(&polished); - match status { - InsertStatus::CopiedFallback => { - log::info!("[coord] Wayland copy-only fallback succeeded") - } - InsertStatus::Failed => { - log::error!("[coord] Wayland copy-only fallback failed: clipboard write failed") + // Wayland: 优先用 fcitx5 插件直写(支持中文),降级到剪贴板拷贝。 + #[cfg(target_os = "linux")] + { + if crate::linux_fcitx::commit_text(&polished).is_ok() { + log::info!( + "[coord] Wayland fcitx5 commit succeeded ({} chars)", + polished.chars().count() + ); + InsertStatus::Inserted + } else { + log::info!( + "[coord] Wayland fcitx5 unavailable; attempting copy-only fallback ({} chars)", + polished.chars().count() + ); + let status = inner.inserter.copy_fallback(&polished); + match status { + InsertStatus::CopiedFallback => { + log::info!("[coord] Wayland copy-only fallback succeeded") + } + InsertStatus::Failed => log::error!( + "[coord] Wayland copy-only fallback failed: clipboard write failed" + ), + other => log::warn!( + "[coord] Wayland copy-only fallback returned unexpected status: {other:?}" + ), + } + status } - other => log::warn!( - "[coord] Wayland copy-only fallback returned unexpected status: {other:?}" - ), } - status + #[cfg(not(target_os = "linux"))] + { + let status = inner.inserter.copy_fallback(&polished); + status + } } else if focus_ready_for_paste { #[cfg(target_os = "windows")] { @@ -1637,7 +1651,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { let done_message = if tsf_required_insert_failed { Some("TSF 未上屏,已禁止非 TSF 兜底".to_string()) } else if wayland_session { - wayland_done_message(status, polish_error.is_some()) + fcitx_fallback_done_message(status, polish_error.is_some()) } else { default_done_message(status, polish_error.is_some()) }; @@ -1726,8 +1740,8 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> #[cfg(test)] mod tests { use super::{ - append_typed_prefix, default_done_message, dictation_error_code, finalize_polished_text, - streaming_insert_eligible, wayland_done_message, + append_typed_prefix, default_done_message, dictation_error_code, + fcitx_fallback_done_message, finalize_polished_text, streaming_insert_eligible, }; use crate::types::{ChineseScriptPreference, CorrectionRule, InsertStatus, PolishMode}; @@ -1814,13 +1828,12 @@ mod tests { } #[test] - fn wayland_disables_streaming_insert_even_when_pref_enabled() { - assert!(!streaming_insert_eligible( + fn streaming_insert_no_longer_blocked_by_wayland() { + assert!(streaming_insert_eligible( true, false, PolishMode::Light, false, - true )); } @@ -1831,23 +1844,22 @@ mod tests { false, PolishMode::Light, false, - false )); } #[test] - fn wayland_done_message_tells_user_manual_paste_is_required() { + fn fcitx_fallback_done_message_tells_user_manual_paste_is_required() { assert_eq!( - wayland_done_message(InsertStatus::CopiedFallback, false), - Some("Wayland 未启用自动输入,已复制到剪贴板,请手动粘贴".to_string()) + fcitx_fallback_done_message(InsertStatus::CopiedFallback, false), + Some("未检测到 fcitx5,已复制到剪贴板,请手动粘贴".to_string()) ); assert_eq!( - wayland_done_message(InsertStatus::CopiedFallback, true), - Some("Wayland 未启用自动输入,已复制原文到剪贴板,请手动粘贴".to_string()) + fcitx_fallback_done_message(InsertStatus::CopiedFallback, true), + Some("未检测到 fcitx5,已复制原文到剪贴板,请手动粘贴".to_string()) ); assert_eq!( - wayland_done_message(InsertStatus::Failed, false), - Some("Wayland 未启用自动输入,剪贴板写入失败".to_string()) + fcitx_fallback_done_message(InsertStatus::Failed, false), + Some("fcitx5 不可用,剪贴板写入失败".to_string()) ); } diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index b48ece86..755330cc 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -50,6 +50,21 @@ impl TextInserter { if text.is_empty() { return InsertStatus::CopiedFallback; } + // Linux: 使用 fcitx5 CommitText 直写(支持中文、兼容 Wayland/X11)。 + // 失败时仅复制到剪贴板,不走 enigo XTest(Wayland 不可用)。 + #[cfg(target_os = "linux")] + { + match crate::linux_fcitx::commit_text(text) { + Ok(()) => return InsertStatus::Inserted, + Err(e) => { + log::warn!("[insertion] fcitx commit_text failed: {e}, fallback to clipboard only"); + if copy_to_clipboard(text) { + return InsertStatus::CopiedFallback; + } + return InsertStatus::Failed; + } + } + } insert_with_clipboard_restore(text, restore_clipboard_after_paste, paste_shortcut) } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 94bb9bd4..0d7f2b85 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -21,6 +21,7 @@ mod correction; mod global_hotkey_runtime; mod hotkey; mod insertion; +mod linux_fcitx; mod llm_gemini; mod permissions; mod persistence; diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs new file mode 100644 index 00000000..4945d2bf --- /dev/null +++ b/openless-all/app/src-tauri/src/linux_fcitx.rs @@ -0,0 +1,176 @@ +//! Linux fcitx5 插件 DBus 客户端。 +//! +//! 封装对 `org.fcitx.Fcitx.OpenLess1` 接口的调用, +//! 提供文字提交(替代 enigo XTest)和热键设置功能。 +//! +//! 所有函数会静默返回 `None` 如果 fcitx5 / 插件不可用, +//! 调用方应当降级到原有方案(clipboard / enigo)。 + +use std::time::Duration; + +use dbus::blocking::BlockingSender; + +const DEST: &str = "org.fcitx.Fcitx5"; +const PATH: &str = "/openless"; +const IFACE: &str = "org.fcitx.Fcitx.OpenLess1"; +const TIMEOUT: Duration = Duration::from_secs(3); + +/// 通过 fcitx5 插件向当前焦点输入上下文提交文字。 +/// +/// 返回 `Ok(())` 表示文字已提交,`Err` 表示调用失败(插件未加载 / DBus 不通等)。 +pub fn commit_text(text: &str) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "CommitText") + .map_err(|e| format!("build msg: {e}"))? + .append1(text); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("CommitText: {e}"))?; + Ok(()) +} + +/// 通过 fcitx5 插件设置听写触发快捷键。 +/// +/// `keys` 为 Key::parse 格式的字符串数组,例如 `["Control+space"]`。 +pub fn set_hotkey(keys: &[&str]) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let list: Vec = keys.iter().map(|s| s.to_string()).collect(); + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetHotkey") + .map_err(|e| format!("build msg: {e}"))? + .append1(list); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("SetHotkey: {e}"))?; + Ok(()) +} + +/// 通过 fcitx5 插件直接设置 sym + states 作为触发键。 +pub fn set_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetHotkeyRaw") + .map_err(|e| format!("build msg: {e}"))? + .append2(sym, states); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("SetHotkeyRaw: {e}"))?; + Ok(()) +} + +/// X11 keysym 值(用于 SetHotkeyRaw,绕过 Key::parse 的修饰键限制)。 +const KEYSYM_CONTROL_R: u32 = 0xffe4; +const KEYSYM_CONTROL_L: u32 = 0xffe3; +const KEYSYM_ALT_R: u32 = 0xffea; +const KEYSYM_ALT_L: u32 = 0xffe9; +const KEYSYM_SUPER_R: u32 = 0xffec; +const KEYSYM_SUPER_L: u32 = 0xffeb; + +/// 将 OpenLess 的热键绑定同步到 fcitx5 插件。 +/// +/// 把 `HotkeyTrigger`(如 RightControl)转换为 fcitx5 keysym, +/// 通过 `SetHotkeyRaw` 配置插件(绕过 fcitx5 Key 类对纯修饰键的限制)。 +pub fn sync_binding_to_plugin(binding: &crate::types::HotkeyBinding) { + if binding.trigger == crate::types::HotkeyTrigger::Custom { + return; + } + let (sym, name) = match binding.trigger { + crate::types::HotkeyTrigger::RightControl => (KEYSYM_CONTROL_R, "Control_R"), + crate::types::HotkeyTrigger::LeftControl => (KEYSYM_CONTROL_L, "Control_L"), + crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => { + (KEYSYM_ALT_R, "Alt_R") + } + crate::types::HotkeyTrigger::LeftOption => (KEYSYM_ALT_L, "Alt_L"), + crate::types::HotkeyTrigger::RightCommand => (KEYSYM_SUPER_R, "Super_R"), + crate::types::HotkeyTrigger::Fn => (KEYSYM_SUPER_L, "Super_L"), + crate::types::HotkeyTrigger::Custom => unreachable!(), + }; + match set_hotkey_raw(sym, 0) { + Ok(()) => log::info!("[fcitx] Synced hotkey {name} (sym={sym}) to plugin via SetHotkeyRaw"), + Err(e) => log::warn!("[fcitx] Failed to sync hotkey to plugin: {e}"), + } +} + +/// 快速检查 fcitx5 OpenLess 插件是否可用(DBus 对象存在)。 +pub fn available() -> bool { + let conn = match dbus::blocking::Connection::new_session() { + Ok(c) => c, + Err(_) => return false, + }; + let msg = match dbus::Message::new_method_call(DEST, PATH, "org.freedesktop.DBus.Peer", "Ping") + { + Ok(m) => m, + Err(_) => return false, + }; + conn.send_with_reply_and_block(msg, TIMEOUT).is_ok() +} + +/// 启动 fcitx5 DictationKeyEvent 信号监听线程。 +/// +/// 当 fcitx5 OpenLess 插件检测到配置的听写热键被按下或松开时, +/// 发出 `DictationKeyEvent(uub)` DBus 信号(sym, states, isPress)。 +/// 本函数将此信号转发为 `HotkeyEvent::Pressed` / `Released` 到协调器事件通道。 +/// +/// 后台线程在 `tx` 全部 drop(协调器关闭)或 DBus 连接断开时自动退出。 +/// +/// # 调用时机 +/// +/// 仅在 Wayland session 下调用(X11 走 rdev 避免双发)。 +#[cfg(target_os = "linux")] +pub fn start_dictation_signal_listener( + tx: std::sync::mpsc::Sender, +) { + use std::time::Duration; + + std::thread::Builder::new() + .name("openless-fcitx-signal".into()) + .spawn(move || { + let conn = match dbus::blocking::SyncConnection::new_session() { + Ok(c) => c, + Err(e) => { + log::warn!("[fcitx-hotkey] DBus session failed: {e}"); + return; + } + }; + + let rule = match dbus::message::MatchRule::parse( + "type='signal',\ + interface='org.fcitx.Fcitx.OpenLess1',\ + member='DictationKeyEvent'", + ) { + Ok(r) => r, + Err(e) => { + log::warn!("[fcitx-hotkey] Invalid match rule: {e}"); + return; + } + }; + + // 信号参数: (sym: u32, states: u32, is_press: bool) + if let Err(e) = conn.add_match(rule, move |args: (u32, u32, bool), _conn, _msg| { + let (_sym, _states, is_press) = args; + log::debug!( + "[fcitx-hotkey] DictationKeyEvent: sym={}, states={}, isPress={}", + _sym, + _states, + is_press, + ); + let event = if is_press { + crate::hotkey::HotkeyEvent::Pressed + } else { + crate::hotkey::HotkeyEvent::Released + }; + let _ = tx.send(event); + true // 保持匹配活跃 + }) { + log::warn!("[fcitx-hotkey] Failed to add match: {e}"); + return; + } + + log::info!("[fcitx-hotkey] Listening for DictationKeyEvent signals"); + loop { + if let Err(e) = conn.process(Duration::from_millis(500)) { + log::warn!("[fcitx-hotkey] DBus process error: {e}"); + break; + } + } + }) + .ok(); +} diff --git a/openless-all/app/src-tauri/src/unicode_keystroke.rs b/openless-all/app/src-tauri/src/unicode_keystroke.rs index fd7d78c0..f49dcee8 100644 --- a/openless-all/app/src-tauri/src/unicode_keystroke.rs +++ b/openless-all/app/src-tauri/src/unicode_keystroke.rs @@ -412,39 +412,24 @@ mod windows_impl { #[cfg(target_os = "linux")] mod linux_impl { use super::{TisError, TypeError}; - use enigo::{Enigo, Keyboard, Settings}; + #[allow(unused_imports)] use tauri::{AppHandle, Runtime}; pub struct PreviousInputSource; - /// 用 enigo 逐字符发出 chunk。X11 上走 XTest 稳定;Wayland 上看 compositor 是否 - /// 给 libei 权限,stock GNOME-Wayland 通常拒绝 —— 失败时尽量返回已成功字符数, - /// 让调用方的 history / clipboard 与实际落屏内容一致。 - /// - /// 不处理 fcitx / ibus 输入法切换 —— Linux 输入法栈与 X11 合成事件的交互非常 - /// 碎片化,v1 实验阶段直接交给用户保证当前输入源是英文键盘。 + /// 优先通过 fcitx5 插件一次性提交整段文字(支持中文、兼容 Wayland/X11)。 pub fn type_unicode_chunk(text: &str) -> Result { if text.is_empty() { return Ok(0); } - let mut enigo = - Enigo::new(&Settings::default()).map_err(|e| TypeError::EnigoInit(e.to_string()))?; - let mut typed_chars = 0; - for ch in text.chars() { - if let Err(e) = enigo.text(&ch.to_string()) { - let source = TypeError::EnigoText(e.to_string()); - return if typed_chars == 0 { - Err(source) - } else { - Err(TypeError::Partial { - typed_chars, - source: Box::new(source), - }) - }; - } - typed_chars += 1; + // fcitx5 插件能处理全部文字,且是 native 方式(不走键盘合成), + // 所以整个 chunk 一次性 commit 即可,返回全部字符数。 + // 失败时不降级——enigo XTest 在 Wayland 不可用。 + if crate::linux_fcitx::commit_text(text).is_ok() { + Ok(text.chars().count()) + } else { + Err(TypeError::EnigoText("commit_text failed on Wayland, try clipboard fallback".into())) } - Ok(typed_chars) } pub async fn switch_to_ascii( diff --git a/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt b/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt new file mode 100644 index 00000000..ac219c3e --- /dev/null +++ b/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.12) +project(openless-fcitx5-plugin VERSION 1.0.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(Fcitx5Core REQUIRED) +find_package(Fcitx5Utils REQUIRED) +find_package(Fcitx5Module REQUIRED COMPONENTS DBus) + +# FCITX_INSTALL_*DIR comes from Fcitx5Utils +message(STATUS "FCITX_INSTALL_LIBDIR: ${FCITX_INSTALL_LIBDIR}") +message(STATUS "FCITX_INSTALL_PKGDATADIR: ${FCITX_INSTALL_PKGDATADIR}") +message(STATUS "FCITX_INSTALL_ADDONDIR: ${FCITX_INSTALL_ADDONDIR}") + +add_library(openless MODULE openless.cpp) +target_link_libraries(openless PRIVATE + Fcitx5::Core + Fcitx5::Utils) + +# Locate fcitx5 module headers (e.g. fcitx-module/dbus/dbus_public.h) +find_path(FCITX5_MODULE_INCLUDE_DIR + NAMES "fcitx-module/dbus/dbus_public.h" + HINTS "/usr/include/Fcitx5/Module" + PATH_SUFFIXES "include/Fcitx5/Module" +) +if(FCITX5_MODULE_INCLUDE_DIR) + target_include_directories(openless PRIVATE "${FCITX5_MODULE_INCLUDE_DIR}") + message(STATUS "FCITX5_MODULE_INCLUDE_DIR: ${FCITX5_MODULE_INCLUDE_DIR}") +else() + message(FATAL_ERROR "Cannot find fcitx5 module headers (fcitx-module/dbus/dbus_public.h)") +endif() + +# Install the plugin .so to fcitx5 addon dir +install(TARGETS openless + LIBRARY DESTINATION "${FCITX_INSTALL_ADDONDIR}") + +# Generate and install addon config +configure_file( + openless.conf.in + openless.conf + @ONLY) +install( + FILES "${CMAKE_CURRENT_BINARY_DIR}/openless.conf" + DESTINATION "${FCITX_INSTALL_PKGDATADIR}/addon") diff --git a/openless-all/scripts/linux-fcitx5-plugin/build.sh b/openless-all/scripts/linux-fcitx5-plugin/build.sh new file mode 100755 index 00000000..8eb7f778 --- /dev/null +++ b/openless-all/scripts/linux-fcitx5-plugin/build.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# Build and optionally install the fcitx5 OpenLess plugin. +# +# Usage: +# ./build.sh # build only, .so in build/libopenless.so +# ./build.sh install # build + install to system fcitx5 dirs (requires sudo) +# +set -euo pipefail + +cd "$(dirname "$0")" + +BUILD_DIR="${BUILD_DIR:-build}" + +echo "==> Configuring..." +cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release + +echo "==> Building..." +cmake --build "$BUILD_DIR" --parallel + +echo "==> Plugin built: ${BUILD_DIR}/libopenless.so" + +if [ "${1:-}" = "install" ]; then + echo "==> Installing (requires sudo)..." + sudo cmake --install "$BUILD_DIR" + echo "==> Done. Restart fcitx5 to pick up the new plugin." +else + echo "==> Use '$0 install' to install system-wide." +fi diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.conf.in b/openless-all/scripts/linux-fcitx5-plugin/openless.conf.in new file mode 100644 index 00000000..dc54a671 --- /dev/null +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.conf.in @@ -0,0 +1,15 @@ +[Addon] +Name=OpenLess +Name[zh_CN]=OpenLess 听写辅助 +Comment=OpenLess dictation commit helper — exposes CommitText DBus method and dictation hotkey +Comment[zh_CN]=供 OpenLess 听写提交文字的 DBus 接口及快捷键监听 +Category=Module +Type=SharedLibrary +Library=libopenless +Version=1.0.0 +OnDemand=False +Configurable=False + +[Addon/Dependencies] +0=core:@Fcitx5Core_VERSION@ +1=dbus diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp new file mode 100644 index 00000000..43554758 --- /dev/null +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -0,0 +1,264 @@ +/* + * SPDX-FileCopyrightText: 2025 OpenLess Contributors + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * + * fcitx5 插件 — 供 OpenLess 听写文字提交 + 快捷键监听。 + * + * DBus 接口: org.fcitx.Fcitx.OpenLess1 (对象路径 /openless) + * 方法: + * CommitText(s: text) — 将文字提交到当前焦点输入上下文 + * SetHotkey(as: keys) — 设置听写触发快捷键 (Key::parse 格式) + * SetHotkeyRaw(uu: sym, states) — 直接设 sym+states (不走 parse) + * 信号: + * DictationKeyEvent(uu: sym, states) — 热键被按下 + * + * 后续: 当需要 IBus 引擎兼容时 (GNOME),另行实现 org.freedesktop.IBus.Engine。 + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +FCITX_DEFINE_LOG_CATEGORY(openless, "openless"); + +namespace fcitx { + +FCITX_CONFIGURATION(OpenLessConfig, + KeyListOption triggerKey{this, + "TriggerKey", + _("Dictation trigger key"), + {}, + KeyListConstrain()}; +); + +class OpenLess final : public AddonInstance, + public dbus::ObjectVTable { +public: + OpenLess(Instance *instance) + : instance_(instance), + triggerRawSym_(0), + triggerRawStates_(0), + savedIc_(nullptr) { + + // 1. 读取配置 + reloadConfig(); + + // 2. 注册 DBus 接口 + auto *dbusMod = instance_->addonManager().addon("dbus", true); + if (dbusMod) { + auto *bus = dbusMod->call(); + if (bus) { + bus->addObjectVTable( + "/openless", + "org.fcitx.Fcitx.OpenLess1", + *this); + FCITX_LOGC(openless, Info) + << "DBus interface registered at /openless"; + } else { + FCITX_LOGC(openless, Warn) + << "Failed to get DBus bus"; + } + } else { + FCITX_LOGC(openless, Warn) + << "DBus module not available"; + } + + // 3. 注册快捷键事件监听 + eventHandlers_.push_back( + instance_->watchEvent( + EventType::InputContextKeyEvent, + EventWatcherPhase::PreInputMethod, + [this](Event &event) { + auto &keyEvent = static_cast(event); + // 保存当前输入上下文:快捷键按下时用户在目标 app 中, + // 此后胶囊窗口可能抢走焦点,但 commitText 仍能用此 IC 提交文字。 + if (!keyEvent.isRelease()) { + auto *ic = keyEvent.inputContext(); + if (ic) { + savedIc_ = ic; + } + } + // 先检查 raw sym/states(修饰键专用路径,绕过 Key::parse 限制) + if ((triggerRawSym_ != 0 && + keyEvent.key().sym() == static_cast(triggerRawSym_) && + keyEvent.key().states() == static_cast(triggerRawStates_)) || + (triggerRawSym_ == 0 && [&]() { + for (const auto &hk : triggerKeyList_) { + if (keyEvent.key().sym() == hk.sym() && + keyEvent.key().states() == hk.states()) + return true; + } + return false; + }())) { + auto sym = triggerRawSym_ != 0 + ? triggerRawSym_ + : static_cast(triggerKeyList_[0].sym()); + auto states = triggerRawStates_ != 0 + ? triggerRawStates_ + : static_cast(triggerKeyList_[0].states()); + bool isPress = !keyEvent.isRelease(); + FCITX_LOGC(openless, Debug) + << "Dictation hotkey: sym=" + << sym << " states=" << states + << " isPress=" << isPress; + dictationKeyEvent(sym, states, isPress); + keyEvent.filterAndAccept(); + return; + } + })); + + FCITX_LOGC(openless, Info) << "OpenLess plugin loaded"; + } + + ~OpenLess() = default; + + // ---- DBus 方法 ---- + // 返回 void 而非 std::tuple<>,以匹配 FCITX_OBJECT_VTABLE_METHOD 的 RET("") + + void commitText(const std::string &text) { + // 优先使用快捷键按下时保存的输入上下文(savedIc_), + // 此时用户在目标 app 中,此后胶囊窗口抢焦点不影响提交。 + // 若 savedIc_ 为空则兜底用 foreachFocused。 + auto *ic = savedIc_; + if (!ic) { + FCITX_LOGC(openless, Warn) + << "CommitText: savedIc_ is null, trying foreachFocused"; + auto &mgr = instance_->inputContextManager(); + mgr.foreachFocused([&](InputContext *focusedIc) { + ic = focusedIc; + return false; + }); + } + if (!ic) { + FCITX_LOGC(openless, Warn) + << "CommitText: no input context available"; + throw std::runtime_error("no focused input context"); + } + FCITX_LOGC(openless, Debug) << "CommitText: " << text; + ic->commitString(text); + } + + void setHotkey(const std::vector &keys) { + KeyList keyList; + for (const auto &s : keys) { + Key key(s); + if (key.isValid()) { + keyList.push_back(key); + } else { + FCITX_LOGC(openless, Warn) + << "SetHotkey: invalid key '" << s << "'"; + } + } + config_.triggerKey.setValue(keyList); + // KeyList 路径激活时清空 raw 路径,避免优先级冲突 + triggerRawSym_ = 0; + triggerRawStates_ = 0; + safeSaveAsIni(config_, configFile()); + rebuildTriggerKeys(); + } + + void setHotkeyRaw(uint32_t sym, uint32_t states) { + triggerRawSym_ = sym; + triggerRawStates_ = states; + // 同时尝试维护 KeyList(如果 sym 可转为有效 key) + Key key(static_cast(sym), + static_cast(states)); + if (key.isValid()) { + KeyList keys = {key}; + config_.triggerKey.setValue(keys); + } else { + // 修饰键无法用 KeyList 表达,清空 KeyList 避免误匹配 + config_.triggerKey.setValue(KeyList{}); + } + // 合并写入 config 和 raw sym/states + RawConfig raw; + raw.setValueByPath("TriggerRawSym", std::to_string(sym)); + raw.setValueByPath("TriggerRawStates", std::to_string(states)); + config_.save(raw); + safeSaveAsIni(raw, configFile()); + rebuildTriggerKeys(); + } + + FCITX_OBJECT_VTABLE_METHOD(commitText, "CommitText", "s", ""); + FCITX_OBJECT_VTABLE_METHOD(setHotkey, "SetHotkey", "as", ""); + FCITX_OBJECT_VTABLE_METHOD(setHotkeyRaw, "SetHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub"); + + Instance *instance() { return instance_; } + + void reloadConfig() override { + readAsIni(config_, configFile()); + // 加载原始 sym/states(由 SetHotkeyRaw 写入的持久化键值) + RawConfig raw; + readAsIni(raw, configFile()); + { + auto *v = raw.valueByPath("TriggerRawSym"); + triggerRawSym_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + { + auto *v = raw.valueByPath("TriggerRawStates"); + triggerRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + rebuildTriggerKeys(); + } + + const Configuration *getConfig() const override { + return &config_; + } + + void setConfig(const RawConfig &rawConfig) override { + config_.load(rawConfig, true); + safeSaveAsIni(config_, configFile()); + rebuildTriggerKeys(); + } + +private: + static constexpr const char *configFile() { + return "conf/openless.conf"; + } + + void rebuildTriggerKeys() { + triggerKeyList_ = config_.triggerKey.value(); + } + + Instance *instance_; + OpenLessConfig config_; + KeyList triggerKeyList_; + uint32_t triggerRawSym_; + uint32_t triggerRawStates_; + /// 快捷键按下时保存的输入上下文指针,用于 commitText 在失焦后仍能提交文字。 + /// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。 + InputContext *savedIc_; + std::vector>> + eventHandlers_; +}; + +class OpenLessFactory : public AddonFactory { +public: + AddonInstance *create(AddonManager *manager) override { + return new OpenLess(manager->instance()); + } +}; + +} // namespace fcitx + +FCITX_ADDON_FACTORY(fcitx::OpenLessFactory); From a1b7a2a1f97dbe09a74036c86239bcd776867cf8 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Fri, 15 May 2026 23:55:36 +0800 Subject: [PATCH 02/15] fix(linux): cfg-gate linux_fcitx module + skip capsule window on Wayland MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add #[cfg(target_os = "linux")] to mod linux_fcitx to fix macOS/Windows CI compilation (dbus crate is Linux-only). - Wayland: skip capsule window show/hide entirely in emit_capsule so the target app never loses keyboard focus. Text is committed via fcitx5 plugin commit_string — no window means the compositor forwards the commit to the right app. - X11 keeps existing behavior (show capsule window once per session). Co-Authored-By: Claude Opus 4.7 --- openless-all/app/src-tauri/src/coordinator.rs | 11 ++++++++++- openless-all/app/src-tauri/src/lib.rs | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 44855167..c710cced 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -4151,6 +4151,15 @@ fn emit_capsule( return; }; let show_capsule = inner_for_main.prefs.get().show_capsule; + + // Wayland: 不操作胶囊窗口(不 show/hide,不 reposition), + // 避免抢走目标 app 键盘焦点。文字通过 fcitx5 插件直接 commit, + // 用户始终在目标 app 中。 + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + if crate::hotkey::is_wayland_session() { + return; + } + // 三平台统一:Done / Cancelled / Error 状态保留 ~1.5s toast // (schedule_capsule_idle 之后会回 Idle 隐藏)。 // Windows 上 linger 的真实问题(截图选中 / 死区 / 拖拽卡顿)由 #140 加的 @@ -4161,7 +4170,7 @@ fn emit_capsule( if !show_capsule_window_no_activate(&app_for_main, &window) { #[cfg(not(any(target_os = "macos", target_os = "windows")))] { - // Linux: 仅首次 Idle→可见时 show 窗口,避免每次 emit 都抢焦点。 + // Linux (X11): 仅首次 Idle→可见时 show 窗口,避免每次 emit 都抢焦点。 let was_visible = inner_for_main .capsule_window_visible .swap(true, std::sync::atomic::Ordering::SeqCst); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 0d7f2b85..6e9d352f 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -21,6 +21,7 @@ mod correction; mod global_hotkey_runtime; mod hotkey; mod insertion; +#[cfg(target_os = "linux")] mod linux_fcitx; mod llm_gemini; mod permissions; From 4f1e457cfa3a5dd2b26d02ed4301cae0ee85d710 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sat, 16 May 2026 00:00:22 +0800 Subject: [PATCH 03/15] feat(ci): build fcitx5 plugin into Linux deb/rpm packages - Add "Build fcitx5 plugin" step to release-tauri.yml: compiles the C++ plugin via cmake, copies .so + .conf to src-tauri/linux-fcitx5-plugin/ for the Tauri bundler. - Override tauri build --config to include deb.files / rpm.files that place the plugin at /usr/lib/fcitx5/ so it's auto-detected after install. - Add fcitx5, fcitx5-module-dbus as deb/rpm dependencies. - Local builds unaffected (config is CI-only via --config flag). Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release-tauri.yml | 52 +++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 05079cf4..18ce840b 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -339,8 +339,22 @@ jobs: throw "MSI installer smoke failed with exit $LASTEXITCODE" } - # ── Linux:产 deb / rpm / AppImage ── - # bundle.resources 里的 Windows TSF DLL 占位对 Linux 包没意义,用空 map 覆盖跳过。 + # ── Linux:先编译 fcitx5 插件,再产 deb / rpm / AppImage ── + - name: Build fcitx5 plugin + if: matrix.platform == 'ubuntu-22.04' + shell: bash + working-directory: 'openless-all/scripts/linux-fcitx5-plugin' + run: | + sudo apt-get install -y cmake fcitx5-dev + mkdir -p build && cd build + cmake .. + make + # 把插件 .so + .conf 复制到 src-tauri/linux-fcitx5-plugin/ 下面, + # 供 tauri deb/rpm bundler 的 files 配置使用。 + mkdir -p "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin" + cp libopenless.so "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin/" + cp openless.conf "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin/" + - name: Build (Linux) if: matrix.platform == 'ubuntu-22.04' shell: bash @@ -349,13 +363,39 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | + # 构建带 fcitx5 插件的 deb/rpm/AppImage。 + # 插件 .so + .conf 由上一步 Build fcitx5 plugin 生成并复制到 + # src-tauri/linux-fcitx5-plugin/ 下。 + cat > /tmp/tauri-linux-config.json << 'CONFIG_EOF' + { + "bundle": { + "resources": {}, + "linux": { + "deb": { + "depends": ["fcitx5", "fcitx5-module-dbus", "libdbus-1-3"], + "files": { + "/usr/lib/fcitx5/libopenless.so": "linux-fcitx5-plugin/libopenless.so", + "/usr/share/fcitx5/addon/openless.conf": "linux-fcitx5-plugin/openless.conf" + } + }, + "rpm": { + "depends": ["fcitx5", "fcitx5-module-dbus"], + "files": { + "/usr/lib64/fcitx5/libopenless.so": "linux-fcitx5-plugin/libopenless.so", + "/usr/share/fcitx5/addon/openless.conf": "linux-fcitx5-plugin/openless.conf" + } + } + } + } + } + CONFIG_EOF if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then - npm run tauri -- build --bundles deb,rpm,appimage \ - --config '{"bundle":{"resources":{},"createUpdaterArtifacts":true}}' + jq '.bundle.createUpdaterArtifacts = true' /tmp/tauri-linux-config.json > /tmp/tauri-linux-config-signed.json + CONFIG_FILE=/tmp/tauri-linux-config-signed.json else - npm run tauri -- build --bundles deb,rpm,appimage \ - --config '{"bundle":{"resources":{}}}' + CONFIG_FILE=/tmp/tauri-linux-config.json fi + npm run tauri -- build --bundles deb,rpm,appimage --config "$CONFIG_FILE" - name: Disambiguate macOS updater bundle filename if: startsWith(matrix.platform, 'macos') && env.TAURI_SIGNING_PRIVATE_KEY != '' From dfced4ff1d0f0dbe2cf689d6b56693512c3c4a38 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sat, 16 May 2026 00:08:42 +0800 Subject: [PATCH 04/15] fix(linux): sync fcitx5 plugin hotkey binding on update When ensure_modifier_hotkey_monitor finds an existing monitor and updates the rdev/CGEventTap binding, it must also sync the new binding to the fcitx5 plugin on Wayland. Previously the early return skipped this, leaving plugin and coordinator out of sync after a hotkey change. Co-Authored-By: Claude Opus 4.7 --- openless-all/app/src-tauri/src/coordinator.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c710cced..fd3bb193 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -704,7 +704,15 @@ impl Coordinator { fn ensure_modifier_hotkey_monitor(&self, binding: crate::types::HotkeyBinding) { if let Some(monitor) = self.inner.hotkey.lock().as_ref() { + // 先 clone 再 move,确保 fcitx5 插件也能同步到新热键。 + #[cfg(target_os = "linux")] + let plugin_binding = binding.clone(); monitor.update_binding(binding); + // Wayland: 同步新热键到 fcitx5 插件(rdev 路径已由 update_binding 更新)。 + #[cfg(target_os = "linux")] + if crate::hotkey::is_wayland_session() { + crate::linux_fcitx::sync_binding_to_plugin(&plugin_binding); + } return; } let (tx, rx) = mpsc::channel::(); From 85c2cafcad288051ca672c49feec15c20d0ec4f1 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sat, 16 May 2026 00:15:37 +0800 Subject: [PATCH 05/15] fix(ci): use cmake-determined plugin install path in deb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FCITX_INSTALL_ADDONDIR varies by distro (multiarch Ubuntu → /usr/lib/x86_64-linux-gnu/fcitx5/; Fedora → /usr/lib64/fcitx5/; Arch → /usr/lib/fcitx5/). Extract from cmake cache at build time via GITHUB_ENV instead of hardcoding. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release-tauri.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 18ce840b..dc95cf09 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -347,8 +347,18 @@ jobs: run: | sudo apt-get install -y cmake fcitx5-dev mkdir -p build && cd build - cmake .. + cmake .. 2>&1 make + # 从 cmake 缓存中读出 distro 实际的插件安装路径(multiarch 感知)。 + FCITX_ADDON_DIR=$(cmake -LA . 2>/dev/null \ + | grep "^FCITX_INSTALL_ADDONDIR:" \ + | cut -d= -f2) + FCITX_PKGDATA_DIR=$(cmake -LA . 2>/dev/null \ + | grep "^FCITX_INSTALL_PKGDATADIR:" \ + | cut -d= -f2) + echo "Detected: addon=$FCITX_ADDON_DIR pkgdata=$FCITX_PKGDATA_DIR" + echo "FCITX_ADDON_DIR=$FCITX_ADDON_DIR" >> "$GITHUB_ENV" + echo "FCITX_ADDON_CONF_DIR=${FCITX_PKGDATA_DIR}/addon" >> "$GITHUB_ENV" # 把插件 .so + .conf 复制到 src-tauri/linux-fcitx5-plugin/ 下面, # 供 tauri deb/rpm bundler 的 files 配置使用。 mkdir -p "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin" @@ -366,7 +376,7 @@ jobs: # 构建带 fcitx5 插件的 deb/rpm/AppImage。 # 插件 .so + .conf 由上一步 Build fcitx5 plugin 生成并复制到 # src-tauri/linux-fcitx5-plugin/ 下。 - cat > /tmp/tauri-linux-config.json << 'CONFIG_EOF' + cat > /tmp/tauri-linux-config.json << CONFIG_EOF { "bundle": { "resources": {}, @@ -374,8 +384,8 @@ jobs: "deb": { "depends": ["fcitx5", "fcitx5-module-dbus", "libdbus-1-3"], "files": { - "/usr/lib/fcitx5/libopenless.so": "linux-fcitx5-plugin/libopenless.so", - "/usr/share/fcitx5/addon/openless.conf": "linux-fcitx5-plugin/openless.conf" + "${FCITX_ADDON_DIR}/libopenless.so": "linux-fcitx5-plugin/libopenless.so", + "${FCITX_ADDON_CONF_DIR}/openless.conf": "linux-fcitx5-plugin/openless.conf" } }, "rpm": { From 07132c2087d9f1b6ecca019e54773a701323d355 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sat, 16 May 2026 00:25:42 +0800 Subject: [PATCH 06/15] fix(linux): track fcitx5 InputContext lifetime via destroyed signal Prevents savedIc_ from becoming a dangling pointer by connecting to the InputContext::destroyed signal when the IC is saved. On destruction the pointer is cleared automatically, and commitText falls through to foreachFocused for the current focused IC. Co-Authored-By: Claude Opus 4.7 --- .../scripts/linux-fcitx5-plugin/openless.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp index 43554758..cb8ce8e3 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -91,10 +91,22 @@ class OpenLess final : public AddonInstance, auto &keyEvent = static_cast(event); // 保存当前输入上下文:快捷键按下时用户在目标 app 中, // 此后胶囊窗口可能抢走焦点,但 commitText 仍能用此 IC 提交文字。 + // 同时监听 IC 销毁信号,自动清空指针避免野指针。 if (!keyEvent.isRelease()) { auto *ic = keyEvent.inputContext(); - if (ic) { + if (ic != savedIc_) { savedIc_ = ic; + savedIcDestroyedConnection_.reset(); + if (ic) { + savedIcDestroyedConnection_ = + std::make_unique( + ic->connect( + ic->destroyed, [this]() { + FCITX_LOGC(openless, Debug) + << "savedIc_ destroyed, clearing"; + savedIc_ = nullptr; + })); + } } } // 先检查 raw sym/states(修饰键专用路径,绕过 Key::parse 限制) @@ -247,7 +259,9 @@ class OpenLess final : public AddonInstance, uint32_t triggerRawStates_; /// 快捷键按下时保存的输入上下文指针,用于 commitText 在失焦后仍能提交文字。 /// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。 + /// 通过 savedIcDestroyedConnection_ 监听 IC 销毁信号自动清空,避免野指针。 InputContext *savedIc_; + std::unique_ptr savedIcDestroyedConnection_; std::vector>> eventHandlers_; }; From dbae3b7a5f29d7a53e208566b8a00cf01a9cd70f Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sat, 16 May 2026 01:40:14 +0800 Subject: [PATCH 07/15] fix(linux): correct fcitx5 InputContext::destroyed signal connection syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ic->connect(ic->destroyed, ...) is invalid — InputContext has no two-arg connect overload. Use ic->destroyed.connect(callback) directly, which calls Signal::connect. Also simplified ScopedConnection storage from unique_ptr to direct member (ScopedConnection is move-assignable). Co-Authored-By: Claude Opus 4.7 --- .../scripts/linux-fcitx5-plugin/openless.cpp | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp index cb8ce8e3..3dcbe1f8 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -96,16 +96,14 @@ class OpenLess final : public AddonInstance, auto *ic = keyEvent.inputContext(); if (ic != savedIc_) { savedIc_ = ic; - savedIcDestroyedConnection_.reset(); + savedIcDestroyedConnection_ = ScopedConnection(); if (ic) { savedIcDestroyedConnection_ = - std::make_unique( - ic->connect( - ic->destroyed, [this]() { - FCITX_LOGC(openless, Debug) - << "savedIc_ destroyed, clearing"; - savedIc_ = nullptr; - })); + ic->destroyed.connect([this]() { + FCITX_LOGC(openless, Debug) + << "savedIc_ destroyed, clearing"; + savedIc_ = nullptr; + }); } } } @@ -259,9 +257,10 @@ class OpenLess final : public AddonInstance, uint32_t triggerRawStates_; /// 快捷键按下时保存的输入上下文指针,用于 commitText 在失焦后仍能提交文字。 /// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。 - /// 通过 savedIcDestroyedConnection_ 监听 IC 销毁信号自动清空,避免野指针。 + /// 通过 savedIcDestroyedConnection_(连接到 InputContext::destroyed 信号) + /// 监听 IC 销毁时自动清空指针,避免野指针。 InputContext *savedIc_; - std::unique_ptr savedIcDestroyedConnection_; + ScopedConnection savedIcDestroyedConnection_; std::vector>> eventHandlers_; }; From acfdd394814186d9c25d475b226f4ad823f57ab1 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sat, 16 May 2026 01:47:18 +0800 Subject: [PATCH 08/15] fix(linux): unify on fcitx5 for both Wayland and X11, drop enigo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fcitx5 插件 Wayland/X11 都可使用,不再为 X11 单独维护 enigo XTest。 Linux 统一走 fcitx5 CommitText 直写,插件不可用时降级到剪贴板拷贝。 Co-Authored-By: Claude Opus 4.7 --- openless-all/app/src-tauri/src/insertion.rs | 4 ++-- openless-all/app/src-tauri/src/unicode_keystroke.rs | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 755330cc..ba122ba4 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -50,8 +50,8 @@ impl TextInserter { if text.is_empty() { return InsertStatus::CopiedFallback; } - // Linux: 使用 fcitx5 CommitText 直写(支持中文、兼容 Wayland/X11)。 - // 失败时仅复制到剪贴板,不走 enigo XTest(Wayland 不可用)。 + // Linux: 始终优先使用 fcitx5 CommitText 直写(支持中文、Wayland/X11 均可)。 + // 如果插件未加载,降级到剪贴板拷贝(统一路径,不单独维护 enigo XTest)。 #[cfg(target_os = "linux")] { match crate::linux_fcitx::commit_text(text) { diff --git a/openless-all/app/src-tauri/src/unicode_keystroke.rs b/openless-all/app/src-tauri/src/unicode_keystroke.rs index f49dcee8..95476979 100644 --- a/openless-all/app/src-tauri/src/unicode_keystroke.rs +++ b/openless-all/app/src-tauri/src/unicode_keystroke.rs @@ -417,18 +417,18 @@ mod linux_impl { pub struct PreviousInputSource; - /// 优先通过 fcitx5 插件一次性提交整段文字(支持中文、兼容 Wayland/X11)。 + /// 通过 fcitx5 插件一次性提交整段文字(支持中文、Wayland/X11 均可)。 + /// 如果插件未加载返回 Err,调用方降级到剪贴板拷贝。 pub fn type_unicode_chunk(text: &str) -> Result { if text.is_empty() { return Ok(0); } - // fcitx5 插件能处理全部文字,且是 native 方式(不走键盘合成), - // 所以整个 chunk 一次性 commit 即可,返回全部字符数。 - // 失败时不降级——enigo XTest 在 Wayland 不可用。 if crate::linux_fcitx::commit_text(text).is_ok() { Ok(text.chars().count()) } else { - Err(TypeError::EnigoText("commit_text failed on Wayland, try clipboard fallback".into())) + Err(TypeError::EnigoText( + "fcitx5 plugin unavailable, try clipboard fallback".into(), + )) } } From b349a4dcba5b261f564afd1648cc42d6d1ad50a9 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sat, 16 May 2026 09:01:56 +0800 Subject: [PATCH 09/15] fix(linux): remove dead rdev code and leftover Wayland references Remove 290 lines of unused rdev listener code from the Linux hotkey platform module, drop the rdev dependency from Cargo.toml, and clean up capsule_window_visible init leftover. Update doc comments and frontend i18n to reflect the unified fcitx5-only path. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release-tauri.yml | 3 +- openless-all/app/src-tauri/Cargo.lock | 122 +----- openless-all/app/src-tauri/Cargo.toml | 1 - openless-all/app/src-tauri/src/cli.rs | 5 +- openless-all/app/src-tauri/src/commands.rs | 12 - openless-all/app/src-tauri/src/coordinator.rs | 54 +-- .../src-tauri/src/coordinator/dictation.rs | 124 +----- openless-all/app/src-tauri/src/hotkey.rs | 394 +----------------- openless-all/app/src-tauri/src/insertion.rs | 2 +- openless-all/app/src-tauri/src/lib.rs | 11 +- openless-all/app/src-tauri/src/linux_fcitx.rs | 16 +- openless-all/app/src-tauri/src/selection.rs | 2 +- openless-all/app/src-tauri/src/types.rs | 10 +- .../app/src-tauri/src/unicode_keystroke.rs | 4 +- openless-all/app/src/i18n/en.ts | 22 +- openless-all/app/src/i18n/ja.ts | 22 +- openless-all/app/src/i18n/ko.ts | 22 +- openless-all/app/src/i18n/zh-CN.ts | 22 +- openless-all/app/src/i18n/zh-TW.ts | 22 +- openless-all/app/src/lib/ipc.ts | 6 - openless-all/app/src/lib/types.ts | 2 +- openless-all/app/src/pages/Settings.tsx | 187 +-------- .../src/pages/settings/AdvancedSection.tsx | 2 +- .../src/pages/settings/PermissionsSection.tsx | 2 +- .../scripts/linux-fcitx5-plugin/openless.cpp | 41 +- 25 files changed, 123 insertions(+), 987 deletions(-) diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index dc95cf09..a8f2038c 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -373,7 +373,8 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | - # 构建带 fcitx5 插件的 deb/rpm/AppImage。 + # 构建带 fcitx5 插件的 deb/rpm。AppImage 不含插件(无法安装系统路径), + # 用户需手动安装脚本 scripts/linux-fcitx5-plugin/build.sh 输出的 .so 和 .conf。 # 插件 .so + .conf 由上一步 Build fcitx5 plugin 生成并复制到 # src-tauri/linux-fcitx5-plugin/ 下。 cat > /tmp/tauri-linux-config.json << CONFIG_EOF diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 86f88cf4..705ee466 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -409,12 +409,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - [[package]] name = "block-buffer" version = "0.10.4" @@ -759,21 +753,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "cocoa" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667fdc068627a2816b9ff831201dd9864249d6ee8d190b9532357f1fc0f61ea7" -dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation 0.9.4", - "core-graphics 0.21.0", - "foreign-types 0.3.2", - "libc", - "objc", -] - [[package]] name = "colorchoice" version = "1.0.5" @@ -815,23 +794,13 @@ dependencies = [ "version_check", ] -[[package]] -name = "core-foundation" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" -dependencies = [ - "core-foundation-sys 0.7.0", - "libc", -] - [[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "core-foundation-sys 0.8.7", + "core-foundation-sys", "libc", ] @@ -841,46 +810,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "core-foundation-sys 0.8.7", + "core-foundation-sys", "libc", ] -[[package]] -name = "core-foundation-sys" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" - [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core-graphics" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.7.0", - "foreign-types 0.3.2", - "libc", -] - -[[package]] -name = "core-graphics" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a67c4378cf203eace8fb6567847eb641fd6ff933c1145a115c6ee820ebb978" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "foreign-types 0.3.2", - "libc", -] - [[package]] name = "core-graphics" version = "0.23.2" @@ -949,7 +888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" dependencies = [ "bitflags 1.3.2", - "core-foundation-sys 0.8.7", + "core-foundation-sys", "coreaudio-sys", ] @@ -969,7 +908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" dependencies = [ "alsa", - "core-foundation-sys 0.8.7", + "core-foundation-sys", "coreaudio-rs", "dasp_sample", "jni 0.21.1", @@ -2414,7 +2353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", - "core-foundation-sys 0.8.7", + "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "log", @@ -2871,12 +2810,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -3027,15 +2960,6 @@ dependencies = [ "libc", ] -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - [[package]] name = "markup5ever" version = "0.38.0" @@ -3391,15 +3315,6 @@ dependencies = [ "libc", ] -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - [[package]] name = "objc-sys" version = "0.3.5" @@ -3778,7 +3693,6 @@ dependencies = [ "once_cell", "parking_lot", "raw-window-handle", - "rdev", "reqwest 0.12.28", "serde", "serde_json", @@ -4445,22 +4359,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "rdev" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00552ca2dc2f93b84cd7b5581de49549411e4e41d89e1c691bcb93dc4be360c3" -dependencies = [ - "cocoa", - "core-foundation 0.7.0", - "core-foundation-sys 0.7.0", - "core-graphics 0.19.2", - "lazy_static", - "libc", - "winapi", - "x11", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -4786,7 +4684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", - "core-foundation-sys 0.8.7", + "core-foundation-sys", "jni 0.22.4", "log", "once_cell", @@ -4931,7 +4829,7 @@ checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.11.1", "core-foundation 0.9.4", - "core-foundation-sys 0.8.7", + "core-foundation-sys", "libc", "security-framework-sys", ] @@ -4944,7 +4842,7 @@ checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.1", "core-foundation 0.10.1", - "core-foundation-sys 0.8.7", + "core-foundation-sys", "libc", "security-framework-sys", ] @@ -4955,7 +4853,7 @@ version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ - "core-foundation-sys 0.8.7", + "core-foundation-sys", "libc", ] @@ -5453,7 +5351,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ - "core-foundation-sys 0.8.7", + "core-foundation-sys", "libc", ] diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 822fc632..aaa89e29 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -50,7 +50,6 @@ global-hotkey = "0.6" cpal = "0.15" enigo = "0.2" arboard = "3" -rdev = "0.5" [target.'cfg(target_os = "macos")'.dependencies.keyring] version = "3.6.3" diff --git a/openless-all/app/src-tauri/src/cli.rs b/openless-all/app/src-tauri/src/cli.rs index a5b11999..e0d00f9c 100644 --- a/openless-all/app/src-tauri/src/cli.rs +++ b/openless-all/app/src-tauri/src/cli.rs @@ -1,9 +1,6 @@ //! 极简 CLI 参数解析 — 用于支持桌面环境快捷键调起 OpenLess 触发听写 / QA。 //! -//! 这条路径的来历:Linux Wayland 协议层面禁止"应用监听全局键盘"(除了焦点窗口), -//! 因此 rdev 在 Wayland 上必然失效(issue #420)。本仓库不为 Wayland 引入门户 -//! GlobalShortcuts(GNOME 尚未原生落地,引入会增加合成器分裂的维护负担——见 -//! `docs/issue-420-wayland-hotkey-research.md` 3.1 节),改走桌面环境快捷键 → +//! 这条路径的来历:Linux 上 fcitx5 插件提供了热键 + 文字提交的完整方案, //! `openless --toggle-dictation` → tauri-plugin-single-instance 转发的 CLI 路径。 //! macOS / Windows 上仍走原生 hotkey 监听器,CLI 是补充而非替代。 //! diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 618da137..8dfc0bed 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -387,18 +387,6 @@ 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 fd3bb193..c6a1c8fd 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -150,10 +150,6 @@ struct Inner { /// 最近一次应用到 capsule 窗口的几何状态。避免录音 level tick 反复触发 /// resize / reposition。 capsule_layout: Mutex>, - /// Linux: 胶囊窗口当前是否已 show 过。防止每次 emit_capsule 都调 window.show() - /// 抢走目标 app 的键盘焦点。首次 Idle→可见时 show 一次,后续可见→可见不重新 show。 - /// 回到 Idle 时 reset 为 false,下次 session 再 show。 - capsule_window_visible: AtomicBool, /// QA 用的 ASR 句柄(始终是 Volcengine 流式)。 qa_asr: Mutex>>, /// QA 用的 Recorder 句柄。 @@ -227,7 +223,6 @@ impl Coordinator { qa_hotkey: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), capsule_layout: Mutex::new(None), - capsule_window_visible: AtomicBool::new(false), qa_asr: Mutex::new(None), qa_recorder: Mutex::new(None), qa_stream_cancelled: Arc::new(AtomicBool::new(false)), @@ -278,7 +273,6 @@ impl Coordinator { qa_hotkey: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), capsule_layout: Mutex::new(None), - capsule_window_visible: AtomicBool::new(false), qa_asr: Mutex::new(None), qa_recorder: Mutex::new(None), qa_stream_cancelled: Arc::new(AtomicBool::new(false)), @@ -704,15 +698,11 @@ impl Coordinator { fn ensure_modifier_hotkey_monitor(&self, binding: crate::types::HotkeyBinding) { if let Some(monitor) = self.inner.hotkey.lock().as_ref() { - // 先 clone 再 move,确保 fcitx5 插件也能同步到新热键。 #[cfg(target_os = "linux")] let plugin_binding = binding.clone(); monitor.update_binding(binding); - // Wayland: 同步新热键到 fcitx5 插件(rdev 路径已由 update_binding 更新)。 #[cfg(target_os = "linux")] - if crate::hotkey::is_wayland_session() { - crate::linux_fcitx::sync_binding_to_plugin(&plugin_binding); - } + crate::linux_fcitx::sync_binding_to_plugin(&plugin_binding); return; } let (tx, rx) = mpsc::channel::(); @@ -733,9 +723,9 @@ impl Coordinator { .name("openless-hotkey-bridge".into()) .spawn(move || hotkey_bridge_loop(inner_clone, rx)) .ok(); - // Wayland: 启动 fcitx5 插件信号监听作为热键源(X11 走 rdev 避免双发)。 + // Linux: 启动 fcitx5 插件信号监听作为热键源。 #[cfg(target_os = "linux")] - if crate::hotkey::is_wayland_session() { + { crate::linux_fcitx::start_dictation_signal_listener(fcitx_tx); crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); } @@ -784,16 +774,14 @@ impl Coordinator { /// 返回当前听写阶段(read-only 快照),供 CLI 入口在 dispatch toggle 时决策。 /// 与原热键边沿走的 `handle_pressed` 分支完全相同的判定逻辑:Idle → start, - /// Listening → stop。Linux/Wayland 下桌面快捷键 → CLI 转发是唯一触发路径, - /// 必须复用这套语义。 + /// Listening → stop。可用于桌面快捷键 → 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 - /// 是唯一进入点。 + /// Processing → 忽略。桌面快捷键 → CLI 转发的备用进入点。 pub async fn cli_toggle_qa_panel(&self) { handle_qa_hotkey_pressed(&self.inner).await; } @@ -993,9 +981,9 @@ fn hotkey_supervisor_loop(inner: Arc) { .name("openless-hotkey-bridge".into()) .spawn(move || hotkey_bridge_loop(inner_clone, rx)) .ok(); - // Wayland: 启动 fcitx5 插件信号监听作为热键源(X11 走 rdev 避免双发)。 + // Linux: 启动 fcitx5 插件信号监听作为热键源。 #[cfg(target_os = "linux")] - if crate::hotkey::is_wayland_session() { + { crate::linux_fcitx::start_dictation_signal_listener(fcitx_tx); crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); } @@ -3630,7 +3618,7 @@ mod tests { #[test] fn focus_restore_failure_uses_specific_error_code_when_insert_fails() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, false, false, false), + dictation_error_code(InsertStatus::Failed, false, false, false), Some("focusRestoreFailed") ); } @@ -3647,7 +3635,7 @@ mod tests { #[cfg(target_os = "windows")] fn tsf_required_failure_keeps_tsf_error_when_focus_was_ready() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, true, false, false), + dictation_error_code(InsertStatus::Failed, false, true, false), Some("windowsImeTsfRequired") ); } @@ -4159,12 +4147,10 @@ fn emit_capsule( return; }; let show_capsule = inner_for_main.prefs.get().show_capsule; - - // Wayland: 不操作胶囊窗口(不 show/hide,不 reposition), - // 避免抢走目标 app 键盘焦点。文字通过 fcitx5 插件直接 commit, - // 用户始终在目标 app 中。 - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - if crate::hotkey::is_wayland_session() { + // Linux: 不操作胶囊窗口(不 show/hide,不 reposition)。 + // 文字通过 fcitx5 插件直接 commit,用户始终在目标 app 中。 + #[cfg(target_os = "linux")] + { return; } @@ -4176,16 +4162,6 @@ fn emit_capsule( maybe_position_capsule_bottom_center(&inner_for_main, &window, translation); if show_capsule && visible { if !show_capsule_window_no_activate(&app_for_main, &window) { - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - { - // Linux (X11): 仅首次 Idle→可见时 show 窗口,避免每次 emit 都抢焦点。 - let was_visible = inner_for_main - .capsule_window_visible - .swap(true, std::sync::atomic::Ordering::SeqCst); - if !was_visible { - let _ = window.show(); - } - } #[cfg(any(target_os = "macos", target_os = "windows"))] let _ = window.show(); } @@ -4194,10 +4170,6 @@ fn emit_capsule( #[cfg(target_os = "macos")] crate::restore_main_window_key_if_active(&app_for_main); } else { - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - inner_for_main - .capsule_window_visible - .store(false, std::sync::atomic::Ordering::SeqCst); hide_capsule_window_if_present(); let _ = window.hide(); } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 4efeb421..56e661b1 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -21,7 +21,7 @@ const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(25 /// - **Windows**:`switch_to_ascii` 是 no-op(SendInput Unicode 绕过 TSF); /// `type_unicode_chunk` 走 `SendInput(KEYEVENTF_UNICODE)`。 /// - **Linux(实验)**:`switch_to_ascii` 是 no-op;`type_unicode_chunk` 走 enigo -/// `Keyboard::text`。X11 / XTest 稳定,Wayland 看 compositor 给不给 libei 权限。 +/// `Keyboard::text`。X11 / XTest 稳定。 /// /// 通用流程: /// 1. `switch_to_ascii`(macOS)/ no-op(其他);失败则降级回一次性 `polish_or_passthrough`。 @@ -335,18 +335,6 @@ fn streaming_insert_eligible( && (mode != PolishMode::Raw || raw_uses_llm) } -fn fcitx_fallback_done_message(status: InsertStatus, polish_failed: bool) -> Option { - match status { - InsertStatus::Inserted | InsertStatus::PasteSent => None, - InsertStatus::CopiedFallback => Some(if polish_failed { - "未检测到 fcitx5,已复制原文到剪贴板,请手动粘贴".to_string() - } else { - "未检测到 fcitx5,已复制到剪贴板,请手动粘贴".to_string() - }), - InsertStatus::Failed => Some("fcitx5 不可用,剪贴板写入失败".to_string()), - } -} - fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option { if polish_failed { // polish 失败优先告知用户,即使 insert 成功也要让用户知道这版是原文 @@ -1408,7 +1396,6 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { }; // 流式插入 opt-in 路径:开关打开 + 非翻译 + 非 Raw 模式 → 进入流式分支。 // 任何不满足都走原一次性 polish_or_passthrough 路径,行为跟历史完全一致。 - let wayland_session = crate::hotkey::is_wayland_session(); let streaming_eligible = streaming_insert_eligible( prefs.streaming_insert, translation_active, @@ -1416,7 +1403,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { raw_uses_llm, ); log::info!( - "[coord] polish dispatch: translation={translation_active} mode={mode:?} wayland_session={wayland_session} streaming_eligible={streaming_eligible}" + "[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}" ); let (polished, polish_error, already_streamed) = if translation_active { @@ -1520,41 +1507,6 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { polish_error ); InsertStatus::Inserted - } else if wayland_session { - // Wayland: 优先用 fcitx5 插件直写(支持中文),降级到剪贴板拷贝。 - #[cfg(target_os = "linux")] - { - if crate::linux_fcitx::commit_text(&polished).is_ok() { - log::info!( - "[coord] Wayland fcitx5 commit succeeded ({} chars)", - polished.chars().count() - ); - InsertStatus::Inserted - } else { - log::info!( - "[coord] Wayland fcitx5 unavailable; attempting copy-only fallback ({} chars)", - polished.chars().count() - ); - let status = inner.inserter.copy_fallback(&polished); - match status { - InsertStatus::CopiedFallback => { - log::info!("[coord] Wayland copy-only fallback succeeded") - } - InsertStatus::Failed => log::error!( - "[coord] Wayland copy-only fallback failed: clipboard write failed" - ), - other => log::warn!( - "[coord] Wayland copy-only fallback returned unexpected status: {other:?}" - ), - } - status - } - } - #[cfg(not(target_os = "linux"))] - { - let status = inner.inserter.copy_fallback(&polished); - status - } } else if focus_ready_for_paste { #[cfg(target_os = "windows")] { @@ -1577,13 +1529,23 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { .insert(&polished, restore_clipboard, paste_shortcut) } } else { - log::warn!( - "[coord] original insertion target is not foreground; copied output without paste" - ); - if allow_non_tsf_insertion_fallback { - inner.inserter.copy_fallback(&polished) - } else { - InsertStatus::Failed + #[cfg(target_os = "linux")] + { + // Linux: fcitx5 commitString 无需窗口焦点,始终尝试插入。 + inner + .inserter + .insert(&polished, restore_clipboard, paste_shortcut) + } + #[cfg(not(target_os = "linux"))] + { + log::warn!( + "[coord] original insertion target is not foreground; copied output without paste" + ); + if allow_non_tsf_insertion_fallback { + inner.inserter.copy_fallback(&polished) + } else { + InsertStatus::Failed + } } }; restore_prepared_windows_ime_session(inner, current_session_id); @@ -1613,7 +1575,6 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { polish_error.is_some(), focus_ready_for_paste, allow_non_tsf_insertion_fallback, - wayland_session, ) .map(str::to_string); let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); @@ -1650,8 +1611,6 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } let done_message = if tsf_required_insert_failed { Some("TSF 未上屏,已禁止非 TSF 兜底".to_string()) - } else if wayland_session { - fcitx_fallback_done_message(status, polish_error.is_some()) } else { default_done_message(status, polish_error.is_some()) }; @@ -1680,11 +1639,8 @@ pub(super) fn dictation_error_code( polish_failed: bool, focus_ready_for_paste: bool, allow_non_tsf_insertion_fallback: bool, - wayland_session: bool, ) -> Option<&'static str> { - if wayland_session && status == InsertStatus::Failed { - Some("waylandClipboardWriteFailed") - } else if !focus_ready_for_paste && status == InsertStatus::Failed { + if !focus_ready_for_paste && status == InsertStatus::Failed { Some("focusRestoreFailed") } else if cfg!(target_os = "windows") && focus_ready_for_paste @@ -1741,7 +1697,7 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> mod tests { use super::{ append_typed_prefix, default_done_message, dictation_error_code, - fcitx_fallback_done_message, finalize_polished_text, streaming_insert_eligible, + finalize_polished_text, streaming_insert_eligible, }; use crate::types::{ChineseScriptPreference, CorrectionRule, InsertStatus, PolishMode}; @@ -1828,17 +1784,7 @@ mod tests { } #[test] - fn streaming_insert_no_longer_blocked_by_wayland() { - assert!(streaming_insert_eligible( - true, - false, - PolishMode::Light, - false, - )); - } - - #[test] - fn x11_linux_can_still_use_streaming_insert_when_other_gates_pass() { + fn streaming_insert_eligible_when_gates_allow() { assert!(streaming_insert_eligible( true, false, @@ -1848,23 +1794,7 @@ mod tests { } #[test] - fn fcitx_fallback_done_message_tells_user_manual_paste_is_required() { - assert_eq!( - fcitx_fallback_done_message(InsertStatus::CopiedFallback, false), - Some("未检测到 fcitx5,已复制到剪贴板,请手动粘贴".to_string()) - ); - assert_eq!( - fcitx_fallback_done_message(InsertStatus::CopiedFallback, true), - Some("未检测到 fcitx5,已复制原文到剪贴板,请手动粘贴".to_string()) - ); - assert_eq!( - fcitx_fallback_done_message(InsertStatus::Failed, false), - Some("fcitx5 不可用,剪贴板写入失败".to_string()) - ); - } - - #[test] - fn default_done_message_keeps_existing_non_wayland_behavior() { + fn default_done_message_works_correctly() { assert_eq!( default_done_message(InsertStatus::PasteSent, false), Some("已尝试粘贴".to_string()) @@ -1874,12 +1804,4 @@ mod tests { Some("润色失败,已插入原文".to_string()) ); } - - #[test] - fn wayland_clipboard_failure_uses_specific_error_code() { - assert_eq!( - dictation_error_code(InsertStatus::Failed, false, false, true, true), - Some("waylandClipboardWriteFailed") - ); - } } diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index c93e7c05..944c21f9 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -6,7 +6,7 @@ //! 非主线程触发 `dispatch_assert_queue_fail` → SIGTRAP abort(已踩坑)。 //! - Windows:原生 `WH_KEYBOARD_LL` low-level keyboard hook,保留 modifier-only //! trigger(如右 Control / 右 Alt)的真实语义,不再把平台能力藏在 `rdev` 抽象里。 -//! - Linux / 其他:继续 best-effort 走 `rdev::listen`。 +//! - Linux:fcitx5 插件提供热键事件(DBus 信号 `DictationKeyEvent`)。 //! //! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。 @@ -173,21 +173,6 @@ 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(), @@ -1199,69 +1184,39 @@ mod platform { #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] mod platform { - use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Sender; - use std::sync::Arc; - use std::time::Duration; - - use rdev::{listen, Event, EventType, Key}; - use super::{ - install_error, reset_shared_held_state, start_listener_thread, update_shared_binding, - update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, - }; + use super::{HotkeyAdapter, HotkeyEvent}; 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 当成"安装失败"。 + /// Linux 统一使用 fcitx5 插件作为热键源(Wayland / X11 均可), + /// 不再启用 rdev 监听器。此处返回占位 adapter 让上层走 `Installed` 分支。 /// - /// 用户实际的触发路径变成:桌面环境快捷键 → `openless --toggle-dictation` - /// → tauri-plugin-single-instance 拦截并把 argv 转给主实例 coordinator。 - /// 前端 Settings 面板会监听 `wayland_cli_mode` 事件并展示对应的引导文案。 + /// 实际的热键事件由 `linux_fcitx::start_dictation_signal_listener` 接收 + /// fcitx5 插件的 DBus 信号并转发到 `Sender`。 pub fn start_adapter( - binding: HotkeyBinding, + _binding: HotkeyBinding, tx: Sender, ) -> Result, HotkeyInstallError> { - 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, - tx, - "openless-hotkey-rdev", - "hotkey hook 启动超时", - run_listen_loop, - )?; - let _ = listener.startup; - Ok(Box::new(RdevHotkeyAdapter { - shared: listener.shared, - })) + log::info!( + "[hotkey] Linux — fcitx5 plugin handles hotkeys; rdev listener skipped" + ); + Ok(Box::new(PlaceholderAdapter { _tx: tx })) } - /// Wayland 下的占位 adapter:实现接口但不监听键盘。 - /// 上层 coordinator 仍会把它登记为 `Installed`(hotkey 状态显示正常), - /// 用户的触发路径由 CLI + single-instance 转发承担。 - struct WaylandCliAdapter { + /// Linux 占位 adapter:实现接口但不监听键盘。 + /// 热键事件由 fcitx5 插件的 `DictationKeyEvent` DBus 信号提供。 + struct PlaceholderAdapter { _tx: Sender, } - impl HotkeyAdapter for WaylandCliAdapter { + impl HotkeyAdapter for PlaceholderAdapter { fn kind(&self) -> HotkeyAdapterKind { - // 复用 Rdev kind 显示,避免新增枚举项波及整个序列化层。 - // 真实 adapter 状态由 `wayland_cli_mode` 事件在前端单独引导。 - HotkeyAdapterKind::Rdev + HotkeyAdapterKind::Fcitx5 } fn update_binding(&self, _binding: HotkeyBinding) { - // Wayland 下绑定由桌面环境管理;忽略后端绑定变更,但不报错。 + // fcitx5 插件热键由 sync_binding_to_plugin 单独同步。 } fn update_modifier_shortcuts( @@ -1269,323 +1224,8 @@ mod platform { _qa_trigger: Option, _translation_trigger: Option, ) { - // 同上 — modifier-only 修饰键在 Wayland 上也走不通,留空。 } fn reset_held_state(&self) {} } - - struct RdevHotkeyAdapter { - shared: Arc, - } - - impl HotkeyAdapter for RdevHotkeyAdapter { - fn kind(&self) -> HotkeyAdapterKind { - HotkeyAdapterKind::Rdev - } - - fn update_binding(&self, binding: HotkeyBinding) { - update_shared_binding(&self.shared, binding); - } - - fn update_modifier_shortcuts( - &self, - qa_trigger: Option, - translation_trigger: Option, - ) { - update_shared_modifier_shortcuts(&self.shared, qa_trigger, translation_trigger); - } - - fn reset_held_state(&self) { - reset_shared_held_state(&self.shared); - } - } - - fn run_listen_loop(shared: Arc, tx: Sender, status_tx: StartupTx<()>) { - let status_sent = Arc::new(AtomicBool::new(false)); - let ready_status_sent = Arc::clone(&status_sent); - let ready_status_tx = status_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(350)); - if !ready_status_sent.swap(true, Ordering::SeqCst) { - let _ = ready_status_tx.send(Ok(())); - } - }); - let cb_shared = Arc::clone(&shared); - let result = listen(move |event: Event| { - dispatch_event(&cb_shared, &tx, event); - }); - if let Err(err) = result { - if !status_sent.swap(true, Ordering::SeqCst) { - let _ = status_tx.send(Err(install_error( - "listen_failed", - format!("rdev::listen 启动失败: {err:?}"), - ))); - } - log::error!("[hotkey] rdev::listen 启动失败: {:?}", err); - } - } - - fn dispatch_event(shared: &Shared, tx: &Sender, event: Event) { - let trigger = shared.binding.read().trigger; - match event.event_type { - EventType::KeyPress(key) => { - if key == Key::Escape { - let _ = tx.send(HotkeyEvent::Cancelled); - return; - } - // Shift(任一侧)= 翻译模式修饰键。详见 issue #4。 - if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - let was_held = shared - .translation_modifier_held - .swap(true, Ordering::SeqCst); - if !was_held { - let _ = tx.send(HotkeyEvent::TranslationModifierPressed); - } - return; - } - handle_optional_modifier_press( - shared, - tx, - key, - *shared.qa_trigger.read(), - &shared.qa_trigger_held, - HotkeyEvent::QaShortcutPressed, - ); - handle_optional_modifier_press( - shared, - tx, - key, - *shared.translation_trigger.read(), - &shared.translation_trigger_held, - HotkeyEvent::TranslationModifierPressed, - ); - if trigger == HotkeyTrigger::Custom { - return; - } - if key == trigger_to_rdev_key(trigger) { - let was_held = shared.trigger_held.swap(true, Ordering::SeqCst); - if !was_held { - let _ = tx.send(HotkeyEvent::Pressed); - } - } - } - EventType::KeyRelease(key) => { - if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - shared - .translation_modifier_held - .store(false, Ordering::SeqCst); - return; - } - handle_optional_modifier_release( - shared, - key, - *shared.qa_trigger.read(), - &shared.qa_trigger_held, - ); - handle_optional_modifier_release( - shared, - key, - *shared.translation_trigger.read(), - &shared.translation_trigger_held, - ); - if trigger == HotkeyTrigger::Custom { - return; - } - if key == trigger_to_rdev_key(trigger) { - let was_held = shared.trigger_held.swap(false, Ordering::SeqCst); - if was_held { - let _ = tx.send(HotkeyEvent::Released); - } - } - } - _ => {} - } - } - - fn handle_optional_modifier_press( - shared: &Shared, - tx: &Sender, - key: Key, - trigger: Option, - held: &std::sync::atomic::AtomicBool, - event: HotkeyEvent, - ) { - let Some(trigger) = trigger else { - return; - }; - if trigger == HotkeyTrigger::Custom || key != trigger_to_rdev_key(trigger) { - return; - } - let was_held = held.swap(true, Ordering::SeqCst); - if !was_held { - let _ = tx.send(event); - } - } - - fn handle_optional_modifier_release( - _shared: &Shared, - key: Key, - trigger: Option, - held: &std::sync::atomic::AtomicBool, - ) { - let Some(trigger) = trigger else { - return; - }; - if trigger != HotkeyTrigger::Custom && key == trigger_to_rdev_key(trigger) { - held.store(false, Ordering::SeqCst); - } - } - - fn trigger_to_rdev_key(trigger: HotkeyTrigger) -> Key { - match trigger { - HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => Key::AltGr, - HotkeyTrigger::LeftOption => Key::Alt, - HotkeyTrigger::RightControl => Key::ControlRight, - HotkeyTrigger::LeftControl => Key::ControlLeft, - HotkeyTrigger::RightCommand => Key::MetaRight, - HotkeyTrigger::Fn => Key::Function, - HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"), - } - } - - #[cfg(test)] - mod tests { - use super::*; - use parking_lot::RwLock; - use std::sync::atomic::AtomicBool; - use std::sync::mpsc; - use std::time::SystemTime; - - fn shared(trigger: HotkeyTrigger) -> Shared { - Shared { - binding: RwLock::new(HotkeyBinding { - trigger, - mode: crate::types::HotkeyMode::Toggle, - keys: None, - }), - trigger_held: AtomicBool::new(false), - qa_trigger: RwLock::new(None), - qa_trigger_held: AtomicBool::new(false), - translation_trigger: RwLock::new(None), - translation_trigger_held: AtomicBool::new(false), - translation_modifier_held: AtomicBool::new(false), - } - } - - fn key_event(event_type: EventType) -> Event { - Event { - time: SystemTime::UNIX_EPOCH, - name: None, - event_type, - } - } - - fn drain(rx: &mpsc::Receiver) -> Vec { - rx.try_iter().collect() - } - - #[test] - fn rdev_modifier_edges_are_deduped_from_mock_events() { - let shared = shared(HotkeyTrigger::RightControl); - let (tx, rx) = mpsc::channel(); - - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyPress(Key::ControlRight)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyPress(Key::ControlRight)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyRelease(Key::ControlRight)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyRelease(Key::ControlRight)), - ); - - assert_eq!( - drain(&rx), - vec![HotkeyEvent::Pressed, HotkeyEvent::Released] - ); - } - - #[test] - fn rdev_modifier_edges_ignore_unrelated_keys_and_reemit_after_release() { - let shared = shared(HotkeyTrigger::RightControl); - let (tx, rx) = mpsc::channel(); - - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyPress(Key::ControlLeft)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyRelease(Key::ControlRight)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyPress(Key::ControlRight)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyRelease(Key::ControlRight)), - ); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyPress(Key::ControlRight)), - ); - - assert_eq!( - drain(&rx), - vec![ - HotkeyEvent::Pressed, - HotkeyEvent::Released, - HotkeyEvent::Pressed - ] - ); - } - - #[test] - fn rdev_optional_modifier_shortcuts_use_independent_latches() { - let shared = shared(HotkeyTrigger::RightControl); - *shared.qa_trigger.write() = Some(HotkeyTrigger::RightCommand); - *shared.translation_trigger.write() = Some(HotkeyTrigger::LeftOption); - let (tx, rx) = mpsc::channel(); - - dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::MetaRight))); - dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::MetaRight))); - dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::Alt))); - dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::ShiftLeft))); - dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::ShiftLeft))); - dispatch_event( - &shared, - &tx, - key_event(EventType::KeyRelease(Key::MetaRight)), - ); - dispatch_event(&shared, &tx, key_event(EventType::KeyPress(Key::MetaRight))); - - assert_eq!( - drain(&rx), - vec![ - HotkeyEvent::QaShortcutPressed, - HotkeyEvent::TranslationModifierPressed, - HotkeyEvent::TranslationModifierPressed, - HotkeyEvent::QaShortcutPressed, - ] - ); - } - } } diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index ba122ba4..8be9e6a3 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -50,7 +50,7 @@ impl TextInserter { if text.is_empty() { return InsertStatus::CopiedFallback; } - // Linux: 始终优先使用 fcitx5 CommitText 直写(支持中文、Wayland/X11 均可)。 + // Linux: 始终优先使用 fcitx5 CommitText 直写(支持中文)。 // 如果插件未加载,降级到剪贴板拷贝(统一路径,不单独维护 enigo XTest)。 #[cfg(target_os = "linux")] { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 6e9d352f..8b335faa 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -74,7 +74,7 @@ pub fn run() { // 否则两份 OpenLess(如 /Applications/ + dev build)会各自抓全局热键, // 导致按一次键、两个进程同时跑流水线、文本被插入两遍。见 issue #50。 // - // 第二个进程的 argv 还有一个用处:作为 Linux/Wayland 下的「触发器入口」。 + // 第二个进程的 argv 还有一个用处:作为 Linux 下的「触发器入口」。 // 桌面环境快捷键执行 `openless --toggle-dictation` 时,第二个进程被本插件 // 拦截 → argv 直接转给主实例 coordinator。详见 issue #420 / `cli.rs`。 .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { @@ -247,14 +247,6 @@ 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(); @@ -274,7 +266,6 @@ 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, diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs index 4945d2bf..a02d7827 100644 --- a/openless-all/app/src-tauri/src/linux_fcitx.rs +++ b/openless-all/app/src-tauri/src/linux_fcitx.rs @@ -113,7 +113,7 @@ pub fn available() -> bool { /// /// # 调用时机 /// -/// 仅在 Wayland session 下调用(X11 走 rdev 避免双发)。 +/// Linux 热键源:通过 DBus 监听 fcitx5 插件的 DictationKeyEvent 信号。 #[cfg(target_os = "linux")] pub fn start_dictation_signal_listener( tx: std::sync::mpsc::Sender, @@ -144,7 +144,7 @@ pub fn start_dictation_signal_listener( }; // 信号参数: (sym: u32, states: u32, is_press: bool) - if let Err(e) = conn.add_match(rule, move |args: (u32, u32, bool), _conn, _msg| { + let match_result = conn.add_match(rule, move |args: (u32, u32, bool), _conn, _msg| { let (_sym, _states, is_press) = args; log::debug!( "[fcitx-hotkey] DictationKeyEvent: sym={}, states={}, isPress={}", @@ -159,10 +159,14 @@ pub fn start_dictation_signal_listener( }; let _ = tx.send(event); true // 保持匹配活跃 - }) { - log::warn!("[fcitx-hotkey] Failed to add match: {e}"); - return; - } + }); + let _match = match match_result { + Ok(m) => m, + Err(e) => { + log::warn!("[fcitx-hotkey] Failed to add match: {e}"); + return; + } + }; log::info!("[fcitx-hotkey] Listening for DictationKeyEvent signals"); loop { diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index df1b489f..53a0b8c7 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -5,7 +5,7 @@ //! 走辅助功能 API 直读焦点元素的选区,**不**触碰剪贴板。 //! 2. **macOS / Windows** Cmd+C / Ctrl+C:snapshot 用户原剪贴板 → 模拟复制 → 80ms //! 后读出新内容 → 还原原剪贴板。 -//! 3. **Linux**:返回 `None`(X11/Wayland AX 模式不统一,留作 best-effort 后续)。 +//! 3. **Linux**:返回 `None`(AX 模式不统一,留作 best-effort 后续)。 //! //! 截断策略:超过 4000 字符的选区只保留首 2000 + 尾 2000 + `[…truncated…]` 标记, //! 避免给 LLM 灌过长 context。 diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 5ea66e87..efa323f5 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -600,7 +600,7 @@ pub struct UserPreferences { /// 平台原语: /// - macOS:CGEvent Unicode FFI;CJK / 日文 IME 会拦截,session 期间临时切到 ABC /// - Windows:SendInput Unicode(绕过 TSF);不需要切输入法 - /// - Linux(实验性):enigo `Keyboard::text`;X11 稳定,Wayland 看 compositor + /// - Linux:通过 fcitx5 插件 commitString 直写或剪贴板回落。 /// /// 限制: /// - 不再走剪贴板路径,对 secure input 框(密码框 / 1Password)静默拒绝 @@ -1472,7 +1472,7 @@ pub enum HotkeyMode { pub enum HotkeyAdapterKind { MacEventTap, WindowsLowLevel, - Rdev, + Fcitx5, } impl HotkeyAdapterKind { @@ -1480,7 +1480,7 @@ impl HotkeyAdapterKind { match self { HotkeyAdapterKind::MacEventTap => "macOS Event Tap", HotkeyAdapterKind::WindowsLowLevel => "Windows 低层键盘 hook", - HotkeyAdapterKind::Rdev => "rdev 监听器", + HotkeyAdapterKind::Fcitx5 => "fcitx5 输入法插件", } } } @@ -1681,7 +1681,7 @@ impl HotkeyCapability { #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] { Self { - adapter: HotkeyAdapterKind::Rdev, + adapter: HotkeyAdapterKind::Fcitx5, available_triggers: vec![ HotkeyTrigger::RightAlt, HotkeyTrigger::RightControl, @@ -1693,7 +1693,7 @@ impl HotkeyCapability { supports_side_specific_modifiers: true, explicit_fallback_available: false, status_hint: Some( - "Linux 仅 best-effort:X11 可尝试 rdev 监听;Wayland 请在桌面环境中绑定 openless --toggle-dictation 等 CLI 命令。".into(), + "Linux 使用 fcitx5 插件监听热键和提交文字;无需桌面环境额外配置。".into(), ), } } diff --git a/openless-all/app/src-tauri/src/unicode_keystroke.rs b/openless-all/app/src-tauri/src/unicode_keystroke.rs index 95476979..99b39b5d 100644 --- a/openless-all/app/src-tauri/src/unicode_keystroke.rs +++ b/openless-all/app/src-tauri/src/unicode_keystroke.rs @@ -14,9 +14,7 @@ //! 必须 `switch_to_ascii` 切到 ABC,session 结束再 `restore_input_source` 切回。 //! - **Windows**:`SendInput(KEYEVENTF_UNICODE)` 直接发 UTF-16 scancode。TSF 不拦 //! Unicode 事件(与 keyboard layout / IME 解耦),所以不需要切输入法。 -//! - **Linux**:enigo `Keyboard::text(...)`。X11 走 XTest 稳定;Wayland 看 compositor -//! 是否给 libei 权限,stock GNOME-Wayland 经常拒绝,调用方应当容忍失败回落到一次性。 -//! 不切输入法 —— Linux 的 fcitx / ibus 与 enigo 的交互非常碎,v1 不尝试。 +//! - **Linux**:走 fcitx5 插件 commitString 直写(DBus)或剪贴板回落。 //! //! ## 已知坑(macOS) //! diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 65e915b2..0e61fe58 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -406,24 +406,6 @@ 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 create a custom shortcut for each command below in your system settings (QA and cancel commands are optional):', - copyButton: 'Copy', - copyButtonCopied: 'Copied', - commandToggleDictationLabel: 'Start / stop dictation', - commandToggleQaLabel: 'Open / close QA panel', - commandCancelDictationLabel: 'Cancel current dictation', - helpToggle: 'Setup steps for each desktop environment', - gnomeTitle: 'GNOME', - gnomeSteps: 'Settings → Keyboard → View and Customize Shortcuts → Custom Shortcuts → Add Shortcut. Repeat 1–3 times, pasting each command above and recording the key combination you want.', - kdeTitle: 'KDE Plasma', - kdeSteps: 'System Settings → Keyboard → Shortcuts → Add New → Command/URL. Repeat for each command above, recording different trigger keys, then Apply.', - hyprlandTitle: 'Hyprland', - hyprlandSteps: 'Edit ~/.config/hypr/hyprland.conf, add any 1–3 of the lines below, then run hyprctl reload:\nbind = SUPER, Y, exec, openless --toggle-dictation\nbind = SUPER, U, exec, openless --toggle-qa\nbind = SUPER, I, exec, openless --cancel-dictation', - swayTitle: 'sway', - swaySteps: 'Edit ~/.config/sway/config, add any 1–3 of the lines below, then run swaymsg reload:\nbindsym $mod+y exec openless --toggle-dictation\nbindsym $mod+u exec openless --toggle-qa\nbindsym $mod+i exec openless --cancel-dictation', - }, }, providers: { llmTitle: 'LLM (polishing)', @@ -567,7 +549,7 @@ export const en: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode types directly, bypassing TSF / IME — no input-method switching needed.', streamingInsertHintLinux: - 'Uses enigo + XTest on X11. On Wayland, streaming insertion is disabled and output is kept in the clipboard for manual paste.', + 'Uses fcitx5 plugin for text submission; streaming insertion uses enigo + XTest for keystroke synthesis.', streamingInsertSaveClipboardLabel: 'Copy to clipboard', streamingInsertSaveClipboardHint: 'After a successful insert, write the final text to the clipboard so Cmd+V can paste it again. Off = clipboard is never touched.', localAsrTitle: 'Local ASR models (experimental)', @@ -732,7 +714,7 @@ export const en: typeof zhCN = { adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows low-level keyboard hook', - rdev: 'rdev listener', + fcitx5: 'fcitx5 input method plugin', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index bd7b5b08..e4ebc05b 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -379,24 +379,6 @@ export const ja: typeof zhCN = { startupAtBoot: '起動時に自動起動', startupAtBootDesc: 'ログイン時に OpenLess を自動起動。', startupAtBootError: '自動起動の切り替えに失敗:{{message}}', - wayland: { - calloutTitle: 'Wayland デスクトップを検出', - calloutBody: 'Wayland はセキュリティ上、アプリのグローバルショートカット監視を許可していません。システム設定で以下の各コマンド用にカスタムショートカットを作成してください(QA と録音キャンセルは任意):', - copyButton: 'コピー', - copyButtonCopied: 'コピー済み', - commandToggleDictationLabel: '録音の開始 / 停止', - commandToggleQaLabel: 'QA パネルの表示 / 非表示', - commandCancelDictationLabel: '現在の録音をキャンセル', - helpToggle: '各デスクトップ環境の設定手順', - gnomeTitle: 'GNOME', - gnomeSteps: '設定 → キーボード → ショートカットの表示とカスタマイズ → カスタムショートカット → 追加。1〜3 回繰り返し、コマンド欄に上記の各コマンドを貼り付け、希望のキー組み合わせを入力。', - kdeTitle: 'KDE Plasma', - kdeSteps: 'システム設定 → キーボード → ショートカット → 新規追加 → コマンド/URL。上記の各コマンドに対してトリガーキーを別々に記録し、保存。', - hyprlandTitle: 'Hyprland', - hyprlandSteps: '~/.config/hypr/hyprland.conf に以下から 1〜3 行を追加し、hyprctl reload を実行:\nbind = SUPER, Y, exec, openless --toggle-dictation\nbind = SUPER, U, exec, openless --toggle-qa\nbind = SUPER, I, exec, openless --cancel-dictation', - swayTitle: 'sway', - swaySteps: '~/.config/sway/config に以下から 1〜3 行を追加し、swaymsg reload を実行:\nbindsym $mod+y exec openless --toggle-dictation\nbindsym $mod+u exec openless --toggle-qa\nbindsym $mod+i exec openless --cancel-dictation', - }, }, providers: { llmTitle: 'LLM モデル(整文)', @@ -540,7 +522,7 @@ export const ja: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode で TSF / IME を迂回。入力ソースの切替は不要です。', streamingInsertHintLinux: - 'X11 では enigo + XTest でキー合成します。Wayland ではストリーミング入力を無効化し、出力をクリップボードに残して手動貼り付けします。', + 'fcitx5 プラグインで文字を送信。ストリーミング入力は enigo + XTest でキー合成。', streamingInsertSaveClipboardLabel: 'クリップボードに保存', streamingInsertSaveClipboardHint: '挿入成功後に最終テキストをクリップボードへ書き込み、Cmd+V で再貼付け可能にします。OFF ではクリップボードに触れません。', localAsrTitle: 'ローカル ASR モデル(実験的)', @@ -705,7 +687,7 @@ export const ja: typeof zhCN = { adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低レベルキーボードフック', - rdev: 'rdev リスナー', + fcitx5: 'fcitx5 インプットメソッドプラグイン', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 19a54b51..711ed980 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -379,24 +379,6 @@ export const ko: typeof zhCN = { startupAtBoot: '부팅 시 자동 시작', startupAtBootDesc: '로그인 시 OpenLess 자동 시작.', startupAtBootError: '자동 시작 전환 실패: {{message}}', - wayland: { - calloutTitle: 'Wayland 데스크톱 환경 감지됨', - calloutBody: 'Wayland는 보안상 앱의 전역 단축키 감지를 허용하지 않습니다. 시스템 설정에서 아래 각 명령에 대해 사용자 지정 단축키를 만드세요 (QA와 취소 명령은 선택 사항):', - copyButton: '복사', - copyButtonCopied: '복사됨', - commandToggleDictationLabel: '녹음 시작 / 중지', - commandToggleQaLabel: 'QA 패널 열기 / 닫기', - commandCancelDictationLabel: '현재 녹음 취소', - helpToggle: '각 데스크톱 환경별 설정 단계', - gnomeTitle: 'GNOME', - gnomeSteps: '설정 → 키보드 → 단축키 보기 및 사용자 지정 → 사용자 지정 단축키 → 추가. 1-3회 반복하여 명령 칸에 위 각 명령을 붙여넣고 원하는 키 조합을 기록.', - kdeTitle: 'KDE Plasma', - kdeSteps: '시스템 설정 → 키보드 → 단축키 → 새로 추가 → 명령/URL. 위 각 명령에 대해 다른 트리거 키를 기록하고 저장.', - hyprlandTitle: 'Hyprland', - hyprlandSteps: '~/.config/hypr/hyprland.conf 파일에 아래 1-3 줄을 추가하고 hyprctl reload 실행:\nbind = SUPER, Y, exec, openless --toggle-dictation\nbind = SUPER, U, exec, openless --toggle-qa\nbind = SUPER, I, exec, openless --cancel-dictation', - swayTitle: 'sway', - swaySteps: '~/.config/sway/config 파일에 아래 1-3 줄을 추가하고 swaymsg reload 실행:\nbindsym $mod+y exec openless --toggle-dictation\nbindsym $mod+u exec openless --toggle-qa\nbindsym $mod+i exec openless --cancel-dictation', - }, }, providers: { llmTitle: 'LLM 모델(정리)', @@ -540,7 +522,7 @@ export const ko: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode 로 TSF / IME 를 우회. 입력 소스 전환 불필요.', streamingInsertHintLinux: - 'X11에서는 enigo + XTest로 키를 합성합니다. Wayland에서는 스트리밍 입력을 비활성화하고 출력을 클립보드에 남겨 수동 붙여넣기를 사용합니다.', + 'fcitx5 플러그인으로 텍스트 전송. 스트리밍 입력은 enigo + XTest 키 합성 사용.', streamingInsertSaveClipboardLabel: '클립보드에 저장', streamingInsertSaveClipboardHint: '삽입 성공 후 최종 텍스트를 클립보드에 기록하여 Cmd+V 로 다시 붙여넣을 수 있게 합니다. 끄면 클립보드를 건드리지 않습니다.', localAsrTitle: '로컬 ASR 모델 (실험적)', @@ -705,7 +687,7 @@ export const ko: typeof zhCN = { adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 저수준 키보드 후크', - rdev: 'rdev 리스너', + fcitx5: 'fcitx5 입력기 플러그인', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index cd95e0ef..dd91bdcc 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -404,24 +404,6 @@ export const zhCN = { startupAtBoot: '开机自启', startupAtBootDesc: '登录系统时自动启动 OpenLess。', startupAtBootError: '开机自启切换失败:{{message}}', - wayland: { - calloutTitle: '检测到 Wayland 桌面环境', - calloutBody: 'Wayland 出于安全考虑不允许应用监听全局快捷键。请在系统设置中为下面的命令分别创建自定义快捷键(QA 与取消录音命令可选):', - copyButton: '复制', - copyButtonCopied: '已复制', - commandToggleDictationLabel: '开始 / 停止录音', - commandToggleQaLabel: '打开 / 关闭 QA 面板', - commandCancelDictationLabel: '取消当前录音', - helpToggle: '查看各桌面环境配置步骤', - gnomeTitle: 'GNOME', - gnomeSteps: '设置 → 键盘 → 查看和自定义快捷键 → 自定义快捷键 → 添加快捷键。重复添加 1-3 组,命令处分别填入上方命令,再录入想用的按键组合。', - kdeTitle: 'KDE Plasma', - kdeSteps: '系统设置 → 键盘 → 快捷键 → 添加新的 → 命令/URL,动作处分别粘贴上方命令并录入不同触发键,保存即可。', - hyprlandTitle: 'Hyprland', - hyprlandSteps: '编辑 ~/.config/hypr/hyprland.conf,加入下面任意 1-3 行后执行 hyprctl reload:\nbind = SUPER, Y, exec, openless --toggle-dictation\nbind = SUPER, U, exec, openless --toggle-qa\nbind = SUPER, I, exec, openless --cancel-dictation', - swayTitle: 'sway', - swaySteps: '编辑 ~/.config/sway/config,加入下面任意 1-3 行后执行 swaymsg reload:\nbindsym $mod+y exec openless --toggle-dictation\nbindsym $mod+u exec openless --toggle-qa\nbindsym $mod+i exec openless --cancel-dictation', - }, }, providers: { llmTitle: 'LLM 模型(润色)', @@ -565,7 +547,7 @@ export const zhCN = { streamingInsertHintWindows: 'SendInput Unicode 直接送字符,绕过 TSF / IME,不切输入法。', streamingInsertHintLinux: - 'X11 使用 enigo + XTest 合成按键;Wayland 下会自动关闭流式输入,并保留到剪贴板供手动粘贴。', + '通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键。', streamingInsertSaveClipboardLabel: '同步到剪贴板', streamingInsertSaveClipboardHint: '插入成功后把最终文本写入剪贴板,方便 Cmd+V 再次粘贴;关闭后流式过程不动剪贴板。', localAsrTitle: '本地 ASR 模型(实验性)', @@ -730,7 +712,7 @@ export const zhCN = { adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低层键盘 hook', - rdev: 'rdev 监听器', + fcitx5: 'fcitx5 输入法插件', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index f86426fa..84866116 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -406,24 +406,6 @@ export const zhTW: typeof zhCN = { startupAtBoot: '開機自啓', startupAtBootDesc: '登錄系統時自動啓動 OpenLess。', startupAtBootError: '開機自啓切換失敗:{{message}}', - wayland: { - calloutTitle: '偵測到 Wayland 桌面環境', - calloutBody: 'Wayland 出於安全考慮不允許應用監聽全域快速鍵。請在系統設定中為下面的命令分別建立自訂快速鍵(QA 與取消錄音命令可選):', - copyButton: '複製', - copyButtonCopied: '已複製', - commandToggleDictationLabel: '開始 / 停止錄音', - commandToggleQaLabel: '開啟 / 關閉 QA 面板', - commandCancelDictationLabel: '取消目前錄音', - helpToggle: '查看各桌面環境設定步驟', - gnomeTitle: 'GNOME', - gnomeSteps: '設定 → 鍵盤 → 檢視並自訂快速鍵 → 自訂快速鍵 → 新增快速鍵。重複新增 1-3 組,命令處分別填入上方命令,再錄入想用的按鍵組合。', - kdeTitle: 'KDE Plasma', - kdeSteps: '系統設定 → 鍵盤 → 快速鍵 → 新增 → 命令/URL,動作處分別貼上上方命令並錄入不同觸發鍵,儲存即可。', - hyprlandTitle: 'Hyprland', - hyprlandSteps: '編輯 ~/.config/hypr/hyprland.conf,加入下面任意 1-3 行後執行 hyprctl reload:\nbind = SUPER, Y, exec, openless --toggle-dictation\nbind = SUPER, U, exec, openless --toggle-qa\nbind = SUPER, I, exec, openless --cancel-dictation', - swayTitle: 'sway', - swaySteps: '編輯 ~/.config/sway/config,加入下面任意 1-3 行後執行 swaymsg reload:\nbindsym $mod+y exec openless --toggle-dictation\nbindsym $mod+u exec openless --toggle-qa\nbindsym $mod+i exec openless --cancel-dictation', - }, }, providers: { llmTitle: 'LLM 模型(潤色)', @@ -567,7 +549,7 @@ export const zhTW: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode 直接送字元,繞過 TSF / IME,不切輸入法。', streamingInsertHintLinux: - 'X11 使用 enigo + XTest 合成按鍵;Wayland 下會自動關閉串流輸入,並保留到剪貼簿供手動貼上。', + '通過 fcitx5 插件提交文字;串流輸入使用 enigo + XTest 合成按鍵。', streamingInsertSaveClipboardLabel: '同步到剪貼簿', streamingInsertSaveClipboardHint: '插入成功後把最終文字寫入剪貼簿,方便 Cmd+V 再次貼上;關閉後流式過程不動剪貼簿。', localAsrTitle: '本地 ASR 模型(實驗性)', @@ -732,7 +714,7 @@ export const zhTW: typeof zhCN = { adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低層鍵盤 hook', - rdev: 'rdev 監聽器', + fcitx5: 'fcitx5 輸入法插件', }, }, localAsr: { diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 98852e83..f83773d7 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -509,12 +509,6 @@ 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/lib/types.ts b/openless-all/app/src/lib/types.ts index 74631256..d419f4fb 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -74,7 +74,7 @@ export interface HotkeyBinding { keys?: HotkeyKey[] | null; } -export type HotkeyAdapterKind = 'macEventTap' | 'windowsLowLevel' | 'rdev'; +export type HotkeyAdapterKind = 'macEventTap' | 'windowsLowLevel' | 'fcitx5'; export interface HotkeyCapability { adapter: HotkeyAdapterKind; diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 725ea492..6041d2a1 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -16,7 +16,6 @@ import { import { createHotkeyRecorderState, orderHotkeyCodes, updateHotkeyRecorderState } from '../lib/hotkeyRecorder'; import { isTauri, - isWaylandCliMode, listMicrophoneDevices, openExternal, listProviderModels, @@ -200,24 +199,6 @@ 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 }, @@ -385,11 +366,10 @@ function RecordingSection() { {t('settings.recording.migrationNoticeTitle')}
- {t('settings.recording.migrationNoticeDesc')} + {t('settings.recording.migrationNoticeDesc')}
)} - {waylandCliMode && } (null); - - const onCopy = useCallback(async (command: string) => { - try { - await navigator.clipboard.writeText(command); - setCopiedCommand(command); - // 1.5s 后还原按钮文案;同时校验仍是这条命令,避免被后点的覆盖。 - setTimeout(() => { - setCopiedCommand(prev => (prev === command ? null : prev)); - }, 1500); - } catch (err) { - console.warn('[wayland-callout] clipboard write failed', err); - } - }, []); - - // 三条 CLI 命令 + 用途短标签。aeoform 在 #420 反馈 1.3.1-19 没提 - // --toggle-qa / --cancel-dictation,本次补全。 - const commandRows: Array = [ - ['openless --toggle-dictation', t('settings.recording.wayland.commandToggleDictationLabel')], - ['openless --toggle-qa', t('settings.recording.wayland.commandToggleQaLabel')], - ['openless --cancel-dictation', t('settings.recording.wayland.commandCancelDictationLabel')], - ]; - - 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')} -
-
- {commandRows.map(([command, label]) => ( -
-
- - {command} - - -
- - {label} - -
- ))} -
- - {helpOpen && ( -
- {helpEntries.map(([title, body]) => ( -
-
- {title} -
-
- {body} -
-
- ))} -
- )} -
- ); -} - function HotkeyRecorder({ binding, onCommit, diff --git a/openless-all/app/src/pages/settings/AdvancedSection.tsx b/openless-all/app/src/pages/settings/AdvancedSection.tsx index 54955307..1eda22d7 100644 --- a/openless-all/app/src/pages/settings/AdvancedSection.tsx +++ b/openless-all/app/src/pages/settings/AdvancedSection.tsx @@ -114,7 +114,7 @@ export function AdvancedSection() { 时延显著降低,但有几个限制(不满足时自动回落原一次性插入路径): - macOS:CGEvent Unicode + 临时切到 ABC 输入源(CJK / 日文 IME 拦截兜底) - Windows:SendInput Unicode,绕过 TSF / IME,不需要切输入法 - - Linux(实验):X11 走 enigo + XTest;Wayland 下禁用流式输入并回落剪贴板 + - Linux:通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键 - 仅 OpenAI-compatible provider 实装;Gemini / Codex 透明降级 - 密码框 / 1Password / SSH prompt 等 Secure Input 框拒绝合成按键 → 失败回落 每个平台用各自的 hint key,互相不显示对方平台的细节。 */} diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index 014f57d1..d9642372 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -202,5 +202,5 @@ function WindowsImeStatusPill({ status }: { status: WindowsImeStatus | null }) { function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus['adapter']) { if (adapter === 'macEventTap') return i18n.t('hotkey.adapter.macEventTap'); if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel'); - return i18n.t('hotkey.adapter.rdev'); + return i18n.t('hotkey.adapter.fcitx5'); } diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp index 3dcbe1f8..94bc67d5 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -91,21 +91,9 @@ class OpenLess final : public AddonInstance, auto &keyEvent = static_cast(event); // 保存当前输入上下文:快捷键按下时用户在目标 app 中, // 此后胶囊窗口可能抢走焦点,但 commitText 仍能用此 IC 提交文字。 - // 同时监听 IC 销毁信号,自动清空指针避免野指针。 + // IC 销毁时通过 InputContextDestroyed 事件自动清空指针(见下方)。 if (!keyEvent.isRelease()) { - auto *ic = keyEvent.inputContext(); - if (ic != savedIc_) { - savedIc_ = ic; - savedIcDestroyedConnection_ = ScopedConnection(); - if (ic) { - savedIcDestroyedConnection_ = - ic->destroyed.connect([this]() { - FCITX_LOGC(openless, Debug) - << "savedIc_ destroyed, clearing"; - savedIc_ = nullptr; - }); - } - } + savedIc_ = keyEvent.inputContext(); } // 先检查 raw sym/states(修饰键专用路径,绕过 Key::parse 限制) if ((triggerRawSym_ != 0 && @@ -136,6 +124,18 @@ class OpenLess final : public AddonInstance, } })); + // 4. 监听 InputContext 销毁事件,自动清空 savedIc_ 避免野指针 + eventHandlers_.push_back( + instance_->watchEvent( + EventType::InputContextDestroyed, + EventWatcherPhase::Default, + [this](Event &event) { + auto &icEvent = static_cast(event); + if (icEvent.inputContext() == savedIc_) { + savedIc_ = nullptr; + } + })); + FCITX_LOGC(openless, Info) << "OpenLess plugin loaded"; } @@ -183,6 +183,15 @@ class OpenLess final : public AddonInstance, triggerRawSym_ = 0; triggerRawStates_ = 0; safeSaveAsIni(config_, configFile()); + // 同时清除磁盘上残留的 TriggerRawSym/TriggerRawStates(旧 raw 模式的持久化值), + // 防止下次 fcitx5 重启 reloadConfig 重新加载旧 raw 热键覆盖新配置。 + { + RawConfig raw; + readAsIni(raw, configFile()); + raw.setValueByPath("TriggerRawSym", "0"); + raw.setValueByPath("TriggerRawStates", "0"); + safeSaveAsIni(raw, configFile()); + } rebuildTriggerKeys(); } @@ -257,10 +266,8 @@ class OpenLess final : public AddonInstance, uint32_t triggerRawStates_; /// 快捷键按下时保存的输入上下文指针,用于 commitText 在失焦后仍能提交文字。 /// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。 - /// 通过 savedIcDestroyedConnection_(连接到 InputContext::destroyed 信号) - /// 监听 IC 销毁时自动清空指针,避免野指针。 + /// 通过 InputContextDestroyed 事件监听 IC 销毁时自动清空指针。 InputContext *savedIc_; - ScopedConnection savedIcDestroyedConnection_; std::vector>> eventHandlers_; }; From 323dbed401f393defbe194de3eeb4496f20cd828 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sat, 16 May 2026 09:21:38 +0800 Subject: [PATCH 10/15] fix(linux): add QA and translation modifier hotkey support to fcitx5 plugin Extend the fcitx5 plugin with SetQaHotkeyRaw/SetTranslationHotkeyRaw DBus methods and corresponding signals, so that QA panel toggle and translation modifier hotkeys work on Linux (not just the main dictation hotkey). Built-in Shift key translation modifier also handled by the plugin. Document DBus security model in plugin header. Co-Authored-By: Claude Opus 4.7 --- openless-all/app/src-tauri/src/hotkey.rs | 6 +- openless-all/app/src-tauri/src/linux_fcitx.rs | 154 +++++++++++++----- .../scripts/linux-fcitx5-plugin/openless.cpp | 134 ++++++++++++--- 3 files changed, 234 insertions(+), 60 deletions(-) diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 944c21f9..6075f8e4 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -1221,9 +1221,11 @@ mod platform { fn update_modifier_shortcuts( &self, - _qa_trigger: Option, - _translation_trigger: Option, + qa_trigger: Option, + translation_trigger: Option, ) { + crate::linux_fcitx::sync_qa_binding(qa_trigger); + crate::linux_fcitx::sync_translation_binding(translation_trigger); } fn reset_held_state(&self) {} diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs index a02d7827..1351b75d 100644 --- a/openless-all/app/src-tauri/src/linux_fcitx.rs +++ b/openless-all/app/src-tauri/src/linux_fcitx.rs @@ -56,39 +56,108 @@ pub fn set_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { Ok(()) } -/// X11 keysym 值(用于 SetHotkeyRaw,绕过 Key::parse 的修饰键限制)。 +/// 通过 fcitx5 插件设置 QA 面板快捷键 sym + states。 +pub fn set_qa_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetQaHotkeyRaw") + .map_err(|e| format!("build msg: {e}"))? + .append2(sym, states); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("SetQaHotkeyRaw: {e}"))?; + Ok(()) +} + +/// 通过 fcitx5 插件设置翻译模式修饰键 sym + states。 +pub fn set_translation_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetTranslationHotkeyRaw") + .map_err(|e| format!("build msg: {e}"))? + .append2(sym, states); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("SetTranslationHotkeyRaw: {e}"))?; + Ok(()) +} + +/// X11 keysym 值(用于 SetHotkeyRaw / SetQaHotkeyRaw / SetTranslationHotkeyRaw, +/// 绕过 Key::parse 的修饰键限制)。 const KEYSYM_CONTROL_R: u32 = 0xffe4; const KEYSYM_CONTROL_L: u32 = 0xffe3; const KEYSYM_ALT_R: u32 = 0xffea; const KEYSYM_ALT_L: u32 = 0xffe9; const KEYSYM_SUPER_R: u32 = 0xffec; const KEYSYM_SUPER_L: u32 = 0xffeb; +const KEYSYM_SHIFT_R: u32 = 0xffe2; +const KEYSYM_SHIFT_L: u32 = 0xffe1; -/// 将 OpenLess 的热键绑定同步到 fcitx5 插件。 -/// -/// 把 `HotkeyTrigger`(如 RightControl)转换为 fcitx5 keysym, -/// 通过 `SetHotkeyRaw` 配置插件(绕过 fcitx5 Key 类对纯修饰键的限制)。 +/// 将 HotkeyTrigger 转换为 X11 keysym。 +fn trigger_to_keysym(trigger: crate::types::HotkeyTrigger) -> u32 { + match trigger { + crate::types::HotkeyTrigger::RightControl => KEYSYM_CONTROL_R, + crate::types::HotkeyTrigger::LeftControl => KEYSYM_CONTROL_L, + crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => KEYSYM_ALT_R, + crate::types::HotkeyTrigger::LeftOption => KEYSYM_ALT_L, + crate::types::HotkeyTrigger::RightCommand => KEYSYM_SUPER_R, + crate::types::HotkeyTrigger::Fn => KEYSYM_SUPER_L, + crate::types::HotkeyTrigger::Custom => unreachable!(), + } +} + +fn trigger_name(trigger: crate::types::HotkeyTrigger) -> &'static str { + match trigger { + crate::types::HotkeyTrigger::RightControl => "Control_R", + crate::types::HotkeyTrigger::LeftControl => "Control_L", + crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => "Alt_R", + crate::types::HotkeyTrigger::LeftOption => "Alt_L", + crate::types::HotkeyTrigger::RightCommand => "Super_R", + crate::types::HotkeyTrigger::Fn => "Super_L", + crate::types::HotkeyTrigger::Custom => unreachable!(), + } +} + +/// 将 OpenLess 的主听写热键绑定同步到 fcitx5 插件。 pub fn sync_binding_to_plugin(binding: &crate::types::HotkeyBinding) { if binding.trigger == crate::types::HotkeyTrigger::Custom { return; } - let (sym, name) = match binding.trigger { - crate::types::HotkeyTrigger::RightControl => (KEYSYM_CONTROL_R, "Control_R"), - crate::types::HotkeyTrigger::LeftControl => (KEYSYM_CONTROL_L, "Control_L"), - crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => { - (KEYSYM_ALT_R, "Alt_R") - } - crate::types::HotkeyTrigger::LeftOption => (KEYSYM_ALT_L, "Alt_L"), - crate::types::HotkeyTrigger::RightCommand => (KEYSYM_SUPER_R, "Super_R"), - crate::types::HotkeyTrigger::Fn => (KEYSYM_SUPER_L, "Super_L"), - crate::types::HotkeyTrigger::Custom => unreachable!(), - }; + let sym = trigger_to_keysym(binding.trigger); + let name = trigger_name(binding.trigger); match set_hotkey_raw(sym, 0) { Ok(()) => log::info!("[fcitx] Synced hotkey {name} (sym={sym}) to plugin via SetHotkeyRaw"), Err(e) => log::warn!("[fcitx] Failed to sync hotkey to plugin: {e}"), } } +/// 将 QA 面板快捷键同步到 fcitx5 插件。 +pub fn sync_qa_binding(trigger: Option) { + let Some(trigger) = trigger else { + // 无 QA 快捷键时清空插件端配置 + let _ = set_qa_hotkey_raw(0, 0); + return; + }; + let sym = trigger_to_keysym(trigger); + let name = trigger_name(trigger); + match set_qa_hotkey_raw(sym, 0) { + Ok(()) => log::info!("[fcitx] Synced QA hotkey {name} (sym={sym}) to plugin via SetQaHotkeyRaw"), + Err(e) => log::warn!("[fcitx] Failed to sync QA hotkey to plugin: {e}"), + } +} + +/// 将翻译模式快捷键同步到 fcitx5 插件。 +pub fn sync_translation_binding(trigger: Option) { + let Some(trigger) = trigger else { + let _ = set_translation_hotkey_raw(0, 0); + return; + }; + let sym = trigger_to_keysym(trigger); + let name = trigger_name(trigger); + match set_translation_hotkey_raw(sym, 0) { + Ok(()) => log::info!("[fcitx] Synced translation hotkey {name} (sym={sym}) to plugin via SetTranslationHotkeyRaw"), + Err(e) => log::warn!("[fcitx] Failed to sync translation hotkey to plugin: {e}"), + } +} + /// 快速检查 fcitx5 OpenLess 插件是否可用(DBus 对象存在)。 pub fn available() -> bool { let conn = match dbus::blocking::Connection::new_session() { @@ -110,10 +179,6 @@ pub fn available() -> bool { /// 本函数将此信号转发为 `HotkeyEvent::Pressed` / `Released` 到协调器事件通道。 /// /// 后台线程在 `tx` 全部 drop(协调器关闭)或 DBus 连接断开时自动退出。 -/// -/// # 调用时机 -/// -/// Linux 热键源:通过 DBus 监听 fcitx5 插件的 DictationKeyEvent 信号。 #[cfg(target_os = "linux")] pub fn start_dictation_signal_listener( tx: std::sync::mpsc::Sender, @@ -131,10 +196,10 @@ pub fn start_dictation_signal_listener( } }; + // 同时监听所有三个信号 let rule = match dbus::message::MatchRule::parse( "type='signal',\ - interface='org.fcitx.Fcitx.OpenLess1',\ - member='DictationKeyEvent'", + interface='org.fcitx.Fcitx.OpenLess1'", ) { Ok(r) => r, Err(e) => { @@ -143,24 +208,35 @@ pub fn start_dictation_signal_listener( } }; - // 信号参数: (sym: u32, states: u32, is_press: bool) - let match_result = conn.add_match(rule, move |args: (u32, u32, bool), _conn, _msg| { - let (_sym, _states, is_press) = args; + let tx2 = tx.clone(); + let _match = match conn.add_match(rule, move |args: (u32, u32, bool), _conn, msg| { + let (sym, states, is_press) = args; + let member = msg.member(); + let member_str: String = member.as_ref().map(|m| m.to_string()).unwrap_or_default(); log::debug!( - "[fcitx-hotkey] DictationKeyEvent: sym={}, states={}, isPress={}", - _sym, - _states, - is_press, + "[fcitx-hotkey] Signal {}: sym={}, states={}, isPress={}", + member_str, sym, states, is_press, ); - let event = if is_press { - crate::hotkey::HotkeyEvent::Pressed - } else { - crate::hotkey::HotkeyEvent::Released - }; - let _ = tx.send(event); - true // 保持匹配活跃 - }); - let _match = match match_result { + if let Some(member) = member { + if member == "DictationKeyEvent" { + let event = if is_press { + crate::hotkey::HotkeyEvent::Pressed + } else { + crate::hotkey::HotkeyEvent::Released + }; + let _ = tx.send(event); + } else if member == "QaShortcutEvent" { + if is_press { + let _ = tx2.send(crate::hotkey::HotkeyEvent::QaShortcutPressed); + } + } else if member == "TranslationModifierEvent" { + if is_press { + let _ = tx2.send(crate::hotkey::HotkeyEvent::TranslationModifierPressed); + } + } + } + true + }) { Ok(m) => m, Err(e) => { log::warn!("[fcitx-hotkey] Failed to add match: {e}"); @@ -168,7 +244,7 @@ pub fn start_dictation_signal_listener( } }; - log::info!("[fcitx-hotkey] Listening for DictationKeyEvent signals"); + log::info!("[fcitx-hotkey] Listening for OpenLess1 signals"); loop { if let Err(e) = conn.process(Duration::from_millis(500)) { log::warn!("[fcitx-hotkey] DBus process error: {e}"); diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp index 94bc67d5..82800369 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -7,13 +7,18 @@ * * DBus 接口: org.fcitx.Fcitx.OpenLess1 (对象路径 /openless) * 方法: - * CommitText(s: text) — 将文字提交到当前焦点输入上下文 - * SetHotkey(as: keys) — 设置听写触发快捷键 (Key::parse 格式) - * SetHotkeyRaw(uu: sym, states) — 直接设 sym+states (不走 parse) + * CommitText(s: text) — 将文字提交到当前焦点输入上下文 + * 安全性:本接口在会话总线(session bus)上对同用户 + * 所有进程开放,此为 fcitx5/IBus 体系的标准安全模型 + * (非特权进程隔离)。 + * SetHotkey(as: keys) — 设置听写触发快捷键 (Key::parse 格式) + * SetHotkeyRaw(uu: sym, states) — 直接设听写触发 sym+states (不走 parse) + * SetQaHotkeyRaw(uu: sym, states) — 直接设 QA 面板触发 sym+states + * SetTranslationHotkeyRaw(uu: sym, states) — 直接设翻译模式触发 sym+states * 信号: - * DictationKeyEvent(uu: sym, states) — 热键被按下 - * - * 后续: 当需要 IBus 引擎兼容时 (GNOME),另行实现 org.freedesktop.IBus.Engine。 + * DictationKeyEvent(uub: sym, states, isPress) — 听写热键按下/抬起 + * QaShortcutEvent(uub: sym, states, isPress) — QA 快捷键按下/抬起 + * TranslationModifierEvent(uub: sym, states, isPress) — 翻译修饰键按下/抬起 */ #include @@ -57,6 +62,10 @@ class OpenLess final : public AddonInstance, : instance_(instance), triggerRawSym_(0), triggerRawStates_(0), + qaRawSym_(0), + qaRawStates_(0), + translationRawSym_(0), + translationRawStates_(0), savedIc_(nullptr) { // 1. 读取配置 @@ -91,34 +100,73 @@ class OpenLess final : public AddonInstance, auto &keyEvent = static_cast(event); // 保存当前输入上下文:快捷键按下时用户在目标 app 中, // 此后胶囊窗口可能抢走焦点,但 commitText 仍能用此 IC 提交文字。 - // IC 销毁时通过 InputContextDestroyed 事件自动清空指针(见下方)。 if (!keyEvent.isRelease()) { savedIc_ = keyEvent.inputContext(); } - // 先检查 raw sym/states(修饰键专用路径,绕过 Key::parse 限制) + + auto sym = static_cast(keyEvent.key().sym()); + auto states = static_cast(keyEvent.key().states()); + bool isPress = !keyEvent.isRelease(); + + // 检查听写触发键(raw + keylist 双路径) + bool dictationMatched = false; if ((triggerRawSym_ != 0 && - keyEvent.key().sym() == static_cast(triggerRawSym_) && - keyEvent.key().states() == static_cast(triggerRawStates_)) || + sym == triggerRawSym_ && + states == triggerRawStates_) || (triggerRawSym_ == 0 && [&]() { for (const auto &hk : triggerKeyList_) { - if (keyEvent.key().sym() == hk.sym() && - keyEvent.key().states() == hk.states()) + if (sym == static_cast(hk.sym()) && + states == static_cast(hk.states())) return true; } return false; }())) { - auto sym = triggerRawSym_ != 0 + dictationMatched = true; + auto dsym = triggerRawSym_ != 0 ? triggerRawSym_ : static_cast(triggerKeyList_[0].sym()); - auto states = triggerRawStates_ != 0 + auto dstates = triggerRawStates_ != 0 ? triggerRawStates_ : static_cast(triggerKeyList_[0].states()); - bool isPress = !keyEvent.isRelease(); FCITX_LOGC(openless, Debug) - << "Dictation hotkey: sym=" - << sym << " states=" << states + << "Dictation hotkey: sym=" << dsym + << " states=" << dstates + << " isPress=" << isPress; + dictationKeyEvent(dsym, dstates, isPress); + keyEvent.filterAndAccept(); + return; + } + + // 检查 QA 快捷键 + if (qaRawSym_ != 0 && + sym == qaRawSym_ && + states == qaRawStates_) { + FCITX_LOGC(openless, Debug) + << "QA shortcut: sym=" << qaRawSym_ + << " states=" << qaRawStates_ << " isPress=" << isPress; - dictationKeyEvent(sym, states, isPress); + qaShortcutEvent(qaRawSym_, qaRawStates_, isPress); + keyEvent.filterAndAccept(); + return; + } + + // 检查翻译模式修饰键(自定义 + 内置 Shift) + bool translationMatched = false; + if (translationRawSym_ != 0 && + sym == translationRawSym_ && + states == translationRawStates_) { + translationMatched = true; + } + // 内置 Shift 修饰键 + if (sym == 0xffe1 || sym == 0xffe2) { + translationMatched = true; + } + if (translationMatched) { + FCITX_LOGC(openless, Debug) + << "Translation modifier: sym=" << sym + << " states=" << states + << " isPress=" << isPress; + translationModifierEvent(sym, states, isPress); keyEvent.filterAndAccept(); return; } @@ -217,16 +265,44 @@ class OpenLess final : public AddonInstance, rebuildTriggerKeys(); } + void setQaHotkeyRaw(uint32_t sym, uint32_t states) { + qaRawSym_ = sym; + qaRawStates_ = states; + RawConfig raw; + readAsIni(raw, configFile()); + raw.setValueByPath("QaRawSym", std::to_string(sym)); + raw.setValueByPath("QaRawStates", std::to_string(states)); + safeSaveAsIni(raw, configFile()); + FCITX_LOGC(openless, Info) + << "SetQaHotkeyRaw: sym=" << sym << " states=" << states; + } + + void setTranslationHotkeyRaw(uint32_t sym, uint32_t states) { + translationRawSym_ = sym; + translationRawStates_ = states; + RawConfig raw; + readAsIni(raw, configFile()); + raw.setValueByPath("TranslationRawSym", std::to_string(sym)); + raw.setValueByPath("TranslationRawStates", std::to_string(states)); + safeSaveAsIni(raw, configFile()); + FCITX_LOGC(openless, Info) + << "SetTranslationHotkeyRaw: sym=" << sym << " states=" << states; + } + FCITX_OBJECT_VTABLE_METHOD(commitText, "CommitText", "s", ""); FCITX_OBJECT_VTABLE_METHOD(setHotkey, "SetHotkey", "as", ""); FCITX_OBJECT_VTABLE_METHOD(setHotkeyRaw, "SetHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(setQaHotkeyRaw, "SetQaHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(setTranslationHotkeyRaw, "SetTranslationHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub"); + FCITX_OBJECT_VTABLE_SIGNAL(qaShortcutEvent, "QaShortcutEvent", "uub"); + FCITX_OBJECT_VTABLE_SIGNAL(translationModifierEvent, "TranslationModifierEvent", "uub"); Instance *instance() { return instance_; } void reloadConfig() override { readAsIni(config_, configFile()); - // 加载原始 sym/states(由 SetHotkeyRaw 写入的持久化键值) + // 加载原始 sym/states(由 SetHotkeyRaw / SetQaHotkeyRaw / SetTranslationHotkeyRaw 写入的持久化键值) RawConfig raw; readAsIni(raw, configFile()); { @@ -237,6 +313,22 @@ class OpenLess final : public AddonInstance, auto *v = raw.valueByPath("TriggerRawStates"); triggerRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; } + { + auto *v = raw.valueByPath("QaRawSym"); + qaRawSym_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + { + auto *v = raw.valueByPath("QaRawStates"); + qaRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + { + auto *v = raw.valueByPath("TranslationRawSym"); + translationRawSym_ = v ? std::stoul(*v, nullptr, 0) : 0; + } + { + auto *v = raw.valueByPath("TranslationRawStates"); + translationRawStates_ = v ? std::stoul(*v, nullptr, 0) : 0; + } rebuildTriggerKeys(); } @@ -264,6 +356,10 @@ class OpenLess final : public AddonInstance, KeyList triggerKeyList_; uint32_t triggerRawSym_; uint32_t triggerRawStates_; + uint32_t qaRawSym_; + uint32_t qaRawStates_; + uint32_t translationRawSym_; + uint32_t translationRawStates_; /// 快捷键按下时保存的输入上下文指针,用于 commitText 在失焦后仍能提交文字。 /// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。 /// 通过 InputContextDestroyed 事件监听 IC 销毁时自动清空指针。 From 60250c839d7b157bbb3804b6731d1848578e471e Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sat, 16 May 2026 09:32:12 +0800 Subject: [PATCH 11/15] fix(linux): support custom combo hotkeys and surface fcitx5 errors - Add SetCustomDictationTrigger(s) to plugin for custom key combos - Add fcitx5 availability check before hotkey install, surface error - Sync custom dictation combo to fcitx5 plugin from coordinator Co-Authored-By: Claude Opus 4.7 --- openless-all/app/src-tauri/src/coordinator.rs | 56 ++++++++++++++++++- openless-all/app/src-tauri/src/linux_fcitx.rs | 49 ++++++++++++++++ .../scripts/linux-fcitx5-plugin/openless.cpp | 44 ++++++++++++++- 3 files changed, 144 insertions(+), 5 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c6a1c8fd..9f0dd656 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -479,6 +479,8 @@ impl Coordinator { .name("openless-combo-hotkey-bridge".into()) .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) .ok(); + #[cfg(target_os = "linux")] + sync_custom_dictation_to_plugin(&inner_clone); } Err(e) => { log::warn!("[coord] update combo hotkey binding 失败: {e}"); @@ -702,7 +704,11 @@ impl Coordinator { let plugin_binding = binding.clone(); monitor.update_binding(binding); #[cfg(target_os = "linux")] - crate::linux_fcitx::sync_binding_to_plugin(&plugin_binding); + if plugin_binding.trigger == crate::types::HotkeyTrigger::Custom { + sync_custom_dictation_to_plugin(&self.inner); + } else { + crate::linux_fcitx::sync_binding_to_plugin(&plugin_binding); + } return; } let (tx, rx) = mpsc::channel::(); @@ -727,7 +733,11 @@ impl Coordinator { #[cfg(target_os = "linux")] { crate::linux_fcitx::start_dictation_signal_listener(fcitx_tx); - crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); + if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom { + sync_custom_dictation_to_plugin(&self.inner); + } else { + crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); + } } } Err(e) => { @@ -942,6 +952,23 @@ fn hotkey_supervisor_loop(inner: Arc) { if inner.hotkey.lock().is_some() { return; } + // Linux: 启动前检查 fcitx5 插件是否可用 + #[cfg(target_os = "linux")] + if !crate::linux_fcitx::available() { + *inner.hotkey_status.lock() = HotkeyStatus { + adapter: capability.adapter, + state: HotkeyStatusState::Failed, + message: Some("fcitx5 插件不可用 — 请确保 fcitx5 已安装且在运行".into()), + last_error: Some(crate::types::HotkeyInstallError { + code: "fcitx5_unavailable".into(), + message: "fcitx5 插件 DBus 接口无响应".into(), + }), + }; + log::warn!("[hotkey-supervisor] fcitx5 plugin unavailable, retrying..."); + attempts += 1; + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } *inner.hotkey_status.lock() = HotkeyStatus { adapter: capability.adapter, state: HotkeyStatusState::Starting, @@ -985,7 +1012,11 @@ fn hotkey_supervisor_loop(inner: Arc) { #[cfg(target_os = "linux")] { crate::linux_fcitx::start_dictation_signal_listener(fcitx_tx); - crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); + if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom { + sync_custom_dictation_to_plugin(&inner); + } else { + crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); + } } return; } @@ -1554,6 +1585,25 @@ fn is_builtin_translation_shift(binding: &crate::types::ShortcutBinding) -> bool binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") } +/// Linux: 从 prefs 读取自定义组合键,同步到 fcitx5 插件。 +#[cfg(target_os = "linux")] +fn sync_custom_dictation_to_plugin(inner: &Arc) { + let prefs = inner.prefs.get(); + let dictation = &prefs.dictation_hotkey; + // 只有 Custom 组合键才需要走插件同步 + if dictation.modifiers.is_empty() { + return; + } + let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(dictation); + if key_string.is_empty() { + return; + } + match crate::linux_fcitx::set_custom_dictation_trigger(&key_string) { + Ok(()) => log::info!("[fcitx] Synced custom dictation trigger '{}' to plugin", key_string), + Err(e) => log::warn!("[fcitx] Failed to sync custom dictation trigger: {e}"), + } +} + fn modifier_shortcut_triggers( inner: &Arc, ) -> ( diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs index 1351b75d..cf19dbcf 100644 --- a/openless-all/app/src-tauri/src/linux_fcitx.rs +++ b/openless-all/app/src-tauri/src/linux_fcitx.rs @@ -129,6 +129,55 @@ pub fn sync_binding_to_plugin(binding: &crate::types::HotkeyBinding) { } } +/// 将 ShortcutBinding 转换为 fcitx5 Key::parse 格式的字符串。 +/// +/// 例如 `modifiers: ["Ctrl", "Alt"], primary: "d"` → `"Control+Alt+d"`。 +pub fn binding_to_fcitx_key_string(binding: &crate::types::ShortcutBinding) -> String { + let mut parts: Vec = Vec::new(); + for m in &binding.modifiers { + let lower = m.to_lowercase(); + let normalized = match lower.as_str() { + "ctrl" | "control" => "Control", + "alt" | "option" | "opt" => "Alt", + "shift" => "Shift", + "super" | "meta" | "cmd" | "win" | "command" => "Super", + other => other, + }; + if !parts.contains(&normalized.to_string()) { + parts.push(normalized.to_string()); + } + } + // 主键:取小写,去掉 "Key" 前缀(如 "KeyD" → "d") + let primary = binding.primary.trim(); + let primary = if let Some(stripped) = primary.strip_prefix("Key") { + stripped.to_lowercase() + } else { + primary.to_lowercase() + }; + if primary.is_empty() { + return String::new(); + } + if parts.is_empty() { + primary + } else { + format!("{}+{}", parts.join("+"), primary) + } +} + +/// 通过 fcitx5 插件的 SetCustomDictationTrigger 方法设置自定义组合键。 +pub fn set_custom_dictation_trigger(key_string: &str) -> Result<(), String> { + let conn = dbus::blocking::Connection::new_session() + .map_err(|e| format!("dbus session: {e}"))?; + let msg = dbus::Message::new_method_call( + DEST, PATH, IFACE, "SetCustomDictationTrigger", + ) + .map_err(|e| format!("build msg: {e}"))? + .append1(key_string); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("SetCustomDictationTrigger: {e}"))?; + Ok(()) +} + /// 将 QA 面板快捷键同步到 fcitx5 插件。 pub fn sync_qa_binding(trigger: Option) { let Some(trigger) = trigger else { diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp index 82800369..848ebb31 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -13,6 +13,7 @@ * (非特权进程隔离)。 * SetHotkey(as: keys) — 设置听写触发快捷键 (Key::parse 格式) * SetHotkeyRaw(uu: sym, states) — 直接设听写触发 sym+states (不走 parse) + * SetCustomDictationTrigger(s: keyString) — 设置自定义组合键 (Key::parse 格式) * SetQaHotkeyRaw(uu: sym, states) — 直接设 QA 面板触发 sym+states * SetTranslationHotkeyRaw(uu: sym, states) — 直接设翻译模式触发 sym+states * 信号: @@ -66,6 +67,7 @@ class OpenLess final : public AddonInstance, qaRawStates_(0), translationRawSym_(0), translationRawStates_(0), + hasCustomDictationKey_(false), savedIc_(nullptr) { // 1. 读取配置 @@ -108,8 +110,23 @@ class OpenLess final : public AddonInstance, auto states = static_cast(keyEvent.key().states()); bool isPress = !keyEvent.isRelease(); + // 检查自定义组合键(优先级最高) + if (hasCustomDictationKey_ && + keyEvent.key().sym() == customDictationKey_.sym() && + keyEvent.key().states() == customDictationKey_.states()) { + FCITX_LOGC(openless, Debug) + << "Custom dictation combo: sym=" << sym + << " states=" << states + << " isPress=" << isPress; + dictationKeyEvent( + static_cast(customDictationKey_.sym()), + static_cast(customDictationKey_.states()), + isPress); + keyEvent.filterAndAccept(); + return; + } + // 检查听写触发键(raw + keylist 双路径) - bool dictationMatched = false; if ((triggerRawSym_ != 0 && sym == triggerRawSym_ && states == triggerRawStates_) || @@ -121,7 +138,6 @@ class OpenLess final : public AddonInstance, } return false; }())) { - dictationMatched = true; auto dsym = triggerRawSym_ != 0 ? triggerRawSym_ : static_cast(triggerKeyList_[0].sym()); @@ -265,6 +281,27 @@ class OpenLess final : public AddonInstance, rebuildTriggerKeys(); } + void setCustomDictationTrigger(const std::string &keyString) { + Key key(keyString); + if (!key.isValid()) { + FCITX_LOGC(openless, Warn) + << "SetCustomDictationTrigger: invalid key '" << keyString << "'"; + hasCustomDictationKey_ = false; + return; + } + customDictationKey_ = key; + hasCustomDictationKey_ = true; + // 有自定义键时清空已有 raw+keylist 路径,避免双发 + triggerRawSym_ = 0; + triggerRawStates_ = 0; + config_.triggerKey.setValue(KeyList{}); + safeSaveAsIni(config_, configFile()); + FCITX_LOGC(openless, Info) + << "SetCustomDictationTrigger: '" << keyString << "'" + << " sym=" << static_cast(key.sym()) + << " states=" << static_cast(key.states()); + } + void setQaHotkeyRaw(uint32_t sym, uint32_t states) { qaRawSym_ = sym; qaRawStates_ = states; @@ -292,6 +329,7 @@ class OpenLess final : public AddonInstance, FCITX_OBJECT_VTABLE_METHOD(commitText, "CommitText", "s", ""); FCITX_OBJECT_VTABLE_METHOD(setHotkey, "SetHotkey", "as", ""); FCITX_OBJECT_VTABLE_METHOD(setHotkeyRaw, "SetHotkeyRaw", "uu", ""); + FCITX_OBJECT_VTABLE_METHOD(setCustomDictationTrigger, "SetCustomDictationTrigger", "s", ""); FCITX_OBJECT_VTABLE_METHOD(setQaHotkeyRaw, "SetQaHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setTranslationHotkeyRaw, "SetTranslationHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub"); @@ -360,6 +398,8 @@ class OpenLess final : public AddonInstance, uint32_t qaRawStates_; uint32_t translationRawSym_; uint32_t translationRawStates_; + Key customDictationKey_; + bool hasCustomDictationKey_; /// 快捷键按下时保存的输入上下文指针,用于 commitText 在失焦后仍能提交文字。 /// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。 /// 通过 InputContextDestroyed 事件监听 IC 销毁时自动清空指针。 From 088e2ab3d988046d58d40349ff84a364e73fe4b0 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sat, 16 May 2026 09:38:21 +0800 Subject: [PATCH 12/15] fix(ci): derive RPM fcitx5 plugin path from cmake detection Map Debian multiarch addon dir to /usr/lib64 for RPM packages. Uses FCITX_RPM_ADDON_DIR derived from cmake output instead of hardcoded paths. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release-tauri.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index a8f2038c..73d59154 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -359,6 +359,11 @@ jobs: echo "Detected: addon=$FCITX_ADDON_DIR pkgdata=$FCITX_PKGDATA_DIR" echo "FCITX_ADDON_DIR=$FCITX_ADDON_DIR" >> "$GITHUB_ENV" echo "FCITX_ADDON_CONF_DIR=${FCITX_PKGDATA_DIR}/addon" >> "$GITHUB_ENV" + # 对 RPM 目标映射路径:Debian multiarch(如 /usr/lib/x86_64-linux-gnu) + # -> /usr/lib64(RPM 标准)。conf 路径跨发行版一致。 + RPM_ADDON_DIR=$(echo "$FCITX_ADDON_DIR" \ + | sed 's|/usr/lib/[^/]*/fcitx5|/usr/lib64/fcitx5|;s|/usr/lib/x86_64-linux-gnu/fcitx5|/usr/lib64/fcitx5|') + echo "FCITX_RPM_ADDON_DIR=$RPM_ADDON_DIR" >> "$GITHUB_ENV" # 把插件 .so + .conf 复制到 src-tauri/linux-fcitx5-plugin/ 下面, # 供 tauri deb/rpm bundler 的 files 配置使用。 mkdir -p "$GITHUB_WORKSPACE/openless-all/app/src-tauri/linux-fcitx5-plugin" @@ -392,8 +397,8 @@ jobs: "rpm": { "depends": ["fcitx5", "fcitx5-module-dbus"], "files": { - "/usr/lib64/fcitx5/libopenless.so": "linux-fcitx5-plugin/libopenless.so", - "/usr/share/fcitx5/addon/openless.conf": "linux-fcitx5-plugin/openless.conf" + "${FCITX_RPM_ADDON_DIR}/libopenless.so": "linux-fcitx5-plugin/libopenless.so", + "${FCITX_ADDON_CONF_DIR}/openless.conf": "linux-fcitx5-plugin/openless.conf" } } } From 416da6038c5a32d0ee39889e22f5caac6b1bdeea Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sat, 16 May 2026 09:45:57 +0800 Subject: [PATCH 13/15] fix(linux): allow single-key custom triggers in fcitx5 plugin sync Co-Authored-By: Claude Opus 4.7 --- openless-all/app/src-tauri/src/coordinator.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9f0dd656..f592b16d 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1590,10 +1590,6 @@ fn is_builtin_translation_shift(binding: &crate::types::ShortcutBinding) -> bool fn sync_custom_dictation_to_plugin(inner: &Arc) { let prefs = inner.prefs.get(); let dictation = &prefs.dictation_hotkey; - // 只有 Custom 组合键才需要走插件同步 - if dictation.modifiers.is_empty() { - return; - } let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(dictation); if key_string.is_empty() { return; From 9cd8b9a8fc37da6e5d65db7dea78501cce980093 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sat, 16 May 2026 09:53:25 +0800 Subject: [PATCH 14/15] fix(linux): clear previous hotkey trigger when switching between custom combo and preset modifier setHotkey/setHotkeyRaw now clear hasCustomDictationKey_ to prevent stale custom combo from persisting after switching to a preset modifier. setCustomDictationTrigger now persists TriggerRawSym=TriggerRawStates=0 to raw config so the old raw trigger doesn't reload after fcitx5 restart. Co-Authored-By: Claude Opus 4.7 --- .../scripts/linux-fcitx5-plugin/openless.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp index 848ebb31..9460cb06 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -232,6 +232,8 @@ class OpenLess final : public AddonInstance, } void setHotkey(const std::vector &keys) { + // 切换预设修饰键时清空自定义组合键,避免双发 + hasCustomDictationKey_ = false; KeyList keyList; for (const auto &s : keys) { Key key(s); @@ -260,6 +262,8 @@ class OpenLess final : public AddonInstance, } void setHotkeyRaw(uint32_t sym, uint32_t states) { + // 切换预设修饰键时清空自定义组合键,避免双发 + hasCustomDictationKey_ = false; triggerRawSym_ = sym; triggerRawStates_ = states; // 同时尝试维护 KeyList(如果 sym 可转为有效 key) @@ -295,7 +299,15 @@ class OpenLess final : public AddonInstance, triggerRawSym_ = 0; triggerRawStates_ = 0; config_.triggerKey.setValue(KeyList{}); - safeSaveAsIni(config_, configFile()); + // 同时持久化清空 TriggerRawSym/TriggerRawStates,防止 fcitx5 重启后从 INI 加载旧值 + { + RawConfig raw; + readAsIni(raw, configFile()); + config_.save(raw); + raw.setValueByPath("TriggerRawSym", "0"); + raw.setValueByPath("TriggerRawStates", "0"); + safeSaveAsIni(raw, configFile()); + } FCITX_LOGC(openless, Info) << "SetCustomDictationTrigger: '" << keyString << "'" << " sym=" << static_cast(key.sym()) From f043176d1987b6e2dd3acbea2719fa3af2e4ffaa Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Sat, 16 May 2026 11:03:46 +0800 Subject: [PATCH 15/15] fix(linux): correct Fn hotkey mapping from Super_L to RightControl Fn key does not generate a standard X11 keysym on Linux. The previous mapping to Super_L (0xffeb) was incorrect and would bind the Windows/Super key instead. Map to RightControl (0xffe4) to match the existing fallback convention used on Windows (VK_RCONTROL) and in the coordinator's hotkey matching logic. Co-Authored-By: Claude Opus 4.7 --- openless-all/app/src-tauri/src/linux_fcitx.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs index cf19dbcf..1ca3e5a2 100644 --- a/openless-all/app/src-tauri/src/linux_fcitx.rs +++ b/openless-all/app/src-tauri/src/linux_fcitx.rs @@ -99,7 +99,7 @@ fn trigger_to_keysym(trigger: crate::types::HotkeyTrigger) -> u32 { crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => KEYSYM_ALT_R, crate::types::HotkeyTrigger::LeftOption => KEYSYM_ALT_L, crate::types::HotkeyTrigger::RightCommand => KEYSYM_SUPER_R, - crate::types::HotkeyTrigger::Fn => KEYSYM_SUPER_L, + crate::types::HotkeyTrigger::Fn => KEYSYM_CONTROL_R, crate::types::HotkeyTrigger::Custom => unreachable!(), } } @@ -111,7 +111,7 @@ fn trigger_name(trigger: crate::types::HotkeyTrigger) -> &'static str { crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => "Alt_R", crate::types::HotkeyTrigger::LeftOption => "Alt_L", crate::types::HotkeyTrigger::RightCommand => "Super_R", - crate::types::HotkeyTrigger::Fn => "Super_L", + crate::types::HotkeyTrigger::Fn => "Control_R", crate::types::HotkeyTrigger::Custom => unreachable!(), } }