From 905b28f70440345224ebadeb2dc876598030d720 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 27 Jan 2026 21:34:48 +0800 Subject: [PATCH 1/2] feat: complete implementation --- .../changes/add-dweb-auto-update/design.md | 31 ++ .../changes/add-dweb-auto-update/proposal.md | 14 + .../specs/dweb-update/spec.md | 46 ++ .../specs/release-tooling/spec.md | 50 ++ .../changes/add-dweb-auto-update/tasks.md | 10 + package.json | 1 + scripts/env-registry.json | 94 ++++ scripts/release.ts | 248 ++++++---- scripts/set-env.ts | 467 ++++++++++++++++++ scripts/set-secret.ts | 362 +------------- src/components/common/dweb-update-dialog.tsx | 60 +++ src/i18n/locales/ar/settings.json | 14 + src/i18n/locales/en/settings.json | 14 + src/i18n/locales/zh-CN/settings.json | 14 + src/i18n/locales/zh-TW/settings.json | 14 + src/lib/__tests__/dweb-update.test.ts | 51 ++ src/lib/dweb-update.ts | 155 ++++++ src/pages/settings/index.tsx | 46 +- src/providers/AppInitializer.tsx | 17 +- src/stores/dweb-update.ts | 72 +++ src/vite-env.d.ts | 3 + vite.config.ts | 6 + 22 files changed, 1325 insertions(+), 464 deletions(-) create mode 100644 openspec/changes/add-dweb-auto-update/design.md create mode 100644 openspec/changes/add-dweb-auto-update/proposal.md create mode 100644 openspec/changes/add-dweb-auto-update/specs/dweb-update/spec.md create mode 100644 openspec/changes/add-dweb-auto-update/specs/release-tooling/spec.md create mode 100644 openspec/changes/add-dweb-auto-update/tasks.md create mode 100644 scripts/env-registry.json create mode 100644 scripts/set-env.ts create mode 100644 src/components/common/dweb-update-dialog.tsx create mode 100644 src/lib/__tests__/dweb-update.test.ts create mode 100644 src/lib/dweb-update.ts create mode 100644 src/stores/dweb-update.ts diff --git a/openspec/changes/add-dweb-auto-update/design.md b/openspec/changes/add-dweb-auto-update/design.md new file mode 100644 index 000000000..356586138 --- /dev/null +++ b/openspec/changes/add-dweb-auto-update/design.md @@ -0,0 +1,31 @@ +## Context +- DWEB 需要独立于 Web 的自动升级能力,且必须遵循当前 build 产物路径(stable: /dweb/metadata.json, dev: /dweb-dev/metadata.json)。 +- 现有 set-secret 仅管理 secrets,缺少统一的 env/var 描述与本地+GitHub 同步。 +- release 流程必须严格基于 origin/main,避免本地工作区干扰,并在分支保护下可用。 + +## Goals / Non-Goals +- Goals: + - DWEB 自动升级:启动延迟检查 + 关于页手动检查 + 升级弹窗 + - 统一配置管理:本地 .env.local 与 GitHub Variables/Secrets + - Release 仅基于 origin/main,必要时 admin 直提 +- Non-Goals: + - Web 端升级提示(仅 DWEB) + - 改动现有更新路径与部署策略 + +## Decisions +- Decision: 通过 Vite define 注入 `__KEYAPP_SITE_ORIGIN__`(来源于 env.SITE_ORIGIN 或构建环境 vars),避免直接暴露 VITE_ 前缀。 +- Decision: 更新检测仅在 DWEB 构建中启用(SERVICE_IMPL=dweb),使用 `__DEV_MODE__` 区分 dev/stable 访问路径。 +- Decision: metadata.json 解析版本与 changelog(manifest 中 `change_log` 优先),作为弹窗内容来源。 +- Decision: set-env 使用 JSON 配置描述字段(类型/显示/本地+GitHub),GitHub 同步分别走 `gh variable` 与 `gh secret`。 +- Decision: release 脚本在临时目录 clone origin/main,校验本地与远端一致;如保护分支阻止 push,则允许 `--admin` 直提策略。 + +## Risks / Trade-offs +- Risk: metadata.json 结构变动导致解析失败 → 失败时仅提示“无法检查更新”,不阻断启动。 +- Risk: admin 直提存在权限依赖 → 无权限时明确失败并提示使用管理员账户执行。 + +## Migration Plan +- 引入 set-env 后,set-secret 保持提示但不再新增字段。 +- release 脚本加入 strict 校验;CI/手动流程保持不变。 + +## Open Questions +- 是否需要把 changelog 展示为可展开的完整内容(默认展示 3–5 行)? diff --git a/openspec/changes/add-dweb-auto-update/proposal.md b/openspec/changes/add-dweb-auto-update/proposal.md new file mode 100644 index 000000000..15bf9d0e5 --- /dev/null +++ b/openspec/changes/add-dweb-auto-update/proposal.md @@ -0,0 +1,14 @@ +# Change: DWEB 自动升级 + 配置工具 + Release 守卫 + +## Why +DWEB 需要自动检测更新并提供一键升级入口,同时需要统一本地/GitHub 环境配置与发布流程,降低发布心智负担与错误率。 + +## What Changes +- 新增 DWEB 自动升级:启动延迟检查 + 关于页手动检查 + 升级弹窗 + dweb://install 链接 +- 注入 KeyApp 官网 Origin(可配置),根据稳定/测试模式拼接 metadata URL +- 新增 set-env 工具(替代 set-secret):基于配置文件管理本地与 GitHub 的变量/Secrets +- 升级 release 工具:仅允许基于 origin/main 进行发布(必要时使用 admin 方式完成提交) + +## Impact +- Affected specs: dweb-update, release-tooling +- Affected code: src/services/* (update service), src/pages/settings/* (About UI), vite.config.ts (define 注入), scripts/set-env.ts, scripts/release.ts diff --git a/openspec/changes/add-dweb-auto-update/specs/dweb-update/spec.md b/openspec/changes/add-dweb-auto-update/specs/dweb-update/spec.md new file mode 100644 index 000000000..868cd4789 --- /dev/null +++ b/openspec/changes/add-dweb-auto-update/specs/dweb-update/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements +### Requirement: DWEB Update Source Resolution +The DWEB build SHALL resolve the update metadata URL using the configured site origin and build channel. + +#### Scenario: Stable channel metadata URL +- **GIVEN** the app runs in DWEB and `__DEV_MODE__` is false +- **WHEN** resolving the update metadata URL +- **THEN** the path MUST be `/dweb/metadata.json` under the configured site origin + +#### Scenario: Dev channel metadata URL +- **GIVEN** the app runs in DWEB and `__DEV_MODE__` is true +- **WHEN** resolving the update metadata URL +- **THEN** the path MUST be `/dweb-dev/metadata.json` under the configured site origin + +### Requirement: Automatic Update Check +The DWEB build SHALL perform a delayed update check after startup without blocking initial render. + +#### Scenario: Deferred startup check +- **GIVEN** the application has started in DWEB mode +- **WHEN** the delayed timer elapses +- **THEN** the update check runs without blocking initial rendering + +### Requirement: Manual Update Check +The user SHALL be able to trigger a manual update check from the Settings > About view. + +#### Scenario: User-initiated check +- **GIVEN** the user opens Settings > About +- **WHEN** the user triggers the update check action +- **THEN** the app fetches the update metadata and reports the result + +### Requirement: Update Prompt +When a higher version is available, the DWEB build SHALL present an upgrade prompt with a DWEB install link. + +#### Scenario: Update available +- **GIVEN** the remote metadata version is higher than the local version +- **WHEN** the update check completes +- **THEN** an upgrade dialog is shown +- **AND** the dialog includes a `dweb://install?url=` link pointing to the metadata URL + +### Requirement: Changelog Display +The upgrade dialog SHALL display changelog content when available from metadata/manifest. + +#### Scenario: Changelog present +- **GIVEN** update metadata contains a changelog field +- **WHEN** the upgrade dialog is shown +- **THEN** the changelog is displayed in the dialog diff --git a/openspec/changes/add-dweb-auto-update/specs/release-tooling/spec.md b/openspec/changes/add-dweb-auto-update/specs/release-tooling/spec.md new file mode 100644 index 000000000..906f07379 --- /dev/null +++ b/openspec/changes/add-dweb-auto-update/specs/release-tooling/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements +### Requirement: Environment Configuration Registry +The project SHALL define a machine-readable environment configuration registry describing each env/secret field. + +#### Scenario: Registry includes metadata +- **GIVEN** the registry is loaded +- **WHEN** iterating over fields +- **THEN** each field provides key, type (env/secret), and display policy metadata + +### Requirement: set-env Local and GitHub Sync +The set-env tool SHALL support syncing values to local `.env.local`, GitHub Variables, and GitHub Secrets based on the registry. + +#### Scenario: Local-only update +- **GIVEN** a field is marked for local sync +- **WHEN** the user saves +- **THEN** `.env.local` is updated with the provided value + +#### Scenario: GitHub variable update +- **GIVEN** a field is marked as GitHub variable +- **WHEN** the user syncs to GitHub +- **THEN** `gh variable set` is used to update the repository variable + +#### Scenario: GitHub secret update +- **GIVEN** a field is marked as GitHub secret +- **WHEN** the user syncs to GitHub +- **THEN** `gh secret set` is used to update the repository secret + +### Requirement: Release Main-Only Guard +The release tool SHALL only run when the target source equals `origin/main`. + +#### Scenario: Non-main invocation +- **GIVEN** the release tool is invoked outside `origin/main` +- **WHEN** validation runs +- **THEN** the tool aborts with a clear error + +### Requirement: Origin/Main Parity +The release tool SHALL ensure the release is created from the latest `origin/main` without relying on the current working tree state. + +#### Scenario: Local working tree differs +- **GIVEN** local changes exist +- **WHEN** release is initiated +- **THEN** the release process uses a clean `origin/main` source and does not modify the local tree + +### Requirement: Protected Branch Admin Path +When branch protection blocks direct updates, the release tool SHALL provide an admin path to complete the release. + +#### Scenario: Protection blocks direct push +- **GIVEN** branch protection rejects a direct push +- **WHEN** the operator enables admin mode +- **THEN** the release completes using administrator privileges diff --git a/openspec/changes/add-dweb-auto-update/tasks.md b/openspec/changes/add-dweb-auto-update/tasks.md new file mode 100644 index 000000000..62f3bab5b --- /dev/null +++ b/openspec/changes/add-dweb-auto-update/tasks.md @@ -0,0 +1,10 @@ +## 1. Implementation +- [x] 1.1 增加环境配置描述文件(字段/类型/展示策略/本地+GitHub) +- [x] 1.2 实现 scripts/set-env.ts(替代 set-secret)并保留兼容提示 +- [x] 1.3 release 脚本改造:仅允许 origin/main 一致性发布;支持 admin 直接提交 +- [x] 1.4 注入 KeyApp 官网 Origin 到运行时常量(非 VITE_ 直接暴露) +- [x] 1.5 新增 DWEB 更新服务(延迟自动检查 + 手动检查) +- [x] 1.6 实现升级弹窗(版本差异 + changelog + 安装链接) +- [x] 1.7 设置页“关于”增加手动检查入口 +- [x] 1.8 增加必要的单元测试/最小契约测试 +- [x] 1.9 更新文档/白皮书(如需要) diff --git a/package.json b/package.json index 8435cb8bc..3a980ed62 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "docs:preview": "vitepress preview docs", "gen:stable": "bun scripts/gen-stable.ts", "release": "bun scripts/release.ts", + "set-env": "bun scripts/set-env.ts", "set-secret": "bun scripts/set-secret.ts", "gen:logo": "bun scripts/gen-logo.ts", "i18n:extract": "bun scripts/i18n-extract.ts", diff --git a/scripts/env-registry.json b/scripts/env-registry.json new file mode 100644 index 000000000..e90b9b2be --- /dev/null +++ b/scripts/env-registry.json @@ -0,0 +1,94 @@ +{ + "schemaVersion": 1, + "fields": [ + { + "key": "SITE_ORIGIN", + "label": "KeyApp 站点 Origin", + "type": "url", + "required": true, + "targets": { "local": true, "github": "variable" }, + "mask": "tail", + "description": "DWEB 自动更新使用的官网域名" + }, + { + "key": "DWEB_SFTP_URL", + "label": "DWEB SFTP 地址", + "type": "url", + "required": false, + "targets": { "local": true, "github": "variable" }, + "mask": "tail" + }, + { + "key": "DWEB_SFTP_USER", + "label": "DWEB SFTP 用户名 (正式)", + "type": "string", + "required": true, + "targets": { "local": true, "github": "secret" }, + "mask": "tail" + }, + { + "key": "DWEB_SFTP_PASS", + "label": "DWEB SFTP 密码 (正式)", + "type": "password", + "required": true, + "targets": { "local": true, "github": "secret" }, + "mask": "hidden" + }, + { + "key": "DWEB_SFTP_USER_DEV", + "label": "DWEB SFTP 用户名 (开发)", + "type": "string", + "required": false, + "targets": { "local": true, "github": "secret" }, + "mask": "tail" + }, + { + "key": "DWEB_SFTP_PASS_DEV", + "label": "DWEB SFTP 密码 (开发)", + "type": "password", + "required": false, + "targets": { "local": true, "github": "secret" }, + "mask": "hidden" + }, + { + "key": "E2E_TEST_MNEMONIC", + "label": "E2E 测试助记词", + "type": "string", + "required": false, + "targets": { "local": true, "github": "secret" }, + "mask": "hidden" + }, + { + "key": "E2E_TEST_SECOND_SECRET", + "label": "E2E 安全密码", + "type": "password", + "required": false, + "targets": { "local": true, "github": "secret" }, + "mask": "hidden" + }, + { + "key": "TRONGRID_API_KEY", + "label": "TronGrid API Key", + "type": "password", + "required": false, + "targets": { "local": true, "github": "secret" }, + "mask": "hidden" + }, + { + "key": "ETHERSCAN_API_KEY", + "label": "Etherscan API Key", + "type": "password", + "required": false, + "targets": { "local": true, "github": "secret" }, + "mask": "hidden" + }, + { + "key": "MORALIS_API_KEY", + "label": "Moralis API Key", + "type": "password", + "required": false, + "targets": { "local": true, "github": "secret" }, + "mask": "hidden" + } + ] +} diff --git a/scripts/release.ts b/scripts/release.ts index f62304f6e..4e7329b51 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -2,23 +2,28 @@ /** * BFM Pay 正式版发布脚本 * - * 交互式脚本,完整发布流程: - * 1. 检查工作区状态 - * 2. 选择版本号(小版本/中版本/大版本/当前/自定义) - * 3. 运行类型检查和测试 - * 4. 构建 Web 和 DWEB 版本 - * 5. 上传 DWEB 到正式服务器 - * 6. 更新 package.json 和 manifest.json - * 7. 更新 CHANGELOG.md - * 8. 提交变更 - * 9. 推送并手动触发 CI 发布(CI 创建 tag/release) + * 交互式脚本,完整发布流程(仅基于 origin/main): + * 1. 创建 origin/main 的临时工作树 + * 2. 检查工作区状态(必须干净且与 origin/main 一致) + * 3. 选择版本号(小版本/中版本/大版本/当前/自定义) + * 4. 运行类型检查和测试 + * 5. 构建 Web 和 DWEB 版本 + * 6. 上传 DWEB 到正式服务器 + * 7. 更新 package.json 和 manifest.json + * 8. 更新 CHANGELOG.md + * 9. 提交变更 + * 10. 推送并手动触发 CI 发布(CI 创建 tag/release) + * + * 可选参数: + * --admin 当 main 受保护且无法直推时,用 PR + admin 合并兜底 * * Usage: * pnpm release */ import { execSync } from 'node:child_process' -import { existsSync, readFileSync, writeFileSync, cpSync, rmSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync, cpSync, rmSync, mkdtempSync, mkdirSync, symlinkSync, lstatSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { confirm, select, input } from '@inquirer/prompts' import semver from 'semver' @@ -26,9 +31,8 @@ import semver from 'semver' // ==================== 配置 ==================== const ROOT = resolve(import.meta.dirname, '..') -const PACKAGE_JSON_PATH = join(ROOT, 'package.json') -const MANIFEST_JSON_PATH = join(ROOT, 'manifest.json') -const CHANGELOG_PATH = join(ROOT, 'CHANGELOG.md') +let WORKDIR = ROOT +const workPath = (...parts: string[]) => join(WORKDIR, ...parts) // 颜色输出 const colors = { @@ -50,12 +54,17 @@ const log = { step: (msg: string) => console.log(`\n${colors.cyan}▸${colors.reset} ${colors.cyan}${msg}${colors.reset}`), } +const ADMIN_MODE = process.argv.includes('--admin') + // ==================== 工具函数 ==================== -function exec(cmd: string, options?: { silent?: boolean; env?: Record }): string { +function exec( + cmd: string, + options?: { silent?: boolean; env?: Record; cwd?: string }, +): string { try { const result = execSync(cmd, { - cwd: ROOT, + cwd: options?.cwd ?? WORKDIR, encoding: 'utf-8', stdio: options?.silent ? 'pipe' : 'inherit', env: { ...process.env, ...options?.env }, @@ -70,7 +79,7 @@ function exec(cmd: string, options?: { silent?: boolean; env?: Record void } { + const parent = join(ROOT, '.git-worktree') + if (!existsSync(parent)) { + mkdirSync(parent, { recursive: true }) + } + execSync('git fetch origin main --tags', { cwd: ROOT, stdio: 'inherit' }) + const workdir = mkdtempSync(join(parent, 'release-')) + execSync(`git worktree add --detach "${workdir}" origin/main`, { cwd: ROOT, stdio: 'inherit' }) + ensureNodeModules(workdir) + const cleanup = () => { + try { + execSync(`git worktree remove --force "${workdir}"`, { cwd: ROOT, stdio: 'inherit' }) + } catch { + rmSync(workdir, { recursive: true, force: true }) + } + } + return { path: workdir, cleanup } +} + // ==================== 检查函数 ==================== async function checkWorkspace(): Promise { log.step('检查工作区状态') - // 检查是否在 worktree 中 - const cwd = process.cwd() - if (cwd.includes('.git-worktree')) { - if (process.env.ALLOW_WORKTREE_RELEASE === 'true') { - log.warn('检测到 worktree 环境,继续执行(ALLOW_WORKTREE_RELEASE=true)') - } else { - log.error('请在主目录中运行此脚本,不要在 worktree 中运行') - return false - } + const head = execOutput('git rev-parse HEAD') + const originMain = execOutput('git rev-parse origin/main') + if (head !== originMain) { + log.error('当前工作树不是 origin/main 最新提交,已终止') + return false } // 检查未提交的变更 @@ -120,33 +154,13 @@ async function checkWorkspace(): Promise { if (status) { log.warn('检测到未提交的变更:') console.log(status) - - const shouldContinue = await confirm({ - message: '是否继续?(未提交的变更将被包含在发布中)', - default: false, - }) - - if (!shouldContinue) { - return false - } + log.error('发布必须基于干净的 origin/main') + return false } else { log.success('工作区干净') } - // 检查当前分支 - const branch = execOutput('git branch --show-current') - if (branch !== 'main') { - log.warn(`当前分支: ${branch}(建议在 main 分支发布)`) - const shouldContinue = await confirm({ - message: '是否继续?', - default: false, - }) - if (!shouldContinue) { - return false - } - } else { - log.success(`当前分支: ${branch}`) - } + log.success('确认 origin/main 一致性') return true } @@ -168,7 +182,7 @@ interface ManifestJson { async function selectVersion(): Promise { log.step('选择版本号') - const pkg = readJson(PACKAGE_JSON_PATH) + const pkg = readJson(workPath('package.json')) const currentVersion = pkg.version console.log(`\n当前版本: ${colors.bold}${currentVersion}${colors.reset}\n`) @@ -246,8 +260,8 @@ async function runBuild(): Promise { }) // 移动到 dist-web - const distDir = join(ROOT, 'dist') - const distWebDir = join(ROOT, 'dist-web') + const distDir = workPath('dist') + const distWebDir = workPath('dist-web') if (existsSync(distWebDir)) { rmSync(distWebDir, { recursive: true }) } @@ -262,7 +276,7 @@ async function runBuild(): Promise { }) // 移动到 dist-dweb - const distDwebDir = join(ROOT, 'dist-dweb') + const distDwebDir = workPath('dist-dweb') if (existsSync(distDwebDir)) { rmSync(distDwebDir, { recursive: true }) } @@ -272,7 +286,7 @@ async function runBuild(): Promise { } log.step('运行 Plaoc 打包') - const distsDir = join(ROOT, 'dists') + const distsDir = workPath('dists') if (existsSync(distsDir)) { rmSync(distsDir, { recursive: true }) } @@ -325,18 +339,18 @@ function updateVersionFiles(version: string, changelog: string): void { log.step('更新版本文件') // 更新 package.json - const pkg = readJson(PACKAGE_JSON_PATH) + const pkg = readJson(workPath('package.json')) pkg.version = version pkg.lastChangelogCommit = execOutput('git rev-parse HEAD') - writeJson(PACKAGE_JSON_PATH, pkg) + writeJson(workPath('package.json'), pkg) log.success('更新 package.json') // 更新 manifest.json - if (existsSync(MANIFEST_JSON_PATH)) { - const manifest = readJson(MANIFEST_JSON_PATH) + if (existsSync(workPath('manifest.json'))) { + const manifest = readJson(workPath('manifest.json')) manifest.version = version manifest.change_log = changelog - writeJson(MANIFEST_JSON_PATH, manifest) + writeJson(workPath('manifest.json'), manifest) log.success('更新 manifest.json') } } @@ -358,14 +372,14 @@ async function updateChangelog(version: string): Promise { // 读取现有 CHANGELOG 或创建新的 let existingContent = '' - if (existsSync(CHANGELOG_PATH)) { - existingContent = readFileSync(CHANGELOG_PATH, 'utf-8') + if (existsSync(workPath('CHANGELOG.md'))) { + existingContent = readFileSync(workPath('CHANGELOG.md'), 'utf-8') existingContent = existingContent.replace(/^# 更新日志\n+/, '') existingContent = existingContent.replace(/^# Changelog\n+/, '') } const newContent = `# 更新日志\n\n${content}${existingContent}` - writeFileSync(CHANGELOG_PATH, newContent) + writeFileSync(workPath('CHANGELOG.md'), newContent) log.success('更新 CHANGELOG.md') return summary @@ -404,9 +418,33 @@ ${colors.yellow}推送后请在 GitHub Actions 手动触发 stable 发布:${colo return } - // 推送代码(受保护分支可能需要走 PR) - exec('git push origin HEAD') - log.success('推送代码') + try { + exec('git push origin HEAD:refs/heads/main') + log.success('推送到 origin/main') + } catch (error) { + if (!ADMIN_MODE) { + throw error + } + log.warn('直接推送 main 失败,尝试使用 --admin 走 PR 合并') + try { + execSync('gh --version', { stdio: 'ignore' }) + } catch { + throw new Error('未检测到 gh CLI,无法使用 --admin') + } + const branch = `release/v${version}` + exec(`git checkout -b ${branch}`) + exec(`git push origin ${branch}`) + exec( + `gh pr create --base main --head ${branch} --title \"release: v${version}\" --body \"auto release\"`, + { cwd: WORKDIR }, + ) + const prNumber = exec(`gh pr view --head ${branch} --json number --jq .number`, { + cwd: WORKDIR, + silent: true, + }) + exec(`gh pr merge ${prNumber} --admin --squash --delete-branch`, { cwd: WORKDIR }) + log.success('已使用 --admin 合并到 main') + } console.log(` ${colors.green}GitHub Actions 将自动:${colors.reset} @@ -429,24 +467,27 @@ ${colors.magenta}╔════════════════════ ╚════════════════════════════════════════╝${colors.reset} `) - // 1. 检查工作区 - const canContinue = await checkWorkspace() - if (!canContinue) { - log.info('发布已取消') - process.exit(0) - } - - // 2. 选择版本号 - let newVersion: string + const { path, cleanup } = prepareReleaseWorktree() + WORKDIR = path try { - newVersion = await selectVersion() - } catch (error) { - log.info('发布已取消') - process.exit(0) - } + // 1. 检查工作区 + const canContinue = await checkWorkspace() + if (!canContinue) { + log.info('发布已取消') + return + } - // 3. 确认发布流程 - console.log(` + // 2. 选择版本号 + let newVersion: string + try { + newVersion = await selectVersion() + } catch (error) { + log.info('发布已取消') + return + } + + // 3. 确认发布流程 + console.log(` ${colors.cyan}发布流程:${colors.reset} 1. 运行类型检查和测试 2. 构建 Web 和 DWEB 版本 @@ -456,35 +497,35 @@ ${colors.cyan}发布流程:${colors.reset} 6. 推送并手动触发 CI 发布(CI 创建 tag/release) `) - const confirmRelease = await confirm({ - message: '确认开始发布流程?', - default: true, - }) + const confirmRelease = await confirm({ + message: '确认开始发布流程?', + default: true, + }) - if (!confirmRelease) { - log.info('发布已取消') - process.exit(0) - } + if (!confirmRelease) { + log.info('发布已取消') + return + } - // 4. 运行构建 - await runBuild() + // 4. 运行构建 + await runBuild() - // 5. 上传 DWEB - await uploadDweb() + // 5. 上传 DWEB + await uploadDweb() - // 6. 更新 CHANGELOG - const changelog = await updateChangelog(newVersion) + // 6. 更新 CHANGELOG + const changelog = await updateChangelog(newVersion) - // 7. 更新版本文件 - updateVersionFiles(newVersion, changelog) + // 7. 更新版本文件 + updateVersionFiles(newVersion, changelog) - // 8. 提交变更 - await commitRelease(newVersion) + // 8. 提交变更 + await commitRelease(newVersion) - // 9. 推送 - await pushAndTriggerCD(newVersion) + // 9. 推送 + await pushAndTriggerCD(newVersion) - console.log(` + console.log(` ${colors.green}╔════════════════════════════════════════╗ ║ 发布完成! v${newVersion.padEnd(20)}║ ╚════════════════════════════════════════╝${colors.reset} @@ -495,6 +536,9 @@ ${colors.blue}下一步:${colors.reset} - 发布完成后查看 Release: https://github.com/BioforestChain/KeyApp/releases - 访问 GitHub Pages: https://bioforestchain.github.io/KeyApp/ `) + } finally { + cleanup() + } } main().catch((error) => { diff --git a/scripts/set-env.ts b/scripts/set-env.ts new file mode 100644 index 000000000..2a837808b --- /dev/null +++ b/scripts/set-env.ts @@ -0,0 +1,467 @@ +#!/usr/bin/env bun + +import { execSync } from 'node:child_process' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const ROOT = process.cwd() +const ENV_LOCAL_PATH = join(ROOT, '.env.local') +const REGISTRY_PATH = join(ROOT, 'scripts', 'env-registry.json') + +const ANSI = { + clear: '\x1b[2J', + home: '\x1b[H', + hideCursor: '\x1b[?25l', + showCursor: '\x1b[?25h', + clearLine: '\x1b[2K', + moveTo: (row: number, col: number) => `\x1b[${row};${col}H`, + moveUp: (n: number) => `\x1b[${n}A`, + bold: '\x1b[1m', + dim: '\x1b[2m', + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + bgBlue: '\x1b[44m', + white: '\x1b[37m', +} + +type MaskMode = 'none' | 'tail' | 'head' | 'head-tail' | 'hidden' + +type FieldType = 'string' | 'password' | 'url' | 'number' + +interface EnvField { + key: string + label: string + type?: FieldType + required?: boolean + targets?: { + local?: boolean + github?: 'secret' | 'variable' + } + mask?: MaskMode + description?: string +} + +interface EnvRegistry { + schemaVersion: number + fields: EnvField[] +} + +interface State { + cursor: number + values: Map + editing: boolean + editBuffer: string + editCursor: number + message: string + messageType: 'info' | 'error' | 'success' + localEnv: Map + ghSecrets: Set + ghVariables: Set + hasGhCli: boolean + target: 'local' | 'github' | 'both' + saved: boolean +} + +function validateMnemonic(v: string): string | null { + if (!v) return null + const words = v.split(/\s+/).filter(Boolean) + if (words.length !== 12 && words.length !== 24) { + return `需要 12 或 24 个词,当前 ${words.length} 个` + } + return null +} + +const CUSTOM_VALIDATORS: Record string | null> = { + E2E_TEST_MNEMONIC: validateMnemonic, +} + +function readRegistry(): EnvRegistry { + if (!existsSync(REGISTRY_PATH)) { + throw new Error(`缺少配置文件: ${REGISTRY_PATH}`) + } + const raw = JSON.parse(readFileSync(REGISTRY_PATH, 'utf-8')) as unknown + if (!raw || typeof raw !== 'object') throw new Error('配置文件格式错误') + const registry = raw as EnvRegistry + if (!Array.isArray(registry.fields)) throw new Error('配置字段缺失') + return registry +} + +const REGISTRY = readRegistry() +const FIELDS = REGISTRY.fields + +function getTermSize(): { cols: number; rows: number } { + return { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 } +} + +function checkGhCli(): boolean { + try { + execSync('gh auth status', { stdio: 'pipe' }) + return true + } catch { + return false + } +} + +function getGitHubSecrets(): Set { + const secrets = new Set() + try { + const output = execSync('gh secret list', { encoding: 'utf-8', stdio: 'pipe' }) + for (const line of output.split('\n')) { + const name = line.split('\t')[0]?.trim() + if (name) secrets.add(name) + } + } catch {} + return secrets +} + +function getGitHubVariables(): Set { + const vars = new Set() + try { + const output = execSync('gh variable list', { encoding: 'utf-8', stdio: 'pipe' }) + for (const line of output.split('\n')) { + const name = line.split('\t')[0]?.trim() + if (name) vars.add(name) + } + } catch {} + return vars +} + +function getLocalEnv(): Map { + const env = new Map() + if (!existsSync(ENV_LOCAL_PATH)) return env + const content = readFileSync(ENV_LOCAL_PATH, 'utf-8') + for (const line of content.split('\n')) { + const match = line.match(/^([A-Z_][A-Z0-9_]*)="(.*)"$/) + if (match) env.set(match[1], match[2]) + } + return env +} + +function saveLocalEnv(values: Map, fields: EnvField[]): void { + let content = existsSync(ENV_LOCAL_PATH) ? readFileSync(ENV_LOCAL_PATH, 'utf-8') : '' + for (const field of fields) { + if (!field.targets?.local) continue + const value = values.get(field.key) + if (!value) continue + const regex = new RegExp(`^${field.key}=".*"$`, 'm') + const newLine = `${field.key}="${value}"` + if (regex.test(content)) { + content = content.replace(regex, newLine) + } else { + content = content.trimEnd() + '\n' + newLine + '\n' + } + } + writeFileSync(ENV_LOCAL_PATH, content) +} + +function saveGitHub(values: Map, fields: EnvField[]): number { + let count = 0 + for (const field of fields) { + const target = field.targets?.github + if (!target) continue + const value = values.get(field.key) + if (!value) continue + try { + const cmd = target === 'secret' ? `gh secret set ${field.key}` : `gh variable set ${field.key}` + execSync(cmd, { input: value, stdio: ['pipe', 'pipe', 'pipe'] }) + count++ + } catch {} + } + return count +} + +function maskValue(value: string, field: EnvField): string { + if (!value) return '' + const mask = field.mask ?? (field.type === 'password' ? 'hidden' : 'tail') + if (mask === 'none') return value + if (mask === 'hidden') return '•'.repeat(Math.min(value.length, 16)) + if (value.length <= 6) return value + if (mask === 'tail') return `${'*'.repeat(Math.min(value.length - 4, 12))}${value.slice(-4)}` + if (mask === 'head') return `${value.slice(0, 4)}${'*'.repeat(Math.min(value.length - 4, 12))}` + if (mask === 'head-tail') { + const head = value.slice(0, 3) + const tail = value.slice(-3) + return `${head}${'*'.repeat(Math.min(value.length - 6, 12))}${tail}` + } + return value +} + +function validateFieldValue(field: EnvField, value: string): string | null { + if (!value) return null + if (field.type === 'url') { + try { + new URL(value) + } catch { + return 'URL 格式不正确' + } + } + if (field.type === 'number') { + if (!Number.isFinite(Number(value))) return '需要数字格式' + } + const custom = CUSTOM_VALIDATORS[field.key] + return custom ? custom(value) : null +} + +function render(state: State): void { + const { cols } = getTermSize() + const width = Math.min(cols - 4, 80) + let out = ANSI.home + ANSI.hideCursor + + const title = ' KeyApp 环境配置 ' + const padTitle = Math.floor((width - Bun.stringWidth(title)) / 2) + out += `${ANSI.cyan}┌${'─'.repeat(width)}┐${ANSI.reset}\n` + out += `${ANSI.cyan}│${' '.repeat(padTitle)}${ANSI.bold}${title}${ANSI.reset}${ANSI.cyan}${' '.repeat(width - padTitle - Bun.stringWidth(title))}│${ANSI.reset}\n` + out += `${ANSI.cyan}├${'─'.repeat(width)}┤${ANSI.reset}\n` + + const targetLabel = state.target === 'both' ? 'Local + GitHub' : state.target === 'local' ? 'Local only' : 'GitHub only' + out += `${ANSI.cyan}│${ANSI.reset} Target: ${ANSI.yellow}${targetLabel}${ANSI.reset}${' '.repeat(width - 10 - targetLabel.length)}${ANSI.cyan}│${ANSI.reset}\n` + out += `${ANSI.cyan}├${'─'.repeat(width)}┤${ANSI.reset}\n` + + for (let i = 0; i < FIELDS.length; i++) { + const field = FIELDS[i] + const isSelected = i === state.cursor + const value = state.values.get(field.key) || '' + const hasLocal = state.localEnv.has(field.key) + const hasGh = state.ghSecrets.has(field.key) || state.ghVariables.has(field.key) + + const prefix = isSelected ? `${ANSI.cyan}▸${ANSI.reset}` : ' ' + const labelWidth = 30 + const label = field.label.padEnd(labelWidth).slice(0, labelWidth) + + let displayValue: string + if (state.editing && isSelected) { + const buf = field.type === 'password' ? '•'.repeat(state.editBuffer.length) : state.editBuffer + displayValue = `[${buf}█]` + } else { + const masked = maskValue(value, field) + displayValue = value ? `[${masked}]` : `[${ANSI.dim}空${ANSI.reset}]` + } + + const localStatus = field.targets?.local ? (hasLocal || value ? `${ANSI.green}L${ANSI.reset}` : `${ANSI.dim}·${ANSI.reset}`) : `${ANSI.dim}-${ANSI.reset}` + const ghAvailable = field.targets?.github ? true : false + const ghStatus = !ghAvailable + ? `${ANSI.dim}-${ANSI.reset}` + : !state.hasGhCli + ? `${ANSI.dim}?${ANSI.reset}` + : hasGh || value + ? `${ANSI.green}G${ANSI.reset}` + : `${ANSI.dim}·${ANSI.reset}` + const reqMark = field.required ? `${ANSI.red}*${ANSI.reset}` : ' ' + + const lineContent = `${prefix} ${reqMark}${label} ${displayValue} ${localStatus}${ghStatus}` + const lineLen = 3 + 1 + labelWidth + 1 + Bun.stringWidth(displayValue.replace(/\x1b\[[0-9;]*m/g, '')) + 3 + const pad = Math.max(0, width - lineLen) + out += `${ANSI.cyan}│${ANSI.reset}${lineContent}${' '.repeat(pad)}${ANSI.cyan}│${ANSI.reset}\n` + } + + out += `${ANSI.cyan}├${'─'.repeat(width)}┤${ANSI.reset}\n` + + const selected = FIELDS[state.cursor] + const desc = selected?.description + const msgColor = state.messageType === 'error' ? ANSI.red : state.messageType === 'success' ? ANSI.green : ANSI.dim + const msgText = state.message || desc || '↑↓ 移动 Enter 编辑 K 保留 D 清空 S 保存 P 推送 T 切换目标 Q 退出' + const msgLen = Bun.stringWidth(msgText.replace(/\x1b\[[0-9;]*m/g, '')) + out += `${ANSI.cyan}│${ANSI.reset} ${msgColor}${msgText}${ANSI.reset}${' '.repeat(Math.max(0, width - msgLen - 2))}${ANSI.cyan}│${ANSI.reset}\n` + out += `${ANSI.cyan}└${'─'.repeat(width)}┘${ANSI.reset}\n` + + process.stdout.write(out) +} + +function resetMessage(state: State): void { + state.message = '' + state.messageType = 'info' +} + +function validateRequired(fields: EnvField[], values: Map, target: 'local' | 'github' | 'both'): string | null { + for (const field of fields) { + if (!field.required) continue + if (target === 'local' || target === 'both') { + if (field.targets?.local && !values.get(field.key)) return `缺少必填项: ${field.label}` + } + if (target === 'github' || target === 'both') { + if (field.targets?.github && !values.get(field.key)) return `缺少必填项: ${field.label}` + } + } + return null +} + +function applyValidation(field: EnvField, value: string): string | null { + const error = validateFieldValue(field, value) + return error +} + +async function run(): Promise { + const localEnv = getLocalEnv() + const hasGhCli = checkGhCli() + const ghSecrets = hasGhCli ? getGitHubSecrets() : new Set() + const ghVariables = hasGhCli ? getGitHubVariables() : new Set() + + const values = new Map() + for (const field of FIELDS) { + const local = localEnv.get(field.key) + if (local) values.set(field.key, local) + } + + const state: State = { + cursor: 0, + values, + editing: false, + editBuffer: '', + editCursor: 0, + message: '', + messageType: 'info', + localEnv, + ghSecrets, + ghVariables, + hasGhCli, + target: 'both', + saved: false, + } + + process.stdout.write(ANSI.clear + ANSI.home) + render(state) + + process.stdin.setRawMode(true) + process.stdin.resume() + process.stdin.setEncoding('utf8') + + process.stdin.on('data', (key: string) => { + const currentField = FIELDS[state.cursor] + + if (state.editing) { + if (key === '\r') { + const error = applyValidation(currentField, state.editBuffer) + if (error) { + state.message = error + state.messageType = 'error' + render(state) + return + } + state.values.set(currentField.key, state.editBuffer) + state.editing = false + resetMessage(state) + render(state) + return + } + if (key === '\u0003' || key === '\u001b') { + state.editing = false + resetMessage(state) + render(state) + return + } + if (key === '\u007f') { + state.editBuffer = state.editBuffer.slice(0, -1) + render(state) + return + } + state.editBuffer += key + render(state) + return + } + + if (key === '\u0003' || key === 'q' || key === 'Q') { + process.stdout.write(ANSI.showCursor) + process.exit(0) + } + + if (key === '\u001b[A') { + state.cursor = (state.cursor - 1 + FIELDS.length) % FIELDS.length + resetMessage(state) + render(state) + return + } + + if (key === '\u001b[B') { + state.cursor = (state.cursor + 1) % FIELDS.length + resetMessage(state) + render(state) + return + } + + if (key === '\r') { + state.editing = true + state.editBuffer = state.values.get(currentField.key) || '' + state.editCursor = state.editBuffer.length + resetMessage(state) + render(state) + return + } + + if (key === 'd' || key === 'D') { + state.values.delete(currentField.key) + state.message = `已清空 ${currentField.label}` + state.messageType = 'success' + render(state) + return + } + + if (key === 'k' || key === 'K') { + resetMessage(state) + render(state) + return + } + + if (key === 't' || key === 'T') { + state.target = state.target === 'both' ? 'local' : state.target === 'local' ? 'github' : 'both' + state.message = `目标切换为 ${state.target}` + state.messageType = 'info' + render(state) + return + } + + if (key === 's' || key === 'S') { + const requiredError = validateRequired(FIELDS, state.values, state.target) + if (requiredError) { + state.message = requiredError + state.messageType = 'error' + render(state) + return + } + + let savedCount = 0 + if (state.target === 'local' || state.target === 'both') { + saveLocalEnv(state.values, FIELDS) + savedCount++ + } + if ((state.target === 'github' || state.target === 'both') && state.hasGhCli) { + savedCount += saveGitHub(state.values, FIELDS) + } + + state.message = savedCount > 0 ? '已保存' : '未执行保存(检查 gh 或目标)' + state.messageType = savedCount > 0 ? 'success' : 'error' + state.saved = savedCount > 0 + render(state) + return + } + + if (key === 'p' || key === 'P') { + if (!state.hasGhCli) { + state.message = '未检测到 gh CLI' + state.messageType = 'error' + render(state) + return + } + const requiredError = validateRequired(FIELDS, state.values, 'github') + if (requiredError) { + state.message = requiredError + state.messageType = 'error' + render(state) + return + } + const pushed = saveGitHub(state.values, FIELDS) + state.message = pushed > 0 ? `已推送 ${pushed} 项到 GitHub` : '没有需要推送的字段' + state.messageType = pushed > 0 ? 'success' : 'info' + render(state) + return + } + }) +} + +run().catch((error) => { + console.error(`${ANSI.red}错误: ${error.message}${ANSI.reset}`) + process.exit(1) +}) diff --git a/scripts/set-secret.ts b/scripts/set-secret.ts index b1abbd572..10b84529e 100644 --- a/scripts/set-secret.ts +++ b/scripts/set-secret.ts @@ -1,364 +1,6 @@ #!/usr/bin/env bun import { execSync } from 'node:child_process' -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -const ROOT = process.cwd() -const ENV_LOCAL_PATH = join(ROOT, '.env.local') - -const ANSI = { - clear: '\x1b[2J', - home: '\x1b[H', - hideCursor: '\x1b[?25l', - showCursor: '\x1b[?25h', - clearLine: '\x1b[2K', - moveTo: (row: number, col: number) => `\x1b[${row};${col}H`, - moveUp: (n: number) => `\x1b[${n}A`, - bold: '\x1b[1m', - dim: '\x1b[2m', - reset: '\x1b[0m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - cyan: '\x1b[36m', - bgBlue: '\x1b[44m', - white: '\x1b[37m', -} - -interface SecretField { - key: string - label: string - isPassword: boolean - required: boolean - validate?: (value: string) => string | null -} - -const FIELDS: SecretField[] = [ - { key: 'DWEB_SFTP_USER', label: 'DWEB SFTP 用户名 (正式)', isPassword: false, required: true }, - { key: 'DWEB_SFTP_PASS', label: 'DWEB SFTP 密码 (正式)', isPassword: true, required: true }, - { key: 'DWEB_SFTP_USER_DEV', label: 'DWEB SFTP 用户名 (开发)', isPassword: false, required: false }, - { key: 'DWEB_SFTP_PASS_DEV', label: 'DWEB SFTP 密码 (开发)', isPassword: true, required: false }, - { key: 'E2E_TEST_MNEMONIC', label: 'E2E 测试助记词', isPassword: false, required: false, validate: validateMnemonic }, - { key: 'E2E_TEST_SECOND_SECRET', label: 'E2E 安全密码', isPassword: true, required: false }, - { key: 'TRONGRID_API_KEY', label: 'TronGrid API Key', isPassword: true, required: false }, - { key: 'ETHERSCAN_API_KEY', label: 'Etherscan API Key', isPassword: true, required: false }, - { key: 'MORALIS_API_KEY', label: 'Moralis API Key', isPassword: true, required: false }, -] - -function validateMnemonic(v: string): string | null { - if (!v) return null - const words = v.split(/\s+/).filter(Boolean) - if (words.length !== 12 && words.length !== 24) { - return `需要 12 或 24 个词,当前 ${words.length} 个` - } - return null -} - -interface State { - cursor: number - values: Map - editing: boolean - editBuffer: string - editCursor: number - message: string - messageType: 'info' | 'error' | 'success' - localEnv: Map - ghSecrets: Set - hasGhCli: boolean - target: 'local' | 'github' | 'both' - saved: boolean -} - -function getTermSize(): { cols: number; rows: number } { - return { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 } -} - -function checkGhCli(): boolean { - try { - execSync('gh auth status', { stdio: 'pipe' }) - return true - } catch { - return false - } -} - -function getGitHubSecrets(): Set { - const secrets = new Set() - try { - const output = execSync('gh secret list', { encoding: 'utf-8', stdio: 'pipe' }) - for (const line of output.split('\n')) { - const name = line.split('\t')[0]?.trim() - if (name) secrets.add(name) - } - } catch {} - return secrets -} - -function getLocalEnv(): Map { - const env = new Map() - if (!existsSync(ENV_LOCAL_PATH)) return env - const content = readFileSync(ENV_LOCAL_PATH, 'utf-8') - for (const line of content.split('\n')) { - const match = line.match(/^([A-Z_][A-Z0-9_]*)="(.*)"$/) - if (match) env.set(match[1], match[2]) - } - return env -} - -function saveLocalEnv(values: Map): void { - let content = existsSync(ENV_LOCAL_PATH) ? readFileSync(ENV_LOCAL_PATH, 'utf-8') : '' - for (const [key, value] of values) { - if (!value) continue - const regex = new RegExp(`^${key}=".*"$`, 'm') - const newLine = `${key}="${value}"` - if (regex.test(content)) { - content = content.replace(regex, newLine) - } else { - content = content.trimEnd() + '\n' + newLine + '\n' - } - } - writeFileSync(ENV_LOCAL_PATH, content) -} - -function saveGitHubSecrets(values: Map): number { - let count = 0 - for (const [key, value] of values) { - if (!value) continue - try { - execSync(`gh secret set ${key}`, { input: value, stdio: ['pipe', 'pipe', 'pipe'] }) - count++ - } catch {} - } - return count -} - -function maskValue(v: string, isPassword: boolean): string { - if (!v) return '' - if (isPassword) return '•'.repeat(Math.min(v.length, 16)) - if (v.length > 20) return v.slice(0, 17) + '...' - return v -} - -function render(state: State): void { - const { cols } = getTermSize() - const width = Math.min(cols - 4, 76) - let out = ANSI.home + ANSI.hideCursor - - const title = ' BFM Pay 配置管理 ' - const padTitle = Math.floor((width - Bun.stringWidth(title)) / 2) - out += `${ANSI.cyan}┌${'─'.repeat(width)}┐${ANSI.reset}\n` - out += `${ANSI.cyan}│${' '.repeat(padTitle)}${ANSI.bold}${title}${ANSI.reset}${ANSI.cyan}${' '.repeat(width - padTitle - Bun.stringWidth(title))}│${ANSI.reset}\n` - out += `${ANSI.cyan}├${'─'.repeat(width)}┤${ANSI.reset}\n` - - const targetLabel = state.target === 'both' ? 'Local + GitHub' : state.target === 'local' ? 'Local only' : 'GitHub only' - out += `${ANSI.cyan}│${ANSI.reset} Target: ${ANSI.yellow}${targetLabel}${ANSI.reset}${' '.repeat(width - 10 - targetLabel.length)}${ANSI.cyan}│${ANSI.reset}\n` - out += `${ANSI.cyan}├${'─'.repeat(width)}┤${ANSI.reset}\n` - - for (let i = 0; i < FIELDS.length; i++) { - const field = FIELDS[i] - const isSelected = i === state.cursor - const value = state.values.get(field.key) || '' - const hasLocal = state.localEnv.has(field.key) - const hasGh = state.ghSecrets.has(field.key) - - const prefix = isSelected ? `${ANSI.cyan}▸${ANSI.reset}` : ' ' - const labelWidth = 28 - const label = field.label.padEnd(labelWidth).slice(0, labelWidth) - - let displayValue: string - if (state.editing && isSelected) { - const buf = field.isPassword ? '•'.repeat(state.editBuffer.length) : state.editBuffer - displayValue = `[${buf}█]` - } else { - const masked = maskValue(value, field.isPassword) - displayValue = value ? `[${masked}]` : `[${ANSI.dim}空${ANSI.reset}]` - } - - const localStatus = hasLocal || value ? `${ANSI.green}L${ANSI.reset}` : `${ANSI.dim}·${ANSI.reset}` - const ghStatus = !state.hasGhCli ? `${ANSI.dim}?${ANSI.reset}` : hasGh || value ? `${ANSI.green}G${ANSI.reset}` : `${ANSI.dim}·${ANSI.reset}` - const reqMark = field.required ? `${ANSI.red}*${ANSI.reset}` : ' ' - - const lineContent = `${prefix} ${reqMark}${label} ${displayValue} ${localStatus}${ghStatus}` - const lineLen = 3 + 1 + labelWidth + 1 + Bun.stringWidth(displayValue.replace(/\x1b\[[0-9;]*m/g, '')) + 3 - const pad = Math.max(0, width - lineLen) - out += `${ANSI.cyan}│${ANSI.reset}${lineContent}${' '.repeat(pad)}${ANSI.cyan}│${ANSI.reset}\n` - } - - out += `${ANSI.cyan}├${'─'.repeat(width)}┤${ANSI.reset}\n` - - const msgColor = state.messageType === 'error' ? ANSI.red : state.messageType === 'success' ? ANSI.green : ANSI.dim - const msgText = state.message || '↑↓ 移动 Enter 编辑 K 保留 D 清空 S 保存 T 切换目标 Q 退出' - const msgLen = Bun.stringWidth(msgText.replace(/\x1b\[[0-9;]*m/g, '')) - out += `${ANSI.cyan}│${ANSI.reset} ${msgColor}${msgText}${ANSI.reset}${' '.repeat(Math.max(0, width - msgLen - 2))}${ANSI.cyan}│${ANSI.reset}\n` - out += `${ANSI.cyan}└${'─'.repeat(width)}┘${ANSI.reset}\n` - - process.stdout.write(out) -} - -async function run(): Promise { - const args = process.argv.slice(2) - if (args.includes('--list') || args.includes('-l')) { - showListMode() - return - } - - const state: State = { - cursor: 0, - values: new Map(), - editing: false, - editBuffer: '', - editCursor: 0, - message: '', - messageType: 'info', - localEnv: getLocalEnv(), - ghSecrets: getGitHubSecrets(), - hasGhCli: checkGhCli(), - target: 'both', - saved: false, - } - - for (const field of FIELDS) { - const existing = state.localEnv.get(field.key) - if (existing) state.values.set(field.key, existing) - } - - process.stdout.write(ANSI.clear) - process.stdin.setRawMode(true) - process.stdin.resume() - - render(state) - - process.stdin.on('data', (data: Buffer) => { - const key = data.toString() - - if (state.editing) { - handleEditMode(state, key) - } else { - handleNavigationMode(state, key) - } - - render(state) - }) - - process.on('exit', () => { - process.stdout.write(ANSI.showCursor) - process.stdin.setRawMode(false) - }) -} - -function handleEditMode(state: State, key: string): void { - if (key === '\r' || key === '\n') { - const field = FIELDS[state.cursor] - if (field.validate) { - const err = field.validate(state.editBuffer) - if (err) { - state.message = err - state.messageType = 'error' - return - } - } - state.values.set(field.key, state.editBuffer) - state.editing = false - state.message = `${field.key} 已更新` - state.messageType = 'success' - } else if (key === '\x1b') { - state.editing = false - state.editBuffer = '' - state.message = '取消编辑' - state.messageType = 'info' - } else if (key === '\x7f') { - state.editBuffer = state.editBuffer.slice(0, -1) - } else if (key.length === 1 && key.charCodeAt(0) >= 32) { - state.editBuffer += key - } -} - -function handleNavigationMode(state: State, key: string): void { - state.message = '' - state.messageType = 'info' - - if (key === '\x1b[A' || key === 'k') { - state.cursor = Math.max(0, state.cursor - 1) - } else if (key === '\x1b[B' || key === 'j') { - state.cursor = Math.min(FIELDS.length - 1, state.cursor + 1) - } else if (key === '\r' || key === '\n' || key === 'e') { - state.editing = true - state.editBuffer = state.values.get(FIELDS[state.cursor].key) || '' - state.message = 'Enter 确认 | Esc 取消' - state.messageType = 'info' - } else if (key === 'd' || key === 'D') { - const field = FIELDS[state.cursor] - state.values.delete(field.key) - state.message = `${field.key} 已清空` - state.messageType = 'info' - } else if (key === 't' || key === 'T') { - if (state.target === 'both') state.target = 'local' - else if (state.target === 'local') state.target = 'github' - else state.target = 'both' - } else if (key === 's' || key === 'S') { - save(state) - } else if (key === 'q' || key === 'Q' || key === '\x03') { - process.stdout.write(ANSI.showCursor + '\n') - if (!state.saved && state.values.size > 0) { - console.log(`${ANSI.yellow}未保存的更改已丢弃${ANSI.reset}`) - } - process.exit(0) - } -} - -function save(state: State): void { - let localCount = 0 - let ghCount = 0 - - if (state.target === 'local' || state.target === 'both') { - saveLocalEnv(state.values) - localCount = state.values.size - } - - if ((state.target === 'github' || state.target === 'both') && state.hasGhCli) { - ghCount = saveGitHubSecrets(state.values) - } - - state.saved = true - state.localEnv = getLocalEnv() - state.ghSecrets = getGitHubSecrets() - - if (state.target === 'both') { - state.message = `已保存 Local:${localCount} GitHub:${ghCount}` - } else if (state.target === 'local') { - state.message = `已保存到 .env.local (${localCount} 项)` - } else { - state.message = `已保存到 GitHub Secrets (${ghCount} 项)` - } - state.messageType = 'success' -} - -function showListMode(): void { - const localEnv = getLocalEnv() - const ghSecrets = getGitHubSecrets() - const hasGhCli = checkGhCli() - - console.log(`\n${ANSI.cyan}${ANSI.bold}配置状态${ANSI.reset}\n`) - - for (const field of FIELDS) { - const hasLocal = localEnv.has(field.key) - const hasGh = ghSecrets.has(field.key) - const localStatus = hasLocal ? `${ANSI.green}✓${ANSI.reset}` : `${ANSI.dim}·${ANSI.reset}` - const ghStatus = !hasGhCli ? `${ANSI.dim}?${ANSI.reset}` : hasGh ? `${ANSI.green}✓${ANSI.reset}` : `${ANSI.dim}·${ANSI.reset}` - const reqMark = field.required ? `${ANSI.red}*${ANSI.reset}` : ' ' - console.log(` ${reqMark}${field.key.padEnd(25)} L:${localStatus} G:${ghStatus} ${ANSI.dim}${field.label}${ANSI.reset}`) - } - - if (!hasGhCli) { - console.log(`\n${ANSI.yellow}⚠ GitHub CLI 未登录${ANSI.reset}`) - } - console.log() -} - -run().catch((err) => { - process.stdout.write(ANSI.showCursor) - console.error(`${ANSI.red}错误: ${err.message}${ANSI.reset}`) - process.exit(1) -}) +console.log('[set-secret] 已弃用,请使用 pnpm set-env 或 bun scripts/set-env.ts') +execSync('bun scripts/set-env.ts', { stdio: 'inherit' }) diff --git a/src/components/common/dweb-update-dialog.tsx b/src/components/common/dweb-update-dialog.tsx new file mode 100644 index 000000000..565755f00 --- /dev/null +++ b/src/components/common/dweb-update-dialog.tsx @@ -0,0 +1,60 @@ +import { useTranslation } from 'react-i18next' +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/alert-dialog' +import { useDwebUpdateState, dwebUpdateActions } from '@/stores/dweb-update' + +export function DwebUpdateDialog() { + const { t } = useTranslation() + const { dialogOpen, status, currentVersion, latestVersion, changeLog, installUrl } = useDwebUpdateState() + + if (status !== 'update-available') return null + + const description = t('settings:update.dialogDesc', { + current: currentVersion, + latest: latestVersion ?? currentVersion, + }) + + const handleInstall = () => { + if (installUrl) { + window.location.href = installUrl + } + } + + return ( + { + if (!open) dwebUpdateActions.closeDialog() + }} + > + + + {t('settings:update.dialogTitle')} + {description} + + {changeLog ? ( +
+
+ {t('settings:update.changelogTitle')} +
+ {changeLog} +
+ ) : null} + + {t('settings:update.later')} + + {t('settings:update.install')} + + +
+
+ ) +} diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index bac522e26..d13ee67e0 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -101,6 +101,20 @@ "walletManagement": "إدارة المحفظة", "miniappSources": "مصادر التطبيقات المصغرة الموثوقة" }, + "update": { + "check": "التحقق من التحديثات", + "checking": "جارٍ التحقق...", + "available": "تحديث متاح", + "upToDate": "أحدث إصدار", + "failed": "فشل التحقق من التحديث", + "notChecked": "لم يتم التحقق", + "dwebRequired": "يتطلب بيئة DWEB", + "dialogTitle": "تحديث متاح", + "dialogDesc": "الإصدار الحالي {{current}}، الأحدث {{latest}}", + "changelogTitle": "سجل التغييرات", + "install": "تثبيت التحديث", + "later": "لاحقًا" + }, "language": "اللغة", "languagePage": { "hint": "بعد اختيار لغة الواجهة، سيتحول التطبيق فوراً إلى اللغة المحددة.", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 2ff89b8eb..28b9a9c82 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -25,6 +25,20 @@ "storage": "Storage", "miniappSources": "Miniapp Trusted Sources" }, + "update": { + "check": "Check for updates", + "checking": "Checking...", + "available": "Update available", + "upToDate": "Up to date", + "failed": "Update check failed", + "notChecked": "Not checked", + "dwebRequired": "DWEB environment required", + "dialogTitle": "Update available", + "dialogDesc": "Current {{current}}, latest {{latest}}", + "changelogTitle": "Changelog", + "install": "Install update", + "later": "Later" + }, "appearance": { "title": "Appearance", "system": "Follow System", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 5e87b75f8..57a87c758 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -25,6 +25,20 @@ "storage": "存储空间", "miniappSources": "小程序可信源" }, + "update": { + "check": "检查更新", + "checking": "检查中...", + "available": "发现新版本", + "upToDate": "已是最新版本", + "failed": "检查更新失败", + "notChecked": "未检查", + "dwebRequired": "需要在 DWEB 环境中检查", + "dialogTitle": "发现新版本", + "dialogDesc": "当前版本 {{current}},最新版本 {{latest}}", + "changelogTitle": "更新说明", + "install": "立即升级", + "later": "稍后" + }, "appearance": { "title": "外观设置", "system": "跟随系统", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 59dc8a04a..da118a013 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -101,6 +101,20 @@ "walletManagement": "錢包管理", "miniappSources": "小程序可信源" }, + "update": { + "check": "檢查更新", + "checking": "檢查中...", + "available": "發現新版本", + "upToDate": "已是最新版本", + "failed": "檢查更新失敗", + "notChecked": "未檢查", + "dwebRequired": "需要在 DWEB 環境中檢查", + "dialogTitle": "發現新版本", + "dialogDesc": "目前版本 {{current}},最新版本 {{latest}}", + "changelogTitle": "更新說明", + "install": "立即升級", + "later": "稍後" + }, "language": "語言", "languagePage": { "hint": "選擇介面語言後,應用程式將立即切換到所選語言。", diff --git a/src/lib/__tests__/dweb-update.test.ts b/src/lib/__tests__/dweb-update.test.ts new file mode 100644 index 000000000..4606a9222 --- /dev/null +++ b/src/lib/__tests__/dweb-update.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { checkDwebUpdate, resolveDwebMetadataUrl, resolveInstallUrl } from '../dweb-update' + +type FetchResponse = { + ok: boolean + status: number + json: () => Promise +} + +function stubGlobals() { + vi.stubGlobal('__KEYAPP_SITE_ORIGIN__', 'https://example.com/KeyApp/') + vi.stubGlobal('__DEV_MODE__', false) + vi.stubGlobal('__APP_VERSION__', '1.2.3') + Object.defineProperty(window, 'dwebTarget', { value: true, configurable: true }) +} + +describe('dweb update', () => { + beforeEach(() => { + stubGlobals() + }) + + it('resolves stable metadata url', () => { + const url = resolveDwebMetadataUrl() + expect(url).toBe('https://example.com/KeyApp/dweb/metadata.json') + }) + + it('resolves dev metadata url', () => { + vi.stubGlobal('__DEV_MODE__', true) + const url = resolveDwebMetadataUrl() + expect(url).toBe('https://example.com/KeyApp/dweb-dev/metadata.json') + }) + + it('builds install url', () => { + const url = resolveInstallUrl('https://example.com/KeyApp/dweb/metadata.json') + expect(url).toBe('dweb://install?url=https%3A%2F%2Fexample.com%2FKeyApp%2Fdweb%2Fmetadata.json') + }) + + it('returns update available when remote version is higher', async () => { + const response: FetchResponse = { + ok: true, + status: 200, + json: async () => ({ version: '1.2.4', change_log: 'fix' }), + } + const fetchSpy = vi.fn(async () => response) + vi.stubGlobal('fetch', fetchSpy) + + const result = await checkDwebUpdate() + expect(result.status).toBe('update-available') + expect(result.latestVersion).toBe('1.2.4') + }) +}) diff --git a/src/lib/dweb-update.ts b/src/lib/dweb-update.ts new file mode 100644 index 000000000..4f9c43e46 --- /dev/null +++ b/src/lib/dweb-update.ts @@ -0,0 +1,155 @@ +import { isDwebEnvironment } from './crypto/secure-storage' + +export interface DwebUpdateMetadata { + version: string + changeLog?: string +} + +export type DwebUpdateStatus = 'not-dweb' | 'error' | 'up-to-date' | 'update-available' + +export interface DwebUpdateCheckResult { + status: DwebUpdateStatus + currentVersion: string + latestVersion?: string + metadataUrl: string + installUrl: string + changeLog?: string + error?: string +} + +interface ParsedVersion { + major: number + minor: number + patch: number + suffix?: string + suffixNumber?: number +} + +const VERSION_REGEX = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/ + +function parseVersion(value: string): ParsedVersion | null { + const match = value.match(VERSION_REGEX) + if (!match) return null + const [, major, minor, patch, suffix] = match + const suffixNumber = suffix && /^\d+$/.test(suffix) ? Number(suffix) : undefined + return { + major: Number(major), + minor: Number(minor), + patch: Number(patch), + suffix: suffix ?? undefined, + suffixNumber, + } +} + +function compareVersion(remote: string, local: string): number | null { + const remoteParsed = parseVersion(remote) + const localParsed = parseVersion(local) + if (!remoteParsed || !localParsed) return null + if (remoteParsed.major !== localParsed.major) return remoteParsed.major - localParsed.major + if (remoteParsed.minor !== localParsed.minor) return remoteParsed.minor - localParsed.minor + if (remoteParsed.patch !== localParsed.patch) return remoteParsed.patch - localParsed.patch + + const remoteSuffix = remoteParsed.suffixNumber ?? (remoteParsed.suffix ? Number.NaN : 0) + const localSuffix = localParsed.suffixNumber ?? (localParsed.suffix ? Number.NaN : 0) + + if (!Number.isNaN(remoteSuffix) && !Number.isNaN(localSuffix)) { + return remoteSuffix - localSuffix + } + + if (remoteParsed.suffix && !localParsed.suffix) return 1 + if (!remoteParsed.suffix && localParsed.suffix) return -1 + if (remoteParsed.suffix && localParsed.suffix) { + return remoteParsed.suffix.localeCompare(localParsed.suffix) + } + return 0 +} + +function normalizeOrigin(origin: string): string { + return origin.endsWith('/') ? origin : `${origin}/` +} + +export function resolveDwebMetadataUrl(): string { + const base = normalizeOrigin(__KEYAPP_SITE_ORIGIN__) + const path = __DEV_MODE__ ? 'dweb-dev/metadata.json' : 'dweb/metadata.json' + return new URL(path, base).toString() +} + +export function resolveInstallUrl(metadataUrl: string): string { + const encoded = encodeURIComponent(metadataUrl) + return `dweb://install?url=${encoded}` +} + +async function fetchMetadata(url: string): Promise { + const response = await fetch(url, { cache: 'no-store' }) + if (!response.ok) { + throw new Error(`metadata fetch failed: ${response.status}`) + } + const data = (await response.json()) as { version?: string; change_log?: string; changeLog?: string } + const version = typeof data.version === 'string' ? data.version : '' + const changeLog = typeof data.change_log === 'string' ? data.change_log : data.changeLog + return { version, changeLog } +} + +export async function checkDwebUpdate(): Promise { + const currentVersion = __APP_VERSION__ + let metadataUrl = '' + let installUrl = '' + try { + metadataUrl = resolveDwebMetadataUrl() + installUrl = resolveInstallUrl(metadataUrl) + } catch (error) { + const message = error instanceof Error ? error.message : 'invalid site origin' + return { status: 'error', currentVersion, metadataUrl, installUrl, error: message } + } + + if (!isDwebEnvironment()) { + return { status: 'not-dweb', currentVersion, metadataUrl, installUrl } + } + + try { + const metadata = await fetchMetadata(metadataUrl) + if (!metadata.version) { + return { + status: 'error', + currentVersion, + metadataUrl, + installUrl, + error: 'metadata version missing', + } + } + + const diff = compareVersion(metadata.version, currentVersion) + if (diff === null) { + return { + status: 'error', + currentVersion, + metadataUrl, + installUrl, + error: 'version parse failed', + } + } + + if (diff > 0) { + return { + status: 'update-available', + currentVersion, + latestVersion: metadata.version, + metadataUrl, + installUrl, + changeLog: metadata.changeLog, + } + } + + return { + status: 'up-to-date', + currentVersion, + latestVersion: metadata.version, + metadataUrl, + installUrl, + changeLog: metadata.changeLog, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error' + return { status: 'error', currentVersion, metadataUrl, installUrl, error: message } + } +} diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index 3e31f5662..4ae7985ba 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -16,6 +16,7 @@ import { IconLink as Link, IconInfoCircle as Info, IconDatabase as Database, + IconRefresh, IconWorld, IconIdBadge2, } from '@tabler/icons-react'; @@ -29,6 +30,8 @@ import { chainConfigSelectors, useUserProfile, } from '@/stores'; +import { useToast } from '@/services'; +import { dwebUpdateActions, useDwebUpdateState } from '@/stores/dweb-update'; import { ContactAvatar } from '@/components/common/contact-avatar'; import { SettingsItem } from './settings-item'; import { SettingsSection } from './settings-section'; @@ -58,12 +61,14 @@ const CURRENCY_NAMES: Record = { export function SettingsPage() { const { navigate } = useNavigation(); const { push } = useFlow(); - const { t } = useTranslation(['settings', 'common', 'security']); + const { t } = useTranslation(['settings', 'common', 'security', 'error']); const currentWallet = useCurrentWallet(); const profile = useUserProfile(); const currentLanguage = useLanguage(); const currentCurrency = useCurrency(); const currentTheme = useTheme(); + const toast = useToast(); + const dwebUpdateState = useDwebUpdateState(); const [appearanceSheetOpen, setAppearanceSheetOpen] = useState(false); const [twoStepSecretStatus, setTwoStepSecretStatus] = useState<'loading' | 'set' | 'not_set' | 'unavailable'>( 'loading', @@ -106,6 +111,38 @@ export function SettingsPage() { return t(`settings:appearance.${currentTheme}`); }; + const getUpdateStatusText = () => { + switch (dwebUpdateState.status) { + case 'checking': + return t('settings:update.checking'); + case 'update-available': + return t('settings:update.available'); + case 'up-to-date': + return t('settings:update.upToDate'); + case 'error': + return t('settings:update.failed'); + case 'not-dweb': + return t('settings:update.dwebRequired'); + default: + return t('settings:update.notChecked'); + } + }; + + const handleCheckUpdate = async () => { + const result = await dwebUpdateActions.check('manual'); + if (result.status === 'not-dweb') { + void toast.show(t('error:dwebEnvironmentRequired')); + return; + } + if (result.status === 'error') { + void toast.show(t('settings:update.failed')); + return; + } + if (result.status === 'up-to-date') { + void toast.show(t('settings:update.upToDate')); + } + }; + const getTwoStepSecretStatusText = () => { switch (twoStepSecretStatus) { case 'loading': @@ -295,6 +332,13 @@ export function SettingsPage() { }} />
+ } + label={t('settings:update.check')} + value={getUpdateStatusText()} + onClick={handleCheckUpdate} + /> +
} label={t('settings:items.storage')} diff --git a/src/providers/AppInitializer.tsx b/src/providers/AppInitializer.tsx index 2471dbc8d..5ff9aaf81 100644 --- a/src/providers/AppInitializer.tsx +++ b/src/providers/AppInitializer.tsx @@ -13,6 +13,8 @@ import { useStore } from '@tanstack/react-store' import { initializeThemeHue } from '@/hooks/useWalletTheme' import { MigrationRequiredView } from '@/components/common/migration-required-view' import { LoadingSpinner } from '@/components/common' +import { DwebUpdateDialog } from '@/components/common/dweb-update-dialog' +import { dwebUpdateActions } from '@/stores/dweb-update' // 立即执行:在 React 渲染之前应用缓存的主题色,避免闪烁 initializeThemeHue() @@ -47,6 +49,14 @@ export function AppInitializer({ children }: { children: ReactNode }) { }) }, []) // 只在挂载时执行一次 + useEffect(() => { + if (!isReady) return + const timer = setTimeout(() => { + void dwebUpdateActions.check('auto') + }, 3000) + return () => clearTimeout(timer) + }, [isReady]) + // 检测到需要迁移时,显示迁移界面 if (chainConfigMigrationRequired || walletMigrationRequired) { return @@ -61,5 +71,10 @@ export function AppInitializer({ children }: { children: ReactNode }) { ) } - return <>{children} + return ( + <> + {children} + + + ) } diff --git a/src/stores/dweb-update.ts b/src/stores/dweb-update.ts new file mode 100644 index 000000000..61e6a98be --- /dev/null +++ b/src/stores/dweb-update.ts @@ -0,0 +1,72 @@ +import { Store } from '@tanstack/react-store' +import { useStore } from '@tanstack/react-store' +import type { DwebUpdateCheckResult, DwebUpdateStatus } from '@/lib/dweb-update' +import { checkDwebUpdate } from '@/lib/dweb-update' + +export type DwebUpdateViewStatus = 'idle' | 'checking' | DwebUpdateStatus + +export interface DwebUpdateState { + status: DwebUpdateViewStatus + currentVersion: string + latestVersion?: string + changeLog?: string + metadataUrl?: string + installUrl?: string + error?: string + lastCheckedAt?: number + dialogOpen: boolean +} + +const initialState: DwebUpdateState = { + status: 'idle', + currentVersion: __APP_VERSION__, + dialogOpen: false, +} + +export const dwebUpdateStore = new Store(initialState) + +export const dwebUpdateActions = { + openDialog: () => { + dwebUpdateStore.setState((state) => ({ ...state, dialogOpen: true })) + }, + closeDialog: () => { + dwebUpdateStore.setState((state) => ({ ...state, dialogOpen: false })) + }, + check: async (source: 'auto' | 'manual'): Promise => { + const { status } = dwebUpdateStore.state + if (status === 'checking') { + return { + status: 'error', + currentVersion: __APP_VERSION__, + metadataUrl: '', + installUrl: '', + error: 'already checking', + } + } + dwebUpdateStore.setState((state) => ({ ...state, status: 'checking' })) + + const result = await checkDwebUpdate() + dwebUpdateStore.setState((state) => ({ + ...state, + status: result.status, + currentVersion: result.currentVersion, + latestVersion: result.latestVersion, + changeLog: result.changeLog, + metadataUrl: result.metadataUrl, + installUrl: result.installUrl, + error: result.error, + lastCheckedAt: Date.now(), + dialogOpen: result.status === 'update-available' ? true : state.dialogOpen, + })) + + if (source === 'auto' && result.status !== 'update-available') { + dwebUpdateStore.setState((state) => ({ ...state, dialogOpen: false })) + } + + return result + }, +} + +export function useDwebUpdateState(): DwebUpdateState { + return useStore(dwebUpdateStore) +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index d6729eba5..b096129b1 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -11,6 +11,9 @@ declare global { /** App 版本号 - 通过 vite.config.ts define 配置 */ const __APP_VERSION__: string + /** KeyApp 官网 Origin - 通过 vite.config.ts define 配置 */ + const __KEYAPP_SITE_ORIGIN__: string + /** 默认生态源列表 - 通过 vite.config.ts define 配置 */ const __ECOSYSTEM_SOURCES__: Array<{ name: string; url: string; icon?: string }> diff --git a/vite.config.ts b/vite.config.ts index 8a58a9b9f..240167eb2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -113,6 +113,10 @@ export default defineConfig(({ mode }) => { 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 siteOrigin = + env.SITE_ORIGIN ?? + process.env.SITE_ORIGIN ?? + 'https://bioforestchain.github.io/KeyApp/' const buildTime = new Date() const pad = (value: number) => value.toString().padStart(2, '0') @@ -205,6 +209,8 @@ export default defineConfig(({ mode }) => { __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), + // KeyApp 官网 Origin(DWEB 升级检查使用) + __KEYAPP_SITE_ORIGIN__: JSON.stringify(siteOrigin), // 默认生态源列表(用于订阅源管理展示) __ECOSYSTEM_SOURCES__: JSON.stringify(ecosystemSources), // API Keys 对象(用于动态读取环境变量) From 7b51129e230a33afa1e8087ac995db9ab61b94cc Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 27 Jan 2026 21:39:57 +0800 Subject: [PATCH 2/2] feat: complete implementation --- src/pages/settings/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index 4ae7985ba..6dde2783c 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -131,7 +131,7 @@ export function SettingsPage() { const handleCheckUpdate = async () => { const result = await dwebUpdateActions.check('manual'); if (result.status === 'not-dweb') { - void toast.show(t('error:dwebEnvironmentRequired')); + void toast.show(t('error:crypto.dwebEnvironmentRequired')); return; } if (result.status === 'error') {