From 83426bc0570dccfc989951bbf1ecc01ea954f00c Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 18:34:10 +0800 Subject: [PATCH] feat: add Windows IME IPC protocol --- openless-all/app/src-tauri/src/lib.rs | 1 + .../app/src-tauri/src/windows_ime_protocol.rs | 112 ++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 openless-all/app/src-tauri/src/windows_ime_protocol.rs diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 4d7b9f10..533a6d87 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ mod persistence; mod polish; mod recorder; mod types; +mod windows_ime_protocol; #[cfg(target_os = "macos")] use std::sync::mpsc; diff --git a/openless-all/app/src-tauri/src/windows_ime_protocol.rs b/openless-all/app/src-tauri/src/windows_ime_protocol.rs new file mode 100644 index 00000000..adf6c93b --- /dev/null +++ b/openless-all/app/src-tauri/src/windows_ime_protocol.rs @@ -0,0 +1,112 @@ +use serde::{Deserialize, Serialize}; + +pub const OPENLESS_IME_PROTOCOL_VERSION: u32 = 1; +pub const OPENLESS_IME_PIPE_NAME: &str = r"\\.\pipe\OpenLessImeSubmit"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum ImePipeMessage { + ClientReady { + protocol_version: u32, + client_id: String, + process_id: u32, + thread_id: u32, + }, + SubmitText { + protocol_version: u32, + session_id: String, + text: String, + created_at: String, + }, + SubmitResult { + protocol_version: u32, + session_id: String, + status: ImeSubmitStatus, + error_code: Option, + }, + CancelSession { + protocol_version: u32, + session_id: String, + }, + Ping { + protocol_version: u32, + }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ImeSubmitStatus { + Committed, + Rejected, + Failed, +} + +pub fn encode_message(message: &ImePipeMessage) -> Result { + let mut line = serde_json::to_string(message)?; + line.push('\n'); + Ok(line) +} + +pub fn decode_message(line: &str) -> Result { + serde_json::from_str(line) +} + +pub fn is_result_for_pending_session( + message: &ImePipeMessage, + pending_session_id: &str, +) -> Result<(), &'static str> { + match message { + ImePipeMessage::SubmitResult { session_id, .. } if session_id == pending_session_id => { + Ok(()) + } + ImePipeMessage::SubmitResult { .. } => Err("submit result belongs to a different session"), + _ => Err("message is not a submit result"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn submit_text_roundtrips_as_camel_case_json() { + let message = ImePipeMessage::SubmitText { + protocol_version: OPENLESS_IME_PROTOCOL_VERSION, + session_id: "session-1".to_string(), + text: "\u{4f60}\u{597d} OpenLess".to_string(), + created_at: "2026-05-01T12:00:00Z".to_string(), + }; + + let json = encode_message(&message).expect("encode"); + assert!(json.contains("\"submitText\"")); + assert!(json.contains("\"sessionId\"")); + assert!(json.contains("\"createdAt\"")); + assert!(!json.contains("\"session_id\"")); + assert!(!json.contains("\"created_at\"")); + assert!(json.ends_with('\n')); + + let decoded = decode_message(json.trim_end()).expect("decode"); + assert_eq!(decoded, message); + } + + #[test] + fn stale_submit_result_is_rejected() { + let result = ImePipeMessage::SubmitResult { + protocol_version: OPENLESS_IME_PROTOCOL_VERSION, + session_id: "old-session".to_string(), + status: ImeSubmitStatus::Committed, + error_code: Some("ime-busy".to_string()), + }; + + let json = encode_message(&result).expect("encode"); + assert!(json.contains("\"errorCode\"")); + assert!(!json.contains("\"error_code\"")); + + assert!(is_result_for_pending_session(&result, "current-session").is_err()); + assert!(is_result_for_pending_session(&result, "old-session").is_ok()); + } +}