Skip to content
Open
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
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,5 @@ docs/windows-ui-tracking/
# 用于本地语音推理的参考文件。
CapsWriter

.reasonix
/.codex
.reasonix
/.codex
91 changes: 90 additions & 1 deletion openless-all/app/scripts/build-mac.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,110 @@ npm run check:macos-metal-toolchain
# 可能报 "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"
echo "▶ Cargo release strip: ${CARGO_PROFILE_RELEASE_STRIP} (macOS only)"
echo "▶ Rust proc-macro host wrapper: ${RUSTC_WRAPPER}"

echo "▶ tauri build"
TAURI_BUILD_ARGS=(build)
TAURI_BUILD_ARGS=(build --ci)
case "$(uname -m)" in
arm64)
MAC_BUNDLE_ARCH="aarch64"
TAURI_BUILD_ARGS+=(--config src-tauri/tauri.macos-mlx.conf.json)
;;
x86_64)
MAC_BUNDLE_ARCH="x64"
;;
*)
echo "✗ 不支持的 macOS 构建架构:$(uname -m)"
exit 1
;;
esac
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ -n "${TAURI_SIGNING_PRIVATE_KEY_PATH:-}" ]; then
TAURI_BUILD_ARGS+=(--config '{"bundle":{"createUpdaterArtifacts":true}}')
fi
npm run tauri -- "${TAURI_BUILD_ARGS[@]}"

APP_VERSION="$(node -p "require('./package.json').version")"
DMG_PATH="$DMG_DIR/OpenLess_${APP_VERSION}_${MAC_BUNDLE_ARCH}.dmg"

echo "▶ 校验 Info.plist / 签名"
/usr/libexec/PlistBuddy -c "Print :NSMicrophoneUsageDescription" "$INFO" >/dev/null
bash scripts/check-macos-speech-usage-description.sh "$INFO"
codesign -d --entitlements :- "$APP" 2>/dev/null | grep -q "com.apple.security.device.audio-input"
codesign --verify --deep --strict --verbose=2 "$APP" 2>&1 | tail -2

if [ "$MAC_BUNDLE_ARCH" = "aarch64" ]; then
echo "▶ 校验 MLX metallib 已进入 app / DMG / updater"
APP_METALLIB="$APP/Contents/MacOS/mlx.metallib"
if [ ! -s "$APP_METALLIB" ]; then
echo "✗ Apple Silicon app 缺少 Contents/MacOS/mlx.metallib"
exit 1
fi
APP_METALLIB_SHA="$(shasum -a 256 "$APP_METALLIB" | awk '{print $1}')"

if [ ! -f "$DMG_PATH" ]; then
echo "✗ 未找到 Tauri 生成的 DMG:$DMG_PATH"
exit 1
fi
DMG_MOUNT="$(mktemp -d "${TMPDIR:-/tmp}/openless-dmg-verify.XXXXXX")"
cleanup_dmg_mount() {
hdiutil detach "$DMG_MOUNT" >/dev/null 2>&1 || true
rmdir "$DMG_MOUNT" >/dev/null 2>&1 || true
}
trap cleanup_dmg_mount EXIT
hdiutil attach "$DMG_PATH" -readonly -nobrowse -mountpoint "$DMG_MOUNT" >/dev/null
DMG_METALLIB="$DMG_MOUNT/OpenLess.app/Contents/MacOS/mlx.metallib"
if [ ! -s "$DMG_METALLIB" ]; then
echo "✗ DMG 中缺少 OpenLess.app/Contents/MacOS/mlx.metallib"
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 不一致"
exit 1
fi
cleanup_dmg_mount
trap - EXIT

if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ -n "${TAURI_SIGNING_PRIVATE_KEY_PATH:-}" ]; then
UPDATER_ARCHIVE="src-tauri/target/release/bundle/macos/OpenLess.app.tar.gz"
if [ ! -f "$UPDATER_ARCHIVE" ]; then
echo "✗ 未找到 Tauri updater archive:$UPDATER_ARCHIVE"
exit 1
fi
UPDATER_METALLIB_SHA="$(tar -xOf "$UPDATER_ARCHIVE" \
OpenLess.app/Contents/MacOS/mlx.metallib | shasum -a 256 | awk '{print $1}')"
if [ "$UPDATER_METALLIB_SHA" != "$APP_METALLIB_SHA" ]; then
echo "✗ app 与 updater 中的 mlx.metallib SHA-256 不一致"
exit 1
fi
fi
echo "✓ MLX metallib sha256=$APP_METALLIB_SHA"
elif [ -e "$APP/Contents/MacOS/mlx.metallib" ]; then
echo "✗ Intel app 不应包含 Apple Silicon MLX metallib"
exit 1
fi

HAS_DEVELOPER_ID=0
if [ -n "${APPLE_CERTIFICATE:-}" ] \
|| { [ -n "${APPLE_SIGNING_IDENTITY:-}" ] && [ "${APPLE_SIGNING_IDENTITY}" != "-" ]; }; then
HAS_DEVELOPER_ID=1
fi
HAS_NOTARIZATION_CREDENTIALS=0
if { [ -n "${APPLE_ID:-}" ] \
&& [ -n "${APPLE_PASSWORD:-}" ] \
&& [ -n "${APPLE_TEAM_ID:-}" ]; } \
|| { [ -n "${APPLE_API_KEY:-}" ] && [ -n "${APPLE_API_ISSUER:-}" ]; }; then
HAS_NOTARIZATION_CREDENTIALS=1
fi
if [ "$HAS_DEVELOPER_ID" = "1" ] && [ "$HAS_NOTARIZATION_CREDENTIALS" = "1" ]; then
echo "▶ 校验 Gatekeeper 与公证票据"
spctl --assess --type execute --verbose=2 "$APP"
xcrun stapler validate "$APP"
xcrun stapler validate "$DMG_PATH"
fi

echo "▶ 清理发布产物扩展属性"
# 这只能保证 CI/本机构建产物本身干净;浏览器下载仍可能重新加 quarantine。
# 用户免手工 xattr 的根本方案是 Developer ID 签名 + Apple notarization。
Expand Down
27 changes: 27 additions & 0 deletions openless-all/app/scripts/macos-mlx-bundle-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
import { fileURLToPath } from "node:url"

const appRoot = resolve(fileURLToPath(new URL("..", import.meta.url)))
const overlay = JSON.parse(
readFileSync(resolve(appRoot, "src-tauri/tauri.macos-mlx.conf.json"), "utf8"),
)
const buildScript = readFileSync(resolve(appRoot, "scripts/build-mac.sh"), "utf8")

assert.equal(
overlay.build.beforeBundleCommand,
"node scripts/stage-macos-mlx-metallib.mjs",
)
assert.equal(
overlay.bundle.macOS.files["MacOS/mlx.metallib"],
"target/release/openless-mlx/mlx.metallib",
)
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/)

console.log("macOS MLX bundle contract tests passed")
28 changes: 28 additions & 0 deletions openless-all/app/scripts/rustc-macos-proc-macro-wrapper.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env bash

# Tauri sets MACOSX_DEPLOYMENT_TARGET for the application target. On Apple
# Silicon, Rust proc-macro dylibs built with that target cannot be loaded by
# rustc on this toolchain, even though regular application crates can use it.
# Keep the target for normal crates and use the host default only for proc
# macros.

set -euo pipefail

RUSTC="$1"
shift

is_proc_macro=0
previous_arg=""
for arg in "$@"; do
if [[ "$arg" == "--crate-type=proc-macro" || ( "$previous_arg" == "--crate-type" && "$arg" == "proc-macro" ) ]]; then
is_proc_macro=1
break
fi
previous_arg="$arg"
done

if (( is_proc_macro )); then
unset MACOSX_DEPLOYMENT_TARGET
fi

exec "$RUSTC" "$@"
61 changes: 61 additions & 0 deletions openless-all/app/scripts/rustc-macos-proc-macro-wrapper.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import assert from "node:assert/strict"
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { spawnSync } from "node:child_process"
import { fileURLToPath } from "node:url"

if (process.platform === "win32") {
console.log("rustc macOS wrapper test skipped on Windows")
process.exit(0)
}

const appRoot = resolve(fileURLToPath(new URL("..", import.meta.url)))
const wrapper = join(appRoot, "scripts", "rustc-macos-proc-macro-wrapper.sh")
const root = mkdtempSync(join(tmpdir(), "openless-rustc-wrapper-test-"))
const fakeRustc = join(root, "fake-rustc.sh")

writeFileSync(
fakeRustc,
`#!/usr/bin/env bash
printf 'deployment=%s\\n' "\${MACOSX_DEPLOYMENT_TARGET-unset}"
printf 'arg=<%s>\\n' "$@"
`,
)
chmodSync(fakeRustc, 0o755)

function run(args) {
return spawnSync("bash", [wrapper, fakeRustc, ...args], {
cwd: appRoot,
encoding: "utf8",
env: { ...process.env, MACOSX_DEPLOYMENT_TARGET: "14.0" },
})
}

try {
for (const args of [
["--crate-type=proc-macro", "--crate-name", "inline"],
["--crate-type", "proc-macro", "--crate-name", "split"],
]) {
const result = run(args)
assert.equal(result.status, 0, result.stderr)
assert.match(result.stdout, /^deployment=unset/m)
assert.deepEqual(
result.stdout.match(/^arg=<.*>$/gm),
args.map(arg => `arg=<${arg}>`),
)
}

const regularArgs = ["--crate-type=lib", "--crate-name", "regular"]
const regular = run(regularArgs)
assert.equal(regular.status, 0, regular.stderr)
assert.match(regular.stdout, /^deployment=14\.0/m)
assert.deepEqual(
regular.stdout.match(/^arg=<.*>$/gm),
regularArgs.map(arg => `arg=<${arg}>`),
)
} finally {
rmSync(root, { recursive: true, force: true })
}

console.log("rustc macOS proc-macro wrapper tests passed")
82 changes: 82 additions & 0 deletions openless-all/app/scripts/stage-macos-mlx-metallib.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { createHash } from "node:crypto"
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
} from "node:fs"
import { dirname, join, resolve } from "node:path"
import { fileURLToPath } from "node:url"

const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")

function parseArgs(argv) {
const options = {
buildRoot: join(appRoot, "src-tauri", "target", "release", "build"),
output: join(appRoot, "src-tauri", "target", "release", "openless-mlx", "mlx.metallib"),
}
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index + 1]
if (argv[index] === "--build-root" && value) {
options.buildRoot = resolve(value)
index += 1
} else if (argv[index] === "--output" && value) {
options.output = resolve(value)
index += 1
} else {
throw new Error(`未知参数或缺少参数值:${argv[index]}`)
}
}
return options
}

function sha256(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex")
}

function collectCandidates(buildRoot) {
if (!existsSync(buildRoot)) return []
return readdirSync(buildRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory() && entry.name.startsWith("qwen3-asr-rs-"))
.map(entry => join(buildRoot, entry.name, "out", "lib", "mlx.metallib"))
.filter(path => existsSync(path) && statSync(path).isFile() && statSync(path).size > 0)
.sort()
}

export function stageMetallib({ buildRoot, output }) {
rmSync(output, { force: true })
const candidates = collectCandidates(buildRoot)
if (candidates.length === 0) {
throw new Error(`未找到 MLX metallib:${buildRoot}`)
}

const byHash = new Map()
for (const candidate of candidates) {
const hash = sha256(candidate)
const paths = byHash.get(hash) ?? []
paths.push(candidate)
byHash.set(hash, paths)
}
if (byHash.size > 1) {
const details = [...byHash.entries()]
.flatMap(([hash, paths]) => paths.map(path => ` ${hash} ${path}`))
.join("\n")
throw new Error(`发现多个内容冲突的 MLX metallib,拒绝猜测构建产物:\n${details}`)
}

mkdirSync(dirname(output), { recursive: true })
copyFileSync(candidates[0], output)
const hash = [...byHash.keys()][0]
console.log(`✓ staged MLX metallib: ${output}`)
console.log(` sha256=${hash} candidates=${candidates.length}`)
}

try {
stageMetallib(parseArgs(process.argv.slice(2)))
} catch (error) {
console.error(`✗ ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
65 changes: 65 additions & 0 deletions openless-all/app/scripts/stage-macos-mlx-metallib.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import assert from "node:assert/strict"
import {
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { spawnSync } from "node:child_process"
import { fileURLToPath } from "node:url"

const appRoot = resolve(fileURLToPath(new URL("..", import.meta.url)))
const script = join(appRoot, "scripts", "stage-macos-mlx-metallib.mjs")

function candidate(root, suffix, content) {
const path = join(root, `qwen3-asr-rs-${suffix}`, "out", "lib", "mlx.metallib")
mkdirSync(resolve(path, ".."), { recursive: true })
writeFileSync(path, content)
return path
}

function run(buildRoot, output) {
return spawnSync(process.execPath, [script, "--build-root", buildRoot, "--output", output], {
cwd: appRoot,
encoding: "utf8",
})
}

const root = mkdtempSync(join(tmpdir(), "openless-mlx-stage-test-"))
try {
const buildRoot = join(root, "build")
const output = join(root, "staged", "mlx.metallib")
mkdirSync(buildRoot, { recursive: true })
mkdirSync(resolve(output, ".."), { recursive: true })

writeFileSync(output, "stale", { flag: "w" })
const missing = run(buildRoot, output)
assert.notEqual(missing.status, 0)
assert.match(missing.stderr, /未找到 MLX metallib/)
assert.equal(existsSync(output), false)

candidate(buildRoot, "one", "kernel-a")
const single = run(buildRoot, output)
assert.equal(single.status, 0, single.stderr)
assert.equal(readFileSync(output, "utf8"), "kernel-a")

candidate(buildRoot, "same", "kernel-a")
const identical = run(buildRoot, output)
assert.equal(identical.status, 0, identical.stderr)
assert.equal(readFileSync(output, "utf8"), "kernel-a")

candidate(buildRoot, "conflict", "kernel-b")
const conflict = run(buildRoot, output)
assert.notEqual(conflict.status, 0)
assert.match(conflict.stderr, /内容冲突/)
assert.match(conflict.stderr, /qwen3-asr-rs-one/)
assert.match(conflict.stderr, /qwen3-asr-rs-conflict/)
} finally {
rmSync(root, { recursive: true, force: true })
}

console.log("stage-macos-mlx-metallib tests passed")
Loading
Loading