Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 4 additions & 55 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
63 changes: 58 additions & 5 deletions openless-all/app/crates/openless-core/src/settings.rs
Original file line number Diff line number Diff line change
@@ -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")]
Expand Down Expand Up @@ -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,
}
}
}
Expand Down Expand Up @@ -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);
}
}
5 changes: 3 additions & 2 deletions openless-all/app/crates/openless-core/src/shared_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 头部,
Expand Down
11 changes: 10 additions & 1 deletion openless-all/app/scripts/build-mac.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 不一致"
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions openless-all/app/scripts/macos-mlx-bundle-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 16 additions & 0 deletions openless-all/app/src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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 符号。
Expand Down
20 changes: 20 additions & 0 deletions openless-all/app/src-tauri/src/asr/local/mlx_set_metallib_path.cpp
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions openless-all/app/src-tauri/src/asr/local/mlx_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
11 changes: 4 additions & 7 deletions openless-all/app/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 9 additions & 6 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 按需显示。
Expand Down
Loading
Loading