From 330a1101f825e3723c58094a89cd6072d236a3f4 Mon Sep 17 00:00:00 2001 From: Karl-Dai Karl Date: Thu, 13 Aug 2026 11:05:18 +0800 Subject: [PATCH 1/2] ci: align automated release workflow with IEC104 --- .github/workflows/ci.yml | 49 ---- .github/workflows/nightly-release.yml | 202 ++++++++++++++++ .github/workflows/release-on-merge.yml | 135 +++++++++++ .github/workflows/release.yml | 304 +++++++++++++++++-------- .github/workflows/test.yml | 100 ++++++++ scripts/build-release-notes.mjs | 4 + scripts/build-release-notes.test.mjs | 47 ++++ scripts/prepare-release.mjs | 225 ++++++++++++++++++ scripts/prepare-release.test.mjs | 92 ++++++++ 9 files changed, 1010 insertions(+), 148 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/nightly-release.yml create mode 100644 .github/workflows/release-on-merge.yml create mode 100644 .github/workflows/test.yml create mode 100644 scripts/build-release-notes.test.mjs create mode 100644 scripts/prepare-release.mjs create mode 100644 scripts/prepare-release.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index b3f03f5..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: [main] - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - rust: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v4 - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libudev-dev - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - name: Check formatting - run: cargo fmt --all -- --check - - name: Lint - run: cargo clippy --workspace --all-targets -- -D warnings - - name: Test - run: cargo test --workspace - - frontend: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - name: Install locked dependencies - run: npm ci --ignore-scripts - - name: Audit dependencies - run: npm audit --audit-level=high - - name: Test shared frontend - run: npm test -w shared-frontend - - name: Build slave frontend - run: npm run build -w frontend - - name: Build master frontend - run: npm run build -w master-frontend diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml new file mode 100644 index 0000000..212f7ee --- /dev/null +++ b/.github/workflows/nightly-release.yml @@ -0,0 +1,202 @@ +name: Nightly release preparation + +on: + schedule: + # 00:07 Asia/Shanghai, away from GitHub's start-of-hour congestion window. + - cron: '7 0 * * *' + timezone: 'Asia/Shanghai' + workflow_dispatch: + +permissions: + actions: write + contents: write + pull-requests: write + +concurrency: + group: nightly-release-preparation + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Find unreleased commits + id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + latest_tag="$(gh release view --json tagName --jq .tagName)" + if ! git rev-parse --verify --quiet "refs/tags/$latest_tag" >/dev/null; then + git fetch origin "refs/tags/$latest_tag:refs/tags/$latest_tag" + fi + if git merge-base --is-ancestor "$latest_tag" HEAD; then + unreleased="$(git rev-list --count "$latest_tag"..HEAD)" + else + echo "::error::Latest published release $latest_tag is not an ancestor of main" + exit 1 + fi + + echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT" + echo "unreleased=$unreleased" >> "$GITHUB_OUTPUT" + if [ "$unreleased" -eq 0 ]; then + echo "No commits after $latest_tag" + echo "prepare=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + next_version="$(node scripts/prepare-release.mjs next "$latest_tag")" + current_version="$(node scripts/prepare-release.mjs current)" + echo "next_version=$next_version" >> "$GITHUB_OUTPUT" + echo "branch=automation/release-v$next_version" >> "$GITHUB_OUTPUT" + if [ "$current_version" = "${latest_tag#v}" ]; then + echo "prepare=true" >> "$GITHUB_OUTPUT" + elif [ "$current_version" = "$next_version" ]; then + node scripts/prepare-release.mjs verify-current + echo "v$current_version is already prepared on main and is waiting to be released." + echo "prepare=false" >> "$GITHUB_OUTPUT" + else + echo "::error::App version $current_version is neither latest release ${latest_tag#v} nor next patch $next_version" + exit 1 + fi + + - name: Stop when main is already fully released + if: steps.release.outputs.unreleased == '0' + run: echo "main matches ${{ steps.release.outputs.latest_tag }}; no release PR is needed." + + - name: Recover or refuse an existing release branch + id: branch + if: steps.release.outputs.prepare == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_BRANCH: ${{ steps.release.outputs.branch }} + shell: bash + run: | + set -euo pipefail + if git ls-remote --exit-code --heads origin "$RELEASE_BRANCH" >/dev/null 2>&1; then + pr_url="$(gh pr list --state open --head "$RELEASE_BRANCH" --json url --jq '.[0].url // empty')" + if [ -n "$pr_url" ]; then + echo "Release PR already exists: $pr_url" + echo "exists=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Recover only an exact generated release commit whose parent is + # the current main. Never adopt a stale or unrelated branch. + git fetch origin "refs/heads/$RELEASE_BRANCH:refs/remotes/origin/$RELEASE_BRANCH" + branch_ref="refs/remotes/origin/$RELEASE_BRANCH" + branch_sha="$(git rev-parse "$branch_ref")" + parent_sha="$(git rev-parse "$branch_ref^")" + version="${RELEASE_BRANCH#automation/release-v}" + subject="$(git log -1 --format=%s "$branch_ref")" + if [ "$parent_sha" != "$GITHUB_SHA" ] || [ "$subject" != "chore(release): prepare v$version" ]; then + echo "::error::Remote branch $RELEASE_BRANCH is not the expected release commit on current main" + exit 1 + fi + + recovery_dir="$(mktemp -d)" + git worktree add --detach "$recovery_dir" "$branch_sha" + if ! (cd "$recovery_dir" && node scripts/prepare-release.mjs verify "v$version"); then + git worktree remove --force "$recovery_dir" + echo "::error::Remote branch $RELEASE_BRANCH failed release metadata validation" + exit 1 + fi + git worktree remove --force "$recovery_dir" + + body_file="$(mktemp)" + cat > "$body_file" <> "$GITHUB_OUTPUT" + exit 0 + fi + echo "exists=false" >> "$GITHUB_OUTPUT" + + - name: Prepare patch release + if: steps.release.outputs.prepare == 'true' && steps.branch.outputs.exists != 'true' + env: + LATEST_TAG: ${{ steps.release.outputs.latest_tag }} + NEXT_VERSION: ${{ steps.release.outputs.next_version }} + shell: bash + run: | + node scripts/prepare-release.mjs prepare \ + --from "$LATEST_TAG" \ + --version "$NEXT_VERSION" \ + --date "$(TZ=Asia/Shanghai date +%F)" + + - name: Test release automation + if: steps.release.outputs.prepare == 'true' && steps.branch.outputs.exists != 'true' + run: node --test scripts/*.test.mjs + + - name: Commit release preparation + if: steps.release.outputs.prepare == 'true' && steps.branch.outputs.exists != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LATEST_TAG: ${{ steps.release.outputs.latest_tag }} + NEXT_VERSION: ${{ steps.release.outputs.next_version }} + RELEASE_BRANCH: ${{ steps.release.outputs.branch }} + shell: bash + run: | + set -euo pipefail + git switch -c "$RELEASE_BRANCH" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + CHANGELOG.md \ + crates/modbussim-app/Cargo.toml \ + crates/modbussim-app/tauri.conf.json \ + crates/modbusmaster-app/Cargo.toml \ + crates/modbusmaster-app/tauri.conf.json + git commit -m "chore(release): prepare v$NEXT_VERSION" + git push origin "$RELEASE_BRANCH" + + body_file="$(mktemp)" + cat > "$body_file" <- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Identify the merged release PR + id: pull_request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + version="$(node scripts/prepare-release.mjs current)" + tag="v$version" + + # Every successful Test run on main emits workflow_run. Once this + # version is published, later feature commits must be a clean no-op + # rather than trying to release the same merged preparation PR again. + if gh release view "$tag" >/dev/null 2>&1; then + echo "$tag is already published; nothing to publish." + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + release_branch="$(gh pr list \ + --state merged \ + --base main \ + --search "head:automation/release-v$version" \ + --json headRefName \ + --jq ".[] | select(.headRefName == \"automation/release-v$version\") | .headRefName" \ + | head -n 1)" + if [ -z "$release_branch" ]; then + echo "Current version $version has no matching merged release PR; nothing to publish." + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "release=true" >> "$GITHUB_OUTPUT" + echo "branch=$release_branch" >> "$GITHUB_OUTPUT" + + - name: Validate release metadata + if: steps.pull_request.outputs.release == 'true' + id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_BRANCH: ${{ steps.pull_request.outputs.branch }} + shell: bash + run: | + set -euo pipefail + version="${RELEASE_BRANCH#automation/release-v}" + tag="v$version" + current="$(node scripts/prepare-release.mjs current)" + if [ "$current" != "$version" ]; then + echo "::error::Release branch says $version but app metadata says $current" + exit 1 + fi + node scripts/prepare-release.mjs verify "$tag" + latest_tag="$(gh release view --json tagName --jq .tagName)" + expected="$(node scripts/prepare-release.mjs next "$latest_tag")" + if [ "$version" != "$expected" ]; then + echo "::error::Expected patch version $expected after $latest_tag, got $version" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Create release tag + if: steps.pull_request.outputs.release == 'true' + env: + TAG: ${{ steps.release.outputs.tag }} + shell: bash + run: | + set -euo pipefail + git fetch origin --tags --force + if git rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null; then + if ! git merge-base --is-ancestor "$TAG" "$GITHUB_SHA"; then + echo "::error::Tag $TAG does not belong to the tested main history" + exit 1 + fi + echo "$TAG already exists on the tested main history; continuing idempotently." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "$TAG" + git push origin "$TAG" + + # Tags pushed with GITHUB_TOKEN do not trigger another push workflow. + - name: Start multi-platform release build + if: steps.pull_request.outputs.release == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.release.outputs.tag }} + shell: bash + run: | + set -euo pipefail + existing="$( + gh run list \ + --workflow release.yml \ + --branch "$TAG" \ + --limit 20 \ + --json headSha,status,conclusion \ + --jq ".[] | select(.headSha == \"$GITHUB_SHA\") | [.status, (.conclusion // \"\")] | @tsv" \ + | head -n 1 + )" + if [[ "$existing" == queued$'\t'* || "$existing" == in_progress$'\t'* || "$existing" == completed$'\t'success ]]; then + echo "Release workflow already active or successful for $TAG: $existing" + exit 0 + fi + gh workflow run release.yml --ref "$TAG" -f tag="$TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41da158..5626f9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,167 +4,273 @@ on: push: tags: - 'v*' + workflow_dispatch: + inputs: + tag: + description: Existing v* tag to build and publish + required: true + type: string + +permissions: + contents: write + +env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} jobs: - release: - permissions: - contents: write + validate-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Validate tag and release metadata + shell: bash + run: | + set -euo pipefail + if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid stable release tag: $RELEASE_TAG" + exit 1 + fi + if [ "$GITHUB_SHA" != "$(git rev-list -n 1 "$RELEASE_TAG")" ]; then + echo "::error::Workflow ref $GITHUB_SHA does not match $RELEASE_TAG" + exit 1 + fi + node scripts/prepare-release.mjs verify "$RELEASE_TAG" + + build: + name: Build ${{ matrix.app.role }} (${{ matrix.target.label }}) + needs: validate-release strategy: fail-fast: false matrix: - include: - - platform: macos-latest + app: + - role: Slave + frontend: frontend + project: crates/modbussim-app + crate_bin: modbussim-app + product: ModbusSlave + - role: Master + frontend: master-frontend + project: crates/modbusmaster-app + crate_bin: modbusmaster-app + product: ModbusMaster + target: + - label: macOS Apple Silicon + platform: macos-latest args: '--target aarch64-apple-darwin' - - platform: macos-latest + rust_target: aarch64-apple-darwin + win_arch: '' + - label: macOS Intel + platform: macos-latest args: '--target x86_64-apple-darwin' - - platform: ubuntu-22.04 + rust_target: x86_64-apple-darwin + win_arch: '' + - label: Linux x64 + platform: ubuntu-22.04 args: '' - - platform: windows-latest + rust_target: '' + win_arch: '' + - label: Windows x64 + platform: windows-latest args: '' - runs-on: ${{ matrix.platform }} - + rust_target: '' + win_arch: x64 + - label: Windows ARM64 + platform: windows-latest + args: '--target aarch64-pc-windows-msvc' + rust_target: aarch64-pc-windows-msvc + win_arch: arm64 + runs-on: ${{ matrix.target.platform }} steps: - uses: actions/checkout@v4 - - name: Install Node.js - uses: actions/setup-node@v4 + - name: Install dependencies (Ubuntu) + if: matrix.target.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libudev-dev + + - uses: actions/setup-node@v4 with: node-version: 22 + cache: npm + cache-dependency-path: package-lock.json - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: - targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + targets: ${{ matrix.target.rust_target }} - - name: Install Linux dependencies - if: matrix.platform == 'ubuntu-22.04' - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libudev-dev + - uses: Swatinem/rust-cache@v2 + with: + workspaces: '${{ matrix.app.project }} -> ../../target' - # --- Install all frontend dependencies from the reviewed workspace lockfile --- - name: Install frontend dependencies - run: npm ci --ignore-scripts + # Install only from the reviewed workspace lockfile; lifecycle scripts + # are unnecessary for the frontend build and remain disabled in CI. + run: npm ci --ignore-scripts --no-audit --no-fund - # --- ModbusSlave --- - - name: Build slave frontend - working-directory: frontend + - name: Build frontend + working-directory: ${{ matrix.app.frontend }} run: npm run build - - name: Build and release ModbusSlave + - name: Build and upload ${{ matrix.app.product }} uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} with: - projectPath: crates/modbussim-app - tagName: ${{ github.ref_name }} - releaseName: 'ModbusSim ${{ github.ref_name }}' - releaseBody: | - ## Downloads - - **ModbusSlave** - Modbus TCP 从站模拟器 - **ModbusMaster** - Modbus TCP 主站工具 - - See the assets below to download for your platform. - # Keep the release a draft during the build: GitHub's - # `releases/latest` skips drafts, so existing users' update checks - # keep hitting the previous *complete* release. publish-manifest - # clears the draft flag once installers + manifest are all in. + projectPath: ${{ matrix.app.project }} + tagName: ${{ env.RELEASE_TAG }} + releaseName: 'ModbusSim ${{ env.RELEASE_TAG }}' + releaseBody: 'See the assets below to download ModbusSlave and ModbusMaster for your platform.' + # Keep the release hidden until every one of the 10 build jobs has + # completed and the updater manifests are ready to be generated. releaseDraft: true prerelease: false - # The project ships its own latest-slave/master.json via - # gen-update-manifest; tauri-action's latest.json is unused. Being a - # single shared filename, every parallel matrix job clobbers it — - # a known "delete-a-release-asset" 404 race. Skip it. + # ModbusSim publishes separate slave/master manifests. A shared + # latest.json from each parallel job would race and is not consumed. includeUpdaterJson: false - args: ${{ matrix.args }} - - # --- ModbusMaster --- - - name: Build master frontend - working-directory: master-frontend - run: npm run build + args: ${{ matrix.target.args }} - - name: Build and release ModbusMaster - uses: tauri-apps/tauri-action@v0 + # tauri-action does not retry every bundle upload. Parallel jobs sharing + # a draft release can hit GitHub's delete/upload race, so re-upload this + # job's artifacts idempotently with bounded retries. + - name: Re-upload bundles with retry + if: always() + shell: bash env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - with: - projectPath: crates/modbusmaster-app - tagName: ${{ github.ref_name }} - releaseName: 'ModbusSim ${{ github.ref_name }}' - releaseBody: | - ## Downloads - - **ModbusSlave** - Modbus TCP 从站模拟器 - **ModbusMaster** - Modbus TCP 主站工具 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ -n "${{ matrix.target.rust_target }}" ]; then + BUNDLE_DIR="target/${{ matrix.target.rust_target }}/release/bundle" + else + BUNDLE_DIR="target/release/bundle" + fi + if [ ! -d "$BUNDLE_DIR" ]; then + echo "::warning::bundle dir $BUNDLE_DIR not found; skipping bundle re-upload" + exit 0 + fi + upload_one() { + for attempt in 1 2 3 4 5; do + if gh release upload "$RELEASE_TAG" "$1" --clobber; then return 0; fi + echo "::warning::upload $(basename "$1") attempt $attempt failed, retrying in 15s" + sleep 15 + done + return 1 + } + case "${{ matrix.target.rust_target }}" in + aarch64-apple-darwin) ARCH=aarch64 ;; + x86_64-apple-darwin) ARCH=x64 ;; + *) ARCH="" ;; + esac + fail=0 + while IFS= read -r file; do + [ -n "$file" ] || continue + name="$(basename "$file")" + # Tauri's macOS updater bundle has no arch suffix on disk, while + # tauri-action uploads an arch-qualified name. Match that contract. + case "$name" in + *.app.tar.gz|*.app.tar.gz.sig) + if [ -n "$ARCH" ]; then + base="${name%%.app.tar.gz*}" + rest="${name#"$base"}" + cp "$file" "$(dirname "$file")/${base}_${ARCH}${rest}" + file="$(dirname "$file")/${base}_${ARCH}${rest}" + fi ;; + esac + upload_one "$file" || { echo "::error::failed to upload $(basename "$file")"; fail=1; } + done < <(find "$BUNDLE_DIR" -type f \( \ + -name '*.dmg' -o -name '*.app.tar.gz' -o -name '*.app.tar.gz.sig' \ + -o -name '*-setup.exe' -o -name '*-setup.exe.sig' \ + -o -name '*.msi' -o -name '*.msi.sig' \ + -o -name '*.AppImage' -o -name '*.AppImage.sig' \ + -o -name '*.deb' -o -name '*.deb.sig' \ + -o -name '*.rpm' -o -name '*.rpm.sig' \) ) + exit $fail - See the assets below to download for your platform. - releaseDraft: true - prerelease: false - includeUpdaterJson: false - args: ${{ matrix.args }} + - name: Upload Windows portable exe + if: always() && matrix.target.platform == 'windows-latest' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${RELEASE_TAG#v}" + ARCH="${{ matrix.target.win_arch }}" + if [ -n "${{ matrix.target.rust_target }}" ]; then + SRC="target/${{ matrix.target.rust_target }}/release/${{ matrix.app.crate_bin }}.exe" + else + SRC="target/release/${{ matrix.app.crate_bin }}.exe" + fi + if [ ! -f "$SRC" ]; then + echo "::warning::cargo binary $SRC not found; skipping portable upload" + exit 0 + fi + DEST="${{ matrix.app.product }}_${VERSION}_${ARCH}-portable.exe" + cp "$SRC" "$DEST" + for attempt in 1 2 3 4 5; do + if gh release upload "$RELEASE_TAG" "$DEST" --clobber; then exit 0; fi + echo "::warning::portable upload attempt $attempt failed, retrying in 10s" + sleep 10 + done + echo "::error::portable upload failed after 5 attempts" + exit 1 publish-manifest: - needs: release + needs: build runs-on: ubuntu-latest - # Needs write access so the GITHUB_TOKEN can list the draft release (drafts - # are only visible to tokens with push access) and PATCH it to published. - # Without this the "clear draft flag" step's release lookup returns empty - # and the job fails with "draft release not found". - permissions: - contents: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - # Un-draft first: this job only runs once every build succeeded, so the - # release is now complete enough to go live. Publishing here (rather than - # last) lets the manifest script run against a normal published release - # — `releases/tags/` and public asset URLs all work. The residual - # window where `releases/latest` lacks the update manifest shrinks from - # the whole build to the few seconds of manifest generation. + + # Draft releases are not returned by /releases/tags/. Resolve the + # numeric id from the authenticated list endpoint, then publish by id. - name: Publish release (clear draft flag) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # `gh release edit ` (and `gh api releases/tags/`) resolve the - # release via GitHub's /releases/tags/ endpoint, which DOES NOT - # return drafts — it 404s with "release not found". Resolve the draft's - # numeric id via the list endpoint (which includes drafts) and PATCH it - # by id instead. Everything after this point sees a published release. + shell: bash run: | - rid=$(gh api repos/${{ github.repository }}/releases \ - --jq '.[] | select(.tag_name=="${{ github.ref_name }}") | .id') - if [ -z "$rid" ]; then - echo "::error::draft release for ${{ github.ref_name }} not found"; exit 1 + set -euo pipefail + release_id="$(gh api "repos/$GITHUB_REPOSITORY/releases" \ + --jq ".[] | select(.tag_name==\"$RELEASE_TAG\") | .id" | head -n 1)" + if [ -z "$release_id" ]; then + echo "::error::draft release for $RELEASE_TAG not found" + exit 1 fi - gh api -X PATCH repos/${{ github.repository }}/releases/"$rid" -F draft=false - - name: Generate manifests + gh api -X PATCH "repos/$GITHUB_REPOSITORY/releases/$release_id" -F draft=false + + - name: Generate updater manifests env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node scripts/gen-update-manifest.mjs ${{ github.ref_name }} - - name: Upload manifests to release + run: node scripts/gen-update-manifest.mjs "$RELEASE_TAG" + + - name: Upload updater manifests env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash run: | for attempt in 1 2 3 4 5; do - if gh release upload ${{ github.ref_name }} \ - latest-slave.json latest-slave-cn0.json latest-slave-cn1.json latest-slave-cn2.json latest-slave-cn3.json \ - latest-master.json latest-master-cn0.json latest-master-cn1.json latest-master-cn2.json latest-master-cn3.json \ + if gh release upload "$RELEASE_TAG" \ + latest-slave*.json \ + latest-master*.json \ --clobber; then exit 0; fi echo "::warning::manifest upload attempt $attempt failed, retrying in 10s" sleep 10 done - echo "::error::manifest upload failed after 5 attempts"; exit 1 + echo "::error::manifest upload failed after 5 attempts" + exit 1 - name: Render rich release body from CHANGELOG - run: node scripts/build-release-notes.mjs ${{ github.ref_name }} + run: node scripts/build-release-notes.mjs "$RELEASE_TAG" - name: Replace release body env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release edit ${{ github.ref_name }} --notes-file RELEASE_BODY.md + run: gh release edit "$RELEASE_TAG" --notes-file RELEASE_BODY.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..cee90a8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,100 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust: + strategy: + fail-fast: false + matrix: + platform: [ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies (Ubuntu) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libudev-dev + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: ${{ matrix.platform == 'ubuntu-22.04' && 'rustfmt,clippy' || '' }} + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: Swatinem/rust-cache@v2 + + # tauri::generate_context! requires both frontendDist directories at compile time. + - name: Create empty frontend dist dirs + shell: bash + run: mkdir -p frontend/dist master-frontend/dist + + - name: Check Rust formatting + if: matrix.platform == 'ubuntu-22.04' + run: cargo fmt --all -- --check + + - name: Lint Rust workspace + if: matrix.platform == 'ubuntu-22.04' + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Run Rust tests + run: cargo test --workspace + + - name: Validate release metadata + run: node scripts/prepare-release.mjs verify-current + + frontend: + strategy: + fail-fast: false + matrix: + app: [frontend, master-frontend] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install workspace dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Audit dependencies + if: matrix.app == 'frontend' + run: npm audit --audit-level=high + + - name: Test shared frontend + if: matrix.app == 'frontend' + run: npm test --workspace shared-frontend + + - name: Type-check and build + working-directory: ${{ matrix.app }} + run: npm run build + + release-scripts: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Test release automation + run: node --test scripts/*.test.mjs diff --git a/scripts/build-release-notes.mjs b/scripts/build-release-notes.mjs index 1c3bed0..3f3e1c7 100644 --- a/scripts/build-release-notes.mjs +++ b/scripts/build-release-notes.mjs @@ -50,6 +50,10 @@ const PLATFORMS = [ { label: 'macOS Intel', file: (p, v) => `${p}_${v}_x64.dmg` }, { label: 'Windows x64 (NSIS)', file: (p, v) => `${p}_${v}_x64-setup.exe` }, { label: 'Windows x64 (MSI)', file: (p, v) => `${p}_${v}_x64_en-US.msi` }, + { label: 'Windows x64 portable', file: (p, v) => `${p}_${v}_x64-portable.exe` }, + { label: 'Windows ARM64 (NSIS)', file: (p, v) => `${p}_${v}_arm64-setup.exe` }, + { label: 'Windows ARM64 (MSI)', file: (p, v) => `${p}_${v}_arm64_en-US.msi` }, + { label: 'Windows ARM64 portable', file: (p, v) => `${p}_${v}_arm64-portable.exe` }, { label: 'Linux AppImage', file: (p, v) => `${p}_${v}_amd64.AppImage` }, { label: 'Linux deb', file: (p, v) => `${p}_${v}_amd64.deb` }, { label: 'Linux rpm', file: (p, v) => `${p}-${v}-1.x86_64.rpm` }, diff --git a/scripts/build-release-notes.test.mjs b/scripts/build-release-notes.test.mjs new file mode 100644 index 0000000..f6f83bb --- /dev/null +++ b/scripts/build-release-notes.test.mjs @@ -0,0 +1,47 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { buildBody } from './build-release-notes.mjs' + +const changelog = `# Changelog + +## [0.17.2] - 2026-08-13 + +### Fixed 修复 + +- point workflow + +## [0.17.1] - 2026-06-12 + +- old +` + +describe('buildBody', () => { + it('renders the matching changelog section and both apps', () => { + const body = buildBody('v0.17.2', changelog) + assert.match(body, /^# ModbusSim v0\.17\.2\b/) + assert.ok(body.includes('### Fixed 修复')) + assert.ok(!body.includes('0.17.1')) + assert.ok(body.includes('ModbusSlave_0.17.2_aarch64.dmg')) + assert.ok(body.includes('ModbusMaster_0.17.2_amd64.AppImage')) + }) + + it('lists x64 and ARM64 Windows installers and portable executables', () => { + const body = buildBody('v0.17.2', changelog) + assert.ok(body.includes('ModbusSlave_0.17.2_x64-setup.exe')) + assert.ok(body.includes('ModbusMaster_0.17.2_x64-portable.exe')) + assert.ok(body.includes('ModbusSlave_0.17.2_arm64-setup.exe')) + assert.ok(body.includes('ModbusMaster_0.17.2_arm64_en-US.msi')) + assert.ok(body.includes('ModbusSlave_0.17.2_arm64-portable.exe')) + }) + + it('keeps mirror and macOS first-launch guidance', () => { + const body = buildBody('v0.17.2', changelog) + assert.ok(body.indexOf('ghfast.top/') < body.indexOf('## 下载 / Downloads')) + assert.ok(body.includes('macOS 首次启动 / First launch on macOS')) + assert.ok(body.includes('xattr -dr com.apple.quarantine')) + }) + + it('warns when the version section is missing', () => { + assert.ok(buildBody('v9.9.9', changelog).includes('CHANGELOG.md 缺少 `9.9.9`')) + }) +}) diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 0000000..3f84ddc --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const VERSION_FILES = [ + 'crates/modbussim-app/Cargo.toml', + 'crates/modbussim-app/tauri.conf.json', + 'crates/modbusmaster-app/Cargo.toml', + 'crates/modbusmaster-app/tauri.conf.json', +] + +const CATEGORY_ORDER = ['Added', 'Fixed', 'Changed', 'Documentation', 'Tests'] +const CATEGORY_TITLES = { + Added: 'Added 新增', + Fixed: 'Fixed 修复', + Changed: 'Changed 改进', + Documentation: 'Documentation 文档', + Tests: 'Tests 测试', +} + +export function normalizeVersion(value) { + const match = String(value).trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/) + if (!match) throw new Error(`expected a stable SemVer tag, got: ${value}`) + return `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}` +} + +export function nextPatchVersion(tag) { + const version = normalizeVersion(tag) + const [major, minor, patch] = version.split('.').map(Number) + return `${major}.${minor}.${patch + 1}` +} + +export function parseCommit(subject) { + const conventional = subject.match( + /^(feat|fix|perf|refactor|docs|test|build|ci|chore)(?:\(([^)]+)\))?(!)?:\s*(.+)$/i, + ) + if (!conventional) return { category: 'Changed', summary: subject } + + const [, rawType, scope, breaking, summary] = conventional + const type = rawType.toLowerCase() + let category = 'Changed' + if (type === 'feat') category = 'Added' + else if (type === 'fix') category = 'Fixed' + else if (type === 'docs') category = 'Documentation' + else if (type === 'test') category = 'Tests' + + const scopedSummary = scope ? `${scope}: ${summary}` : summary + return { + category, + summary: breaking ? `${scopedSummary} (breaking)` : scopedSummary, + } +} + +export function groupCommits(subjects) { + const groups = new Map(CATEGORY_ORDER.map((category) => [category, []])) + for (const subject of subjects.map((item) => item.trim()).filter(Boolean)) { + const commit = parseCommit(subject) + groups.get(commit.category).push(commit.summary) + } + return groups +} + +export function buildChangelogSection(version, date, subjects) { + const groups = groupCommits(subjects) + const lines = [`## [${normalizeVersion(version)}] - ${date}`, ''] + + for (const category of CATEGORY_ORDER) { + const entries = groups.get(category) + if (entries.length === 0) continue + lines.push(`### ${CATEGORY_TITLES[category]}`, '') + lines.push(...entries.map((entry) => `- ${entry}`), '') + } + + lines.push( + '### Notes 说明', + '', + '- 本节由夜间发布自动化根据上一版本后的提交生成;合并发布 PR 前可直接编辑补充 / This section is generated by the nightly release automation from commits since the previous version and can be refined before the release PR is merged.', + '', + ) + return lines.join('\n') +} + +export function updateCargoToml(source, version) { + const updated = source.replace( + /(\[package\][\s\S]*?\nversion = ")[^"]+("\n)/, + `$1${normalizeVersion(version)}$2`, + ) + if (updated === source) throw new Error('could not update Cargo.toml package version') + return updated +} + +export function updateJsonVersion(source, version) { + const updated = source.replace( + /("version"\s*:\s*")[^"]+("\s*,)/, + `$1${normalizeVersion(version)}$2`, + ) + if (updated === source) throw new Error('could not update JSON version') + return updated +} + +export function prependChangelog(source, section) { + const firstRelease = source.indexOf('\n## [') + if (firstRelease < 0) throw new Error('could not find the first CHANGELOG release section') + const heading = section.split('\n', 1)[0] + if (source.includes(`${heading}\n`)) throw new Error(`${heading} already exists in CHANGELOG.md`) + const introduction = source.slice(0, firstRelease).trimEnd() + const history = source.slice(firstRelease).trimStart() + return `${introduction}\n\n${section.trim()}\n\n${history}` +} + +function read(root, path) { + return readFileSync(resolve(root, path), 'utf8') +} + +function write(root, path, value) { + writeFileSync(resolve(root, path), value) +} + +export function currentVersion(root = process.cwd()) { + const match = read(root, VERSION_FILES[0]).match(/\nversion = "([^"]+)"/) + if (!match) throw new Error(`could not read version from ${VERSION_FILES[0]}`) + return normalizeVersion(match[1]) +} + +export function verifyPreparedRelease(root, tag) { + const version = normalizeVersion(tag) + const errors = [] + + for (const path of VERSION_FILES) { + const source = read(root, path) + const match = path.endsWith('.json') + ? source.match(/"version"\s*:\s*"([^"]+)"/) + : source.match(/\nversion = "([^"]+)"/) + if (!match || match[1] !== version) errors.push(`${path} is not version ${version}`) + } + + if (!read(root, 'CHANGELOG.md').includes(`## [${version}]`)) { + errors.push(`CHANGELOG.md has no ${version} section`) + } + + if (errors.length > 0) throw new Error(errors.join('\n')) + return version +} + +export function prepareRelease(root, { fromTag, version, date, subjects }) { + const baseVersion = normalizeVersion(fromTag) + const nextVersion = normalizeVersion(version) + if (currentVersion(root) !== baseVersion) { + throw new Error(`app version must match latest tag ${fromTag} before preparing a release`) + } + if (nextVersion !== nextPatchVersion(fromTag)) { + throw new Error(`expected next patch ${nextPatchVersion(fromTag)}, got ${nextVersion}`) + } + if (subjects.length === 0) throw new Error(`no commits found after ${fromTag}`) + + for (const path of VERSION_FILES) { + const source = read(root, path) + write(root, path, path.endsWith('.json') + ? updateJsonVersion(source, nextVersion) + : updateCargoToml(source, nextVersion)) + } + const section = buildChangelogSection(nextVersion, date, subjects) + write(root, 'CHANGELOG.md', prependChangelog(read(root, 'CHANGELOG.md'), section)) + + verifyPreparedRelease(root, `v${nextVersion}`) +} + +function option(args, name) { + const index = args.indexOf(name) + if (index < 0 || !args[index + 1]) throw new Error(`missing ${name}`) + return args[index + 1] +} + +function gitSubjects(fromTag) { + const output = execFileSync( + 'git', + ['log', '--reverse', '--format=%s', `${fromTag}..HEAD`], + { encoding: 'utf8' }, + ) + return output.split('\n').map((line) => line.trim()).filter(Boolean) +} + +function main(args) { + const [command, ...rest] = args + if (command === 'next') { + if (!rest[0]) throw new Error('usage: prepare-release.mjs next ') + console.log(nextPatchVersion(rest[0])) + return + } + if (command === 'current') { + console.log(currentVersion()) + return + } + if (command === 'verify') { + if (!rest[0]) throw new Error('usage: prepare-release.mjs verify ') + console.log(`release v${verifyPreparedRelease(process.cwd(), rest[0])} is internally consistent`) + return + } + if (command === 'verify-current') { + const version = currentVersion() + console.log(`release v${verifyPreparedRelease(process.cwd(), version)} is internally consistent`) + return + } + if (command === 'prepare') { + const fromTag = option(rest, '--from') + const version = option(rest, '--version') + const date = option(rest, '--date') + const subjects = gitSubjects(fromTag) + prepareRelease(process.cwd(), { fromTag, version, date, subjects }) + console.log(`prepared v${normalizeVersion(version)} from ${subjects.length} commit(s) after ${fromTag}`) + return + } + throw new Error('usage: prepare-release.mjs ...') +} + +if (import.meta.url === `file://${process.argv[1]}`) { + try { + main(process.argv.slice(2)) + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) + } +} diff --git a/scripts/prepare-release.test.mjs b/scripts/prepare-release.test.mjs new file mode 100644 index 0000000..5d6173a --- /dev/null +++ b/scripts/prepare-release.test.mjs @@ -0,0 +1,92 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + buildChangelogSection, + nextPatchVersion, + parseCommit, + prepareRelease, + prependChangelog, + updateCargoToml, + updateJsonVersion, +} from './prepare-release.mjs' + +describe('prepare-release', () => { + it('bumps a stable tag by one patch version', () => { + assert.equal(nextPatchVersion('v0.17.1'), '0.17.2') + assert.throws(() => nextPatchVersion('v0.17.1-rc.1'), /stable SemVer/) + }) + + it('classifies conventional commits and preserves scopes', () => { + assert.deepEqual(parseCommit('fix(slave): complete point workflows (#6)'), { + category: 'Fixed', + summary: 'slave: complete point workflows (#6)', + }) + assert.deepEqual(parseCommit('feat!: change workspace format'), { + category: 'Added', + summary: 'change workspace format (breaking)', + }) + assert.deepEqual(parseCommit('plain subject'), { + category: 'Changed', + summary: 'plain subject', + }) + }) + + it('generates categorized changelog sections', () => { + const changelog = buildChangelogSection('0.17.2', '2026-08-13', [ + 'fix(slave): complete point workflows (#6)', + 'feat(master): add polling preset', + 'docs: explain releases', + ]) + assert.match(changelog, /^## \[0\.17\.2\] - 2026-08-13/) + assert.ok(changelog.includes('### Added 新增\n\n- master: add polling preset')) + assert.ok(changelog.includes('### Fixed 修复\n\n- slave: complete point workflows (#6)')) + }) + + it('updates all supported version file formats', () => { + assert.ok(updateCargoToml('[package]\nname = "app"\nversion = "0.17.1"\n', '0.17.2') + .includes('version = "0.17.2"')) + assert.ok(updateJsonVersion('{\n "version": "0.17.1",\n "x": true\n}\n', '0.17.2') + .includes('"version": "0.17.2"')) + }) + + it('inserts a new changelog section without deleting history', () => { + const oldChangelog = '# Changelog\n\nIntro.\n\n## [0.17.1] - 2026-06-12\n\n- old\n' + const section = buildChangelogSection('0.17.2', '2026-08-13', ['fix: new fix']) + const changelog = prependChangelog(oldChangelog, section) + assert.ok(changelog.indexOf('[0.17.2]') < changelog.indexOf('[0.17.1]')) + assert.ok(changelog.includes('- old')) + }) + + it('prepares and verifies all four ModbusSim version files together', () => { + const root = mkdtempSync(join(tmpdir(), 'modbussim-release-')) + try { + for (const dir of ['crates/modbussim-app', 'crates/modbusmaster-app']) { + mkdirSync(join(root, dir), { recursive: true }) + writeFileSync(join(root, dir, 'Cargo.toml'), '[package]\nname = "app"\nversion = "0.17.1"\n') + writeFileSync(join(root, dir, 'tauri.conf.json'), '{\n "version": "0.17.1",\n "x": true\n}\n') + } + writeFileSync( + join(root, 'CHANGELOG.md'), + '# Changelog\n\nIntro.\n\n## [0.17.1] - 2026-06-12\n\n- old\n', + ) + + prepareRelease(root, { + fromTag: 'v0.17.1', + version: '0.17.2', + date: '2026-08-13', + subjects: ['fix(slave): complete point workflows (#6)'], + }) + + for (const dir of ['crates/modbussim-app', 'crates/modbusmaster-app']) { + assert.ok(readFileSync(join(root, dir, 'Cargo.toml'), 'utf8').includes('version = "0.17.2"')) + assert.ok(readFileSync(join(root, dir, 'tauri.conf.json'), 'utf8').includes('"version": "0.17.2"')) + } + assert.ok(readFileSync(join(root, 'CHANGELOG.md'), 'utf8').includes('## [0.17.2] - 2026-08-13')) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) From 28f9c53ade643108960dcd3e4eb7577ce946a127 Mon Sep 17 00:00:00 2001 From: Karl-Dai Karl Date: Thu, 13 Aug 2026 13:36:55 +0800 Subject: [PATCH 2/2] feat: add silent background update choices --- crates/modbusmaster-app/src/lib.rs | 11 +- crates/modbusmaster-app/src/update.rs | 222 +++++++++++++----- .../modbusmaster-app/tests/update_helpers.rs | 29 +-- crates/modbussim-app/src/lib.rs | 11 +- crates/modbussim-app/src/update.rs | 222 +++++++++++++----- crates/modbussim-app/tests/update_helpers.rs | 29 +-- frontend/src/App.vue | 6 - master-frontend/src/App.vue | 6 - .../src/components/UpdateDialog.vue | 119 ++++------ shared-frontend/src/i18n/locales/en-US.ts | 8 +- shared-frontend/src/i18n/locales/zh-CN.ts | 8 +- 11 files changed, 407 insertions(+), 264 deletions(-) diff --git a/crates/modbusmaster-app/src/lib.rs b/crates/modbusmaster-app/src/lib.rs index afd05c2..0ce4a6a 100644 --- a/crates/modbusmaster-app/src/lib.rs +++ b/crates/modbusmaster-app/src/lib.rs @@ -14,6 +14,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_aptabase::Builder::new(analytics::APTABASE_KEY).build()) .manage(AppState::new()) + .manage(update::UpdateState::default()) .invoke_handler(tauri::generate_handler![ // Connection commands commands::create_master_connection, @@ -60,7 +61,8 @@ pub fn run() { // Update commands update::check_for_update, update::install_update, - update::snooze_update, + update::skip_update, + update::schedule_update_on_next_launch, // Analytics commands analytics::get_analytics_enabled, analytics::set_analytics_enabled, @@ -75,6 +77,13 @@ pub fn run() { )?; } analytics::track_started(app.handle()); + + let update_app = app.handle().clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = update::install_pending_update(update_app).await { + log::warn!("automatic update on launch failed: {error}"); + } + }); Ok(()) }) .build(tauri::generate_context!()) diff --git a/crates/modbusmaster-app/src/update.rs b/crates/modbusmaster-app/src/update.rs index 7c9d9a8..8592b8b 100644 --- a/crates/modbusmaster-app/src/update.rs +++ b/crates/modbusmaster-app/src/update.rs @@ -1,15 +1,15 @@ use chrono::{DateTime, Duration, Utc}; use serde::Serialize; -use tauri::{AppHandle, Emitter}; +use tauri::{AppHandle, Manager, State}; use tauri_plugin_store::StoreExt; -use tauri_plugin_updater::UpdaterExt; +use tauri_plugin_updater::{Update, UpdaterExt}; +use tokio::sync::Mutex; const STORE_FILE: &str = "update_state.json"; const KEY_LAST_CHECK: &str = "last_check_at"; -const KEY_SNOOZED_VER: &str = "snoozed_version"; -const KEY_SNOOZED_UNTIL: &str = "snoozed_until"; +const KEY_SKIPPED_VERSION: &str = "skipped_version"; +const KEY_INSTALL_ON_NEXT_LAUNCH: &str = "install_on_next_launch"; const THROTTLE_HOURS: i64 = 6; -const SNOOZE_HOURS: i64 = 24; #[derive(Serialize, Clone)] pub struct UpdateMeta { @@ -18,11 +18,29 @@ pub struct UpdateMeta { pub pub_date: Option, } +struct PreparedUpdate { + meta: UpdateMeta, + update: Update, + bytes: Vec, +} + +#[derive(Default)] +pub struct UpdateState { + prepared: Mutex>, +} + fn read_str(app: &AppHandle, key: &str) -> Option { let store = app.store(STORE_FILE).ok()?; store.get(key).and_then(|v| v.as_str().map(String::from)) } +fn read_bool(app: &AppHandle, key: &str) -> bool { + let Ok(store) = app.store(STORE_FILE) else { + return false; + }; + store.get(key).and_then(|v| v.as_bool()).unwrap_or(false) +} + fn write_str(app: &AppHandle, key: &str, value: &str) { if let Ok(store) = app.store(STORE_FILE) { store.set(key, serde_json::Value::String(value.to_string())); @@ -30,19 +48,59 @@ fn write_str(app: &AppHandle, key: &str, value: &str) { } } +fn write_bool(app: &AppHandle, key: &str, value: bool) { + if let Ok(store) = app.store(STORE_FILE) { + store.set(key, serde_json::Value::Bool(value)); + let _ = store.save(); + } +} + +fn remove_value(app: &AppHandle, key: &str) { + if let Ok(store) = app.store(STORE_FILE) { + store.delete(key); + let _ = store.save(); + } +} + fn parse_ts(s: Option) -> Option> { s.and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)) } -// `force = true` (toolbar button) bypasses the 6h throttle and 24h snooze. -// Startup auto-checks pass `force = None / false`. +fn update_meta(update: &Update) -> UpdateMeta { + UpdateMeta { + version: update.version.clone(), + notes: update.body.clone().unwrap_or_default(), + pub_date: update.date.map(|d| d.to_string()), + } +} + +async fn download_update(update: &Update) -> Result, String> { + update + .download( + |_, _| {}, + || log::info!("update download finished; verifying release signature"), + ) + .await + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn check_for_update( app: AppHandle, + state: State<'_, UpdateState>, force: Option, ) -> Result, String> { let force = force.unwrap_or(false); + if !force && read_bool(&app, KEY_INSTALL_ON_NEXT_LAUNCH) { + return Ok(None); + } + + let mut prepared = state.prepared.lock().await; + if let Some(update) = prepared.as_ref() { + return Ok(Some(update.meta.clone())); + } + let now = Utc::now(); if !force { let last = parse_ts(read_str(&app, KEY_LAST_CHECK)); @@ -53,67 +111,113 @@ pub async fn check_for_update( write_str(&app, KEY_LAST_CHECK, &now.to_rfc3339()); let updater = app.updater().map_err(|e| e.to_string())?; - // Surface fetch / parse failures to the caller so the UI can distinguish - // "already on latest" (Ok(None)) from "endpoint unreachable / 404"; the - // frontend silences this only for the startup auto-check. - let update = updater.check().await.map_err(|e| e.to_string())?; - let Some(update) = update else { + let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { return Ok(None); }; - - if !force { - let snoozed_v = read_str(&app, KEY_SNOOZED_VER); - let snoozed_u = parse_ts(read_str(&app, KEY_SNOOZED_UNTIL)); - if is_snoozed(snoozed_v.as_deref(), snoozed_u, &update.version, now) { - return Ok(None); - } + if !force + && is_skipped( + read_str(&app, KEY_SKIPPED_VERSION).as_deref(), + &update.version, + ) + { + return Ok(None); } - Ok(Some(UpdateMeta { - version: update.version.clone(), - notes: update.body.clone().unwrap_or_default(), - pub_date: update.date.map(|d| d.to_string()), - })) + let meta = update_meta(&update); + let bytes = download_update(&update).await?; + *prepared = Some(PreparedUpdate { + meta: meta.clone(), + update, + bytes, + }); + Ok(Some(meta)) } #[tauri::command] -pub async fn install_update(app: AppHandle) -> Result<(), String> { - let updater = app.updater().map_err(|e| e.to_string())?; - let update = updater - .check() - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "no update available".to_string())?; - - let mut downloaded: u64 = 0; - let app_clone = app.clone(); - update - .download_and_install( - move |chunk_len, content_len| { - downloaded += chunk_len as u64; - if let Some(total) = content_len { - let pct = (downloaded as f64 / total as f64 * 100.0).round() as u32; - let _ = app_clone.emit("update-progress", pct); - } - }, - || { - log::info!("update downloaded, installing"); - }, - ) - .await +pub async fn install_update(app: AppHandle, state: State<'_, UpdateState>) -> Result<(), String> { + let prepared = state.prepared.lock().await; + let ready = prepared + .as_ref() + .ok_or_else(|| "update package is not ready".to_string())?; + ready + .update + .install(&ready.bytes) .map_err(|e| e.to_string())?; + remove_value(&app, KEY_SKIPPED_VERSION); + remove_value(&app, KEY_INSTALL_ON_NEXT_LAUNCH); + drop(prepared); + app.restart() +} - app.restart(); +#[tauri::command] +pub async fn skip_update( + app: AppHandle, + state: State<'_, UpdateState>, + version: String, +) -> Result<(), String> { + let mut prepared = state.prepared.lock().await; + if !prepared + .as_ref() + .is_some_and(|update| update.meta.version == version) + { + return Err("update package is not ready".to_string()); + } + *prepared = None; + write_str(&app, KEY_SKIPPED_VERSION, &version); + remove_value(&app, KEY_INSTALL_ON_NEXT_LAUNCH); + Ok(()) } #[tauri::command] -pub fn snooze_update(app: AppHandle, version: String) -> Result<(), String> { - let until = Utc::now() + Duration::hours(SNOOZE_HOURS); - write_str(&app, KEY_SNOOZED_VER, &version); - write_str(&app, KEY_SNOOZED_UNTIL, &until.to_rfc3339()); +pub async fn schedule_update_on_next_launch( + app: AppHandle, + state: State<'_, UpdateState>, + version: String, +) -> Result<(), String> { + let prepared = state.prepared.lock().await; + if !prepared + .as_ref() + .is_some_and(|update| update.meta.version == version) + { + return Err("update package is not ready".to_string()); + } + write_bool(&app, KEY_INSTALL_ON_NEXT_LAUNCH, true); + remove_value(&app, KEY_SKIPPED_VERSION); Ok(()) } +pub async fn install_pending_update(app: AppHandle) -> Result<(), String> { + if !read_bool(&app, KEY_INSTALL_ON_NEXT_LAUNCH) { + return Ok(()); + } + + let state = app.state::(); + let mut prepared = state.prepared.lock().await; + let updater = app.updater().map_err(|e| e.to_string())?; + let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { + remove_value(&app, KEY_INSTALL_ON_NEXT_LAUNCH); + return Ok(()); + }; + let meta = update_meta(&update); + let bytes = download_update(&update).await?; + *prepared = Some(PreparedUpdate { + meta, + update, + bytes, + }); + let ready = prepared + .as_ref() + .expect("prepared update was just inserted"); + ready + .update + .install(&ready.bytes) + .map_err(|e| e.to_string())?; + remove_value(&app, KEY_SKIPPED_VERSION); + remove_value(&app, KEY_INSTALL_ON_NEXT_LAUNCH); + drop(prepared); + app.restart() +} + pub fn should_check( last_check: Option>, now: DateTime, @@ -125,14 +229,6 @@ pub fn should_check( } } -pub fn is_snoozed( - snoozed_version: Option<&str>, - snoozed_until: Option>, - remote_version: &str, - now: DateTime, -) -> bool { - match (snoozed_version, snoozed_until) { - (Some(v), Some(until)) => v == remote_version && now < until, - _ => false, - } +pub fn is_skipped(skipped_version: Option<&str>, remote_version: &str) -> bool { + skipped_version == Some(remote_version) } diff --git a/crates/modbusmaster-app/tests/update_helpers.rs b/crates/modbusmaster-app/tests/update_helpers.rs index e6784ba..ea9a65a 100644 --- a/crates/modbusmaster-app/tests/update_helpers.rs +++ b/crates/modbusmaster-app/tests/update_helpers.rs @@ -1,5 +1,5 @@ use chrono::{DateTime, Duration, Utc}; -use modbusmaster_app_lib::update::{is_snoozed, should_check}; +use modbusmaster_app_lib::update::{is_skipped, should_check}; fn ts(s: &str) -> DateTime { DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) @@ -29,31 +29,16 @@ fn should_check_after_throttle_window() { } #[test] -fn snoozed_when_same_version_within_window() { - assert!(is_snoozed( - Some("1.0.9"), - Some(ts("2026-04-29T00:00:00Z")), - "1.0.9", - ts("2026-04-28T10:00:00Z"), - )); +fn skipped_when_versions_match() { + assert!(is_skipped(Some("1.0.9"), "1.0.9")); } #[test] -fn not_snoozed_after_window_expires() { - assert!(!is_snoozed( - Some("1.0.9"), - Some(ts("2026-04-28T09:00:00Z")), - "1.0.9", - ts("2026-04-28T10:00:00Z"), - )); +fn not_skipped_without_a_saved_version() { + assert!(!is_skipped(None, "1.0.9")); } #[test] -fn not_snoozed_for_different_version() { - assert!(!is_snoozed( - Some("1.0.9"), - Some(ts("2026-04-29T00:00:00Z")), - "1.0.10", - ts("2026-04-28T10:00:00Z"), - )); +fn skipped_release_does_not_hide_a_newer_version() { + assert!(!is_skipped(Some("1.0.9"), "1.0.10")); } diff --git a/crates/modbussim-app/src/lib.rs b/crates/modbussim-app/src/lib.rs index 4807b4a..5f9b374 100644 --- a/crates/modbussim-app/src/lib.rs +++ b/crates/modbussim-app/src/lib.rs @@ -22,6 +22,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_aptabase::Builder::new(analytics::APTABASE_KEY).build()) .manage(AppState::new()) + .manage(update::UpdateState::default()) .invoke_handler(tauri::generate_handler![ // Slave connection commands commands::create_slave_connection, @@ -75,7 +76,8 @@ pub fn run() { // Update commands update::check_for_update, update::install_update, - update::snooze_update, + update::skip_update, + update::schedule_update_on_next_launch, // Analytics commands analytics::get_analytics_enabled, analytics::set_analytics_enabled, @@ -91,6 +93,13 @@ pub fn run() { } analytics::track_started(app.handle()); + let update_app = app.handle().clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = update::install_pending_update(update_app).await { + log::warn!("automatic update on launch failed: {error}"); + } + }); + // Start the single point-mutation tick task. let state = app.state::(); mutation::spawn_mutation_tick( diff --git a/crates/modbussim-app/src/update.rs b/crates/modbussim-app/src/update.rs index 7c9d9a8..8592b8b 100644 --- a/crates/modbussim-app/src/update.rs +++ b/crates/modbussim-app/src/update.rs @@ -1,15 +1,15 @@ use chrono::{DateTime, Duration, Utc}; use serde::Serialize; -use tauri::{AppHandle, Emitter}; +use tauri::{AppHandle, Manager, State}; use tauri_plugin_store::StoreExt; -use tauri_plugin_updater::UpdaterExt; +use tauri_plugin_updater::{Update, UpdaterExt}; +use tokio::sync::Mutex; const STORE_FILE: &str = "update_state.json"; const KEY_LAST_CHECK: &str = "last_check_at"; -const KEY_SNOOZED_VER: &str = "snoozed_version"; -const KEY_SNOOZED_UNTIL: &str = "snoozed_until"; +const KEY_SKIPPED_VERSION: &str = "skipped_version"; +const KEY_INSTALL_ON_NEXT_LAUNCH: &str = "install_on_next_launch"; const THROTTLE_HOURS: i64 = 6; -const SNOOZE_HOURS: i64 = 24; #[derive(Serialize, Clone)] pub struct UpdateMeta { @@ -18,11 +18,29 @@ pub struct UpdateMeta { pub pub_date: Option, } +struct PreparedUpdate { + meta: UpdateMeta, + update: Update, + bytes: Vec, +} + +#[derive(Default)] +pub struct UpdateState { + prepared: Mutex>, +} + fn read_str(app: &AppHandle, key: &str) -> Option { let store = app.store(STORE_FILE).ok()?; store.get(key).and_then(|v| v.as_str().map(String::from)) } +fn read_bool(app: &AppHandle, key: &str) -> bool { + let Ok(store) = app.store(STORE_FILE) else { + return false; + }; + store.get(key).and_then(|v| v.as_bool()).unwrap_or(false) +} + fn write_str(app: &AppHandle, key: &str, value: &str) { if let Ok(store) = app.store(STORE_FILE) { store.set(key, serde_json::Value::String(value.to_string())); @@ -30,19 +48,59 @@ fn write_str(app: &AppHandle, key: &str, value: &str) { } } +fn write_bool(app: &AppHandle, key: &str, value: bool) { + if let Ok(store) = app.store(STORE_FILE) { + store.set(key, serde_json::Value::Bool(value)); + let _ = store.save(); + } +} + +fn remove_value(app: &AppHandle, key: &str) { + if let Ok(store) = app.store(STORE_FILE) { + store.delete(key); + let _ = store.save(); + } +} + fn parse_ts(s: Option) -> Option> { s.and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)) } -// `force = true` (toolbar button) bypasses the 6h throttle and 24h snooze. -// Startup auto-checks pass `force = None / false`. +fn update_meta(update: &Update) -> UpdateMeta { + UpdateMeta { + version: update.version.clone(), + notes: update.body.clone().unwrap_or_default(), + pub_date: update.date.map(|d| d.to_string()), + } +} + +async fn download_update(update: &Update) -> Result, String> { + update + .download( + |_, _| {}, + || log::info!("update download finished; verifying release signature"), + ) + .await + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn check_for_update( app: AppHandle, + state: State<'_, UpdateState>, force: Option, ) -> Result, String> { let force = force.unwrap_or(false); + if !force && read_bool(&app, KEY_INSTALL_ON_NEXT_LAUNCH) { + return Ok(None); + } + + let mut prepared = state.prepared.lock().await; + if let Some(update) = prepared.as_ref() { + return Ok(Some(update.meta.clone())); + } + let now = Utc::now(); if !force { let last = parse_ts(read_str(&app, KEY_LAST_CHECK)); @@ -53,67 +111,113 @@ pub async fn check_for_update( write_str(&app, KEY_LAST_CHECK, &now.to_rfc3339()); let updater = app.updater().map_err(|e| e.to_string())?; - // Surface fetch / parse failures to the caller so the UI can distinguish - // "already on latest" (Ok(None)) from "endpoint unreachable / 404"; the - // frontend silences this only for the startup auto-check. - let update = updater.check().await.map_err(|e| e.to_string())?; - let Some(update) = update else { + let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { return Ok(None); }; - - if !force { - let snoozed_v = read_str(&app, KEY_SNOOZED_VER); - let snoozed_u = parse_ts(read_str(&app, KEY_SNOOZED_UNTIL)); - if is_snoozed(snoozed_v.as_deref(), snoozed_u, &update.version, now) { - return Ok(None); - } + if !force + && is_skipped( + read_str(&app, KEY_SKIPPED_VERSION).as_deref(), + &update.version, + ) + { + return Ok(None); } - Ok(Some(UpdateMeta { - version: update.version.clone(), - notes: update.body.clone().unwrap_or_default(), - pub_date: update.date.map(|d| d.to_string()), - })) + let meta = update_meta(&update); + let bytes = download_update(&update).await?; + *prepared = Some(PreparedUpdate { + meta: meta.clone(), + update, + bytes, + }); + Ok(Some(meta)) } #[tauri::command] -pub async fn install_update(app: AppHandle) -> Result<(), String> { - let updater = app.updater().map_err(|e| e.to_string())?; - let update = updater - .check() - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "no update available".to_string())?; - - let mut downloaded: u64 = 0; - let app_clone = app.clone(); - update - .download_and_install( - move |chunk_len, content_len| { - downloaded += chunk_len as u64; - if let Some(total) = content_len { - let pct = (downloaded as f64 / total as f64 * 100.0).round() as u32; - let _ = app_clone.emit("update-progress", pct); - } - }, - || { - log::info!("update downloaded, installing"); - }, - ) - .await +pub async fn install_update(app: AppHandle, state: State<'_, UpdateState>) -> Result<(), String> { + let prepared = state.prepared.lock().await; + let ready = prepared + .as_ref() + .ok_or_else(|| "update package is not ready".to_string())?; + ready + .update + .install(&ready.bytes) .map_err(|e| e.to_string())?; + remove_value(&app, KEY_SKIPPED_VERSION); + remove_value(&app, KEY_INSTALL_ON_NEXT_LAUNCH); + drop(prepared); + app.restart() +} - app.restart(); +#[tauri::command] +pub async fn skip_update( + app: AppHandle, + state: State<'_, UpdateState>, + version: String, +) -> Result<(), String> { + let mut prepared = state.prepared.lock().await; + if !prepared + .as_ref() + .is_some_and(|update| update.meta.version == version) + { + return Err("update package is not ready".to_string()); + } + *prepared = None; + write_str(&app, KEY_SKIPPED_VERSION, &version); + remove_value(&app, KEY_INSTALL_ON_NEXT_LAUNCH); + Ok(()) } #[tauri::command] -pub fn snooze_update(app: AppHandle, version: String) -> Result<(), String> { - let until = Utc::now() + Duration::hours(SNOOZE_HOURS); - write_str(&app, KEY_SNOOZED_VER, &version); - write_str(&app, KEY_SNOOZED_UNTIL, &until.to_rfc3339()); +pub async fn schedule_update_on_next_launch( + app: AppHandle, + state: State<'_, UpdateState>, + version: String, +) -> Result<(), String> { + let prepared = state.prepared.lock().await; + if !prepared + .as_ref() + .is_some_and(|update| update.meta.version == version) + { + return Err("update package is not ready".to_string()); + } + write_bool(&app, KEY_INSTALL_ON_NEXT_LAUNCH, true); + remove_value(&app, KEY_SKIPPED_VERSION); Ok(()) } +pub async fn install_pending_update(app: AppHandle) -> Result<(), String> { + if !read_bool(&app, KEY_INSTALL_ON_NEXT_LAUNCH) { + return Ok(()); + } + + let state = app.state::(); + let mut prepared = state.prepared.lock().await; + let updater = app.updater().map_err(|e| e.to_string())?; + let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { + remove_value(&app, KEY_INSTALL_ON_NEXT_LAUNCH); + return Ok(()); + }; + let meta = update_meta(&update); + let bytes = download_update(&update).await?; + *prepared = Some(PreparedUpdate { + meta, + update, + bytes, + }); + let ready = prepared + .as_ref() + .expect("prepared update was just inserted"); + ready + .update + .install(&ready.bytes) + .map_err(|e| e.to_string())?; + remove_value(&app, KEY_SKIPPED_VERSION); + remove_value(&app, KEY_INSTALL_ON_NEXT_LAUNCH); + drop(prepared); + app.restart() +} + pub fn should_check( last_check: Option>, now: DateTime, @@ -125,14 +229,6 @@ pub fn should_check( } } -pub fn is_snoozed( - snoozed_version: Option<&str>, - snoozed_until: Option>, - remote_version: &str, - now: DateTime, -) -> bool { - match (snoozed_version, snoozed_until) { - (Some(v), Some(until)) => v == remote_version && now < until, - _ => false, - } +pub fn is_skipped(skipped_version: Option<&str>, remote_version: &str) -> bool { + skipped_version == Some(remote_version) } diff --git a/crates/modbussim-app/tests/update_helpers.rs b/crates/modbussim-app/tests/update_helpers.rs index 6a81ed0..be69c3f 100644 --- a/crates/modbussim-app/tests/update_helpers.rs +++ b/crates/modbussim-app/tests/update_helpers.rs @@ -1,5 +1,5 @@ use chrono::{DateTime, Duration, Utc}; -use modbussim_app_lib::update::{is_snoozed, should_check}; +use modbussim_app_lib::update::{is_skipped, should_check}; fn ts(s: &str) -> DateTime { DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) @@ -29,31 +29,16 @@ fn should_check_after_throttle_window() { } #[test] -fn snoozed_when_same_version_within_window() { - assert!(is_snoozed( - Some("1.0.9"), - Some(ts("2026-04-29T00:00:00Z")), - "1.0.9", - ts("2026-04-28T10:00:00Z"), - )); +fn skipped_when_versions_match() { + assert!(is_skipped(Some("1.0.9"), "1.0.9")); } #[test] -fn not_snoozed_after_window_expires() { - assert!(!is_snoozed( - Some("1.0.9"), - Some(ts("2026-04-28T09:00:00Z")), - "1.0.9", - ts("2026-04-28T10:00:00Z"), - )); +fn not_skipped_without_a_saved_version() { + assert!(!is_skipped(None, "1.0.9")); } #[test] -fn not_snoozed_for_different_version() { - assert!(!is_snoozed( - Some("1.0.9"), - Some(ts("2026-04-29T00:00:00Z")), - "1.0.10", - ts("2026-04-28T10:00:00Z"), - )); +fn skipped_release_does_not_hide_a_newer_version() { + assert!(!is_skipped(Some("1.0.9"), "1.0.10")); } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a9fe3eb..46fd841 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -94,11 +94,6 @@ onMounted(() => { }, 2000) }) -function snoozeUpdate() { - if (updateMeta.value) { - invoke('snooze_update', { version: updateMeta.value.version }).catch(() => {}) - } -} diff --git a/master-frontend/src/App.vue b/master-frontend/src/App.vue index a62ef04..1bae935 100644 --- a/master-frontend/src/App.vue +++ b/master-frontend/src/App.vue @@ -113,11 +113,6 @@ async function checkUpdate(force = false): Promise { } provide('checkUpdate', checkUpdate) -function snoozeUpdate() { - if (updateMeta.value) { - invoke('snooze_update', { version: updateMeta.value.version }).catch(() => {}) - } -} diff --git a/shared-frontend/src/components/UpdateDialog.vue b/shared-frontend/src/components/UpdateDialog.vue index cd28a52..76c7fe9 100644 --- a/shared-frontend/src/components/UpdateDialog.vue +++ b/shared-frontend/src/components/UpdateDialog.vue @@ -1,7 +1,6 @@ @@ -126,6 +127,7 @@ onBeforeUnmount(() => {
+
{{ t('update.ready') }}
- -
-
- {{ t('update.downloading', { pct: progress }) }} - {{ progress }}% -
-
-
-
-
-
@@ -259,6 +249,15 @@ onBeforeUnmount(() => { color: #a6adc8; margin-bottom: 8px; } +.upd-ready { + margin-bottom: 10px; + padding: 8px 10px; + border: 1px solid rgba(166, 227, 161, 0.28); + border-radius: 7px; + background: rgba(166, 227, 161, 0.08); + color: #a6e3a1; + font-size: 12px; +} .upd-notes { background: #181825; border: 1px solid #313244; @@ -323,34 +322,6 @@ onBeforeUnmount(() => { margin: 12px 0; } -/* Progress */ -.upd-progress { margin-top: 14px; } -.upd-progress-row { - display: flex; - justify-content: space-between; - font-size: 12px; - color: #bac2de; - margin-bottom: 6px; -} -.upd-progress-pct { - font-variant-numeric: tabular-nums; - color: #89b4fa; - font-weight: 600; -} -.upd-track { - height: 6px; - border-radius: 999px; - background: #313244; - overflow: hidden; -} -.upd-fill { - height: 100%; - border-radius: 999px; - background: #89b4fa; - transform-origin: left; - transition: transform 200ms ease-out; -} - /* Error */ .upd-error { margin-top: 14px; @@ -389,9 +360,16 @@ onBeforeUnmount(() => { font-weight: 500; transition: background 140ms ease, border-color 140ms ease; } +.btn:disabled { cursor: wait; opacity: 0.55; } .btn:focus-visible { outline: 2px solid #89b4fa; outline-offset: 2px; } .btn-primary { background: #89b4fa; color: #11111b; } .btn-primary:hover { background: #74c7ec; } +.btn-secondary { + background: #313244; + color: #cdd6f4; + border-color: #45475a; +} +.btn-secondary:hover { background: #45475a; } .btn-ghost { background: transparent; color: #bac2de; @@ -412,7 +390,4 @@ onBeforeUnmount(() => { .upd-notes::-webkit-scrollbar-track, .upd-body::-webkit-scrollbar-track { background: transparent; } -@media (prefers-reduced-motion: reduce) { - .upd-fill { transition: none; } -} diff --git a/shared-frontend/src/i18n/locales/en-US.ts b/shared-frontend/src/i18n/locales/en-US.ts index 80ce4ab..2a325ca 100644 --- a/shared-frontend/src/i18n/locales/en-US.ts +++ b/shared-frontend/src/i18n/locales/en-US.ts @@ -360,12 +360,12 @@ const messages: Messages = { available: 'Update available', newVersion: 'Version v{version} is available', changelog: 'Release notes', + ready: 'The update was downloaded and verified in the background.', installNow: 'Install now', - later: 'Later', - downloading: 'Downloading {pct}%', + installNextLaunch: 'Update on next launch', + skip: 'Skip this version', + working: 'Working…', failedTitle: 'Update failed', - retry: 'Retry', - close: 'Close', }, } diff --git a/shared-frontend/src/i18n/locales/zh-CN.ts b/shared-frontend/src/i18n/locales/zh-CN.ts index 3658595..1e0b236 100644 --- a/shared-frontend/src/i18n/locales/zh-CN.ts +++ b/shared-frontend/src/i18n/locales/zh-CN.ts @@ -358,12 +358,12 @@ const messages = { available: '检测到新版本', newVersion: '新版本 v{version} 可用', changelog: '更新说明', + ready: '更新已在后台下载并验签完成,可以安装。', installNow: '立即更新', - later: '稍后', - downloading: '正在下载 {pct}%', + installNextLaunch: '下次启动自动更新', + skip: '跳过此版本', + working: '正在处理…', failedTitle: '更新失败', - retry: '重试', - close: '关闭', }, } as const