feat: route Windows insertion through temporary TSF IME

This commit is contained in:
millionart committed 2026-05-01 19:53:51 +08:00
1 parent d4655b7cb9
commit 3a6f7f3c92
4 files changed
+259

No files matched your search

@@ -31,6 +31,8 @@ use crate::types::{
CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus,
HotkeyStatusState, InsertStatus, PolishMode,
};
#[cfg(target_os = "windows")]
use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionPhase {
@@ -89,6 +91,10 @@ struct Inner {
prefs: PreferencesStore,
vocab: DictionaryStore,
inserter: TextInserter,
#[cfg(target_os = "windows")]
windows_ime: WindowsImeSessionController,
#[cfg(target_os = "windows")]
prepared_windows_ime_session: Arc<Mutex<Option<PreparedWindowsImeSession>>>,
state: Mutex<SessionState>,
asr: Mutex<Option<ActiveAsr>>,
recorder: Mutex<Option<Recorder>>,
@@ -118,6 +124,10 @@ impl Coordinator {
prefs,
vocab,
inserter: TextInserter::new(),
#[cfg(target_os = "windows")]
windows_ime: WindowsImeSessionController::new(),
#[cfg(target_os = "windows")]
prepared_windows_ime_session: Arc::new(Mutex::new(None)),
state: Mutex::new(SessionState::default()),
asr: Mutex::new(None),
recorder: Mutex::new(None),
@@ -465,6 +475,11 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
// 如果迟到错误到达时 id 已不匹配就 drop,不会误中止后续 session。
state.session_id = state.session_id.wrapping_add(1);
}
#[cfg(target_os = "windows")]
{
let prepared = inner.windows_ime.prepare_session();
*inner.prepared_windows_ime_session.lock() = Some(prepared);
}
// 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。
inner.translation_modifier_seen.store(false, Ordering::SeqCst);
@@ -486,6 +501,7 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
Some(message.clone()),
None,
);
restore_prepared_windows_ime_session(inner);
inner.state.lock().phase = SessionPhase::Idle;
return Err(message);
}
@@ -500,6 +516,7 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
Some(message.clone()),
None,
);
restore_prepared_windows_ime_session(inner);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(message);
@@ -536,6 +553,7 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
}
}
if cancel_raced_during_starting(inner) {
restore_prepared_windows_ime_session(inner);
inner.state.lock().phase = SessionPhase::Idle;
return Ok(());
}
@@ -547,6 +565,7 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
Some(format!("ASR 连接失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
@@ -560,6 +579,7 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
if let Some(rec) = inner.recorder.lock().take() {
rec.stop();
}
restore_prepared_windows_ime_session(inner);
inner.state.lock().phase = SessionPhase::Idle;
return Ok(());
}
@@ -637,6 +657,7 @@ fn start_recorder_for_starting(
Some(format!("录音启动失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
@@ -697,6 +718,7 @@ fn abort_recording_with_error(inner: &Arc<Inner>, message: String) {
ActiveAsr::Whisper(w) => w.cancel(),
}
}
restore_prepared_windows_ime_session(inner);
emit_capsule(
inner,
@@ -749,6 +771,7 @@ async fn finish_starting_session(inner: &Arc<Inner>) {
ActiveAsr::Whisper(w) => w.cancel(),
}
}
restore_prepared_windows_ime_session(inner);
inner.state.lock().phase = SessionPhase::Idle;
}
BeginOutcome::Started | BeginOutcome::PendingStop => {
@@ -781,6 +804,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let asr = match asr_opt {
Some(a) => a,
None => {
restore_prepared_windows_ime_session(inner);
inner.state.lock().phase = SessionPhase::Idle;
return Ok(());
}
@@ -803,6 +827,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
Some(format!("识别失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
@@ -821,6 +846,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
Some(format!("识别失败: {e}")),
None,
);
restore_prepared_windows_ime_session(inner);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err(e.to_string());
@@ -832,6 +858,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
// 优先级高于 empty 检查 — 用户取消 → 静默丢弃,不写失败历史也不弹错误胶囊。
if inner.state.lock().cancelled {
log::info!("[coord] cancel detected after ASR — discarding transcript");
restore_prepared_windows_ime_session(inner);
inner.state.lock().phase = SessionPhase::Idle;
return Ok(());
}
@@ -863,6 +890,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
Some("ASR returned empty transcript".to_string()),
None,
);
restore_prepared_windows_ime_session(inner);
inner.state.lock().phase = SessionPhase::Idle;
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
return Err("ASR returned empty transcript".to_string());
@@ -907,12 +935,16 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
"[coord] cancel detected before insert — discarding output (chars={})",
polished.chars().count()
);
restore_prepared_windows_ime_session(inner);
return Ok(());
}
let focus_target = inner.state.lock().focus_target;
restore_focus_target_if_possible(focus_target);
let restore_clipboard = inner.prefs.get().restore_clipboard_after_paste;
#[cfg(target_os = "windows")]
let status = insert_with_windows_ime_first(inner, &polished, restore_clipboard).await;
#[cfg(not(target_os = "windows"))]
let status = inner.inserter.insert(&polished, restore_clipboard);
let inserted_chars = polished.chars().count() as u32;
@@ -1016,6 +1048,7 @@ fn cancel_session(inner: &Arc<Inner>) {
ActiveAsr::Whisper(w) => w.cancel(),
}
}
restore_prepared_windows_ime_session(inner);
// Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾;
// 其他阶段直接转 Idle。
if phase != SessionPhase::Processing {
@@ -1028,6 +1061,53 @@ fn cancel_session(inner: &Arc<Inner>) {
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
}
#[cfg(target_os = "windows")]
fn restore_prepared_windows_ime_session(inner: &Arc<Inner>) {
if let Some(prepared) = inner.prepared_windows_ime_session.lock().take() {
inner.windows_ime.restore_session(prepared);
}
}
#[cfg(not(target_os = "windows"))]
fn restore_prepared_windows_ime_session(_inner: &Arc<Inner>) {}
#[cfg(target_os = "windows")]
async fn insert_with_windows_ime_first(
inner: &Arc<Inner>,
polished: &str,
restore_clipboard: bool,
) -> InsertStatus {
let prepared = inner.prepared_windows_ime_session.lock().take();
let Some(prepared) = prepared else {
return inner
.inserter
.insert_via_clipboard_fallback(polished, restore_clipboard);
};
let request = crate::windows_ime_ipc::ImeSubmitRequest {
session_id: Uuid::new_v4().to_string(),
text: polished.to_string(),
created_at: Utc::now().to_rfc3339(),
};
let ime_status = match inner.windows_ime.submit_prepared(&prepared, request).await {
Ok(status) => status,
Err(error) => {
log::warn!("[windows-ime] TSF submit failed, falling back to clipboard: {error}");
InsertStatus::CopiedFallback
}
};
inner.windows_ime.restore_session(prepared);
if ime_status == InsertStatus::Inserted {
ime_status
} else {
inner
.inserter
.insert_via_clipboard_fallback(polished, restore_clipboard)
}
}
// ─────────────────────────── helpers ───────────────────────────
#[cfg(any(debug_assertions, test))]
@@ -35,6 +35,15 @@ impl TextInserter {
insert_with_clipboard_restore(text, restore_clipboard_after_paste)
}
#[cfg(not(target_os = "macos"))]
pub fn insert_via_clipboard_fallback(
&self,
text: &str,
restore_clipboard_after_paste: bool,
) -> InsertStatus {
self.insert(text, restore_clipboard_after_paste)
}
/// Insert `text` at the current cursor position.
#[cfg(target_os = "macos")]
pub fn insert(&self, text: &str, _restore_clipboard_after_paste: bool) -> InsertStatus {
+1
View File
@@ -23,6 +23,7 @@ mod types;
mod windows_ime_ipc;
mod windows_ime_profile;
mod windows_ime_protocol;
mod windows_ime_session;
#[cfg(target_os = "macos")]
use std::sync::mpsc;
@@ -0,0 +1,169 @@
use crate::types::InsertStatus;
use crate::windows_ime_ipc::{ImeSubmitRequest, WindowsImeIpcServer};
use crate::windows_ime_profile::{
restore_decision, ImeProfileSnapshot, ProfileRestoreDecision, WindowsImeProfileManager,
};
use crate::windows_ime_protocol::ImeSubmitStatus;
#[derive(Debug)]
pub enum WindowsImeSessionError {
Profile(String),
Ipc(String),
}
impl std::fmt::Display for WindowsImeSessionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Profile(message) | Self::Ipc(message) => write!(f, "{message}"),
}
}
}
impl std::error::Error for WindowsImeSessionError {}
pub fn map_ime_status_to_insert_status(status: ImeSubmitStatus) -> InsertStatus {
match status {
ImeSubmitStatus::Committed => InsertStatus::Inserted,
ImeSubmitStatus::Rejected | ImeSubmitStatus::Failed => InsertStatus::CopiedFallback,
}
}
pub fn should_fallback_after_ime_result(status: ImeSubmitStatus) -> bool {
!matches!(status, ImeSubmitStatus::Committed)
}
#[derive(Debug)]
pub struct PreparedWindowsImeSession {
saved_profile: Option<ImeProfileSnapshot>,
openless_activated: bool,
}
impl PreparedWindowsImeSession {
pub fn unavailable() -> Self {
Self {
saved_profile: None,
openless_activated: false,
}
}
pub fn is_ready_for_tsf_submit(&self) -> bool {
self.saved_profile.is_some() && self.openless_activated
}
}
pub struct WindowsImeSessionController {
profile_manager: WindowsImeProfileManager,
ipc_server: WindowsImeIpcServer,
}
impl WindowsImeSessionController {
pub fn new() -> Self {
Self {
profile_manager: WindowsImeProfileManager::new(),
ipc_server: WindowsImeIpcServer::new(),
}
}
pub fn prepare_session(&self) -> PreparedWindowsImeSession {
#[cfg(target_os = "windows")]
{
let saved_profile = match self.profile_manager.capture_active_profile() {
Ok(snapshot) => snapshot,
Err(error) => {
let error = WindowsImeSessionError::Profile(error.to_string());
log::warn!("[windows-ime] capture active profile failed: {error}");
return PreparedWindowsImeSession::unavailable();
}
};
match self.profile_manager.activate_openless_profile() {
Ok(()) => PreparedWindowsImeSession {
saved_profile: Some(saved_profile),
openless_activated: true,
},
Err(error) => {
let error = WindowsImeSessionError::Profile(error.to_string());
log::warn!("[windows-ime] activate OpenLess profile failed: {error}");
PreparedWindowsImeSession::unavailable()
}
}
}
#[cfg(not(target_os = "windows"))]
{
PreparedWindowsImeSession::unavailable()
}
}
pub async fn submit_prepared(
&self,
prepared: &PreparedWindowsImeSession,
request: ImeSubmitRequest,
) -> Result<InsertStatus, WindowsImeSessionError> {
if !prepared.is_ready_for_tsf_submit() {
return Ok(InsertStatus::CopiedFallback);
}
let status = self
.ipc_server
.submit_text(request)
.await
.map_err(|error| WindowsImeSessionError::Ipc(error.to_string()))?;
if should_fallback_after_ime_result(status) {
log::warn!("[windows-ime] TSF submit returned {status:?}; falling back to clipboard");
}
Ok(map_ime_status_to_insert_status(status))
}
pub fn restore_session(&self, prepared: PreparedWindowsImeSession) {
let should_restore = match self.profile_manager.is_openless_profile_active() {
Ok(openless_active) => {
restore_decision(prepared.saved_profile.as_ref(), openless_active)
}
Err(error) => {
log::warn!("[windows-ime] check active profile before restore failed: {error}");
ProfileRestoreDecision::KeepCurrentProfile
}
};
if should_restore != ProfileRestoreDecision::RestoreSavedProfile {
return;
}
let Some(saved_profile) = prepared.saved_profile.as_ref() else {
return;
};
if let Err(error) = self.profile_manager.restore_profile(saved_profile) {
log::warn!("[windows-ime] restore saved profile failed: {error}");
}
}
}
impl Default for WindowsImeSessionController {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn committed_ime_result_maps_to_inserted() {
assert_eq!(
map_ime_status_to_insert_status(ImeSubmitStatus::Committed),
InsertStatus::Inserted
);
}
#[test]
fn rejected_ime_result_requests_fallback() {
assert!(should_fallback_after_ime_result(ImeSubmitStatus::Rejected));
assert!(should_fallback_after_ime_result(ImeSubmitStatus::Failed));
assert!(!should_fallback_after_ime_result(
ImeSubmitStatus::Committed
));
}
}