diff --git a/.github/workflows/build-client.yml b/.github/workflows/build-client.yml new file mode 100644 index 0000000..d3ec594 --- /dev/null +++ b/.github/workflows/build-client.yml @@ -0,0 +1,464 @@ +# 构建桌面客户端(desktop/,Tauri v2 + React)。 +# +# 产物都挂在这次运行下面(Actions 页面直接下载),保留 14 天: +# Linux x64 deb + AppImage cam-desktop_<版本>_amd64.deb / .AppImage ← 默认每次编 +# Windows x64 NSIS 安装包 cam-desktop_<版本>_x64-setup.exe ← 有 runner 才编 +# macOS 通用 .app + .dmg arm64 与 x86_64 合成一个包 ← 有 runner 才编 +# +# ── 触发 ───────────────────────────────────────────────────────────────────── +# * push 到 main/master —— 只认 desktop/、protocol/ 与本文件的改动,改固件不会 +# 白占 runner +# * 打 v* tag、开 PR +# * 手动 Run workflow:逐个平台勾选,不进 paths 过滤("就想编一次"走它) +# Windows 与 macOS 两个 job 目前默认不跑,原因与打开方式见下面第 1 条。 +# +# ── 跑在 Gitea 上要先对上的两件事 ──────────────────────────────────────────── +# 1. runner 标签是注册 runner 时自己起的名字,猜不到,所以三个 job 都写成 +# "仓库变量优先、常见标签兜底": +# runs-on: ${{ vars.RUNNER_LINUX || 'ubuntu-latest' }} +# 标签对不上时 job 是**排队**而不是报错 —— 排队会一直占着这次运行不结束, +# 所以第一次跑之前先看一眼 runner 页面上那几行标签。 +# +# 本仓库目前只有 Linux 的两个 runner 标签(ubuntu-latest / ubuntu-24.04 / +# ubuntu-22.04),所以**默认只跑 Linux**:Windows 与 macOS 两个 job 要等 +# 仓库变量 ENABLE_WINDOWS / ENABLE_MACOS 设成 true 才会跑(手动 Run workflow +# 时另看勾选框),机器的标签则用 RUNNER_WINDOWS / RUNNER_MACOS 指过去。 +# 等那两台机器上的 runner 注册好再打开,否则每次 push 都会多出两个永远排队的 +# job。 +# +# 为什么不能拿 Linux runner 交叉编出来: +# * Windows:Rust 侧用 mingw 交叉编还能出个裸 exe,但 tauri 的 NSIS 打包器 +# 本身只能在 Windows 上跑,出不了安装包; +# * macOS:要 Apple SDK 与 codesign/lipo,只能在 macOS 上编。 +# 所以这两个平台的 job 留着是为了"机器到位那天直接开",不是摆设。 +# 那两台机器上要预装好:Windows 的 MSVC Build Tools(link.exe)、macOS 的 +# Xcode Command Line Tools,以及 rustup(dtolnay/rust-toolchain 靠它装工具链)。 +# Linux 的 job 通常跑在容器里,所以没装 rustup 也没关系 —— 见下面那步兜底。 +# 2. 这个文件放在 .github/workflows/ 下就够:Gitea 的 [actions] WORKFLOW_DIRS +# 默认值就是 [".gitea/workflows", ".github/workflows"](源码里的默认值,且 +# .yml 与 .yaml 都认),所以两个目录都是原生支持的,不用挪。 +# +# ── 三个平台都显式写 --target,这不是啰嗦 ──────────────────────────────────── +# desktop/.cargo/config.toml 里钉着 `target = "aarch64-apple-darwin"`(本机开发用; +# 它存在的理由是 desktop/ 嵌在固件目录树里,不然会继承根目录那份 +# xtensa-esp32s3-espidf 的交叉编译配置)。CI 上不覆盖它,Linux 与 Windows 的 runner +# 会去编 macOS 的目标,然后在依赖深处以 libc 找不到 MAP_FAILED 这类形式失败。 +# 所以每个 job 都把真正的目标交两遍:CARGO_BUILD_TARGET 给裸 cargo(测试)用, +# --target 给 tauri build 用(打包路径也跟着它走)。 +# +# ── 还有一条:仓库根目录的 rust-toolchain.toml 钉的是 `esp` ───────────────── +# 那是给 ESP32 固件的定制工具链,CI 上没有、也不打算装(espup 要下几百 MB)。 +# rustup 是按**当前目录**往上找 rust-toolchain.toml 的: +# * desktop/** 有自己的 rust-toolchain.toml(stable),所以那里的 cargo 直接可用; +# * protocol/ 没有,会一路找到根目录那个 esp —— 所以那条测试写成 `cargo +stable`, +# 显式越过它。 +# 任何在仓库根目录直接跑 cargo 的步骤都会去要 esp 工具链,别加。 +# +# ── 没做的事 ──────────────────────────────────────────────────────────────── +# 不签名、不公证:Windows 的 exe 与 macOS 的 app 在别人机器上会被 SmartScreen / +# Gatekeeper 拦一次(右键打开即可)。要做的话给 macOS 那几个 job 加 +# APPLE_CERTIFICATE / APPLE_SIGNING_IDENTITY / APPLE_ID / APPLE_PASSWORD / +# APPLE_TEAM_ID(tauri 认这些环境变量,构建步骤不用改)。也没有自动发 Release: +# 要发的话在最后加一个下载全部 artifact 再创建 release 的 job。 + +name: 构建客户端 + +on: + push: + branches: [main, master] + tags: ["v*"] + # GitHub 语义下 tag 推送不受 paths 过滤影响;如果你们的 Gitea 版本把 tag 也 + # 一起过滤掉了(打 tag 不触发),把这三行 paths 删掉即可。 + paths: + - "desktop/**" + - "protocol/**" + - ".github/workflows/build-client.yml" + pull_request: + paths: + - "desktop/**" + - "protocol/**" + - ".github/workflows/build-client.yml" + workflow_dispatch: + inputs: + # Linux 的 runner 有,默认勾上;另两个平台目前没有 runner,默认不勾 —— + # 勾了而没有对应 runner,那个 job 会一直排队。 + linux: + description: "构建 Linux(deb + AppImage)" + type: boolean + default: true + windows: + description: "构建 Windows(NSIS 安装包)——需要 Windows runner" + type: boolean + default: false + macos: + description: "构建 macOS(通用 dmg)——需要 macOS runner" + type: boolean + default: false + +# 同一分支连推两次时把上一次没跑完的取消掉:自建 runner 数量有限,让过期的构建 +# 占着机器没有意义。老版本 runner 忽略这个键,不影响构建。 +concurrency: + group: build-client-${{ github.ref }} + cancel-in-progress: true + +env: + # 与本机开发对齐:node 24 / pnpm 12(pnpm-lock.yaml 就是 pnpm 12.8.1 写的, + # lockfileVersion 9.0)。改这两个数之前先确认 lock 文件还认。 + NODE_VERSION: "24" + PNPM_VERSION: "12" + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + +jobs: + # ─────────────────────────────────────────────────────────────────────────── + # 守门:类型检查、前端构建、两个不依赖窗口的 crate 的单元测试。 + # 下面每个构建 job 都 needs 它 —— 编到一半才发现 tsc 报错,是最浪费 runner 的 + # 一种失败。src-tauri 的 30 个测试放在 Linux 那个 job 里跑(它本来就要装 + # webkit 依赖,而且那几条录制测试要真的起 ffmpeg)。 + # ─────────────────────────────────────────────────────────────────────────── + checks: + name: 检查(类型 + 单元测试) + runs-on: ${{ vars.RUNNER_LINUX || 'ubuntu-latest' }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + # runner 镜像里多半没有 rustup,而 dtolnay/rust-toolchain 靠它装工具链。 + # 这里只装一个最小的 rustup 本身,工具链交给下面那个 action —— 这样自建 + # 镜像不用为了 CI 专门烤一个带 Rust 的镜像。 + # 国内网络慢的话,在 runner 的环境变量里给 RUSTUP_DIST_SERVER 指一个镜像 + # (如 https://mirrors.ustc.edu.cn/rust-static)会快很多。 + - name: 确保有 rustup + run: | + if command -v rustup >/dev/null 2>&1; then + rustup --version + exit 0 + fi + if command -v curl >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain none + elif command -v wget >/dev/null 2>&1; then + wget -qO- https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain none + else + echo "装 rustup 需要 curl 或 wget,这个镜像里两个都没有" >&2 + exit 1 + fi + if [ -n "$GITHUB_PATH" ]; then + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + fi + + # pnpm 必须在 setup-node 之前:setup-node 的 cache: pnpm 要用它算 store 路径。 + - name: 装 pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: 装 Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + cache-dependency-path: desktop/pnpm-lock.yaml + + - name: 前端依赖(严格按 lock 装) + working-directory: desktop + run: pnpm install --frozen-lockfile + + - name: 类型检查(tsc --noEmit) + working-directory: desktop + run: pnpm run check + + - name: 前端构建(vite) + working-directory: desktop + run: pnpm build + + - name: 装 Rust(stable) + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + # 只缓存 registry(crate 源码),不缓存 target/:自建 runner 的容器每次都是 + # 干净的,几 GB 的 target 缓存拉下来的时间常常比重新编译还长,磁盘也扛不住。 + # 缓存服务在不同 Gitea 版本上差异较大,所以失败不让它拖垮构建。 + - name: cargo 缓存 + uses: actions/cache@v4 + continue-on-error: true + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + # protocol 没有自己的 rust-toolchain.toml,会被根目录那个 `esp` 接住, + # 所以这里必须 +stable 显式指定。 + - name: protocol 单元测试(15) + working-directory: protocol + run: cargo +stable test --locked --target x86_64-unknown-linux-gnu + + # core 在 desktop/ 下面,desktop/rust-toolchain.toml 已经是 stable,不用 +stable。 + - name: cam-core 单元测试(80) + working-directory: desktop/core + run: cargo test --locked --target x86_64-unknown-linux-gnu + + # ─────────────────────────────────────────────────────────────────────────── + # Linux x64:deb + AppImage + # ─────────────────────────────────────────────────────────────────────────── + linux: + name: Linux x64(deb + AppImage) + needs: checks + if: ${{ github.event.inputs.linux != 'false' }} + runs-on: ${{ vars.RUNNER_LINUX || 'ubuntu-latest' }} + timeout-minutes: 120 + env: + TARGET: x86_64-unknown-linux-gnu + CARGO_BUILD_TARGET: x86_64-unknown-linux-gnu + steps: + - uses: actions/checkout@v4 + + - name: 确保有 rustup + run: | + if command -v rustup >/dev/null 2>&1; then + rustup --version + exit 0 + fi + if command -v curl >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain none + elif command -v wget >/dev/null 2>&1; then + wget -qO- https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain none + else + echo "装 rustup 需要 curl 或 wget,这个镜像里两个都没有" >&2 + exit 1 + fi + if [ -n "$GITHUB_PATH" ]; then + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + fi + + - name: 装系统依赖 + run: | + # 容器里跑的 runner 通常是 root,没有 sudo;托管 runner 上有。 + SUDO="" + [ "$(id -u)" -eq 0 ] || SUDO=sudo + $SUDO apt-get update + $SUDO apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + libxdo-dev \ + libssl-dev \ + xdg-utils \ + build-essential curl wget file \ + ffmpeg + # AppImage 打包要运行 linuxdeploy(它本身是个 AppImage);24.04 起包名带 t64。 + $SUDO apt-get install -y libfuse2 || $SUDO apt-get install -y libfuse2t64 || true + + - name: 装 Rust(stable) + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + - name: 装 pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: 装 Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + cache-dependency-path: desktop/pnpm-lock.yaml + + - name: 前端依赖(严格按 lock 装) + working-directory: desktop + run: pnpm install --frozen-lockfile + + - name: cargo 缓存 + uses: actions/cache@v4 + continue-on-error: true + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + # 上面 apt 装了 ffmpeg,所以 recording.rs 里那几条"真的起 ffmpeg 并读回帧数" + # 的测试不会走 has_ffmpeg() 的跳过分支,是真的在验。 + # 如果这台 runner 上它们长期不稳定,删掉这一步即可 —— 它们不在守门 job 里, + # 不影响另外两个平台的产物。 + - name: Tauri 后端单元测试(30) + working-directory: desktop/src-tauri + run: cargo test --locked --target ${{ env.TARGET }} + + # beforeBuildCommand(pnpm build)由 tauri 自己跑,这里不用再 build 一次。 + - name: 打包(tauri build) + working-directory: desktop + run: pnpm tauri build --target ${{ env.TARGET }} --bundles deb,appimage + + - name: 上传产物 + uses: actions/upload-artifact@v4 + with: + name: cam-desktop-linux-x64 + # 一个都没找到就报错:产物路径写错却"构建成功"是最难发现的一种失败。 + if-no-files-found: error + retention-days: 14 + path: | + desktop/src-tauri/target/${{ env.TARGET }}/release/bundle/deb/*.deb + desktop/src-tauri/target/${{ env.TARGET }}/release/bundle/appimage/*.AppImage + + # ─────────────────────────────────────────────────────────────────────────── + # Windows x64:NSIS 安装包 + # ─────────────────────────────────────────────────────────────────────────── + windows: + name: Windows x64(NSIS 安装包) + needs: checks + # 默认不跑:本仓库目前没有 Windows runner,跑了只会一直排队。有了机器之后 + # 在仓库变量里加 ENABLE_WINDOWS=true 就会跟着每次 push 自动跑;手动触发时 + # 勾 "构建 Windows" 也能单独跑一次。标签用 RUNNER_WINDOWS 指。 + if: >- + ${{ + (github.event_name == 'workflow_dispatch' && github.event.inputs.windows == 'true') + || (github.event_name != 'workflow_dispatch' && vars.ENABLE_WINDOWS == 'true') + }} + # 自建 Windows runner 上要有 MSVC Build Tools(tauri 的 Rust 侧要 link.exe)、 + # WebView2 运行时(装了包的应用要它才能起窗口;打包本身不需要)与 rustup。 + runs-on: ${{ vars.RUNNER_WINDOWS || 'windows-latest' }} + timeout-minutes: 120 + env: + TARGET: x86_64-pc-windows-msvc + CARGO_BUILD_TARGET: x86_64-pc-windows-msvc + steps: + - uses: actions/checkout@v4 + + - name: 装 Rust(stable) + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: 装 pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: 装 Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + cache-dependency-path: desktop/pnpm-lock.yaml + + - name: 前端依赖(严格按 lock 装) + working-directory: desktop + run: pnpm install --frozen-lockfile + + - name: cargo 缓存 + uses: actions/cache@v4 + continue-on-error: true + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + # nsis 是纯安装包,不需要 WiX;tauri 会自己把 NSIS 下到用户缓存里,所以 + # 这台 runner 要能访问 GitHub(离线环境改成 --bundles msi 也一样要下 WiX)。 + - name: 打包(tauri build) + working-directory: desktop + run: pnpm tauri build --target ${{ env.TARGET }} --bundles nsis + + - name: 上传产物 + uses: actions/upload-artifact@v4 + with: + name: cam-desktop-windows-x64 + if-no-files-found: error + retention-days: 14 + path: | + desktop/src-tauri/target/${{ env.TARGET }}/release/bundle/nsis/*.exe + + # ─────────────────────────────────────────────────────────────────────────── + # macOS 通用包:.app + .dmg + # ─────────────────────────────────────────────────────────────────────────── + macos: + name: macOS 通用(.app + .dmg) + needs: checks + # 同 Windows:默认不跑,有机器之后设仓库变量 ENABLE_MACOS=true,标签用 + # RUNNER_MACOS 指。 + if: >- + ${{ + (github.event_name == 'workflow_dispatch' && github.event.inputs.macos == 'true') + || (github.event_name != 'workflow_dispatch' && vars.ENABLE_MACOS == 'true') + }} + # 自建 macOS runner 上要有 Xcode Command Line Tools(链接要它)与 rustup。 + runs-on: ${{ vars.RUNNER_MACOS || 'macos-latest' }} + timeout-minutes: 150 + env: + # 注意:这里**不能**设 CARGO_BUILD_TARGET —— universal-apple-darwin 是 tauri + # 自己的伪目标(编两遍再 lipo 合成),裸 cargo 不认它。这个 job 里也没有裸 + # cargo 步骤,所有目标都由 tauri build 的 --target 交代。 + TARGET: universal-apple-darwin + steps: + - uses: actions/checkout@v4 + + # 通用包要两个架构的 std 都在。 + - name: 装 Rust(stable + 两个 mac 目标) + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + + - name: 装 pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: 装 Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + cache-dependency-path: desktop/pnpm-lock.yaml + + - name: 前端依赖(严格按 lock 装) + working-directory: desktop + run: pnpm install --frozen-lockfile + + - name: cargo 缓存 + uses: actions/cache@v4 + continue-on-error: true + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('protocol/Cargo.lock', 'desktop/core/Cargo.lock', 'desktop/src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + # 只想要当前架构、想省一半编译时间的话,把 --target 换成 aarch64-apple-darwin + # (Apple Silicon 的 runner)并去掉上面 targets 里的 x86_64 即可。 + - name: 打包(tauri build) + working-directory: desktop + run: pnpm tauri build --target ${{ env.TARGET }} --bundles app,dmg + + # 第二行是兜底:不同 tauri-cli 版本给通用包放的目录略有差异,宁可多写一行, + # 也不要上传时静默少东西。(注意 path 是块标量,里面写 # 是路径不是注释。) + - name: 上传产物 + uses: actions/upload-artifact@v4 + with: + name: cam-desktop-macos-universal + if-no-files-found: error + retention-days: 14 + path: | + desktop/src-tauri/target/${{ env.TARGET }}/release/bundle/dmg/*.dmg + desktop/src-tauri/target/*/release/bundle/dmg/*.dmg