Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
397 changes: 396 additions & 1 deletion .github/workflows/cd.yml

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 ??= {}
Expand All @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 更新日志

## [0.2.0] - 2026-01-26

DWEB 安装资源与版本发布流程优化

<!-- last-commit: 5c166987badd06a0a81f4fd5a48ef6763d586be0 -->

1 change: 1 addition & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

295 changes: 288 additions & 7 deletions docs/.vitepress/plugins/webapp-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<boolean> {
log.info(`尝试下载: ${tag}/${assetName}`)

Expand Down Expand Up @@ -108,6 +128,250 @@ async function buildLocal(): Promise<boolean> {
}
}

async function buildLocalDweb(): Promise<boolean> {
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<void> {
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<void> {
const tag = channel === 'stable' ? 'latest' : 'beta'
const assetName = channel === 'stable' ? 'bfmpay-web.zip' : 'bfmpay-web-beta.zip'
Expand Down Expand Up @@ -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 目录准备完成')
}

Expand All @@ -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
Expand All @@ -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<string, string> = {
js: 'application/javascript',
css: 'text/css',
Expand Down
Loading