diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 94579d30f..2a593094c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -87,6 +87,8 @@ jobs: GH_TOKEN: ${{ github.token }} CHANNEL: ${{ steps.channel.outputs.channel }} VITEPRESS_BASE: ${{ vars.VITEPRESS_BASE || format('/{0}/', github.event.repository.name) }} + SITE_ORIGIN: ${{ vars.SITE_ORIGIN || format('https://{0}.github.io', github.repository_owner) }} + VITE_DEV_MODE: ${{ steps.channel.outputs.channel == 'beta' && 'true' || 'false' }} run: | pnpm install --frozen-lockfile @@ -141,12 +143,161 @@ jobs: cp -r docs/.vitepress/dist/* gh-pages/ touch gh-pages/.nojekyll + CHANNEL="${{ steps.channel.outputs.channel }}" + + # 准备当前渠道的 DWEB 资源 + if [ -d "dists" ] && [ "$(ls -A dists)" ]; then + if [[ "$CHANNEL" == "stable" ]]; then + DWEB_DIR="gh-pages/dweb" + DWEB_ZIP="bfmpay-dweb.zip" + DWEB_PATH="dweb" + else + DWEB_DIR="gh-pages/dweb-dev" + DWEB_ZIP="bfmpay-dweb-beta.zip" + DWEB_PATH="dweb-dev" + fi + + mkdir -p "$DWEB_DIR" + cp -r dists/* "$DWEB_DIR/" + if [ -d "public/logos" ]; then + mkdir -p "$DWEB_DIR/logos" + cp -r public/logos/* "$DWEB_DIR/logos/" + if [ -f "public/logos/logo-256.webp" ]; then + cp public/logos/logo-256.webp "$DWEB_DIR/logo-256.webp" + fi + fi + + if [ -f "$DWEB_DIR/metadata.json" ]; then + DWEB_DIR="$DWEB_DIR" DWEB_ZIP="$DWEB_ZIP" DWEB_PATH="$DWEB_PATH" SITE_ORIGIN="$SITE_ORIGIN" VITEPRESS_BASE="$VITEPRESS_BASE" node - <<'NODE' +const fs = require('fs'); +const path = require('path'); +const dwebDir = process.env.DWEB_DIR; +const zipName = process.env.DWEB_ZIP; +const dwebPath = process.env.DWEB_PATH || ''; +const siteOrigin = process.env.SITE_ORIGIN || ''; +const basePath = process.env.VITEPRESS_BASE || '/'; +const metadataPath = path.join(dwebDir, 'metadata.json'); +const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); +const logoFileName = 'logo-256.webp'; +const normalizedPath = dwebPath.replace(/^\/+/, '').replace(/\/$/, ''); +const baseUrl = siteOrigin ? new URL(basePath, siteOrigin).toString() : ''; +const logoPath = normalizedPath ? `${normalizedPath}/${logoFileName}` : logoFileName; +const logoUrl = baseUrl ? new URL(logoPath, baseUrl).toString() : logoFileName; +if (metadata.logo) { + const lower = String(metadata.logo).toLowerCase(); + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + metadata.logo = logoUrl; + } +} else { + metadata.logo = logoUrl; +} +if (Array.isArray(metadata.icons)) { + metadata.icons = metadata.icons.map((icon) => { + if (!icon?.src) return icon; + const src = String(icon.src); + const lower = src.toLowerCase(); + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + return { ...icon, src: logoUrl }; + } + return icon; + }); +} +const bundleUrl = typeof metadata.bundle_url === 'string' ? metadata.bundle_url : ''; +if (bundleUrl) { + const bundleFile = bundleUrl.replace(/^\.\//, ''); + const bundlePath = path.join(dwebDir, bundleFile); + if (fs.existsSync(bundlePath)) { + fs.copyFileSync(bundlePath, path.join(dwebDir, zipName)); + } else { + console.warn(`DWEB bundle not found: ${bundlePath}`); + } +} else { + console.warn('metadata.json missing bundle_url'); +} +fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +NODE + else + echo "metadata.json missing in $DWEB_DIR" + fi + else + echo "No DWEB assets found, skipping gh-pages DWEB publish" + fi + + # beta 渠道尝试同步 stable DWEB 资源(从 release) + if [[ "$CHANNEL" != "stable" ]]; then + STABLE_TAG=$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName' -R ${{ github.repository }} || true) + if [ -n "$STABLE_TAG" ]; then + mkdir -p /tmp/stable-dweb + gh release download "$STABLE_TAG" --pattern 'metadata.json' --dir /tmp/stable-dweb -R ${{ github.repository }} || true + if [ -f "/tmp/stable-dweb/metadata.json" ]; then + BUNDLE_FILE=$(node -e "const fs=require('fs');const m=JSON.parse(fs.readFileSync('/tmp/stable-dweb/metadata.json','utf8'));console.log((m.bundle_url||'').replace(/^\\.\\//,''));") + if [ -n "$BUNDLE_FILE" ]; then + gh release download "$STABLE_TAG" --pattern "$BUNDLE_FILE" --dir /tmp/stable-dweb -R ${{ github.repository }} || true + fi + mkdir -p gh-pages/dweb + cp /tmp/stable-dweb/metadata.json gh-pages/dweb/ + if [ -n "$BUNDLE_FILE" ] && [ -f "/tmp/stable-dweb/$BUNDLE_FILE" ]; then + cp "/tmp/stable-dweb/$BUNDLE_FILE" gh-pages/dweb/ + cp "/tmp/stable-dweb/$BUNDLE_FILE" gh-pages/dweb/bfmpay-dweb.zip + fi + if [ -d "public/logos" ]; then + mkdir -p gh-pages/dweb/logos + cp -r public/logos/* gh-pages/dweb/logos/ + if [ -f "public/logos/logo-256.webp" ]; then + cp public/logos/logo-256.webp gh-pages/dweb/logo-256.webp + fi + fi + if [ -f "gh-pages/dweb/metadata.json" ]; then + DWEB_PATH="dweb" SITE_ORIGIN="$SITE_ORIGIN" VITEPRESS_BASE="$VITEPRESS_BASE" node - <<'NODE' +const fs = require('fs'); +const path = require('path'); +const metadataPath = path.join('gh-pages', 'dweb', 'metadata.json'); +const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); +const logoFileName = 'logo-256.webp'; +const dwebPath = process.env.DWEB_PATH || ''; +const siteOrigin = process.env.SITE_ORIGIN || ''; +const basePath = process.env.VITEPRESS_BASE || '/'; +const normalizedPath = dwebPath.replace(/^\/+/, '').replace(/\/$/, ''); +const baseUrl = siteOrigin ? new URL(basePath, siteOrigin).toString() : ''; +const logoPath = normalizedPath ? `${normalizedPath}/${logoFileName}` : logoFileName; +const logoUrl = baseUrl ? new URL(logoPath, baseUrl).toString() : logoFileName; +if (metadata.logo) { + const lower = String(metadata.logo).toLowerCase(); + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + metadata.logo = logoUrl; + } +} else { + metadata.logo = logoUrl; +} +if (Array.isArray(metadata.icons)) { + metadata.icons = metadata.icons.map((icon) => { + if (!icon?.src) return icon; + const src = String(icon.src); + const lower = src.toLowerCase(); + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + return { ...icon, src: logoUrl }; + } + return icon; + }); +} +fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +NODE + fi + fi + fi + fi + - name: Create release artifacts + if: steps.channel.outputs.channel == 'stable' env: CHANNEL: ${{ steps.channel.outputs.channel }} run: | mkdir -p release VERSION="${{ steps.version.outputs.version }}" + RELEASE_TAG="v${VERSION}" + RELEASE_BASE_URL="https://github.com/${{ github.repository }}/releases/download/${RELEASE_TAG}/" + RELEASE_TAG="v${VERSION}" + RELEASE_BASE_URL="https://github.com/${{ github.repository }}/releases/download/${RELEASE_TAG}/" if [[ "$CHANNEL" == "stable" ]]; then cd dist-web && zip -r ../release/bfmpay-web.zip . && cd .. @@ -173,6 +324,50 @@ jobs: cp dists/metadata.json release/metadata.json fi + # Copy dweb bundle + logos for release install assets + if [ -d "dists" ] && [ "$(ls -A dists)" ]; then + cp -r dists/* release/ + fi + if [ -d "public/logos" ]; then + mkdir -p release/logos + cp -r public/logos/* release/logos/ + if [ -f "public/logos/logo-256.webp" ]; then + cp public/logos/logo-256.webp release/logo-256.webp + fi + fi + + if [ -f "release/metadata.json" ]; then + RELEASE_BASE_URL="$RELEASE_BASE_URL" node - <<'NODE' +const fs = require('fs'); +const path = require('path'); +const metadataPath = path.join('release', 'metadata.json'); +const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); +const logoFileName = 'logo-256.webp'; +const releaseBaseUrl = process.env.RELEASE_BASE_URL || ''; +const logoUrl = releaseBaseUrl ? new URL(logoFileName, releaseBaseUrl).toString() : logoFileName; +if (metadata.logo) { + const lower = String(metadata.logo).toLowerCase(); + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + metadata.logo = logoUrl; + } +} else { + metadata.logo = logoUrl; +} +if (Array.isArray(metadata.icons)) { + metadata.icons = metadata.icons.map((icon) => { + if (!icon?.src) return icon; + const src = String(icon.src); + const lower = src.toLowerCase(); + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + return { ...icon, src: logoUrl }; + } + return icon; + }); +} +fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +NODE + fi + # 上传 DWEB 到 SFTP 服务器 - name: Upload DWEB to SFTP env: @@ -205,6 +400,7 @@ jobs: # 直接在 build job 中创建 release,避免跨 job 传递 artifact(self-hosted 下载很慢) # 使用重试逻辑处理网络不稳定问题 - name: Create or Update Release + if: steps.channel.outputs.channel == 'stable' uses: nick-fields/retry@v3 with: timeout_minutes: 10 @@ -309,12 +505,16 @@ jobs: # ===== 构建当前渠道的 Web 版本 ===== - name: Build Web version + env: + VITE_DEV_MODE: ${{ steps.channel.outputs.channel == 'beta' && 'true' || 'false' }} run: | SERVICE_IMPL=web pnpm build mv dist dist-web # ===== 构建 DWEB 版本 ===== - name: Build DWEB version + env: + VITE_DEV_MODE: ${{ steps.channel.outputs.channel == 'beta' && 'true' || 'false' }} run: | SERVICE_IMPL=dweb pnpm build mv dist dist-dweb @@ -398,12 +598,17 @@ jobs: - name: Build VitePress site env: VITEPRESS_BASE: ${{ vars.VITEPRESS_BASE || format('/{0}/', github.event.repository.name) }} + SITE_ORIGIN: ${{ vars.SITE_ORIGIN || format('https://{0}.github.io', github.repository_owner) }} run: | echo "Building with VITEPRESS_BASE=$VITEPRESS_BASE" pnpm docs:build # ===== 准备 GitHub Pages ===== - name: Prepare GitHub Pages + env: + GH_TOKEN: ${{ github.token }} + VITEPRESS_BASE: ${{ vars.VITEPRESS_BASE || format('/{0}/', github.event.repository.name) }} + SITE_ORIGIN: ${{ vars.SITE_ORIGIN || format('https://{0}.github.io', github.repository_owner) }} run: | # VitePress 输出目录 mkdir -p gh-pages @@ -412,11 +617,156 @@ jobs: # 禁用 Jekyll touch gh-pages/.nojekyll + CHANNEL="${{ steps.channel.outputs.channel }}" + + # 准备当前渠道的 DWEB 资源 + if [ -d "dists" ] && [ "$(ls -A dists)" ]; then + if [[ "$CHANNEL" == "stable" ]]; then + DWEB_DIR="gh-pages/dweb" + DWEB_ZIP="bfmpay-dweb.zip" + DWEB_PATH="dweb" + else + DWEB_DIR="gh-pages/dweb-dev" + DWEB_ZIP="bfmpay-dweb-beta.zip" + DWEB_PATH="dweb-dev" + fi + + mkdir -p "$DWEB_DIR" + cp -r dists/* "$DWEB_DIR/" + if [ -d "public/logos" ]; then + mkdir -p "$DWEB_DIR/logos" + cp -r public/logos/* "$DWEB_DIR/logos/" + if [ -f "public/logos/logo-256.webp" ]; then + cp public/logos/logo-256.webp "$DWEB_DIR/logo-256.webp" + fi + fi + + if [ -f "$DWEB_DIR/metadata.json" ]; then + DWEB_DIR="$DWEB_DIR" DWEB_ZIP="$DWEB_ZIP" DWEB_PATH="$DWEB_PATH" SITE_ORIGIN="$SITE_ORIGIN" VITEPRESS_BASE="$VITEPRESS_BASE" node - <<'NODE' +const fs = require('fs'); +const path = require('path'); +const dwebDir = process.env.DWEB_DIR; +const zipName = process.env.DWEB_ZIP; +const dwebPath = process.env.DWEB_PATH || ''; +const siteOrigin = process.env.SITE_ORIGIN || ''; +const basePath = process.env.VITEPRESS_BASE || '/'; +const metadataPath = path.join(dwebDir, 'metadata.json'); +const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); +const logoFileName = 'logo-256.webp'; +const normalizedPath = dwebPath.replace(/^\/+/, '').replace(/\/$/, ''); +const baseUrl = siteOrigin ? new URL(basePath, siteOrigin).toString() : ''; +const logoPath = normalizedPath ? `${normalizedPath}/${logoFileName}` : logoFileName; +const logoUrl = baseUrl ? new URL(logoPath, baseUrl).toString() : logoFileName; +if (metadata.logo) { + const lower = String(metadata.logo).toLowerCase(); + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + metadata.logo = logoUrl; + } +} else { + metadata.logo = logoUrl; +} +if (Array.isArray(metadata.icons)) { + metadata.icons = metadata.icons.map((icon) => { + if (!icon?.src) return icon; + const src = String(icon.src); + const lower = src.toLowerCase(); + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + return { ...icon, src: logoUrl }; + } + return icon; + }); +} +const bundleUrl = typeof metadata.bundle_url === 'string' ? metadata.bundle_url : ''; +if (bundleUrl) { + const bundleFile = bundleUrl.replace(/^\.\//, ''); + const bundlePath = path.join(dwebDir, bundleFile); + if (fs.existsSync(bundlePath)) { + fs.copyFileSync(bundlePath, path.join(dwebDir, zipName)); + } else { + console.warn(`DWEB bundle not found: ${bundlePath}`); + } +} else { + console.warn('metadata.json missing bundle_url'); +} +fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +NODE + else + echo "metadata.json missing in $DWEB_DIR" + fi + else + echo "No DWEB assets found, skipping gh-pages DWEB publish" + fi + + # beta 渠道尝试同步 stable DWEB 资源(从 release) + if [[ "$CHANNEL" != "stable" ]]; then + STABLE_TAG=$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName' -R ${{ github.repository }} || true) + if [ -n "$STABLE_TAG" ]; then + mkdir -p /tmp/stable-dweb + gh release download "$STABLE_TAG" --pattern 'metadata.json' --dir /tmp/stable-dweb -R ${{ github.repository }} || true + if [ -f "/tmp/stable-dweb/metadata.json" ]; then + BUNDLE_FILE=$(node -e "const fs=require('fs');const m=JSON.parse(fs.readFileSync('/tmp/stable-dweb/metadata.json','utf8'));console.log((m.bundle_url||'').replace(/^\\.\\//,''));") + if [ -n "$BUNDLE_FILE" ]; then + gh release download "$STABLE_TAG" --pattern "$BUNDLE_FILE" --dir /tmp/stable-dweb -R ${{ github.repository }} || true + fi + mkdir -p gh-pages/dweb + cp /tmp/stable-dweb/metadata.json gh-pages/dweb/ + if [ -n "$BUNDLE_FILE" ] && [ -f "/tmp/stable-dweb/$BUNDLE_FILE" ]; then + cp "/tmp/stable-dweb/$BUNDLE_FILE" gh-pages/dweb/ + cp "/tmp/stable-dweb/$BUNDLE_FILE" gh-pages/dweb/bfmpay-dweb.zip + fi + if [ -d "public/logos" ]; then + mkdir -p gh-pages/dweb/logos + cp -r public/logos/* gh-pages/dweb/logos/ + if [ -f "public/logos/logo-256.webp" ]; then + cp public/logos/logo-256.webp gh-pages/dweb/logo-256.webp + fi + fi + if [ -f "gh-pages/dweb/metadata.json" ]; then + DWEB_PATH="dweb" SITE_ORIGIN="$SITE_ORIGIN" VITEPRESS_BASE="$VITEPRESS_BASE" node - <<'NODE' +const fs = require('fs'); +const path = require('path'); +const metadataPath = path.join('gh-pages', 'dweb', 'metadata.json'); +const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); +const logoFileName = 'logo-256.webp'; +const dwebPath = process.env.DWEB_PATH || ''; +const siteOrigin = process.env.SITE_ORIGIN || ''; +const basePath = process.env.VITEPRESS_BASE || '/'; +const normalizedPath = dwebPath.replace(/^\/+/, '').replace(/\/$/, ''); +const baseUrl = siteOrigin ? new URL(basePath, siteOrigin).toString() : ''; +const logoPath = normalizedPath ? `${normalizedPath}/${logoFileName}` : logoFileName; +const logoUrl = baseUrl ? new URL(logoPath, baseUrl).toString() : logoFileName; +if (metadata.logo) { + const lower = String(metadata.logo).toLowerCase(); + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + metadata.logo = logoUrl; + } +} else { + metadata.logo = logoUrl; +} +if (Array.isArray(metadata.icons)) { + metadata.icons = metadata.icons.map((icon) => { + if (!icon?.src) return icon; + const src = String(icon.src); + const lower = src.toLowerCase(); + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + return { ...icon, src: logoUrl }; + } + return icon; + }); +} +fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +NODE + fi + fi + fi + fi + echo "=== GitHub Pages structure ===" find gh-pages -maxdepth 2 -type d # ===== 创建 Release 产物 ===== - name: Create release artifacts + if: steps.channel.outputs.channel == 'stable' env: CHANNEL: ${{ steps.channel.outputs.channel }} run: | @@ -452,6 +802,50 @@ jobs: cp dists/metadata.json release/metadata.json fi + # Copy dweb bundle + logos for release install assets + if [ -d "dists" ] && [ "$(ls -A dists)" ]; then + cp -r dists/* release/ + fi + if [ -d "public/logos" ]; then + mkdir -p release/logos + cp -r public/logos/* release/logos/ + if [ -f "public/logos/logo-256.webp" ]; then + cp public/logos/logo-256.webp release/logo-256.webp + fi + fi + + if [ -f "release/metadata.json" ]; then + RELEASE_BASE_URL="$RELEASE_BASE_URL" node - <<'NODE' +const fs = require('fs'); +const path = require('path'); +const metadataPath = path.join('release', 'metadata.json'); +const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); +const logoFileName = 'logo-256.webp'; +const releaseBaseUrl = process.env.RELEASE_BASE_URL || ''; +const logoUrl = releaseBaseUrl ? new URL(logoFileName, releaseBaseUrl).toString() : logoFileName; +if (metadata.logo) { + const lower = String(metadata.logo).toLowerCase(); + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + metadata.logo = logoUrl; + } +} else { + metadata.logo = logoUrl; +} +if (Array.isArray(metadata.icons)) { + metadata.icons = metadata.icons.map((icon) => { + if (!icon?.src) return icon; + const src = String(icon.src); + const lower = src.toLowerCase(); + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + return { ...icon, src: logoUrl }; + } + return icon; + }); +} +fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); +NODE + fi + echo "=== Release artifacts ===" ls -la release/ @@ -485,6 +879,7 @@ jobs: echo "Deployed to gh-pages branch" - name: Upload release artifacts + if: steps.channel.outputs.channel == 'stable' uses: actions/upload-artifact@v4 with: name: release-${{ steps.channel.outputs.channel }}-${{ steps.version.outputs.version }} @@ -493,7 +888,7 @@ jobs: # ==================== 创建 Release (GitHub-hosted) ==================== create-release-standard: - if: vars.USE_SELF_HOSTED != 'true' + if: vars.USE_SELF_HOSTED != 'true' && needs.build-standard.outputs.channel == 'stable' needs: build-standard runs-on: ubuntu-latest diff --git a/.storybook/main.ts b/.storybook/main.ts index 902056df4..d8bbbb9f5 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,5 +1,6 @@ import type { StorybookConfig } from '@storybook/react-vite' import { loadEnv } from 'vite' +import { readFileSync } from 'node:fs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' @@ -12,6 +13,12 @@ const config: StorybookConfig = { }, viteFinal: async (config) => { const env = loadEnv(config.mode ?? 'development', process.cwd(), '') + const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8')) as { version?: string } + const buildTime = new Date() + const pad = (value: number) => value.toString().padStart(2, '0') + const buildSuffix = `-${pad(buildTime.getUTCMonth() + 1)}${pad(buildTime.getUTCDate())}${pad(buildTime.getUTCHours())}` + const isDevBuild = (env.VITE_DEV_MODE ?? process.env.VITE_DEV_MODE) === 'true' + const appVersion = `${pkg.version ?? '0.0.0'}${isDevBuild ? buildSuffix : ''}` // Keep Storybook deterministic: use mock exchange rates instead of network calls. config.resolve ??= {} @@ -32,6 +39,7 @@ const config: StorybookConfig = { const etherscanApiKey = env.ETHERSCAN_API_KEY ?? process.env.ETHERSCAN_API_KEY ?? '' config.define = { ...config.define, + '__APP_VERSION__': JSON.stringify(appVersion), '__API_KEYS__': JSON.stringify({ TRONGRID_API_KEY: tronGridApiKey, ETHERSCAN_API_KEY: etherscanApiKey, diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..90d36be8e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# 更新日志 + +## [0.2.0] - 2026-01-26 + +DWEB 安装资源与版本发布流程优化 + + + diff --git a/deno.lock b/deno.lock index 67f488349..aa4d20a87 100644 --- a/deno.lock +++ b/deno.lock @@ -10141,6 +10141,7 @@ "npm:@bnqkl/wallet-sdk@~0.23.8", "npm:@bnqkl/wallet-typings@~0.23.8", "npm:@effect/platform@~0.94.2", + "npm:@emotion/is-prop-valid@^1.4.0", "npm:@fontsource-variable/dm-sans@^5.2.8", "npm:@fontsource-variable/figtree@^5.2.10", "npm:@fontsource/dm-mono@^5.2.7", diff --git a/docs/.vitepress/plugins/webapp-loader.ts b/docs/.vitepress/plugins/webapp-loader.ts index 9dd0f6b43..1d92b9fc4 100644 --- a/docs/.vitepress/plugins/webapp-loader.ts +++ b/docs/.vitepress/plugins/webapp-loader.ts @@ -7,14 +7,17 @@ * 3. 都没有则运行本地构建 */ -import { existsSync, mkdirSync, rmSync, cpSync, writeFileSync, readFileSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync, cpSync, writeFileSync, readFileSync, readdirSync, statSync } from 'node:fs' import { execSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { resolve, join } from 'node:path' import type { Plugin } from 'vite' const ROOT = resolve(__dirname, '../../..') const DOCS_PUBLIC = resolve(__dirname, '../../public') // docs/public const DIST_WEB_DIR = join(ROOT, 'dist-web') +const DIST_DWEB_DIR = join(ROOT, 'dist-dweb') +const DISTS_DIR = join(ROOT, 'dists') // 从 package.json 读取 GitHub 信息 function getGitHubInfo() { @@ -47,6 +50,23 @@ const log = { warn: (msg: string) => console.log(`${colors.yellow}[webapp-loader]${colors.reset} ${msg}`), } +function copyDirContents(src: string, dest: string) { + if (!existsSync(src)) return + mkdirSync(dest, { recursive: true }) + for (const entry of readdirSync(src)) { + cpSync(join(src, entry), join(dest, entry), { recursive: true }) + } +} + +function commandExists(command: string): boolean { + try { + execSync(`${command} --version`, { stdio: 'ignore' }) + return true + } catch { + return false + } +} + async function downloadFromRelease(tag: string, assetName: string, outputDir: string): Promise { log.info(`尝试下载: ${tag}/${assetName}`) @@ -108,6 +128,250 @@ async function buildLocal(): Promise { } } +async function buildLocalDweb(): Promise { + log.info('运行本地构建: pnpm build:dweb') + try { + execSync('pnpm build:dweb', { + cwd: ROOT, + stdio: 'inherit', + env: { ...process.env, SERVICE_IMPL: 'dweb' }, + }) + + const distDir = join(ROOT, 'dist') + if (existsSync(distDir)) { + if (existsSync(DIST_DWEB_DIR)) { + rmSync(DIST_DWEB_DIR, { recursive: true, force: true }) + } + cpSync(distDir, DIST_DWEB_DIR, { recursive: true }) + rmSync(distDir, { recursive: true, force: true }) + } + + return existsSync(DIST_DWEB_DIR) + } catch { + return false + } +} + +function createZip(sourceDir: string, outputPath: string): boolean { + try { + execSync(`zip -r "${outputPath}" .`, { cwd: sourceDir, stdio: 'ignore' }) + return existsSync(outputPath) + } catch { + return false + } +} + +function normalizeAssetPath(path: string): string { + return path.startsWith('/') ? path.slice(1) : path +} + +function resolveAbsoluteBaseUrl(): string | null { + const origin = process.env.DWEB_SITE_ORIGIN ?? process.env.VITEPRESS_SITE_ORIGIN ?? process.env.SITE_ORIGIN + if (!origin) return null + const base = process.env.VITEPRESS_BASE ?? '/' + return new URL(base, origin).toString() +} + +function rewriteMetadataLogo(metadataPath: string, logoFileName: string, absoluteBaseUrl?: string, dwebPath?: string): void { + if (!existsSync(metadataPath)) return + const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as { + logo?: string + icons?: Array<{ src: string; [key: string]: unknown }> + } + + const normalizedPath = dwebPath ? normalizeAssetPath(dwebPath) : '' + const absoluteLogoUrl = absoluteBaseUrl + ? new URL(normalizedPath ? `${normalizedPath}/${logoFileName}` : logoFileName, absoluteBaseUrl).toString() + : null + + if (metadata.logo) { + const lower = metadata.logo.toLowerCase() + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + metadata.logo = absoluteLogoUrl ?? logoFileName + } + } else { + metadata.logo = absoluteLogoUrl ?? logoFileName + } + + if (Array.isArray(metadata.icons)) { + metadata.icons = metadata.icons.map((icon) => { + if (!icon?.src) return icon + const src = String(icon.src) + const lower = src.toLowerCase() + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + return { ...icon, src: absoluteLogoUrl ?? logoFileName } + } + return icon + }) + } + + writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)) +} + +function writeFallbackMetadata(outputDir: string, zipName: string): void { + const manifestPath = join(ROOT, 'manifest.json') + if (!existsSync(manifestPath)) return + + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { + id?: string + name?: string + short_name?: string + description?: string + logo?: string + change_log?: string + images?: unknown[] + author?: string[] + version?: string + categories?: string[] + languages?: string[] + homepage_url?: string + icons?: Array<{ src: string; [key: string]: unknown }> + home?: string + } + + const zipPath = join(outputDir, zipName) + let hash = '' + let size = 0 + if (existsSync(zipPath)) { + const zipBuffer = readFileSync(zipPath) + hash = createHash('sha256').update(zipBuffer).digest('hex') + size = statSync(zipPath).size + } + + const metadata = { + id: manifest.id ?? 'bfmpay.bfmeta.com.dweb', + minTarget: 3, + maxTarget: 3, + name: manifest.name ?? 'BFM Pay', + short_name: manifest.short_name ?? manifest.name ?? 'BFM Pay', + description: manifest.description ?? '', + logo: 'logo-256.webp', + bundle_url: `./${zipName}`, + bundle_hash: hash ? `sha256:${hash}` : '', + bundle_size: size, + bundle_signature: '', + public_key_url: '', + release_date: new Date().toString(), + change_log: manifest.change_log ?? '', + images: manifest.images ?? [], + author: manifest.author ?? [], + version: manifest.version ?? '0.0.0', + categories: manifest.categories ?? ['application'], + languages: manifest.languages ?? [], + homepage_url: manifest.homepage_url ?? '', + plugins: [], + permissions: [], + dir: 'ltr', + lang: '', + icons: (manifest.icons ?? []).map((icon) => ({ + ...icon, + src: 'logo-256.webp', + })), + screenshots: [], + display: 'standalone', + orientation: 'portrait', + theme_color: '#667eea', + background_color: '#ffffff', + shortcuts: [], + home: manifest.home ?? '', + } + + writeFileSync(join(outputDir, 'metadata.json'), JSON.stringify(metadata, null, 2)) +} + +function ensureDwebBundle(): boolean { + const metadataPath = join(DISTS_DIR, 'metadata.json') + if (existsSync(metadataPath)) return true + + if (!existsSync(DIST_DWEB_DIR)) { + return false + } + + if (!commandExists('plaoc')) { + log.warn('plaoc CLI 未安装,无法生成 DWEB metadata.json') + return false + } + + try { + if (existsSync(DISTS_DIR)) { + rmSync(DISTS_DIR, { recursive: true, force: true }) + } + execSync(`plaoc bundle "${DIST_DWEB_DIR}" -c ./ -o "${DISTS_DIR}"`, { cwd: ROOT, stdio: 'inherit' }) + return existsSync(metadataPath) + } catch { + return false + } +} + +async function prepareDwebAssets(channel: 'stable' | 'beta', outputDir: string, dwebPath: string): Promise { + const zipName = channel === 'stable' ? 'bfmpay-dweb.zip' : 'bfmpay-dweb-beta.zip' + const logoFileName = 'logo-256.webp' + const absoluteBaseUrl = resolveAbsoluteBaseUrl() + + if (existsSync(outputDir) && existsSync(join(outputDir, 'metadata.json'))) { + log.info(`${channel} dweb 已存在,跳过`) + return + } + + if (!existsSync(DIST_DWEB_DIR)) { + await buildLocalDweb() + } + + if (ensureDwebBundle()) { + rmSync(outputDir, { recursive: true, force: true }) + mkdirSync(outputDir, { recursive: true }) + copyDirContents(DISTS_DIR, outputDir) + + const logosDir = join(ROOT, 'public', 'logos') + copyDirContents(logosDir, join(outputDir, 'logos')) + const rootLogoPath = join(outputDir, logoFileName) + if (existsSync(join(logosDir, logoFileName))) { + cpSync(join(logosDir, logoFileName), rootLogoPath) + } + + const metadataPath = join(outputDir, 'metadata.json') + if (existsSync(metadataPath)) { + rewriteMetadataLogo(metadataPath, logoFileName, absoluteBaseUrl ?? undefined, dwebPath) + const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as { bundle_url?: string } + if (metadata.bundle_url) { + const bundleFile = metadata.bundle_url.replace(/^\.\//, '') + const bundlePath = join(outputDir, bundleFile) + if (existsSync(bundlePath)) { + cpSync(bundlePath, join(outputDir, zipName)) + } else { + log.warn(`DWEB bundle 未找到: ${bundlePath}`) + } + } else { + log.warn('metadata.json 缺少 bundle_url') + } + } + return + } + + log.warn(`${channel} dweb 产物未准备好 (缺少 metadata.json),使用 fallback metadata`) + rmSync(outputDir, { recursive: true, force: true }) + mkdirSync(outputDir, { recursive: true }) + + const logosDir = join(ROOT, 'public', 'logos') + copyDirContents(logosDir, join(outputDir, 'logos')) + if (existsSync(join(logosDir, logoFileName))) { + cpSync(join(logosDir, logoFileName), join(outputDir, logoFileName)) + } + + const zipPath = join(outputDir, zipName) + if (existsSync(DIST_DWEB_DIR)) { + const zipped = createZip(DIST_DWEB_DIR, zipPath) + if (!zipped) { + log.warn(`无法创建 DWEB ZIP: ${zipPath}`) + } + } else { + log.warn('dist-dweb 不存在,fallback metadata 将不包含有效 bundle') + } + + writeFallbackMetadata(outputDir, zipName) + rewriteMetadataLogo(join(outputDir, 'metadata.json'), logoFileName, absoluteBaseUrl ?? undefined, dwebPath) +} + async function prepareWebapp(channel: 'stable' | 'beta', outputDir: string): Promise { const tag = channel === 'stable' ? 'latest' : 'beta' const assetName = channel === 'stable' ? 'bfmpay-web.zip' : 'bfmpay-web-beta.zip' @@ -164,6 +428,11 @@ async function prepareAllWebapps() { prepareWebapp('beta', join(DOCS_PUBLIC, 'webapp-dev')), ]) + await Promise.all([ + prepareDwebAssets('stable', join(DOCS_PUBLIC, 'dweb'), 'dweb'), + prepareDwebAssets('beta', join(DOCS_PUBLIC, 'dweb-dev'), 'dweb-dev'), + ]) + log.success('webapp 目录准备完成') } @@ -181,19 +450,31 @@ export function webappLoaderPlugin(): Plugin { prepared = true } - // 为 webapp 子目录添加 SPA fallback 中间件 + server.httpServer?.once('listening', () => { + const origin = + server.resolvedUrls?.local?.[0] ?? `http://localhost:${server.config.server.port ?? 5173}/` + const baseUrl = new URL(server.config.base ?? '/', origin).toString() + rewriteMetadataLogo(join(DOCS_PUBLIC, 'dweb', 'metadata.json'), 'logo-256.webp', baseUrl, 'dweb') + rewriteMetadataLogo(join(DOCS_PUBLIC, 'dweb-dev', 'metadata.json'), 'logo-256.webp', baseUrl, 'dweb-dev') + }) + + // 为 webapp/dweb 子目录添加静态/SPA 处理 // 注意:这个中间件需要在 vite 的静态文件服务之前 server.middlewares.use((req, res, next) => { const url = req.url || '' + const base = server.config.base ?? '/' + const basePrefix = base === '/' ? '' : base.replace(/\/$/, '') + const requestPath = basePrefix && url.startsWith(basePrefix) ? url.slice(basePrefix.length) || '/' : url - // 处理 /webapp/ 和 /webapp-dev/ 路径 - const match = url.match(/^\/(webapp|webapp-dev)(\/.*)?$/) + // 处理 /webapp/ /webapp-dev/ /dweb/ /dweb-dev/ 路径 + const match = requestPath.match(/^\/(webapp|webapp-dev|dweb|dweb-dev)(\/.*)?$/) if (match) { const webappDir = match[1] const subPath = match[2] || '/' + const normalizedSubPath = subPath.replace(/^\/+/, '') // 检查是否请求的是静态文件 - const hasExtension = /\.[a-zA-Z0-9]+$/.test(subPath) + const hasExtension = /\.[a-zA-Z0-9]+$/.test(normalizedSubPath) if (!hasExtension) { // 目录或 SPA 路由请求,返回 index.html @@ -205,9 +486,9 @@ export function webappLoaderPlugin(): Plugin { } } else { // 静态文件请求,尝试从 public 目录读取 - const filePath = join(DOCS_PUBLIC, webappDir, subPath) + const filePath = join(DOCS_PUBLIC, webappDir, normalizedSubPath) if (existsSync(filePath)) { - const ext = subPath.split('.').pop() || '' + const ext = normalizedSubPath.split('.').pop() || '' const mimeTypes: Record = { js: 'application/javascript', css: 'text/css', diff --git a/docs/.vitepress/theme/components/DwebInstallLink.vue b/docs/.vitepress/theme/components/DwebInstallLink.vue new file mode 100644 index 000000000..242777a78 --- /dev/null +++ b/docs/.vitepress/theme/components/DwebInstallLink.vue @@ -0,0 +1,21 @@ + + + diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 000000000..1a25fb977 --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,11 @@ +import type { Theme } from 'vitepress' +import DefaultTheme from 'vitepress/theme' +import DwebInstallLink from './components/DwebInstallLink.vue' + +export default { + extends: DefaultTheme, + enhanceApp(ctx) { + DefaultTheme.enhanceApp?.(ctx) + ctx.app.component('DwebInstallLink', DwebInstallLink) + }, +} satisfies Theme diff --git a/docs/download.md b/docs/download.md index 1511b9ef1..b4a8acb17 100644 --- a/docs/download.md +++ b/docs/download.md @@ -41,8 +41,8 @@

DWEB 稳定版

经过充分测试的稳定版本,推荐日常使用。

- 安装到 DWEB - 下载 ZIP 文件 + 安装到 DWEB + 下载 ZIP 文件
@@ -51,11 +51,12 @@

DWEB 测试版

包含最新功能,每次代码更新自动发布。

- 安装 Beta 版 - 下载 ZIP 文件 + 安装 Beta 版 + 下载 ZIP 文件 + ## 版本说明 | 版本 | 更新频率 | 稳定性 | 适用场景 | diff --git a/manifest.json b/manifest.json index 84cd0b718..6e56684b1 100644 --- a/manifest.json +++ b/manifest.json @@ -3,19 +3,27 @@ "name": "BFM Pay", "short_name": "BFM Pay", "description": "BFM Pay - 多链钱包应用", - "logo": "/logos/logo-256.webp", - "change_log": "初始版本", + "logo": "logos/logo-256.webp", + "change_log": "DWEB 安装资源与版本发布流程优化", "icons": [ { - "src": "/logos/logo-256.webp", + "src": "logos/logo-256.webp", "purpose": "maskable" } ], "images": [], - "languages": ["中文", "English"], - "author": ["@bfmeta.info"], - "version": "0.1.0", - "categories": ["application", "wallet"], + "languages": [ + "中文", + "English" + ], + "author": [ + "@bfmeta.info" + ], + "version": "0.2.0", + "categories": [ + "application", + "wallet" + ], "home": "bfmpay.bfmeta.info", "homepage_url": "bfmpay.bfmeta.info" } diff --git a/package.json b/package.json index 067f858b8..af1b92b87 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@biochain/keyapp", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "packageManager": "pnpm@10.28.0", "scripts": { @@ -81,6 +81,7 @@ "@bnqkl/wallet-sdk": "^0.23.8", "@bnqkl/wallet-typings": "^0.23.8", "@effect/platform": "^0.94.2", + "@emotion/is-prop-valid": "^1.4.0", "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource-variable/figtree": "^5.2.10", "@fontsource/dm-mono": "^5.2.7", @@ -206,5 +207,6 @@ "workspaces": [ "packages/*", "miniapps/*" - ] + ], + "lastChangelogCommit": "5c166987badd06a0a81f4fd5a48ef6763d586be0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e16e7339..0f007e6af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@effect/platform': specifier: ^0.94.2 version: 0.94.2(effect@3.19.15) + '@emotion/is-prop-valid': + specifier: ^1.4.0 + version: 1.4.0 '@fontsource-variable/dm-sans': specifier: ^5.2.8 version: 5.2.8 @@ -163,7 +166,7 @@ importers: version: 4.17.21 motion: specifier: ^12.23.26 - version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) qrcode.react: specifier: ^4.2.0 version: 4.2.0(react@19.2.3) @@ -434,7 +437,7 @@ importers: version: 2.1.1 framer-motion: specifier: ^12.23.26 - version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) i18next: specifier: ^25.2.1 version: 25.7.3(typescript@5.9.3) @@ -591,7 +594,7 @@ importers: version: 2.1.1 framer-motion: specifier: ^12.23.26 - version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) i18next: specifier: ^25.2.1 version: 25.7.3(typescript@5.9.3) @@ -1634,6 +1637,12 @@ packages: '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -9495,6 +9504,12 @@ snapshots: '@emotion/hash@0.9.2': {} + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -13572,12 +13587,13 @@ snapshots: forwarded@0.2.0: {} - framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + framer-motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: motion-dom: 12.23.23 motion-utils: 12.23.6 tslib: 2.8.1 optionalDependencies: + '@emotion/is-prop-valid': 1.4.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -14474,11 +14490,12 @@ snapshots: motion-utils@12.23.6: {} - motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + motion@12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - framer-motion: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + framer-motion: 12.23.26(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tslib: 2.8.1 optionalDependencies: + '@emotion/is-prop-valid': 1.4.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) diff --git a/scripts/build.ts b/scripts/build.ts index 1309f1f52..ff37173c9 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -75,6 +75,15 @@ function exec(cmd: string, options?: { cwd?: string; env?: Record + } + + const normalizedPath = dwebPath ? dwebPath.replace(/^\/+/, '') : '' + const absoluteLogoUrl = absoluteBaseUrl + ? new URL(normalizedPath ? `${normalizedPath}/${logoFileName}` : logoFileName, absoluteBaseUrl).toString() + : null + + if (metadata.logo) { + const lower = metadata.logo.toLowerCase() + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + metadata.logo = absoluteLogoUrl ?? logoFileName + } + } else { + metadata.logo = absoluteLogoUrl ?? logoFileName + } + + if (Array.isArray(metadata.icons)) { + metadata.icons = metadata.icons.map((icon) => { + if (!icon?.src) return icon + const src = String(icon.src) + const lower = src.toLowerCase() + if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) { + return { ...icon, src: absoluteLogoUrl ?? logoFileName } + } + return icon + }) + } + + writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)) +} + +function publishDwebAssets(targetDir: string, zipAlias: string, dwebPath: string) { + const logosDir = join(ROOT, 'public', 'logos') + const hasDwebAssets = existsSync(DISTS_DIR) && readdirSync(DISTS_DIR).length > 0 + if (!hasDwebAssets) { + log.warn('DWEB 产物不存在,跳过 DWEB 资源发布') + return + } + + cleanDir(targetDir) + copyDirContents(DISTS_DIR, targetDir) + copyDir(logosDir, join(targetDir, 'logos')) + const logoFileName = 'logo-256.webp' + const rootLogoPath = join(targetDir, logoFileName) + if (existsSync(join(logosDir, logoFileName))) { + cpSync(join(logosDir, logoFileName), rootLogoPath) + } + + const metadataPath = join(targetDir, 'metadata.json') + if (existsSync(metadataPath)) { + const siteBaseUrl = resolveSiteBaseUrl() + rewriteMetadataLogo(metadataPath, logoFileName, siteBaseUrl ?? undefined, dwebPath) + const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')) as { bundle_url?: string } + if (metadata.bundle_url) { + const bundleFile = metadata.bundle_url.replace(/^\.\//, '') + const bundlePath = join(targetDir, bundleFile) + if (existsSync(bundlePath)) { + cpSync(bundlePath, join(targetDir, zipAlias)) + } else { + log.warn(`DWEB bundle 未找到: ${bundlePath}`) + } + } else { + log.warn('metadata.json 缺少 bundle_url') + } + } else { + log.warn('metadata.json 未找到,DWEB install 可能无法使用') + } +} + // ==================== 构建函数 ==================== async function runTypecheck() { @@ -143,6 +258,7 @@ async function buildWeb() { exec('pnpm build:web', { env: { SERVICE_IMPL: 'web', + VITE_DEV_MODE: getChannel() === 'beta' ? 'true' : 'false', }, }) @@ -225,8 +341,13 @@ async function buildDweb() { // 运行 plaoc bundle 打包 log.step('运行 Plaoc 打包') try { - exec(`plaoc bundle "${DIST_DWEB_DIR}" -c ./ -o "${DISTS_DIR}"`) - log.success('Plaoc 打包完成') + if (commandExists('plaoc')) { + exec(`plaoc bundle "${DIST_DWEB_DIR}" -c ./ -o "${DISTS_DIR}"`) + log.success('Plaoc 打包完成') + } else { + log.warn('Plaoc CLI 未安装,使用 dist-dweb 作为 dists 兜底') + copyDir(DIST_DWEB_DIR, DISTS_DIR) + } } catch (error) { log.warn('Plaoc 打包失败,可能未安装 plaoc CLI') log.info('请安装: npm install -g @aspect/plaoc-cli') @@ -282,6 +403,11 @@ async function prepareGhPages(webDir: string) { // 创建 .nojekyll 文件(禁用 Jekyll 处理) writeFileSync(join(ghPagesDir, '.nojekyll'), '') + const dwebTargetDir = join(ghPagesDir, channel === 'stable' ? 'dweb' : 'dweb-dev') + const dwebZipAlias = channel === 'stable' ? 'bfmpay-dweb.zip' : 'bfmpay-dweb-beta.zip' + const dwebPath = channel === 'stable' ? 'dweb' : 'dweb-dev' + publishDwebAssets(dwebTargetDir, dwebZipAlias, dwebPath) + log.success('GitHub Pages 目录准备完成') return ghPagesDir } @@ -322,13 +448,25 @@ async function createReleaseArtifacts(webDir: string, dwebDir: string) { const dwebVersionZipPath = join(releaseDir, `bfmpay-dweb-${version}${suffix}.zip`) cpSync(dwebZipPath, dwebVersionZipPath) - // 复制 metadata.json 到 release 目录(plaoc bundle 自动生成) - const metadataSrc = join(DISTS_DIR, 'metadata.json') - if (existsSync(metadataSrc)) { - cpSync(metadataSrc, join(releaseDir, 'metadata.json')) + // 复制 dweb metadata/bundle 到 release 目录(plaoc bundle 自动生成) + if (existsSync(DISTS_DIR) && readdirSync(DISTS_DIR).length > 0) { + copyDirContents(DISTS_DIR, releaseDir) log.info(` - metadata.json`) } else { - log.warn('metadata.json 未找到,dweb://install 链接可能无法正常工作') + log.warn('DWEB 产物不存在,dweb://install 链接可能无法正常工作') + } + + // 复制 DWEB logo 资源(供 metadata.json 相对路径使用) + const logosDir = join(ROOT, 'public', 'logos') + copyDir(logosDir, join(releaseDir, 'logos')) + if (existsSync(join(logosDir, 'logo-256.webp'))) { + cpSync(join(logosDir, 'logo-256.webp'), join(releaseDir, 'logo-256.webp')) + } + + const releaseMetadataPath = join(releaseDir, 'metadata.json') + if (existsSync(releaseMetadataPath)) { + const releaseBaseUrl = resolveReleaseBaseUrl() + rewriteMetadataLogo(releaseMetadataPath, 'logo-256.webp', releaseBaseUrl ?? undefined) } log.success(`Release 产物创建完成: ${releaseDir}`) diff --git a/scripts/release.ts b/scripts/release.ts index a3128eca8..882f54595 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -10,8 +10,8 @@ * 5. 上传 DWEB 到正式服务器 * 6. 更新 package.json 和 manifest.json * 7. 更新 CHANGELOG.md - * 8. 提交变更并打 tag - * 9. 推送触发 GitHub Pages 更新 + * 8. 提交变更 + * 9. 推送并手动触发 CI 发布(CI 创建 tag/release) * * Usage: * pnpm release @@ -73,6 +73,15 @@ function execOutput(cmd: string): string { return execSync(cmd, { cwd: ROOT, encoding: 'utf-8' }).trim() } +function commandExists(command: string): boolean { + try { + execSync(`${command} --version`, { stdio: 'ignore' }) + return true + } catch { + return false + } +} + function readJson(path: string): T { return JSON.parse(readFileSync(path, 'utf-8')) } @@ -89,8 +98,12 @@ async function checkWorkspace(): Promise { // 检查是否在 worktree 中 const cwd = process.cwd() if (cwd.includes('.git-worktree')) { - log.error('请在主目录中运行此脚本,不要在 worktree 中运行') - return false + if (process.env.ALLOW_WORKTREE_RELEASE === 'true') { + log.warn('检测到 worktree 环境,继续执行(ALLOW_WORKTREE_RELEASE=true)') + } else { + log.error('请在主目录中运行此脚本,不要在 worktree 中运行') + return false + } } // 检查未提交的变更 @@ -220,7 +233,7 @@ async function runBuild(): Promise { log.step('构建 Web 版本') exec('pnpm build:web', { - env: { SERVICE_IMPL: 'web' }, + env: { SERVICE_IMPL: 'web', VITE_DEV_MODE: 'false' }, }) // 移动到 dist-web @@ -254,7 +267,12 @@ async function runBuild(): Promise { if (existsSync(distsDir)) { rmSync(distsDir, { recursive: true }) } - exec(`plaoc bundle "${distDwebDir}" -c ./ -o "${distsDir}"`) + if (commandExists('plaoc')) { + exec(`plaoc bundle "${distDwebDir}" -c ./ -o "${distsDir}"`) + } else { + log.warn('Plaoc CLI 未安装,使用 dist-dweb 作为 dists 兜底') + cpSync(distDwebDir, distsDir, { recursive: true }) + } log.success('构建完成') } @@ -345,8 +363,8 @@ async function updateChangelog(version: string): Promise { // ==================== Git 操作 ==================== -async function commitAndTag(version: string): Promise { - log.step('提交变更并创建 Tag') +async function commitRelease(version: string): Promise { + log.step('提交变更') // 添加所有变更 exec('git add -A') @@ -354,20 +372,14 @@ async function commitAndTag(version: string): Promise { // 提交 exec(`git commit -m "release: v${version}"`) log.success(`提交: release: v${version}`) - - // 创建 tag - exec(`git tag -a v${version} -m "Release v${version}"`) - log.success(`创建 Tag: v${version}`) } async function pushAndTriggerCD(version: string): Promise { log.step('推送到 GitHub') console.log(` -${colors.yellow}推送后将触发:${colors.reset} - - GitHub Actions CD 流程 - - GitHub Pages 更新 - - GitHub Release 创建 +${colors.yellow}推送后请在 GitHub Actions 手动触发 stable 发布:${colors.reset} + - CD 会在完成后创建 Tag 并生成 Release `) const shouldPush = await confirm({ @@ -382,21 +394,18 @@ ${colors.yellow}推送后将触发:${colors.reset} return } - // 推送代码 - exec('git push origin main') + // 推送代码(受保护分支可能需要走 PR) + exec('git push origin HEAD') log.success('推送代码') - // 推送 tag(这会触发 CD) - exec(`git push origin v${version}`) - log.success(`推送 Tag v${version}`) - console.log(` ${colors.green}GitHub Actions 将自动:${colors.reset} - 构建 Web 和 DWEB 版本 - 部署到 GitHub Pages - - 创建 GitHub Release + - 创建 Tag & GitHub Release - 上传 DWEB 到正式服务器 +请在 Actions 中手动选择 stable 触发发布。 查看进度: https://github.com/BioforestChain/KeyApp/actions `) } @@ -433,8 +442,8 @@ ${colors.cyan}发布流程:${colors.reset} 2. 构建 Web 和 DWEB 版本 3. 上传 DWEB 到正式服务器 4. 更新版本号和 CHANGELOG - 5. 提交变更并创建 Tag - 6. 推送触发 GitHub Pages 更新 + 5. 提交变更 + 6. 推送并手动触发 CI 发布(CI 创建 tag/release) `) const confirmRelease = await confirm({ @@ -459,8 +468,8 @@ ${colors.cyan}发布流程:${colors.reset} // 7. 更新版本文件 updateVersionFiles(newVersion, changelog) - // 8. 提交并打 tag - await commitAndTag(newVersion) + // 8. 提交变更 + await commitRelease(newVersion) // 9. 推送 await pushAndTriggerCD(newVersion) @@ -471,8 +480,9 @@ ${colors.green}╔════════════════════ ╚════════════════════════════════════════╝${colors.reset} ${colors.blue}下一步:${colors.reset} - - 检查 GitHub Actions: https://github.com/BioforestChain/KeyApp/actions - - 查看 Release: https://github.com/BioforestChain/KeyApp/releases + - 在 GitHub Actions 手动触发 stable 发布 + - 查看进度: https://github.com/BioforestChain/KeyApp/actions + - 发布完成后查看 Release: https://github.com/BioforestChain/KeyApp/releases - 访问 GitHub Pages: https://bioforestchain.github.io/KeyApp/ `) } diff --git a/src/main.tsx b/src/main.tsx index 50b3a7fe4..c38320e63 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,7 +6,13 @@ import { startServiceMain } from './service-main' import { startFrontendMain } from './frontend-main' // 禁用右键菜单(移动端 App 体验) -document.addEventListener('contextmenu', (e) => e.preventDefault()) +document.addEventListener('contextmenu', (event) => { + const target = event.target as HTMLElement | null + if (target?.closest?.('[data-allow-context-menu="true"]')) { + return + } + event.preventDefault() +}) const rootElement = document.getElementById('root') if (!rootElement) throw new Error('Root element not found') diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index 1b1c179c9..3e31f5662 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -282,11 +282,14 @@ export function SettingsPage() { {/* 关于 */} - + } label={t('settings:items.aboutApp')} - value="v1.0.0" + value={`v${__APP_VERSION__}`} onClick={() => { // TODO: 关于页面 }} diff --git a/src/pages/settings/settings-section.tsx b/src/pages/settings/settings-section.tsx index 183ba84e8..44344af18 100644 --- a/src/pages/settings/settings-section.tsx +++ b/src/pages/settings/settings-section.tsx @@ -8,15 +8,21 @@ export interface SettingsSectionProps { children: ReactNode /** 额外 className */ className?: string + /** 允许系统右键菜单(DWEB 开发模式) */ + allowContextMenu?: boolean } export function SettingsSection({ title, children, className, + allowContextMenu, }: SettingsSectionProps) { return ( -
+
{title && (

{title} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index aa41feb08..6829f1e3f 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -8,6 +8,9 @@ declare global { /** Dev 模式标识 - 通过 vite.config.ts define 配置 */ const __DEV_MODE__: boolean + /** App 版本号 - 通过 vite.config.ts define 配置 */ + const __APP_VERSION__: string + /** API Keys 映射 - 通过 vite.config.ts / Storybook viteFinal define 配置 */ const __API_KEYS__: Record diff --git a/vite.config.ts b/vite.config.ts index d0e69b26f..69e415516 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite'; import commonjs from 'vite-plugin-commonjs'; import mkcert from 'vite-plugin-mkcert'; import { networkInterfaces } from 'node:os'; +import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { mockDevToolsPlugin } from './scripts/vite-plugin-mock-devtools'; import { miniappsPlugin } from './scripts/vite-plugin-miniapps'; @@ -51,6 +52,15 @@ function getPreferredLanIPv4(): string | undefined { return ips[0]; } +function getPackageVersion(): string { + try { + const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')) as { version?: string } + return pkg.version ?? '0.0.0' + } catch { + return '0.0.0' + } +} + export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); @@ -74,6 +84,12 @@ export default defineConfig(({ mode }) => { const tronGridApiKey = env.TRONGRID_API_KEY ?? process.env.TRONGRID_API_KEY ?? ''; const etherscanApiKey = env.ETHERSCAN_API_KEY ?? process.env.ETHERSCAN_API_KEY ?? ''; const moralisApiKey = env.MORALIS_API_KEY ?? process.env.MORALIS_API_KEY ?? ''; + const isDevBuild = (env.VITE_DEV_MODE ?? process.env.VITE_DEV_MODE) === 'true' + + const buildTime = new Date() + const pad = (value: number) => value.toString().padStart(2, '0') + const buildSuffix = `-${pad(buildTime.getUTCMonth() + 1)}${pad(buildTime.getUTCDate())}${pad(buildTime.getUTCHours())}` + const appVersion = `${getPackageVersion()}${isDevBuild ? buildSuffix : ''}` return { base: BASE_URL, @@ -159,6 +175,8 @@ export default defineConfig(({ mode }) => { __MOCK_MODE__: JSON.stringify(SERVICE_IMPL === 'mock'), // Dev 模式标识(用于显示开发版水印) __DEV_MODE__: JSON.stringify((env.VITE_DEV_MODE ?? process.env.VITE_DEV_MODE) === 'true'), + // App 版本号(stable=package.json,dev=追加 -MMDDHH,UTC) + __APP_VERSION__: JSON.stringify(appVersion), // API Keys 对象(用于动态读取环境变量) __API_KEYS__: JSON.stringify({ TRONGRID_API_KEY: tronGridApiKey,