diff --git a/.github/actions/cache-macos-mlx/action.yml b/.github/actions/cache-macos-mlx/action.yml new file mode 100644 index 000000000..3fcc8fc52 --- /dev/null +++ b/.github/actions/cache-macos-mlx/action.yml @@ -0,0 +1,45 @@ +name: Cache macOS MLX native build +description: Preserve vendored MLX CMake output before rust-cache removes in-tree path dependencies. +inputs: + profile: + description: Cargo output profile directory (debug or release) + required: true +runs: + using: composite + steps: + - name: Fingerprint MLX native toolchain + id: native + shell: bash + working-directory: openless-all/app + env: + OPENLESS_MLX_PROFILE: ${{ inputs.profile }} + run: | + set -euo pipefail + case "$OPENLESS_MLX_PROFILE" in + debug|release) ;; + *) echo "::error::Unsupported MLX cache profile"; exit 1 ;; + esac + fingerprint=$({ + rustc -vV + xcrun clang --version + # Downloadable Metal toolchains can change independently of Clang. + # Their InstalledDir may contain a per-boot mount identifier. + xcrun --sdk macosx metal --version | sed '/^InstalledDir:/d' + xcrun --sdk macosx --show-sdk-version + cmake --version + git -C src-tauri/vendor/qwen3-asr-rs rev-parse HEAD + git -C src-tauri/vendor/qwen3-asr-rs/mlx-c rev-parse HEAD + for name in CARGO_PROFILE_DEV_DEBUG CARGO_PROFILE_TEST_DEBUG CARGO_PROFILE_RELEASE_CODEGEN_UNITS CARGO_PROFILE_RELEASE_STRIP RUSTFLAGS CARGO_ENCODED_RUSTFLAGS CC CXX CFLAGS CXXFLAGS SDKROOT DEVELOPER_DIR MACOSX_DEPLOYMENT_TARGET CMAKE_GENERATOR CMAKE_TOOLCHAIN_FILE; do + printf '%s=%s\n' "$name" "${!name-}" + done + } | shasum -a 256 | awk '{print $1}') + echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT" + + # Call this action AFTER rust-cache: post actions run in reverse order, + # so MLX is saved before rust-cache prunes src-tauri/vendor path packages. + # Cache only CMake output, not Cargo fingerprints: Cargo still reruns the + # build script and CMake validates its native inputs on every clean checkout. + - uses: actions/cache@v4 + with: + path: openless-all/app/src-tauri/target/${{ inputs.profile }}/build/qwen3-asr-rs-*/out + key: macos-mlx-v1-${{ runner.arch }}-${{ inputs.profile }}-${{ steps.native.outputs.fingerprint }}-${{ hashFiles('openless-all/app/src-tauri/Cargo.toml', 'openless-all/app/src-tauri/Cargo.lock', 'openless-all/app/src-tauri/tauri*.json', '.cargo/config.toml', '.github/actions/cache-macos-mlx/action.yml') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6515e726..2c2f191b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,12 @@ on: pull_request: branches: [main, beta] workflow_dispatch: + inputs: + platform: + description: Platforms to check (macos runs only macOS gates) + type: choice + options: [all, macos] + default: all # 同一 PR 快速重复推送时取消旧运行;workflow_dispatch 用 run_id 隔离。 concurrency: @@ -20,6 +26,7 @@ concurrency: jobs: android-check: + if: github.event_name != 'workflow_dispatch' || inputs.platform != 'macos' name: Android cargo check runs-on: ubuntu-latest defaults: @@ -166,6 +173,7 @@ jobs: --no-daemon linux-core-contract: + if: github.event_name != 'workflow_dispatch' || inputs.platform != 'macos' name: Linux core tests runs-on: ubuntu-22.04 defaults: @@ -194,13 +202,7 @@ jobs: # 一个平台挂掉不阻塞其他平台拿到验证结果。 fail-fast: false matrix: - include: - - os: macos-latest - label: macOS - preflight: false - - os: windows-latest - label: Windows - preflight: true + include: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '[{"os":"macos-latest","label":"macOS","preflight":false}]' || '[{"os":"macos-latest","label":"macOS","preflight":false},{"os":"windows-latest","label":"Windows","preflight":true}]') }} runs-on: ${{ matrix.os }} env: # 新增 shared Core 后,macOS 首次编译同时构建 MLX C++ 依赖和完整 @@ -226,15 +228,36 @@ jobs: cache: npm cache-dependency-path: openless-all/app/package-lock.json - # 现有测试继续用 stable;额外安装声明的 MSRV,供下方兼容性门禁显式调用。 + # macOS MSRV 在独立 job 并行检查;Windows 保留原有检查顺序。 - uses: dtolnay/rust-toolchain@1.88.0 + if: runner.os == 'Windows' - uses: dtolnay/rust-toolchain@stable + - name: Configure macOS check profile + if: runner.os == 'macOS' + run: echo "CARGO_PROFILE_DEV_DEBUG=0" >> "$GITHUB_ENV" + - uses: swatinem/rust-cache@v2 + if: runner.os == 'Windows' with: workspaces: 'openless-all/app/src-tauri -> target' + - name: Cache macOS stable dependencies + if: runner.os == 'macOS' + uses: swatinem/rust-cache@v2 + with: + key: macos-stable-v1 + workspaces: | + openless-all/app -> target + openless-all/app/src-tauri -> target + + - name: Cache macOS MLX native build + if: runner.os == 'macOS' && runner.arch == 'ARM64' + uses: ./.github/actions/cache-macos-mlx + with: + profile: debug + - name: Prepare Windows Sherpa static libraries if: runner.os == 'Windows' shell: pwsh @@ -284,11 +307,14 @@ jobs: run: cargo test --locked -p openless-core hardening_actually_narrows_the_writable_roots -- --ignored --nocapture --test-threads=1 - name: Check Tauri backend (cargo check) + if: runner.os == 'Windows' run: cargo check --locked --manifest-path src-tauri/Cargo.toml + # test 编译并运行 lib/bin,覆盖原 cargo check 的生产 binary 路径。 + # 避免先 metadata-only check、再为同一依赖图做一次 codegen。 - name: Run Rust backend unit tests if: runner.os != 'Windows' - run: cargo test --locked --manifest-path src-tauri/Cargo.toml --lib + run: cargo test --locked --manifest-path src-tauri/Cargo.toml --lib --bins --timings - name: Compile Rust backend unit tests (Windows) # Windows runner 能链接 lib test binary,但干净镜像缺少可选 native runtime @@ -304,9 +330,11 @@ jobs: run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml - name: Check Tauri backend with Rust 1.88 MSRV + if: runner.os == 'Windows' run: cargo +1.88.0 check --locked --manifest-path src-tauri/Cargo.toml - name: Compile backend tests with Rust 1.88 MSRV + if: runner.os == 'Windows' run: cargo +1.88.0 test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --no-run - name: Verify version sync across all 5 files @@ -338,3 +366,42 @@ jobs: exit 1 fi echo "[ok] 全部 5 处版本号一致:$PKG" + + macos-msrv: + name: macOS Rust 1.88 MSRV + runs-on: macos-latest + env: + CARGO_BUILD_JOBS: 2 + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + CMAKE_BUILD_PARALLEL_LEVEL: 2 + defaults: + run: + working-directory: openless-all/app + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: openless-all/app/package-lock.json + - uses: dtolnay/rust-toolchain@1.88.0 + - uses: swatinem/rust-cache@v2 + with: + key: macos-msrv-v1 + workspaces: | + openless-all/app/src-tauri -> target + openless-all/app/src-tauri/backend-tests -> target + - name: Cache macOS MLX native build + if: runner.arch == 'ARM64' + uses: ./.github/actions/cache-macos-mlx + with: + profile: debug + - run: npm ci + - run: npm run build + - name: Check Tauri backend with Rust 1.88 MSRV + run: cargo +1.88.0 check --locked --manifest-path src-tauri/Cargo.toml --timings + - name: Compile backend tests with Rust 1.88 MSRV + run: cargo +1.88.0 test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --no-run --timings diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 4f2f3a627..68b66be60 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -21,6 +21,12 @@ on: tags: - 'v*-tauri' workflow_dispatch: + inputs: + platform: + description: Desktop platforms to build (manual builds do not publish a release) + type: choice + options: [all, macos] + default: all # 同一 tag 重复推送只跑最新一次;workflow_dispatch 用 run_id 隔离避免互相取消。 concurrency: @@ -34,19 +40,7 @@ jobs: strategy: fail-fast: false matrix: - include: - - platform: macos-latest - rust-target: aarch64-apple-darwin - updater-target: darwin - updater-arch: aarch64 - - platform: macos-15-intel - rust-target: x86_64-apple-darwin - updater-target: darwin - updater-arch: x86_64 - - platform: windows-latest - rust-target: x86_64-pc-windows-msvc - updater-target: windows - updater-arch: x86_64 + include: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"}]' || '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"},{"platform":"windows-latest","rust-target":"x86_64-pc-windows-msvc","updater-target":"windows","updater-arch":"x86_64"}]') }} runs-on: ${{ matrix.platform }} env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -79,11 +73,31 @@ jobs: with: targets: ${{ matrix.rust-target }} + # 在恢复缓存前设置实际编译环境,让 cache key 包含 macOS profile。 + - name: Configure macOS build environment + if: startsWith(matrix.platform, 'macos') + working-directory: openless-all/app + run: bash scripts/macos-build-env.sh + - name: Cache Cargo + if: matrix.platform == 'windows-latest' uses: swatinem/rust-cache@v2 with: workspaces: 'openless-all/app/src-tauri -> target' + - name: Cache macOS release dependencies + if: startsWith(matrix.platform, 'macos') + uses: swatinem/rust-cache@v2 + with: + key: macos-release-v1 + workspaces: 'openless-all/app/src-tauri -> target' + + - name: Cache macOS MLX native build + if: matrix.updater-arch == 'aarch64' + uses: ./.github/actions/cache-macos-mlx + with: + profile: release + - name: Prepare Windows Sherpa static libraries if: matrix.platform == 'windows-latest' working-directory: 'openless-all/app' @@ -192,6 +206,14 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: bash scripts/build-mac.sh + - name: Upload macOS Cargo timings + if: always() && startsWith(matrix.platform, 'macos') + uses: actions/upload-artifact@v4 + with: + name: macos-cargo-timings-${{ matrix.updater-arch }} + path: openless-all/app/src-tauri/target/cargo-timings/*.html + if-no-files-found: ignore + # ── Windows:先 build OpenLessIme.dll(x64+x86),再跑 tauri bundle。 # openless-ime.wxs 用 $(env.OPENLESS_IME_DLL_X64) / _X86 拿绝对路径, # 跨 candle/light cwd 都能 resolve(Tauri wix bundler cwd 不固定)。 @@ -427,6 +449,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: openless-macos-${{ matrix.updater-arch }} + compression-level: 0 path: | openless-all/app/src-tauri/target/release/bundle/dmg/*.dmg if-no-files-found: error @@ -436,6 +459,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: openless-macos-${{ matrix.updater-arch }}-updater + compression-level: 0 path: | openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz.sig diff --git a/docs/index.md b/docs/index.md index 073a576c9..d59ff8e7a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,6 +23,7 @@ ## 平台与运营 +- [macOS CI 与打包耗时](macos-build-performance.md):基线日志、Rust 编译优化、缓存边界与仅 macOS 验证入口。 - [Android APK / 悬浮窗计划](android-mobile-apk-overlay-plan.md)(实施中) - [火山引擎 ASR 配置](volcengine-setup.md) - [讯飞(iflytek)ASR 配置](xfyun-asr.md) diff --git a/docs/macos-build-performance.md b/docs/macos-build-performance.md new file mode 100644 index 000000000..e9578cbf9 --- /dev/null +++ b/docs/macos-build-performance.md @@ -0,0 +1,55 @@ +# macOS CI 与打包耗时 + +状态:实现说明;更新:2026-09-25。范围仅包含 macOS 检查和桌面打包。Windows、Android、Linux 的编译参数与发布行为由原有工作流维护。 + +## 调研依据 + +对照 `beta` 的 `d9113e0b`、[CI 35984434219](https://github.com/Open-Less/openless/actions/runs/35984434219) 与 [桌面构建 35989822601](https://github.com/Open-Less/openless/actions/runs/35989822601) 的完整日志: + +| 基线步骤 | 时间 | 日志证据 | +| --- | --- | --- | +| macOS 检查 job | 24 分 59 秒 | stable 检查、测试、MSRV 串行执行 | +| stable `cargo check` | 7 分 54 秒 | 含 MLX,本轮 dev profile 带 debug info | +| stable Tauri 库测试编译 | 7 分 27 秒 | 再次编译 qwen3、Core、Host,test profile 不带 debug info | +| Rust 1.88 Host 检查 | 5 分 03 秒 | 第三次编译 qwen3 与 Host | +| Rust 1.88 独立 backend-tests 编译 | 58.51 秒 | 独立 target 未配置缓存 | +| Apple Silicon release Rust 编译 | 14 分 16 秒 | 依赖缓存命中约 731 MB,仍重新编译 qwen3、Core、Host | +| Intel release Rust 编译 | 13 分 57 秒 | 依赖缓存命中约 700 MB,仍重新编译 Core、Host | + +ARM/Intel 的整个打包步骤分别约 15 分 17 秒、15 分 08 秒。主要等待发生在 Rust,而不是 npm 安装、DMG 或 artifact 上传。时间是该次运行的观测值,不是不同 runner、缓存和提交之间的性能保证。 + +源码中的相关因素: + +- `ci.yml` 的 macOS job 先 metadata-only check,再生成测试机器码;dev/test 的 debug 配置也不同。MSRV 与 stable 共用 job 和缓存,Core workspace、独立 backend-tests 的 target 没有全部纳入缓存。 +- Tauri release profile 使用 `opt-level=3`、thin LTO 和 `codegen-units=1`。最后一个设置限制单个大 crate 的 LLVM 并行能力;命中第三方依赖缓存仍无法避免 Core/Host 的代码生成成本。 +- `build-mac.sh` 曾将所有 `qwen3-asr-rs-*` 目录当成重复输出。Cargo 实际分别保存 build-script 可执行文件和 `OUT_DIR`;只留一个目录会破坏下一轮缓存。 +- `rust-cache` 默认只保留依赖产物,不应把命中缓存等同于整个应用无须重编译。参见 [rust-cache 缓存行为](https://github.com/Swatinem/rust-cache#cache-details)。没有添加 sccache:该工具不能缓存调用系统 linker 的 bin/cdylib/proc-macro,参见 [官方限制](https://github.com/mozilla/sccache/blob/main/docs/Rust.md)。 + +## 当前流程 + +`ci.yml` 的 macOS stable job 用 `cargo test --lib --bins` 编译并运行库及二进制目标,覆盖库的生产构建与测试构建。MSRV 单独并行执行,继续检查完整 Tauri Host 并编译独立 backend-tests。两个 job 均保留 `--locked`、MLX 子模块与两路编译并发限制;stable 保留全部前端/合同测试和 Codex sandbox 实测。 + +macOS 检查统一关闭 dev/test debug info,分别缓存 Core、Host、backend-tests 的实际 target 目录。stable、MSRV、release 缓存分开,避免不同工具链和 profile 的产物互相挤占。 + +MLX 的 CMake 输出另用 [cache-macos-mlx](../.github/actions/cache-macos-mlx/action.yml) 保存。实际使用的 `rust-cache` [源码](https://github.com/Swatinem/rust-cache/blob/6323deb102c322ba6fcbdcafc7e3dddab59af2b6/src/workspace.ts) 排除 workspace 目录内的 path 依赖,导致 `src-tauri/vendor/qwen3-asr-rs` 的原生输出在 post 阶段被清理;首轮验证中,命中 Cargo 缓存仍重建 MLX 约 7 分 17 秒。独立缓存步骤放在 `rust-cache` 后,利用 post 的逆序执行先保存原生输出。缓存按架构、profile、Rust/Clang/Metal/CMake/SDK、子模块提交、编译环境和 manifest/lock/config 隔离,无跨 key 的模糊回退。Metal 版本输出去掉每次启动可能变化的挂载目录,保留实际版本与目标信息。只保存 CMake `out`,不保存 Cargo freshness 指纹,下一轮仍执行 build script 与 CMake 输入校验。 + +`scripts/macos-build-env.sh` 为 macOS 打包默认设置 `CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16`,保留 `opt-level=3`、thin LTO 和 unwind;环境变量可以显式覆盖为其他值。参数取舍依据 [Cargo profiles](https://doc.rust-lang.org/cargo/reference/profiles.html#codegen-units):更多 codegen units 允许更快的并行代码生成,可能影响最终体积或优化效果。共享 `Cargo.toml` 不修改。CI 在恢复缓存前加载同一环境,本地 `build-mac.sh` 也加载它。 + +MLX 清理只比较含非空 metallib 的输出目录,保留 build-script 可执行文件。构建前删除本次架构的旧 app、DMG 和 updater,保留 Cargo 编译缓存;Tauri 非零退出直接失败。因此热构建复用旧时间戳二进制时仍能正确打包,失败时也不会接受旧安装包。现有用途声明、签名、公证和 MLX 包内容校验继续执行。 + +## 仅 macOS 的验证入口 + +从待验证分支手动触发已有工作流: + +```sh +gh workflow run ci.yml --repo Open-Less/openless --ref -f platform=macos +gh workflow run release-tauri.yml --repo Open-Less/openless --ref -f platform=macos +``` + +前者只运行 macOS stable/MSRV,后者并行生成 Apple Silicon 与 Intel 的桌面包。使用分支 ref,不创建 tag 或 GitHub Release。省略 input 的既有手动运行以及 tag 发布仍使用原有全部平台矩阵。 + +桌面工作流上传两个架构的 Cargo HTML timings;失败时若已生成计时文件也会上传。DMG/updater 本身已压缩,artifact 使用 `compression-level: 0`。 + +本地在 `openless-all/app` 运行 `npm test`、`cargo test --locked --manifest-path src-tauri/Cargo.toml --lib --bins` 与 `INSTALL=0 bash scripts/build-mac.sh`。`macos-build-cache.test.mjs` 实际执行隔离的 shell 构建流程,验证缓存目录保留、重复热打包、旧产物清理以及失败传播。 + +性能验收应记录一次新缓存运行及相同提交的再次运行,分别报告 Rust 编译、完整 job 和产物大小。不同机器的本地构建时间不与 GitHub runner 直接比较;构建通过不等于真实设备 ASR 性能已经验证。 diff --git a/openless-all/app/scripts/build-mac.sh b/openless-all/app/scripts/build-mac.sh index cb2d0de79..05306dcc5 100755 --- a/openless-all/app/scripts/build-mac.sh +++ b/openless-all/app/scripts/build-mac.sh @@ -27,25 +27,28 @@ fi echo "▶ 检查 Apple Silicon MLX 构建依赖" npm run check:macos-metal-toolchain -# Homebrew rustc 在 macOS 上对 `strip=symbols` 生成的 proc-macro dylib -# 可能报 "mis-aligned LINKEDIT string pool"。仅官方 macOS 发布脚本降级 -# 为 debuginfo;Cargo.toml 的全局 profile 仍让 Linux/Windows/Android 使用 symbols。 -export CARGO_PROFILE_RELEASE_STRIP=debuginfo -export RUSTC_WRAPPER="$PWD/scripts/rustc-macos-proc-macro-wrapper.sh" +source scripts/macos-build-env.sh +echo "▶ Cargo release codegen units: ${CARGO_PROFILE_RELEASE_CODEGEN_UNITS} (macOS only)" echo "▶ Cargo release strip: ${CARGO_PROFILE_RELEASE_STRIP} (macOS only)" echo "▶ Rust proc-macro host wrapper: ${RUSTC_WRAPPER}" -# 只保留最新一份 qwen3-asr-rs 构建目录:本地混跑 cargo check/test/build 会按不同 -# feature 上下文生成多份,各自带一份 metallib,会让暂存脚本拒绝猜测。 -KEEP_QWEN_DIR="$(ls -dt src-tauri/target/release/build/qwen3-asr-rs-* 2>/dev/null | head -1 || true)" +# Cargo 为 build-script 可执行文件和 OUT_DIR 创建不同目录。只在多个 +# metallib 输出之间清理,保留所有 build-script 缓存,避免每次重新编译。 +KEEP_QWEN_DIR="" +for d in src-tauri/target/release/build/qwen3-asr-rs-*; do + [ -s "$d/out/lib/mlx.metallib" ] || continue + if [ -z "$KEEP_QWEN_DIR" ] || [ "$d/out/lib/mlx.metallib" -nt "$KEEP_QWEN_DIR/out/lib/mlx.metallib" ]; then + KEEP_QWEN_DIR="$d" + fi +done if [ -n "$KEEP_QWEN_DIR" ]; then for d in src-tauri/target/release/build/qwen3-asr-rs-*; do + [ -s "$d/out/lib/mlx.metallib" ] || continue [ "$d" = "$KEEP_QWEN_DIR" ] || rm -rf "$d" done fi echo "▶ tauri build" -BUILD_START_TS="$(date +%s)" TAURI_BUILD_ARGS=(build --ci) case "$(uname -m)" in arm64) @@ -63,16 +66,17 @@ esac if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ -n "${TAURI_SIGNING_PRIVATE_KEY_PATH:-}" ]; then TAURI_BUILD_ARGS+=(--config '{"bundle":{"createUpdaterArtifacts":true}}') fi -# bundle_dmg(AppleScript)在 Xcode beta 上可能退出非零;.app 与 DMG 是否真实 -# 产出交给下方的新鲜度/存在性校验判定,不在这一步盲 abort。 -npm run tauri -- "${TAURI_BUILD_ARGS[@]}" || echo "⚠ tauri build 退出码非零,继续校验产物" - APP_VERSION="$(node -p "require('./package.json').version")" DMG_PATH="$DMG_DIR/OpenLess_${APP_VERSION}_${MAC_BUNDLE_ARCH}.dmg" -# bundle 必须是本次构建产出的(与构建开始时间比;打包后原始二进制还会被签名 -# 触碰,不能拿它当基准)。 -if [ ! -d "$APP" ] || [ "$(stat -f %m "$APP/Contents/MacOS/openless")" -lt "$BUILD_START_TS" ]; then +# 清掉交付产物,保留 Cargo 缓存。热构建可复用旧时间戳的二进制,不能用 +# 编译文件的 mtime 判断 bundle 新鲜度;Tauri 失败时也不能接受上轮安装包。 +rm -rf "$APP" +rm -f "$DMG_PATH" "${APP}.tar.gz" "${APP}.tar.gz.sig" +TAURI_BUILD_ARGS+=(-- --locked --timings) +npm run tauri -- "${TAURI_BUILD_ARGS[@]}" + +if [ ! -f "$APP/Contents/MacOS/openless" ]; then echo "✗ $APP 缺失或不是本次构建的产物(打包未完成),中止" exit 1 fi diff --git a/openless-all/app/scripts/macos-build-cache.test.mjs b/openless-all/app/scripts/macos-build-cache.test.mjs new file mode 100644 index 000000000..05d9f9b4a --- /dev/null +++ b/openless-all/app/scripts/macos-build-cache.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +if (process.platform !== 'darwin') { + console.log('macOS build cache tests skipped on other platforms'); + process.exit(0); +} + +const scripts = dirname(fileURLToPath(import.meta.url)); +const root = mkdtempSync(join(tmpdir(), 'openless-macos-build-')); +const app = join(root, 'src-tauri/target/release/bundle/macos/OpenLess.app'); +const dmg = join(root, 'src-tauri/target/release/bundle/dmg/OpenLess_1.2.3_x64.dmg'); +const builds = join(root, 'src-tauri/target/release/build'); + +function put(path, content) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +function executable(name, content) { + const path = join(root, 'bin', name); + put(path, `#!/usr/bin/env bash\nset -euo pipefail\n${content}\n`); + chmodSync(path, 0o755); +} + +function run(mode = 'success') { + const env = { ...process.env }; + for (const name of Object.keys(env)) { + if (/^(APPLE_|TAURI_SIGNING_|CARGO_PROFILE_RELEASE_|GITHUB_ENV$)/.test(name)) delete env[name]; + } + return spawnSync('bash', ['scripts/build-mac.sh'], { + cwd: root, + encoding: 'utf8', + env: { ...env, INSTALL: '0', BUILD_FIXTURE_MODE: mode, PATH: `${root}/bin:${env.PATH}` }, + }); +} + +try { + mkdirSync(join(root, 'scripts'), { recursive: true }); + for (const name of [ + 'build-mac.sh', + 'macos-build-env.sh', + 'check-macos-speech-usage-description.sh', + ]) { + copyFileSync(join(scripts, name), join(root, 'scripts', name)); + } + put(join(root, 'package.json'), '{"version":"1.2.3"}'); + put( + join(root, 'fixture.plist'), + ` + +NSMicrophoneUsageDescriptionMicrophone +NSSpeechRecognitionUsageDescriptionSpeech +`, + ); + + executable('uname', 'echo x86_64'); + executable('codesign', 'echo com.apple.security.device.audio-input'); + executable('xattr', 'exit 1'); + executable( + 'npm', + ` +if [ "$*" = "run check:macos-metal-toolchain" ]; then exit 0; fi +[[ "$*" == *"-- --locked --timings"* ]] +[[ "$CARGO_PROFILE_RELEASE_CODEGEN_UNITS" == 16 ]] +[[ "$CARGO_PROFILE_RELEASE_STRIP" == debuginfo ]] +app=src-tauri/target/release/bundle/macos/OpenLess.app +dmg=src-tauri/target/release/bundle/dmg/OpenLess_1.2.3_x64.dmg +# Old bundles must be gone before compilation starts, while Cargo stays warm. +[[ ! -e "$app" && ! -e "$dmg" && ! -e "$app.tar.gz" && ! -e "$app.tar.gz.sig" ]] +[[ -f src-tauri/target/release/build/qwen3-asr-rs-helper/build-script-build ]] +[[ -f src-tauri/target/release/build/qwen3-asr-rs-new/out/lib/mlx.metallib ]] +[[ ! -e src-tauri/target/release/build/qwen3-asr-rs-old ]] +if [ "$BUILD_FIXTURE_MODE" = missing ]; then exit 0; fi +mkdir -p "$app/Contents/MacOS" "$(dirname "$dmg")" +cp fixture.plist "$app/Contents/Info.plist" +echo binary > "$app/Contents/MacOS/openless" +# Cargo may reuse a binary compiled before this packaging invocation. +touch -t 200001010000 "$app/Contents/MacOS/openless" +echo dmg > "$dmg" +if [ "$BUILD_FIXTURE_MODE" = failure ]; then exit 23; fi +`, + ); + + put(join(builds, 'qwen3-asr-rs-helper/build-script-build'), 'cached executable'); + put(join(builds, 'qwen3-asr-rs-new/out/lib/mlx.metallib'), 'new shader'); + put(join(builds, 'qwen3-asr-rs-old/out/lib/mlx.metallib'), 'old shader'); + utimesSync(join(builds, 'qwen3-asr-rs-old/out/lib/mlx.metallib'), 1, 1); + put(join(app, 'Contents/MacOS/openless'), 'stale binary'); + put(dmg, 'stale dmg'); + put(`${app}.tar.gz`, 'stale updater'); + put(`${app}.tar.gz.sig`, 'stale signature'); + + for (let repeat = 0; repeat < 2; repeat += 1) { + const result = run(); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal(readFileSync(dmg, 'utf8').trim(), 'dmg'); + assert.equal( + readFileSync(join(builds, 'qwen3-asr-rs-helper/build-script-build'), 'utf8'), + 'cached executable', + ); + } + const failure = run('failure'); + assert.equal(failure.status, 23, failure.stdout + failure.stderr); + const missing = run('missing'); + assert.notEqual(missing.status, 0, missing.stdout + missing.stderr); + assert.equal(existsSync(app), false); + assert.equal(existsSync(dmg), false); +} finally { + rmSync(root, { recursive: true, force: true }); +} + +console.log('macOS warm build and packaging failure tests passed'); diff --git a/openless-all/app/scripts/macos-build-env.sh b/openless-all/app/scripts/macos-build-env.sh new file mode 100644 index 000000000..a89c14676 --- /dev/null +++ b/openless-all/app/scripts/macos-build-env.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Source locally; execute before rust-cache in GitHub Actions. +set -euo pipefail + +MACOS_BUILD_APP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Keep opt-level=3 and thin LTO, while allowing LLVM to compile large crates +# in parallel. The shared Cargo profile continues to govern other platforms. +export CARGO_PROFILE_RELEASE_CODEGEN_UNITS="${CARGO_PROFILE_RELEASE_CODEGEN_UNITS:-16}" +# Avoid malformed proc-macro dylibs with the macOS strip tool. +export CARGO_PROFILE_RELEASE_STRIP=debuginfo +export RUSTC_WRAPPER="$MACOS_BUILD_APP_ROOT/scripts/rustc-macos-proc-macro-wrapper.sh" + +if [ -n "${GITHUB_ENV:-}" ]; then + { + echo "CARGO_PROFILE_RELEASE_CODEGEN_UNITS=$CARGO_PROFILE_RELEASE_CODEGEN_UNITS" + echo "CARGO_PROFILE_RELEASE_STRIP=$CARGO_PROFILE_RELEASE_STRIP" + echo "RUSTC_WRAPPER=$RUSTC_WRAPPER" + } >> "$GITHUB_ENV" +fi