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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
26 changes: 21 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,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:?}");
}
}

Expand All @@ -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::<Vec<_>>().join("")
};
let duration_ms = if st.bytes_received > 0 {
st.bytes_received / BYTES_PER_MS
Expand Down
136 changes: 128 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,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<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);
// 代数计数器:每次状态变化 +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 到主线程;
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