From c66a265935ffe62d6aef304503a624d2d5ebe50d Mon Sep 17 00:00:00 2001 From: Sunwuyuan Date: Sun, 28 Jun 2026 13:59:11 +0800 Subject: [PATCH] PWA --- .github/workflows/store-pwa.yml | 94 ++++++++++++++++++++++++++++ docs/microsoft-store-pwa.md | 23 +++++++ index.html | 13 +++- package.json | 2 + scripts/validate-pwa-build.js | 100 ++++++++++++++++++++++++++++++ src/App.vue | 20 ++++-- src/components/PwaInstallCard.vue | 7 +++ vite.config.mjs | 43 +++++++++++-- 8 files changed, 290 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/store-pwa.yml create mode 100644 docs/microsoft-store-pwa.md create mode 100644 scripts/validate-pwa-build.js diff --git a/.github/workflows/store-pwa.yml b/.github/workflows/store-pwa.yml new file mode 100644 index 0000000..97b7d59 --- /dev/null +++ b/.github/workflows/store-pwa.yml @@ -0,0 +1,94 @@ +name: PWA Store Build + +on: + pull_request: + branches: ["main"] + paths: + - ".github/workflows/store-pwa.yml" + - "index.html" + - "package.json" + - "pnpm-lock.yaml" + - "public/**" + - "scripts/**" + - "src/**" + - "vite.config.mjs" + workflow_dispatch: + inputs: + production_url: + description: "Public HTTPS URL used for Microsoft Store/PWABuilder submission" + required: true + default: "https://cs.houlang.cloud/" + +permissions: + contents: read + +concurrency: + group: pwa-store-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-validate: + runs-on: ubuntu-latest + env: + VITE_APP_ID: d158067f53627d2b98babe8bffd2fd7d + VITE_DEFAULT_KV_SERVER: https://kv-service.houlang.cloud + VITE_DEFAULT_AUTH_SERVER: https://kv.houlang.cloud + STORE_PRODUCTION_URL: ${{ inputs.production_url || 'https://cs.houlang.cloud/' }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Enable Corepack + run: corepack enable + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: "20.x" + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build and validate PWA + run: pnpm run build:store + + - name: Create store handoff metadata + run: | + node <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + + const url = process.env.STORE_PRODUCTION_URL; + const handoffDir = path.resolve('store-handoff'); + fs.mkdirSync(handoffDir, { recursive: true }); + fs.writeFileSync( + path.join(handoffDir, 'microsoft-store-pwa.json'), + JSON.stringify({ + productionUrl: url, + manifestUrl: new URL('manifest.webmanifest', url).toString(), + packageSource: 'Use PWABuilder or Microsoft Partner Center with the production URL above.', + validationArtifact: 'dist/', + notes: [ + 'Serve the app over HTTPS before packaging.', + 'Run this workflow after changing manifest, icons, service worker, or public assets.', + 'Partner Center listing screenshots, age rating, privacy policy, and publisher identity are handled outside this repository.' + ] + }, null, 2) + ); + NODE + + - name: Upload validated PWA dist + uses: actions/upload-artifact@v4 + with: + name: classworks-pwa-dist + path: dist + if-no-files-found: error + + - name: Upload Microsoft Store handoff + uses: actions/upload-artifact@v4 + with: + name: microsoft-store-handoff + path: store-handoff + if-no-files-found: error diff --git a/docs/microsoft-store-pwa.md b/docs/microsoft-store-pwa.md new file mode 100644 index 0000000..962c1a1 --- /dev/null +++ b/docs/microsoft-store-pwa.md @@ -0,0 +1,23 @@ +# Microsoft Store PWA 上架流程 + +## 仓库内自动化 + +1. 在 GitHub Actions 手动运行 `PWA Store Build`。 +2. 确认 `production_url` 是最终公开 HTTPS 地址,例如 `https://cs.houlang.cloud/`。 +3. workflow 会执行 `pnpm run build:store`,生成并校验 `dist/manifest.webmanifest`、`dist/sw.js`、图标和 HTML PWA hint。 +4. 下载 `classworks-pwa-dist` 和 `microsoft-store-handoff` artifacts,作为 PWABuilder 或 Partner Center 提交前的构建记录。 + +## 提交到 Microsoft Store + +1. 确保生产站点已部署最新构建,并可通过 HTTPS 访问。 +2. 使用 PWABuilder 或 Microsoft Partner Center 指向生产 URL。 +3. 填写商店资料:应用分类、年龄分级、隐私政策、支持链接、截图和发布者信息。 +4. 如果 manifest、图标、service worker 或公开资源有变更,重新运行 `PWA Store Build`。 + +## 本地校验 + +```bash +pnpm run build:store +``` + +该命令会先刷新音频资源清单,再构建 Vite 应用,最后执行 PWA 构建校验。 diff --git a/index.html b/index.html index d04c40c..642a83e 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,7 @@ + @@ -27,9 +28,17 @@ - - + + + + + + + + + + diff --git a/package.json b/package.json index 868d6e3..98f5711 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "scripts": { "dev": "vite --host", "build": "vite build", + "build:store": "node scripts/generate-sound-list.js && vite build && node scripts/validate-pwa-build.js", "preview": "vite preview", "lint": "eslint . --fix", + "pwa:validate": "node scripts/validate-pwa-build.js", "prebuild": "node scripts/generate-sound-list.js" }, "dependencies": { diff --git a/scripts/validate-pwa-build.js b/scripts/validate-pwa-build.js new file mode 100644 index 0000000..c101989 --- /dev/null +++ b/scripts/validate-pwa-build.js @@ -0,0 +1,100 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const distDir = path.resolve('dist'); +const manifestPath = path.join(distDir, 'manifest.webmanifest'); +const indexPath = path.join(distDir, 'index.html'); +const serviceWorkerPath = path.join(distDir, 'sw.js'); + +const errors = []; + +function fail(message) { + errors.push(message); +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + fail(`无法读取或解析 ${path.relative(process.cwd(), filePath)}: ${error.message}`); + return null; + } +} + +function fileExistsFromManifest(src) { + const normalized = src.replace(/^\.?\//, '').split('?')[0]; + return fs.existsSync(path.join(distDir, normalized)); +} + +if (!fs.existsSync(distDir)) { + fail('dist 目录不存在,请先运行构建。'); +} + +const manifest = fs.existsSync(manifestPath) ? readJson(manifestPath) : null; +if (!manifest) { + fail('dist/manifest.webmanifest 不存在。'); +} else { + for (const field of ['id', 'name', 'short_name', 'description', 'start_url', 'scope', 'display', 'theme_color', 'background_color']) { + if (!manifest[field]) { + fail(`manifest 缺少 ${field}。`); + } + } + + if (!['standalone', 'fullscreen', 'minimal-ui'].includes(manifest.display)) { + fail('manifest.display 应为 standalone、fullscreen 或 minimal-ui。'); + } + + const icons = Array.isArray(manifest.icons) ? manifest.icons : []; + const has192 = icons.some((icon) => icon.sizes?.includes('192x192') && fileExistsFromManifest(icon.src)); + const has512 = icons.some((icon) => icon.sizes?.includes('512x512') && fileExistsFromManifest(icon.src)); + const hasMaskable = icons.some((icon) => icon.sizes?.includes('512x512') && icon.purpose?.includes('maskable') && fileExistsFromManifest(icon.src)); + + if (!has192) fail('manifest 缺少可访问的 192x192 图标。'); + if (!has512) fail('manifest 缺少可访问的 512x512 图标。'); + if (!hasMaskable) fail('manifest 缺少可访问的 512x512 maskable 图标。'); + + if (!Array.isArray(manifest.categories) || manifest.categories.length === 0) { + fail('manifest 缺少 categories,Microsoft Store/PWABuilder 会降低质量评分。'); + } +} + +if (!fs.existsSync(indexPath)) { + fail('dist/index.html 不存在。'); +} else { + const html = fs.readFileSync(indexPath, 'utf8'); + if (!html.includes('rel="manifest"')) { + fail('index.html 缺少 manifest 链接。'); + } + if (!html.includes('apple-touch-icon')) { + fail('index.html 缺少 apple-touch-icon。'); + } + if (!html.includes('msapplication-TileImage')) { + fail('index.html 缺少 Windows tile 图标声明。'); + } +} + +if (!fs.existsSync(serviceWorkerPath)) { + fail('dist/sw.js 不存在,PWA 离线能力未生成。'); +} else { + const sw = fs.readFileSync(serviceWorkerPath, 'utf8'); + if (!sw.includes('sw-cache-manager.js')) { + fail('sw.js 未导入 sw-cache-manager.js。'); + } + if (!sw.includes('index.html')) { + fail('sw.js 未包含导航回退,离线打开应用可能失败。'); + } +} + +if (!fs.existsSync(path.join(distDir, 'sw-cache-manager.js'))) { + fail('dist/sw-cache-manager.js 不存在。'); +} + +if (errors.length > 0) { + console.error('PWA 构建校验失败:'); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); +} + +console.log('PWA 构建校验通过。'); diff --git a/src/App.vue b/src/App.vue index af2408a..cf027ee 100644 --- a/src/App.vue +++ b/src/App.vue @@ -72,6 +72,17 @@ const bgOverlayStyle = computed(() => ({ let unwatchSettings = null; +const onBeforeInstallPrompt = (e) => { + e.preventDefault(); + window.deferredPwaPrompt = e; + window.dispatchEvent(new Event('pwa-prompt-ready')); +}; + +const onAppInstalled = () => { + window.deferredPwaPrompt = null; + window.dispatchEvent(new Event('pwa-installed')); +}; + onMounted(() => { // 应用保存的主题设置 const savedTheme = getSetting("theme.mode"); @@ -88,15 +99,14 @@ onMounted(() => { } }); - window.addEventListener('beforeinstallprompt', (e) => { - e.preventDefault(); - window.deferredPwaPrompt = e; - window.dispatchEvent(new Event('pwa-prompt-ready')); - }); + window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt); + window.addEventListener('appinstalled', onAppInstalled); }); onUnmounted(() => { if (unwatchSettings) unwatchSettings(); + window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt); + window.removeEventListener('appinstalled', onAppInstalled); });