From 4779878e7d82be6b96a002c6470005ae41f5cc38 Mon Sep 17 00:00:00 2001 From: cooper Date: Mon, 18 May 2026 21:33:34 +0800 Subject: [PATCH] fix(windows): package beta feedback UI gates --- .../long-input-local-qwen-contract.test.mjs | 98 +++++++++ .../app/scripts/windows-ime-restore.test.mjs | 207 ++++++++++++++++++ .../windows-main-opaque-canvas.test.mjs | 23 ++ .../windows-right-ctrl-policy.test.mjs | 34 +++ .../windows-selectlite-contract.test.mjs | 70 ++++++ openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/ja.ts | 2 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + openless-all/app/src/main.tsx | 8 + openless-all/app/src/pages/Settings.tsx | 23 ++ openless-all/app/src/styles/global.css | 6 + 13 files changed, 479 insertions(+) create mode 100644 openless-all/app/scripts/long-input-local-qwen-contract.test.mjs create mode 100644 openless-all/app/scripts/windows-ime-restore.test.mjs create mode 100644 openless-all/app/scripts/windows-main-opaque-canvas.test.mjs create mode 100644 openless-all/app/scripts/windows-right-ctrl-policy.test.mjs create mode 100644 openless-all/app/scripts/windows-selectlite-contract.test.mjs diff --git a/openless-all/app/scripts/long-input-local-qwen-contract.test.mjs b/openless-all/app/scripts/long-input-local-qwen-contract.test.mjs new file mode 100644 index 00000000..af32da22 --- /dev/null +++ b/openless-all/app/scripts/long-input-local-qwen-contract.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +function sliceBetween(source, start, end, name) { + const startIndex = source.indexOf(start); + assert.notEqual(startIndex, -1, `${name}: missing start marker ${start}`); + const endIndex = source.indexOf(end, startIndex + start.length); + assert.notEqual(endIndex, -1, `${name}: missing end marker ${end}`); + return source.slice(startIndex, endIndex); +} + +function assertOrdered(source, fragments, name) { + let cursor = -1; + for (const fragment of fragments) { + const next = source.indexOf(fragment, cursor + 1); + assert.notEqual(next, -1, `${name}: missing or out of order fragment ${fragment}`); + cursor = next; + } +} + +const localProviderRs = await readFile( + new URL("../src-tauri/src/asr/local/local_provider.rs", import.meta.url), + "utf8", +); +const coordinatorRs = await readFile( + new URL("../src-tauri/src/coordinator.rs", import.meta.url), + "utf8", +); +const dictationRs = await readFile( + new URL("../src-tauri/src/coordinator/dictation.rs", import.meta.url), + "utf8", +); + +const localQwenImpl = sliceBetween( + localProviderRs, + "impl LocalQwenAsr {", + "impl crate::recorder::AudioConsumer for LocalQwenAsr {", + "LocalQwenAsr impl", +); + +assertOrdered( + localQwenImpl, + [ + "pub fn buffer_duration_ms(&self) -> u64 {", + "(self.buffer.lock().len() as u64 / 2) * 1000 / 16_000", + "pub async fn transcribe(self: Arc) -> Result {", + "let pcm_bytes = std::mem::take(&mut *self.buffer.lock());", + "let duration_ms = (pcm_bytes.len() as u64 / 2) * 1000 / 16_000;", + "let mut samples_f32 = i16_le_bytes_to_f32(&pcm_bytes);", + "samples_f32.extend(std::iter::repeat(0.0f32).take(8_000));", + "engine.transcribe_stream(&samples_f32)", + "Ok(RawTranscript { text, duration_ms })", + ], + "local Qwen ASR should measure original audio, append 0.5s silence, and transcribe padded samples", +); + +const localQwenBranch = sliceBetween( + dictationRs, + "ActiveAsr::Local(local) => {", + "inner.local_asr_cache.touch();", + "dictation local Qwen branch", +); +assertOrdered( + localQwenBranch, + [ + "let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;", + "let timeout_duration = local_qwen_transcribe_timeout(audio_secs);", + "\"[coord] local Qwen3-ASR transcribe: audio={:.2}s timeout={}s\"", + "let result = tokio::time::timeout(timeout_duration, local.transcribe()).await;", + ], + "dictation should compute a dynamic timeout from buffered audio before consuming local Qwen ASR", +); + +const timeoutHelper = sliceBetween( + coordinatorRs, + "fn local_qwen_transcribe_timeout(audio_secs: f64) -> std::time::Duration {", + "fn startup_race_status_for_starting(", + "local_qwen_transcribe_timeout", +); +assertOrdered( + timeoutHelper, + [ + "let secs = ((audio_secs * 0.6).ceil() as u64)", + ".saturating_add(10)", + ".max(COORDINATOR_GLOBAL_TIMEOUT_SECS);", + "std::time::Duration::from_secs(secs)", + ], + "local Qwen ASR timeout should be max(15, ceil(audio_s * 0.6) + 10)", +); + +for (const testName of [ + "local_qwen_timeout_floors_at_global_timeout_for_short_audio", + "local_qwen_timeout_scales_with_audio_duration", + "local_qwen_timeout_ceils_partial_seconds", + "local_qwen_timeout_handles_zero_duration", +]) { + assert.match(coordinatorRs, new RegExp(`fn ${testName}\\(\\)`), `missing Rust timeout test ${testName}`); +} diff --git a/openless-all/app/scripts/windows-ime-restore.test.mjs b/openless-all/app/scripts/windows-ime-restore.test.mjs new file mode 100644 index 00000000..3a1ad8a9 --- /dev/null +++ b/openless-all/app/scripts/windows-ime-restore.test.mjs @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +function sliceBetween(source, start, end, name) { + const startIndex = source.indexOf(start); + assert.notEqual(startIndex, -1, `${name}: missing start marker ${start}`); + const endIndex = source.indexOf(end, startIndex + start.length); + assert.notEqual(endIndex, -1, `${name}: missing end marker ${end}`); + return source.slice(startIndex, endIndex); +} + +function assertOrdered(source, fragments, name) { + let cursor = -1; + for (const fragment of fragments) { + const next = source.indexOf(fragment, cursor + 1); + assert.notEqual(next, -1, `${name}: missing or out of order fragment ${fragment}`); + cursor = next; + } +} + +function assertMatch(source, pattern, name) { + assert.match(source, pattern, name); +} + +const profileRs = await readFile( + new URL("../src-tauri/src/windows_ime_profile.rs", import.meta.url), + "utf8", +); +const sessionRs = await readFile( + new URL("../src-tauri/src/windows_ime_session.rs", import.meta.url), + "utf8", +); +const coordinatorRs = await readFile( + new URL("../src-tauri/src/coordinator.rs", import.meta.url), + "utf8", +); +const dictationRs = await readFile( + new URL("../src-tauri/src/coordinator/dictation.rs", import.meta.url), + "utf8", +); + +const activateOpenLess = sliceBetween( + profileRs, + "pub fn activate_openless_profile() -> WindowsImeProfileResult<()> {", + "pub fn restore_profile(snapshot: &ImeProfileSnapshot) -> WindowsImeProfileResult<()> {", + "activate_openless_profile", +); +assertOrdered( + activateOpenLess, + [ + "profiles.EnableLanguageProfile(&clsid, OPENLESS_TSF_LANG_ID, &profile_guid, true)?;", + "profiles.ChangeCurrentLanguage(OPENLESS_TSF_LANG_ID)?;", + "profiles.ActivateLanguageProfile(&clsid, OPENLESS_TSF_LANG_ID, &profile_guid)", + "manager.ActivateProfile(", + "TF_PROFILETYPE_INPUTPROCESSOR", + ], + "OpenLess activation should update legacy TSF before modern TSF", +); + +const restoreProfile = sliceBetween( + profileRs, + "pub fn restore_profile(snapshot: &ImeProfileSnapshot) -> WindowsImeProfileResult<()> {", + "pub fn is_openless_profile_active() -> WindowsImeProfileResult {", + "restore_profile", +); +const restoreTextService = sliceBetween( + restoreProfile, + "ImeProfileKind::TextService => {", + "ImeProfileKind::KeyboardLayout => {", + "restore_profile text service branch", +); +assertOrdered( + restoreTextService, + [ + "let lang_id = snapshot.lang_id();", + "profiles.ChangeCurrentLanguage(lang_id)?;", + "profiles.ActivateLanguageProfile(&clsid, lang_id, &profile_guid)", + "let modern_result = with_profile_manager", + "manager.ActivateProfile(", + "TF_PROFILETYPE_INPUTPROCESSOR", + "if let Err(err) = modern_result", + "[windows-ime] legacy restore OK but modern ActivateProfile failed", + ], + "saved text-service restore should mirror activation and log modern TSF drift", +); +const restoreKeyboard = restoreProfile.slice( + restoreProfile.indexOf("ImeProfileKind::KeyboardLayout => {"), +); +assertOrdered( + restoreKeyboard, + [ + "let lang_id = snapshot.lang_id();", + "profiles.ChangeCurrentLanguage(lang_id)", + "let modern_result = with_profile_manager", + "manager.ActivateProfile(", + "TF_PROFILETYPE_KEYBOARDLAYOUT", + "if let Err(err) = modern_result", + "[windows-ime] legacy restore OK but modern ActivateProfile (keyboard) failed", + ], + "saved keyboard-layout restore should restore legacy language before modern TSF bookkeeping", +); + +const prepareSession = sliceBetween( + sessionRs, + "pub fn prepare_session(&self) -> PreparedWindowsImeSession {", + "pub async fn submit_prepared(", + "prepare_session", +); +assertOrdered( + prepareSession, + [ + "self.profile_manager.capture_active_profile()", + "self.profile_manager.activate_openless_profile()", + "saved_profile: Some(saved_profile)", + "PreparedWindowsImeSession::activation_failed(saved_profile)", + ], + "Windows IME session should snapshot the user profile before activating OpenLess", +); + +const restoreSession = sliceBetween( + sessionRs, + "pub fn restore_session(&self, prepared: PreparedWindowsImeSession) {", + "#[cfg(test)]", + "restore_session", +); +assertOrdered( + restoreSession, + [ + "self.profile_manager.is_openless_profile_active()", + "restore_decision(", + "ProfileRestoreDecision::RestoreSavedProfile", + "self.profile_manager.restore_profile(saved_profile)", + "[windows-ime] restore saved profile failed", + ], + "Windows IME restore session should make a restore decision and log API-level restore failures", +); + +const insertWithWindowsImeFirst = sliceBetween( + coordinatorRs, + "async fn insert_with_windows_ime_first(", + "fn should_try_non_tsf_insertion_fallback(", + "insert_with_windows_ime_first", +); +assertOrdered( + insertWithWindowsImeFirst, + [ + "take_matching_prepared_windows_ime_session(&mut slot, session_id)", + "inner.windows_ime.submit_prepared(&prepared, request).await", + "inner.windows_ime.restore_session(prepared);", + "insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut)", + ], + "TSF submit should restore the saved IME before falling back to non-TSF insertion", +); + +const beginSession = sliceBetween( + dictationRs, + "pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> {", + "pub(super) async fn start_recorder_and_enter_listening(", + "dictation begin_session", +); +assertOrdered( + beginSession, + [ + "let prepared = inner.windows_ime.prepare_session();", + "store_prepared_windows_ime_session(&mut slots, current_session_id, prepared);", + "if let Err(message) = ensure_asr_credentials()", + "restore_prepared_windows_ime_session(inner, current_session_id);", + "if let Err(message) = ensure_microphone_permission(inner)", + "restore_prepared_windows_ime_session(inner, current_session_id);", + ], + "begin_session should prepare IME early and restore it on pre-recorder errors", +); + +const finishInsert = sliceBetween( + dictationRs, + "let status = if already_streamed {", + "let inserted_chars = polished.chars().count() as u32;", + "dictation finish insert", +); +assertOrdered( + finishInsert, + [ + "InsertStatus::Inserted", + "insert_with_windows_ime_first(", + "inner.inserter.copy_fallback(&polished)", + "restore_prepared_windows_ime_session(inner, current_session_id);", + ], + "dictation success, TSF fallback, copy fallback, and streamed insert paths should converge on IME restore", +); + +assertMatch( + dictationRs, + /if inner\.state\.lock\(\)\.cancelled \{[\s\S]*?\[coord\] cancel detected after ASR[\s\S]*?restore_prepared_windows_ime_session\(inner, current_session_id\);[\s\S]*?return Ok\(\(\)\);/, + "cancel after ASR should restore the prepared Windows IME session", +); +assertMatch( + dictationRs, + /pub\(super\) fn cancel_session\(inner: &Arc\) \{[\s\S]*?cancel_asr_for_session\(inner, decision\.session_id\);[\s\S]*?restore_prepared_windows_ime_session\(inner, decision\.session_id\);/, + "explicit cancel_session should restore the prepared Windows IME session", +); + +const currentSessionRestoreCalls = + dictationRs.match(/restore_prepared_windows_ime_session\(inner, current_session_id\);/g) ?? []; +assert.ok( + currentSessionRestoreCalls.length >= 20, + `dictation should keep broad Windows IME cleanup coverage; found ${currentSessionRestoreCalls.length} current-session restore calls`, +); diff --git a/openless-all/app/scripts/windows-main-opaque-canvas.test.mjs b/openless-all/app/scripts/windows-main-opaque-canvas.test.mjs new file mode 100644 index 00000000..087a8e28 --- /dev/null +++ b/openless-all/app/scripts/windows-main-opaque-canvas.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const mainTsx = await readFile(new URL("../src/main.tsx", import.meta.url), "utf8"); +const globalCss = await readFile(new URL("../src/styles/global.css", import.meta.url), "utf8"); + +assert.match( + mainTsx, + /const openlessWindowKind = isCapsule \? "capsule" : isQa \? "qa" : "main";[\s\S]*const openlessPlatform = detectOS\(\);[\s\S]*document\.documentElement\.dataset\.openlessWindow = openlessWindowKind;[\s\S]*document\.documentElement\.dataset\.openlessPlatform = openlessPlatform;[\s\S]*document\.body\.dataset\.openlessWindow = openlessWindowKind;[\s\S]*document\.body\.dataset\.openlessPlatform = openlessPlatform;/, + "frontend should mark each webview kind and platform before rendering", +); + +assert.match( + globalCss, + /html, body, #root \{[\s\S]*background: transparent;[\s\S]*\}/, + "capsule and QA windows should keep the shared transparent WebView baseline", +); + +assert.match( + globalCss, + /html\[data-openless-window="main"\]\[data-openless-platform="win"\],[\s\S]*html\[data-openless-window="main"\]\[data-openless-platform="win"\] body,[\s\S]*html\[data-openless-window="main"\]\[data-openless-platform="win"\] #root \{[\s\S]*background: #f5f5f7;[\s\S]*\}/, + "Windows main window should have an internal opaque canvas to prevent transparent WebView bleed-through", +); diff --git a/openless-all/app/scripts/windows-right-ctrl-policy.test.mjs b/openless-all/app/scripts/windows-right-ctrl-policy.test.mjs new file mode 100644 index 00000000..bebe9d9e --- /dev/null +++ b/openless-all/app/scripts/windows-right-ctrl-policy.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +function assertMatch(source, pattern, message) { + assert.match(source, pattern, message); +} + +const settingsTsx = await readFile(new URL("../src/pages/Settings.tsx", import.meta.url), "utf8"); + +assertMatch( + settingsTsx, + /const showRightCtrlHoldWarning = detectOS\(\) === 'win'[\s\S]*&& prefs\.hotkey\.mode === 'hold'[\s\S]*&& prefs\.dictationHotkey\.primary === 'RightControl'[\s\S]*&& prefs\.dictationHotkey\.modifiers\.length === 0;/, + "Right Ctrl warning should be scoped to Windows + RightControl + hold mode only", +); + +assertMatch( + settingsTsx, + /\{showRightCtrlHoldWarning && \([\s\S]*settings\.recording\.rightCtrlHoldWarningTitle[\s\S]*settings\.recording\.rightCtrlHoldWarningDesc[\s\S]*\)\}/, + "Recording settings should render the scoped Right Ctrl hold warning", +); + +for (const locale of ["en", "zh-CN", "zh-TW", "ja", "ko"]) { + const source = await readFile(new URL(`../src/i18n/${locale}.ts`, import.meta.url), "utf8"); + assertMatch( + source, + /rightCtrlHoldWarningTitle:/, + `${locale} should define the Right Ctrl warning title`, + ); + assertMatch( + source, + /rightCtrlHoldWarningDesc:/, + `${locale} should define the Right Ctrl warning description`, + ); +} diff --git a/openless-all/app/scripts/windows-selectlite-contract.test.mjs b/openless-all/app/scripts/windows-selectlite-contract.test.mjs new file mode 100644 index 00000000..3095fbfb --- /dev/null +++ b/openless-all/app/scripts/windows-selectlite-contract.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const srcRootPath = fileURLToPath(new URL("../src/", import.meta.url)); + +async function collectTsxFiles(dirPath) { + const entries = await readdir(dirPath, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const childPath = join(dirPath, entry.name); + if (entry.isDirectory()) { + files.push(...await collectTsxFiles(childPath)); + } else if (entry.name.endsWith(".tsx")) { + files.push(childPath); + } + } + return files; +} + +function stripComments(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); +} + +function assertMatch(source, pattern, message) { + assert.match(source, pattern, message); +} + +function assertSelectLiteUsage(source, minCount, fileLabel) { + const count = source.match(/= minCount, `${fileLabel} should use SelectLite at least ${minCount} time(s); found ${count}`); +} + +const selectLite = await readFile(new URL("../src/components/ui/SelectLite.tsx", import.meta.url), "utf8"); +const settings = await readFile(new URL("../src/pages/Settings.tsx", import.meta.url), "utf8"); +const localAsr = await readFile(new URL("../src/pages/LocalAsr.tsx", import.meta.url), "utf8"); +const translation = await readFile(new URL("../src/pages/Translation.tsx", import.meta.url), "utf8"); +const languageSection = await readFile(new URL("../src/pages/settings/LanguageSection.tsx", import.meta.url), "utf8"); + +const tsxFiles = await collectTsxFiles(srcRootPath); +for (const filePath of tsxFiles) { + const source = stripComments(await readFile(filePath, "utf8")); + assert.doesNotMatch( + source, + /`, + ); +} + +assertMatch(selectLite, /export interface SelectOption \{[\s\S]*value: string;[\s\S]*label: string;[\s\S]*disabled\?: boolean;/, "SelectLite should expose value/label/disabled options"); +assertMatch(selectLite, /interface SelectLiteProps \{[\s\S]*value: string;[\s\S]*onChange: \(value: string\) => void;[\s\S]*options: SelectOption\[\];[\s\S]*disabled\?: boolean;/, "SelectLite should accept value, onChange, options, and disabled"); +assertMatch(selectLite, /createPortal\(/, "SelectLite popover should render through a portal"); +assertMatch(selectLite, /role="combobox"[\s\S]*aria-haspopup="listbox"[\s\S]*aria-expanded=\{open\}/, "SelectLite trigger should expose combobox/listbox a11y state"); +assertMatch(selectLite, /role="listbox"/, "SelectLite popover should use role=listbox"); +assertMatch(selectLite, /role="option"[\s\S]*aria-selected=\{isSelected\}[\s\S]*aria-disabled=\{option\.disabled\}/, "SelectLite options should expose selected and disabled state"); +assertMatch(selectLite, /event\.key === 'Escape'[\s\S]*closeMenu\(\)/, "SelectLite should close on Escape"); +assertMatch(selectLite, /event\.key === 'ArrowDown'[\s\S]*moveHighlight\(1\)/, "SelectLite should move highlight down from keyboard"); +assertMatch(selectLite, /event\.key === 'ArrowUp'[\s\S]*moveHighlight\(-1\)/, "SelectLite should move highlight up from keyboard"); +assertMatch(selectLite, /event\.key === 'Enter'[\s\S]*selectIndex\(highlight\)/, "SelectLite should select highlighted option on Enter"); +assertMatch(selectLite, /document\.addEventListener\('mousedown', handlePointerDown\)/, "SelectLite should close on outside pointer down"); +assertMatch(selectLite, /window\.addEventListener\('wheel', handleScrollOutside/, "SelectLite should close on outside wheel/scroll"); +assertMatch(selectLite, /textOverflow: 'ellipsis'/, "SelectLite should constrain long labels instead of expanding layout"); + +assertSelectLiteUsage(settings, 4, "Settings.tsx"); +assertSelectLiteUsage(localAsr, 5, "LocalAsr.tsx"); +assertSelectLiteUsage(translation, 1, "Translation.tsx"); +assertSelectLiteUsage(languageSection, 1, "LanguageSection.tsx"); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 3b177f83..2fe689cb 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -514,6 +514,8 @@ export const en: typeof zhCN = { modeDesc: 'Toggle = tap once to start, again to stop. Push-to-talk = hold to record.', modeToggle: 'Toggle', modeHold: 'Push-to-talk', + rightCtrlHoldWarningTitle: 'Right Ctrl may conflict with chat send shortcuts', + rightCtrlHoldWarningDesc: 'Push-to-talk reserves Right Ctrl while recording, so Right Ctrl+Enter in apps like QQ or WeChat may arrive as Enter only. Switch back to Toggle, or choose a shortcut that is not used for sending messages.', migrationNoticeTitle: 'Default recording mode is now Toggle', migrationNoticeDesc: 'This update changes the default; if you prefer push-to-talk, switch it back here.', microphoneLabel: 'Preferred microphone', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index d7479197..c2d0a9e5 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -516,6 +516,8 @@ export const ja: typeof zhCN = { modeDesc: 'トグル式 = 1 回押して開始、もう 1 回押して終了;押し続けて話す = 押している間だけ録音。', modeToggle: 'トグル式', modeHold: '押し続けて話す', + rightCtrlHoldWarningTitle: '右 Ctrl はチャット送信ショートカットと競合する場合があります', + rightCtrlHoldWarningDesc: '押し続けて話すでは録音中に右 Ctrl を予約するため、QQ / WeChat などの右 Ctrl+Enter が Enter だけとして届く場合があります。トグル式に戻すか、送信に使わないショートカットを選んでください。', migrationNoticeTitle: 'デフォルトがトグル式に変更されました', migrationNoticeDesc: '以前にトリガー方式を変更していた場合は、ここで再度確認してください。今回のアップデートではショートカット方式のデフォルト値と読み込みロジックが変更されています。「押し続けて話す」が好みであれば再度切り替えてください。', microphoneLabel: '優先マイク', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index dbd068b1..55fc642a 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -516,6 +516,8 @@ export const ko: typeof zhCN = { modeDesc: '토글 방식 = 한 번 누르면 시작, 다시 누르면 종료; 눌러서 말하기 = 누르고 있는 동안만 녹음.', modeToggle: '토글 방식', modeHold: '눌러서 말하기', + rightCtrlHoldWarningTitle: '오른쪽 Ctrl 이 채팅 전송 단축키와 충돌할 수 있습니다', + rightCtrlHoldWarningDesc: '눌러서 말하기는 녹음 중 오른쪽 Ctrl 을 예약하므로 QQ / WeChat 등의 오른쪽 Ctrl+Enter 가 Enter 만으로 전달될 수 있습니다. 토글 방식으로 돌아가거나 메시지 전송에 쓰지 않는 단축키를 선택하세요.', migrationNoticeTitle: '기본값이 토글 방식으로 변경됨', migrationNoticeDesc: '이전에 트리거 방식을 변경했다면 여기서 다시 한 번 확인해 주세요. 이번 업데이트는 단축키 방식의 기본값과 읽기 로직을 조정했습니다. "눌러서 말하기"가 더 익숙하다면 다시 전환할 수 있습니다.', microphoneLabel: '기본 선택 마이크', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 089cbbbe..594b58fa 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -512,6 +512,8 @@ export const zhCN = { modeDesc: '切换式按一次开始、再按一次结束;按住说话按下保持、松开结束。', modeToggle: '切换式', modeHold: '按住说话', + rightCtrlHoldWarningTitle: '右 Ctrl 可能与聊天发送快捷键冲突', + rightCtrlHoldWarningDesc: '按住说话会在录音期间占用右 Ctrl,QQ / 微信里的右 Ctrl+Enter 可能只收到 Enter。建议切回切换式,或改用不参与发送消息的快捷键。', migrationNoticeTitle: '默认已改为切换式说话', migrationNoticeDesc: '本次更新调整了默认值,如果习惯按住说话,请在此处切回。', microphoneLabel: '首选麦克风', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 34a11160..47594921 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -514,6 +514,8 @@ export const zhTW: typeof zhCN = { modeDesc: '切換式 = 按一次開始、再按一次結束;按住說話 = 按住開始、鬆開結束。', modeToggle: '切換式', modeHold: '按住說話', + rightCtrlHoldWarningTitle: '右 Ctrl 可能與聊天送出快捷鍵衝突', + rightCtrlHoldWarningDesc: '按住說話會在錄音期間佔用右 Ctrl,QQ / 微信裏的右 Ctrl+Enter 可能只收到 Enter。建議切回切換式,或改用不參與送出訊息的快捷鍵。', migrationNoticeTitle: '默認已改爲切換式說話', migrationNoticeDesc: '如果你之前改過快捷鍵觸發方式,請在這裏手動確認一次。本次更新調整了快捷鍵方式的默認值與讀取邏輯;如果你更習慣按住說話,可以重新切回“按住說話”。', comboRecordLabel: '錄製快捷鍵', diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index 0c592ec5..f1c1ed75 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -1,6 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App"; +import { detectOS } from "./components/WindowChrome"; import i18n from "./i18n"; // 副作用:触发 i18next init import "./styles/tokens.css"; import "./styles/global.css"; @@ -9,6 +10,13 @@ const params = new URLSearchParams(window.location.search); const windowKind = params.get("window"); const isCapsule = windowKind === "capsule"; const isQa = windowKind === "qa"; +const openlessWindowKind = isCapsule ? "capsule" : isQa ? "qa" : "main"; +const openlessPlatform = detectOS(); + +document.documentElement.dataset.openlessWindow = openlessWindowKind; +document.documentElement.dataset.openlessPlatform = openlessPlatform; +document.body.dataset.openlessWindow = openlessWindowKind; +document.body.dataset.openlessPlatform = openlessPlatform; const root = ReactDOM.createRoot(document.getElementById("root")!); diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 111a40e6..bc98eaea 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -343,6 +343,10 @@ function RecordingSection() { const selectedMicrophoneLabel = effectiveMicrophoneDeviceName ? effectiveMicrophoneDeviceName : t('settings.recording.microphoneDefault'); + const showRightCtrlHoldWarning = detectOS() === 'win' + && prefs.hotkey.mode === 'hold' + && prefs.dictationHotkey.primary === 'RightControl' + && prefs.dictationHotkey.modifiers.length === 0; return ( <> @@ -398,6 +402,25 @@ function RecordingSection() { ))} + {showRightCtrlHoldWarning && ( +
+
+ {t('settings.recording.rightCtrlHoldWarningTitle')} +
+
+ {t('settings.recording.rightCtrlHoldWarningDesc')} +
+
+ )}