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] 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") + // 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 } - 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")] { @@ -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);