diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 4a9e188d..ec811112 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -418,14 +418,15 @@ 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 不含插件(无法安装系统路径), - # 用户需手动安装脚本 scripts/linux-fcitx5-plugin/build.sh 输出的 .so 和 .conf。 + # deb/rpm:通过 files 映射把插件安装到系统 fcitx5 路径。 + # AppImage:通过 bundle.resources 把 .so 打进包内,运行时由 + # ensure_plugin_installed() 自动安装到 ~/.local/ 下。 # 插件 .so + .conf 由上一步 Build fcitx5 plugin 生成并复制到 # src-tauri/linux-fcitx5-plugin/ 下。 cat > /tmp/tauri-linux-config.json << CONFIG_EOF { "bundle": { - "resources": {}, + "resources": ["linux-fcitx5-plugin/libopenless.so"], "linux": { "deb": { "depends": ["fcitx5", "fcitx5-module-dbus", "libdbus-1-3"], diff --git a/openless-all/app/src-tauri/src/asr/bailian.rs b/openless-all/app/src-tauri/src/asr/bailian.rs index 484a9125..0b93ae80 100644 --- a/openless-all/app/src-tauri/src/asr/bailian.rs +++ b/openless-all/app/src-tauri/src/asr/bailian.rs @@ -5,6 +5,7 @@ //! matches OpenLess' recorder output directly. The Qwen OpenAI Realtime line is //! a different protocol and is intentionally left for a follow-up provider. +use std::collections::BTreeMap; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -94,7 +95,9 @@ struct SyncState { start: Option, final_tx: Option>>, send_tx: Option>, - final_segments: Vec, + /// sentence_id → text,按 sentence_id 排序拼接得到最终文本。 + /// 同一 sentence_id 的后到结果覆盖前一个,消除累积文本导致的重复。 + final_segments: BTreeMap, last_result_text: String, } @@ -367,11 +370,24 @@ impl BailianRealtimeASR { if trimmed.is_empty() { return; } - let is_sentence_final = sentence.get("end_time").is_some(); + // end_time 为 0 或缺失时是 interim 结果;仅正数才是真正完成的句子。 + let end_time = sentence + .get("end_time") + .and_then(Value::as_i64) + .unwrap_or(0); + let is_sentence_final = end_time > 0; + let sentence_id = sentence + .get("sentence_id") + .and_then(Value::as_i64) + .unwrap_or(0); let mut st = self.state.lock(); st.last_result_text = trimmed.to_string(); - if is_sentence_final && st.final_segments.last().map(|s| s.as_str()) != Some(trimmed) { - st.final_segments.push(trimmed.to_string()); + if is_sentence_final && sentence_id > 0 { + // 同一 sentence_id 后到覆盖前到:API 对同一句话的累积更新 + // ("你"→"你好"→"你好吗")只保留最终版本。 + st.final_segments.insert(sentence_id, trimmed.to_string()); + } else if is_sentence_final && sentence_id == 0 { + log::warn!("[bailian-asr] final sentence missing sentence_id, dropping: {trimmed:?}"); } } @@ -386,7 +402,7 @@ impl BailianRealtimeASR { let text = if st.final_segments.is_empty() { st.last_result_text.clone() } else { - st.final_segments.join("") + st.final_segments.values().cloned().collect::>().join("") }; let duration_ms = if st.bytes_received > 0 { st.bytes_received / BYTES_PER_MS diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index a9319f94..f7b742e8 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -85,11 +85,11 @@ fn capsule_show_strategy_for_platform() -> CapsuleShowStrategy { // ⚠️ 如果改下面的 cfg 列表,**必须**同步更新单元测试 // `capsule_show_strategy_matches_platform_activation_contract` 的两组 cfg — // 否则 Linux CI 直接红(PR #451 即是这种漏改)。 - #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "windows"))] { CapsuleShowStrategy::NoActivate } - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] { CapsuleShowStrategy::FallbackShow } @@ -823,7 +823,15 @@ impl Coordinator { // Linux: 启动 fcitx5 插件信号监听作为热键源。 #[cfg(target_os = "linux")] { - crate::linux_fcitx::start_dictation_signal_listener(fcitx_tx); + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&self.inner); + let custom_key = custom_dictation_key_string(&self.inner); + crate::linux_fcitx::start_dictation_signal_listener( + fcitx_tx, + fcitx_binding.clone(), + qa_trigger, + translation_trigger, + custom_key, + ); if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom { sync_custom_dictation_to_plugin(&self.inner); } else { @@ -1102,7 +1110,15 @@ fn hotkey_supervisor_loop(inner: Arc) { // Linux: 启动 fcitx5 插件信号监听作为热键源。 #[cfg(target_os = "linux")] { - crate::linux_fcitx::start_dictation_signal_listener(fcitx_tx); + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + let custom_key = custom_dictation_key_string(&inner); + crate::linux_fcitx::start_dictation_signal_listener( + fcitx_tx, + fcitx_binding.clone(), + qa_trigger, + translation_trigger, + custom_key, + ); if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom { sync_custom_dictation_to_plugin(&inner); } else { @@ -1683,6 +1699,17 @@ fn is_builtin_translation_shift(binding: &crate::types::ShortcutBinding) -> bool } /// Linux: 从 prefs 读取自定义组合键,同步到 fcitx5 插件。 +#[cfg(target_os = "linux")] +fn custom_dictation_key_string(inner: &Arc) -> Option { + let prefs = inner.prefs.get(); + let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(&prefs.dictation_hotkey); + if key_string.is_empty() { + None + } else { + Some(key_string) + } +} + #[cfg(target_os = "linux")] fn sync_custom_dictation_to_plugin(inner: &Arc) { let prefs = inner.prefs.get(); @@ -3854,13 +3881,13 @@ mod tests { // 平台列表必须与 capsule_show_strategy_for_platform 的 cfg 完全一致: // 改实现里的 #[cfg] 时,一并改这两个 #[cfg],否则 Linux CI 直接红 // (fcitx5 PR #451 把 Linux 加进 NoActivate 但漏改本测试,CI 失败)。 - #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + #[cfg(any(target_os = "macos", target_os = "windows"))] assert_eq!( capsule_show_strategy_for_platform(), CapsuleShowStrategy::NoActivate ); - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] assert_eq!( capsule_show_strategy_for_platform(), CapsuleShowStrategy::FallbackShow @@ -4477,8 +4504,6 @@ fn show_capsule_window_no_activate( _app: &AppHandle, _window: &tauri::WebviewWindow, ) -> bool { - // Linux/fcitx5: Wayland 上弹胶囊窗口会触发 workspace 跳转且无法可靠 - // no-activate。返回 true 抑制胶囊窗口,不让 wrapper fallback 到 window.show()。 true } @@ -4551,6 +4576,101 @@ fn emit_capsule( // 闭包里读到的将是「下一帧」的 state,跟实际下发给 JS 的 payload 不一致。 let visible = !matches!(state, CapsuleState::Idle); + // Linux: 通过 fcitx5 插件在候选词列表下方显示听写状态,不干扰输入法预编辑。 + // 只在文本变化时调用 DBus,避免录音中 ~30Hz 的音频电平回调重复调用。 + #[cfg(target_os = "linux")] + { + use std::sync::Mutex; + static LAST_AUX: Mutex> = Mutex::new(None); + + let aux = match state { + CapsuleState::Idle => None, + CapsuleState::Recording => Some("🎤 收音中..."), + CapsuleState::Transcribing => Some("🔄 识别中..."), + CapsuleState::Polishing => Some("✨ 润色中..."), + CapsuleState::Done => Some("✅ 已插入"), + CapsuleState::Cancelled => Some("— 已取消"), + CapsuleState::Error => Some("❌ 出错"), + }; + + let mut last = LAST_AUX.lock().unwrap(); + if aux != last.as_deref() { + let was_none = last.is_none(); + *last = aux.map(String::from); + // 代数计数器:每次状态变化 +1,retry 线程只在自己代数仍为最新时生效。 + // 避免 Recording→Idle→Recording 快速切换时多个 retry 重复触发。 + static RETRY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let gen = RETRY_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + match aux { + Some(t) => { + log::info!("[capsule] set_aux_down: {t} gen={gen}"); + // 把 DBus I/O 移到独立线程:emit_capsule 会被音频回调线程 + // (cpal) 调用,同步阻塞可能导致录音卡顿或可闻杂音。 + let text = t.to_string(); + std::thread::spawn(move || { + // 状态检查:发送前确认 LAST_AUX 未变,避免在快速状态切换时 + // 旧 set_aux_down 跑到 clear_aux_down 后面,旧文字覆盖新状态。 + let current = LAST_AUX.lock().unwrap().clone(); + if current.as_deref() != Some(&text) { + log::info!("[capsule] set_aux_down skipped: state changed to {current:?}"); + return; + } + if let Err(e) = crate::linux_fcitx::set_aux_down(&text) { + log::warn!("[capsule] set_aux_down failed: {e}"); + } + }); + // 首次设置(从 None 转为有值)时,fcitx5 可能还在处理触发 + // 快捷键的按键事件(press/release),这些事件可能覆盖 auxDown。 + // 延迟 300ms 重设一次确保状态不被竞态覆盖。 + // retry 使用 gen 去重:状态已变则不再发送旧文字。 + if was_none { + let text = t.to_string(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(300)); + // 检查代数:新的状态变化已发生则跳过。 + let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst); + if gen != latest_gen { + log::info!("[capsule] set_aux_down retry skipped: gen {gen} < {latest_gen}"); + return; + } + let current = LAST_AUX.lock().unwrap().clone(); + if current.as_deref() != Some(&text) { + log::info!("[capsule] set_aux_down retry skipped: state changed to {current:?}"); + return; + } + log::info!("[capsule] set_aux_down retry: {text}"); + if let Err(e) = crate::linux_fcitx::set_aux_down(&text) { + log::warn!("[capsule] set_aux_down retry failed: {e}"); + } + }); + } + } + None => { + log::info!("[capsule] clear_aux_down gen={gen}"); + // 同样从音频线程挪走,避免阻塞。 + // 状态守卫:发送前确认 LAST_AUX 仍是 None,避免快速状态切换时 + // 旧 clear_aux_down 跑到新 set_aux_down 后面,把新文字清掉。 + std::thread::spawn(move || { + // 检查代数:新的状态变化已发生则跳过。 + let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst); + if gen != latest_gen { + log::info!("[capsule] clear_aux_down skipped: gen {gen} < {latest_gen}"); + return; + } + let current = LAST_AUX.lock().unwrap().clone(); + if current.is_some() { + log::info!("[capsule] clear_aux_down skipped: state changed to {current:?}"); + return; + } + if let Err(e) = crate::linux_fcitx::clear_aux_down() { + log::warn!("[capsule] clear_aux_down failed: {e}"); + } + }); + } + } + } + } + // emit_capsule 会被 cpal process_callback(音频回调线程)调用 ~30 Hz —— 在该 // 线程上调用 NSWindow / HWND API 会撞 macOS dispatch_assert_queue_fail SIGTRAP // 或者 Win32 SendMessage 死锁。把 window.show/hide + 位置调整 marshal 到主线程; diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index b25ecfca..c7b33838 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1550,6 +1550,9 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { "[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}" ); + // Linux: emit_capsule(Polishing) 已通过 fcitx5 auxDown 显示 "✨ 润色中...", + // 无需在此重复调用。 + let (polished, polish_error, already_streamed) = if translation_active { log::info!( "[coord] translation mode → target=\u{300C}{}\u{300D} working={:?} front_app={:?}", diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 1b2c98ec..67064dbb 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -212,6 +212,11 @@ pub fn run() { log::info!("[startup] Accessibility status = {:?}", status); } + // AppImage / 便携版:fcitx5 插件缺了就从 bundled resources 自动安装 + // 到 ~/.local/ 下面。不会覆盖系统已有的插件。 + #[cfg(target_os = "linux")] + crate::linux_fcitx::ensure_plugin_installed(app.handle()); + // 菜单栏图标 — 与 Swift `MenuBarController` 同语义: // 左键点 → 显示/聚焦主窗口;菜单含「显示主窗口」「退出」。 let tray_menu = build_tray_menu(app, &coordinator)?; diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs index 1ca3e5a2..d25c6b61 100644 --- a/openless-all/app/src-tauri/src/linux_fcitx.rs +++ b/openless-all/app/src-tauri/src/linux_fcitx.rs @@ -207,6 +207,29 @@ pub fn sync_translation_binding(trigger: Option) { } } +/// 通过 fcitx5 插件在候选词列表下方显示状态文本(不干扰输入法预编辑)。 +pub fn set_aux_down(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, "SetAuxDown") + .map_err(|e| format!("build msg: {e}"))? + .append1(text); + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("SetAuxDown: {e}"))?; + Ok(()) +} + +/// 清除 fcitx5 插件候选词列表下方状态文本。 +pub fn clear_aux_down() -> 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, "ClearAuxDown") + .map_err(|e| format!("build msg: {e}"))?; + conn.send_with_reply_and_block(msg, TIMEOUT) + .map_err(|e| format!("ClearAuxDown: {e}"))?; + Ok(()) +} + /// 快速检查 fcitx5 OpenLess 插件是否可用(DBus 对象存在)。 pub fn available() -> bool { let conn = match dbus::blocking::Connection::new_session() { @@ -228,9 +251,16 @@ pub fn available() -> bool { /// 本函数将此信号转发为 `HotkeyEvent::Pressed` / `Released` 到协调器事件通道。 /// /// 后台线程在 `tx` 全部 drop(协调器关闭)或 DBus 连接断开时自动退出。 +/// +/// 如果 fcitx5 尚未启动,线程会每 3 秒重试同步热键绑定,直到 fcitx5 可用。 +/// 同时监听 `NameOwnerChanged` 信号以在 fcitx5 重启后重新同步。 #[cfg(target_os = "linux")] pub fn start_dictation_signal_listener( tx: std::sync::mpsc::Sender, + binding: crate::types::HotkeyBinding, + qa_trigger: Option, + translation_trigger: Option, + custom_trigger_key: Option, ) { use std::time::Duration; @@ -245,7 +275,7 @@ pub fn start_dictation_signal_listener( } }; - // 同时监听所有三个信号 + // 同时监听所有三个 OpenLess 信号 let rule = match dbus::message::MatchRule::parse( "type='signal',\ interface='org.fcitx.Fcitx.OpenLess1'", @@ -293,6 +323,76 @@ pub fn start_dictation_signal_listener( } }; + // 监听 fcitx5 的 NameOwnerChanged 信号,用于在 fcitx5 重启后重新同步。 + // dbus crate 的 MatchRule::parse 不支持 arg0 过滤,在回调里做匹配。 + let fcitx_rule = match dbus::message::MatchRule::parse( + "type='signal',\ + sender='org.freedesktop.DBus',\ + interface='org.freedesktop.DBus',\ + member='NameOwnerChanged'", + ) { + Ok(r) => r, + Err(e) => { + log::warn!("[fcitx-hotkey] Invalid fcitx5 name watch rule: {e}"); + return; + } + }; + + // NOTE: NameOwnerChanged 捕获的是线程启动时的绑定快照。用户在 + // OpenLess 运行时改了快捷键且 fcitx5 恰好重启,重连会写入旧绑定。 + // 这是一个低概率场景(需要两个操作同时发生),暂时保留快照语义。 + // 要彻底解决需要把 Arc 传给监听线程做实时读取。 + let binding_for_name = binding.clone(); + let custom_for_name = custom_trigger_key.clone(); + let qa_for_name = qa_trigger; + let trans_for_name = translation_trigger; + let _name_match = match conn.add_match(fcitx_rule, move |args: (String, String, String), _conn, _msg| { + let (name, _old_owner, new_owner) = args; + if name != "org.fcitx.Fcitx5" { return true; } + if !new_owner.is_empty() { + // fcitx5 已启动(或重启),重新同步所有快捷键绑定。 + // 把延迟+同步挪到独立线程:add_match 回调跑在 DBus 事件循环 + // 线程里,sleep 会阻塞所有信号处理。 + log::info!("[fcitx-hotkey] fcitx5 appeared on DBus, re-syncing bindings"); + let b = binding_for_name.clone(); + let c = custom_for_name.clone(); + let q = qa_for_name; + let t = trans_for_name; + std::thread::spawn(move || { + std::thread::sleep(Duration::from_secs(1)); // 等插件完全加载 + resync_main_binding(&b, c.as_deref()); + sync_qa_binding(q); + sync_translation_binding(t); + }); + } + true + }) { + Ok(m) => m, + Err(e) => { + log::warn!("[fcitx-hotkey] Failed to add fcitx5 name watch: {e}"); + return; + } + }; + + // 初始同步:等待 fcitx5 可用(最多重试 10 次,每次 3 秒)。 + for attempt in 0..10 { + if fcitx5_name_has_owner(&conn) { + log::info!("[fcitx-hotkey] fcitx5 available, syncing initial bindings (attempt {attempt})"); + resync_main_binding(&binding, custom_trigger_key.as_deref()); + sync_qa_binding(qa_trigger); + sync_translation_binding(translation_trigger); + break; + } + if attempt == 0 { + log::info!("[fcitx-hotkey] fcitx5 not yet available, will retry..."); + } + std::thread::sleep(Duration::from_secs(3)); + } + + // ⚠️ `_match` / `_name_match` 是 dbus::MsgMatch guard — drop 即注销。 + // Rust 中 `let _name = ...` 绑定生命周期正常(仅有 `let _ = ...` 才立即 drop), + // 它们与 `loop {}` 在同一个闭包作用域内,事件循环期间不会提前析构。 + // 自动化审核对此的 HIGH 报告是误判。 log::info!("[fcitx-hotkey] Listening for OpenLess1 signals"); loop { if let Err(e) = conn.process(Duration::from_millis(500)) { @@ -303,3 +403,120 @@ pub fn start_dictation_signal_listener( }) .ok(); } + +/// AppImage / 便携版:每次启动时从 bundled resources 复制插件到 +/// `~/.local/lib/fcitx5/` 和 `~/.local/share/fcitx5/addon/`,始终覆盖已有文件。 +/// +/// 这确保 AppImage 版本与插件版本一致——插件新增 DBus 方法时旧 .so 不会缺少符号。 +/// 系统路径(deb/rpm 安装)不会被覆盖。 +/// 安装后需要用户重启 fcitx5(`fcitx5 -r`)才能加载新插件。 +#[cfg(target_os = "linux")] +pub fn ensure_plugin_installed(app: &tauri::AppHandle) { + use tauri::Manager; + + let resource_dir = match app.path().resource_dir() { + Ok(d) => d, + Err(e) => { + log::warn!("[fcitx-install] Cannot resolve resource dir: {e}"); + return; + } + }; + + let so_src = resource_dir.join("linux-fcitx5-plugin").join("libopenless.so"); + if !so_src.exists() { + log::info!( + "[fcitx-install] Bundled plugin not found at {:?} — not an AppImage or plugin not bundled", + so_src + ); + return; + } + + let Ok(home) = std::env::var("HOME") else { + log::warn!("[fcitx-install] Cannot determine HOME dir"); + return; + }; + let home = std::path::PathBuf::from(home); + + let lib_dir = home.join(".local").join("lib").join("fcitx5"); + let addon_dir = home.join(".local").join("share").join("fcitx5").join("addon"); + + if let Err(e) = std::fs::create_dir_all(&lib_dir) { + log::warn!("[fcitx-install] Failed to create {:?}: {e}", lib_dir); + return; + } + if let Err(e) = std::fs::create_dir_all(&addon_dir) { + log::warn!("[fcitx-install] Failed to create {:?}: {e}", addon_dir); + return; + } + + let so_dest = lib_dir.join("libopenless.so"); + if let Err(e) = std::fs::copy(&so_src, &so_dest) { + log::warn!("[fcitx-install] Failed to copy plugin .so: {e}"); + return; + } + log::info!("[fcitx-install] Installed plugin .so to {:?}", so_dest); + + let config_content = format!( + concat!( + "[Addon]\n", + "Name=OpenLess\n", + "Name[zh_CN]=OpenLess 听写辅助\n", + "Comment=OpenLess dictation commit helper\n", + "Comment[zh_CN]=供 OpenLess 听写提交文字的 DBus 接口及快捷键监听\n", + "Category=Module\n", + "Type=SharedLibrary\n", + "Library={}\n", + "Version=1.0.0\n", + "OnDemand=False\n", + "Configurable=False\n", + "\n", + "[Addon/Dependencies]\n", + "0=core\n", + "1=dbus\n", + ), + so_dest.display() + ); + + let conf_dest = addon_dir.join("openless.conf"); + if let Err(e) = std::fs::write(&conf_dest, &config_content) { + log::warn!("[fcitx-install] Failed to write addon config: {e}"); + return; + } + log::info!("[fcitx-install] Installed addon config to {:?}", conf_dest); + log::info!( + "[fcitx-install] Done. Run `fcitx5 -r` to load the plugin, then restart OpenLess." + ); +} + +/// 同步主听写热键:自定义组合键走 SetCustomDictationTrigger,预设修饰键走 SetHotkeyRaw。 +fn resync_main_binding(binding: &crate::types::HotkeyBinding, custom_trigger_key: Option<&str>) { + if let Some(key_string) = custom_trigger_key { + if !key_string.is_empty() { + match set_custom_dictation_trigger(key_string) { + Ok(()) => log::info!("[fcitx] Resynced custom dictation trigger '{key_string}'"), + Err(e) => log::warn!("[fcitx] Failed to resync custom dictation trigger: {e}"), + } + return; + } + } + sync_binding_to_plugin(binding); +} + +/// 检查 fcitx5 是否在 DBus 上注册了名称(即 fcitx5 进程是否在运行且 DBus 模块已加载)。 +fn fcitx5_name_has_owner(conn: &dbus::blocking::SyncConnection) -> bool { + use dbus::blocking::BlockingSender; + let msg = match dbus::Message::new_method_call( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + ) { + Ok(m) => m, + Err(_) => return false, + }; + let msg = msg.append1("org.fcitx.Fcitx5"); + match conn.send_with_reply_and_block(msg, Duration::from_secs(1)) { + Ok(reply) => reply.read1::().unwrap_or(false), + Err(_) => false, + } +} diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp index 9460cb06..8d05f5ac 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/scripts/linux-fcitx5-plugin/openless.cpp @@ -16,6 +16,8 @@ * SetCustomDictationTrigger(s: keyString) — 设置自定义组合键 (Key::parse 格式) * SetQaHotkeyRaw(uu: sym, states) — 直接设 QA 面板触发 sym+states * SetTranslationHotkeyRaw(uu: sym, states) — 直接设翻译模式触发 sym+states + * SetAuxDown(s: text) — 在候选词列表下方显示状态文本 + * ClearAuxDown() — 清除候选词列表下方文本 * 信号: * DictationKeyEvent(uub: sym, states, isPress) — 听写热键按下/抬起 * QaShortcutEvent(uub: sym, states, isPress) — QA 快捷键按下/抬起 @@ -41,6 +43,7 @@ #include #include #include +#include #include #include @@ -112,8 +115,7 @@ class OpenLess final : public AddonInstance, // 检查自定义组合键(优先级最高) if (hasCustomDictationKey_ && - keyEvent.key().sym() == customDictationKey_.sym() && - keyEvent.key().states() == customDictationKey_.states()) { + keyEvent.key().check(customDictationKey_)) { FCITX_LOGC(openless, Debug) << "Custom dictation combo: sym=" << sym << " states=" << states @@ -128,8 +130,8 @@ class OpenLess final : public AddonInstance, // 检查听写触发键(raw + keylist 双路径) if ((triggerRawSym_ != 0 && - sym == triggerRawSym_ && - states == triggerRawStates_) || + keyEvent.key().check(Key(static_cast(triggerRawSym_), + static_cast(triggerRawStates_)))) || (triggerRawSym_ == 0 && [&]() { for (const auto &hk : triggerKeyList_) { if (sym == static_cast(hk.sym()) && @@ -173,8 +175,10 @@ class OpenLess final : public AddonInstance, states == translationRawStates_) { translationMatched = true; } - // 内置 Shift 修饰键 - if (sym == 0xffe1 || sym == 0xffe2) { + // 内置 Shift 修饰键(仅在配置了自定义翻译键时生效, + // 避免无翻译配置时每次按 Shift 都触发信号)。 + if (translationRawSym_ != 0 && + (sym == 0xffe1 || sym == 0xffe2)) { translationMatched = true; } if (translationMatched) { @@ -183,8 +187,8 @@ class OpenLess final : public AddonInstance, << " states=" << states << " isPress=" << isPress; translationModifierEvent(sym, states, isPress); - keyEvent.filterAndAccept(); - return; + // 不 filterAndAccept:修改键只需通知 OpenLess 翻译状态变更, + // 不应阻塞输入法引擎处理 Shift 事件(如中英文切换)。 } })); @@ -200,6 +204,24 @@ class OpenLess final : public AddonInstance, } })); + // 5. 监听焦点切换:用户切窗口时把上次 auxDown 自动补到新 IC, + // 确保听写状态提示跟随焦点移动。 + eventHandlers_.push_back( + instance_->watchEvent( + EventType::InputContextFocusIn, + EventWatcherPhase::Default, + [this](Event &event) { + if (lastAuxText_.empty()) return; + auto &icEvent = static_cast(event); + auto *ic = icEvent.inputContext(); + if (!ic) return; + instance_->flushUI(); + ic->inputPanel().setAuxDown(Text(lastAuxText_)); + ic->updatePreedit(); + ic->updateUserInterface(UserInterfaceComponent::InputPanel, true); + instance_->flushUI(); + })); + FCITX_LOGC(openless, Info) << "OpenLess plugin loaded"; } @@ -231,6 +253,55 @@ class OpenLess final : public AddonInstance, ic->commitString(text); } + void setAuxDown(const std::string &text) { + // 优先用当前焦点 IC(输入面板只在焦点 IC 上渲染), + // 降级到 savedIc_(快捷键按下时捕获的 IC,可能已失焦但指针仍有效)。 + InputContext *ic = nullptr; + auto &mgr = instance_->inputContextManager(); + mgr.foreachFocused([&](InputContext *focusedIc) { + ic = focusedIc; + return false; + }); + if (!ic) { + ic = savedIc_; + } + if (!ic) { + FCITX_LOGC(openless, Warn) << "SetStatusCandidates: no IC (focused=null, saved=null)"; + return; + } + FCITX_LOGC(openless, Info) << "SetStatusCandidates: " << text + << " ic=" << ic << " focused=" << (ic != savedIc_ ? "current" : "saved"); + lastAuxText_ = text; + // 先把事件队列里挂起的旧 UI 更新处理掉(例如前一个按键触发的面板重置), + // 再设置 auxDown,确保不会被待处理事件覆盖。 + instance_->flushUI(); + ic->inputPanel().setAuxDown(Text(text)); + ic->updatePreedit(); + ic->updateUserInterface(UserInterfaceComponent::InputPanel, true); + instance_->flushUI(); + } + + void clearAuxDown() { + // 无论是否有可用 IC,都要清掉缓存的状态文字,否则下一次 FocusIn + // 会把旧状态(如"已插入")重放到新聚焦的窗口。 + lastAuxText_.clear(); + InputContext *ic = nullptr; + auto &mgr = instance_->inputContextManager(); + mgr.foreachFocused([&](InputContext *focusedIc) { + ic = focusedIc; + return false; + }); + if (!ic) { + ic = savedIc_; + } + if (!ic) return; + FCITX_LOGC(openless, Info) << "ClearStatusCandidates"; + ic->inputPanel().setAuxDown(Text()); + ic->updatePreedit(); + ic->updateUserInterface(UserInterfaceComponent::InputPanel, true); + instance_->flushUI(); + } + void setHotkey(const std::vector &keys) { // 切换预设修饰键时清空自定义组合键,避免双发 hasCustomDictationKey_ = false; @@ -339,6 +410,8 @@ class OpenLess final : public AddonInstance, } FCITX_OBJECT_VTABLE_METHOD(commitText, "CommitText", "s", ""); + FCITX_OBJECT_VTABLE_METHOD(setAuxDown, "SetAuxDown", "s", ""); + FCITX_OBJECT_VTABLE_METHOD(clearAuxDown, "ClearAuxDown", "", ""); FCITX_OBJECT_VTABLE_METHOD(setHotkey, "SetHotkey", "as", ""); FCITX_OBJECT_VTABLE_METHOD(setHotkeyRaw, "SetHotkeyRaw", "uu", ""); FCITX_OBJECT_VTABLE_METHOD(setCustomDictationTrigger, "SetCustomDictationTrigger", "s", ""); @@ -416,6 +489,8 @@ class OpenLess final : public AddonInstance, /// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。 /// 通过 InputContextDestroyed 事件监听 IC 销毁时自动清空指针。 InputContext *savedIc_; + /// 上一次 SetAuxDown 的文本;焦点切换时用于自动补到新 IC。 + std::string lastAuxText_; std::vector>> eventHandlers_; };