Skip to content
Open
7 changes: 4 additions & 3 deletions .github/workflows/release-tauri.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Empty file.
24 changes: 19 additions & 5 deletions openless-all/app/src-tauri/src/asr/bailian.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -94,7 +95,9 @@ struct SyncState {
start: Option<Instant>,
final_tx: Option<oneshot::Sender<Result<RawTranscript, BailianASRError>>>,
send_tx: Option<mpsc::UnboundedSender<SendItem>>,
final_segments: Vec<String>,
/// sentence_id → text,按 sentence_id 排序拼接得到最终文本。
/// 同一 sentence_id 的后到结果覆盖前一个,消除累积文本导致的重复。
final_segments: BTreeMap<i64, String>,
last_result_text: String,
}

Expand Down Expand Up @@ -367,11 +370,22 @@ 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());
}
}

Expand All @@ -386,7 +400,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::<Vec<_>>().join("")
};
let duration_ms = if st.bytes_received > 0 {
st.bytes_received / BYTES_PER_MS
Expand Down
106 changes: 98 additions & 8 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1102,7 +1110,15 @@ fn hotkey_supervisor_loop(inner: Arc<Inner>) {
// 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 {
Expand Down Expand Up @@ -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<Inner>) -> Option<String> {
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<Inner>) {
let prefs = inner.prefs.get();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -4477,8 +4504,6 @@ fn show_capsule_window_no_activate<R: tauri::Runtime>(
_app: &AppHandle<R>,
_window: &tauri::WebviewWindow<R>,
) -> bool {
// Linux/fcitx5: Wayland 上弹胶囊窗口会触发 workspace 跳转且无法可靠
// no-activate。返回 true 抑制胶囊窗口,不让 wrapper fallback 到 window.show()。
true
}

Expand Down Expand Up @@ -4551,6 +4576,71 @@ 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<Option<String>> = 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);
match aux {
Some(t) => {
log::info!("[capsule] set_aux_down: {t}");
// 把 DBus I/O 移到独立线程:emit_capsule 会被音频回调线程
// (cpal) 调用,同步阻塞可能导致录音卡顿或可闻杂音。
let text = t.to_string();
std::thread::spawn(move || {
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 重设一次确保状态不被竞态覆盖。
// 重设前检查 LAST_AUX:如果状态已经变了则跳过,避免旧文字覆盖新状态。
if was_none {
let text = t.to_string();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(300));
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");
// 同样从音频线程挪走,避免阻塞。
std::thread::spawn(|| {
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 到主线程;
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1550,6 +1550,9 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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={:?}",
Expand Down
5 changes: 5 additions & 0 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
Loading
Loading