fix(windows): 粘贴模式下也可隐藏键盘列表中的 OpenLess (#1015) (#1017)

* fix(windows): allow hiding OpenLess from keyboard list in paste mode

Extend the keyboard-list visibility toggle and desired-profile logic from
SendInput-only to all non-TSF insertion modes so clipboard paste can hide
OpenLess without blocking TSF when switched back.

Refs: #1015
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(macos): 把 mlx.metallib 放到 Resources,避免 ad-hoc codesign 失败

Contents/MacOS 里的非 Mach-O 会被当成 nested code;克隆仓没有 Developer ID 时 identity '-' 会直接 bundle 失败。metallib 改为 Contents/Resources,worker 启动时调用 MLX set_metallib_path。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): dispatch 时按版本号跳过 Windows MSI,避免 Beta.N 被 WiX 拒绝

workflow_dispatch 的 ref 不是 tag,OPENLESS_RELEASE_CHANNEL 会落成 stable,但 app 版本仍是 1.3.18-Beta.7。WiX 要求 prerelease 只能是数字,MSI 直接失败。版本带 Beta/Alpha/RC 时与 beta 渠道一样跳过 MSI。

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci(linux): test core without egui host

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-09-06 23:37:56 +08:00
1 parent fc38edd1a3
commit fd632c2fc7
20 files changed
+311 -128

No files matched your search

+4 -55
View File
@@ -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
@@ -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);
}
}
@@ -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 头部,
+10 -1
View File
@@ -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
@@ -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")
+16
View File
@@ -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 符号。
@@ -0,0 +1,20 @@
// MLX worker 启动时把 bundled metallib 路径交给 libmlx。
// mlx-c 没暴露 set_metallib_path,这里直接调 C++ API。
#include <string>
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;
}
@@ -1031,7 +1031,44 @@ pub(crate) fn run_if_requested() {
}
}
fn bundled_mlx_metallib_from_exe(exe: &Path) -> Option<PathBuf> {
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();
@@ -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(
+9 -6
View File
@@ -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 按需显示。
@@ -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<windows::core::GUID> {
@@ -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<bool> {
///
/// 返回 `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<bool> {
/// - `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)? 传播导致整个设置保存事务回滚。这是回归护栏。
+2 -2
View File
@@ -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.',
+2 -2
View File
@@ -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 = 時間で削除しない。',
+2 -2
View File
@@ -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 = 시간 기반 정리 비활성화.',
+2 -2
View File
@@ -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 = 不按时间清理。',
+2 -2
View File
@@ -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 = 不按時間清理。',
+1 -1
View File
@@ -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[];
@@ -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');
@@ -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';
}
@@ -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() {
</SettingRow>
)}
{capability.adapter === 'windowsLowLevel'
&& (prefs.windowsInsertionMode === 'sendInput' || prefs.windowsSendInputInsertionOnly) && (
&& showWindowsSendInputNewlineMode(
prefs.windowsInsertionMode,
prefs.windowsSendInputInsertionOnly,
) && (
<SettingRow
label={t('settings.recording.windowsSendInputNewlineModeLabel')}
desc={t('settings.recording.windowsSendInputNewlineModeDesc')}
@@ -542,7 +549,10 @@ export function RecordingInputSection() {
</SettingRow>
)}
{capability.adapter === 'windowsLowLevel'
&& (prefs.windowsInsertionMode === 'sendInput' || prefs.windowsSendInputInsertionOnly) && (
&& showWindowsOpenlessKeyboardListToggle(
prefs.windowsInsertionMode,
prefs.windowsSendInputInsertionOnly,
) && (
<SettingRow
label={t('settings.recording.windowsShowOpenlessInKeyboardListLabel')}
desc={t('settings.recording.windowsShowOpenlessInKeyboardListDesc')}