* 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:
20 files changed
+311
-128
No files matched your search
@@ -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 头部,
|
||||
|
||||
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -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)? 传播导致整个设置保存事务回滚。这是回归护栏。
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 = 時間で削除しない。',
|
||||
|
||||
@@ -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 = 시간 기반 정리 비활성화.',
|
||||
|
||||
@@ -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 = 不按时间清理。',
|
||||
|
||||
@@ -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 = 不按時間清理。',
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in new issue
Block a user