diff --git a/.github/workflows/manual-release.yml b/.github/workflows/manual-release.yml index a6cf9c8..aaabdde 100644 --- a/.github/workflows/manual-release.yml +++ b/.github/workflows/manual-release.yml @@ -4,13 +4,13 @@ on: workflow_dispatch: inputs: tag: - description: "发布 Tag(可选,默认使用 package.json version)" + description: "发布 Tag(v4.0.0;留空时从 package.json 生成)" required: false type: string draft: description: "是否创建为草稿" required: false - default: false + default: true type: boolean prerelease: description: "是否标记为预发布" @@ -18,6 +18,10 @@ on: default: false type: boolean +concurrency: + group: manual-release-${{ github.ref }} + cancel-in-progress: false + permissions: contents: write packages: write @@ -26,14 +30,19 @@ env: NODE_VERSION: "22" REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} + EXPECTED_VERSION: "4.0.0" + EXPECTED_ANDROID_VERSION_CODE: "40000" + RELEASE_NOTES_PATH: docs/marketing/releases/v4.0.0.md jobs: prepare: runs-on: ubuntu-latest outputs: - version: ${{ steps.get_version.outputs.version }} - tag: ${{ steps.compute_tag.outputs.tag }} - release_name: ${{ steps.compute_tag.outputs.release_name }} + version: ${{ steps.metadata.outputs.version }} + tag: ${{ steps.metadata.outputs.tag }} + major: ${{ steps.metadata.outputs.major }} + major_minor: ${{ steps.metadata.outputs.major_minor }} + release_name: ${{ steps.metadata.outputs.release_name }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -42,29 +51,68 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} + cache: npm - - name: Read version from package.json - id: get_version - shell: bash - run: | - VERSION=$(node -p "JSON.parse(require('fs').readFileSync('package.json','utf-8')).version") - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + - name: Install dependencies from lockfile + run: npm ci --no-audit --no-fund - - name: Compute release metadata - id: compute_tag + - name: Validate release metadata + id: metadata shell: bash + env: + INPUT_TAG: ${{ inputs.tag }} run: | - INPUT_TAG="${{ inputs.tag }}" - VERSION="${{ steps.get_version.outputs.version }}" + set -euo pipefail + + if [ "${GITHUB_REF_NAME}" != "main" ]; then + echo "Manual Release must be dispatched from main, got ${GITHUB_REF_NAME}" >&2 + exit 1 + fi + + version="$(node -p "require('./package.json').version")" + if [ "$version" != "$EXPECTED_VERSION" ]; then + echo "package.json version must be ${EXPECTED_VERSION}, got ${version}" >&2 + exit 1 + fi + + input_tag="$INPUT_TAG" + tag="${input_tag:-v${version}}" + if [ "$tag" != "v${version}" ]; then + echo "Release tag must equal v${version}, got ${tag}" >&2 + exit 1 + fi - if [ -n "${INPUT_TAG}" ]; then - TAG="${INPUT_TAG}" - else - TAG="v${VERSION}" + android_version_name="$(sed -nE 's/^[[:space:]]*versionName[[:space:]]+"([^"]+)".*/\1/p' android/app/build.gradle | head -n 1)" + android_version_code="$(sed -nE 's/^[[:space:]]*versionCode[[:space:]]+([0-9]+).*/\1/p' android/app/build.gradle | head -n 1)" + if [ "$android_version_name" != "$version" ]; then + echo "Android versionName must equal ${version}, got ${android_version_name}" >&2 + exit 1 + fi + if [ "$android_version_code" != "$EXPECTED_ANDROID_VERSION_CODE" ]; then + echo "Android versionCode must equal ${EXPECTED_ANDROID_VERSION_CODE}, got ${android_version_code}" >&2 + exit 1 fi - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "release_name=Immersive Clock ${TAG}" >> "$GITHUB_OUTPUT" + if [ ! -s "$RELEASE_NOTES_PATH" ]; then + echo "Release notes not found or empty: ${RELEASE_NOTES_PATH}" >&2 + exit 1 + fi + if ! grep -Fq "沉浸式时钟 v${version}" "$RELEASE_NOTES_PATH"; then + echo "Release notes do not identify v${version}" >&2 + exit 1 + fi + + IFS=. read -r major minor patch <<< "$version" + if [ -z "$major" ] || [ -z "$minor" ] || [ -z "$patch" ]; then + echo "Invalid semantic version: ${version}" >&2 + exit 1 + fi + + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "major=${major}" >> "$GITHUB_OUTPUT" + echo "major_minor=${major}.${minor}" >> "$GITHUB_OUTPUT" + echo "release_name=沉浸式时钟 v${version}" >> "$GITHUB_OUTPUT" build-web: runs-on: ubuntu-latest @@ -77,31 +125,36 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} + cache: npm - - name: Install dependencies - run: npm install --no-audit --no-fund + - name: Install dependencies from lockfile + run: npm ci --no-audit --no-fund - name: Build Web env: NODE_ENV: production run: npm run build - - name: Package Web dist + - name: Package versioned Web build + shell: bash run: | - cd dist - zip -r ../immersive-clock-web.zip . - cd .. + set -euo pipefail + archive="immersive-clock-web-${{ needs.prepare.outputs.version }}.zip" + (cd dist && zip -q -r "../${archive}" .) + test -s "$archive" + unzip -t "$archive" - - name: Upload Web Artifact + - name: Upload Web artifact uses: actions/upload-artifact@v4 with: name: web-build - path: immersive-clock-web.zip + path: immersive-clock-web-${{ needs.prepare.outputs.version }}.zip if-no-files-found: error build-electron-windows: runs-on: windows-latest needs: prepare + timeout-minutes: 40 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -110,6 +163,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} + cache: npm - name: Cache electron-builder uses: actions/cache@v4 @@ -117,100 +171,73 @@ jobs: path: | ~\AppData\Local\electron ~\AppData\Local\electron-builder - key: ${{ runner.os }}-electron-builder-${{ hashFiles('package.json', 'electron-builder.json') }} + key: ${{ runner.os }}-electron-builder-${{ hashFiles('package-lock.json', 'electron-builder.json') }} restore-keys: | ${{ runner.os }}-electron-builder- - - name: Install dependencies - run: npm install --no-audit --no-fund + - name: Install dependencies from lockfile + run: npm ci --no-audit --no-fund - name: Cleanup broken NSIS cache shell: pwsh run: | $nsisResourcesDir = Join-Path $env:LOCALAPPDATA "electron-builder\Cache\nsis\nsis-resources-3.4.1" $wrongArchive = Join-Path $nsisResourcesDir "nsis-resources.7z" - if (Test-Path $wrongArchive) { - Write-Host "Detected legacy/broken NSIS resources cache ($wrongArchive). Removing $nsisResourcesDir to force re-download." Remove-Item -Recurse -Force $nsisResourcesDir } - - name: Create .env file from secrets - run: | - $content = @" - VITE_QWEATHER_API_HOST=${{ secrets.VITE_QWEATHER_API_HOST }} - VITE_QWEATHER_API_KEY=${{ secrets.VITE_QWEATHER_API_KEY }} - VITE_AMAP_API_KEY=${{ secrets.VITE_AMAP_API_KEY }} - "@ - - Set-Content -Path .env -Value $content - - - name: Build Electron app + - name: Build Windows Electron packages + shell: pwsh env: NODE_ENV: production - DEBUG: electron-builder run: | $maxAttempts = 3 - for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { - Write-Host "Build attempt $attempt of $maxAttempts..." - npm run build:electron if ($LASTEXITCODE -ne 0) { - if ($attempt -eq $maxAttempts) { - throw "build:electron failed after $maxAttempts attempts" - } - Write-Host "build:electron failed, retrying in 10 seconds..." + if ($attempt -eq $maxAttempts) { throw "build:electron failed" } Start-Sleep -Seconds 10 continue } npm run pack:electron - if ($LASTEXITCODE -eq 0) { - break - } - - if ($attempt -eq $maxAttempts) { - throw "pack:electron failed after $maxAttempts attempts" - } - - Write-Host "pack:electron failed, retrying in 10 seconds..." + if ($LASTEXITCODE -eq 0) { break } + if ($attempt -eq $maxAttempts) { throw "pack:electron failed" } Start-Sleep -Seconds 10 } - timeout-minutes: 30 - name: Verify Windows artifacts shell: pwsh run: | - if (-not (Test-Path "release")) { - throw "release directory not found" + $version = "${{ needs.prepare.outputs.version }}" + $setup = "release/immersive-clock-$version-x64-Setup.exe" + $portable = "release/immersive-clock-$version-x64-Portable.exe" + if (-not (Test-Path $setup -PathType Leaf) -or (Get-Item $setup).Length -eq 0) { + throw "Missing Windows Setup: $setup" } - - $setupFiles = Get-ChildItem -Path "release" -Filter "*-Setup.exe" -File -ErrorAction SilentlyContinue - $portableFiles = Get-ChildItem -Path "release" -Filter "*-Portable.exe" -File -ErrorAction SilentlyContinue - - if (($setupFiles.Count + $portableFiles.Count) -eq 0) { - Get-ChildItem -Path "release" -Force | Format-Table -AutoSize | Out-String | Write-Host - throw "No Windows installer files found in release/" + if (-not (Test-Path $portable -PathType Leaf) -or (Get-Item $portable).Length -eq 0) { + throw "Missing Windows Portable: $portable" } - - name: Upload Windows NSIS installer + - name: Upload Windows Setup uses: actions/upload-artifact@v4 with: - name: windows-nsis-installer - path: release/*-Setup.exe + name: windows-setup + path: release/immersive-clock-${{ needs.prepare.outputs.version }}-x64-Setup.exe if-no-files-found: error - name: Upload Windows Portable uses: actions/upload-artifact@v4 with: name: windows-portable - path: release/*-Portable.exe + path: release/immersive-clock-${{ needs.prepare.outputs.version }}-x64-Portable.exe if-no-files-found: error build-electron-linux: runs-on: ubuntu-latest needs: prepare + timeout-minutes: 40 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -219,6 +246,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} + cache: npm - name: Cache electron-builder uses: actions/cache@v4 @@ -226,22 +254,14 @@ jobs: path: | ~/.cache/electron ~/.cache/electron-builder - key: ${{ runner.os }}-electron-builder-${{ hashFiles('package.json', 'electron-builder.json') }} + key: ${{ runner.os }}-electron-builder-${{ hashFiles('package-lock.json', 'electron-builder.json') }} restore-keys: | ${{ runner.os }}-electron-builder- - - name: Install dependencies - run: npm install --no-audit --no-fund - - - name: Create .env file from secrets - run: | - cat > .env << EOF - VITE_QWEATHER_API_HOST=${{ secrets.VITE_QWEATHER_API_HOST }} - VITE_QWEATHER_API_KEY=${{ secrets.VITE_QWEATHER_API_KEY }} - VITE_AMAP_API_KEY=${{ secrets.VITE_AMAP_API_KEY }} - EOF + - name: Install dependencies from lockfile + run: npm ci --no-audit --no-fund - - name: Build Electron app + - name: Build Linux Electron packages env: NODE_ENV: production run: | @@ -251,39 +271,145 @@ jobs: - name: Verify Linux artifacts shell: bash run: | - if [ ! -d "release" ]; then - echo "release directory not found" - exit 1 - fi - - ls -la release - - shopt -s nullglob - files=(release/*.AppImage release/*.deb release/*.rpm) - if [ ${#files[@]} -eq 0 ]; then - echo "No Linux artifacts found in release/" - exit 1 - fi + set -euo pipefail + for extension in AppImage deb rpm; do + shopt -s nullglob + files=(release/immersive-clock-${{ needs.prepare.outputs.version }}-*.${extension}) + if [ ${#files[@]} -ne 1 ] || [ ! -s "${files[0]}" ]; then + echo "Expected one non-empty ${extension} package" >&2 + find release -maxdepth 1 -type f -print 2>/dev/null || true + exit 1 + fi + done - name: Upload Linux AppImage uses: actions/upload-artifact@v4 with: name: linux-appimage - path: release/*.AppImage + path: release/immersive-clock-${{ needs.prepare.outputs.version }}-*.AppImage if-no-files-found: error - name: Upload Linux DEB uses: actions/upload-artifact@v4 with: name: linux-deb - path: release/*.deb + path: release/immersive-clock-${{ needs.prepare.outputs.version }}-*.deb if-no-files-found: error - name: Upload Linux RPM uses: actions/upload-artifact@v4 with: name: linux-rpm - path: release/*.rpm + path: release/immersive-clock-${{ needs.prepare.outputs.version }}-*.rpm + if-no-files-found: error + + build-android-release: + runs-on: ubuntu-latest + needs: prepare + timeout-minutes: 35 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android API and Build Tools + run: sdkmanager "platforms;android-36" "build-tools;36.0.0" + + - name: Setup Gradle cache + uses: gradle/actions/setup-gradle@v4 + + - name: Install dependencies from lockfile + run: npm ci --no-audit --no-fund + + - name: Decode release keystore + shell: bash + env: + ANDROID_RELEASE_KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }} + run: | + set -euo pipefail + if [ -z "$ANDROID_RELEASE_KEYSTORE_BASE64" ]; then + echo "ANDROID_RELEASE_KEYSTORE_BASE64 is not configured" >&2 + exit 1 + fi + printf '%s' "$ANDROID_RELEASE_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/immersive-clock-release.jks" + chmod 600 "$RUNNER_TEMP/immersive-clock-release.jks" + test -s "$RUNNER_TEMP/immersive-clock-release.jks" + + - name: Build and sync Android web assets + env: + NODE_ENV: production + run: npm run build:android + + - name: Verify Android build excludes Service Worker + shell: bash + run: | + if [ -f "dist/sw.js" ] || [ -f "android/app/src/main/assets/public/sw.js" ]; then + echo "Android build unexpectedly contains a Service Worker" >&2 + exit 1 + fi + + - name: Assemble signed Android Release APK + env: + ANDROID_RELEASE_STORE_FILE: ${{ runner.temp }}/immersive-clock-release.jks + ANDROID_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }} + ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} + ANDROID_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }} + run: npm run pack:android:release + + - name: Verify Android package, version, and signature + shell: bash + env: + EXPECTED_CERT_SHA256: ${{ vars.ANDROID_RELEASE_CERT_SHA256 }} + run: | + set -euo pipefail + apk="android/app/build/outputs/apk/release/app-release.apk" + apksigner="$ANDROID_HOME/build-tools/36.0.0/apksigner" + aapt="$ANDROID_HOME/build-tools/36.0.0/aapt" + + test -s "$apk" + "$apksigner" verify --verbose --print-certs "$apk" + + actual_cert="$("$apksigner" verify --print-certs "$apk" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | head -n 1 | tr -d ':[:space:]' | tr '[:lower:]' '[:upper:]')" + expected_cert="$(printf '%s' "$EXPECTED_CERT_SHA256" | tr -d ':[:space:]' | tr '[:lower:]' '[:upper:]')" + if [ -z "$expected_cert" ] || [ "$actual_cert" != "$expected_cert" ]; then + echo "Android signing certificate SHA-256 does not match the configured repository variable" >&2 + exit 1 + fi + + badging="$("$aapt" dump badging "$apk" | head -n 1)" + printf '%s\n' "$badging" + grep -Fq "name='io.github.qziky.immersiveclock'" <<< "$badging" + grep -Fq "versionCode='${EXPECTED_ANDROID_VERSION_CODE}'" <<< "$badging" + grep -Fq "versionName='${{ needs.prepare.outputs.version }}'" <<< "$badging" + + output="immersive-clock-${{ needs.prepare.outputs.version }}-android-release.apk" + cp "$apk" "$output" + test -s "$output" + + - name: Remove decoded keystore + if: always() + shell: bash + run: rm -f "$RUNNER_TEMP/immersive-clock-release.jks" + + - name: Upload Android Release APK + uses: actions/upload-artifact@v4 + with: + name: android-release + path: immersive-clock-${{ needs.prepare.outputs.version }}-android-release.apk if-no-files-found: error build-docker: @@ -299,64 +425,105 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to Container Registry + - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} + - name: Normalize image name + id: image + shell: bash + run: | + image="$(printf '%s/%s' "$REGISTRY" "$IMAGE_NAME" | tr '[:upper:]' '[:lower:]')" + echo "name=${image}" >> "$GITHUB_OUTPUT" - - name: Build and push multi-arch Docker image - uses: docker/build-push-action@v5 + - name: Build and push multi-architecture image + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + tags: | + ${{ steps.image.outputs.name }}:${{ needs.prepare.outputs.version }} + ${{ steps.image.outputs.name }}:${{ needs.prepare.outputs.major_minor }} + ${{ steps.image.outputs.name }}:${{ needs.prepare.outputs.major }} + ${{ steps.image.outputs.name }}:latest + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ needs.prepare.outputs.version }} cache-from: type=gha cache-to: type=gha,mode=max + - name: Verify all image tags and architectures + shell: bash + run: | + set -euo pipefail + image="${{ steps.image.outputs.name }}" + for tag in "${{ needs.prepare.outputs.version }}" "${{ needs.prepare.outputs.major_minor }}" "${{ needs.prepare.outputs.major }}" latest; do + output="$(docker buildx imagetools inspect "${image}:${tag}")" + printf '%s\n' "$output" + grep -Fq "linux/amd64" <<< "$output" + grep -Fq "linux/arm64" <<< "$output" + done + publish: runs-on: ubuntu-latest - needs: [prepare, build-web, build-electron-windows, build-electron-linux, build-docker] + needs: + - prepare + - build-web + - build-electron-windows + - build-electron-linux + - build-android-release + - build-docker permissions: contents: write steps: - - name: Download all artifacts + - name: Checkout repository for fixed Release Notes + uses: actions/checkout@v4 + + - name: Download all release artifacts uses: actions/download-artifact@v4 with: path: release-assets - - name: List assets + - name: Collect and validate release files shell: bash run: | - ls -R release-assets + set -euo pipefail + version="${{ needs.prepare.outputs.version }}" + mkdir release-files + find release-assets -type f -exec cp '{}' release-files/ \; + + test -s "release-files/immersive-clock-web-${version}.zip" + test -s "release-files/immersive-clock-${version}-x64-Setup.exe" + test -s "release-files/immersive-clock-${version}-x64-Portable.exe" + test -s "release-files/immersive-clock-${version}-android-release.apk" + + for extension in AppImage deb rpm; do + shopt -s nullglob + files=(release-files/immersive-clock-${version}-*.${extension}) + if [ ${#files[@]} -ne 1 ] || [ ! -s "${files[0]}" ]; then + echo "Expected one non-empty ${extension} release file" >&2 + find release-files -maxdepth 1 -type f -print + exit 1 + fi + done + + (cd release-files && sha256sum * | sort -k2 > SHA256SUMS.txt) + test -s release-files/SHA256SUMS.txt + find release-files -maxdepth 1 -type f -printf '%f %s bytes\n' | sort - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.prepare.outputs.tag }} name: ${{ needs.prepare.outputs.release_name }} target_commitish: ${{ github.sha }} + body_path: docs/marketing/releases/v4.0.0.md draft: ${{ inputs.draft }} prerelease: ${{ inputs.prerelease }} fail_on_unmatched_files: true - files: | - release-assets/**/immersive-clock-web.zip - release-assets/**/*-Setup.exe - release-assets/**/*-Portable.exe - release-assets/**/*.AppImage - release-assets/**/*.deb - release-assets/**/*.rpm + files: release-files/* diff --git a/README.en-US.md b/README.en-US.md index 11827db..3da29dc 100644 --- a/README.en-US.md +++ b/README.en-US.md @@ -8,7 +8,7 @@
A local-first fullscreen time dashboard for classrooms, study spaces, and personal focus.
- Live Demo + Download v4.0.0 · User Guide · @@ -17,7 +17,7 @@
@@ -40,7 +40,7 @@ controls into a low-distraction interface made for long-running displays. | **Study** | Classrooms, study rooms, personal focus | Time, weather, progress, events, quotes, and optional environment monitoring | | **Local first** | Account-free personal use | Settings, schedules, resources, and history stay on the current device by default | | **Appearance** | Projection tuning and personalization | Fonts, backgrounds, time display, and component-level styling | -| **Multi-platform** | Browsers, desktops, and mobile devices | Web/PWA, Windows/Linux, and an Android Debug APK | +| **Multi-platform** | Browsers, desktops, and mobile devices | Web/PWA, Windows/Linux, and a release-signed Android APK | > Local first does not mean every feature is fully offline. Core timers and local content work > offline; fresh weather, city search, online quotes, external time sync, and feedback pages need a @@ -75,21 +75,26 @@ per-page appearance controls. ## Quick start -### Use it directly +### Use the Web / PWA build -1. Open the [live demo (external deployment)](https://clock.qqhkx.com). +1. Download `immersive-clock-web-4.0.0.zip` from + [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest), then deploy it to an + HTTPS web server with SPA fallback support. 2. Use the HUD in the lower-right corner to switch between clock, countdown, stopwatch, and study. 3. In a supported browser, choose “Install app” or “Add to Home Screen” to launch it as a PWA. +> External website deployments are outside the v4.0.0 release scope. This README does not guarantee +> their current version or availability. + ### Installable builds -| Platform | Get it | Current boundary | -| --------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| Web / PWA | [Live demo](https://clock.qqhkx.com) | Installation and offline behavior depend on browser support and cached resources | -| Windows | [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest) | x64 installer and portable builds, subject to actual release assets | -| Linux | [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest) | AppImage, deb, and rpm, subject to actual release assets | -| Android | [Debug APK guide](docs/technical/engineering/android-debug-build.md) | Debug-signed APK only; this is not a production app-store package | -| macOS | Use Web / PWA | No native macOS package is currently published | +| Platform | Get it | Current boundary | +| --------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| Web / PWA | [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest) | Download the versioned Web ZIP and self-host it | +| Windows | [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest) | x64 installer and portable builds, subject to actual release assets | +| Linux | [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest) | AppImage, deb, and rpm, subject to actual release assets | +| Android | [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest) | Release-signed sideload APK; not distributed through an app store | +| macOS | Self-host the Web / PWA build | No native macOS package is currently published | ## Privacy and capability boundaries diff --git a/README.md b/README.md index b654057..0a38e13 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@为教室、自习空间与个人专注打造的本地优先全屏时间看板。
- 在线体验 + 下载 v4.0.0 · 使用文档 · @@ -17,7 +17,7 @@
@@ -32,15 +32,15 @@ 沉浸式时钟将四种时间模式、自习信息、环境提示和外观定制组织在一个适合长时间展示的 低干扰界面中。 -| 能力 | 适用场景 | 说明 | -| ------------ | ---------------------- | ------------------------------------------ | -| **时钟** | 桌面、投屏、常驻显示 | 大字时间、日期、秒数开关与自动隐藏 HUD | -| **倒计时** | 考试、演讲、番茄钟 | 快捷时长、自定义时间与结束提醒 | -| **秒表** | 活动、训练、课堂计时 | 开始、暂停、继续与归零 | -| **自习** | 教室、自习室、个人专注 | 时间、天气、进度、事件、语录与可选环境监测 | -| **本地优先** | 无账号的个人使用 | 设置、课表、资源和历史默认保存在当前设备 | -| **外观定制** | 投屏适配与个性化 | 字体、背景、时间显示和组件级样式 | -| **多平台** | 浏览器、桌面与移动设备 | Web/PWA、Windows/Linux、Android Debug APK | +| 能力 | 适用场景 | 说明 | +| ------------ | ---------------------- | -------------------------------------------- | +| **时钟** | 桌面、投屏、常驻显示 | 大字时间、日期、秒数开关与自动隐藏 HUD | +| **倒计时** | 考试、演讲、番茄钟 | 快捷时长、自定义时间与结束提醒 | +| **秒表** | 活动、训练、课堂计时 | 开始、暂停、继续与归零 | +| **自习** | 教室、自习室、个人专注 | 时间、天气、进度、事件、语录与可选环境监测 | +| **本地优先** | 无账号的个人使用 | 设置、课表、资源和历史默认保存在当前设备 | +| **外观定制** | 投屏适配与个性化 | 字体、背景、时间显示和组件级样式 | +| **多平台** | 浏览器、桌面与移动设备 | Web/PWA、Windows/Linux、正式签名 Android APK | > 本地优先不等于所有功能完全离线。核心计时与本地内容可离线使用;新天气、城市搜索、在线 > 语录、外部校时和反馈页面需要网络。 @@ -71,21 +71,24 @@ ## 快速开始 -### 直接使用 +### 使用 Web / PWA -1. 打开[在线体验(外部部署)](https://clock.qqhkx.com)。 +1. 从 [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest) 下载 + `immersive-clock-web-4.0.0.zip`,部署到支持 HTTPS 和 SPA fallback 的 Web 服务。 2. 通过页面右下角 HUD 切换时钟、倒计时、秒表和自习模式。 3. 在支持的浏览器中选择“安装应用”或“添加到主屏幕”,即可作为 PWA 启动。 +> 仓库外的在线站点不在 v4.0.0 发布范围内;本页不对其当前版本或可用性作保证。 + ### 安装版本 -| 平台 | 获取方式 | 当前边界 | -| --------- | --------------------------------------------------------------------------- | ------------------------------------------ | -| Web / PWA | [在线体验](https://clock.qqhkx.com) | 安装和离线表现取决于浏览器及缓存状态 | -| Windows | [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest) | x64 安装版与便携版,以发布附件为准 | -| Linux | [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest) | AppImage、deb 与 rpm,以发布附件为准 | -| Android | [Debug APK 构建说明](docs/technical/engineering/android-debug-build.md) | 当前仅提供调试签名 APK,不是应用商店正式包 | -| macOS | 使用 Web / PWA | 当前没有对外发布原生安装包 | +| 平台 | 获取方式 | 当前边界 | +| --------- | --------------------------------------------------------------------------- | ------------------------------------ | +| Web / PWA | [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest) | 下载版本化 Web ZIP 后自托管 | +| Windows | [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest) | x64 安装版与便携版,以发布附件为准 | +| Linux | [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest) | AppImage、deb 与 rpm,以发布附件为准 | +| Android | [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases/latest) | 正式签名侧载 APK,不是应用商店分发包 | +| macOS | 自托管 Web / PWA | 当前没有对外发布原生安装包 | ## 隐私与能力边界 diff --git a/android/.gitignore b/android/.gitignore index 5e98ee6..08956df 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -8,6 +8,9 @@ .gradle/ build/ local.properties +keystore.properties +*.jks +*.keystore captures/ .cxx/ *.iml diff --git a/android/app/build.gradle b/android/app/build.gradle index 220b186..cc6e85c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,5 +1,27 @@ apply plugin: 'com.android.application' +def releaseStoreFile = System.getenv('ANDROID_RELEASE_STORE_FILE') +def releaseStorePassword = System.getenv('ANDROID_RELEASE_KEYSTORE_PASSWORD') +def releaseKeyAlias = System.getenv('ANDROID_RELEASE_KEY_ALIAS') +def releaseKeyPassword = System.getenv('ANDROID_RELEASE_KEY_PASSWORD') +def releaseSigningConfigured = [ + releaseStoreFile, + releaseStorePassword, + releaseKeyAlias, + releaseKeyPassword +].every { value -> value != null && !value.trim().isEmpty() } +def releaseTaskRequested = gradle.startParameter.taskNames.any { taskName -> + taskName.toLowerCase().contains('release') +} + +if (releaseTaskRequested && !releaseSigningConfigured) { + throw new GradleException( + 'Android release signing requires ANDROID_RELEASE_STORE_FILE, ' + + 'ANDROID_RELEASE_KEYSTORE_PASSWORD, ANDROID_RELEASE_KEY_ALIAS, and ' + + 'ANDROID_RELEASE_KEY_PASSWORD.' + ) +} + android { namespace = "io.github.qziky.immersiveclock" compileSdk = rootProject.ext.compileSdkVersion @@ -8,16 +30,30 @@ android { applicationId "io.github.qziky.immersiveclock" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 31303 - versionName "3.13.3" + versionCode 40000 + versionName "4.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } + signingConfigs { + if (releaseSigningConfigured) { + release { + storeFile file(releaseStoreFile) + storePassword releaseStorePassword + keyAlias releaseKeyAlias + keyPassword releaseKeyPassword + } + } + } + buildTypes { release { + if (releaseSigningConfigured) { + signingConfig signingConfigs.release + } minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } diff --git a/docs/marketing/releases/v4.0.0.md b/docs/marketing/releases/v4.0.0.md new file mode 100644 index 0000000..8bb47e0 --- /dev/null +++ b/docs/marketing/releases/v4.0.0.md @@ -0,0 +1,69 @@ +# 沉浸式时钟 v4.0.0 + +发布日期:2026 年 8 月 5 日 + +v4.0.0 是沉浸式时钟的一次大版本升级。相较 v3.13.3,本版本重建了统一 UI 与响应式设置体验, +加入 Android 原生容器和正式签名 APK,并系统升级了外观、本地数据、环境监测、天气与自习信息编排。 + +## 下载附件 + +| 平台 | Release 附件 | 说明 | +| ----------- | ------------------------------------------- | --------------------------------------------------------------- | +| Web / PWA | `immersive-clock-web-4.0.0.zip` | 供自托管的生产构建,需要 HTTPS、SPA fallback 与天气同源代理。 | +| Windows x64 | `immersive-clock-4.0.0-x64-Setup.exe` | NSIS 安装版。 | +| Windows x64 | `immersive-clock-4.0.0-x64-Portable.exe` | 便携版。 | +| Linux x64 | `immersive-clock-4.0.0-*.AppImage` | AppImage 包,实际架构标识以附件名为准。 | +| Linux x64 | `immersive-clock-4.0.0-*.deb` | Debian/Ubuntu 系安装包。 | +| Linux x64 | `immersive-clock-4.0.0-*.rpm` | Fedora/RHEL 系安装包。 | +| Android | `immersive-clock-4.0.0-android-release.apk` | 使用项目长期发布证书正式签名的侧载 APK。 | +| 校验和 | `SHA256SUMS.txt` | 本次全部附件的 SHA-256 校验值。 | +| Container | `ghcr.io/qziky/immersive-clock` | 公开的 amd64/arm64 镜像,标签为 `4.0.0`、`4.0`、`4`、`latest`。 | + +本次不提供 macOS 原生安装包。macOS 用户可自行部署 Web/PWA 构建。 + +## 主要变化 + +- 统一时钟、倒计时、秒表、自习、设置及弹层的 UI,改善移动端响应式布局、键盘操作、焦点管理与读屏体验。 +- 新增 Capacitor Android 原生容器,并统一 Web/PWA、Electron 与 Android 的屏幕常亮控制。 +- 升级主显示字体、信息字体、背景与页面级外观配置,支持本地资源、预览和引用清理。 +- 新增本地数据检查、选择性导出、导入预检、恢复和彻底清理,覆盖设置、资源、噪音历史、缓存、诊断与设备状态。 +- 环境监测升级为 0–100 环境安静评分,加入设备校准、原始特征归档、历史重算、分段统计和多标签页 Leader 协调。 +- 改进天气、城市定位、分钟级降水、空气质量、天气提醒、固定时间、课程事件和倒计时提醒的编排。 +- 语录支持本地内容及多个独立在线渠道,可配置启停、权重、顺序、缓存和故障转移。 + +## 更新方法 + +升级前建议在“设置 → 系统数据”创建完整 JSON 备份,并单独导出需要保留的噪音原始特征归档。 + +- Web/PWA:用 Web ZIP 内容替换自托管站点,再确认 Service Worker 已取得新版本;必要时关闭所有旧标签页后重新打开。 +- Windows 安装版:退出旧版本后运行新安装程序。便携版请解压或替换到新目录。 +- Linux:退出旧版本后按原包格式安装新版,或替换 AppImage 并重新授予执行权限。 +- Android:从本 Release 下载 APK 后覆盖安装。后续版本必须继续使用同一发布证书才能直接升级。 +- Docker:固定生产环境到 `4.0.0`,或按需使用 `4.0`、`4`、`latest`;部署后检查健康端点和天气代理。 + +旧设置会自动迁移至设置 schema v12,IndexedDB 会升级至 v7。自动迁移不替代备份;浏览器、 +桌面客户端和 Android WebView 的本地数据彼此独立,也不会自动云同步。 + +## Android 安装与权限 + +Android APK 不是应用商店包,需要允许浏览器或文件管理器“安装未知应用”。系统出现来源提示属于侧载流程, +请只使用本 GitHub Release 附件,并用 `SHA256SUMS.txt` 核对文件。 + +首次使用定位或环境监测时,系统会分别请求前台位置和麦克风权限。应用不进行后台录音;拒绝权限不会影响 +时钟、倒计时和秒表。当前没有连接 Android 真机,因此本次验收以 APK 构建、application ID、版本清单和 +签名指纹检查为准,不将真机安装冒烟测试描述为已完成。 + +## 数据与指标边界 + +- 设置、课程、语录、自定义字体、背景和噪音历史默认保存在当前客户端。 +- 导出文件是明文,可能包含位置、课程安排、语录和噪音活动时间,请妥善保存。 +- 环境安静评分用于同一设备、相近条件下的相对比较,不是专业声级计。 +- 估算 dB(A) 需要外部参考校准,不能用于执法、职业健康、设备验收或科学实验结论。 +- 新天气、在线语录、网络校时和反馈页面仍依赖网络及相应第三方服务。 + +## 发布范围说明 + +本 Release 的 Web ZIP 是本次 Web 端正式制品,但仓库外的在线站点不属于本次发布、部署或验收范围; +本发布说明不表示 `clock.qqhkx.com` 已升级至 v4.0.0,也不对其当前可用性作保证。 + +完整历史请查看应用内“更新日志”或仓库中的 `public/docs/changelog.md`。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 89ba6df..702a1bb 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -28,7 +28,7 @@ - [测试策略](engineering/testing-strategy.md):Vitest、Playwright、Browser 验证和选择测试范围。 - [测试覆盖地图](engineering/testing-coverage-map.md):代码与测试的稳定映射。 - [构建、发布与部署](engineering/build-release-and-deployment.md):Web、PWA、Electron、Docker 和发布流水线。 -- [Android Debug APK 构建](engineering/android-debug-build.md):本地依赖、CI Artifact、真机安装与已知限制。 +- [Android APK 构建与发布](engineering/android-debug-build.md):Debug CI、正式签名、侧载、恢复与真机验收。 - [排障指南](engineering/troubleshooting.md):本地开发、存储、网络、噪音和打包故障排查。 ## 文档边界 diff --git a/docs/technical/architecture/web-pwa-and-electron.md b/docs/technical/architecture/web-pwa-and-electron.md index 3bf1b0c..a9d2572 100644 --- a/docs/technical/architecture/web-pwa-and-electron.md +++ b/docs/technical/architecture/web-pwa-and-electron.md @@ -5,17 +5,18 @@ ## 构建模式 -| 命令 | 结果 | -| ------------------------ | ----------------------------------------------------------------- | -| `npm run dev` | Vite Web 开发服务器,默认 `127.0.0.1:3005`。 | -| `npm run dev:electron` | `vite --mode electron`,启动 Electron 主进程并加载开发服务器。 | -| `npm run build` | Web/PWA 构建到 `dist/`,随后执行 `scripts/postbuild.mjs`。 | -| `npm run build:electron` | 清理并构建渲染层和 `dist-electron/`,随后修正 Electron 相对路径。 | -| `npm run pack:electron` | 使用 `electron-builder.json` 打包到 `release/`。 | -| `npm run dist:electron` | 先构建 Electron,再执行打包。 | -| `npm run build:android` | Android mode 构建到 `dist/`,随后执行 `cap sync android`。 | -| `npm run pack:android` | 通过 Gradle Wrapper 生成 Debug APK。 | -| `npm run open:android` | 补齐 Wrapper 并在 Android Studio 打开原生工程。 | +| 命令 | 结果 | +| ------------------------------ | ----------------------------------------------------------------- | +| `npm run dev` | Vite Web 开发服务器,默认 `127.0.0.1:3005`。 | +| `npm run dev:electron` | `vite --mode electron`,启动 Electron 主进程并加载开发服务器。 | +| `npm run build` | Web/PWA 构建到 `dist/`,随后执行 `scripts/postbuild.mjs`。 | +| `npm run build:electron` | 清理并构建渲染层和 `dist-electron/`,随后修正 Electron 相对路径。 | +| `npm run pack:electron` | 使用 `electron-builder.json` 打包到 `release/`。 | +| `npm run dist:electron` | 先构建 Electron,再执行打包。 | +| `npm run build:android` | Android mode 构建到 `dist/`,随后执行 `cap sync android`。 | +| `npm run pack:android` | 通过 Gradle Wrapper 生成 Debug APK。 | +| `npm run pack:android:release` | 使用环境变量中的长期发布证书生成正式签名 Release APK。 | +| `npm run open:android` | 补齐 Wrapper 并在 Android Studio 打开原生工程。 | `vite.config.ts` 从 `package.json` 或 `VITE_APP_VERSION` 注入版本,Web 使用 `/` base,Electron 和 Android 使用 `./` base。生产构建以 Terser 压缩并移除 `console`/`debugger`;开发和测试保留 @@ -48,6 +49,11 @@ Android 的小米天气客户端使用 `CapacitorHttp` 请求固定绝对上游 仍经过 `weatherRequestGuard`,并将原生状态码、超时、非 JSON 与网络失败映射为现有 `HttpRequestError`。Web 继续使用部署代理,Electron 继续使用 `app://local` 协议代理。 +Debug APK 使用 Android 默认调试证书。Release APK 的 Gradle 配置只从 +`ANDROID_RELEASE_STORE_FILE`、`ANDROID_RELEASE_KEYSTORE_PASSWORD`、 +`ANDROID_RELEASE_KEY_ALIAS` 与 `ANDROID_RELEASE_KEY_PASSWORD` 读取长期签名;Release 任务缺少 +任一字段即失败。正式工作流还会将 `apksigner` 输出与仓库变量中的证书 SHA-256 比对。 + ## Electron 主进程 `electron/main.ts` 完成以下工作: diff --git a/docs/technical/engineering/android-debug-build.md b/docs/technical/engineering/android-debug-build.md index a947107..8b033ad 100644 --- a/docs/technical/engineering/android-debug-build.md +++ b/docs/technical/engineering/android-debug-build.md @@ -1,8 +1,8 @@ -# Android Debug APK 构建 +# Android APK 构建与发布 项目使用 Capacitor 8 将 React/Vite 产物装入 Android WebView,永久 application ID 为 -`io.github.qziky.immersiveclock`。当前只产出使用 Android 默认调试证书签名的 Debug APK,不包含 -Release APK、AAB、Google Play 发布或正式签名配置。 +`io.github.qziky.immersiveclock`。开发流程保留 Debug APK;正式 GitHub Release 提供由项目长期 +发布证书签名的侧载 APK,不发布 AAB 或应用商店包。 ## 本地环境 @@ -12,7 +12,7 @@ Release APK、AAB、Google Play 发布或正式签名配置。 - Android Studio(可选,用于真机调试和 Logcat); - Android 设备最低 API 24(Android 7.0)。 -首次安装依赖后执行: +Debug 构建: ```bash npm ci @@ -20,50 +20,57 @@ npm run build:android npm run pack:android ``` -`build:android` 使用 Vite 的 `android` mode 构建 `dist/`,禁用 Electron、PWA 插件和 Service -Worker,然后执行 `cap sync android`。`pack:android` 会下载固定的 Gradle 8.14.3 Wrapper JAR, -校验 SHA-256 后运行 `assembleDebug`。生成文件位于: +输出为 `android/app/build/outputs/apk/debug/app-debug.apk`。Debug APK 使用 Android 默认调试证书, +仅用于开发与 CI Artifact,不作为 GitHub Release 附件。 + +Release 构建需要在仓库外安全保存的 keystore,并在当前进程提供: ```text -android/app/build/outputs/apk/debug/app-debug.apk +ANDROID_RELEASE_STORE_FILE +ANDROID_RELEASE_KEYSTORE_PASSWORD +ANDROID_RELEASE_KEY_ALIAS +ANDROID_RELEASE_KEY_PASSWORD ``` -运行 `npm run open:android` 可补齐 Wrapper JAR并在 Android Studio 中打开原生工程。`android/` -中的 Web assets、Capacitor 生成配置、Cordova 兼容工程、Gradle 缓存和 APK 都是生成文件,不提交。 +然后执行 `npm run build:android` 与 `npm run pack:android:release`。Gradle 只在 Release 任务读取这些 +字段,缺少任一字段会直接失败;Debug 构建不受影响。密码、keystore、明文凭据和签名属性文件都 +不得提交到仓库。 ## GitHub Actions -`.github/workflows/android.yml` 支持手动触发,并在 `main` 分支的 Android 相关源码、配置或依赖 -变化时触发。工作流使用 Node.js 22、JDK 21、Android API 36 和 Gradle 缓存,依次执行: +`.github/workflows/android.yml` 在 `main` 的 Android 相关变更或手动触发时构建 Debug APK,供开发 +验收使用。Artifact 名为 `immersive-clock-android-debug`,默认保留 14 天。 -1. `npm ci`; -2. `npm run typecheck`; -3. `npm run build:android`; -4. `npm run pack:android`; -5. 校验并上传 `app-debug.apk`。 +`.github/workflows/manual-release.yml` 从以下 Secrets 读取正式签名: -在 GitHub 仓库的 Actions 页面打开 “Android Debug APK” 运行,从 Artifacts 下载 -`immersive-clock-android-debug`。Artifact 默认保留 14 天。该工作流是独立 job,不改变 Web、 -Electron 或 Docker 工作流的依赖关系。 +- `ANDROID_RELEASE_KEYSTORE_BASE64` +- `ANDROID_RELEASE_KEYSTORE_PASSWORD` +- `ANDROID_RELEASE_KEY_ALIAS` +- `ANDROID_RELEASE_KEY_PASSWORD` -## 真机安装与验收 +公开变量 `ANDROID_RELEASE_CERT_SHA256` 保存预期证书 SHA-256。工作流解码 keystore、构建 Release +APK、用 `apksigner verify --print-certs` 核对证书指纹,并用 `aapt dump badging` 核对 application ID、 +`versionName` 和 `versionCode`,最终发布 `immersive-clock-
-
----
-
-_如有问题或建议,欢迎反馈。祝您使用愉快!_ 🌟
diff --git a/public/docs/changelog.md b/public/docs/changelog.md
index 16ebd13..4d9e764 100644
--- a/public/docs/changelog.md
+++ b/public/docs/changelog.md
@@ -1,5 +1,40 @@
# 📝 更新日志
+## 🚀 v4.0.0(2026-08-05)
+
+### ✨ 全新体验
+
+- 重建统一界面语言与组件体系,覆盖时钟、倒计时、秒表、自习、设置和各类弹层。
+- 设置中心采用响应式布局,完善窄屏操作、键盘导航、焦点管理、语义标签与读屏提示。
+- 新增 Android 原生容器,并统一 Web/PWA、Electron 与 Android 的屏幕常亮行为。
+
+### 🎨 外观与本地数据
+
+- 升级主显示字体、信息字体、背景和页面级外观配置,支持本地自定义资源与实时预览。
+- 新增完整的数据检查、选择性导出、预检、恢复和清理流程,覆盖设置、资源、噪音历史、缓存、诊断与设备状态。
+- 旧版设置会自动迁移至设置 schema v12,IndexedDB 数据库升级至 v7;大版本升级前仍建议创建完整备份。
+
+### 🔊 环境监测
+
+- 将默认噪音指标升级为 0–100 环境安静评分,用于同一设备、相近条件下的相对比较。
+- 新增设备校准档案、原始特征归档、历史评分重算、分段统计和更完整的自习报告。
+- 新增多标签页 Leader 协调,避免同一浏览器重复占用麦克风和重复写入历史。
+- 估算 dB(A) 仍需外部参考校准,不能替代专业声级计或用于执法、职业健康与科学结论。
+
+### 🌤️ 自习信息与提醒
+
+- 改进小米天气适配、城市定位、分钟级降水、空气质量、预警与逐小时预报编排。
+- 改进固定时间、阶段目标、课程事件、倒计时提醒与自习页面信息层级。
+- 语录支持本地内容及一言、今日诗词、Advice Slip 等独立在线渠道,并提供缓存、权重、顺序与故障转移。
+
+### 📦 平台与发布
+
+- 提供 Web 压缩包、Windows x64 安装版/便携版、Linux AppImage/deb/rpm 与正式签名 Android APK。
+- 发布公开的 `ghcr.io/qziky/immersive-clock` amd64/arm64 容器镜像。
+- 本次不提供 macOS 原生安装包;仓库外的在线站点不在本次发布范围内。
+
+---
+
## 🚀 v3.13.3
### ✨ 新增
diff --git a/scripts/generate-readme-assets.mjs b/scripts/generate-readme-assets.mjs
index a5bf619..1f412ca 100644
--- a/scripts/generate-readme-assets.mjs
+++ b/scripts/generate-readme-assets.mjs
@@ -12,6 +12,7 @@ const LEGACY_HERO_PATH = path.join(ROOT, "public", "assets", "readme-hero.png");
const BASE_URL = process.env.README_CAPTURE_BASE_URL || "http://127.0.0.1:3005";
const FIXED_TIME = new Date("2026-08-05T08:30:00+08:00");
const VIEWPORT = { width: 1440, height: 900 };
+const APP_VERSION = JSON.parse(await readFile(path.join(ROOT, "package.json"), "utf8")).version;
const BUILT_IN_QUOTE_CHANNELS = [
{ id: "local-inspirational", enabled: false, weight: 1, orderMode: "sequential" },
@@ -39,7 +40,7 @@ function createDemoSettings(fixedTime) {
timeDisplay: { showClockSeconds: true, showStudySeconds: true },
announcement: {
hideUntil: fixedTime + 14 * 24 * 60 * 60 * 1000,
- version: "3.13.3",
+ version: APP_VERSION,
},
weather: {
locationMode: "manual",
diff --git a/tests/e2e/e2eUtils.ts b/tests/e2e/e2eUtils.ts
index 3c9dfde..4f01ca1 100644
--- a/tests/e2e/e2eUtils.ts
+++ b/tests/e2e/e2eUtils.ts
@@ -1,7 +1,13 @@
+import { readFileSync } from "node:fs";
+
import { expect, type Page } from "@playwright/test";
const TOUR_STORAGE_KEY = "immersive-clock:has-seen-tour";
+export const CURRENT_APP_VERSION = JSON.parse(
+ readFileSync(new URL("../../package.json", import.meta.url), "utf8")
+).version as string;
+
async function dismissTourOverlay(page: Page) {
try {
await page.evaluate((key) => {
diff --git a/tests/e2e/settings-persistence.e2e.spec.ts b/tests/e2e/settings-persistence.e2e.spec.ts
index 7877079..d23ea67 100644
--- a/tests/e2e/settings-persistence.e2e.spec.ts
+++ b/tests/e2e/settings-persistence.e2e.spec.ts
@@ -1,6 +1,6 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
-import { showHud } from "./e2eUtils";
+import { CURRENT_APP_VERSION, showHud } from "./e2eUtils";
async function openStudySettings(page: Parameters