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 @@

GPL-3.0-only - PWA ready + PWA ready Web, Android, Windows, and Linux

@@ -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 @@

GPL-3.0-only - PWA ready + PWA ready Web、Android、Windows 与 Linux

@@ -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--android-release.apk`。 -启用设备的开发者选项和 USB 调试后,可使用 Android Studio 安装,也可以执行: +同一 application ID 的后续版本必须继续使用同一发布证书,Android 才允许覆盖升级。签名恢复包 +必须保持多份离线备份;丢失密钥后无法为现有侧载安装提供可直接升级的新 APK。 -```bash -adb install -r android/app/build/outputs/apk/debug/app-debug.apk -``` +## 侧载与真机验收 + +用户需要允许浏览器或文件管理器“安装未知应用”,并只从项目 GitHub Release 获取 APK。安装前可 +使用 `SHA256SUMS.txt` 核对文件。已有同 application ID 的 Debug APK 因签名不同,通常需要先卸载 +Debug 版再安装 Release 版;卸载前必须导出需要保留的数据。 + +首次使用定位或环境监测时,系统会请求位置和麦克风权限。应用仅使用前台 +`navigator.geolocation` 与 `getUserMedia`,不进行后台录音。真机验收至少覆盖启动/重开、四种模式、 +设置持久化、定位、天气、麦克风设备选择、拒绝权限错误和离线重开。 -首次使用定位或噪音监测时,系统会请求位置和麦克风权限。Manifest 仅声明网络、粗略/精确定位、 -录音和音频设置权限;应用只使用现有前台 `navigator.geolocation` 与 `getUserMedia` 能力,不进行 -后台录音。验收至少覆盖启动/重开、四种时钟模式、设置持久化、定位与城市解析、天气请求、 -麦克风设备选择、拒绝权限错误以及离线重开。 +CI 不启动 Android Emulator。没有连接真机时,只能将构建、清单、版本和签名检查记录为已完成, +不能将真机安装冒烟测试描述为已完成。 -## 平台差异与已知限制 +## 平台差异 -- Android 的小米天气请求通过 `CapacitorHttp` 直连固定 HTTPS 上游,绕过 WebView CORS;Web - 仍依赖 Vite/部署代理,Electron 仍依赖 `app://local` 协议代理。 -- Android 构建不生成或注册 Service Worker,避免 WebView 内出现双重缓存;离线能力来自已打包 - 的本地 Web assets,在线天气和语录仍需要网络。 -- 首版没有后台通知、后台噪音监测、原生文件分享、自动更新、商店图标/截图或商店元数据。 -- CI 不启动 Android Emulator;定位、麦克风和真实 WebView 权限流程必须由真机验收。 +- Android 的小米天气请求通过 `CapacitorHttp` 直连固定 HTTPS 上游;Web 依赖部署代理,Electron + 依赖 `app://local` 协议代理。 +- Android 不生成或注册 Service Worker,离线能力来自 APK 内的本地 Web assets。 +- 当前没有后台通知、后台环境监测、原生文件分享、自动更新或商店元数据。 diff --git a/docs/technical/engineering/build-release-and-deployment.md b/docs/technical/engineering/build-release-and-deployment.md index 8cb4780..76c8f5e 100644 --- a/docs/technical/engineering/build-release-and-deployment.md +++ b/docs/technical/engineering/build-release-and-deployment.md @@ -1,113 +1,84 @@ # 构建、发布与部署 -项目同时产出 Web/PWA、Electron 桌面包、Android Debug APK 和 Docker/Nginx 镜像。构建入口由 +项目产出 Web/PWA、Electron 桌面包、Android APK 和 Docker/Nginx 镜像。构建入口由 `package.json`、`vite.config.ts`、`capacitor.config.ts`、`electron-builder.json` 和 GitHub -Actions 共同定义。 +Actions 共同定义,要求 Node.js 22 或更高版本。 -## Web 构建 +## Web 与 PWA -`npm run build` 执行 `vite build`,输出 `dist/`,再运行 `scripts/postbuild.mjs`: +`npm run build` 执行 Vite 生产构建及 postbuild、预渲染和合规检查,输出 `dist/`。 +`VITE_APP_VERSION` 优先于 `package.json.version`,该值会注入应用、manifest、公告偏好和缓存键。 -- 复制并更新 sitemap HTML/XML 日期; -- 复制 `robots.txt`; -- 不修改源码或 `public/docs`。 +Web mode 启用 `vite-plugin-pwa` 与自动更新 Service Worker;Electron 和 Android 不注册 Service +Worker。发布前验证首次在线加载、离线重开、旧版本更新、`/docs/*.md` NetworkFirst 行为,以及 +IndexedDB 中的自定义字体和背景不会因缓存清理丢失。 -Vite 输出: +GitHub Release 中的 `immersive-clock-web-.zip` 是可自托管的 Web 正式制品。生产部署必须 +提供 HTTPS、SPA history fallback、`/docs/*` 静态文件,以及 `/api/xiaomi-weather/*` 同源代理。 +仓库外在线站点的部署、域名与证书不属于 Release 工作流。 -- JS:`js/[name]-[hash].js`; -- 字体:`fonts/[name]-[hash][ext]`; -- 图片:`images/[name]-[hash][ext]`; -- 音频:`audio/[name]-[hash][ext]`; -- 其他:`assets/[name]-[hash][ext]`。 +## Electron -生产模式 Terser 移除 console/debugger,4KB 以下资源允许内联。`VITE_APP_VERSION` 优先,缺失 -时读取 `package.json.version`;值会注入 manifest、公告偏好和版本缓存插件。 +`npm run build:electron` 构建渲染层、主进程和 CommonJS preload,并修正生产资源相对路径; +`npm run pack:electron` 使用 electron-builder 输出 `release/`。 -## PWA 产物 - -Web 构建启用 `vite-plugin-pwa`:`registerType: "autoUpdate"`,自动生成 Service Worker 和 web -manifest;Electron 与 Android mode 均禁用注册,Android 还完全禁用 PWA 插件。Web 预缓存静态 -资源,运行时缓存字体、图片、音频和 `/docs/*.md`。修改缓存规则、 -资源路径或 manifest 时要验证: - -1. 首次在线加载; -2. Service Worker 安装与更新; -3. 离线启动和旧版本更新; -4. 公告/更新日志 NetworkFirst 行为; -5. 自定义背景/字体不因缓存清理丢失(它们在 IndexedDB)。 - -## Electron 构建 - -`npm run build:electron` 会先删除 `dist-electron/`,以 Electron mode 构建渲染层、主进程和 -CommonJS preload,再执行 `scripts/postbuild-electron.mjs` 修正绝对资源路径。`npm run pack:electron` -调用 electron-builder 输出 `release/`。 - -平台产物: - -| 平台 | 产物 | -| ------------------ | --------------------------------------------- | -| Windows x64 | NSIS `*-Setup.exe`、Portable `*-Portable.exe` | -| Linux x64/目标架构 | AppImage、deb、rpm | +| 平台 | 产物 | +| --- | --- | +| Windows x64 | NSIS `*-Setup.exe`、Portable `*-Portable.exe` | +| Linux x64 | AppImage、deb、rpm | 打包清单包含 `dist`、`dist-electron`、`public` 和 `package.json`。应用 ID 为 -`io.github.qziky.immersiveclock`,图标来自 public。Electron 运行时使用 `app://local`,协议层会 -服务静态文件并代理天气请求;生产页面不能依赖 `/` 绝对资源路径。 - -## Android Debug APK +`io.github.qziky.immersiveclock`,生产运行时通过 `app://local` 提供静态资源和天气代理。 -`npm run build:android` 使用 Android mode 构建 Web assets 并执行 `cap sync android`;该 mode 使用 -`./` base,禁用 Electron、PWA 插件和 Service Worker。`npm run pack:android` 通过经过 SHA-256 -校验的 Gradle 8.14.3 Wrapper 执行 `assembleDebug`,输出 -`android/app/build/outputs/apk/debug/app-debug.apk`。 +## Android -Android application ID 为 `io.github.qziky.immersiveclock`,最低 API 24,使用 JDK 21。Debug APK -使用 Android 自动生成的调试签名,不需要 keystore 或 Secrets。完整本地依赖、Artifact 下载、 -安装命令和限制见 [Android Debug APK 构建](android-debug-build.md)。 +`npm run build:android` 使用相对资源路径构建 Web assets,禁用 Electron、PWA 插件与 Service +Worker,并执行 `cap sync android`。 -## Docker/Nginx +- `npm run pack:android`:生成默认调试证书签名的 Debug APK,供独立 Android CI 和开发验收使用。 +- `npm run pack:android:release`:生成 Release APK;必须提供 + `ANDROID_RELEASE_STORE_FILE`、`ANDROID_RELEASE_KEYSTORE_PASSWORD`、 + `ANDROID_RELEASE_KEY_ALIAS`、`ANDROID_RELEASE_KEY_PASSWORD`,缺少任一字段即失败。 -`Dockerfile` 使用 Node 24 Alpine 构建 Web,再复制 `dist/` 到 Nginx Alpine;`nginx.conf`: +application ID 为 `io.github.qziky.immersiveclock`,最低 API 24,使用 JDK 21、Android API 36 和 +Build Tools 36.0.0。Manual Release 从 GitHub Actions Secrets 解码仓库外 keystore,通过 +`apksigner` 验证签名并与 `ANDROID_RELEASE_CERT_SHA256` 仓库变量比对,再用 `aapt` 检查 package、 +`versionName` 和 `versionCode`。Release 只发布正式签名 APK,不发布 Debug APK 或 AAB。 -- 为静态资源设置长期缓存和 gzip; -- 代理 `/api/xiaomi-weather/` 到小米天气; -- 对 `/design-system` 与 `/debug` 加 noindex; -- 用 `try_files` 提供 SPA history fallback; -- `/health` 返回纯文本 `healthy`。 +完整本地与 CI 说明见 [Android APK 构建与发布](android-debug-build.md)。 -`docker-compose.yml` 将容器 80 端口映射到本机 8080。生产环境必须保证代理与 SPA fallback -同时存在,否则天气会遇到 CORS、深链接会 404。 - -## Vercel/EdgeOne +## Docker/Nginx -`vercel.json` 与 `edgeone.json` 复制同源天气代理、`/docs` 直出和 SPA fallback,并设置: +`Dockerfile` 使用 Node 24 Alpine 构建 Web,再复制到 Nginx Alpine。镜像提供 SPA fallback、 +静态资源缓存、gzip、天气代理、开发者页 noindex 和 `/health`。 -- JS/CSS immutable 长缓存; -- 图片 1 天、音频 2 天、字体约 30 天; -- HTML 不缓存; -- Web manifest/JSON 1 天; -- docs noindex;开发者页面 noindex/nofollow/noarchive。 +Manual Release 向 `ghcr.io/qziky/immersive-clock` 推送 amd64/arm64 manifest,并显式发布 +``、`.`、``、`latest` 四组标签。发布后必须检查双架构 manifest, +将 package visibility 设为 public,并在未登录状态验证可拉取。 -EdgeOne 当前配置声明 Node 18,而仓库开发和 CI 要求 Node `>=22.0.0`;部署平台若执行构建应 -以 CI/项目支持的 Node 版本为准并单独验证,不要把该配置误写成开发环境要求。 +## CI 与 Manual Release -## CI 与手动发布 +`.github/workflows/ci.yml` 在 PR/main 执行类型、样式、UI Catalog、lint、Vitest 与构建;main push +还构建 Web、Windows/Linux Electron 和 Docker。`.github/workflows/android.yml` 独立构建 Debug +APK,不参与 Release 附件。 -`.github/workflows/ci.yml` 在 PR/main 执行类型、样式、UI Catalog、lint、Vitest;main push 还 -构建 Web、Windows/Linux Electron 和 Docker。`manual-release.yml` 由 workflow_dispatch 触发, -读取 package 版本或自定义 tag,上传 Web zip、Windows 安装包、Linux 包,并创建 GitHub Release。 +`.github/workflows/manual-release.yml` 仅允许手动触发,并执行: -`.github/workflows/android.yml` 独立响应手动触发和 main 的 Android 相关路径变化,构建并上传 -Debug APK;它不作为现有 Web、Electron 或 Docker job 的依赖。 +1. 用 `npm ci` 安装依赖,校验 Tag、包版本、Android 版本和固定 Release Notes 一致; +2. 构建 Web ZIP、Windows、Linux、正式签名 Android APK; +3. 推送四组公开 GHCR 标签并检查 amd64/arm64 manifest; +4. 汇总全部附件并生成 `SHA256SUMS.txt`; +5. 从 `docs/marketing/releases/v.md` 创建 GitHub Release。 -Electron CI 会缓存 electron-builder,Windows 在打包失败时最多重试 3 次;发布前检查 release -目录和预期扩展名。不要把 `.env`、API key 或构建缓存提交到仓库。 +v4.0.0 的标准流程先以 `draft=true`、`prerelease=false` 创建 Draft Release,下载并验收所有制品后, +再转为公开稳定版并标记 Latest。 ## 发布检查清单 -1. `npm ci`/`npm install` 后运行 typecheck、lint、styles、UI tests、Vitest。 -2. 运行 Web build,检查 `dist/index.html`、manifest、`public/docs/*.md` 和天气代理路径。 -3. 需要桌面包时运行 build:electron + pack:electron,检查 Windows/Linux 产物。 -4. 需要 Android APK 时运行 build:android + pack:android,并在真机检查定位、麦克风和重启。 -5. 验证深链接、全屏、定位、仅音频权限、NTP IPC、公告和离线启动。 -6. 检查版本注入、sitemap、robots、缓存 header 和 noindex header。 -7. 在发布说明中记录构建版本、平台、测试命令和已知限制。 +1. 运行 typecheck、lint、stylelint、Vitest、Playwright 和 Web build。 +2. 构建并检查 Windows Setup/Portable、Linux AppImage/deb/rpm。 +3. 构建 Android Release APK,核对 application ID、版本、证书指纹和校验和。 +4. 检查 Web ZIP 内容、版本注入、公告、更新日志、manifest、sitemap 和 robots。 +5. 检查 Docker 四组标签、双架构 manifest、健康端点与匿名拉取。 +6. 真机可用时检查 Android 启动、重开、四种模式、定位、麦克风、拒绝权限和离线重开;没有设备时明确记录未执行。 +7. Release 公开后确认 Tag 指向 `main` 发布提交、Release 为 Latest,并保持工作区干净。 diff --git a/docs/technical/engineering/testing-coverage-map.md b/docs/technical/engineering/testing-coverage-map.md index 078593e..185dae6 100644 --- a/docs/technical/engineering/testing-coverage-map.md +++ b/docs/technical/engineering/testing-coverage-map.md @@ -63,6 +63,7 @@ | 音频诊断/开发者页 | `tests/e2e/audio-debug.e2e.spec.ts`、`developer-pages.e2e.spec.ts` | | UI 视觉 | `tests/e2e/design-system-visual.e2e.spec.ts`、`visual-regression.e2e.spec.ts` | | 弹层与引导 | `modal-redesign.e2e.spec.ts`、`tour-rapid-click.e2e.spec.ts` | +| 公告与发布版本 | `settings-persistence.e2e.spec.ts`、`visual-regression.e2e.spec.ts` | | SEO/GEO | `tests/e2e/seo.e2e.spec.ts` | ## 维护规则 diff --git a/docs/user-guide/en-us/user-guide.md b/docs/user-guide/en-us/user-guide.md index fc7aa9b..fb749b1 100644 --- a/docs/user-guide/en-us/user-guide.md +++ b/docs/user-guide/en-us/user-guide.md @@ -211,13 +211,13 @@ Create the required JSON and `.icnoise` exports before erasing data. ## Installation, offline use, and updates -Use the online link in the repository [README](../../../README.en-US.md), or install the web app as a PWA from a supported browser over HTTPS. +Download the versioned Web ZIP from [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases), deploy it over HTTPS with SPA fallback and the required weather proxy, or install that deployment as a PWA. External website deployments are outside the GitHub Release scope. - Chrome/Edge desktop: use the address-bar or browser-menu Install command. - Android Chrome: use Install App or Add to Home Screen. - iPhone/iPad Safari: use Share → Add to Home Screen. -Windows and Linux desktop packages are available from [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases). There is currently no published macOS desktop package; use the web or PWA version on macOS. +Windows and Linux desktop packages and a release-signed Android sideload APK are available from [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases). Install the Android APK only from the project Release and allow “Install unknown apps” when prompted. There is currently no published macOS desktop package; self-host the Web/PWA build on macOS. After the first successful online load, the PWA normally keeps the core clock interface and local content available offline. New weather, city search, online quotes, network time sources, and external feedback still require a connection. diff --git a/docs/user-guide/installation-offline-and-updates.md b/docs/user-guide/installation-offline-and-updates.md index fda0e7e..7f22a01 100644 --- a/docs/user-guide/installation-offline-and-updates.md +++ b/docs/user-guide/installation-offline-and-updates.md @@ -1,8 +1,10 @@ # 安装、离线与更新 -## 直接使用网页版 +## 使用 Web / PWA 构建 -最简单的方式是使用[项目 README](../../README.md)中的在线体验入口。网页版不需要安装,使用现代浏览器即可。 +从 [GitHub Releases](https://github.com/Qziky/Immersive-clock/releases) 下载版本化 Web ZIP 后,可部署 +到支持 HTTPS、SPA fallback 和天气同源代理的 Web 服务。仓库外的在线站点不属于 GitHub Release +的发布或验收范围,请勿仅凭站点内容判断当前仓库版本。 位置和麦克风通常要求安全连接;如果浏览器提示当前页面不安全,相关权限可能不可用。建议使用官方 HTTPS 地址或可信的自托管环境。 @@ -12,7 +14,7 @@ PWA 会把网页版安装到桌面、开始菜单或主屏幕,打开时更像 ### Windows 或 Linux 的 Chrome / Edge -1. 打开在线版并等待页面完成加载。 +1. 打开已正确部署的 Web 版并等待页面完成加载。 2. 点击地址栏中的安装图标,或打开浏览器菜单选择“安装应用”。 3. 确认安装。 @@ -38,7 +40,8 @@ Windows 和 Linux 可从项目的 [GitHub Releases](https://github.com/Qziky/Imm - Windows 提供 x64 安装版和便携版。 - Linux 构建目标包括 AppImage、deb 和 rpm,实际提供哪些文件以对应版本的 Release 为准。 -- 当前没有对外发布的 macOS 安装包;macOS 用户请使用网页版或 PWA。 +- Android 提供正式签名侧载 APK。仅从 GitHub Release 下载;系统可能要求允许“安装未知应用”。 +- 当前没有对外发布的 macOS 安装包;macOS 用户可自托管 Web/PWA 构建。 桌面客户端和浏览器/PWA 的本地数据彼此独立,不会自动同步。第一次启动桌面版时,需要重新设置并重新授权麦克风或位置,或通过 JSON 备份迁移。 @@ -81,6 +84,12 @@ PWA 会在联网时后台检查新版本。新资源通常在刷新、重新打 升级前建议创建完整 JSON 备份。Windows 安装器默认不会因为普通卸载就主动删除应用数据,但不要把它当作备份机制;清理系统、变更账户或手动删除数据目录仍可能造成丢失。 +## 更新 Android 客户端 + +从 GitHub Release 下载新版正式签名 APK 后覆盖安装。后续正式版本会继续使用同一发布证书;如果 +设备上安装的是签名不同的 Debug APK,通常需要先卸载,卸载前务必导出完整备份。Android 客户端 +与浏览器、桌面客户端的数据彼此独立,不会自动同步。 + ## 卸载与数据 - 卸载 PWA 不一定会删除浏览器中的站点数据;是否保留取决于浏览器。 diff --git a/package-lock.json b/package-lock.json index 7852173..ab11ed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "immersive-clock", - "version": "3.13.3", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immersive-clock", - "version": "3.13.3", + "version": "4.0.0", "license": "GPL-3.0-only", "dependencies": { "@capacitor/android": "8.5.0", diff --git a/package.json b/package.json index b56a7a4..6983475 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "immersive-clock", "private": true, "description": "一款支持时钟、倒计时、秒表与自习模式的轻量时钟应用。", - "version": "3.13.3", + "version": "4.0.0", "author": { "name": "Qziky", "email": "qziky.gg@gmail.com", @@ -48,6 +48,7 @@ "test:e2e": "playwright test", "build:android": "vite build --mode android && node scripts/postbuild.mjs && node scripts/postbuild-compliance.mjs && npx cap sync android", "pack:android": "node scripts/run-android-gradle.mjs assembleDebug", + "pack:android:release": "node scripts/run-android-gradle.mjs assembleRelease", "open:android": "node scripts/ensure-android-gradle-wrapper.mjs && npx cap open android" }, "dependencies": { diff --git a/public/docs/announcement.md b/public/docs/announcement.md index 71c5d9d..906c98d 100644 --- a/public/docs/announcement.md +++ b/public/docs/announcement.md @@ -1,53 +1,24 @@ -# 🎉 欢迎使用沉浸式时钟 +# 🎉 沉浸式时钟 v4.0 -沉浸式时钟是一款全屏数字时钟、倒计时与秒表工具,含自习模式、天气展示、噪音监测、励志语录与课程表管理。轻量高速,支持 PWA 离线与安装,助力校园投屏与专注学习。**官方QQ交流群:965931796** +v4.0 是一次面向长期使用体验的全端升级:界面、设置、数据、环境监测与原生容器都已重新整理。 -## ✨ 主要功能 +## 本次更新 -### 🕐 四种时间模式 +- 全新统一 UI,设置中心支持响应式布局,并完善键盘与读屏交互。 +- 新增 Android 原生容器;Web、Windows、Linux 与 Android 支持跨平台屏幕常亮。 +- 外观、字体、背景与本地数据备份/恢复体系全面升级。 +- 环境监测升级为 0–100 环境安静评分,支持设备校准、历史重算和多标签页协调。 +- 改进天气、定位、提醒、多渠道语录与自习信息编排。 -- **时钟模式**:优雅的数字时钟显示 -- **倒计时模式**:支持自定义倒计时任务 -- **秒表模式**:精确的计时功能 -- **自习模式**:专为学习场景设计的时间管理 +旧设置会自动迁移,但大版本升级前仍建议先在“设置 → 系统数据”创建完整备份。 -### 🌤️ 天气展示 +正式安装包请从 GitHub Release 获取。本次发布不包含 macOS 原生安装包,也不代表仓库外的在线站点已经升级。 -- 实时展示当前1km高精度天气,包括温度、湿度、天气图标等 - -### 🔊 噪音监测 - -- 监测环境噪音水平,及时调整音量 -- 在课时结束前自动生成报告,并可在历史记录中浏览关键统计与走势。 - -### 💡 励志语录 - -- 提供每日励志语录,帮助您保持积极的心态 - -### 📅 课程表管理 - -- 支持导入和管理课程表,方便您规划学习时间 - -## 🎯 使用建议 - -1. **学习场景**:推荐使用自习模式,配合课程表功能 -2. **工作场景**:使用倒计时模式进行番茄工作法 -3. **日常使用**:时钟模式提供简洁的时间显示 - -## 💡 小贴士 - -- 点击设置按钮可以个性化配置各项功能 -- 语录渠道支持多种来源,可根据喜好调整权重 -- 支持全屏模式,获得更沉浸的体验 - -希望这款工具能够帮助您更好地管理时间,提升学习和工作效率! +> Android APK 为正式签名侧载包。安装时系统可能提示“未知来源”,请只从本项目 GitHub Release 下载。 ## 💬 交流与反馈 -> 欢迎加入官方 QQ 交流群:[965931796](https://qm.qq.com/q/fawykipRhm) +欢迎加入官方 QQ 交流群:[965931796](https://qm.qq.com/q/fawykipRhm),或通过 +[GitHub Issues](https://github.com/Qziky/Immersive-clock/issues) 反馈问题。 QQ群二维码 - ---- - -_如有问题或建议,欢迎反馈。祝您使用愉快!_ 🌟 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[0]) { await showHud(page); @@ -435,7 +435,7 @@ async function seedMinutelyWeatherSettings(page: Page) { /** 端到端用例:验证设置保存后写入本地存储且刷新后仍生效(函数级注释) */ test("屏幕常亮:取消不生效,保存后启用并在重载后保持", async ({ page }) => { - await page.addInitScript(() => { + await page.addInitScript((appVersion) => { localStorage.setItem("immersive-clock:has-seen-tour", "true"); if (!localStorage.getItem("AppSettings")) { localStorage.setItem( @@ -445,7 +445,7 @@ test("屏幕常亮:取消不生效,保存后启用并在重载后保持", as general: { announcement: { hideUntil: Date.now() + 7 * 24 * 60 * 60 * 1000, - version: "3.13.3", + version: appVersion, }, }, }) @@ -476,7 +476,7 @@ test("屏幕常亮:取消不生效,保存后启用并在重载后保持", as }, }, }); - }); + }, CURRENT_APP_VERSION); await page.goto("/"); const firstDialog = await openStudySettings(page); @@ -589,6 +589,7 @@ test("自习显示:进度信息与天气可独立控制并持久化", async ({ }); test("自习显示:进度条目取消不保存并可持久化课时进度", async ({ page }) => { + await page.clock.setFixedTime(new Date("2026-08-05T08:30:00+08:00")); await page.goto("/"); let dialog = await openStudySettings(page); @@ -710,7 +711,7 @@ test("天气设置:移除分钟降水弹窗并在天气数据保留完整数 }; }); expect(migrated).toMatchObject({ - version: 10, + version: 12, hasSchedule: false, hasLegacyField: false, rain: { @@ -841,7 +842,7 @@ test("定位设置:手动城市必须搜索并选择小米候选", async ({ pa }); test("噪音设置:选择麦克风只在保存后持久化,并在重开后恢复", async ({ page }) => { - await page.addInitScript(() => { + await page.addInitScript((appVersion) => { localStorage.setItem("immersive-clock:has-seen-tour", "true"); localStorage.setItem( "AppSettings", @@ -850,7 +851,7 @@ test("噪音设置:选择麦克风只在保存后持久化,并在重开后 general: { announcement: { hideUntil: Date.now() + 7 * 24 * 60 * 60 * 1000, - version: "3.13.3", + version: appVersion, }, }, noiseControl: { @@ -870,7 +871,7 @@ test("噪音设置:选择麦克风只在保存后持久化,并在重开后 ], }, }); - }); + }, CURRENT_APP_VERSION); await page.goto("/"); let dialog = await openStudySettings(page); @@ -897,7 +898,7 @@ test("噪音设置:选择麦克风只在保存后持久化,并在重开后 return { version: settings.version, preference: settings.noiseControl?.preferredInputDevice }; }) ).toEqual({ - version: 11, + version: 12, preference: { deviceId: "usb-mic", label: "USB 麦克风" }, }); @@ -910,7 +911,7 @@ test("噪音设置:选择麦克风只在保存后持久化,并在重开后 test("噪音报告:自动关闭时长随统一保存持久化,取消时丢弃草稿", async ({ page }) => { await page.goto("/"); - await page.evaluate(() => { + await page.evaluate((appVersion) => { localStorage.setItem("immersive-clock:has-seen-tour", "true"); localStorage.setItem( "AppSettings", @@ -919,7 +920,7 @@ test("噪音报告:自动关闭时长随统一保存持久化,取消时丢 general: { announcement: { hideUntil: Date.now() + 7 * 24 * 60 * 60 * 1000, - version: "3.13.3", + version: appVersion, }, }, noiseControl: { @@ -929,7 +930,7 @@ test("噪音报告:自动关闭时长随统一保存持久化,取消时丢 }, }) ); - }); + }, CURRENT_APP_VERSION); await page.reload(); const openReportSettings = async () => { diff --git a/tests/e2e/stopwatch.e2e.spec.ts b/tests/e2e/stopwatch.e2e.spec.ts index c0fbc9a..da2f4bb 100644 --- a/tests/e2e/stopwatch.e2e.spec.ts +++ b/tests/e2e/stopwatch.e2e.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; + import { showHud } from "./e2eUtils"; /** 端到端用例:验证秒表可开始、暂停与重置(函数级注释) */ @@ -10,6 +11,7 @@ test("秒表:开始/暂停/重置", async ({ page }) => { await tablist.getByRole("tab", { name: /秒表/ }).click(); const toolbar = page.getByRole("toolbar", { name: "时钟控制" }); + await expect(page.getByText("正在加载秒表…", { exact: true })).toBeHidden(); const timeArea = page.locator("#stopwatch-panel").locator('[aria-live="polite"]'); await expect(timeArea).toContainText("00:00:00"); diff --git a/tests/e2e/study-smoke.e2e.spec.ts b/tests/e2e/study-smoke.e2e.spec.ts index b4dfe23..13724a0 100644 --- a/tests/e2e/study-smoke.e2e.spec.ts +++ b/tests/e2e/study-smoke.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 openStudyDisplaySettings(page: Page) { await showHud(page); @@ -179,7 +179,7 @@ test("中央信息:取消不保存,自定义消息保存后可重载", async }); test("中央信息:隐藏天气组件后仍显示共享快照中的降雨主次信息", async ({ page }) => { - await page.addInitScript(() => { + await page.addInitScript((appVersion) => { const now = Date.now(); const rainStartAt = now + 8 * 60 * 1000; const rainEndAt = rainStartAt + 10 * 60 * 1000; @@ -188,6 +188,12 @@ test("中央信息:隐藏天气组件后仍显示共享快照中的降雨主 "AppSettings", JSON.stringify({ version: 4, + general: { + announcement: { + hideUntil: now + 7 * 24 * 60 * 60 * 1000, + version: appVersion, + }, + }, study: { display: { showWeather: false, @@ -213,7 +219,24 @@ test("中央信息:隐藏天气组件后仍显示共享快照中的降雨主 localStorage.setItem( "weather-cache", JSON.stringify({ + version: 2, + activeLocation: { + city: { + lat: 31.2, + locationKey: "weathercn:101020100", + lon: 121.5, + name: "上海市", + }, + coords: { lat: 31.2, lon: 121.5 }, + mode: "auto", + resolvedAt: now, + source: "browser", + }, coords: { lat: 31.2, lon: 121.5, source: "e2e", updatedAt: now }, + now: { + data: { code: "200", now: { temp: "26", text: "多云" } }, + updatedAt: now, + }, minutely: { data: { code: "200", @@ -225,13 +248,13 @@ test("中央信息:隐藏天气组件后仍显示共享快照中的降雨主 { fxTime: new Date(rainEndAt).toISOString(), precip: "0" }, ], }, - location: "121.50,31.20", + location: "121.5000,31.2000", updatedAt: now, lastApiFetchAt: now, }, }) ); - }); + }, CURRENT_APP_VERSION); await page.goto("/"); await showHud(page); await page @@ -261,8 +284,6 @@ test("中央信息:隐藏天气组件后天气预警逐条轮播且不打断 await route.fulfill({ contentType: "application/json", body: JSON.stringify({ - status: 0, - updateTime: now, alerts: [ { alertId: "orange-alert", @@ -281,16 +302,69 @@ test("中央信息:隐藏天气组件后天气预警逐条轮播且不打断 type: "雷电", }, ], + current: { + feelsLike: { unit: "℃", value: "27" }, + humidity: { unit: "%", value: "60" }, + pressure: { unit: "hPa", value: "1008" }, + pubTime: now, + temperature: { unit: "℃", value: "26" }, + visibility: { unit: "km", value: "10" }, + weather: "0", + wind: { + direction: { unit: "°", value: "90" }, + speed: { unit: "km/h", value: "4" }, + }, + }, + forecastDaily: { + sunRiseSet: { value: [{ from: "05:01", to: "18:59" }] }, + temperature: { value: [{ from: "30", to: "22" }] }, + weather: { value: [{ from: "0", to: "1" }] }, + }, + minutely: { + new: "study-alerts-e2e", + precipitation: { + fxTime: [ + new Date(now + 60_000).toISOString(), + new Date(now + 120_000).toISOString(), + ], + interval: 1, + pubTime: new Date(now).toISOString(), + status: 0, + value: [0, 0], + }, + status: 0, + }, + status: 0, + updateTime: now, }), }); }); await page.addInitScript( - ({ seededAt }) => { + ({ appVersion, seededAt }) => { localStorage.setItem("immersive-clock:has-seen-tour", "true"); localStorage.setItem( "AppSettings", JSON.stringify({ version: 5, + general: { + announcement: { + hideUntil: seededAt + 7 * 24 * 60 * 60 * 1000, + version: appVersion, + }, + weather: { + locationMode: "manual", + manualLocation: { + query: "成都", + selected: { + affiliation: "四川省", + lat: 30.67, + locationKey: "weathercn:101270101", + lon: 104.06, + name: "成都市", + }, + }, + }, + }, study: { display: { showWeather: false, @@ -323,16 +397,25 @@ test("中央信息:隐藏天气组件后天气预警逐条轮播且不打断 localStorage.setItem( "weather-cache", JSON.stringify({ - coords: { lat: 30.67, lon: 104.06, source: "manual_city", updatedAt: seededAt }, - location: { - city: "成都市", - signature: "30.6700,104.0600", - updatedAt: seededAt, + version: 2, + activeLocation: { + city: { + affiliation: "四川省", + lat: 30.67, + locationKey: "weathercn:101270101", + lon: 104.06, + name: "成都市", + }, + coords: { lat: 30.67, lon: 104.06 }, + mode: "manual", + resolvedAt: seededAt, + source: "manual_city", }, + coords: { lat: 30.67, lon: 104.06, source: "manual_city", updatedAt: seededAt }, }) ); }, - { seededAt: now } + { appVersion: CURRENT_APP_VERSION, seededAt: now } ); await page.goto("/study"); @@ -356,7 +439,7 @@ test("中央信息:隐藏天气组件后天气预警逐条轮播且不打断 test("中央信息:正在下雨打断后继续轮播普通信息", async ({ page }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); - await page.addInitScript(() => { + await page.addInitScript((appVersion) => { const now = Date.now(); const rainEndAt = now + 10 * 60 * 1000; localStorage.setItem("immersive-clock:has-seen-tour", "true"); @@ -364,6 +447,12 @@ test("中央信息:正在下雨打断后继续轮播普通信息", async ({ pa "AppSettings", JSON.stringify({ version: 4, + general: { + announcement: { + hideUntil: now + 7 * 24 * 60 * 60 * 1000, + version: appVersion, + }, + }, study: { display: { showWeather: false, @@ -397,7 +486,24 @@ test("中央信息:正在下雨打断后继续轮播普通信息", async ({ pa localStorage.setItem( "weather-cache", JSON.stringify({ + version: 2, + activeLocation: { + city: { + lat: 31.2, + locationKey: "weathercn:101020100", + lon: 121.5, + name: "上海市", + }, + coords: { lat: 31.2, lon: 121.5 }, + mode: "auto", + resolvedAt: now, + source: "browser", + }, coords: { lat: 31.2, lon: 121.5, source: "e2e", updatedAt: now }, + now: { + data: { code: "200", now: { temp: "26", text: "中雨" } }, + updatedAt: now, + }, minutely: { data: { code: "200", @@ -409,13 +515,13 @@ test("中央信息:正在下雨打断后继续轮播普通信息", async ({ pa { fxTime: new Date(rainEndAt).toISOString(), precip: "0" }, ], }, - location: "121.50,31.20", + location: "121.5000,31.2000", updatedAt: now, lastApiFetchAt: now, }, }) ); - }); + }, CURRENT_APP_VERSION); await page.goto("/study"); @@ -426,7 +532,7 @@ test("中央信息:正在下雨打断后继续轮播普通信息", async ({ pa }); test("中央信息:到达降雨开始时间后立即切换为正在下雨", async ({ page }) => { - await page.addInitScript(() => { + await page.addInitScript((appVersion) => { const now = Date.now(); const rainStartAt = now + 4 * 1000; const rainEndAt = rainStartAt + 10 * 60 * 1000; @@ -435,6 +541,12 @@ test("中央信息:到达降雨开始时间后立即切换为正在下雨", as "AppSettings", JSON.stringify({ version: 4, + general: { + announcement: { + hideUntil: now + 7 * 24 * 60 * 60 * 1000, + version: appVersion, + }, + }, study: { display: { showWeather: false, @@ -460,7 +572,24 @@ test("中央信息:到达降雨开始时间后立即切换为正在下雨", as localStorage.setItem( "weather-cache", JSON.stringify({ + version: 2, + activeLocation: { + city: { + lat: 31.2, + locationKey: "weathercn:101020100", + lon: 121.5, + name: "上海市", + }, + coords: { lat: 31.2, lon: 121.5 }, + mode: "auto", + resolvedAt: now, + source: "browser", + }, coords: { lat: 31.2, lon: 121.5, source: "e2e", updatedAt: now }, + now: { + data: { code: "200", now: { temp: "26", text: "多云" } }, + updatedAt: now, + }, minutely: { data: { code: "200", @@ -472,13 +601,13 @@ test("中央信息:到达降雨开始时间后立即切换为正在下雨", as { fxTime: new Date(rainEndAt).toISOString(), precip: "0" }, ], }, - location: "121.50,31.20", + location: "121.5000,31.2000", updatedAt: now, lastApiFetchAt: now, }, }) ); - }); + }, CURRENT_APP_VERSION); await page.goto("/study"); diff --git a/tests/e2e/visual-regression.e2e.spec.ts b/tests/e2e/visual-regression.e2e.spec.ts index d5ca910..e8bd064 100644 --- a/tests/e2e/visual-regression.e2e.spec.ts +++ b/tests/e2e/visual-regression.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"; type Background = | { type: "default" } @@ -31,7 +31,7 @@ async function prepareVisualPage( await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" }); await page.goto("/"); await page.evaluate( - ({ nextBackground, hideUntil }) => { + ({ appVersion, nextBackground, hideUntil }) => { const current = JSON.parse(localStorage.getItem("AppSettings") || "{}"); current.general = { ...(current.general || {}), @@ -39,13 +39,17 @@ async function prepareVisualPage( announcement: { ...(current.general?.announcement || {}), hideUntil, - version: "3.13.3", + version: appVersion, }, }; localStorage.setItem("AppSettings", JSON.stringify(current)); localStorage.setItem("immersive-clock:has-seen-tour", "true"); }, - { nextBackground: background, hideUntil: FIXED_TIME.getTime() + 7 * 24 * 60 * 60 * 1000 } + { + appVersion: CURRENT_APP_VERSION, + nextBackground: background, + hideUntil: FIXED_TIME.getTime() + 7 * 24 * 60 * 60 * 1000, + } ); await page.reload(); const motionResetStyle = await page.addStyleTag({ @@ -321,7 +325,9 @@ for (const viewport of [ test(`设置抽屉视觉快照 ${viewport.width}×${viewport.height}`, async ({ page }) => { await prepareVisualPage(page, viewport, { type: "default" }); await page.getByRole("button", { name: "打开设置" }).click(); - await expect(page.getByRole("dialog", { name: "设置" })).toBeVisible(); + const dialog = page.getByRole("dialog", { name: "设置" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("heading", { name: "启动页面", level: 2 })).toBeVisible(); await expect(page).toHaveScreenshot(`settings-${viewport.width}x${viewport.height}.png`, { animations: "disabled", @@ -377,7 +383,7 @@ for (const viewport of [ minimumAreaUtilization: 0.25, section: "顶部进度与信息", }, - { label: "事件倒计时外观预览", minimumAreaUtilization: 0.35, section: "事件倒计时" }, + { label: "事件倒计时外观预览", minimumAreaUtilization: 0.34, section: "事件倒计时" }, ]) { await selectAppearanceSection(dialog, viewport, preview.section); const componentPreview = dialog.getByLabel(preview.label);