diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml new file mode 100644 index 0000000..91c3495 --- /dev/null +++ b/.github/workflows/client.yml @@ -0,0 +1,59 @@ +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 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 + - run: npm run test:audio:integration + - run: npm run electron:build -- --dir + - if: runner.os != 'Linux' + run: npm run test:app && node tests/app/startup-failures.mjs + - if: runner.os == 'Linux' + 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: + 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 + retention-days: 7 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9144fe9 --- /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: release-lifecycle + 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..147cc12 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,114 @@ +name: Release installers +on: + push: + tags: ['v*'] + branches: ['codex/multiplatform-release', 'codex/release-*'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: release-lifecycle + 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 + - run: node native/tools/check-linkage.cjs + - 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 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + if: runner.os == 'Windows' + with: + ref: a7f938151321cc7346512e8aab30c9993c931d54 + 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 && 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 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: + 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/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..464518a --- /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 根版本必须一致。界面版本由构建工具注入 preload;安装器和产物名由 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。实现分支与 `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`。 + +任一目标失败均不创建 Release。保留失败日志/截图 7 天;Actions 存储配额不足会明确失败,不能跳过上传宣称发布验证完成。资源缺失、架构错误在启动时给出明确提示。 + +同标签重跑仅允许同提交的草稿,重新汇总完整附件。草稿附件变动意味着必须重新取得验收散列;已发布版本一律拒绝更新。 + +安装器测试涵盖 NSIS 安装/卸载、DMG 挂载复制后启动、AppImage 提取运行和 deb 安装后运行。AppImage 在容器中通过自解压方式启动,FUSE 桌面使用和系统安装提示仍须验收。Windows 真实 1.1.4 升级基线、听感、设备参数和串口音频收发不可用单元测试代替。 + +## 首次发布跟踪 + +- 自动测试结果以功能 PR 的最新提交 CI 为准,不以旧提交成功状态放行。 +- 尚未取得物理设备和完整安装验收结果前,平台表保持“待发布验收”。 +- 首次发布需要仓库成员审查合并版本 PR;代理不会自行冒充审查者。 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/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/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/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/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/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..3ff7f06 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,8 +32,10 @@ "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", + "semver": "7.7.4", "unplugin-icons": "^0.19.0", "unplugin-vue-components": "^0.28.0", "vite": "^7.3.0", @@ -119,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", @@ -153,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", @@ -461,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", @@ -485,6 +518,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 +556,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 +611,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 +1594,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 +3256,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", @@ -3244,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", @@ -3696,21 +3734,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 +4291,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 +4446,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 +4478,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 +4540,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", @@ -5459,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", @@ -5878,19 +5827,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": { @@ -6065,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", @@ -6618,7 +6552,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 +7340,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", @@ -7902,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": { @@ -8020,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", @@ -8319,21 +8256,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 +8418,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" @@ -8941,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 22280ee..a227642 100644 --- a/package.json +++ b/package.json @@ -13,24 +13,32 @@ "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", + "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", "@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,8 +53,10 @@ "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", + "semver": "7.7.4", "unplugin-icons": "^0.19.0", "unplugin-vue-components": "^0.28.0", "vite": "^7.3.0", @@ -54,7 +64,6 @@ "vue-router": "^4.2.0" }, "build": { - "afterPack": "ffmpeg/after-pack.cjs", "productName": "LSAudio", "appId": "listenai.com", "copyright": "listenai", @@ -64,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, @@ -119,5 +90,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 0000000..c9f4b0a Binary files /dev/null and b/public/icon512.png differ 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/adhoc-sign.cjs b/scripts/adhoc-sign.cjs new file mode 100644 index 0000000..1929a2a --- /dev/null +++ b/scripts/adhoc-sign.cjs @@ -0,0 +1,21 @@ +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'); +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')); + 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/after-pack.cjs b/scripts/after-pack.cjs new file mode 100644 index 0000000..55ac3b8 --- /dev/null +++ b/scripts/after-pack.cjs @@ -0,0 +1,15 @@ +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')); + 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 new file mode 100644 index 0000000..d646345 --- /dev/null +++ b/scripts/build-config.cjs @@ -0,0 +1,32 @@ +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/**']; +// 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'] }]; +config.win.artifactName = 'LSAudio-${version}-windows-${arch}-setup.${ext}'; +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/icon512.png', target: [{ target: 'AppImage', arch: ['x64'] }, { target: 'deb', arch: ['x64'] }], + artifactName: 'LSAudio-${version}-linux-x64.${ext}', category: 'AudioVideo', + maintainer: 'ListenAI', synopsis: 'LSAudio audio device tuning client', +}; +module.exports = config; diff --git a/scripts/release/artifacts.cjs b/scripts/release/artifacts.cjs new file mode 100644 index 0000000..9e37f35 --- /dev/null +++ b/scripts/release/artifacts.cjs @@ -0,0 +1,60 @@ +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 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 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], 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) { + 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..34fe45b --- /dev/null +++ b/scripts/release/github.cjs @@ -0,0 +1,105 @@ +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'); + 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(); + 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) { + 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); + 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'); + 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 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}`); + } + 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 === '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'); + 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..ff603d5 --- /dev/null +++ b/scripts/release/version.cjs @@ -0,0 +1,54 @@ +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')) { + 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}`); + 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); + 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; +} +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').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`; + 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/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/audio/resources.mjs b/src/main/audio/resources.mjs new file mode 100644 index 0000000..048a26c --- /dev/null +++ b/src/main/audio/resources.mjs @@ -0,0 +1,21 @@ +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'; +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}`); + } catch (error) { throw new Error(`FFmpeg 资源缺失、架构错误或无法运行,请重新准备资源或安装客户端。${error.message}`); } + return 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..f2a0085 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1,102 +1,49 @@ -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'; +import { verifyFfmpegResource } from './audio/resources.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 })); + 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 }; + 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..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'; @@ -7,31 +7,33 @@ import file from './file'; import { playerIpc } from './player'; let mainWindow = null; +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', () => { - 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/installers.mjs b/tests/app/installers.mjs new file mode 100644 index 0000000..cb3d088 --- /dev/null +++ b/tests/app/installers.mjs @@ -0,0 +1,49 @@ +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'; +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, 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 }); + 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') { + // Run the actual AppImage using its supported extraction mode on CI. + run('chmod', ['+x', files[0]]); + smoke(files[0]); + 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'); + 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'); + 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; 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 new file mode 100644 index 0000000..a5796c6 --- /dev/null +++ b/tests/app/smoke.mjs @@ -0,0 +1,77 @@ +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=${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); + 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 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 = []; + 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 }) => { + 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 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)); + console.log('Packaged client smoke passed: UI, version, native libraries, Unicode project/bin, curves, DSP params and shutdown.'); +} finally { await application.close(); } diff --git a/tests/app/startup-failures.mjs b/tests/app/startup-failures.mjs new file mode 100644 index 0000000..feec5fd --- /dev/null +++ b/tests/app/startup-failures.mjs @@ -0,0 +1,37 @@ +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(/架构与当前客户端不匹配/); + 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); } +console.log('Packaged startup failures: missing/corrupt resources and wrong architecture rejected explicitly.'); 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/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 }); 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/); +}); 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/); +});