diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa4f8fff7..591589e56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI # 多平台跨语言质量门禁。release-tauri.yml 是发版流水线(仅打包构建),这里负责 # 在合并前快速验证两件事: # 1. 全部前端/契约测试与 vite bundle 通过(捕获行为、合同及跨 locale 类型 drift) -# 2. Tauri 后端在 macOS / Windows、移动端在 Android 编译;Linux 只编译共享 core 与 egui host。 +# 2. Tauri 后端在 macOS / Windows、移动端在 Android 编译;Linux 只测试共享 core。 # 跑 build-mac.sh / windows-package-msvc.ps1 / Tauri bundle 太重;只跑轻量 cargo check + vite build。 on: @@ -12,12 +12,6 @@ on: pull_request: branches: [main, beta] workflow_dispatch: - inputs: - upload_linux_validation_artifact: - description: Build and upload the Linux egui validation packages (never a release) - required: false - default: false - type: boolean # 同一 PR 快速重复推送时取消旧运行;workflow_dispatch 用 run_id 隔离。 concurrency: @@ -172,7 +166,7 @@ jobs: --no-daemon linux-core-contract: - name: Linux core and egui host + name: Linux core tests runs-on: ubuntu-22.04 defaults: run: @@ -183,58 +177,13 @@ jobs: submodules: false - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - uses: swatinem/rust-cache@v2 with: workspaces: 'openless-all/app -> target' - - name: Install Linux native dependencies without WebKitGTK - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - libasound2-dev \ - libdbus-1-dev \ - libssl-dev \ - libwayland-dev \ - libx11-dev \ - libxkbcommon-dev \ - ripgrep \ - pkg-config - - - name: Test and lint shared backend - run: | - cargo test --locked -p openless-core - cargo clippy --locked -p openless-core --all-targets -- -D warnings - - - name: Test Linux host and UI contract - run: | - cargo test --locked -p openless-linux-egui --all-targets - cargo check --locked -p openless-linux-egui --all-targets - - - name: Verify dependency direction, compatibility baseline, and secret surfaces - shell: pwsh - run: | - ./scripts/check-core-deps.ps1 - ./scripts/check-core-deps.ps1 openless-linux-egui - ./scripts/check-command-event-baseline.ps1 - ./scripts/check-core-secret-surface.ps1 - ./scripts/check-core-test-isolation.ps1 - ./scripts/check-core-runtime-seam.ps1 - ./scripts/check-linux-public-surface.ps1 - - linux-egui-validation-artifact: - name: Linux egui validation artifact - if: github.event_name == 'workflow_dispatch' && inputs.upload_linux_validation_artifact - needs: linux-core-contract - permissions: - contents: write - uses: ./.github/workflows/release-linux-egui.yml - with: - release_tag: '' - secrets: inherit + - name: Test Core + run: cargo test --locked -p openless-core cross-platform: name: ${{ matrix.label }} checks diff --git a/openless-all/app/crates/openless-core/src/settings.rs b/openless-all/app/crates/openless-core/src/settings.rs index d474125d4..37d0f7ccf 100644 --- a/openless-all/app/crates/openless-core/src/settings.rs +++ b/openless-all/app/crates/openless-core/src/settings.rs @@ -1,7 +1,9 @@ use serde::{Deserialize, Serialize}; use crate::errors::BackendError; -use crate::shared_types::{HotkeyMode, ShortcutBinding, StylePackHotkey, UserPreferences}; +use crate::shared_types::{ + HotkeyMode, ShortcutBinding, StylePackHotkey, UserPreferences, WindowsInsertionMode, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -73,15 +75,17 @@ impl From<&UserPreferences> for HotkeyRuntimeTarget { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WindowsKeyboardRuntimeTarget { - pub send_input_insertion_only: bool, - pub show_openless_in_keyboard_list: bool, + pub openless_language_profile_enabled: bool, } impl From<&UserPreferences> for WindowsKeyboardRuntimeTarget { fn from(preferences: &UserPreferences) -> Self { Self { - send_input_insertion_only: preferences.windows_sendinput_insertion_only, - show_openless_in_keyboard_list: preferences.windows_show_openless_in_keyboard_list, + openless_language_profile_enabled: !matches!( + preferences.windows_insertion_mode, + WindowsInsertionMode::SendInput | WindowsInsertionMode::Paste + ) || preferences + .windows_show_openless_in_keyboard_list, } } } @@ -209,3 +213,52 @@ pub struct SettingsUpdateOutcome { pub struct StylePackRemovalOutcome { pub effects: SettingsEffectPlan, } + +#[cfg(test)] +mod tests { + use super::*; + + fn preferences(mode: WindowsInsertionMode, show: bool) -> UserPreferences { + UserPreferences { + windows_insertion_mode: mode, + windows_sendinput_insertion_only: mode == WindowsInsertionMode::SendInput, + windows_show_openless_in_keyboard_list: show, + ..UserPreferences::default() + } + } + + #[test] + fn windows_keyboard_effect_tracks_the_effective_profile_state() { + for (mode, show, enabled) in [ + (WindowsInsertionMode::Tsf, true, true), + (WindowsInsertionMode::Tsf, false, true), + (WindowsInsertionMode::SendInput, true, true), + (WindowsInsertionMode::SendInput, false, false), + (WindowsInsertionMode::Paste, true, true), + (WindowsInsertionMode::Paste, false, false), + ] { + assert_eq!( + WindowsKeyboardRuntimeTarget::from(&preferences(mode, show)) + .openless_language_profile_enabled, + enabled, + "mode={mode:?} show={show}" + ); + } + + let send_input_hidden = preferences(WindowsInsertionMode::SendInput, false); + let paste_hidden = preferences(WindowsInsertionMode::Paste, false); + assert!( + SettingsEffectPlan::between(&send_input_hidden, &paste_hidden) + .windows_keyboard + .is_none() + ); + + let tsf_with_hidden_pref = preferences(WindowsInsertionMode::Tsf, false); + let change = SettingsEffectPlan::between(&paste_hidden, &tsf_with_hidden_pref) + .windows_keyboard + .expect("returning to TSF must re-enable its language profile"); + assert!(!change.previous.openless_language_profile_enabled); + assert!(change.next.openless_language_profile_enabled); + assert!(!tsf_with_hidden_pref.windows_show_openless_in_keyboard_list); + } +} diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 0ce4c09f2..6266a809b 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -406,8 +406,9 @@ pub struct UserPreferences { alias = "windowsSendinputInsertionOnly" )] pub windows_sendinput_insertion_only: bool, - /// Windows:SendInput 模式下是否在系统键盘列表(Win+Space)中显示 OpenLess TSF 输入法。 - /// 默认 true 保持现有行为;关闭后用户级禁用语言配置文件,无需管理员权限。 + /// Windows:非 TSF 插入方式(SendInput / 剪贴板粘贴)下是否在系统键盘列表(Win+Space) + /// 中显示 OpenLess TSF 输入法。默认 true 保持现有行为;关闭后用户级禁用语言配置文件, + /// 无需管理员权限。TSF 模式仍会强制启用 profile,但不会改写本偏好。 #[serde(default = "default_true", rename = "windowsShowOpenlessInKeyboardList")] pub windows_show_openless_in_keyboard_list: bool, /// 用户的工作语言(多选,原生名)。会作为前提注入 LLM polish/translate 的 system prompt 头部, diff --git a/openless-all/app/scripts/build-mac.sh b/openless-all/app/scripts/build-mac.sh index 0d1493f6b..32e3c4d7e 100755 --- a/openless-all/app/scripts/build-mac.sh +++ b/openless-all/app/scripts/build-mac.sh @@ -71,6 +71,11 @@ if [ "$MAC_BUNDLE_ARCH" = "aarch64" ]; then echo "✗ Apple Silicon app 缺少 Contents/Resources/mlx.metallib" exit 1 fi + # Contents/MacOS 里的非 Mach-O 会被 codesign 当成 nested code。 + if [ -e "$APP/Contents/MacOS/mlx.metallib" ]; then + echo "✗ mlx.metallib 不能放在 Contents/MacOS(ad-hoc codesign 会失败)" + exit 1 + fi APP_METALLIB_SHA="$(shasum -a 256 "$APP_METALLIB" | awk '{print $1}')" if [ ! -f "$DMG_PATH" ]; then @@ -89,6 +94,10 @@ if [ "$MAC_BUNDLE_ARCH" = "aarch64" ]; then echo "✗ DMG 中缺少 OpenLess.app/Contents/Resources/mlx.metallib" exit 1 fi + if [ -e "$DMG_MOUNT/OpenLess.app/Contents/MacOS/mlx.metallib" ]; then + echo "✗ DMG 中的 mlx.metallib 不能放在 Contents/MacOS" + exit 1 + fi DMG_METALLIB_SHA="$(shasum -a 256 "$DMG_METALLIB" | awk '{print $1}')" if [ "$DMG_METALLIB_SHA" != "$APP_METALLIB_SHA" ]; then echo "✗ app 与 DMG 中的 mlx.metallib SHA-256 不一致" @@ -111,7 +120,7 @@ if [ "$MAC_BUNDLE_ARCH" = "aarch64" ]; then fi fi echo "✓ MLX metallib sha256=$APP_METALLIB_SHA" -elif [ -e "$APP/Contents/Resources/mlx.metallib" ]; then +elif [ -e "$APP/Contents/MacOS/mlx.metallib" ] || [ -e "$APP/Contents/Resources/mlx.metallib" ]; then echo "✗ Intel app 不应包含 Apple Silicon MLX metallib" exit 1 fi diff --git a/openless-all/app/scripts/macos-mlx-bundle-contract.test.mjs b/openless-all/app/scripts/macos-mlx-bundle-contract.test.mjs index 098a98651..15029e1e9 100644 --- a/openless-all/app/scripts/macos-mlx-bundle-contract.test.mjs +++ b/openless-all/app/scripts/macos-mlx-bundle-contract.test.mjs @@ -17,11 +17,14 @@ assert.equal( overlay.bundle.macOS.files["Resources/mlx.metallib"], "target/release/openless-mlx/mlx.metallib", ) +assert.equal(overlay.bundle.macOS.files["MacOS/mlx.metallib"], undefined) assert.match(buildScript, /arm64\)[\s\S]*tauri\.macos-mlx\.conf\.json/) assert.doesNotMatch(buildScript, /--bundles app/) assert.doesNotMatch(buildScript, /codesign --force/) assert.doesNotMatch(buildScript, /hdiutil create/) assert.match(buildScript, /OpenLess\.app\.tar\.gz/) assert.match(buildScript, /stapler validate/) +assert.match(buildScript, /Contents\/Resources\/mlx\.metallib/) +assert.match(buildScript, /Contents\/MacOS\/mlx\.metallib/) console.log("macOS MLX bundle contract tests passed") diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index fb4180ffc..0ccccc807 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -20,6 +20,10 @@ fn main() { if target_os == "macos" { link_macos_compiler_runtime(); + let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + if target_arch == "aarch64" { + compile_mlx_metallib_path_shim(); + } } if target_os == "android" { @@ -63,6 +67,18 @@ fn link_macos_compiler_runtime() { println!("cargo:rustc-link-lib=static=clang_rt.osx"); } +/// Apple Silicon 发布包把 mlx.metallib 放在 Contents/Resources。 +/// mlx-c 默认只在可执行文件旁边找,这里补一个 C 入口去调用 set_metallib_path。 +fn compile_mlx_metallib_path_shim() { + const SOURCE: &str = "src/asr/local/mlx_set_metallib_path.cpp"; + println!("cargo:rerun-if-changed={SOURCE}"); + cc::Build::new() + .cpp(true) + .std("c++17") + .file(SOURCE) + .compile("openless_mlx_set_metallib_path"); +} + /// cpal → oboe → oboe-sys 会编译 C++;最终 cdylib 需显式链接 NDK libc++。 fn link_android_cpp_runtime() { // oboe-ext 已部分静态链入 libc++;补链 c++abi 提供 __cxa_pure_virtual 等 ABI 符号。 diff --git a/openless-all/app/src-tauri/src/asr/local/mlx_set_metallib_path.cpp b/openless-all/app/src-tauri/src/asr/local/mlx_set_metallib_path.cpp new file mode 100644 index 000000000..303085ec6 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/mlx_set_metallib_path.cpp @@ -0,0 +1,20 @@ +// MLX worker 启动时把 bundled metallib 路径交给 libmlx。 +// mlx-c 没暴露 set_metallib_path,这里直接调 C++ API。 + +#include + +namespace mlx { +namespace core { +namespace metal { +void set_metallib_path(const std::string& path); +} +} +} + +extern "C" int openless_mlx_set_metallib_path(const char* path) { + if (path == nullptr || path[0] == '\0') { + return 1; + } + mlx::core::metal::set_metallib_path(std::string(path)); + return 0; +} diff --git a/openless-all/app/src-tauri/src/asr/local/mlx_worker.rs b/openless-all/app/src-tauri/src/asr/local/mlx_worker.rs index 3e71de7de..6ad17b7cf 100644 --- a/openless-all/app/src-tauri/src/asr/local/mlx_worker.rs +++ b/openless-all/app/src-tauri/src/asr/local/mlx_worker.rs @@ -1031,7 +1031,44 @@ pub(crate) fn run_if_requested() { } } +fn bundled_mlx_metallib_from_exe(exe: &Path) -> Option { + let macos_dir = exe.parent()?; + if macos_dir.file_name().is_none_or(|name| name != "MacOS") { + return None; + } + Some(macos_dir.parent()?.join("Resources").join("mlx.metallib")) +} + +fn apply_bundled_mlx_metallib_path() { + let Ok(exe) = std::env::current_exe() else { + return; + }; + let Some(metallib) = bundled_mlx_metallib_from_exe(&exe) else { + return; + }; + if !metallib.is_file() { + return; + } + let Some(path) = metallib.to_str() else { + return; + }; + let Ok(c_path) = std::ffi::CString::new(path) else { + return; + }; + extern "C" { + fn openless_mlx_set_metallib_path(path: *const std::os::raw::c_char) -> i32; + } + let status = unsafe { openless_mlx_set_metallib_path(c_path.as_ptr()) }; + if status != 0 { + eprintln!( + "failed to set MLX metallib path {}: status={status}", + metallib.display() + ); + } +} + fn run_worker(socket_path: &Path) -> Result<()> { + apply_bundled_mlx_metallib_path(); let stream = UnixStream::connect(socket_path) .with_context(|| format!("connect MLX worker socket: {}", socket_path.display()))?; let session_dir = socket_path @@ -1242,6 +1279,21 @@ mod tests { .unwrap() } + #[test] + fn bundled_metallib_uses_contents_resources_not_macos() { + let exe = Path::new("/Applications/OpenLess.app/Contents/MacOS/openless"); + assert_eq!( + bundled_mlx_metallib_from_exe(exe).as_deref(), + Some(Path::new( + "/Applications/OpenLess.app/Contents/Resources/mlx.metallib" + )) + ); + assert_eq!( + bundled_mlx_metallib_from_exe(Path::new("/target/release/openless")), + None + ); + } + #[test] fn frame_round_trip_preserves_request() { let (mut sender, mut receiver) = UnixStream::pair().unwrap(); diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 2460c04dd..2e16703ab 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -27,13 +27,10 @@ impl<'a> TauriSettingsRuntime<'a> { &self, target: &openless_core::WindowsKeyboardRuntimeTarget, ) -> Result<(), openless_core::BackendError> { - let preferences = UserPreferences { - windows_sendinput_insertion_only: target.send_input_insertion_only, - windows_show_openless_in_keyboard_list: target.show_openless_in_keyboard_list, - ..UserPreferences::default() - }; - crate::windows_ime_profile::apply_windows_openless_keyboard_list_pref(&preferences) - .map_err(Self::platform_error) + crate::windows_ime_profile::apply_windows_openless_keyboard_list( + target.openless_language_profile_enabled, + ) + .map_err(Self::platform_error) } fn apply_hotkeys( diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 9ef1d81a4..068d12f21 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -598,14 +598,17 @@ fn run_desktop() { log::info!("=== OpenLess 启动 ==="); #[cfg(target_os = "windows")] - if let Err(err) = - crate::windows_ime_profile::apply_windows_openless_keyboard_list_pref( - &coordinator.backend().get_preferences(), - ) { - log::warn!( - "[windows-ime] apply keyboard list visibility pref on startup failed: {err}" + let target = openless_core::WindowsKeyboardRuntimeTarget::from( + &coordinator.backend().get_preferences(), ); + if let Err(err) = crate::windows_ime_profile::apply_windows_openless_keyboard_list( + target.openless_language_profile_enabled, + ) { + log::warn!( + "[windows-ime] apply keyboard list visibility pref on startup failed: {err}" + ); + } } // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 diff --git a/openless-all/app/src-tauri/src/windows_ime_profile.rs b/openless-all/app/src-tauri/src/windows_ime_profile.rs index 5b005d5a8..de232e069 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -3,7 +3,7 @@ pub const OPENLESS_TSF_LANG_ID: u16 = 0x0804; pub const OPENLESS_TEXT_SERVICE_CLSID_BRACED: &str = "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; pub const OPENLESS_PROFILE_GUID_BRACED: &str = "{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"; -use crate::types::{UserPreferences, WindowsImeInstallState, WindowsImeStatus}; +use crate::types::{WindowsImeInstallState, WindowsImeStatus}; #[cfg(target_os = "windows")] fn parse_guid(value: &str) -> WindowsImeProfileResult { @@ -172,14 +172,6 @@ pub fn get_windows_ime_status() -> WindowsImeStatus { } } -/// 根据偏好决定 OpenLess 语言配置文件是否应在用户键盘列表中启用。 -pub fn desired_openless_language_profile_enabled(prefs: &UserPreferences) -> bool { - if !prefs.windows_sendinput_insertion_only { - return true; - } - prefs.windows_show_openless_in_keyboard_list -} - #[cfg(target_os = "windows")] pub fn set_openless_language_profile_enabled(enabled: bool) -> WindowsImeProfileResult<()> { windows_impl::set_openless_language_profile_enabled(enabled) @@ -208,7 +200,7 @@ pub fn is_openless_language_profile_enabled() -> WindowsImeProfileResult { /// /// 返回 `Some(result)` 表示无需触碰注册表即可结束;`None` 表示已安装,需要走真正的 /// `EnableLanguageProfile` 变更。抽成纯函数,使「未安装」这一分支能在任意平台上被测试 -/// 覆盖到(`apply_windows_openless_keyboard_list_pref` 依赖 Windows 注册表,macOS/CI +/// 覆盖到(`apply_windows_openless_keyboard_list` 依赖 Windows 注册表,macOS/CI /// 无法命中其内部分支)。 /// /// 关键语义:TSF IME 未安装时,键盘列表里根本没有 OpenLess 条目—— @@ -216,8 +208,8 @@ pub fn is_openless_language_profile_enabled() -> WindowsImeProfileResult { /// - `desired == true`(显示)也只能是 no-op(没东西可启用)。 /// /// 两支都必须是 `Ok(())`。此前「不显示 + 未安装」错误地返回 `Err`,经 settings.rs 的 -/// `apply_keyboard_list(&prefs)?` 传播,导致整个设置保存事务回滚(用户勾「仅 SendInput -/// 插入」+「不在键盘列表显示」且未装 TSF IME 时,之后任何设置都存不进)。 +/// 设置事务传播,导致整个设置保存回滚(用户在非 TSF 插入方式下 +/// 勾「不在键盘列表显示」且未装 TSF IME 时,之后任何设置都存不进)。 fn keyboard_list_pref_short_circuit( install_state: WindowsImeInstallState, _desired: bool, @@ -229,9 +221,8 @@ fn keyboard_list_pref_short_circuit( } } -/// 将「SendInput + 键盘列表可见性」偏好同步到当前用户的 TSF 语言配置文件。 -pub fn apply_windows_openless_keyboard_list_pref(prefs: &UserPreferences) -> Result<(), String> { - let desired = desired_openless_language_profile_enabled(prefs); +/// 将 Core 已决定的键盘列表目标状态同步到当前用户的 TSF 语言配置文件。 +pub fn apply_windows_openless_keyboard_list(desired: bool) -> Result<(), String> { #[cfg(target_os = "windows")] { let status = get_windows_ime_status(); @@ -863,30 +854,6 @@ mod tests { assert!(!is_openless_profile_snapshot(&keyboard)); } - #[test] - fn desired_openless_language_profile_enabled_follows_sendinput_and_visibility_pref() { - let tsf_only = UserPreferences { - windows_sendinput_insertion_only: false, - windows_show_openless_in_keyboard_list: false, - ..UserPreferences::default() - }; - assert!(desired_openless_language_profile_enabled(&tsf_only)); - - let sendinput_show = UserPreferences { - windows_sendinput_insertion_only: true, - windows_show_openless_in_keyboard_list: true, - ..UserPreferences::default() - }; - assert!(desired_openless_language_profile_enabled(&sendinput_show)); - - let sendinput_hide = UserPreferences { - windows_sendinput_insertion_only: true, - windows_show_openless_in_keyboard_list: false, - ..UserPreferences::default() - }; - assert!(!desired_openless_language_profile_enabled(&sendinput_hide)); - } - // ── Fix: TSF IME 未安装时「键盘列表可见性」偏好不应报错 ── // 之前 `desired == false`(不显示)+ 未安装错误地返回 Err,经 settings.rs 的 // apply_keyboard_list(&prefs)? 传播导致整个设置保存事务回滚。这是回归护栏。 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 42192c2ed..b41cfd459 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -918,8 +918,8 @@ export const en: typeof zhCN = { windowsSendInputNewlineModeShiftEnter: 'Shift+Enter (chat input boxes)', windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', windowsShowOpenlessInKeyboardListLabel: 'Show OpenLess in keyboard list', - windowsShowOpenlessInKeyboardListDesc: 'When off, Win+Space will not cycle to OpenLess. SendInput insertion is unaffected. Turn this back on to restore the entry.', - windowsShowOpenlessInKeyboardListError: 'Could not update the keyboard list: OpenLess TSF IME is not installed or the system rejected the change.', + windowsShowOpenlessInKeyboardListDesc: 'When off, Win+Space will not cycle to OpenLess. SendInput and clipboard-paste insertion are unaffected. Turn this back on to restore the entry.', + windowsShowOpenlessInKeyboardListError: 'Could not update the keyboard list: the system rejected changing the OpenLess language profile.', historyGroupTitle: 'History & context', historyRetentionLabel: 'History retention (days)', historyRetentionDesc: 'Entries older than this are pruned on new writes; 0 = no time-based pruning.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 2b5463ff6..6d11101fa 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -920,8 +920,8 @@ export const ja: typeof zhCN = { windowsSendInputNewlineModeShiftEnter: 'Shift+Enter(チャット入力)', windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', windowsShowOpenlessInKeyboardListLabel: 'キーボード一覧に OpenLess を表示', - windowsShowOpenlessInKeyboardListDesc: 'オフにすると Win+Space で OpenLess に切り替わりません。SendInput 挿入には影響しません。オンに戻すと一覧に再表示されます。', - windowsShowOpenlessInKeyboardListError: 'キーボード一覧を更新できません:OpenLess TSF IME が未インストールか、システムが変更を拒否しました。', + windowsShowOpenlessInKeyboardListDesc: 'オフにすると Win+Space で OpenLess に切り替わりません。SendInput とクリップボード貼り付け挿入には影響しません。オンに戻すと一覧に再表示されます。', + windowsShowOpenlessInKeyboardListError: 'キーボード一覧を更新できません:システムが OpenLess 言語プロファイルの変更を拒否しました。', historyGroupTitle: '履歴とコンテキスト', historyRetentionLabel: '履歴保持期間(日)', historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f3ad19f3b..f35c8425f 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -920,8 +920,8 @@ export const ko: typeof zhCN = { windowsSendInputNewlineModeShiftEnter: 'Shift+Enter(채팅 입력창)', windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', windowsShowOpenlessInKeyboardListLabel: '키보드 목록에 OpenLess 표시', - windowsShowOpenlessInKeyboardListDesc: '끄면 Win+Space로 OpenLess에 전환되지 않습니다. SendInput 삽입에는 영향 없습니다. 다시 켜면 목록에 복원됩니다.', - windowsShowOpenlessInKeyboardListError: '키보드 목록을 업데이트할 수 없습니다: OpenLess TSF IME가 설치되지 않았거나 시스템이 변경을 거부했습니다.', + windowsShowOpenlessInKeyboardListDesc: '끄면 Win+Space로 OpenLess에 전환되지 않습니다. SendInput 및 클립보드 붙여넣기 삽입에는 영향 없습니다. 다시 켜면 목록에 복원됩니다.', + windowsShowOpenlessInKeyboardListError: '키보드 목록을 업데이트할 수 없습니다: 시스템이 OpenLess 언어 프로필 변경을 거부했습니다.', historyGroupTitle: '기록 및 컨텍스트', historyRetentionLabel: '기록 보관 기간(일)', historyRetentionDesc: '보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 816224d3e..88a69f24f 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -916,8 +916,8 @@ export const zhCN = { windowsSendInputNewlineModeShiftEnter: 'Shift+Enter(聊天输入框)', windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', windowsShowOpenlessInKeyboardListLabel: '在键盘列表中显示 OpenLess', - windowsShowOpenlessInKeyboardListDesc: '关闭后 Win+Space 切换输入法时不会出现 OpenLess;SendInput 插入不受影响。重新开启本项可恢复显示。', - windowsShowOpenlessInKeyboardListError: '无法更新键盘列表:OpenLess TSF 输入法未安装或系统拒绝操作。', + windowsShowOpenlessInKeyboardListDesc: '关闭后 Win+Space 切换输入法时不会出现 OpenLess;SendInput 与剪贴板粘贴插入不受影响。重新开启本项可恢复显示。', + windowsShowOpenlessInKeyboardListError: '无法更新键盘列表:系统拒绝更改 OpenLess 语言配置文件。', historyGroupTitle: '历史与上下文', historyRetentionLabel: '历史保留天数', historyRetentionDesc: '超过保留天数的历史在写入新条目时被清理;0 = 不按时间清理。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 534421198..905fd5d24 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -918,8 +918,8 @@ export const zhTW: typeof zhCN = { windowsSendInputNewlineModeShiftEnter: 'Shift+Enter(聊天輸入框)', windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', windowsShowOpenlessInKeyboardListLabel: '在鍵盤列表中顯示 OpenLess', - windowsShowOpenlessInKeyboardListDesc: '關閉後 Win+Space 切換輸入法時不會出現 OpenLess;SendInput 插入不受影響。重新開啟本項可恢復顯示。', - windowsShowOpenlessInKeyboardListError: '無法更新鍵盤列表:OpenLess TSF 輸入法未安裝或系統拒絕操作。', + windowsShowOpenlessInKeyboardListDesc: '關閉後 Win+Space 切換輸入法時不會出現 OpenLess;SendInput 與剪貼簿貼上插入不受影響。重新開啟本項可恢復顯示。', + windowsShowOpenlessInKeyboardListError: '無法更新鍵盤列表:系統拒絕更改 OpenLess 語言設定檔。', historyGroupTitle: '歷史與上下文', historyRetentionLabel: '歷史保留天數', historyRetentionDesc: '超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index e0d5ea45d..97f6c2997 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -390,7 +390,7 @@ export interface UserPreferences { macosNewlineMode: MacosNewlineMode; /** 旧版兼容:`true` 等价于 `windowsInsertionMode === 'sendInput'`。 */ windowsSendInputInsertionOnly: boolean; - /** Windows:SendInput 模式下是否在系统键盘列表(Win+Space)中显示 OpenLess。 */ + /** Windows:非 TSF 插入方式下是否在系统键盘列表(Win+Space)中显示 OpenLess。 */ windowsShowOpenlessInKeyboardList: boolean; /** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */ workingLanguages: string[]; diff --git a/openless-all/app/src/lib/windowsKeyboardListToggle.test.ts b/openless-all/app/src/lib/windowsKeyboardListToggle.test.ts new file mode 100644 index 000000000..2dce12e83 --- /dev/null +++ b/openless-all/app/src/lib/windowsKeyboardListToggle.test.ts @@ -0,0 +1,76 @@ +import { + effectiveWindowsInsertionMode, + showWindowsOpenlessKeyboardListToggle, + showWindowsSendInputNewlineMode, +} from './windowsKeyboardListToggle'; + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +assert( + effectiveWindowsInsertionMode(undefined, true) === 'sendInput', + 'legacy true should resolve to sendInput', +); +assert( + effectiveWindowsInsertionMode(undefined, false) === 'tsf', + 'legacy false should resolve to tsf', +); +assert( + effectiveWindowsInsertionMode(undefined, undefined) === 'tsf', + 'missing mode and legacy should resolve to tsf', +); + +assert( + !showWindowsOpenlessKeyboardListToggle('tsf'), + 'tsf should hide keyboard list toggle', +); +assert( + showWindowsOpenlessKeyboardListToggle('sendInput'), + 'sendInput should show keyboard list toggle', +); +assert( + showWindowsOpenlessKeyboardListToggle('paste'), + 'paste should show keyboard list toggle', +); +assert( + showWindowsOpenlessKeyboardListToggle(undefined, true), + 'legacy sendInput-only should show keyboard list toggle', +); + +assert( + !showWindowsOpenlessKeyboardListToggle('tsf', true), + 'explicit tsf must win over legacy true for keyboard list toggle', +); +assert( + !showWindowsSendInputNewlineMode('tsf', true), + 'explicit tsf must win over legacy true for newline mode', +); + +assert( + showWindowsOpenlessKeyboardListToggle('paste', true), + 'explicit paste should show keyboard list toggle even with legacy true', +); +assert( + !showWindowsSendInputNewlineMode('paste', true), + 'explicit paste should hide newline mode even with legacy true', +); + +assert( + showWindowsSendInputNewlineMode('sendInput'), + 'sendInput should show newline mode', +); +assert( + !showWindowsSendInputNewlineMode('paste'), + 'paste should hide newline mode', +); +assert( + !showWindowsSendInputNewlineMode('tsf'), + 'tsf should hide newline mode', +); +assert( + showWindowsSendInputNewlineMode(undefined, true), + 'legacy sendInput-only should show newline mode', +); + +console.log('windowsKeyboardListToggle.test.ts: ok'); diff --git a/openless-all/app/src/lib/windowsKeyboardListToggle.ts b/openless-all/app/src/lib/windowsKeyboardListToggle.ts new file mode 100644 index 000000000..9c95a6d2f --- /dev/null +++ b/openless-all/app/src/lib/windowsKeyboardListToggle.ts @@ -0,0 +1,27 @@ +// Windows 插入相关设置行的显示谓词。 +// 旧布尔 windowsSendInputInsertionOnly 只在新字段 windowsInsertionMode 缺失时兜底。 + +import type { WindowsInsertionMode } from './types'; + +export function effectiveWindowsInsertionMode( + mode: WindowsInsertionMode | undefined, + sendInputOnly?: boolean, +): WindowsInsertionMode { + return mode ?? (sendInputOnly ? 'sendInput' : 'tsf'); +} + +/** 非 TSF(SendInput / Paste)时显示「在键盘列表中显示 OpenLess」。 */ +export function showWindowsOpenlessKeyboardListToggle( + mode: WindowsInsertionMode | undefined, + sendInputOnly?: boolean, +): boolean { + return effectiveWindowsInsertionMode(mode, sendInputOnly) !== 'tsf'; +} + +/** 仅 SendInput 时显示换行方式选项;Paste 下隐藏。 */ +export function showWindowsSendInputNewlineMode( + mode: WindowsInsertionMode | undefined, + sendInputOnly?: boolean, +): boolean { + return effectiveWindowsInsertionMode(mode, sendInputOnly) === 'sendInput'; +} diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index 2cf6b811b..8fc63cc78 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -9,6 +9,10 @@ import { playRecordStartCue } from '../../lib/audioCue'; import { defaultDictationHotkey } from '../../lib/hotkey'; import { emitSaved } from '../../lib/savedEvent'; import { isHotkeyModeMigrationNoticeActive } from '../../lib/hotkeyMigration'; +import { + showWindowsOpenlessKeyboardListToggle, + showWindowsSendInputNewlineMode, +} from '../../lib/windowsKeyboardListToggle'; import { isTauri, listMicrophoneDevices, @@ -504,7 +508,10 @@ export function RecordingInputSection() { )} {capability.adapter === 'windowsLowLevel' - && (prefs.windowsInsertionMode === 'sendInput' || prefs.windowsSendInputInsertionOnly) && ( + && showWindowsSendInputNewlineMode( + prefs.windowsInsertionMode, + prefs.windowsSendInputInsertionOnly, + ) && ( )} {capability.adapter === 'windowsLowLevel' - && (prefs.windowsInsertionMode === 'sendInput' || prefs.windowsSendInputInsertionOnly) && ( + && showWindowsOpenlessKeyboardListToggle( + prefs.windowsInsertionMode, + prefs.windowsSendInputInsertionOnly, + ) && (