From be49758b3121fe2db8509dda46bef068ac00a86c Mon Sep 17 00:00:00 2001 From: xryu Date: Tue, 29 Sep 2026 19:55:07 +0800 Subject: [PATCH 1/6] feat: load restored native libraries and validate packaged clients --- .github/workflows/client.yml | 55 ++++++++ native/CMakeLists.txt | 10 ++ native/resources.cjs | 31 +++++ native/tools/run.cjs | 5 +- package-lock.json | 197 ++++++++++------------------- package.json | 10 +- scripts/adhoc-sign.cjs | 13 ++ scripts/after-pack.cjs | 14 ++ scripts/build-config.cjs | 21 +++ src/libs/capi.js | 122 +----------------- src/libs/index.js | 2 +- src/libs/native-bindings.mjs | 57 +++++++++ src/libs/native-runtime.mjs | 25 ++++ src/libs/stru.js | 4 +- src/main/draw.js | 9 +- src/main/index.js | 127 ++++++------------- src/main/ipcMain.js | 16 ++- src/renderer/components/TopBar.vue | 2 +- tests/app/smoke.mjs | 46 +++++++ 19 files changed, 411 insertions(+), 355 deletions(-) create mode 100644 .github/workflows/client.yml create mode 100644 native/resources.cjs create mode 100644 scripts/adhoc-sign.cjs create mode 100644 scripts/after-pack.cjs create mode 100644 scripts/build-config.cjs create mode 100644 src/libs/native-bindings.mjs create mode 100644 src/libs/native-runtime.mjs create mode 100644 tests/app/smoke.mjs diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml new file mode 100644 index 0000000..89a20a5 --- /dev/null +++ b/.github/workflows/client.yml @@ -0,0 +1,55 @@ +name: Client platforms +on: + push: + branches: [master, 'codex/**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + client: + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + target: win32-x64 + - runner: macos-15-intel + target: darwin-x64 + - runner: macos-15 + target: darwin-arm64 + - runner: ubuntu-24.04 + target: linux-x64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 35 + env: + EXPECTED_TARGET: ${{ matrix.target }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: npm + - run: node -e "require('assert').equal(process.platform+'-'+process.arch, process.env.EXPECTED_TARGET)" + - if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libasound2-dev libfuse2 xvfb + - run: npm ci + - run: npm run native:test + - if: runner.os == 'Windows' + run: npm run native:compare + - run: npm run test:audio + - run: npm run test:audio:integration + - run: npm run electron:build -- --dir + - if: runner.os != 'Linux' + run: npm run test:app + - if: runner.os == 'Linux' + run: xvfb-run -a npm run test:app + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: client-smoke-${{ matrix.target }} + path: | + artifacts/app-smoke/**/result.json + artifacts/app-smoke/**/main-window.png + native/build/Testing/Temporary/*.log + if-no-files-found: warn diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 57b6f6a..afd4f92 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -1,6 +1,16 @@ cmake_minimum_required(VERSION 3.20) +cmake_policy(SET CMP0091 NEW) project(lsaudio_native VERSION 0.1.0 LANGUAGES C) +set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +set(CMAKE_BUILD_WITH_INSTALL_RPATH ON) +if(APPLE) + set(CMAKE_INSTALL_RPATH "@loader_path") + set(CMAKE_INSTALL_NAME_DIR "@rpath") +elseif(UNIX) + set(CMAKE_INSTALL_RPATH "$ORIGIN") +endif() + set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_EXTENSIONS OFF) diff --git a/native/resources.cjs b/native/resources.cjs new file mode 100644 index 0000000..4206607 --- /dev/null +++ b/native/resources.cjs @@ -0,0 +1,31 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { checkArchitecture } = require('../ffmpeg/assets.cjs'); +const modules = ['eqdrc', 'eqdraw', 'soundeffect']; +function libraryName(name, platform = process.platform) { + return platform === 'win32' ? `lsaudio_${name}.dll` : platform === 'darwin' ? `liblsaudio_${name}.dylib` : `liblsaudio_${name}.so`; +} +function hash(file) { return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } +function writeManifest(directory, platform = process.platform, arch = process.arch) { + const files = Object.fromEntries(modules.map(name => { const file = libraryName(name, platform); return [file, hash(path.join(directory, file))]; })); + const manifest = { schemaVersion: 1, platform, arch, files }; + fs.writeFileSync(path.join(directory, 'native-manifest.json'), JSON.stringify(manifest, null, 2) + '\n'); + return manifest; +} +async function stageNative(directory, platform, arch) { + if (platform !== process.platform || arch !== process.arch) throw new Error('Native libraries must be built on the matching platform/architecture'); + const source = path.join(__dirname, 'build/lib'); + const metadata = JSON.parse(fs.readFileSync(path.join(source, 'native-manifest.json'), 'utf8')); + if (metadata.platform !== platform || metadata.arch !== arch) throw new Error('Native build target mismatch'); + fs.mkdirSync(directory, { recursive: true }); + for (const name of modules) { + const file = libraryName(name, platform); + if (hash(path.join(source, file)) !== metadata.files[file]) throw new Error(`Stale native manifest: ${file}`); + await checkArchitecture(path.join(source, file), `${platform}-${arch}`); + fs.copyFileSync(path.join(source, file), path.join(directory, file)); + if (platform !== 'win32') fs.chmodSync(path.join(directory, file), 0o755); + } + return writeManifest(directory, platform, arch); +} +module.exports = { libraryName, writeManifest, stageNative }; diff --git a/native/tools/run.cjs b/native/tools/run.cjs index db7b912..866e420 100644 --- a/native/tools/run.cjs +++ b/native/tools/run.cjs @@ -17,7 +17,10 @@ function run(command, args, timeout = 120000) { if (result.status !== 0) throw new Error(`${command} failed (exit ${result.status}, signal ${result.signal})`); } function configure() { run('cmake', ['--no-warn-unused-cli', '-S', 'native', '-B', 'native/build', '-DCMAKE_BUILD_TYPE=Release']); } -function compile() { configure(); run('cmake', ['--build', 'native/build', '--config', 'Release']); } +function compile() { + configure(); run('cmake', ['--build', 'native/build', '--config', 'Release']); + require('../resources.cjs').writeManifest(path.join(build, 'lib')); +} function observe(module, library, dest, mode) { run(process.execPath, [`native/tests/${module.name}/observe.cjs`, library, dest, mode], 30000); } function windowsOracle() { if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('Oracle capture requires Windows x64. Portable tests use committed fixtures instead.'); diff --git a/package-lock.json b/package-lock.json index af29a98..bd518ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,13 @@ "dependencies": { "@echogarden/audio-io": "^0.4.2", "@electron-toolkit/utils": "^4.0.0", + "fs-extra": "10.1.0", "iconv-lite": "^0.7.1", "koffi": "^2.14.1", "serialport": "^12.0.0" }, "devDependencies": { + "@electron/osx-sign": "1.0.5", "@vitejs/plugin-vue": "^6.0.3", "@vue/eslint-config-prettier": "^10.2.0", "echarts": "^5.4.1", @@ -30,6 +32,7 @@ "fast-glob": "^3.3.2", "lodash": "^4.17.21", "pinia": "^3.0.4", + "playwright-core": "1.63.0", "prettier": "^3.7.4", "sass": "^1.66.1", "unplugin-icons": "^0.19.0", @@ -485,6 +488,22 @@ "node": ">= 10.0.0" } }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@electron/osx-sign": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz", @@ -507,21 +526,6 @@ "node": ">=12.0.0" } }, - "node_modules/@electron/osx-sign/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", @@ -577,6 +581,22 @@ "node": ">= 10" } }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@element-plus/icons-vue": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", @@ -1544,6 +1564,22 @@ "node": ">= 10.0.0" } }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3190,21 +3226,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/app-builder-lib/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/app-builder-lib/node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -3696,21 +3717,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/builder-util/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4268,21 +4274,6 @@ "dmg-license": "^1.0.11" } }, - "node_modules/dmg-builder/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/dmg-builder/node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -4438,22 +4429,6 @@ "fs-extra": "^10.1.0" } }, - "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/electron-builder/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4486,21 +4461,6 @@ "node": ">=12" } }, - "node_modules/electron-builder/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/electron-builder/node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -4563,21 +4523,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/electron-publish/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -5878,19 +5823,17 @@ "peer": true }, "node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "license": "MIT", "dependencies": { - "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { - "node": ">=10" + "node": ">=12" } }, "node_modules/fs-minipass": { @@ -6618,7 +6561,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -7407,6 +7349,19 @@ "dev": true, "license": "MIT" }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -8319,21 +8274,6 @@ "fs-extra": "^10.0.0" } }, - "node_modules/temp-file/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -8496,7 +8436,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" diff --git a/package.json b/package.json index 22280ee..7150532 100644 --- a/package.json +++ b/package.json @@ -13,24 +13,27 @@ "native:test": "node native/tools/run.cjs test", "native:capture": "node native/tools/run.cjs capture", "native:compare": "node native/tools/run.cjs compare", - "predev": "node ffmpeg/manage.cjs prepare", + "predev": "npm run native:build && npm run ffmpeg:prepare", "ffmpeg:prepare": "node ffmpeg/manage.cjs prepare", "ffmpeg:verify": "node ffmpeg/manage.cjs verify", "test:audio": "node --test tests/audio/*.test.mjs", "test:audio:integration": "node ffmpeg/manage.cjs prepare && node --test tests/audio/integration.mjs", "ffmpeg:smoke-pack": "node ffmpeg/smoke-pack.cjs", - "electron:build": "electron-vite build && electron-builder", + "electron:build": "npm run native:build && npm run build && electron-builder --config scripts/build-config.cjs --publish never", "postinstall": "electron-builder install-app-deps", - "postuninstall": "electron-builder install-app-deps" + "postuninstall": "electron-builder install-app-deps", + "test:app": "node tests/app/smoke.mjs" }, "dependencies": { "@echogarden/audio-io": "^0.4.2", "@electron-toolkit/utils": "^4.0.0", + "fs-extra": "10.1.0", "iconv-lite": "^0.7.1", "koffi": "^2.14.1", "serialport": "^12.0.0" }, "devDependencies": { + "@electron/osx-sign": "1.0.5", "@vitejs/plugin-vue": "^6.0.3", "@vue/eslint-config-prettier": "^10.2.0", "echarts": "^5.4.1", @@ -45,6 +48,7 @@ "fast-glob": "^3.3.2", "lodash": "^4.17.21", "pinia": "^3.0.4", + "playwright-core": "1.63.0", "prettier": "^3.7.4", "sass": "^1.66.1", "unplugin-icons": "^0.19.0", diff --git a/scripts/adhoc-sign.cjs b/scripts/adhoc-sign.cjs new file mode 100644 index 0000000..d121701 --- /dev/null +++ b/scripts/adhoc-sign.cjs @@ -0,0 +1,13 @@ +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { signAsync } = require('@electron/osx-sign'); +const { writeManifest } = require('../native/resources.cjs'); +module.exports = async options => { + await signAsync({ ...options, identity: '-', identityValidation: false, preAutoEntitlements: false, + optionsForFile: file => ({ ...options.optionsForFile(file), hardenedRuntime: false }), + }); + writeManifest(path.join(options.app, 'Contents/Resources/native')); + // Updating the resource manifest invalidates only the outer app signature. + execFileSync('codesign', ['--force', '--sign', '-', '--timestamp=none', '--preserve-metadata=entitlements', options.app], { stdio: 'inherit' }); + execFileSync('codesign', ['--verify', '--deep', '--strict', options.app], { stdio: 'inherit' }); +}; diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs new file mode 100644 index 0000000..b7c6e5e --- /dev/null +++ b/scripts/after-pack.cjs @@ -0,0 +1,14 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const ffmpeg = require('../ffmpeg/after-pack.cjs'); +const { stageNative } = require('../native/resources.cjs'); +module.exports = async context => { + await ffmpeg(context); + const arch = typeof context.arch === 'string' ? context.arch : ['ia32', 'x64', 'arm', 'arm64', 'universal'][context.arch]; + const platform = context.electronPlatformName; + const resources = platform === 'darwin' ? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app/Contents/Resources`) : path.join(context.appOutDir, 'resources'); + await stageNative(path.join(resources, 'native'), platform, arch); + const licenses = path.join(resources, 'licenses'); fs.mkdirSync(licenses, { recursive: true }); + fs.copyFileSync(path.join(__dirname, '../native/third_party/cmsis-dsp/LICENSE.txt'), path.join(licenses, 'CMSIS-DSP-LICENSE.txt')); + fs.copyFileSync(path.join(__dirname, '../native/third_party/cmsis-dsp/UPSTREAM.md'), path.join(licenses, 'CMSIS-DSP-SOURCE.md')); +}; diff --git a/scripts/build-config.cjs b/scripts/build-config.cjs new file mode 100644 index 0000000..712f941 --- /dev/null +++ b/scripts/build-config.cjs @@ -0,0 +1,21 @@ +const path = require('node:path'); +const original = require('../package.json').build; +const config = structuredClone(original); +config.afterPack = path.join(__dirname, 'after-pack.cjs'); +config.extraResources = []; +config.files = ['out/**', 'package.json', '!**/*.map', '!node_modules/koffi/{doc,src,vendor}/**', '!node_modules/@serialport/bindings-cpp/src/**']; +// electron-builder selects dependency prebuilds. Keep universal macOS serial +// bindings and all runtime JS loaders; never reuse the old Windows-only denylist. +config.asarUnpack = ['node_modules/**/*.node', 'node_modules/koffi/build/**']; +config.artifactName = 'LSAudio-${version}-${os}-${arch}.${ext}'; +config.win.target = [{ target: 'nsis', arch: ['x64'] }]; +config.win.artifactName = 'LSAudio-${version}-windows-${arch}-setup.${ext}'; +config.mac = { icon: './public/icon.png', target: [{ target: 'dmg', arch: ['x64', 'arm64'] }], + artifactName: 'LSAudio-${version}-macos-${arch}.${ext}', hardenedRuntime: false, notarize: false, + sign: path.join(__dirname, 'adhoc-sign.cjs'), binaries: ['Contents/Resources/ffmpeg/ffmpeg'], +}; +config.linux = { icon: './public/icon.png', target: [{ target: 'AppImage', arch: ['x64'] }, { target: 'deb', arch: ['x64'] }], + artifactName: 'LSAudio-${version}-linux-${arch}.${ext}', category: 'AudioVideo', + maintainer: 'ListenAI', synopsis: 'LSAudio audio device tuning client', +}; +module.exports = config; diff --git a/src/libs/capi.js b/src/libs/capi.js index 00721fa..af7b225 100644 --- a/src/libs/capi.js +++ b/src/libs/capi.js @@ -1,117 +1,9 @@ -import koffi from 'koffi'; -import path from 'path'; -import * as stru from './stru'; -import { is } from '@electron-toolkit/utils'; - -// Resolve static path for native dlls, compatible with Vite/Electron build -const staticRoot = is.dev ? process.cwd() : process.resourcesPath; - -let binDllPath = path.join(staticRoot, 'dlls'); -if (!is.dev) { - console.log('binDllPath', binDllPath, staticRoot); - binDllPath = binDllPath.replace('\\resources', '\\'); -} - -const detectArchFolder = function () { - switch (process.arch) { - case 'ia32': - return 'x86'; - case 'x64': - return 'x64'; - case 'arm64': - return 'arm64'; - default: - throw new Error('Arch not supported!'); - } -}; - -const archFolder = detectArchFolder(); -const binDll = path.join(binDllPath, archFolder, 'iflytekEqDrcDrawApi.dll'); -const eqDrawDll = path.join(binDllPath, archFolder, 'eqdrawDLL.dll'); -const soundEffectDll = path.join(binDllPath, archFolder, 'SoundEffect.dll'); - -console.log('native dlls ->', binDll, eqDrawDll, soundEffectDll); - +import { app } from 'electron'; +import { nativeDirectory } from './native-runtime.mjs'; +import { loadNativeBindings } from './native-bindings.mjs'; +const bindings = loadNativeBindings(nativeDirectory({ packaged: app.isPackaged, appRoot: app.getAppPath(), resourcesPath: process.resourcesPath })); +export const { iflytekBinHandle, iflytekEqDraw, iflytekSoundEffect } = bindings; export const IFLYTEK_ALG_ID_E = { - IFLYTEK_ALG_ID_BASSBOOST: 0, - IFLYTEK_ALG_ID_TREBLEBOOST: 1, - IFLYTEK_ALG_ID_PEQ: 2, - IFLYTEK_ALG_ID_DRC: 3, - IFLYTEK_ALG_ID_GAIN: 4, - IFLYTEK_ALG_ID_LIMITER: 5, -}; - -const lib = koffi.load(binDll); -export const iflytekBinHandle = { - writeToBinFile: lib.func('int writeToBinFile(const char*, struAudioPrm*)'), - readFromBinFile: lib.func( - 'int readFromBinFile(const char*, _Out_ struAudioPrm*)', - ), -}; - -const eqLib = koffi.load(eqDrawDll); -const nativeEqDrawPoints = eqLib.func( - 'struEqDrawResult* eqDrawPoints(struMonoEqPrm*, struUIXYInfo*)', -); -const nativeFreePoints = eqLib.func('void freePoints(struEqDrawResult*)'); -const nativeDrcDrawPoints = eqLib.func( - 'struDrcDrawResult* drcDrawPoints(struDrcPrm*, struUIDrcInfo*)', -); -const nativeFreeDrcPoints = eqLib.func( - 'void freeDrcPoints(struDrcDrawResult*)', -); -export const iflytekEqDraw = { - eqDrawPoints: (monoEqPrm, xyData) => { - const res = nativeEqDrawPoints(monoEqPrm, xyData); - const parsed = koffi.decode(res, stru.struEqDrawResult); - nativeFreePoints(res); - return parsed; - }, - drcDrawPoints: (drcPrm, drcInfo) => { - const res = nativeDrcDrawPoints(drcPrm, drcInfo); - const parsed = koffi.decode(res, stru.struDrcDrawResult); - nativeFreeDrcPoints(res); - return parsed; - }, -}; - -const soundEffectLib = koffi.load(soundEffectDll); -const nativeAudioCreate = soundEffectLib.func( - 'int IFLYTEK_AudioCreate(_Out_ char*, _Out_ int*)', -); -const nativeAudioInitial = soundEffectLib.func( - 'int IFLYTEK_AudioInitial(_Out_ char*)', -); -const nativeAudioReset = soundEffectLib.func( - 'int IFLYTEK_AudioReset(_Out_ char*, int)', -); -const nativeAudioSet = soundEffectLib.func( - 'int IFLYTEK_AudioSet(_Out_ char*, struAudioPrm*)', -); -const nativeAudioGet = soundEffectLib.func( - 'int IFLYTEK_AudioGet(_Out_ char*, _Out_ struAudioPrm*)', -); -const nativeAudioProcess = soundEffectLib.func( - 'int IFLYTEK_AudioProcess(_Out_ char*, _Out_ int*, _Out_ int*, int)', -); -const nativeAudioDelete = soundEffectLib.func( - 'int IFLYTEK_AudioDelete(_Out_ char*)', -); -const nativeAudioGetInfo = soundEffectLib.func( - 'int IFLYTEK_AudioGetInfo(_Out_ const char**)', -); -const nativeSetAlgParam = soundEffectLib.func( - 'int IFLYTEK_SetAlgParam(_Out_ char*, int, _Out_ char*)', -); - -export const iflytekSoundEffect = { - audioCreate: nativeAudioCreate, - audioInitial: nativeAudioInitial, - audioReset: nativeAudioReset, - audioSet: nativeAudioSet, - audioGet: nativeAudioGet, - audioProcess: nativeAudioProcess, - audioDelete: nativeAudioDelete, - audioGetInfo: nativeAudioGetInfo, - setAlgParam: nativeSetAlgParam, + IFLYTEK_ALG_ID_BASSBOOST: 0, IFLYTEK_ALG_ID_TREBLEBOOST: 1, + IFLYTEK_ALG_ID_PEQ: 2, IFLYTEK_ALG_ID_DRC: 3, IFLYTEK_ALG_ID_GAIN: 4, IFLYTEK_ALG_ID_LIMITER: 5, }; diff --git a/src/libs/index.js b/src/libs/index.js index e24850a..261b1e0 100644 --- a/src/libs/index.js +++ b/src/libs/index.js @@ -210,7 +210,7 @@ export function parseAudioPrm(audioPrm) { }; export const readFromBinFile = (binfile) => { - const audioPrmPtr = koffi.alloc(struAudioPrm, koffi.sizeof(struAudioPrm)); + const audioPrmPtr = Buffer.alloc(koffi.sizeof(struAudioPrm)); const ret = iflytekBinHandle.readFromBinFile(binfile, audioPrmPtr); if (ret !== 0) { return null; diff --git a/src/libs/native-bindings.mjs b/src/libs/native-bindings.mjs new file mode 100644 index 0000000..6323527 --- /dev/null +++ b/src/libs/native-bindings.mjs @@ -0,0 +1,57 @@ +import koffi from 'koffi'; +import fs from 'node:fs'; +import * as stru from './stru.js'; +import { verifyNativeResources } from './native-runtime.mjs'; + +export function loadNativeBindings(directory) { + const paths = verifyNativeResources(directory); + const loaded = []; + try { + const core = koffi.load(paths.eqdrc); loaded.push(core); + const drawing = koffi.load(paths.eqdraw); loaded.push(drawing); + const sound = koffi.load(paths.soundeffect); loaded.push(sound); + const encode = core.func('int lsaudio_audio_encode(struAudioPrm*, void*, size_t)'); + const decode = core.func('int lsaudio_audio_decode(const void*, size_t, _Out_ struAudioPrm*)'); + const eq = drawing.func('struEqDrawResult* eqDrawPoints(struMonoEqPrm*, struUIXYInfo*)'); + const drc = drawing.func('struDrcDrawResult* drcDrawPoints(struDrcPrm, struUIDrcInfo*)'); + const freeEq = drawing.func('void freePoints(struEqDrawResult*)'); + const freeDrc = drawing.func('void freeDrcPoints(struDrcDrawResult*)'); + function result(pointer, type, release) { + if (!pointer) throw new Error('原生绘图结果分配失败'); + try { + const value = koffi.decode(pointer, type); + if (value.ret < 0) throw new Error(`原生绘图参数错误:${value.ret}`); + const max = type === stru.struEqDrawResult ? 1024 : 315; + if (value.arr_size < 0 || value.arr_size > max || (value.dot_size !== undefined && (value.dot_size < 0 || value.dot_size > 7))) throw new Error('原生绘图返回了无效长度'); + return value; + } finally { release(pointer); } + } + return { + iflytekBinHandle: { + writeToBinFile(file, params) { + const bytes = Buffer.alloc(360); const ret = encode(params, bytes, bytes.length); + if (ret) return ret; + try { fs.writeFileSync(file, bytes); return 0; } catch { return -3; } + }, + readFromBinFile(file, params) { + try { const bytes = fs.readFileSync(file); return decode(bytes, bytes.length, params); } catch { return -4; } + }, + }, + iflytekEqDraw: { + eqDrawPoints: (params, ui) => result(eq(params, ui), stru.struEqDrawResult, freeEq), + drcDrawPoints: (params, ui) => result(drc(params, ui), stru.struDrcDrawResult, freeDrc), + }, + iflytekSoundEffect: { + audioCreate: sound.func('int IFLYTEK_AudioCreate(void*, _Out_ int*)'), + audioInitial: sound.func('int IFLYTEK_AudioInitial(void*)'), + audioReset: sound.func('int IFLYTEK_AudioReset(void*, int)'), + audioSet: sound.func('int IFLYTEK_AudioSet(void*, struAudioPrm*)'), + audioGet: sound.func('int IFLYTEK_AudioGet(void*, _Out_ struAudioPrm*)'), + audioProcess: sound.func('int IFLYTEK_AudioProcess(void*, const int*, _Out_ int*, int)'), + audioDelete: sound.func('int IFLYTEK_AudioDelete(void*)'), + audioGetInfo: sound.func('int IFLYTEK_AudioGetInfo(_Out_ const char**)'), + setAlgParam: sound.func('int IFLYTEK_SetAlgParam(void*, int, void*)'), + }, + }; + } catch (error) { for (const library of loaded.reverse()) library.unload(); throw error; } +} diff --git a/src/libs/native-runtime.mjs b/src/libs/native-runtime.mjs new file mode 100644 index 0000000..d7b9293 --- /dev/null +++ b/src/libs/native-runtime.mjs @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +export const NATIVE_TARGETS = ['win32-x64', 'darwin-x64', 'darwin-arm64', 'linux-x64']; +export function nativeDirectory({ packaged, appRoot, resourcesPath }) { + return packaged ? path.join(resourcesPath, 'native') : path.join(appRoot, 'native/build/lib'); +} +export function verifyNativeResources(directory, platform = process.platform, arch = process.arch) { + if (!NATIVE_TARGETS.includes(`${platform}-${arch}`)) throw new Error(`不支持的原生运行平台:${platform}-${arch}`); + let manifest; + try { manifest = JSON.parse(fs.readFileSync(path.join(directory, 'native-manifest.json'), 'utf8')); } + catch { throw new Error('原生资源清单缺失或损坏。开发环境请执行 npm run native:build;安装版请重新安装。'); } + if (manifest.schemaVersion !== 1 || manifest.platform !== platform || manifest.arch !== arch) throw new Error('原生资源架构与当前客户端不匹配'); + const result = {}; + for (const name of ['eqdrc', 'eqdraw', 'soundeffect']) { + const file = platform === 'win32' ? `lsaudio_${name}.dll` : platform === 'darwin' ? `liblsaudio_${name}.dylib` : `liblsaudio_${name}.so`; + const absolute = path.join(directory, file); + let digest; + try { digest = crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex'); } + catch { throw new Error(`缺少原生运行库:${file}`); } + if (manifest.files[file] !== digest) throw new Error(`原生运行库校验失败:${file}`); + result[name] = absolute; + } + return result; +} diff --git a/src/libs/stru.js b/src/libs/stru.js index 43a379f..212efee 100644 --- a/src/libs/stru.js +++ b/src/libs/stru.js @@ -99,7 +99,7 @@ export const struUIDrcInfo = koffi.struct('struUIDrcInfo', { endYGain: 'float' }); const point = koffi.array('double', 2); -const points = koffi.array(point, 840); +const points = koffi.array(point, 1024); const drcPoints = koffi.array(point, 315); export const struEqDrawResult = koffi.struct('struEqDrawResult', { ret: 'int', @@ -113,4 +113,4 @@ export const struDrcDrawResult = koffi.struct('struDrcDrawResult', { points: drcPoints, fs: 'float', dots: struDots -}); \ No newline at end of file +}); diff --git a/src/main/draw.js b/src/main/draw.js index 181e3a1..b83300e 100644 --- a/src/main/draw.js +++ b/src/main/draw.js @@ -1,15 +1,12 @@ import { eqDrawPoints, drcDrawPoints, writeToBinFile, readFromBinFile } from '../libs/index'; import { join } from 'path'; -const iconv = require("iconv-lite"); export default (ipcMain) => { ipcMain.handle('eq-draw', (_e, eqData, chartConf) => eqDrawPoints(eqData, chartConf)); ipcMain.handle('drc-draw', (_e, drcData, chartConf) => drcDrawPoints(drcData, chartConf)); ipcMain.handle('write-bin', (_e, binpath, name, audioConf) => { - const pathstr = iconv.encode(join(binpath, name), 'GBK'); - return writeToBinFile(pathstr, audioConf); + return writeToBinFile(join(binpath, name), audioConf); }); ipcMain.handle('read-bin', (_e, binfile) => { - const pathstr = iconv.encode(binfile, 'GBK'); - return readFromBinFile(pathstr); + return readFromBinFile(binfile); }); -}; \ No newline at end of file +}; diff --git a/src/main/index.js b/src/main/index.js index 745aa19..28a42c9 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1,102 +1,47 @@ -import { is } from '@electron-toolkit/utils'; -import { app, BrowserWindow } from 'electron'; -import path from 'path'; -import { IpcMainHandle } from './ipcMain'; -import { stopPlayer } from './player'; -import { stopSerialAudio } from './serialport'; -import { closePcmSources } from './audioDecoder'; +import { app, BrowserWindow, dialog } from 'electron'; +import path from 'node:path'; +import { nativeDirectory, verifyNativeResources } from '../libs/native-runtime.mjs'; -let audioShutdownComplete = false; -let audioShutdown; +let runtime; +let shutdownComplete = false, shutdown; app.on('before-quit', event => { - if (audioShutdownComplete) return; + if (shutdownComplete || !runtime) return; event.preventDefault(); - audioShutdown ||= (async () => { - const results = await Promise.allSettled([stopPlayer(), stopSerialAudio(), closePcmSources()]); + shutdown ||= (async () => { + const results = await Promise.allSettled([runtime.stopPlayer(), runtime.stopSerialAudio(), runtime.closePcmSources()]); for (const result of results) if (result.status === 'rejected') console.error({ event: 'audio-shutdown-failed', message: result.reason?.message }); - audioShutdownComplete = true; - app.quit(); + shutdownComplete = true; app.quit(); })(); }); - -const isDevelopment = process.env.NODE_ENV !== 'production'; -const WINDOW_WIDTH = 1320; -const WINDOW_HEIGHT = 640; - async function createWindow() { - try { - // Create the browser window. - const win = new BrowserWindow({ - height: WINDOW_HEIGHT, - width: WINDOW_WIDTH, - minHeight: WINDOW_HEIGHT, - minWidth: WINDOW_WIDTH, - frame: false, - show: false, - useContentSize: true, - webPreferences: { - preload: path.join(__dirname, '../preload/index.js'), - nodeIntegration: false, - contextIsolation: true, - }, - }); - win.once('ready-to-show', () => { - win.show(); - }); - IpcMainHandle(win); - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - await win.loadURL(process.env['ELECTRON_RENDERER_URL']); - win.webContents.openDevTools(); - } else { - await win.loadFile(path.join(__dirname, '../renderer/index.html')); - } - } catch (error) { - console.log(error); - } + const window = new BrowserWindow({ + height: 640, width: 1320, minHeight: 640, minWidth: 1320, frame: false, show: false, + useContentSize: true, + webPreferences: { preload: path.join(__dirname, '../preload/index.js'), nodeIntegration: false, contextIsolation: true }, + }); + window.once('ready-to-show', () => window.show()); + runtime.IpcMainHandle(window); + if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) await window.loadURL(process.env.ELECTRON_RENDERER_URL); + else await window.loadFile(path.join(__dirname, '../renderer/index.html')); } - -// Quit when all windows are closed. app.on('window-all-closed', () => { - // On macOS it is common for applications and their menu bar - // to stay active until the user quits explicitly with Cmd + Q - if (process.platform !== 'darwin') { - app.quit(); - } -}); - -app.on('activate', () => { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (BrowserWindow.getAllWindows().length === 0) createWindow(); + if (process.platform !== 'darwin') app.quit(); + else if (runtime) void Promise.allSettled([runtime.stopPlayer(), runtime.stopSerialAudio()]); }); - -// This method will be called when Electron has finished -// initialization and is ready to create browser windows. -// Some APIs can only be used after this event occurs. -app.on('ready', async () => { - // if (isDevelopment && !process.env.IS_TEST) { - // // Install Vue Devtools - // try { - // await installExtension(VUEJS_DEVTOOLS); - // } catch (e) { - // console.error('Vue Devtools failed to install:', e.toString()); - // } - // } - createWindow(); -}); -// app.allowRendererProcessReuse = false; - -// Exit cleanly on request from parent process in development mode. -if (isDevelopment) { - if (process.platform === 'win32') { - process.on('message', (data) => { - if (data === 'graceful-exit') { - app.quit(); - } - }); - } else { - process.on('SIGTERM', () => { - app.quit(); - }); - } +app.on('activate', () => { if (runtime && BrowserWindow.getAllWindows().length === 0) void createWindow().catch(startupFailure); }); +function startupFailure(error) { + console.error({ event: 'startup-failed', message: error.message }); + if (!process.env.LSAUDIO_SMOKE) dialog.showErrorBox('LSAudio 无法启动', error.message); + app.exit(1); +} +app.whenReady().then(async () => { + verifyNativeResources(nativeDirectory({ packaged: app.isPackaged, appRoot: app.getAppPath(), resourcesPath: process.resourcesPath })); + // Dynamic imports keep native-load errors inside the visible startup handler. + const [ipc, player, serial, audio] = await Promise.all([import('./ipcMain'), import('./player'), import('./serialport'), import('./audioDecoder')]); + runtime = { IpcMainHandle: ipc.IpcMainHandle, stopPlayer: player.stopPlayer, stopSerialAudio: serial.stopSerialAudio, closePcmSources: audio.closePcmSources }; + await createWindow(); +}).catch(startupFailure); +if (!app.isPackaged) { + if (process.platform === 'win32') process.on('message', data => { if (data === 'graceful-exit') app.quit(); }); + else process.on('SIGTERM', () => app.quit()); } diff --git a/src/main/ipcMain.js b/src/main/ipcMain.js index 6f3455a..fe00221 100644 --- a/src/main/ipcMain.js +++ b/src/main/ipcMain.js @@ -7,6 +7,7 @@ import file from './file'; import { playerIpc } from './player'; let mainWindow = null; +let registered = false; function IpcMainHandle(window) { mainWindow = window; window.on('maximize', () => { @@ -18,20 +19,23 @@ function IpcMainHandle(window) { focusWindow.webContents.send('windowChange', { isMaximized: focusWindow.isMaximized() }); }); window.on('close', () => { - mainWindow = null; + if (mainWindow === window) mainWindow = null; }); + if (registered) return; + registered = true; ipcMain.on('window-min', function () { - window.minimize(); + mainWindow?.minimize(); }); ipcMain.on('window-max', function () { - if (window.isMaximized()) { - window.restore(); + if (!mainWindow) return; + if (mainWindow.isMaximized()) { + mainWindow.restore(); } else { - window.maximize(); + mainWindow.maximize(); } }); ipcMain.on('window-close', function () { - window.close(); + mainWindow?.close(); }); ipcMain.on('open-external', function (event, url) { shell.openExternal(url); diff --git a/src/renderer/components/TopBar.vue b/src/renderer/components/TopBar.vue index d78c23f..f23d54c 100644 --- a/src/renderer/components/TopBar.vue +++ b/src/renderer/components/TopBar.vue @@ -110,7 +110,7 @@ export default { confirmVisible: false, isCose: false, projectModalType: '新建', - version: '1.0.0', + version: '', menus: [ { label: '文件', diff --git a/tests/app/smoke.mjs b/tests/app/smoke.mjs new file mode 100644 index 0000000..0a42142 --- /dev/null +++ b/tests/app/smoke.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { _electron } from 'playwright-core'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import getDefaultConfig from '../../src/renderer/utils/config.js'; +const root = fileURLToPath(new URL('../../', import.meta.url)); +const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); +const directory = path.resolve(process.env.SMOKE_OUTPUT || path.join(root, 'artifacts/app-smoke', `${process.platform}-${process.arch}`, String(Date.now()))); +await mkdir(directory, { recursive: true }); +const executablePath = process.env.APP_EXECUTABLE || (process.platform === 'win32' ? path.join(root, 'dist/win-unpacked/LSAudio.exe') : process.platform === 'darwin' ? path.join(root, `dist/mac${process.arch === 'arm64' ? '-arm64' : ''}/LSAudio.app/Contents/MacOS/LSAudio`) : path.join(root, 'dist/linux-unpacked/lsaudio')); +const application = await _electron.launch({ executablePath, args: [`--user-data-dir=${path.join(directory, 'profile')}`, ...(process.platform === 'linux' ? ['--no-sandbox'] : [])], + env: { ...process.env, LSAUDIO_SMOKE: '1', ELECTRON_ENABLE_LOGGING: '1' }, timeout: 60000 }); +const failures = []; +try { + const page = await application.firstWindow(); page.on('pageerror', error => failures.push(error.message)); + await page.getByText('均衡器参数组', { exact: true }).waitFor({ timeout: 30000 }); + assert.equal(await page.evaluate(() => window.appInfo.version), process.env.EXPECTED_APP_VERSION || pkg.version); + const config = getDefaultConfig(16000); + const result = await page.evaluate(async ({ config, directory }) => { + const ipc = window.ipcRenderer; + const eq = await ipc.invoke('eq-draw', config.eq, { startFreq: 20, endFreq: 7800, startGain: -30, endGain: 30, yNum: 315 }); + const drc = await ipc.invoke('drc-draw', config.drc, { WidthX: 315, HeightY: 315, startXGain: -100, endXGain: 0, startYGain: -100, endYGain: 0 }); + const created = await ipc.invoke('create-project', { pathStr: directory, name: '中文 工程', configJson: config, isSave: true }); + const saved = await ipc.invoke('save-project', { manifestJson: created.data.manifestJson, configJson: config }); + const binary = await ipc.invoke('write-bin', directory, '中文 参数.bin', config); + const read = await ipc.invoke('read-bin', `${directory}/中文 参数.bin`); + const params = await ipc.invoke('player-set-params', JSON.stringify(config)); + const stop = await ipc.invoke('player-stop'); + const serial = await ipc.invoke('sp-get-list'); + localStorage.setItem('release-smoke-pref', 'preserved'); + return { eq: { ret: eq.ret, count: eq.points.length }, drc: { ret: drc.ret, count: drc.points.length }, created: created.code, saved: saved.code, binary, read, params, stop, serial }; + }, { config, directory }); + assert.equal(result.eq.ret, 0); assert.equal(result.eq.count, 840); + assert.equal(result.drc.ret, 0); assert.equal(result.drc.count, 315); + assert.equal(result.created, 0); assert.equal(result.saved, 0); assert.equal(result.binary, 0); + assert.equal(result.read.eq.filters.length, 10); assert.equal(result.read.drc.fs, 16000); + assert.equal(result.params.code, 0); assert.equal(result.stop.code, 0); + const project = JSON.parse(await readFile(path.join(directory, '中文 工程.lsaudio'), 'utf8')); + assert.equal(project.manifestJson.version, 2); + assert.deepEqual(project.configJson, config); + await page.screenshot({ path: path.join(directory, 'main-window.png') }); + assert.deepEqual(failures, []); + await writeFile(path.join(directory, 'result.json'), JSON.stringify({ platform: process.platform, arch: process.arch, version: pkg.version, passed: true, result }, null, 2)); + console.log('Packaged client smoke passed: UI, version, native libraries, Unicode project/bin, curves, DSP params and shutdown.'); +} finally { await application.close(); } From 7d5aa22852483a838769b3cdbc763a1c83f05560 Mon Sep 17 00:00:00 2001 From: xryu Date: Tue, 29 Sep 2026 20:03:05 +0800 Subject: [PATCH 2/6] feat: gate release drafts and publication on complete verified artifacts --- .github/workflows/client.yml | 3 + .github/workflows/publish.yml | 28 +++++++ .github/workflows/release.yml | 104 ++++++++++++++++++++++++++ CHANGELOG.md | 25 +++++++ package-lock.json | 106 ++++++++++----------------- package.json | 12 ++- public/icon512.png | Bin 0 -> 129575 bytes releases/acceptance/README.md | 23 ++++++ scripts/build-config.cjs | 4 +- scripts/release/artifacts.cjs | 54 ++++++++++++++ scripts/release/github.cjs | 96 ++++++++++++++++++++++++ scripts/release/publish-accepted.cjs | 13 ++++ scripts/release/version.cjs | 51 +++++++++++++ tests/app/installers.mjs | 41 +++++++++++ tests/release/gates.test.cjs | 40 ++++++++++ 15 files changed, 527 insertions(+), 73 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 public/icon512.png create mode 100644 releases/acceptance/README.md create mode 100644 scripts/release/artifacts.cjs create mode 100644 scripts/release/github.cjs create mode 100644 scripts/release/publish-accepted.cjs create mode 100644 scripts/release/version.cjs create mode 100644 tests/app/installers.mjs create mode 100644 tests/release/gates.test.cjs diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 89a20a5..85618a3 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -34,6 +34,8 @@ jobs: - if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libasound2-dev libfuse2 xvfb - run: npm ci + - run: npm run release:verify + - run: npm run test:release - run: npm run native:test - if: runner.os == 'Windows' run: npm run native:compare @@ -53,3 +55,4 @@ jobs: artifacts/app-smoke/**/main-window.png native/build/Testing/Temporary/*.log if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f6215ca --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,28 @@ +name: Publish accepted release +on: + push: + branches: [master] + paths: ['releases/acceptance/v*.json'] +permissions: + contents: read +concurrency: + group: publish-accepted-release + cancel-in-progress: false +jobs: + publish: + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + fetch-depth: 0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - run: npm ci --ignore-scripts + - name: Publish only newly accepted exact artifacts + run: node scripts/release/publish-accepted.cjs + env: + GH_TOKEN: ${{ github.token }} + BEFORE_SHA: ${{ github.event.before }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bdd22c5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,104 @@ +name: Release installers +on: + push: + tags: ['v*'] + branches: ['codex/multiplatform-release'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + target: win32-x64 + - runner: macos-15-intel + target: darwin-x64 + - runner: macos-15 + target: darwin-arm64 + - runner: ubuntu-24.04 + target: linux-x64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + env: + EXPECTED_TARGET: ${{ matrix.target }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + fetch-depth: 0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: npm + - run: node -e "require('assert').equal(process.platform+'-'+process.arch, process.env.EXPECTED_TARGET)" + - if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libasound2-dev libfuse2 xvfb + - run: npm ci + - run: npm run release:verify + env: + RELEASE_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || '' }} + RELEASE_MASTER: refs/remotes/origin/master + - run: npm run test:release + - run: npm run native:test + - if: runner.os == 'Windows' + run: npm run native:compare + - run: npm run test:audio + - run: npm run test:audio:integration + - run: npm run electron:build + - if: runner.os != 'Linux' + run: npm run test:app && npm run test:installers + - if: runner.os == 'Linux' + run: xvfb-run -a npm run test:app && xvfb-run -a npm run test:installers + - run: node scripts/release/artifacts.cjs record + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: installers-${{ matrix.target }} + path: artifacts/release/* + if-no-files-found: error + retention-days: 7 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: release-diagnostics-${{ matrix.target }} + path: | + artifacts/app-smoke/**/result.json + artifacts/app-smoke/**/main-window.png + artifacts/installer-smoke/result.json + native/build/Testing/Temporary/*.log + dist/builder-debug.yml + retention-days: 7 + draft: + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + fetch-depth: 0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - run: npm ci --ignore-scripts + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + pattern: installers-* + path: collected + merge-multiple: true + - run: node scripts/release/artifacts.cjs aggregate collected release-assets + - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + run: node scripts/release/github.cjs draft "$RELEASE_TAG" release-assets + env: + RELEASE_TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + - if: github.event_name == 'workflow_dispatch' || !startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-dry-run + path: release-assets/* + retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eada7fa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# 更新日志 + +## 1.1.4 + +### 功能变化 + +- 现有 Windows 客户端基线;多平台发布流程正在验证。 + +### 修复 + +- FFmpeg 资源固定版本和散列校验;统一试听与串口发送的 PCM 解码生命周期。 + +### 兼容性 + +- 保持应用标识、产品名、工程修订号和串口协议。 + +### 已知问题 + +- 本条目记录历史基线,不表示历史版本已完成当前发布验收。 +- 不提供在线更新;实机音频与兼容设备验收需单独记录。 + +### 平台状态 + +- Windows x64:历史开发基线。 +- macOS x64、macOS arm64、Linux x64:待多平台发布验收。 diff --git a/package-lock.json b/package-lock.json index bd518ef..3ff7f06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "playwright-core": "1.63.0", "prettier": "^3.7.4", "sass": "^1.66.1", + "semver": "7.7.4", "unplugin-icons": "^0.19.0", "unplugin-vue-components": "^0.28.0", "vite": "^7.3.0", @@ -122,6 +123,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", @@ -156,6 +167,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -464,6 +485,15 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@electron/get/node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -3265,19 +3295,6 @@ "node": ">=10" } }, - "node_modules/app-builder-lib/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/app-builder-lib/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -5404,19 +5421,6 @@ "node": ">=4" } }, - "node_modules/eslint-plugin-vue/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -6008,19 +6012,6 @@ "node": ">=10.0" } }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -7857,12 +7848,16 @@ "license": "BlueOak-1.0.0" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/semver-compare": { @@ -7975,19 +7970,6 @@ "node": ">=10" } }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/slice-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", @@ -8880,20 +8862,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/vue-eslint-parser/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/vue-router": { "version": "4.6.4", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", diff --git a/package.json b/package.json index 7150532..71405cc 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,12 @@ "electron:build": "npm run native:build && npm run build && electron-builder --config scripts/build-config.cjs --publish never", "postinstall": "electron-builder install-app-deps", "postuninstall": "electron-builder install-app-deps", - "test:app": "node tests/app/smoke.mjs" + "test:app": "node tests/app/smoke.mjs", + "release:prepare": "node scripts/release/version.cjs prepare", + "release:verify": "node scripts/release/version.cjs verify", + "release:tag": "node scripts/release/github.cjs tag", + "test:release": "node --test tests/release/*.test.cjs", + "test:installers": "node tests/app/installers.mjs" }, "dependencies": { "@echogarden/audio-io": "^0.4.2", @@ -51,6 +56,7 @@ "playwright-core": "1.63.0", "prettier": "^3.7.4", "sass": "^1.66.1", + "semver": "7.7.4", "unplugin-icons": "^0.19.0", "unplugin-vue-components": "^0.28.0", "vite": "^7.3.0", @@ -123,5 +129,7 @@ "createStartMenuShortcut": true, "shortcutName": "LSAudio" } - } + }, + "description": "LSAudio audio device tuning client", + "author": "ListenAI" } diff --git a/public/icon512.png b/public/icon512.png new file mode 100644 index 0000000000000000000000000000000000000000..c9f4b0a22e2bab6e89877f383fa9a9705bed281d GIT binary patch literal 129575 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 AwEzGB literal 0 HcmV?d00001 diff --git a/releases/acceptance/README.md b/releases/acceptance/README.md new file mode 100644 index 0000000..6da2043 --- /dev/null +++ b/releases/acceptance/README.md @@ -0,0 +1,23 @@ +# 发布验收记录 + +每个待发布版本使用 `vX.Y.Z[-beta.N].json`,通过审查后合并到 master。合并触发发布流程;没有完整记录的草稿不会发布。不要将模板保存为版本 JSON,不要把无法测试的项目填为通过。 + +```json +{ + "tag": "v1.2.0-beta.1", + "commit": "标签指向的完整提交 SHA", + "manifestSha256": "从草稿下载的 build-manifest.json 的 SHA-256", + "checks": { + "windows-install-upgrade-uninstall": {"status":"pending","evidence":"","tester":"","date":""}, + "macos-x64-dmg": {"status":"pending","evidence":"","tester":"","date":""}, + "macos-arm64-dmg": {"status":"pending","evidence":"","tester":"","date":""}, + "linux-appimage-deb": {"status":"pending","evidence":"","tester":"","date":""}, + "actual-playback": {"status":"pending","evidence":"","tester":"","date":""}, + "device-parameter-read-write": {"status":"pending","evidence":"","tester":"","date":""}, + "device-audio-send": {"status":"pending","evidence":"","tester":"","date":""}, + "installation-prompts-known-issues": {"status":"pending","evidence":"","tester":"","date":""} + } +} +``` + +通过时填写 `passed`、测试者、ISO 日期、具体日志/设备型号/操作结果。Windows 升级必须从提交 `a7f9381` 的真实 1.1.4 安装构建开始,验证工程和设置保留。模拟串口和静音输出测试只证明软件生命周期,不能代替实际试听、设备参数读写和音频发送。 diff --git a/scripts/build-config.cjs b/scripts/build-config.cjs index 712f941..5b792c7 100644 --- a/scripts/build-config.cjs +++ b/scripts/build-config.cjs @@ -10,11 +10,11 @@ config.asarUnpack = ['node_modules/**/*.node', 'node_modules/koffi/build/**']; config.artifactName = 'LSAudio-${version}-${os}-${arch}.${ext}'; config.win.target = [{ target: 'nsis', arch: ['x64'] }]; config.win.artifactName = 'LSAudio-${version}-windows-${arch}-setup.${ext}'; -config.mac = { icon: './public/icon.png', target: [{ target: 'dmg', arch: ['x64', 'arm64'] }], +config.mac = { icon: './public/icon512.png', target: [{ target: 'dmg', arch: [process.arch] }], artifactName: 'LSAudio-${version}-macos-${arch}.${ext}', hardenedRuntime: false, notarize: false, sign: path.join(__dirname, 'adhoc-sign.cjs'), binaries: ['Contents/Resources/ffmpeg/ffmpeg'], }; -config.linux = { icon: './public/icon.png', target: [{ target: 'AppImage', arch: ['x64'] }, { target: 'deb', arch: ['x64'] }], +config.linux = { icon: './public/icon512.png', target: [{ target: 'AppImage', arch: ['x64'] }, { target: 'deb', arch: ['x64'] }], artifactName: 'LSAudio-${version}-linux-${arch}.${ext}', category: 'AudioVideo', maintainer: 'ListenAI', synopsis: 'LSAudio audio device tuning client', }; diff --git a/scripts/release/artifacts.cjs b/scripts/release/artifacts.cjs new file mode 100644 index 0000000..ec417bd --- /dev/null +++ b/scripts/release/artifacts.cjs @@ -0,0 +1,54 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); +const { valid } = require('./version.cjs'); +const targets = ['win32-x64', 'darwin-x64', 'darwin-arm64', 'linux-x64']; +const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +function names(version, target) { + valid(version); + const prefix = `LSAudio-${version}`; + const map = { 'win32-x64': [`${prefix}-windows-x64-setup.exe`], 'darwin-x64': [`${prefix}-macos-x64.dmg`], 'darwin-arm64': [`${prefix}-macos-arm64.dmg`], 'linux-x64': [`${prefix}-linux-x64.AppImage`, `${prefix}-linux-x64.deb`] }; + if (!map[target]) throw new Error(`Unsupported target: ${target}`); + return map[target]; +} +function record(directory, output) { + const version = require('../../package.json').version; + const target = `${process.platform}-${process.arch}`; + fs.mkdirSync(output, { recursive: true }); + const files = names(version, target).map(name => { + const file = path.join(directory, name); + if (!fs.statSync(file).size) throw new Error(`Empty artifact: ${name}`); + fs.copyFileSync(file, path.join(output, name)); + return { name, sha256: hash(file), bytes: fs.statSync(file).size }; + }); + const ffmpeg = require('../../ffmpeg/manifest.json'); + const manifest = { schemaVersion: 1, version, target, commit: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), runId: process.env.GITHUB_RUN_ID || 'local', runAttempt: process.env.GITHUB_RUN_ATTEMPT || '1', + toolchain: { node: process.version, electron: require('electron/package.json').version, builder: require('electron-builder/package.json').version, cmake: execFileSync('cmake', ['--version'], { encoding: 'utf8' }).split('\n')[0] }, + ffmpeg: ffmpeg.targets[target], signing: process.platform === 'darwin' ? 'ad-hoc; not notarized' : 'unsigned', files }; + fs.writeFileSync(path.join(output, `manifest-${target}.json`), JSON.stringify(manifest, null, 2) + '\n'); +} +function aggregate(input, output, version, commit, runId) { + valid(version); + const manifests = targets.map(target => JSON.parse(fs.readFileSync(path.join(input, `manifest-${target}.json`)))); + for (const [i, m] of manifests.entries()) { + if (m.schemaVersion !== 1 || m.target !== targets[i] || m.version !== version || m.commit !== commit || m.runId !== runId) throw new Error('Build manifest provenance mismatch'); + if (JSON.stringify(m.files.map(f => f.name).sort()) !== JSON.stringify(names(version, m.target).sort())) throw new Error('Incomplete target assets'); + for (const file of m.files) { + const source = path.join(input, file.name); + if (hash(source) !== file.sha256 || fs.statSync(source).size !== file.bytes || file.bytes === 0) throw new Error(`Artifact checksum mismatch: ${file.name}`); + } + } + fs.mkdirSync(output, { recursive: true }); + const files = manifests.flatMap(m => m.files).sort((a,b) => a.name.localeCompare(b.name)); + for (const file of files) fs.copyFileSync(path.join(input, file.name), path.join(output, file.name)); + fs.writeFileSync(path.join(output, 'build-manifest.json'), JSON.stringify({ schemaVersion: 1, version, commit, runId, targets: manifests }, null, 2) + '\n'); + fs.writeFileSync(path.join(output, 'SHA256SUMS.txt'), [...files.map(f => `${f.sha256} ${f.name}`), `${hash(path.join(output, 'build-manifest.json'))} build-manifest.json`].join('\n') + '\n'); +} +if (require.main === module) { + const [command, input = 'dist', output = 'artifacts/release'] = process.argv.slice(2); + if (command === 'record') record(input, output); + else if (command === 'aggregate') aggregate(input, output, require('../../package.json').version, process.env.GITHUB_SHA, process.env.GITHUB_RUN_ID); + else throw new Error('Usage: artifacts.cjs record|aggregate input output'); +} +module.exports = { names, hash, aggregate, targets }; diff --git a/scripts/release/github.cjs b/scripts/release/github.cjs new file mode 100644 index 0000000..c88ee45 --- /dev/null +++ b/scripts/release/github.cjs @@ -0,0 +1,96 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { verify, notes } = require('./version.cjs'); +const { hash, names, targets } = require('./artifacts.cjs'); +const repository = process.env.GITHUB_REPOSITORY || 'LISTENAI/equalizer'; +function gh(...args) { return execFileSync('gh', args, { encoding: 'utf8' }).trim(); } +function api(route) { return JSON.parse(gh('api', `repos/${repository}/${route}`)); } +function release(tag) { + const result = spawnSync('gh', ['api', `repos/${repository}/releases/tags/${tag}`], { encoding: 'utf8' }); + if (result.status === 0) return JSON.parse(result.stdout); + if (/HTTP 404/.test(result.stderr)) return null; + throw new Error(result.stderr); +} +function assertDraft(existing, sha) { + if (existing && (!existing.draft || existing.target_commitish !== sha)) throw new Error('Only a draft of the same commit may be updated'); +} +function assets(directory, version, sha) { + const expected = [...targets.flatMap(t => names(version, t)), 'build-manifest.json', 'SHA256SUMS.txt'].sort(); + if (JSON.stringify(fs.readdirSync(directory).sort()) !== JSON.stringify(expected)) throw new Error('Release must contain exactly five installers, checksums and build manifest'); + const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'build-manifest.json'))); + if (manifest.version !== version || manifest.commit !== sha || manifest.targets?.length !== 4) throw new Error('Release manifest mismatch'); + const checksums = fs.readFileSync(path.join(directory, 'SHA256SUMS.txt'), 'utf8').trim().split('\n'); + if (checksums.length !== 6) throw new Error('Incomplete checksums'); + const checked = new Set(); + for (const line of checksums) { + const [digest, name] = line.split(' '); + if (!expected.includes(name) || name === 'SHA256SUMS.txt' || checked.has(name) || hash(path.join(directory, name)) !== digest) throw new Error('Invalid release checksum'); + checked.add(name); + } + return expected; +} +function draft(tag, directory) { + const version = verify({ tag, master: 'refs/remotes/origin/master' }); + const sha = gh('api', `repos/${repository}/git/ref/tags/${tag}`, '--jq', '.object.sha'); + const files = assets(directory, version, sha); + const previous = release(tag); assertDraft(previous, sha); + const notesFile = path.join(directory, '..', 'release-notes.md'); + fs.writeFileSync(notesFile, notes(version) + '\n\n签名状态:Windows/Linux 未签名;macOS 仅 ad-hoc 签名,未公证。无客户端在线更新。\n'); + if (!previous) gh('release', 'create', tag, '-R', repository, '--verify-tag', '--target', sha, '--draft', '--title', `LSAudio ${version}`, '--notes-file', notesFile, '--latest=false', ...(version.includes('-beta.') ? ['--prerelease'] : [])); + gh('release', 'upload', tag, '-R', repository, ...files.map(f => path.join(directory, f)), '--clobber'); + console.log(`Draft ${tag} assembled; acceptance is required before publication.`); +} +const acceptanceItems = ['windows-install-upgrade-uninstall', 'macos-x64-dmg', 'macos-arm64-dmg', 'linux-appimage-deb', 'actual-playback', 'device-parameter-read-write', 'device-audio-send', 'installation-prompts-known-issues']; +function checkAcceptance(report, tag, sha, digest) { + if (report.tag !== tag || report.commit !== sha || report.manifestSha256 !== digest) throw new Error('Acceptance does not match these exact artifacts'); + for (const key of acceptanceItems) { + const item = report.checks?.[key]; + if (item?.status !== 'passed' || !item.evidence?.trim() || !item.tester?.trim() || !Number.isFinite(Date.parse(item.date))) throw new Error(`Acceptance pending: ${key}`); + } +} +function publish(tag, directory, evidence) { + const existing = release(tag); + if (!existing?.draft) throw new Error('A draft is required; published releases are immutable'); + const sha = gh('api', `repos/${repository}/git/ref/tags/${tag}`, '--jq', '.object.sha'); + assertDraft(existing, sha); + const version = tag.slice(1); + const expected = assets(directory, version, sha); + if (JSON.stringify(existing.assets.map(a => a.name).sort()) !== JSON.stringify(expected)) throw new Error('Remote draft attachments differ'); + // Downloaded assets are the publication input, not a fresh local rebuild. + checkAcceptance(JSON.parse(fs.readFileSync(evidence)), tag, sha, hash(path.join(directory, 'build-manifest.json'))); + gh('release', 'edit', tag, '-R', repository, '--draft=false', `--prerelease=${version.includes('-beta.')}`, `--latest=${!version.includes('-beta.')}`); +} +function tag(version) { + if (verify() !== version) throw new Error('Version mismatch'); + const sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + const master = api('branches/master').commit.sha; + if (master !== sha) throw new Error('Only the checked master head may create a release tag'); + const checks = api(`commits/${sha}/check-runs?per_page=100`).check_runs; + for (const name of ['client (windows-latest, win32-x64)', 'client (macos-15-intel, darwin-x64)', 'client (macos-15, darwin-arm64)', 'client (ubuntu-24.04, linux-x64)']) { + if (!checks.some(c => c.name === name && c.status === 'completed' && c.conclusion === 'success' && c.app.slug === 'github-actions')) throw new Error(`Required check missing: ${name}`); + } + const prs = api(`commits/${sha}/pulls`); + let approved = false; + for (const pr of prs.filter(p => p.merged_at && p.base.ref === 'master' && p.merge_commit_sha === sha)) { + const files = api(`pulls/${pr.number}/files?per_page=100`); + const reviews = api(`pulls/${pr.number}/reviews?per_page=100`); + const latest = new Map(); for (const review of reviews) if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) latest.set(review.user.login, review.state); + if (files.some(f => f.filename === 'CHANGELOG.md') && files.some(f => f.filename === 'package.json') && [...latest.values()].includes('APPROVED') && ![...latest.values()].includes('CHANGES_REQUESTED')) approved = true; + } + if (!approved) throw new Error('A reviewed and merged version PR is required'); + const tagName = `v${version}`; + // GitHub CLI credentials (not the workflow GITHUB_TOKEN) make this push + // trigger the tag workflow. Never move an existing tag. + if (execFileSync('git', ['ls-remote', '--tags', 'github', `refs/tags/${tagName}`], { encoding: 'utf8' }).trim()) throw new Error('Tag already exists'); + execFileSync('git', ['tag', tagName, sha], { stdio: 'inherit' }); + execFileSync('git', ['push', 'github', `refs/tags/${tagName}`], { stdio: 'inherit' }); +} +if (require.main === module) { + const [command, value, directory, evidence] = process.argv.slice(2); + if (command === 'draft') draft(value, directory); + else if (command === 'publish') publish(value, directory, evidence); + else if (command === 'tag') tag(value); + else throw new Error('Usage: github.cjs draft | publish | tag '); +} +module.exports = { assertDraft, assets, checkAcceptance, acceptanceItems }; diff --git a/scripts/release/publish-accepted.cjs b/scripts/release/publish-accepted.cjs new file mode 100644 index 0000000..ca6c89b --- /dev/null +++ b/scripts/release/publish-accepted.cjs @@ -0,0 +1,13 @@ +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const { valid } = require('./version.cjs'); +const files = execFileSync('git', ['diff', '--name-only', '--diff-filter=AM', process.env.BEFORE_SHA, 'HEAD', '--', 'releases/acceptance'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean); +for (const file of files) { + const report = JSON.parse(fs.readFileSync(file)); + valid(report.tag?.slice(1)); + if (file !== `releases/acceptance/${report.tag}.json`) throw new Error('Acceptance file/tag mismatch'); + const directory = path.resolve('downloaded', report.tag); fs.mkdirSync(directory, { recursive: true }); + execFileSync('gh', ['release', 'download', report.tag, '-R', process.env.GITHUB_REPOSITORY, '--dir', directory], { stdio: 'inherit' }); + execFileSync(process.execPath, ['scripts/release/github.cjs', 'publish', report.tag, directory, file], { stdio: 'inherit' }); +} diff --git a/scripts/release/version.cjs b/scripts/release/version.cjs new file mode 100644 index 0000000..725aeef --- /dev/null +++ b/scripts/release/version.cjs @@ -0,0 +1,51 @@ +const fs = require('node:fs'); +const { execFileSync } = require('node:child_process'); +const semver = require('semver'); +const pattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-beta\.([1-9]\d*))?$/; +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); +function valid(version) { if (!pattern.test(version)) throw new Error('Version must be X.Y.Z or X.Y.Z-beta.N'); return version; } +function notes(version, text = fs.readFileSync('CHANGELOG.md', 'utf8')) { + const section = text.split(`## ${version}\n`)[1]?.split('\n## ')[0]; + if (!section || /待填写|TODO/.test(section)) throw new Error('Missing or unfinished release notes'); + for (const heading of ['功能变化', '修复', '兼容性', '已知问题', '平台状态']) if (!section.includes(`### ${heading}`)) throw new Error(`Missing changelog section: ${heading}`); + return section.trim(); +} +function verify({ tag, master = 'refs/remotes/github/master' } = {}) { + const pkg = JSON.parse(fs.readFileSync('package.json')); + const lock = JSON.parse(fs.readFileSync('package-lock.json')); + valid(pkg.version); + if (lock.version !== pkg.version || lock.packages[''].version !== pkg.version) throw new Error('Lockfile version mismatch'); + notes(pkg.version); + if (tag) { + if (tag !== `v${pkg.version}`) throw new Error('Tag version mismatch'); + const sha = git('rev-parse', `${tag}^{commit}`); + if (sha !== git('rev-parse', 'HEAD')) throw new Error('Tag does not point to checked-out commit'); + git('merge-base', '--is-ancestor', sha, master); + } + return pkg.version; +} +function prepare(version) { + valid(version); + if (git('status', '--porcelain')) throw new Error('Prepare requires a clean checkout'); + const pkg = JSON.parse(fs.readFileSync('package.json')); + if (!semver.gt(version, pkg.version)) throw new Error('Version must increase'); + if (git('tag', '--list', `v${version}`)) throw new Error('Version tag already exists'); + const lock = JSON.parse(fs.readFileSync('package-lock.json')); + const old = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '# 更新日志\n'; + if (old.includes(`## ${version}\n`)) throw new Error('Changelog version already exists'); + pkg.version = lock.version = lock.packages[''].version = version; + const entry = `## ${version}\n\n${['功能变化', '修复', '兼容性', '已知问题', '平台状态'].map(h => `### ${h}\n\n- 待填写`).join('\n\n')}\n\n`; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + fs.writeFileSync('package-lock.json', JSON.stringify(lock, null, 2) + '\n'); + fs.writeFileSync('CHANGELOG.md', '# 更新日志\n\n' + entry + old.replace(/^# 更新日志\s*/, '')); + console.log(`Prepared ${version}; complete CHANGELOG.md and open a version PR. No tag was created.`); +} +if (require.main === module) { + try { + const [command, version] = process.argv.slice(2); + if (command === 'prepare') prepare(version); + else if (command === 'verify') console.log(`Verified ${verify({ tag: process.env.RELEASE_TAG, master: process.env.RELEASE_MASTER })}`); + else throw new Error('Usage: version.cjs prepare | verify'); + } catch (error) { console.error(error.message); process.exitCode = 1; } +} +module.exports = { valid, notes, verify, prepare }; diff --git a/tests/app/installers.mjs b/tests/app/installers.mjs new file mode 100644 index 0000000..d677ae2 --- /dev/null +++ b/tests/app/installers.mjs @@ -0,0 +1,41 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, existsSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { names } = require('../../scripts/release/artifacts.cjs'); +const { version } = require('../../package.json'); +if (!process.env.CI) throw new Error('Installer tests run on disposable CI runners only'); +const root = process.cwd(); +const output = path.resolve('artifacts/installer-smoke'); mkdirSync(output, { recursive: true }); +const run = (cmd, args, options = {}) => execFileSync(cmd, args, { stdio: 'inherit', ...options }); +const smoke = exe => run(process.execPath, ['tests/app/smoke.mjs'], { env: { ...process.env, APP_EXECUTABLE: exe } }); +const files = names(version, `${process.platform}-${process.arch}`).map(n => path.resolve('dist', n)); +if (process.platform === 'darwin') { + const mount = path.join(output, 'mounted'); mkdirSync(mount, { recursive: true }); + run('hdiutil', ['attach', files[0], '-nobrowse', '-readonly', '-mountpoint', mount]); + try { + const app = path.join(output, 'LSAudio.app'); + run('ditto', [path.join(mount, 'LSAudio.app'), app]); + run('codesign', ['--verify', '--deep', '--strict', app]); + smoke(path.join(app, 'Contents/MacOS/LSAudio')); + } finally { run('hdiutil', ['detach', mount]); } +} else if (process.platform === 'linux') { + // Extraction avoids requiring FUSE in containers; execute the shipped AppRun. + run('chmod', ['+x', files[0]]); + run(files[0], ['--appimage-extract'], { cwd: output }); + smoke(path.join(output, 'squashfs-root/AppRun')); + run('sudo', ['apt-get', 'install', '-y', files[1]]); + try { smoke('/opt/LSAudio/lsaudio'); } + finally { run('sudo', ['dpkg', '--remove', 'lsaudio']); } +} else { + const installed = path.join(process.env.RUNNER_TEMP, 'LSAudio-installer-test'); + run(files[0], ['/S', `/D=${installed}`]); + if (!existsSync(path.join(installed, 'LSAudio.exe'))) throw new Error('NSIS installation failed'); + smoke(path.join(installed, 'LSAudio.exe')); + const uninstaller = path.join(installed, 'Uninstall LSAudio.exe'); + run(uninstaller, ['/S', `_?=${installed}`]); + // NSIS deletes the application before returning in _?= mode. + if (existsSync(path.join(installed, 'LSAudio.exe'))) throw new Error('NSIS uninstallation failed'); +} +writeFileSync(path.join(output, 'result.json'), JSON.stringify({ version, target: `${process.platform}-${process.arch}`, passed: true, scope: 'install/mount, launch actual artifact, project/bin and curves, uninstall/unmount; physical hardware and upgrade acceptance tracked separately' }, null, 2)); diff --git a/tests/release/gates.test.cjs b/tests/release/gates.test.cjs new file mode 100644 index 0000000..21727ef --- /dev/null +++ b/tests/release/gates.test.cjs @@ -0,0 +1,40 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { valid, notes } = require('../../scripts/release/version.cjs'); +const { aggregate, hash, names, targets } = require('../../scripts/release/artifacts.cjs'); +const { assets, assertDraft, checkAcceptance, acceptanceItems } = require('../../scripts/release/github.cjs'); +test('only canonical stable and beta versions are accepted', () => { + for (const v of ['1.2.0', '1.2.0-beta.1']) assert.equal(valid(v), v); + for (const v of ['v1.2.0', '01.2.0', '1.2', '1.2.0-beta.0', '1.2.0-rc.1', '1.2.0+dirty']) assert.throws(() => valid(v)); + assert.throws(() => notes('1.2.0', '## 1.2.0\nTODO')); +}); +test('all four exact-commit builds and all five nonempty installers are required', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lsaudio-release-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const input = path.join(root, 'input'), output = path.join(root, 'output'); fs.mkdirSync(input); + const version = '1.2.0-beta.1', commit = 'a'.repeat(40), runId = '12'; + const records = targets.map(target => { + const files = names(version, target).map(name => { const file = path.join(input, name); fs.writeFileSync(file, `fixture:${target}`); return { name, sha256: hash(file), bytes: fs.statSync(file).size }; }); + return { schemaVersion: 1, target, version, commit, runId, files }; + }); + const save = () => records.forEach(m => fs.writeFileSync(path.join(input, `manifest-${m.target}.json`), JSON.stringify(m))); save(); + aggregate(input, output, version, commit, runId); assert.equal(assets(output, version, commit).length, 7); + records[0].commit = 'b'.repeat(40); save(); assert.throws(() => aggregate(input, output, version, commit, runId), /provenance/); + records[0].commit = commit; save(); + fs.appendFileSync(path.join(input, records[0].files[0].name), 'corrupt'); assert.throws(() => aggregate(input, output, version, commit, runId), /checksum/); + fs.unlinkSync(path.join(input, 'manifest-darwin-arm64.json')); assert.throws(() => aggregate(input, output, version, commit, runId)); + fs.writeFileSync(path.join(output, 'unexpected.exe'), 'extra'); assert.throws(() => assets(output, version, commit), /exactly/); +}); +test('published releases and drafts for another commit are immutable', () => { + assert.doesNotThrow(() => assertDraft({ draft: true, target_commitish: 'a' }, 'a')); + assert.throws(() => assertDraft({ draft: false, target_commitish: 'a' }, 'a')); + assert.throws(() => assertDraft({ draft: true, target_commitish: 'b' }, 'a')); +}); +test('acceptance must refer to exact manifest and every real hardware result', () => { + const report = { tag: 'v1.2.0-beta.1', commit: 'sha', manifestSha256: 'hash', checks: Object.fromEntries(acceptanceItems.map(k => [k, { status: 'passed', evidence: 'test log', tester: 'tester', date: '2026-09-29' }])) }; + assert.doesNotThrow(() => checkAcceptance(report, report.tag, 'sha', 'hash')); + assert.throws(() => checkAcceptance(report, report.tag, 'sha', 'other')); + report.checks['device-audio-send'].status = 'pending'; assert.throws(() => checkAcceptance(report, report.tag, 'sha', 'hash'), /device-audio-send/); +}); From 91fe9b422ed8bc08b65276950cf299cc32ec789d Mon Sep 17 00:00:00 2001 From: xryu Date: Tue, 29 Sep 2026 20:11:28 +0800 Subject: [PATCH 3/6] test: exercise installers upgrades and native startup resource failures --- .github/workflows/release.yml | 9 +++++ README.md | 28 +++++++++----- docs/RELEASING.md | 51 +++++++++++++++++++++++++ package.json | 39 ------------------- scripts/build-config.cjs | 2 +- scripts/release/version.cjs | 3 +- src/main/audio/resources.mjs | 14 +++++++ src/main/index.js | 2 + src/main/ipcMain.js | 6 +-- tests/app/installers.mjs | 15 ++++++-- tests/app/smoke.mjs | 9 ++++- tests/app/upgrade-seed.mjs | 17 +++++++++ tests/release/native-resources.test.cjs | 17 +++++++++ tests/release/version.test.cjs | 33 ++++++++++++++++ 14 files changed, 186 insertions(+), 59 deletions(-) create mode 100644 docs/RELEASING.md create mode 100644 src/main/audio/resources.mjs create mode 100644 tests/app/upgrade-seed.mjs create mode 100644 tests/release/native-resources.test.cjs create mode 100644 tests/release/version.test.cjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bdd22c5..abc1024 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,15 @@ jobs: - run: npm run test:audio - run: npm run test:audio:integration - run: npm run electron:build + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + if: runner.os == 'Windows' + with: + ref: a7f9381 + path: .cache/upgrade-baseline + - name: Build original 1.1.4 upgrade baseline + if: runner.os == 'Windows' + working-directory: .cache/upgrade-baseline + run: npm ci && npm run electron:build -- --publish never - if: runner.os != 'Linux' run: npm run test:app && npm run test:installers - if: runner.os == 'Linux' diff --git a/README.md b/README.md index 3d48d6d..a444af3 100644 --- a/README.md +++ b/README.md @@ -45,19 +45,27 @@ LSAudio 将音效参数编辑、曲线展示、音频试听和设备通信集中 | 组件 | Windows x64 | macOS x64 / arm64 | Linux x64 | | --- | --- | --- | --- | -| 桌面客户端 | 当前运行与打包目标 | 适配中 | 适配中 | +| 桌面客户端 | 待发布验收 | 待发布验收 | 待发布验收 | | FFmpeg 与独立音频服务 | CI 验证 | CI 验证 | CI 验证 | -| C 算法替代实现 | 已通过原 DLL 对照测试 | 待运行验证 | 待运行验证 | +| C 算法替代实现 | 原 DLL 现场对照 | 原生 runner 回归 | 原生 runner 回归 | -**当前请在 Windows x64 上运行完整客户端。** 音频 CI 覆盖资源校验、解码、流式处理与资源打包,不代表完整应用已经支持所有平台。桌面运行时仍使用原 Windows DLL,C 替代实现的接入工作正在推进。 +客户端默认使用项目维护的三个 C 动态库,原厂 DLL 仅用于 Windows 对照测试,不随安装包分发。四个平台的发布必须全部通过 CI 和验收;单项测试成功不代表正式支持。 ## 快速开始 -当前提供源码运行与本地打包方式。 +安装包入口:[GitHub Releases](https://github.com/LISTENAI/equalizer/releases)(需要仓库访问权限)。首个预览版计划为 `1.2.0-beta.1`,验收完成前不会标为已发布。源码运行与本地打包仍可使用。 + +### 安装应用 + +- Windows x64:下载 `-windows-x64-setup.exe`,运行 NSIS 安装程序。未签名版本可能出现 SmartScreen 提示,请核对来源和 SHA-256。 +- macOS:按 Intel / Apple Silicon 选择 x64 / arm64 DMG,挂载后将 LSAudio 拖入应用程序。仅使用 ad-hoc 签名,未公证;如系统阻止启动,在核验来源后通过系统设置的“隐私与安全性”允许打开。 +- Linux x64:AppImage 执行 `chmod +x` 后运行;Debian/Ubuntu 可用 `sudo apt install ./LSAudio--linux-x64.deb` 安装。串口访问需要系统授予相应设备权限。 + +每次发布提供 `SHA256SUMS.txt` 和构建清单。应用内置 FFmpeg,无需单独安装;升级通过下载安装包完成,不提供在线更新。 ### 开发环境 -- Windows x64。 +- Windows x64、macOS x64/arm64 或 Linux x64;正式支持状态见上表。 - Git、Node.js **22.12 或更高的 22.x 版本**及 npm,与音频 CI 使用的 Node.js 主版本一致。 - 首次安装依赖与准备 FFmpeg 时需要联网。 - 连接设备时,需要兼容固件和可用的串口;设备采样率应与调参界面保持一致。 @@ -69,9 +77,9 @@ npm ci npm run dev ``` -开发启动前会自动准备并校验本机的 FFmpeg。正式应用随包内置 FFmpeg,无需用户另行安装。离线准备、本地可执行文件覆盖和目标架构选择见 [FFmpeg 维护说明](ffmpeg/README.md)。 +开发启动前会自动编译本机 C 库并准备、校验 FFmpeg。正式应用随包内置 FFmpeg,无需用户另行安装。离线准备、本地可执行文件覆盖和目标架构选择见 [FFmpeg 维护说明](ffmpeg/README.md)。 -如需编译 C 算法替代实现,另需 **CMake 3.20+** 与 C11 编译器;Windows 推荐安装 Visual Studio 2022 的“使用 C++ 的桌面开发”组件。完整步骤见 [Native 开发指南](native/README.md)。 +开发与打包需要 **CMake 3.20+** 与 C11 编译器;Windows 推荐安装 Visual Studio 2022 的“使用 C++ 的桌面开发”组件。完整步骤见 [Native 开发指南](native/README.md)。 ### 第一次调音 @@ -88,11 +96,11 @@ npm run dev # 编译 Electron 主进程、预加载脚本与前端 npm run build -# 在 Windows x64 上构建客户端安装包 +# 在目标平台的本机架构上构建客户端安装包 npm run electron:build ``` -Windows 默认生成 NSIS 安装程序,产物位置以构建日志为准。打包时会按目标平台准备 FFmpeg,并附带对应的许可证与来源说明。 +产物位于 `dist/`:Windows 为 NSIS,macOS 为 DMG,Linux 为 AppImage 和 deb。打包内置 FFmpeg、C 库及第三方许可资料。版本、草稿、验收与发布操作见 [发布维护指南](docs/RELEASING.md)。 ## 开发与测试 @@ -130,7 +138,7 @@ C 模块包括参数文件与曲线计算、绘图结果包装、实际音效处 - **提交改动**:尽量围绕一个问题组织提交,说明行为变化,并运行与改动相关的测试。界面改动可附截图;协议或算法改动请补充回归用例。 - **维护资源**:FFmpeg 版本更新应包含来源、散列与各平台验证结果;原 DLL 对照基准应从原库重新采集并审查差异。 -当前跨平台工作的重点是接入 C 算法替代实现,并完成 macOS/Linux 完整客户端的运行与打包验证。 +发布门槛包含四平台回归和实际安装产物测试;实际试听与兼容设备验收结果单独记录。参见 [更新日志](CHANGELOG.md) 和 [发布验收记录](releases/acceptance/README.md)。 ## 许可证与第三方组件 diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..5469965 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,51 @@ +# LSAudio 发布维护 + +仓库保持私有;Release 仅供有仓库权限的成员下载。产品标识 `listenai.com`、名称 `LSAudio` 和 Electron 用户数据目录保持不变。不接入在线更新。 + +## 工作流 + +1. 功能 PR:四个平台原生 runner 执行 C 回归、音频测试、应用目录启动测试。Windows 额外与原 DLL 现场对照。测试失败不可通过放宽数值容差绕过。 +2. 版本 PR:在干净工作区运行 `npm run release:prepare -- 1.2.0-beta.1`,填写中文 `CHANGELOG.md`,运行 `npm run release:verify`。提交 package.json、package-lock.json 和更新日志。请维护者审查并合并到 master。 +3. 合并检查通过后,发布执行者(本任务已授权的代理或维护者)检出最新 master,运行 `npm run release:tag -- 1.2.0-beta.1`。工具核对 master HEAD、四平台检查、已审查版本 PR,然后创建并推送不可移动的 `v1.2.0-beta.1` 标签到 **github** 远端。保留原 origin;不向 origin 发布。 +4. 标签触发 `Release installers`,重新测试、构建和验证全部五个安装器。构建使用 `--publish never`、只读 token。全部目标成功后,汇总任务才创建草稿;附带五个安装器、`SHA256SUMS.txt`、`build-manifest.json`。 +5. 从草稿下载实际附件,完成[验收记录](../releases/acceptance/README.md)。验收绑定标签提交和构建清单散列;实机未验证必须保持 pending。审查验收记录并合并到 master 后,`Publish accepted release` 自动发布草稿。beta 为 prerelease 且不设 Latest;稳定版设 Latest。 + +初次版本为 `1.2.0-beta.1`。完成预览版验收后,用同一流程准备 `1.2.0`,不复用 beta 标签。 + +标签步骤使用 GitHub CLI 登录凭据对应的 Git 推送,不使用 Actions 的 GITHUB_TOKEN 推送标签,避免 GitHub 的递归触发保护导致发布工作流不启动。代理会在版本 PR 审查、合并、检查门槛满足后执行,不需要再次确认标签/发布权限。 + +## 版本边界 + +- `package.json` 是产品版本唯一来源,lockfile 根版本必须一致。界面从 Electron 读取版本;安装器和产物名由 builder 注入版本。 +- `.lsaudio` 中的 `manifestJson.version` 是工程修订计数,不随产品发版重置。 +- C ABI/模块版本在 native 中管理;FFmpeg 的实际版本按目标记录在 `ffmpeg/manifest.json`,不能把资源合集的 b6.1.1 当成全部平台实际版本。 +- 接受标签 `vX.Y.Z` 或 `vX.Y.Z-beta.N`,保留历史标签。不覆盖已发布附件,不移动标签。修复用新版本。 + +## 平台和产物 + +| 原生 runner | 文件后缀 | 签名状态 | +| --- | --- | --- | +| Windows x64 | `-windows-x64-setup.exe` | 未签名 | +| macOS x64 | `-macos-x64.dmg` | ad-hoc;无 Developer ID、无公证 | +| macOS arm64 | `-macos-arm64.dmg` | ad-hoc;无 Developer ID、无公证 | +| Linux x64 | `-linux-x64.AppImage`、`-linux-x64.deb` | 未签名 | + +文件名前缀为 `LSAudio-`。构建清单包括提交、Actions run、Node/Electron/builder/CMake、FFmpeg 版本来源、散列、签名状态。macOS 签名会改变 dylib 字节,签名后重新生成 native 清单,再签外层应用并验证。 + +## 演练与排错 + +在 Actions 手动运行 `Release installers`(可选择分支),仅产生构建产物,不创建标签或 Release。本实现分支暂有 push 演练触发,功能合并后可移除该分支过滤项。 + +本机 `npm run electron:build` 使用本机平台/架构,C 库禁止跨宿主误用;输出在 dist。`npm run electron:build -- --dir` 后运行 `npm run test:app`。`test:installers` 仅允许在可丢弃的 CI runner 执行,避免改动开发机安装。Linux 使用 `xvfb-run -a`。 + +任一目标失败均不创建 Release。保留失败日志/截图 7 天;Actions 存储配额不足会明确失败,不能跳过上传宣称发布验证完成。资源缺失、架构错误在启动时给出明确提示。 + +同标签重跑仅允许同提交的草稿,重新汇总完整附件。草稿附件变动意味着必须重新取得验收散列;已发布版本一律拒绝更新。 + +安装器测试涵盖 NSIS 安装/卸载、DMG 挂载复制后启动、AppImage 提取运行和 deb 安装后运行。AppImage 在容器中通过自解压方式启动,FUSE 桌面使用和系统安装提示仍须验收。Windows 真实 1.1.4 升级基线、听感、设备参数和串口音频收发不可用单元测试代替。 + +## 首次发布跟踪 + +- 自动测试结果以功能 PR 的最新提交 CI 为准,不以旧提交成功状态放行。 +- 尚未取得物理设备和完整安装验收结果前,平台表保持“待发布验收”。 +- 首次发布需要仓库成员审查合并版本 PR;代理不会自行冒充审查者。 diff --git a/package.json b/package.json index 71405cc..a227642 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,6 @@ "vue-router": "^4.2.0" }, "build": { - "afterPack": "ffmpeg/after-pack.cjs", "productName": "LSAudio", "appId": "listenai.com", "copyright": "listenai", @@ -74,50 +73,12 @@ "zh-CN", "en-US" ], - "files": [ - "out/**", - "package.json", - "!dist/**", - "!dist_electron/**", - "!ffmpeg/**", - "!dlls/**", - "!src/**", - "!public/**", - "!*.pdf", - "!**/*.map", - "!node_modules/koffi/{doc,src,vendor}/**", - "!node_modules/koffi/lib/native/**", - "!node_modules/koffi/build/koffi/{darwin_*,freebsd_*,linux_*,musl_*,openbsd_*,win32_ia32,win32_arm64}/**", - "!node_modules/@serialport/bindings-cpp/src/**", - "!node_modules/@serialport/bindings-cpp/node_modules/.bin/**", - "!node_modules/@serialport/bindings-cpp/node_modules/node-addon-api/**", - "!node_modules/@serialport/bindings-cpp/prebuilds/{android-*,darwin-*,linux-*,win32-ia32}/**", - "!node_modules/@echogarden/audio-io/addons/bin/{linux-*,macos-*,windows-arm64-*}" - ], - "extraResources": [ - { - "from": "./dlls/x64", - "to": "../dlls/x64", - "filter": [ - "**/*.dll" - ] - } - ], "win": { "icon": "./public/LSAudio256.ico", "target": [ "nsis" ] }, - "mac": { - "binaries": [ - "Resources/ffmpeg/ffmpeg" - ], - "target": { - "target": "dir", - "arch": "arm64" - } - }, "nsis": { "oneClick": false, "perMachine": true, diff --git a/scripts/build-config.cjs b/scripts/build-config.cjs index 5b792c7..710737a 100644 --- a/scripts/build-config.cjs +++ b/scripts/build-config.cjs @@ -15,7 +15,7 @@ config.mac = { icon: './public/icon512.png', target: [{ target: 'dmg', arch: [pr sign: path.join(__dirname, 'adhoc-sign.cjs'), binaries: ['Contents/Resources/ffmpeg/ffmpeg'], }; config.linux = { icon: './public/icon512.png', target: [{ target: 'AppImage', arch: ['x64'] }, { target: 'deb', arch: ['x64'] }], - artifactName: 'LSAudio-${version}-linux-${arch}.${ext}', category: 'AudioVideo', + artifactName: 'LSAudio-${version}-linux-x64.${ext}', category: 'AudioVideo', maintainer: 'ListenAI', synopsis: 'LSAudio audio device tuning client', }; module.exports = config; diff --git a/scripts/release/version.cjs b/scripts/release/version.cjs index 725aeef..581b396 100644 --- a/scripts/release/version.cjs +++ b/scripts/release/version.cjs @@ -5,6 +5,7 @@ const pattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-beta\.([1-9]\d*))? const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); function valid(version) { if (!pattern.test(version)) throw new Error('Version must be X.Y.Z or X.Y.Z-beta.N'); return version; } function notes(version, text = fs.readFileSync('CHANGELOG.md', 'utf8')) { + text = text.replace(/\r\n/g, '\n'); const section = text.split(`## ${version}\n`)[1]?.split('\n## ')[0]; if (!section || /待填写|TODO/.test(section)) throw new Error('Missing or unfinished release notes'); for (const heading of ['功能变化', '修复', '兼容性', '已知问题', '平台状态']) if (!section.includes(`### ${heading}`)) throw new Error(`Missing changelog section: ${heading}`); @@ -31,7 +32,7 @@ function prepare(version) { if (!semver.gt(version, pkg.version)) throw new Error('Version must increase'); if (git('tag', '--list', `v${version}`)) throw new Error('Version tag already exists'); const lock = JSON.parse(fs.readFileSync('package-lock.json')); - const old = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '# 更新日志\n'; + const old = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8').replace(/\r\n/g, '\n') : '# 更新日志\n'; if (old.includes(`## ${version}\n`)) throw new Error('Changelog version already exists'); pkg.version = lock.version = lock.packages[''].version = version; const entry = `## ${version}\n\n${['功能变化', '修复', '兼容性', '已知问题', '平台状态'].map(h => `### ${h}\n\n- 待填写`).join('\n\n')}\n\n`; diff --git a/src/main/audio/resources.mjs b/src/main/audio/resources.mjs new file mode 100644 index 0000000..b745320 --- /dev/null +++ b/src/main/audio/resources.mjs @@ -0,0 +1,14 @@ +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import manifest from '../../../ffmpeg/manifest.json' with { type: 'json' }; +import { resolveFfmpegPath } from './ffmpeg-path.mjs'; +export function verifyFfmpegResource(options) { + const file = resolveFfmpegPath(options); + try { + fs.accessSync(file, process.platform === 'win32' ? fs.constants.R_OK : fs.constants.X_OK); + const text = execFileSync(file, ['-version'], { encoding: 'utf8', windowsHide: true, timeout: 10000 }); + const expected = manifest.targets[`${process.platform}-${process.arch}`].version; + if (!(options.override && !options.packaged) && !text.startsWith(`ffmpeg version ${expected} `) && !text.startsWith(`ffmpeg version ${expected}-`)) throw new Error(`期望版本 ${expected}`); + } catch (error) { throw new Error(`FFmpeg 资源缺失、架构错误或无法运行,请重新准备资源或安装客户端。${error.message}`); } + return file; +} diff --git a/src/main/index.js b/src/main/index.js index 28a42c9..f2a0085 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1,6 +1,7 @@ import { app, BrowserWindow, dialog } from 'electron'; import path from 'node:path'; import { nativeDirectory, verifyNativeResources } from '../libs/native-runtime.mjs'; +import { verifyFfmpegResource } from './audio/resources.mjs'; let runtime; let shutdownComplete = false, shutdown; @@ -36,6 +37,7 @@ function startupFailure(error) { } app.whenReady().then(async () => { verifyNativeResources(nativeDirectory({ packaged: app.isPackaged, appRoot: app.getAppPath(), resourcesPath: process.resourcesPath })); + verifyFfmpegResource({ packaged: app.isPackaged, appRoot: app.getAppPath(), resourcesPath: process.resourcesPath, override: process.env.FFMPEG_PATH }); // Dynamic imports keep native-load errors inside the visible startup handler. const [ipc, player, serial, audio] = await Promise.all([import('./ipcMain'), import('./player'), import('./serialport'), import('./audioDecoder')]); runtime = { IpcMainHandle: ipc.IpcMainHandle, stopPlayer: player.stopPlayer, stopSerialAudio: serial.stopSerialAudio, closePcmSources: audio.closePcmSources }; diff --git a/src/main/ipcMain.js b/src/main/ipcMain.js index fe00221..dc69534 100644 --- a/src/main/ipcMain.js +++ b/src/main/ipcMain.js @@ -11,12 +11,10 @@ let registered = false; function IpcMainHandle(window) { mainWindow = window; window.on('maximize', () => { - let focusWindow = BrowserWindow.getFocusedWindow(); - focusWindow.webContents.send('windowChange', { isMaximized: focusWindow.isMaximized() }); + window.webContents.send('windowChange', { isMaximized: window.isMaximized() }); }); window.on('unmaximize', () => { - let focusWindow = BrowserWindow.getFocusedWindow(); - focusWindow.webContents.send('windowChange', { isMaximized: focusWindow.isMaximized() }); + window.webContents.send('windowChange', { isMaximized: window.isMaximized() }); }); window.on('close', () => { if (mainWindow === window) mainWindow = null; diff --git a/tests/app/installers.mjs b/tests/app/installers.mjs index d677ae2..9faa3b9 100644 --- a/tests/app/installers.mjs +++ b/tests/app/installers.mjs @@ -1,5 +1,5 @@ import { execFileSync, spawnSync } from 'node:child_process'; -import { mkdirSync, existsSync, writeFileSync } from 'node:fs'; +import { mkdirSync, existsSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); @@ -30,12 +30,21 @@ if (process.platform === 'darwin') { finally { run('sudo', ['dpkg', '--remove', 'lsaudio']); } } else { const installed = path.join(process.env.RUNNER_TEMP, 'LSAudio-installer-test'); + const baseline = path.resolve('.cache/upgrade-baseline/dist'); + const installer = readdirSync(baseline).find(name => name.endsWith('.exe') && name.includes('1.1.4')); + if (!installer) throw new Error('Actual 1.1.4 baseline installer required'); + run(path.join(baseline, installer), ['/S', `/D=${installed}`]); + const profile = path.join(output, 'upgrade-profile'), projects = path.join(output, 'upgrade-projects'); + run(process.execPath, ['tests/app/upgrade-seed.mjs'], { env: { ...process.env, APP_EXECUTABLE: path.join(installed, 'LSAudio.exe'), SMOKE_PROFILE: profile, UPGRADE_PROJECT_DIR: projects } }); + const projectFile = path.join(projects, '升级 基线.lsaudio'); + const before = readFileSync(projectFile, 'utf8'); run(files[0], ['/S', `/D=${installed}`]); if (!existsSync(path.join(installed, 'LSAudio.exe'))) throw new Error('NSIS installation failed'); - smoke(path.join(installed, 'LSAudio.exe')); + run(process.execPath, ['tests/app/smoke.mjs'], { env: { ...process.env, APP_EXECUTABLE: path.join(installed, 'LSAudio.exe'), SMOKE_PROFILE: profile, SMOKE_UPGRADE: '1', SMOKE_UPGRADE_PROJECT: projectFile } }); + if (readFileSync(projectFile, 'utf8') !== before) throw new Error('Upgrade modified baseline project'); const uninstaller = path.join(installed, 'Uninstall LSAudio.exe'); run(uninstaller, ['/S', `_?=${installed}`]); // NSIS deletes the application before returning in _?= mode. if (existsSync(path.join(installed, 'LSAudio.exe'))) throw new Error('NSIS uninstallation failed'); } -writeFileSync(path.join(output, 'result.json'), JSON.stringify({ version, target: `${process.platform}-${process.arch}`, passed: true, scope: 'install/mount, launch actual artifact, project/bin and curves, uninstall/unmount; physical hardware and upgrade acceptance tracked separately' }, null, 2)); +writeFileSync(path.join(output, 'result.json'), JSON.stringify({ version, target: `${process.platform}-${process.arch}`, passed: true, scope: 'install/mount, launch actual artifact, project/bin and curves, uninstall/unmount; Windows actual 1.1.4 upgrade and preferences; physical hardware tracked separately' }, null, 2)); diff --git a/tests/app/smoke.mjs b/tests/app/smoke.mjs index 0a42142..f5c732b 100644 --- a/tests/app/smoke.mjs +++ b/tests/app/smoke.mjs @@ -9,13 +9,14 @@ const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); const directory = path.resolve(process.env.SMOKE_OUTPUT || path.join(root, 'artifacts/app-smoke', `${process.platform}-${process.arch}`, String(Date.now()))); await mkdir(directory, { recursive: true }); const executablePath = process.env.APP_EXECUTABLE || (process.platform === 'win32' ? path.join(root, 'dist/win-unpacked/LSAudio.exe') : process.platform === 'darwin' ? path.join(root, `dist/mac${process.arch === 'arm64' ? '-arm64' : ''}/LSAudio.app/Contents/MacOS/LSAudio`) : path.join(root, 'dist/linux-unpacked/lsaudio')); -const application = await _electron.launch({ executablePath, args: [`--user-data-dir=${path.join(directory, 'profile')}`, ...(process.platform === 'linux' ? ['--no-sandbox'] : [])], +const application = await _electron.launch({ executablePath, args: [`--user-data-dir=${process.env.SMOKE_PROFILE || path.join(directory, 'profile')}`, ...(process.platform === 'linux' ? ['--no-sandbox'] : [])], env: { ...process.env, LSAUDIO_SMOKE: '1', ELECTRON_ENABLE_LOGGING: '1' }, timeout: 60000 }); const failures = []; try { const page = await application.firstWindow(); page.on('pageerror', error => failures.push(error.message)); await page.getByText('均衡器参数组', { exact: true }).waitFor({ timeout: 30000 }); assert.equal(await page.evaluate(() => window.appInfo.version), process.env.EXPECTED_APP_VERSION || pkg.version); + if (process.env.SMOKE_UPGRADE) assert.equal(await page.evaluate(() => localStorage.getItem('release-upgrade-pref')), 'from-1.1.4'); const config = getDefaultConfig(16000); const result = await page.evaluate(async ({ config, directory }) => { const ipc = window.ipcRenderer; @@ -39,6 +40,12 @@ try { const project = JSON.parse(await readFile(path.join(directory, '中文 工程.lsaudio'), 'utf8')); assert.equal(project.manifestJson.version, 2); assert.deepEqual(project.configJson, config); + await application.evaluate(({ dialog }, file) => { dialog.showOpenDialogSync = () => [file]; }, process.env.SMOKE_UPGRADE_PROJECT || path.join(directory, '中文 工程.lsaudio')); + const reopened = await page.evaluate(() => window.ipcRenderer.invoke('open-project')); + assert.equal(reopened.code, 0); assert.deepEqual(reopened.data.configJson, config); + await page.reload(); + await page.getByText('均衡器参数组', { exact: true }).waitFor(); + assert.equal(await page.evaluate(() => localStorage.getItem('release-smoke-pref')), 'preserved'); await page.screenshot({ path: path.join(directory, 'main-window.png') }); assert.deepEqual(failures, []); await writeFile(path.join(directory, 'result.json'), JSON.stringify({ platform: process.platform, arch: process.arch, version: pkg.version, passed: true, result }, null, 2)); diff --git a/tests/app/upgrade-seed.mjs b/tests/app/upgrade-seed.mjs new file mode 100644 index 0000000..c38b2e7 --- /dev/null +++ b/tests/app/upgrade-seed.mjs @@ -0,0 +1,17 @@ +import { _electron } from 'playwright-core'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { mkdirSync } from 'node:fs'; +import getDefaultConfig from '../../src/renderer/utils/config.js'; +const directory = process.env.UPGRADE_PROJECT_DIR; +mkdirSync(directory, { recursive: true }); +const app = await _electron.launch({ executablePath: process.env.APP_EXECUTABLE, args: [`--user-data-dir=${process.env.SMOKE_PROFILE}`], timeout: 60000 }); +try { + const page = await app.firstWindow(); await page.getByText('均衡器参数组', { exact: true }).waitFor(); + assert.equal(await page.evaluate(() => window.appInfo.version), '1.1.4'); + const result = await page.evaluate(async ({ config, directory }) => { + localStorage.setItem('release-upgrade-pref', 'from-1.1.4'); + return window.ipcRenderer.invoke('create-project', { pathStr: directory, name: '升级 基线', configJson: config, isSave: true }); + }, { config: getDefaultConfig(16000), directory }); + assert.equal(result.code, 0); +} finally { await app.close(); } diff --git a/tests/release/native-resources.test.cjs b/tests/release/native-resources.test.cjs new file mode 100644 index 0000000..ebdaebf --- /dev/null +++ b/tests/release/native-resources.test.cjs @@ -0,0 +1,17 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { writeManifest, libraryName } = require('../../native/resources.cjs'); +test('startup resource guard rejects absent, corrupt and wrong-architecture resources', async t => { + const { verifyNativeResources, nativeDirectory } = await import('../../src/libs/native-runtime.mjs'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lsaudio-native-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); + assert.equal(nativeDirectory({ packaged: true, resourcesPath: root }), path.join(root, 'native')); + assert.throws(() => verifyNativeResources(root), /清单/); + for (const name of ['eqdrc', 'eqdraw', 'soundeffect']) fs.writeFileSync(path.join(root, libraryName(name)), name); + writeManifest(root); assert.equal(Object.keys(verifyNativeResources(root)).length, 3); + assert.throws(() => verifyNativeResources(root, process.platform, process.arch === 'x64' ? 'arm64' : 'x64')); + fs.appendFileSync(path.join(root, libraryName('eqdrc')), 'corrupt'); assert.throws(() => verifyNativeResources(root), /校验/); + fs.unlinkSync(path.join(root, libraryName('eqdrc'))); assert.throws(() => verifyNativeResources(root), /缺少/); +}); diff --git a/tests/release/version.test.cjs b/tests/release/version.test.cjs new file mode 100644 index 0000000..bda077a --- /dev/null +++ b/tests/release/version.test.cjs @@ -0,0 +1,33 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { prepare, verify } = require('../../scripts/release/version.cjs'); +test('prepare/verify enforce clean increasing versions, lockfile, tag and master ancestry', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lsaudio-version-')); + const cwd = process.cwd(); process.chdir(root); + t.after(() => { process.chdir(cwd); fs.rmSync(root, { recursive: true, force: true }); }); + const git = (...args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + git('init', '--initial-branch=master'); git('config', 'user.email', 'test@example.invalid'); git('config', 'user.name', 'Release tests'); + fs.writeFileSync('package.json', JSON.stringify({ version: '1.1.4' })); + fs.writeFileSync('package-lock.json', JSON.stringify({ version: '1.1.4', packages: { '': { version: '1.1.4' } } })); + git('add', '.'); git('commit', '-m', 'baseline'); + assert.throws(() => prepare('1.1.3'), /increase/); + prepare('1.2.0-beta.1'); + assert.equal(JSON.parse(fs.readFileSync('package.json')).version, '1.2.0-beta.1'); + assert.throws(() => verify(), /unfinished/); + assert.throws(() => prepare('1.2.0'), /clean/); + fs.writeFileSync('CHANGELOG.md', fs.readFileSync('CHANGELOG.md', 'utf8').replaceAll('待填写', '已记录')); + assert.equal(verify(), '1.2.0-beta.1'); + git('add', '.'); git('commit', '-m', 'version'); git('tag', 'v1.2.0-beta.1'); + assert.equal(verify({ tag: 'v1.2.0-beta.1', master: 'master' }), '1.2.0-beta.1'); + assert.throws(() => verify({ tag: 'v1.2.0', master: 'master' }), /mismatch/); + git('checkout', '-b', 'unmerged'); prepare('1.2.0'); + fs.writeFileSync('CHANGELOG.md', fs.readFileSync('CHANGELOG.md', 'utf8').replaceAll('待填写', '已记录')); + git('add', '.'); git('commit', '-m', 'not merged'); git('tag', 'v1.2.0'); + assert.throws(() => verify({ tag: 'v1.2.0', master: 'master' })); + const lock = JSON.parse(fs.readFileSync('package-lock.json')); lock.version = '1.0.0'; fs.writeFileSync('package-lock.json', JSON.stringify(lock)); + assert.throws(() => verify(), /Lockfile/); +}); From 72095b4dc79a47ed1b98d0912967677730bc2b93 Mon Sep 17 00:00:00 2001 From: xryu Date: Tue, 29 Sep 2026 20:18:19 +0800 Subject: [PATCH 4/6] fix: retain target bindings and strengthen packaged application checks --- .github/workflows/client.yml | 4 ++-- .github/workflows/publish.yml | 2 +- .github/workflows/release.yml | 4 ++-- docs/THIRD_PARTY.md | 7 +++++++ native/README.md | 4 +++- scripts/after-pack.cjs | 1 + scripts/build-config.cjs | 15 +++++++++++++-- scripts/release/artifacts.cjs | 6 +++++- scripts/release/github.cjs | 3 ++- tests/app/installers.mjs | 7 +++---- tests/app/smoke.mjs | 20 ++++++++++++++++++++ tests/app/startup-failures.mjs | 28 ++++++++++++++++++++++++++++ 12 files changed, 87 insertions(+), 14 deletions(-) create mode 100644 docs/THIRD_PARTY.md create mode 100644 tests/app/startup-failures.mjs diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 85618a3..70b02bd 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -43,9 +43,9 @@ jobs: - run: npm run test:audio:integration - run: npm run electron:build -- --dir - if: runner.os != 'Linux' - run: npm run test:app + run: npm run test:app && node tests/app/startup-failures.mjs - if: runner.os == 'Linux' - run: xvfb-run -a npm run test:app + run: xvfb-run -a npm run test:app && xvfb-run -a node tests/app/startup-failures.mjs - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f6215ca..9144fe9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,7 +6,7 @@ on: permissions: contents: read concurrency: - group: publish-accepted-release + group: release-lifecycle cancel-in-progress: false jobs: publish: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abc1024..9ddabed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: permissions: contents: read concurrency: - group: release-${{ github.ref }} + group: release-lifecycle cancel-in-progress: false jobs: build: @@ -53,7 +53,7 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 if: runner.os == 'Windows' with: - ref: a7f9381 + ref: a7f938151321cc7346512e8aab30c9993c931d54 path: .cache/upgrade-baseline - name: Build original 1.1.4 upgrade baseline if: runner.os == 'Windows' diff --git a/docs/THIRD_PARTY.md b/docs/THIRD_PARTY.md new file mode 100644 index 0000000..79a2629 --- /dev/null +++ b/docs/THIRD_PARTY.md @@ -0,0 +1,7 @@ +# 随包第三方资料 + +- Electron:应用目录包含 Electron 的 LICENSE / LICENSES.chromium.html。 +- FFmpeg:`resources/ffmpeg` 中包含当前平台的许可证、上游 README 和来源记录;实际版本与 SHA-256 见仓库 `ffmpeg/manifest.json`。 +- CMSIS-DSP FFT 子集:`resources/licenses/CMSIS-DSP-LICENSE.txt` 与 `CMSIS-DSP-SOURCE.md`,Apache-2.0。 +- JavaScript 与 Node 原生依赖:安装包中的依赖目录保留各包许可文件。包括 Koffi、serialport、@echogarden/audio-io(MIT);具体依赖版本见 package-lock.json。 +- LSAudio C 模块为本仓库维护的兼容实现;原厂 Windows DLL 仅为测试输入,不包含在安装包内。项目级许可状态以仓库声明为准,不因使用第三方开源组件而改变。 diff --git a/native/README.md b/native/README.md index 6e97e46..68e1e42 100644 --- a/native/README.md +++ b/native/README.md @@ -73,7 +73,9 @@ cmake --build native/build --config Release ctest --test-dir native/build -C Release --output-on-failure ``` -生成的动态库统一位于 `native/build/lib/`:Windows 为 `lsaudio_.dll`,macOS 为 `liblsaudio_.dylib`,Linux 为 `liblsaudio_.so`。eqdraw 依赖 eqdrc,部署时需一起提供;soundeffect 独立编译,不依赖原厂库或绘图库。当前已在 Windows x64 / MSVC 实测;macOS、Linux 和 ARM64 仍需在对应环境运行上述命令,不能据此宣称整个应用已经跨平台。JS 对照工具按小端布局构造输入,DRC 包装接口使用真正的按值结构体声明;C 文件编解码显式处理小端格式。 +生成的动态库统一位于 `native/build/lib/`:Windows 为 `lsaudio_.dll`,macOS 为 `liblsaudio_.dylib`,Linux 为 `liblsaudio_.so`。eqdraw 依赖 eqdrc,部署时需一起提供;soundeffect 独立编译,不依赖原厂库或绘图库。四个平台的原生 runner 执行回归,不自动放宽容差。JS 对照工具按小端布局构造输入,DRC 包装接口使用真正的按值结构体声明;C 文件编解码显式处理小端格式。 + +客户端默认加载上述库。开发启动前自动编译并生成散列清单,安装版从 `resources/native` 加载,不依赖工作目录。Windows 使用静态 MSVC 运行库;macOS 使用 `@loader_path`,Linux 使用 `$ORIGIN` 寻找相邻库。打包仅接受与宿主平台/架构一致的构建,原厂 DLL 不随应用分发。完整客户端及实机发布验收状态见根 README 和 [发布指南](../docs/RELEASING.md)。 ## 后续模块的维护规则 diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index b7c6e5e..55ac3b8 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -11,4 +11,5 @@ module.exports = async context => { const licenses = path.join(resources, 'licenses'); fs.mkdirSync(licenses, { recursive: true }); fs.copyFileSync(path.join(__dirname, '../native/third_party/cmsis-dsp/LICENSE.txt'), path.join(licenses, 'CMSIS-DSP-LICENSE.txt')); fs.copyFileSync(path.join(__dirname, '../native/third_party/cmsis-dsp/UPSTREAM.md'), path.join(licenses, 'CMSIS-DSP-SOURCE.md')); + fs.copyFileSync(path.join(__dirname, '../docs/THIRD_PARTY.md'), path.join(licenses, 'THIRD-PARTY.md')); }; diff --git a/scripts/build-config.cjs b/scripts/build-config.cjs index 710737a..d646345 100644 --- a/scripts/build-config.cjs +++ b/scripts/build-config.cjs @@ -1,11 +1,22 @@ const path = require('node:path'); +const fs = require('node:fs'); const original = require('../package.json').build; const config = structuredClone(original); config.afterPack = path.join(__dirname, 'after-pack.cjs'); config.extraResources = []; config.files = ['out/**', 'package.json', '!**/*.map', '!node_modules/koffi/{doc,src,vendor}/**', '!node_modules/@serialport/bindings-cpp/src/**']; -// electron-builder selects dependency prebuilds. Keep universal macOS serial -// bindings and all runtime JS loaders; never reuse the old Windows-only denylist. +// Select native bindings for this native runner. macOS serialport ships a +// universal directory whose name is not just darwin-x64 or darwin-arm64. +const selections = [ + ['koffi/build/koffi', name => name === `${process.platform}_${process.arch}`], + ['@serialport/bindings-cpp/prebuilds', name => name === (process.platform === 'darwin' ? 'darwin-x64+arm64' : `${process.platform}-${process.arch}`)], + ['@echogarden/audio-io/addons/bin', name => name.startsWith(`${{ win32: 'windows', darwin: 'macos', linux: 'linux' }[process.platform]}-${process.arch}-`)], +]; +for (const [directory, keep] of selections) { + for (const entry of fs.readdirSync(path.join(__dirname, '../node_modules', directory), { withFileTypes: true })) { + if (!keep(entry.name)) config.files.push(`!node_modules/${directory}/${entry.name}${entry.isDirectory() ? '/**' : ''}`); + } +} config.asarUnpack = ['node_modules/**/*.node', 'node_modules/koffi/build/**']; config.artifactName = 'LSAudio-${version}-${os}-${arch}.${ext}'; config.win.target = [{ target: 'nsis', arch: ['x64'] }]; diff --git a/scripts/release/artifacts.cjs b/scripts/release/artifacts.cjs index ec417bd..c152efd 100644 --- a/scripts/release/artifacts.cjs +++ b/scripts/release/artifacts.cjs @@ -23,8 +23,12 @@ function record(directory, output) { return { name, sha256: hash(file), bytes: fs.statSync(file).size }; }); const ffmpeg = require('../../ffmpeg/manifest.json'); + const compilerFile = fs.readdirSync('native/build/CMakeFiles', { recursive: true }).find(name => name.endsWith('CMakeCCompiler.cmake')); + const compiler = fs.readFileSync(path.join('native/build/CMakeFiles', compilerFile), 'utf8'); + const compilerId = compiler.match(/set\(CMAKE_C_COMPILER_ID "([^"]+)"\)/)?.[1]; + const compilerVersion = compiler.match(/set\(CMAKE_C_COMPILER_VERSION "([^"]+)"\)/)?.[1]; const manifest = { schemaVersion: 1, version, target, commit: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), runId: process.env.GITHUB_RUN_ID || 'local', runAttempt: process.env.GITHUB_RUN_ATTEMPT || '1', - toolchain: { node: process.version, electron: require('electron/package.json').version, builder: require('electron-builder/package.json').version, cmake: execFileSync('cmake', ['--version'], { encoding: 'utf8' }).split('\n')[0] }, + toolchain: { node: process.version, electron: require('electron/package.json').version, builder: require('electron-builder/package.json').version, cmake: execFileSync('cmake', ['--version'], { encoding: 'utf8' }).split('\n')[0], compiler: { id: compilerId, version: compilerVersion } }, ffmpeg: ffmpeg.targets[target], signing: process.platform === 'darwin' ? 'ad-hoc; not notarized' : 'unsigned', files }; fs.writeFileSync(path.join(output, `manifest-${target}.json`), JSON.stringify(manifest, null, 2) + '\n'); } diff --git a/scripts/release/github.cjs b/scripts/release/github.cjs index c88ee45..28f39fc 100644 --- a/scripts/release/github.cjs +++ b/scripts/release/github.cjs @@ -31,6 +31,7 @@ function assets(directory, version, sha) { return expected; } function draft(tag, directory) { + if (!JSON.parse(gh('api', `repos/${repository}`)).private) throw new Error('This release workflow requires a private repository'); const version = verify({ tag, master: 'refs/remotes/origin/master' }); const sha = gh('api', `repos/${repository}/git/ref/tags/${tag}`, '--jq', '.object.sha'); const files = assets(directory, version, sha); @@ -75,7 +76,7 @@ function tag(version) { for (const pr of prs.filter(p => p.merged_at && p.base.ref === 'master' && p.merge_commit_sha === sha)) { const files = api(`pulls/${pr.number}/files?per_page=100`); const reviews = api(`pulls/${pr.number}/reviews?per_page=100`); - const latest = new Map(); for (const review of reviews) if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) latest.set(review.user.login, review.state); + const latest = new Map(); for (const review of reviews) if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)) latest.set(review.user.login, review.state === 'APPROVED' && review.commit_id !== pr.head.sha ? 'STALE' : review.state); if (files.some(f => f.filename === 'CHANGELOG.md') && files.some(f => f.filename === 'package.json') && [...latest.values()].includes('APPROVED') && ![...latest.values()].includes('CHANGES_REQUESTED')) approved = true; } if (!approved) throw new Error('A reviewed and merged version PR is required'); diff --git a/tests/app/installers.mjs b/tests/app/installers.mjs index 9faa3b9..d90e349 100644 --- a/tests/app/installers.mjs +++ b/tests/app/installers.mjs @@ -9,7 +9,7 @@ if (!process.env.CI) throw new Error('Installer tests run on disposable CI runne const root = process.cwd(); const output = path.resolve('artifacts/installer-smoke'); mkdirSync(output, { recursive: true }); const run = (cmd, args, options = {}) => execFileSync(cmd, args, { stdio: 'inherit', ...options }); -const smoke = exe => run(process.execPath, ['tests/app/smoke.mjs'], { env: { ...process.env, APP_EXECUTABLE: exe } }); +const smoke = exe => run(process.execPath, ['tests/app/smoke.mjs'], { env: { ...process.env, APP_EXECUTABLE: exe, APPIMAGE_EXTRACT_AND_RUN: '1', DEBUG: 'pw:browser' } }); const files = names(version, `${process.platform}-${process.arch}`).map(n => path.resolve('dist', n)); if (process.platform === 'darwin') { const mount = path.join(output, 'mounted'); mkdirSync(mount, { recursive: true }); @@ -21,10 +21,9 @@ if (process.platform === 'darwin') { smoke(path.join(app, 'Contents/MacOS/LSAudio')); } finally { run('hdiutil', ['detach', mount]); } } else if (process.platform === 'linux') { - // Extraction avoids requiring FUSE in containers; execute the shipped AppRun. + // Run the actual AppImage using its supported extraction mode on CI. run('chmod', ['+x', files[0]]); - run(files[0], ['--appimage-extract'], { cwd: output }); - smoke(path.join(output, 'squashfs-root/AppRun')); + smoke(files[0]); run('sudo', ['apt-get', 'install', '-y', files[1]]); try { smoke('/opt/LSAudio/lsaudio'); } finally { run('sudo', ['dpkg', '--remove', 'lsaudio']); } diff --git a/tests/app/smoke.mjs b/tests/app/smoke.mjs index f5c732b..8ac6740 100644 --- a/tests/app/smoke.mjs +++ b/tests/app/smoke.mjs @@ -16,6 +16,26 @@ try { const page = await application.firstWindow(); page.on('pageerror', error => failures.push(error.message)); await page.getByText('均衡器参数组', { exact: true }).waitFor({ timeout: 30000 }); assert.equal(await page.evaluate(() => window.appInfo.version), process.env.EXPECTED_APP_VERSION || pkg.version); + const wavFile = path.join(directory, '中文 音频.wav'); + const samples = Buffer.alloc(640); + for (let i = 0; i < 320; i++) samples.writeInt16LE(Math.round(1000 * Math.sin(i / 10)), i * 2); + const header = Buffer.alloc(44); header.write('RIFF'); header.writeUInt32LE(36 + samples.length, 4); header.write('WAVEfmt ', 8); header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22); header.writeUInt32LE(16000, 24); header.writeUInt32LE(32000, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34); header.write('data', 36); header.writeUInt32LE(samples.length, 40); + await writeFile(wavFile, Buffer.concat([header, samples])); + const decoded = await application.evaluate(async ({ app }, file) => { + const require = process.mainModule.require.bind(process.mainModule); + const fs = require('node:fs'), path = require('node:path'); + const main = path.join(app.getAppPath(), 'out/main'); + const audio = require(path.join(main, fs.readdirSync(main).find(name => /^audioDecoder-.*\.js$/.test(name)))); + const source = audio.openPcmSource(file), chunks = []; + await source.ready; + for await (const chunk of source.pcm) chunks.push(chunk); + await source.done; + const cancelled = audio.openPcmSource(file); + cancelled.ready.catch(() => {}); cancelled.done.catch(() => {}); + await cancelled.cancel(); await cancelled.cancel(); + return Buffer.concat(chunks).toString('hex'); + }, wavFile); + assert.equal(decoded, samples.toString('hex')); if (process.env.SMOKE_UPGRADE) assert.equal(await page.evaluate(() => localStorage.getItem('release-upgrade-pref')), 'from-1.1.4'); const config = getDefaultConfig(16000); const result = await page.evaluate(async ({ config, directory }) => { diff --git a/tests/app/startup-failures.mjs b/tests/app/startup-failures.mjs new file mode 100644 index 0000000..4a7efb6 --- /dev/null +++ b/tests/app/startup-failures.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +// Only mutate the disposable unpacked build, never an installed client. +const app = path.resolve(process.platform === 'win32' ? 'dist/win-unpacked' : process.platform === 'darwin' ? `dist/mac${process.arch === 'arm64' ? '-arm64' : ''}/LSAudio.app/Contents` : 'dist/linux-unpacked'); +const exe = path.join(app, process.platform === 'win32' ? 'LSAudio.exe' : process.platform === 'darwin' ? 'MacOS/LSAudio' : 'lsaudio'); +const resources = path.join(app, process.platform === 'darwin' ? 'Resources' : 'resources'); +const manifestPath = path.join(resources, 'native/native-manifest.json'); +const original = fs.readFileSync(manifestPath); +const profile = path.resolve('artifacts/startup-failures/profile'); +fs.mkdirSync(profile, { recursive: true }); +function fails(pattern) { + const result = spawnSync(exe, [`--user-data-dir=${profile}`, ...(process.platform === 'linux' ? ['--no-sandbox'] : [])], { encoding: 'utf8', timeout: 20000, env: { ...process.env, LSAUDIO_SMOKE: '1' } }); + assert.ifError(result.error); assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout + result.stderr, pattern); +} +try { + fs.writeFileSync(manifestPath, '{'); fails(/清单缺失或损坏/); + const metadata = JSON.parse(original); metadata.arch = process.arch === 'x64' ? 'arm64' : 'x64'; + fs.writeFileSync(manifestPath, JSON.stringify(metadata)); fails(/架构与当前客户端不匹配/); +} finally { fs.writeFileSync(manifestPath, original); } +const ffmpeg = path.join(resources, 'ffmpeg', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); +const saved = `${ffmpeg}.startup-test-saved`; +assert.equal(fs.existsSync(saved), false); +fs.renameSync(ffmpeg, saved); +try { fails(/FFmpeg 资源缺失/); } finally { fs.renameSync(saved, ffmpeg); } +console.log('Packaged startup failures: missing/corrupt resources and wrong architecture rejected explicitly.'); From dd41033b09d1cd4f14180ea3704be3e1d465863e Mon Sep 17 00:00:00 2001 From: xryu Date: Tue, 29 Sep 2026 20:26:59 +0800 Subject: [PATCH 5/6] fix: validate signed resources and final release provenance --- .github/workflows/client.yml | 1 + .github/workflows/release.yml | 3 ++- native/tools/check-linkage.cjs | 25 +++++++++++++++++++++++++ scripts/adhoc-sign.cjs | 8 ++++++++ scripts/release/artifacts.cjs | 4 +++- scripts/release/github.cjs | 12 ++++++++++-- scripts/release/version.cjs | 2 ++ src/main/audio/resources.mjs | 7 +++++++ src/main/ipcMain.js | 2 +- tests/app/installers.mjs | 2 +- tests/app/smoke.mjs | 4 ++++ tests/audio/device-smoke.mjs | 10 ++++++---- 12 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 native/tools/check-linkage.cjs diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 70b02bd..91c3495 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -37,6 +37,7 @@ jobs: - run: npm run release:verify - run: npm run test:release - run: npm run native:test + - run: node native/tools/check-linkage.cjs - if: runner.os == 'Windows' run: npm run native:compare - run: npm run test:audio diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ddabed..d16bdea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Release installers on: push: tags: ['v*'] - branches: ['codex/multiplatform-release'] + branches: ['codex/multiplatform-release', 'codex/release-*'] workflow_dispatch: permissions: contents: read @@ -45,6 +45,7 @@ jobs: RELEASE_MASTER: refs/remotes/origin/master - run: npm run test:release - run: npm run native:test + - run: node native/tools/check-linkage.cjs - if: runner.os == 'Windows' run: npm run native:compare - run: npm run test:audio diff --git a/native/tools/check-linkage.cjs b/native/tools/check-linkage.cjs new file mode 100644 index 0000000..066cb6a --- /dev/null +++ b/native/tools/check-linkage.cjs @@ -0,0 +1,25 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { libraryName } = require('../resources.cjs'); +const root = path.resolve('native/build'); +for (const name of ['eqdrc', 'eqdraw', 'soundeffect']) { + const file = path.join(root, 'lib', libraryName(name)); + let output; + if (process.platform === 'darwin') { + output = execFileSync('otool', ['-L', file], { encoding: 'utf8' }).split('\n').slice(1).join('\n'); + const commands = execFileSync('otool', ['-l', file], { encoding: 'utf8' }); + if (!commands.includes('@loader_path')) throw new Error(`Missing loader-relative RPATH: ${file}`); + } else if (process.platform === 'linux') { + output = execFileSync('readelf', ['-d', file], { encoding: 'utf8' }); + if (!output.includes('$ORIGIN')) throw new Error(`Missing origin-relative RPATH: ${file}`); + } else { + const info = fs.readdirSync(path.join(root, 'CMakeFiles'), { recursive: true }).find(f => f.endsWith('CMakeCCompiler.cmake')); + const contents = fs.readFileSync(path.join(root, 'CMakeFiles', info), 'utf8'); + const compiler = contents.match(/set\(CMAKE_C_COMPILER "([^"]+)"\)/)?.[1]; + output = execFileSync(path.join(path.dirname(compiler), 'dumpbin.exe'), ['/DEPENDENTS', file], { encoding: 'utf8' }); + if (/\b(?:VCRUNTIME|MSVCP|MSVCR)\d.*\.dll/i.test(output)) throw new Error(`Dynamic MSVC runtime dependency: ${file}`); + } + if (process.platform !== 'win32' && output.replaceAll('\\', '/').includes(root.replaceAll('\\', '/'))) throw new Error(`Build-machine absolute library dependency: ${file}`); +} +console.log('Native linkage: relative sibling loading and static MSVC runtime verified.'); diff --git a/scripts/adhoc-sign.cjs b/scripts/adhoc-sign.cjs index d121701..1929a2a 100644 --- a/scripts/adhoc-sign.cjs +++ b/scripts/adhoc-sign.cjs @@ -1,4 +1,6 @@ const path = require('node:path'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); const { execFileSync } = require('node:child_process'); const { signAsync } = require('@electron/osx-sign'); const { writeManifest } = require('../native/resources.cjs'); @@ -7,6 +9,12 @@ module.exports = async options => { optionsForFile: file => ({ ...options.optionsForFile(file), hardenedRuntime: false }), }); writeManifest(path.join(options.app, 'Contents/Resources/native')); + const ffmpeg = path.join(options.app, 'Contents/Resources/ffmpeg'); + const metadata = JSON.parse(fs.readFileSync(path.join(ffmpeg, 'build-info.json'))); + metadata.sourceSha256 = metadata.sha256; + metadata.sha256 = crypto.createHash('sha256').update(fs.readFileSync(path.join(ffmpeg, 'ffmpeg'))).digest('hex'); + metadata.signing = 'ad-hoc'; + fs.writeFileSync(path.join(ffmpeg, 'build-info.json'), JSON.stringify(metadata) + '\n'); // Updating the resource manifest invalidates only the outer app signature. execFileSync('codesign', ['--force', '--sign', '-', '--timestamp=none', '--preserve-metadata=entitlements', options.app], { stdio: 'inherit' }); execFileSync('codesign', ['--verify', '--deep', '--strict', options.app], { stdio: 'inherit' }); diff --git a/scripts/release/artifacts.cjs b/scripts/release/artifacts.cjs index c152efd..9e37f35 100644 --- a/scripts/release/artifacts.cjs +++ b/scripts/release/artifacts.cjs @@ -27,9 +27,11 @@ function record(directory, output) { const compiler = fs.readFileSync(path.join('native/build/CMakeFiles', compilerFile), 'utf8'); const compilerId = compiler.match(/set\(CMAKE_C_COMPILER_ID "([^"]+)"\)/)?.[1]; const compilerVersion = compiler.match(/set\(CMAKE_C_COMPILER_VERSION "([^"]+)"\)/)?.[1]; + const ffmpegExecutable = path.join(require('../../ffmpeg/assets.cjs').cacheDirectory(target), ffmpeg.targets[target].executable); + const ffmpegActualVersion = execFileSync(ffmpegExecutable, ['-version'], { encoding: 'utf8', windowsHide: true }).split('\n')[0]; const manifest = { schemaVersion: 1, version, target, commit: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), runId: process.env.GITHUB_RUN_ID || 'local', runAttempt: process.env.GITHUB_RUN_ATTEMPT || '1', toolchain: { node: process.version, electron: require('electron/package.json').version, builder: require('electron-builder/package.json').version, cmake: execFileSync('cmake', ['--version'], { encoding: 'utf8' }).split('\n')[0], compiler: { id: compilerId, version: compilerVersion } }, - ffmpeg: ffmpeg.targets[target], signing: process.platform === 'darwin' ? 'ad-hoc; not notarized' : 'unsigned', files }; + ffmpeg: { ...ffmpeg.targets[target], actualVersion: ffmpegActualVersion }, signing: process.platform === 'darwin' ? 'ad-hoc; not notarized' : 'unsigned', files }; fs.writeFileSync(path.join(output, `manifest-${target}.json`), JSON.stringify(manifest, null, 2) + '\n'); } function aggregate(input, output, version, commit, runId) { diff --git a/scripts/release/github.cjs b/scripts/release/github.cjs index 28f39fc..34fe45b 100644 --- a/scripts/release/github.cjs +++ b/scripts/release/github.cjs @@ -20,6 +20,11 @@ function assets(directory, version, sha) { if (JSON.stringify(fs.readdirSync(directory).sort()) !== JSON.stringify(expected)) throw new Error('Release must contain exactly five installers, checksums and build manifest'); const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'build-manifest.json'))); if (manifest.version !== version || manifest.commit !== sha || manifest.targets?.length !== 4) throw new Error('Release manifest mismatch'); + if (JSON.stringify(manifest.targets.map(m => m.target).sort()) !== JSON.stringify([...targets].sort())) throw new Error('Duplicate or missing build target'); + for (const record of manifest.targets) { + if (record.version !== version || record.commit !== sha || record.runId !== manifest.runId || JSON.stringify(record.files.map(f => f.name).sort()) !== JSON.stringify(names(version, record.target).sort())) throw new Error('Target manifest mismatch'); + for (const file of record.files) if (hash(path.join(directory, file.name)) !== file.sha256 || fs.statSync(path.join(directory, file.name)).size !== file.bytes) throw new Error('Target artifact mismatch'); + } const checksums = fs.readFileSync(path.join(directory, 'SHA256SUMS.txt'), 'utf8').trim().split('\n'); if (checksums.length !== 6) throw new Error('Incomplete checksums'); const checked = new Set(); @@ -64,9 +69,12 @@ function publish(tag, directory, evidence) { } function tag(version) { if (verify() !== version) throw new Error('Version mismatch'); + if (execFileSync('git', ['status', '--porcelain'], { encoding: 'utf8' }).trim()) throw new Error('Tag creation requires a clean checkout'); const sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); - const master = api('branches/master').commit.sha; - if (master !== sha) throw new Error('Only the checked master head may create a release tag'); + const previous = JSON.parse(execFileSync('git', ['show', `${sha}^1:package.json`], { encoding: 'utf8' })).version; + if (!require('semver').gt(version, previous)) throw new Error('Release commit must increase the product version'); + execFileSync('git', ['fetch', 'github', 'master'], { stdio: 'inherit' }); + execFileSync('git', ['merge-base', '--is-ancestor', sha, 'refs/remotes/github/master'], { stdio: 'inherit' }); const checks = api(`commits/${sha}/check-runs?per_page=100`).check_runs; for (const name of ['client (windows-latest, win32-x64)', 'client (macos-15-intel, darwin-x64)', 'client (macos-15, darwin-arm64)', 'client (ubuntu-24.04, linux-x64)']) { if (!checks.some(c => c.name === name && c.status === 'completed' && c.conclusion === 'success' && c.app.slug === 'github-actions')) throw new Error(`Required check missing: ${name}`); diff --git a/scripts/release/version.cjs b/scripts/release/version.cjs index 581b396..ff603d5 100644 --- a/scripts/release/version.cjs +++ b/scripts/release/version.cjs @@ -22,6 +22,8 @@ function verify({ tag, master = 'refs/remotes/github/master' } = {}) { const sha = git('rev-parse', `${tag}^{commit}`); if (sha !== git('rev-parse', 'HEAD')) throw new Error('Tag does not point to checked-out commit'); git('merge-base', '--is-ancestor', sha, master); + const previous = JSON.parse(git('show', `${sha}^1:package.json`)).version; + if (!semver.gt(pkg.version, previous)) throw new Error('Release commit must increase the product version'); } return pkg.version; } diff --git a/src/main/audio/resources.mjs b/src/main/audio/resources.mjs index b745320..048a26c 100644 --- a/src/main/audio/resources.mjs +++ b/src/main/audio/resources.mjs @@ -1,4 +1,6 @@ import fs from 'node:fs'; +import crypto from 'node:crypto'; +import path from 'node:path'; import { execFileSync } from 'node:child_process'; import manifest from '../../../ffmpeg/manifest.json' with { type: 'json' }; import { resolveFfmpegPath } from './ffmpeg-path.mjs'; @@ -6,6 +8,11 @@ export function verifyFfmpegResource(options) { const file = resolveFfmpegPath(options); try { fs.accessSync(file, process.platform === 'win32' ? fs.constants.R_OK : fs.constants.X_OK); + if (!(options.override && !options.packaged)) { + const target = `${process.platform}-${process.arch}`; + const metadata = options.packaged ? JSON.parse(fs.readFileSync(path.join(path.dirname(file), 'build-info.json'), 'utf8')) : { target, sha256: manifest.targets[target].binary.sha256 }; + if (metadata.target !== target || crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') !== metadata.sha256) throw new Error('FFmpeg 资源校验失败'); + } const text = execFileSync(file, ['-version'], { encoding: 'utf8', windowsHide: true, timeout: 10000 }); const expected = manifest.targets[`${process.platform}-${process.arch}`].version; if (!(options.override && !options.packaged) && !text.startsWith(`ffmpeg version ${expected} `) && !text.startsWith(`ffmpeg version ${expected}-`)) throw new Error(`期望版本 ${expected}`); diff --git a/src/main/ipcMain.js b/src/main/ipcMain.js index dc69534..ceedabc 100644 --- a/src/main/ipcMain.js +++ b/src/main/ipcMain.js @@ -1,5 +1,5 @@ -import { ipcMain, BrowserWindow, shell } from 'electron'; +import { ipcMain, shell } from 'electron'; import drawHandle from './draw'; import ProjectHandle from './project'; import serialport from './serialport'; diff --git a/tests/app/installers.mjs b/tests/app/installers.mjs index d90e349..cb3d088 100644 --- a/tests/app/installers.mjs +++ b/tests/app/installers.mjs @@ -1,4 +1,4 @@ -import { execFileSync, spawnSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import { mkdirSync, existsSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; diff --git a/tests/app/smoke.mjs b/tests/app/smoke.mjs index 8ac6740..a5796c6 100644 --- a/tests/app/smoke.mjs +++ b/tests/app/smoke.mjs @@ -24,6 +24,10 @@ try { const decoded = await application.evaluate(async ({ app }, file) => { const require = process.mainModule.require.bind(process.mainModule); const fs = require('node:fs'), path = require('node:path'); + const audioPlatform = { win32: 'windows', darwin: 'macos', linux: 'linux' }[process.platform]; + const backend = { win32: 'mme', darwin: 'coreaudio', linux: 'alsa' }[process.platform]; + const addon = require(path.join(app.getAppPath(), 'node_modules/@echogarden/audio-io/addons/bin', `${audioPlatform}-${process.arch}-${backend}-output.node`)); + if (typeof addon.createAudioOutput !== 'function') throw new Error('Packaged audio output binding did not load'); const main = path.join(app.getAppPath(), 'out/main'); const audio = require(path.join(main, fs.readdirSync(main).find(name => /^audioDecoder-.*\.js$/.test(name)))); const source = audio.openPcmSource(file), chunks = []; diff --git a/tests/audio/device-smoke.mjs b/tests/audio/device-smoke.mjs index 1d6fb16..585691f 100644 --- a/tests/audio/device-smoke.mjs +++ b/tests/audio/device-smoke.mjs @@ -1,4 +1,4 @@ -// Optional audible Windows hardware smoke test. Never run by default in CI. +// Optional audible hardware smoke test. Never run by default in CI. import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -10,9 +10,11 @@ import assets from '../../ffmpeg/assets.cjs'; import { createPcmService } from '../../src/main/audio/pcm-source.mjs'; import { PcmPlayer } from '../../src/main/audio/playback.mjs'; import { processDspFrame } from '../../src/main/audio/dsp-frame.mjs'; +import { verifyNativeResources } from '../../src/libs/native-runtime.mjs'; const root = fileURLToPath(new URL('../../', import.meta.url)); -if (process.platform !== 'win32' || process.arch !== 'x64') throw new Error('This smoke test uses the original Windows x64 DSP DLL'); -const lib = koffi.load(path.join(root, 'dlls/x64/SoundEffect.dll')); +const oracle = process.env.SOUNDEFFECT_ORACLE === '1'; +if (oracle && (process.platform !== 'win32' || process.arch !== 'x64')) throw new Error('Original DLL comparison requires Windows x64'); +const lib = koffi.load(oracle ? path.join(root, 'dlls/x64/SoundEffect.dll') : verifyNativeResources(path.join(root, 'native/build/lib')).soundeffect); const create = lib.func('int IFLYTEK_AudioCreate(void*,void*)'), init = lib.func('int IFLYTEK_AudioInitial(void*)'), del = lib.func('int IFLYTEK_AudioDelete(void*)'); const api = { audioProcess: lib.func('int IFLYTEK_AudioProcess(void*,void*,void*,int)') }; const instance = Buffer.alloc(23552), used = Buffer.alloc(4); @@ -21,7 +23,7 @@ const directory = path.join(root, 'artifacts/audio'); await mkdir(directory, { r const file = path.join(directory, 'device-smoke.pcm'); const input = Buffer.alloc(6400); for (let i = 0; i < 3200; ++i) input.writeInt16LE(Math.round(600 * Math.sin(2 * Math.PI * 440 * i / 16000)), i * 2); await writeFile(file, input); -const ffmpeg = path.join(assets.cacheDirectory(assets.getTarget()), 'ffmpeg.exe'); +const ffmpeg = path.join(assets.cacheDirectory(assets.getTarget()), process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); const wave = path.join(directory, 'device-smoke.wav'); await promisify(execFile)(ffmpeg, ['-nostdin', '-v', 'error', '-y', '-f', 's16le', '-ar', '16000', '-ac', '1', '-i', file, wave], { windowsHide: true }); const service = createPcmService({ getFfmpegPath: () => ffmpeg }); From 4e7aa7b964213398b52cd56b866431f6a55b41da Mon Sep 17 00:00:00 2001 From: xryu Date: Tue, 29 Sep 2026 20:31:14 +0800 Subject: [PATCH 6/6] test: enforce resource integrity checks in release builds --- .github/workflows/release.yml | 4 ++-- docs/RELEASING.md | 4 ++-- tests/app/startup-failures.mjs | 9 +++++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d16bdea..147cc12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,9 +61,9 @@ jobs: working-directory: .cache/upgrade-baseline run: npm ci && npm run electron:build -- --publish never - if: runner.os != 'Linux' - run: npm run test:app && npm run test:installers + run: npm run test:app && node tests/app/startup-failures.mjs && npm run test:installers - if: runner.os == 'Linux' - run: xvfb-run -a npm run test:app && xvfb-run -a npm run test:installers + run: xvfb-run -a npm run test:app && xvfb-run -a node tests/app/startup-failures.mjs && xvfb-run -a npm run test:installers - run: node scripts/release/artifacts.cjs record - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5469965..464518a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -16,7 +16,7 @@ ## 版本边界 -- `package.json` 是产品版本唯一来源,lockfile 根版本必须一致。界面从 Electron 读取版本;安装器和产物名由 builder 注入版本。 +- `package.json` 是产品版本唯一来源,lockfile 根版本必须一致。界面版本由构建工具注入 preload;安装器和产物名由 builder 注入版本。 - `.lsaudio` 中的 `manifestJson.version` 是工程修订计数,不随产品发版重置。 - C ABI/模块版本在 native 中管理;FFmpeg 的实际版本按目标记录在 `ffmpeg/manifest.json`,不能把资源合集的 b6.1.1 当成全部平台实际版本。 - 接受标签 `vX.Y.Z` 或 `vX.Y.Z-beta.N`,保留历史标签。不覆盖已发布附件,不移动标签。修复用新版本。 @@ -34,7 +34,7 @@ ## 演练与排错 -在 Actions 手动运行 `Release installers`(可选择分支),仅产生构建产物,不创建标签或 Release。本实现分支暂有 push 演练触发,功能合并后可移除该分支过滤项。 +在 Actions 手动运行 `Release installers`(可选择分支),仅产生构建产物,不创建标签或 Release。实现分支与 `codex/release-*` 版本分支的 push 也执行演练,便于在合并前验证安装器;均不创建 Release。 本机 `npm run electron:build` 使用本机平台/架构,C 库禁止跨宿主误用;输出在 dist。`npm run electron:build -- --dir` 后运行 `npm run test:app`。`test:installers` 仅允许在可丢弃的 CI runner 执行,避免改动开发机安装。Linux 使用 `xvfb-run -a`。 diff --git a/tests/app/startup-failures.mjs b/tests/app/startup-failures.mjs index 4a7efb6..feec5fd 100644 --- a/tests/app/startup-failures.mjs +++ b/tests/app/startup-failures.mjs @@ -19,9 +19,18 @@ try { fs.writeFileSync(manifestPath, '{'); fails(/清单缺失或损坏/); const metadata = JSON.parse(original); metadata.arch = process.arch === 'x64' ? 'arm64' : 'x64'; fs.writeFileSync(manifestPath, JSON.stringify(metadata)); fails(/架构与当前客户端不匹配/); + metadata.arch = process.arch; + for (const key of Object.keys(metadata.files)) metadata.files[key] = '0'.repeat(64); + fs.writeFileSync(manifestPath, JSON.stringify(metadata)); fails(/原生运行库校验失败/); } finally { fs.writeFileSync(manifestPath, original); } const ffmpeg = path.join(resources, 'ffmpeg', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'); const saved = `${ffmpeg}.startup-test-saved`; +const infoPath = path.join(path.dirname(ffmpeg), 'build-info.json'); +const infoBytes = fs.readFileSync(infoPath); +try { + const info = JSON.parse(infoBytes); info.sha256 = '0'.repeat(64); + fs.writeFileSync(infoPath, JSON.stringify(info)); fails(/FFmpeg 资源校验失败/); +} finally { fs.writeFileSync(infoPath, infoBytes); } assert.equal(fs.existsSync(saved), false); fs.renameSync(ffmpeg, saved); try { fails(/FFmpeg 资源缺失/); } finally { fs.renameSync(saved, ffmpeg); }