refactor(coordinator): 抽出 capsule 子模块
机械式行为保持拆分:从 coordinator.rs 搬出 capsule HUD 呈现层 —— 显示策略、 emit_capsule 状态广播、布局快照/底部居中定位、自动回 Idle 调度,及相关 const/static。 emit_capsule 的 ~30Hz 音频回调线程 run_on_main_thread marshaling 原样保留。 CapsuleLayoutState 经 glob 重导出供 Inner 字段类型可见。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
64e97f5a8c
commit
0430c3ec32
2 files changed
+360
-351
No files matched your search
@@ -61,6 +61,7 @@ use crate::windows_ime_ipc::ImeSubmitTarget;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController};
|
||||
|
||||
mod capsule;
|
||||
mod dictation;
|
||||
mod hotkey_supervisors;
|
||||
mod ime_insertion;
|
||||
@@ -70,6 +71,7 @@ mod voice_agent_hotkeys;
|
||||
|
||||
// glob 重导出:让 dictation.rs/qa.rs/resources.rs/impl 里所有 `super::裸名`
|
||||
// 引用继续通过父模块解析(拆分前的 `use super::*` 契约)。
|
||||
pub(crate) use capsule::*;
|
||||
pub(crate) use hotkey_supervisors::*;
|
||||
pub(crate) use ime_insertion::*;
|
||||
pub(crate) use voice_agent_hotkeys::*;
|
||||
@@ -91,69 +93,6 @@ use resources::{
|
||||
SessionResource, SharedRecordingMuteState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum CapsuleShowStrategy {
|
||||
NoActivate,
|
||||
FallbackShow,
|
||||
}
|
||||
|
||||
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"))]
|
||||
{
|
||||
CapsuleShowStrategy::NoActivate
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
CapsuleShowStrategy::FallbackShow
|
||||
}
|
||||
}
|
||||
|
||||
static CAPSULE_NO_ACTIVATE_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false);
|
||||
static CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
static CAPSULE_FIRST_SHOW_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
// #470 诊断 v2:capsule webview 句柄取不到时的一次性门,区分「窗口压根没创建」(A0)。
|
||||
static CAPSULE_WINDOW_MISSING_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 给 #470 诊断日志用的 capsule 状态短名。显式枚举每个变体到 &'static str,
|
||||
/// 不走 `Debug` —— 哪天 CapsuleState 加了 `String` 字段,`:?` 会把 ASR / polish
|
||||
/// 内容意外灌进日志(pr_agent 提的 forward-looking 隐患);这里只输出状态名。
|
||||
fn capsule_state_log_name(state: CapsuleState) -> &'static str {
|
||||
match state {
|
||||
CapsuleState::Idle => "idle",
|
||||
CapsuleState::Recording => "recording",
|
||||
CapsuleState::Transcribing => "transcribing",
|
||||
CapsuleState::Polishing => "polishing",
|
||||
CapsuleState::Done => "done",
|
||||
CapsuleState::Cancelled => "cancelled",
|
||||
CapsuleState::Error => "error",
|
||||
}
|
||||
}
|
||||
|
||||
fn show_capsule_window_for_recording<R: tauri::Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
) {
|
||||
let mut needs_fallback = true;
|
||||
if capsule_show_strategy_for_platform() == CapsuleShowStrategy::NoActivate {
|
||||
needs_fallback = !show_capsule_window_no_activate(app, window);
|
||||
if needs_fallback && !CAPSULE_NO_ACTIVATE_FALLBACK_WARNED.swap(true, Ordering::SeqCst) {
|
||||
// 产品取舍:no-activate 是 macOS/AeroSpace 的主路径;但如果 ns_window
|
||||
// 暂不可用,仍优先保住录音反馈,不让用户以为听写没启动。fallback 可能
|
||||
// 重新触发 workspace 跳转,只在 no-activate 失败时作为降级路径。
|
||||
log::warn!("[capsule] no-activate show failed; falling back to window.show()");
|
||||
}
|
||||
}
|
||||
|
||||
if needs_fallback {
|
||||
if let Err(e) = window.show() {
|
||||
log::warn!("[capsule] show fallback failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum ActiveAsr {
|
||||
Volcengine(Arc<VolcengineStreamingASR>),
|
||||
@@ -3754,15 +3693,6 @@ fn enabled_phrases(inner: &Arc<Inner>) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 终止态(Done / Cancelled / Error)后延迟 N ms 把胶囊改回 Idle,让浮窗自动消失。
|
||||
/// 用户点 ✕ / ✓ / 中途出错 / 按 Esc 都走这里,统一 2 秒。
|
||||
const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000;
|
||||
|
||||
/// Toggle 模式下,end_session 将 phase 设为 Idle 后在此时间内禁止新的 begin_session。
|
||||
/// 避免用户三连按时第 3 次按下误激活新听写(此时胶囊仍在离场动画周期内)。
|
||||
/// 值取 capsule EXIT_ANIM_MS (360ms) + 余量 ≈ 600ms。
|
||||
const POST_SESSION_COOLDOWN_MS: u64 = 600;
|
||||
|
||||
/// Coordinator 全局超时保护:防止 ASR await_final_result() 永远挂起。
|
||||
/// 设置为 15 秒(比 ASR 的 12 秒 FINAL_RESULT_TIMEOUT 稍长),
|
||||
/// 只在 ASR 超时机制失效时作为最后的防线触发。
|
||||
@@ -3809,285 +3739,6 @@ fn set_phase_idle_if_session_matches(inner: &Arc<Inner>, session_id: SessionId)
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_capsule_idle(inner: &Arc<Inner>, delay_ms: u64) {
|
||||
let inner_clone = Arc::clone(inner);
|
||||
async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
||||
// 必须 dictation **和** QA 同时空闲才能隐藏胶囊。否则旧 dictation Done timer
|
||||
// 的尾巴会在新 QA 录音/思考中把胶囊意外收掉(issue #118 v2 复现)。
|
||||
let dictation_idle = inner_clone.state.lock().phase == SessionPhase::Idle;
|
||||
let qa_idle = inner_clone.qa_state.lock().phase == QaPhase::Idle;
|
||||
if dictation_idle && qa_idle {
|
||||
emit_capsule(&inner_clone, CapsuleState::Idle, 0.0, 0, None, None);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_capsule(
|
||||
inner: &Arc<Inner>,
|
||||
state: CapsuleState,
|
||||
level: f32,
|
||||
elapsed_ms: u64,
|
||||
message: Option<String>,
|
||||
inserted_chars: Option<u32>,
|
||||
) {
|
||||
// 在 app 句柄校验之前记录,便于无 GUI 的测试断言「按下热键 → 弹了哪种胶囊」。
|
||||
*inner.last_capsule_state.lock() = Some(state);
|
||||
let app_opt = inner.app.lock().clone();
|
||||
let Some(app) = app_opt else { return };
|
||||
let translation = inner.translation_modifier_seen.load(Ordering::SeqCst);
|
||||
let operating = inner.state.lock().voice_agent;
|
||||
let payload = CapsulePayload {
|
||||
state,
|
||||
level,
|
||||
elapsed_ms,
|
||||
message,
|
||||
inserted_chars,
|
||||
translation,
|
||||
operating,
|
||||
};
|
||||
|
||||
// visible / translation 是「这一帧 capsule:state event 的 payload」内容 ——
|
||||
// 必须在 call-site(即音频线程触发 emit_capsule 时)就算定,否则 main thread
|
||||
// 闭包里读到的将是「下一帧」的 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() {
|
||||
*last = aux.map(String::from);
|
||||
// 代数计数器:每次状态变化 +1,retry 线程只在自己代数仍为最新时生效。
|
||||
// 避免 Recording→Idle→Recording 快速切换时多个 retry 重复触发。
|
||||
static RETRY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
// fetch_add 返回旧值,所以 latest_gen > gen+1 才表示"在我之后又发生了变更"。
|
||||
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}");
|
||||
let text = t.to_string();
|
||||
std::thread::spawn(move || {
|
||||
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}");
|
||||
}
|
||||
});
|
||||
// 终态(Done/Cancelled/Error)3 秒后自动清除,避免一直跟随焦点。
|
||||
if matches!(
|
||||
state,
|
||||
CapsuleState::Done | CapsuleState::Cancelled | CapsuleState::Error
|
||||
) {
|
||||
let text = t.to_string();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if latest_gen > gen + 1 {
|
||||
return;
|
||||
}
|
||||
let current = LAST_AUX.lock().unwrap().clone();
|
||||
if current.as_deref() != Some(&text) {
|
||||
return;
|
||||
}
|
||||
log::info!("[capsule] auto-clear terminal state: {text}");
|
||||
let _ = crate::linux_fcitx::set_aux_down("");
|
||||
*LAST_AUX.lock().unwrap() = None;
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
log::info!("[capsule] clear_aux_down gen={gen}");
|
||||
std::thread::spawn(move || {
|
||||
let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if latest_gen > gen + 1 {
|
||||
log::info!(
|
||||
"[capsule] clear_aux_down skipped: gen {gen}, latest {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 到主线程;
|
||||
// app.emit_to 走 Tauri 内部事件总线,本身线程安全,保留同步调用。详见 audit 3.2.2。
|
||||
//
|
||||
// show_capsule(用户偏好)在主线程执行时再读 —— 用户可以在录音过程中改设置,
|
||||
// 闭包入队到真正跑之间窗口上限是一两帧(~16-33ms),用最新值消除 stale-pref
|
||||
// 闪烁。pr_agent 关注点 — 见 audit follow-up。
|
||||
let inner_for_main = Arc::clone(inner);
|
||||
let app_for_main = app.clone();
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
let Some(window) = app_for_main.get_webview_window("capsule") else {
|
||||
// #470 诊断 v2:比 A/B/C 更靠前的暗点 A0 —— capsule webview 句柄取不到
|
||||
// (窗口未创建/已销毁)。此前静默 return,无法观测。一次性 warn。
|
||||
if !CAPSULE_WINDOW_MISSING_LOGGED.swap(true, Ordering::SeqCst) {
|
||||
log::warn!(
|
||||
"[capsule] capsule webview window not found — emit_capsule show path skipped (state={})",
|
||||
capsule_state_log_name(state)
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let show_capsule = inner_for_main.prefs.get().show_capsule;
|
||||
// Linux: 不操作胶囊窗口(不 show/hide,不 reposition)。
|
||||
// 文字通过 fcitx5 插件直接 commit,用户始终在目标 app 中。
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
|
||||
// 三平台统一:Done / Cancelled / Error 状态保留 ~1.5s toast
|
||||
// (schedule_capsule_idle 之后会回 Idle 隐藏)。
|
||||
// Windows 上 linger 的真实问题(截图选中 / 死区 / 拖拽卡顿)由 #140 加的
|
||||
// `hide_capsule_window_if_present()` Win32 hard-hide 在 visible=false 分支
|
||||
// 处理,不依赖把 Done/Cancelled/Error 打成 invisible。详见 PR #140 评论。
|
||||
maybe_position_capsule_bottom_center(&inner_for_main, &window, translation);
|
||||
if show_capsule && visible {
|
||||
// 用户报"看不到胶囊"时第一时间能在 log 里确认:胶囊路径有跑、show_capsule
|
||||
// 开关是 true、当前进入 visible 帧 —— 排除 prefs 没存住 / emit_capsule 没触
|
||||
// 发 / state 一直 Idle 这几类常见 root cause。issue #470。
|
||||
if !CAPSULE_FIRST_SHOW_LOGGED.swap(true, Ordering::SeqCst) {
|
||||
log::info!(
|
||||
"[capsule] first show this session: show_capsule=true visible=true state={}",
|
||||
capsule_state_log_name(state)
|
||||
);
|
||||
}
|
||||
show_capsule_window_for_recording(&app_for_main, &window);
|
||||
// macOS/Windows 优先走 no-activate show,避免录音胶囊抢走当前工作 app 焦点。
|
||||
// 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::restore_main_window_key_if_active(&app_for_main);
|
||||
} else {
|
||||
// show_capsule 开关被用户关掉但本次确实想显示(visible=true)的情况:
|
||||
// 一次性 info log,让用户报"胶囊没显示"时能在日志里一眼看到根因 —— 维护者
|
||||
// 不必再让用户"去打开设置确认"。issue #470。
|
||||
if !show_capsule
|
||||
&& visible
|
||||
&& !CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED.swap(true, Ordering::SeqCst)
|
||||
{
|
||||
log::info!(
|
||||
"[capsule] suppressed by user toggle: show_capsule=false visible=true state={}",
|
||||
capsule_state_log_name(state)
|
||||
);
|
||||
}
|
||||
hide_capsule_window_if_present();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = app.emit_to("capsule", "capsule:state", &payload);
|
||||
// 主窗口也需要 capsule:state 事件:AudioCueListener 用它触发录音提示音。
|
||||
// Linux 上胶囊隐藏时提示音仍应工作,所以同时发给 main 窗口。
|
||||
let _ = app.emit_to("main", "capsule:state", &payload);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct CapsuleLayoutState {
|
||||
translation_active: bool,
|
||||
monitor_x: i32,
|
||||
monitor_y: i32,
|
||||
monitor_width: u32,
|
||||
monitor_height: u32,
|
||||
scale_bits: u64,
|
||||
}
|
||||
|
||||
/// 返回胶囊「应该摆放到的显示器」的标识信息。
|
||||
///
|
||||
/// 它看的显示器必须和 `position_capsule_bottom_center` 实际定位用的一致:
|
||||
/// Windows 看「正在输入的 App 所在显示器」,其它平台看胶囊自己的显示器。
|
||||
/// 这是「是否需要重新定位」去重缓存(`maybe_position_capsule_bottom_center`)
|
||||
/// 的 key,如果这里看错了显示器,就会出现「输入焦点移到另一块屏、胶囊却没
|
||||
/// 跟过去」的 bug。
|
||||
fn capsule_layout_snapshot<R: tauri::Runtime>(
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
translation_active: bool,
|
||||
) -> Option<CapsuleLayoutState> {
|
||||
// Windows:以「正在输入的 App 所在显示器」为基准。若用胶囊自己的
|
||||
// current_monitor,输入焦点切到另一块屏时胶囊仍在原屏 → 误判「没变化」
|
||||
// → 跳过重新定位。
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(mon) = crate::foreground_window_monitor() {
|
||||
return Some(CapsuleLayoutState {
|
||||
translation_active,
|
||||
monitor_x: mon.left,
|
||||
monitor_y: mon.top,
|
||||
monitor_width: (mon.right - mon.left).max(0) as u32,
|
||||
monitor_height: (mon.bottom - mon.top).max(0) as u32,
|
||||
scale_bits: mon.scale.to_bits(),
|
||||
});
|
||||
}
|
||||
// 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor。
|
||||
}
|
||||
let monitor = window.current_monitor().ok().flatten()?;
|
||||
Some(CapsuleLayoutState {
|
||||
translation_active,
|
||||
monitor_x: monitor.position().x,
|
||||
monitor_y: monitor.position().y,
|
||||
monitor_width: monitor.size().width,
|
||||
monitor_height: monitor.size().height,
|
||||
scale_bits: monitor.scale_factor().to_bits(),
|
||||
})
|
||||
}
|
||||
|
||||
fn maybe_position_capsule_bottom_center<R: tauri::Runtime>(
|
||||
inner: &Arc<Inner>,
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
translation_active: bool,
|
||||
) {
|
||||
let Some(next) = capsule_layout_snapshot(window, translation_active) else {
|
||||
return;
|
||||
};
|
||||
{
|
||||
let last = inner.capsule_layout.lock();
|
||||
if last.as_ref() == Some(&next) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if crate::position_capsule_bottom_center(window, translation_active).is_ok() {
|
||||
let mut last = inner.capsule_layout.lock();
|
||||
*last = Some(next);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── audio bridge ───────────────────────────
|
||||
|
||||
struct DeferredAsrBridge {
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
//! Capsule HUD 呈现层:显示策略(no-activate / fallback)、emit_capsule 状态广播、
|
||||
//! 布局快照与底部居中定位、自动回 Idle 调度。
|
||||
//!
|
||||
//! 从 `coordinator.rs` 机械拆出(行为保持,仅可见性提升)。emit_capsule 由 cpal 音频
|
||||
//! 回调线程 ~30Hz 调用,run_on_main_thread marshaling 原样保留(SIGTRAP 规避)。
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CapsuleShowStrategy {
|
||||
NoActivate,
|
||||
FallbackShow,
|
||||
}
|
||||
|
||||
pub(crate) 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"))]
|
||||
{
|
||||
CapsuleShowStrategy::NoActivate
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
CapsuleShowStrategy::FallbackShow
|
||||
}
|
||||
}
|
||||
|
||||
static CAPSULE_NO_ACTIVATE_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false);
|
||||
static CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
static CAPSULE_FIRST_SHOW_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
// #470 诊断 v2:capsule webview 句柄取不到时的一次性门,区分「窗口压根没创建」(A0)。
|
||||
static CAPSULE_WINDOW_MISSING_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 给 #470 诊断日志用的 capsule 状态短名。显式枚举每个变体到 &'static str,
|
||||
/// 不走 `Debug` —— 哪天 CapsuleState 加了 `String` 字段,`:?` 会把 ASR / polish
|
||||
/// 内容意外灌进日志(pr_agent 提的 forward-looking 隐患);这里只输出状态名。
|
||||
pub(crate) fn capsule_state_log_name(state: CapsuleState) -> &'static str {
|
||||
match state {
|
||||
CapsuleState::Idle => "idle",
|
||||
CapsuleState::Recording => "recording",
|
||||
CapsuleState::Transcribing => "transcribing",
|
||||
CapsuleState::Polishing => "polishing",
|
||||
CapsuleState::Done => "done",
|
||||
CapsuleState::Cancelled => "cancelled",
|
||||
CapsuleState::Error => "error",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show_capsule_window_for_recording<R: tauri::Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
) {
|
||||
let mut needs_fallback = true;
|
||||
if capsule_show_strategy_for_platform() == CapsuleShowStrategy::NoActivate {
|
||||
needs_fallback = !show_capsule_window_no_activate(app, window);
|
||||
if needs_fallback && !CAPSULE_NO_ACTIVATE_FALLBACK_WARNED.swap(true, Ordering::SeqCst) {
|
||||
// 产品取舍:no-activate 是 macOS/AeroSpace 的主路径;但如果 ns_window
|
||||
// 暂不可用,仍优先保住录音反馈,不让用户以为听写没启动。fallback 可能
|
||||
// 重新触发 workspace 跳转,只在 no-activate 失败时作为降级路径。
|
||||
log::warn!("[capsule] no-activate show failed; falling back to window.show()");
|
||||
}
|
||||
}
|
||||
|
||||
if needs_fallback {
|
||||
if let Err(e) = window.show() {
|
||||
log::warn!("[capsule] show fallback failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 终止态(Done / Cancelled / Error)后延迟 N ms 把胶囊改回 Idle,让浮窗自动消失。
|
||||
/// 用户点 ✕ / ✓ / 中途出错 / 按 Esc 都走这里,统一 2 秒。
|
||||
pub(crate) const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000;
|
||||
|
||||
/// Toggle 模式下,end_session 将 phase 设为 Idle 后在此时间内禁止新的 begin_session。
|
||||
/// 避免用户三连按时第 3 次按下误激活新听写(此时胶囊仍在离场动画周期内)。
|
||||
/// 值取 capsule EXIT_ANIM_MS (360ms) + 余量 ≈ 600ms。
|
||||
pub(crate) const POST_SESSION_COOLDOWN_MS: u64 = 600;
|
||||
|
||||
pub(crate) fn schedule_capsule_idle(inner: &Arc<Inner>, delay_ms: u64) {
|
||||
let inner_clone = Arc::clone(inner);
|
||||
async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
||||
// 必须 dictation **和** QA 同时空闲才能隐藏胶囊。否则旧 dictation Done timer
|
||||
// 的尾巴会在新 QA 录音/思考中把胶囊意外收掉(issue #118 v2 复现)。
|
||||
let dictation_idle = inner_clone.state.lock().phase == SessionPhase::Idle;
|
||||
let qa_idle = inner_clone.qa_state.lock().phase == QaPhase::Idle;
|
||||
if dictation_idle && qa_idle {
|
||||
emit_capsule(&inner_clone, CapsuleState::Idle, 0.0, 0, None, None);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn emit_capsule(
|
||||
inner: &Arc<Inner>,
|
||||
state: CapsuleState,
|
||||
level: f32,
|
||||
elapsed_ms: u64,
|
||||
message: Option<String>,
|
||||
inserted_chars: Option<u32>,
|
||||
) {
|
||||
// 在 app 句柄校验之前记录,便于无 GUI 的测试断言「按下热键 → 弹了哪种胶囊」。
|
||||
*inner.last_capsule_state.lock() = Some(state);
|
||||
let app_opt = inner.app.lock().clone();
|
||||
let Some(app) = app_opt else { return };
|
||||
let translation = inner.translation_modifier_seen.load(Ordering::SeqCst);
|
||||
let operating = inner.state.lock().voice_agent;
|
||||
let payload = CapsulePayload {
|
||||
state,
|
||||
level,
|
||||
elapsed_ms,
|
||||
message,
|
||||
inserted_chars,
|
||||
translation,
|
||||
operating,
|
||||
};
|
||||
|
||||
// visible / translation 是「这一帧 capsule:state event 的 payload」内容 ——
|
||||
// 必须在 call-site(即音频线程触发 emit_capsule 时)就算定,否则 main thread
|
||||
// 闭包里读到的将是「下一帧」的 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() {
|
||||
*last = aux.map(String::from);
|
||||
// 代数计数器:每次状态变化 +1,retry 线程只在自己代数仍为最新时生效。
|
||||
// 避免 Recording→Idle→Recording 快速切换时多个 retry 重复触发。
|
||||
static RETRY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
// fetch_add 返回旧值,所以 latest_gen > gen+1 才表示"在我之后又发生了变更"。
|
||||
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}");
|
||||
let text = t.to_string();
|
||||
std::thread::spawn(move || {
|
||||
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}");
|
||||
}
|
||||
});
|
||||
// 终态(Done/Cancelled/Error)3 秒后自动清除,避免一直跟随焦点。
|
||||
if matches!(
|
||||
state,
|
||||
CapsuleState::Done | CapsuleState::Cancelled | CapsuleState::Error
|
||||
) {
|
||||
let text = t.to_string();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if latest_gen > gen + 1 {
|
||||
return;
|
||||
}
|
||||
let current = LAST_AUX.lock().unwrap().clone();
|
||||
if current.as_deref() != Some(&text) {
|
||||
return;
|
||||
}
|
||||
log::info!("[capsule] auto-clear terminal state: {text}");
|
||||
let _ = crate::linux_fcitx::set_aux_down("");
|
||||
*LAST_AUX.lock().unwrap() = None;
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
log::info!("[capsule] clear_aux_down gen={gen}");
|
||||
std::thread::spawn(move || {
|
||||
let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if latest_gen > gen + 1 {
|
||||
log::info!(
|
||||
"[capsule] clear_aux_down skipped: gen {gen}, latest {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 到主线程;
|
||||
// app.emit_to 走 Tauri 内部事件总线,本身线程安全,保留同步调用。详见 audit 3.2.2。
|
||||
//
|
||||
// show_capsule(用户偏好)在主线程执行时再读 —— 用户可以在录音过程中改设置,
|
||||
// 闭包入队到真正跑之间窗口上限是一两帧(~16-33ms),用最新值消除 stale-pref
|
||||
// 闪烁。pr_agent 关注点 — 见 audit follow-up。
|
||||
let inner_for_main = Arc::clone(inner);
|
||||
let app_for_main = app.clone();
|
||||
let _ = app.run_on_main_thread(move || {
|
||||
let Some(window) = app_for_main.get_webview_window("capsule") else {
|
||||
// #470 诊断 v2:比 A/B/C 更靠前的暗点 A0 —— capsule webview 句柄取不到
|
||||
// (窗口未创建/已销毁)。此前静默 return,无法观测。一次性 warn。
|
||||
if !CAPSULE_WINDOW_MISSING_LOGGED.swap(true, Ordering::SeqCst) {
|
||||
log::warn!(
|
||||
"[capsule] capsule webview window not found — emit_capsule show path skipped (state={})",
|
||||
capsule_state_log_name(state)
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let show_capsule = inner_for_main.prefs.get().show_capsule;
|
||||
// Linux: 不操作胶囊窗口(不 show/hide,不 reposition)。
|
||||
// 文字通过 fcitx5 插件直接 commit,用户始终在目标 app 中。
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
|
||||
// 三平台统一:Done / Cancelled / Error 状态保留 ~1.5s toast
|
||||
// (schedule_capsule_idle 之后会回 Idle 隐藏)。
|
||||
// Windows 上 linger 的真实问题(截图选中 / 死区 / 拖拽卡顿)由 #140 加的
|
||||
// `hide_capsule_window_if_present()` Win32 hard-hide 在 visible=false 分支
|
||||
// 处理,不依赖把 Done/Cancelled/Error 打成 invisible。详见 PR #140 评论。
|
||||
maybe_position_capsule_bottom_center(&inner_for_main, &window, translation);
|
||||
if show_capsule && visible {
|
||||
// 用户报"看不到胶囊"时第一时间能在 log 里确认:胶囊路径有跑、show_capsule
|
||||
// 开关是 true、当前进入 visible 帧 —— 排除 prefs 没存住 / emit_capsule 没触
|
||||
// 发 / state 一直 Idle 这几类常见 root cause。issue #470。
|
||||
if !CAPSULE_FIRST_SHOW_LOGGED.swap(true, Ordering::SeqCst) {
|
||||
log::info!(
|
||||
"[capsule] first show this session: show_capsule=true visible=true state={}",
|
||||
capsule_state_log_name(state)
|
||||
);
|
||||
}
|
||||
show_capsule_window_for_recording(&app_for_main, &window);
|
||||
// macOS/Windows 优先走 no-activate show,避免录音胶囊抢走当前工作 app 焦点。
|
||||
// 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::restore_main_window_key_if_active(&app_for_main);
|
||||
} else {
|
||||
// show_capsule 开关被用户关掉但本次确实想显示(visible=true)的情况:
|
||||
// 一次性 info log,让用户报"胶囊没显示"时能在日志里一眼看到根因 —— 维护者
|
||||
// 不必再让用户"去打开设置确认"。issue #470。
|
||||
if !show_capsule
|
||||
&& visible
|
||||
&& !CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED.swap(true, Ordering::SeqCst)
|
||||
{
|
||||
log::info!(
|
||||
"[capsule] suppressed by user toggle: show_capsule=false visible=true state={}",
|
||||
capsule_state_log_name(state)
|
||||
);
|
||||
}
|
||||
hide_capsule_window_if_present();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let _ = app.emit_to("capsule", "capsule:state", &payload);
|
||||
// 主窗口也需要 capsule:state 事件:AudioCueListener 用它触发录音提示音。
|
||||
// Linux 上胶囊隐藏时提示音仍应工作,所以同时发给 main 窗口。
|
||||
let _ = app.emit_to("main", "capsule:state", &payload);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct CapsuleLayoutState {
|
||||
translation_active: bool,
|
||||
monitor_x: i32,
|
||||
monitor_y: i32,
|
||||
monitor_width: u32,
|
||||
monitor_height: u32,
|
||||
scale_bits: u64,
|
||||
}
|
||||
|
||||
/// 返回胶囊「应该摆放到的显示器」的标识信息。
|
||||
///
|
||||
/// 它看的显示器必须和 `position_capsule_bottom_center` 实际定位用的一致:
|
||||
/// Windows 看「正在输入的 App 所在显示器」,其它平台看胶囊自己的显示器。
|
||||
/// 这是「是否需要重新定位」去重缓存(`maybe_position_capsule_bottom_center`)
|
||||
/// 的 key,如果这里看错了显示器,就会出现「输入焦点移到另一块屏、胶囊却没
|
||||
/// 跟过去」的 bug。
|
||||
pub(crate) fn capsule_layout_snapshot<R: tauri::Runtime>(
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
translation_active: bool,
|
||||
) -> Option<CapsuleLayoutState> {
|
||||
// Windows:以「正在输入的 App 所在显示器」为基准。若用胶囊自己的
|
||||
// current_monitor,输入焦点切到另一块屏时胶囊仍在原屏 → 误判「没变化」
|
||||
// → 跳过重新定位。
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(mon) = crate::foreground_window_monitor() {
|
||||
return Some(CapsuleLayoutState {
|
||||
translation_active,
|
||||
monitor_x: mon.left,
|
||||
monitor_y: mon.top,
|
||||
monitor_width: (mon.right - mon.left).max(0) as u32,
|
||||
monitor_height: (mon.bottom - mon.top).max(0) as u32,
|
||||
scale_bits: mon.scale.to_bits(),
|
||||
});
|
||||
}
|
||||
// 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor。
|
||||
}
|
||||
let monitor = window.current_monitor().ok().flatten()?;
|
||||
Some(CapsuleLayoutState {
|
||||
translation_active,
|
||||
monitor_x: monitor.position().x,
|
||||
monitor_y: monitor.position().y,
|
||||
monitor_width: monitor.size().width,
|
||||
monitor_height: monitor.size().height,
|
||||
scale_bits: monitor.scale_factor().to_bits(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_position_capsule_bottom_center<R: tauri::Runtime>(
|
||||
inner: &Arc<Inner>,
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
translation_active: bool,
|
||||
) {
|
||||
let Some(next) = capsule_layout_snapshot(window, translation_active) else {
|
||||
return;
|
||||
};
|
||||
{
|
||||
let last = inner.capsule_layout.lock();
|
||||
if last.as_ref() == Some(&next) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if crate::position_capsule_bottom_center(window, translation_active).is_ok() {
|
||||
let mut last = inner.capsule_layout.lock();
|
||||
*last = Some(next);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user