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
12 changes: 6 additions & 6 deletions openless-all/app/scripts/windows-ime-install-smoke.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@ $ErrorActionPreference = "Stop"

$TextServiceClsid = "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"
$ProfileGuid = "{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"
$LangId = "0x00000804"
$KeyboardCategoryGuid = "{34745C63-B2F0-4784-8B67-5E12C8701A31}"
$LangId = "0x0000ffff"
$SpeechCategoryGuid = "{B5A73CD1-8355-426B-A161-259808F26B14}"
$ImmersiveCategoryGuid = "{13A016DF-560B-46CD-947A-4C3AF1E0E35D}"
$SystrayCategoryGuid = "{25504FB4-7BAB-4BC1-9C69-CF81890F0EF5}"

# Keep this script aligned with the backend status check and the TSF IPC path
# used by OpenLessImeSubmit-* named pipes.
# used by OpenLessImeMessageWindow (WM_COPYDATA).
$ExpectedBackendKeys = @(
"Software\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\InprocServer32",
"Software\WOW6432Node\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\InprocServer32",
"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\LanguageProfile\0x00000804\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}",
"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{34745C63-B2F0-4784-8B67-5E12C8701A31}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}",
"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\LanguageProfile\0x0000ffff\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}",
"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{B5A73CD1-8355-426B-A161-259808F26B14}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}",
"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{13A016DF-560B-46CD-947A-4C3AF1E0E35D}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}",
"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{25504FB4-7BAB-4BC1-9C69-CF81890F0EF5}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"
)
Expand Down Expand Up @@ -153,7 +153,7 @@ function Assert-OpenLessImeInstalled {
}

Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\LanguageProfile\$LangId\$ProfileGuid" -Label "TSF language profile"
Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$KeyboardCategoryGuid\$TextServiceClsid" -Label "TSF keyboard category"
Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$SpeechCategoryGuid\$TextServiceClsid" -Label "TSF speech category"
Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$ImmersiveCategoryGuid\$TextServiceClsid" -Label "TSF immersive category"
Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$SystrayCategoryGuid\$TextServiceClsid" -Label "TSF systray category"

Expand Down
107 changes: 68 additions & 39 deletions openless-all/app/scripts/windows-ime-lifecycle-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -1,65 +1,94 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const appRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const imeRoot = join(appRoot, 'windows-ime', 'src');
const ipcClient = readFileSync(join(imeRoot, 'ipc_client.cpp'), 'utf8');
const ipcHeader = readFileSync(join(imeRoot, 'ipc_client.h'), 'utf8');
const textService = readFileSync(join(imeRoot, 'text_service.cpp'), 'utf8');
const editSession = readFileSync(join(imeRoot, 'edit_session.cpp'), 'utf8');
const protocol = readFileSync(join(appRoot, 'src-tauri', 'src', 'windows_ime_protocol.rs'), 'utf8');

assert.doesNotMatch(
ipcClient,
/FlushFileBuffers\(/,
'IME shutdown must not block the host UI thread waiting for the pipe client',
);
assert.match(
ipcClient,
/WaitForClientDisconnect/,
'IME replies should wait for client disconnect through cancelable overlapped I/O',
);
assert.match(
ipcHeader,
/HRESULT Start\(/,
'IME activation should report pipe server startup failures',
);
assert.match(
ipcClient,
/WaitForSingleObject\(startup_event_/,
"IME activation must wait for the worker's first named-pipe creation result",
// The IME runs inside every host process. It must not own threads or block the
// host UI thread: joining a worker from Deactivate deadlocked hosts whenever
// the calling thread held the loader lock.
const imeSources = readdirSync(imeRoot)
.filter((name) => /\.(cpp|h)$/.test(name))
.map((name) => [name, readFileSync(join(imeRoot, name), 'utf8')]);
for (const [name, source] of imeSources) {
assert.doesNotMatch(
source,
/std::thread|_beginthreadex|CreateThread\(/,
`${name}: IME must not create threads inside host processes`,
);
assert.doesNotMatch(
source,
/CreateNamedPipeW|ConnectNamedPipe/,
`${name}: IME must not serve named pipes inside host processes`,
);
assert.doesNotMatch(
source,
/WaitForSingleObject|WaitForMultipleObjects|\.join\(\)|SendMessageTimeoutW\(|SendMessageW\(/,
`${name}: IME must not block the host UI thread on waits or synchronous sends`,
);
}
assert.ok(
!existsSync(join(imeRoot, 'ipc_client.cpp')),
'the in-host pipe server must stay removed',
);

assert.match(
ipcClient,
/CreateNamedPipeW[\s\S]*ReportStartupResult/,
'IME worker must report the first CreateNamedPipeW result to activation',
textService,
/message == WM_COPYDATA/,
'IME should receive submissions as WM_COPYDATA on its message window',
);
assert.match(
ipcHeader,
/void Run\(\) noexcept/,
'IME worker exceptions must not terminate the host process',
textService,
/PostMessageW\(message_window_, kRunSubmitMessage/,
'IME should commit from a posted message at the top of the host message loop',
);

assert.doesNotMatch(
textService,
/SendMessageTimeoutW\(/,
'IME worker must not synchronously send a stack request to the owner thread',
/ChangeWindowMessageFilter/,
'IME must not let lower-integrity processes inject text into elevated hosts',
);
assert.match(
textService,
/PostMessageW\(/,
'IME worker should post an owned request to the owner thread',
/STDMETHODIMP OpenLessTextService::Deactivate\(\) \{\s*(?:OpenLessTraceScope trace\(L"Deactivate"\);\s*)?CancelPendingSubmit\(\);\s*DestroyMessageWindow\(\);/,
'IME deactivation should only cancel the pending submit and destroy its window',
);
assert.match(
textService,
/WaitForMultipleObjects\(/,
'IME owner-thread and async edit waits should be cancelable during shutdown',
/service->AddRef\(\);[\s\S]*service->Release\(\);/,
'IME message handling should keep the service alive across re-entrant deactivation',
);
assert.match(
textService,
/PeekMessageW[\s\S]*PM_REMOVE/,
'IME shutdown should release queued submit requests before destroying the message window',

// The message protocol is defined twice (C++ and Rust); keep the copies equal.
const sharedConstants = [
['kCopyDataSubmit', 'IME_COPYDATA_SUBMIT'],
['kCopyDataQuery', 'IME_COPYDATA_QUERY'],
['kStatusAccepted', 'IME_STATUS_ACCEPTED'],
['kStatusPending', 'IME_STATUS_PENDING'],
['kStatusCommitted', 'IME_STATUS_COMMITTED'],
['kStatusUnknownToken', 'IME_STATUS_UNKNOWN_TOKEN'],
['kStatusBadRequest', 'IME_STATUS_BAD_REQUEST'],
];
for (const [nativeName, rustName] of sharedConstants) {
const nativeValue = textService.match(new RegExp(`${nativeName} = (0x[0-9A-Fa-f]+)`));
const rustValue = protocol.match(new RegExp(`${rustName}: \\w+ = (0x[0-9A-Fa-f_]+)`));
assert.ok(nativeValue, `${nativeName} should be defined in text_service.cpp`);
assert.ok(rustValue, `${rustName} should be defined in windows_ime_protocol.rs`);
assert.equal(
Number(rustValue[1].replaceAll('_', '')),
Number(nativeValue[1]),
`${nativeName} and ${rustName} must match`,
);
}
const windowClass = textService.match(/kMessageWindowClassName\[\] = L"([^"]+)"/);
assert.ok(windowClass, 'IME message window class should be defined');
assert.ok(
protocol.includes(`OPENLESS_IME_MESSAGE_WINDOW_CLASS: &str = "${windowClass[1]}"`),
'IME message window class must match between C++ and Rust',
);

assert.match(
Expand Down
16 changes: 8 additions & 8 deletions openless-all/app/scripts/windows-package-msvc.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,13 @@ assert.match(
);
assert.match(
imeTextService,
/WaitForSingleObject/,
'IME pipe submit should wait for async edit-session completion',
/async_edit_->completed/,
'IME submit queries should report async edit-session completion',
);
assert.match(
imeEditSession,
/SetEvent/,
'IME edit session should signal async completion back to the pipe submitter',
/async_state_->completed = true/,
'IME edit session should record async completion for the submit query',
);
assert.match(
imeEditSession,
Expand Down Expand Up @@ -443,7 +443,7 @@ assert.match(
);
assert.match(
imeInstallSmoke,
/OpenLessImeSubmit/,
/OpenLessImeMessageWindow/,
'install smoke should preserve TSF backend context',
);
assert.match(
Expand All @@ -463,13 +463,13 @@ assert.match(
);
assert.match(
imeInstallSmoke,
/LanguageProfile\\0x00000804\\\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E\}/,
/LanguageProfile\\0x0000ffff\\\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E\}/,
'install smoke should check the TSF language profile',
);
assert.match(
imeInstallSmoke,
/Category\\Category\\\{34745C63-B2F0-4784-8B67-5E12C8701A31\}/,
'install smoke should check the keyboard TSF category',
/Category\\Category\\\{B5A73CD1-8355-426B-A161-259808F26B14\}/,
'install smoke should check the speech TSF category',
);
assert.match(
imeInstallSmoke,
Expand Down
36 changes: 1 addition & 35 deletions openless-all/app/src-tauri/src/core_adapters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2978,24 +2978,6 @@ impl CoreTextInserter for TauriTextInserter {
// whichever application happens to be focused at the end.
let insertion_target = insertion_target
.unwrap_or_else(crate::selection::capture_selection_insertion_target);
#[cfg(target_os = "windows")]
let prepared = if context.insertion.windows_insertion_mode
== openless_core::shared_types::WindowsInsertionMode::Tsf
{
let controller = Arc::clone(&windows_ime);
Some(
tauri::async_runtime::spawn_blocking(move || controller.prepare_session())
.await
.map_err(|error| {
BackendError::new(
BackendErrorCode::Internal,
format!("join Windows IME prepare task: {error}"),
)
})?,
)
} else {
None
};
#[cfg(target_os = "macos")]
let (app_handle, mut previous_input_source, streaming_ready) = {
let app_handle = app.lock().clone().ok_or_else(|| {
Expand Down Expand Up @@ -3058,8 +3040,6 @@ impl CoreTextInserter for TauriTextInserter {
finished: Arc::new(AtomicBool::new(false)),
#[cfg(target_os = "windows")]
windows_ime,
#[cfg(target_os = "windows")]
prepared: Arc::new(Mutex::new(prepared)),
#[cfg(target_os = "macos")]
app: app_handle,
#[cfg(target_os = "macos")]
Expand All @@ -3086,8 +3066,6 @@ struct TauriTextInsertionSession {
finished: Arc<AtomicBool>,
#[cfg(target_os = "windows")]
windows_ime: Arc<crate::windows_ime_session::WindowsImeSessionController>,
#[cfg(target_os = "windows")]
prepared: Arc<Mutex<Option<crate::windows_ime_session::PreparedWindowsImeSession>>>,
#[cfg(target_os = "macos")]
app: AppHandle,
#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -3190,23 +3168,16 @@ impl TauriTextInsertionSession {
{
let status = match self.context.insertion.windows_insertion_mode {
openless_core::shared_types::WindowsInsertionMode::Tsf => {
let prepared = self.prepared.lock().take().ok_or_else(|| {
BackendError::new(
BackendErrorCode::InvalidState,
"prepared Windows IME session is unavailable",
)
})?;
let request = crate::windows_ime_ipc::ImeSubmitRequest {
session_id: self.session_id.to_string(),
text: text.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
target: crate::windows_ime_target::capture_ime_submit_target(),
};
let status = match self.windows_ime.submit_prepared(&prepared, request).await {
let status = match self.windows_ime.submit(request).await {
Ok(status) => status,
Err(error) if error.is_outcome_unknown() => {
log::warn!("[core-adapter] TSF outcome is unknown: {error}");
self.windows_ime.restore_session(prepared);
return Err(BackendError::new(
BackendErrorCode::OutcomeUnknown,
error.to_string(),
Expand All @@ -3217,7 +3188,6 @@ impl TauriTextInsertionSession {
crate::types::InsertStatus::Failed
}
};
self.windows_ime.restore_session(prepared);
if status == crate::types::InsertStatus::Failed
&& self.context.insertion.allow_non_tsf_fallback
{
Expand Down Expand Up @@ -3302,10 +3272,6 @@ impl TauriTextInsertionSession {
}

async fn restore_platform_state(&self) -> Result<(), BackendError> {
#[cfg(target_os = "windows")]
if let Some(prepared) = self.prepared.lock().take() {
self.windows_ime.restore_session(prepared);
}
#[cfg(target_os = "macos")]
{
let previous_input_source = self.previous_input_source.lock().take();
Expand Down
1 change: 0 additions & 1 deletion openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ mod windows_ime_ipc;
mod windows_ime_profile;
#[cfg(target_os = "windows")]
mod windows_ime_protocol;
mod windows_ime_restore;
#[cfg(target_os = "windows")]
mod windows_ime_session;
#[cfg(target_os = "windows")]
Expand Down
Loading
Loading