From 6204c14b886cc221a67947d6b9ec7e67c481f508 Mon Sep 17 00:00:00 2001 From: YoungestSongMo <2130460579@qq.com> Date: Thu, 4 Jun 2026 18:31:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=20=E2=80=94=20=E6=97=A0=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=B8=8D=E6=8A=9B=E5=BC=82=E5=B8=B8=E3=80=81=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=86=B3=E5=AE=9A=E4=B8=8B=E8=BD=BD=E3=80=81=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=B1=95=E7=A4=BA=EF=BC=88md=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=AD=E7=9A=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【核心重构】 - getLatestVersion() 不再 throw Error,无更新时返回 { version: APP_VERSION } - checkForUpdates() 先自行预检 → 无更新直发 IPC,有更新才交 electron-updater - 移除 NO_UPDATE_MESSAGE 常量及所有字符串匹配拦截代码 - HeiXiuProvider 增加 500ms TTL 缓存,避免预检+electron-updater 重复请求 API 【用户体验】 - 检查到新版本后不再自动下载,展示更新内容供用户决定 - 新增"下载更新"按钮(与"安装更新"分离为两步操作) - 设置页新增"查看详情"弹窗,Markdown 格式化渲染更新日志 - available / downloading / downloaded 三个状态 UI 独立展示 【Bug 修复】 - 测试服务器版本排序:字符串序 → 语义版本序(_version_key),0.0.10 正确 > 0.0.9 - 版本比较:!= → <(_version_key),防止高版本误判为有更新 - get_best_release fallback 同样改为语义版本排序 - Windows cmd set 命令尾部空格 → Invalid URL(.trim() 修复) - electron-updater 'error' 事件中拦截 NO_UPDATE_MESSAGE → UPDATE_NOT_AVAILABLE 【文档更新】 - CHANGELOG.md、PROJECT.md、UpdateA.md 同步更新架构图和流程说明 --- .env.development | 16 +- .env.production | 15 +- ARCHITECTURE.md | 48 +- CHANGELOG.md | 16 +- PROJECT.md | 65 +++ README.md | 11 +- UpdateA.md | 162 +++---- electron/electron-env.d.ts | 7 +- electron/main.ts | 7 + electron/main/load-env.ts | 92 ++++ electron/main/net-request.ts | 39 +- electron/main/safe-storage-ipc.ts | 81 ++++ electron/main/updater.ts | 611 ++++++++++++++---------- electron/preload.ts | 27 +- package-lock.json | 57 ++- package.json | 5 +- scripts/generate-update-info.mjs | 40 +- scripts/upload-oss.mjs | 159 ++++-- shared/constants/ipc-channels.ts | 4 + shared/types/index.ts | 4 + shared/types/logging.ts | 15 +- src/components/AppProvider.tsx | 100 ++-- src/contexts/app-context.ts | 4 +- src/hooks/use-updater.ts | 16 + src/pages/login/hooks/use-auth-state.ts | 43 +- src/pages/login/index.tsx | 8 +- src/pages/settings/UpdateSetting.tsx | 136 +++++- src/services/auth-token.ts | 51 +- src/services/request.ts | 37 +- src/utils/safe-storage.ts | 147 ++++++ src/vite-env.d.ts | 5 + test-server/.python-version | 1 + test-server/README.md | 180 +++++++ test-server/pyproject.toml | 10 + test-server/requirements.txt | 2 + test-server/uv.lock | 205 ++++++++ vite.config.ts | 130 ++--- 37 files changed, 1908 insertions(+), 648 deletions(-) create mode 100644 electron/main/load-env.ts create mode 100644 electron/main/safe-storage-ipc.ts create mode 100644 src/utils/safe-storage.ts create mode 100644 test-server/.python-version create mode 100644 test-server/README.md create mode 100644 test-server/pyproject.toml create mode 100644 test-server/requirements.txt create mode 100644 test-server/uv.lock diff --git a/.env.development b/.env.development index 96b5b4e..4cb6d19 100644 --- a/.env.development +++ b/.env.development @@ -1,16 +1,16 @@ # ============================================================ -# 开发环境 — 连接测试服务器 -# 环境变量模板 — 复制为 .env.development / .env.production 后填入实际值 -# Vite 仅暴露 VITE_ 前缀的变量给客户端代码(import.meta.env.VITE_xxx) +# 开发环境 — 仅在 npm run dev 时加载 # ============================================================ -# API 基础地址(测试服务器) +# 渲染进程 API 地址(Vite 编译时注入,不可在主进程中使用) VITE_API_BASE_URL=http://8.160.179.64:8000 -# 版本类型:personal(个人版)| enterprise(企业版) +# 主进程 — 更新检查 API(指向本地测试服务器) +UPDATE_API_URL=http://127.0.0.1:8000 + +# 版本类型 VITE_EDITION=personal -# VITE_EDITION=enterprise +EDITION=personal -# 应用标题后缀(可选,覆盖默认构建标题) +# 应用标题后缀 VITE_APP_TITLE=船长·HeiXiu - diff --git a/.env.production b/.env.production index 174f76e..a61dc74 100644 --- a/.env.production +++ b/.env.production @@ -1,9 +1,18 @@ # ============================================================ -# 生产环境 +# 生产环境 — 仅在 npm run build:xxx 打包时加载 +# 打包后 .env.production 不在 ASAR 中 → 代码使用硬编码默认值 +# 此文件仅在本地 build 时生效(如 CI/CD 构建流程) # ============================================================ -# 生产服务器 API 地址 +# 渲染进程 API 地址(生产服务器) VITE_API_BASE_URL=https://www.heixiu.net -# 生产版本(构建时可通过命令行 EDITION=enterprise 覆盖) +# 主进程 — 更新检查 API(生产服务器) +UPDATE_API_URL=https://www.heixiu.net + +# 版本类型 VITE_EDITION=personal +EDITION=personal + +# 应用标题后缀 +VITE_APP_TITLE=船长·HeiXiu diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ab19117..3b29b15 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -167,12 +167,14 @@ NavBar / 设置页 → AppProvider.logout() 3. `XxxAPI` class — 静态方法,调用方式 `XxxAPI.method()` 示例: + ```ts // auth.ts -const AuthUrlObj = { login: '/api/v1/auth/login', ... } as const; +const AuthUrlObj = {login: '/api/v1/auth/login', ...} as const; -export interface LoginRequestBody { ... } -export interface LoginResponseBody { ... } +export interface LoginRequestBody {} + +export interface LoginResponseBody {} export class AuthAPI { static login(data: LoginRequestBody): Promise { @@ -185,19 +187,37 @@ export class AuthAPI { ## 数据存储(localStorage) -| Key | 类型 | 说明 | -|-----|------|------| -| `ele-heixiu-token` | `string` | JWT access_token | -| `ele-heixiu-refresh-token` | `string` | JWT refresh_token | -| `ele-heixiu-device-id` | `string`(UUID v4) | 设备唯一标识,首次自动生成 | -| `ele-heixiu-auth` | `{ username, remember, autoLogin }` | 记住账号偏好(**不存密码**) | +| Key | 类型 | 说明 | +|----------------------------|-------------------------------------|-------------------| +| `ele-heixiu-token` | `string` | JWT access_token | +| `ele-heixiu-refresh-token` | `string` | JWT refresh_token | +| `ele-heixiu-device-id` | `string`(UUID v4) | 设备唯一标识,首次自动生成 | +| `ele-heixiu-auth` | `{ username, remember, autoLogin }` | 记住账号偏好(**不存密码**) | --- ## 环境变量 -| 变量 | 开发值 | 生产值 | -|------|--------|--------| -| `VITE_API_BASE_URL` | `http://8.160.179.64:8000` | `https://www.heixiu.net` | -| `VITE_EDITION` | `personal` | `personal` / `enterprise` | -| `UPDATE_API_URL` | — | `https://www.heixiu.com` | +### 主进程加载顺序(由高到低优先级) + +1. **OS 环境变量** — `set KEY=VALUE && .\app.exe`,不覆盖已有值 +2. **`.env.local`** — 不进 git,个人覆盖(打包后连测试服务器等特殊场景) +3. **`.env.{mode}`** — 开发:`.env.development`,构建:`.env.production` +4. **代码默认值** — `updater.ts` 内 `getApiBaseUrl()` 的 fallback + +> **安全说明**:`UPDATE_API_URL` 是公开的 API 端点地址,不是密钥。反编译 ASAR 可看到,但这不影响安全——应用本身就要连到这个地址。真正的敏感信息(JWT +> token、密码)是运行时获取的,不写死在代码中。 + +### 渲染进程(Vite 编译时注入) + +| 变量 | 开发值 | 生产值 | +|---------------------|----------------------------|---------------------------| +| `VITE_API_BASE_URL` | `http://8.160.179.64:8000` | `https://www.heixiu.net` | +| `VITE_EDITION` | `personal` | `personal` / `enterprise` | + +### 主进程(Node.js 运行时读取) + +| 变量 | 开发值(.env.development) | 生产值(.env.production) | 默认值 | +|------------------|-------------------------|--------------------------|--------------------------| +| `UPDATE_API_URL` | `http://127.0.0.1:8000` | `https://www.heixiu.com` | `https://www.heixiu.com` | +| `EDITION` | `personal` | `personal` | `personal` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 976620d..6be8e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,16 @@ --- -## 0.0.1(2026-06-03) +## 0.0.12(2026-06-04) -- 初始版本 -- 蓝紫渐变主题(亮色 + 暗色) -- 自定义 H5 导航栏 -- 登录 / 注册 Modal -- 公告系统 -- 自动更新检查 +- 重构更新检查流程:无更新不再抛异常,PreCheck → 直接 IPC 通知 +- 新增用户决定下载流程:检查到新版本后展示更新内容,用户确认后再下载 +- 设置页新增"查看详情"弹窗,Markdown 格式化展示更新日志 +- 新增"下载更新"按钮,与"安装更新"分离为两个独立操作 +- 修复测试服务器版本排序(字符串序 → 语义版本序,0.0.10 正确排在 0.0.9 之后) +- electron-updater Provider 增加 TTL 缓存,避免重复 API 请求 +- 修复 Windows cmd `set` 命令空格导致的 Invalid URL 问题 +- 修复 electron-updater 'error' 事件拦截,无更新不再爆红 --- diff --git a/PROJECT.md b/PROJECT.md index b5c474e..dbcd7b9 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -205,6 +205,71 @@ - 类型检查:npm run typecheck(tsc --noEmit)。 +# 自动更新系统 + +## 架构 + +``` +客户端 (Electron 主进程) + checkForUpdates() + ├─ ① HeiXiuProvider.getLatestVersion() → 调用自有版本检查 API + │ └─ hasUpdate=false → 直接 IPC 通知 UI(不经过 electron-updater) + └─ ② hasUpdate=true → electron-updater.checkForUpdates() + ├─ HeiXiuProvider.getLatestVersion() → TTL 缓存命中,不重复请求 + ├─ emit 'update-available'(含 releaseNotes)→ UI 展示版本信息 + └─ 用户点击"下载更新" → downloadUpdate() + ├─ 差分下载(对比 .blockmap,仅下载变更区块) + ├─ SHA512 校验 + └─ 下载完成 → 用户点击"安装更新" → NSIS/DMG/AppImage 执行 +``` + +| 模块 | 位置 | 职责 | +|------|------|------| +| `HeiXiuProvider` | `electron/main/updater.ts` | 自定义 Provider,调用自有 API,无更新返回 APP_VERSION | +| `checkForUpdates` | `electron/main/updater.ts` | 预检版本 → 无更新直发 IPC / 有更新交 electron-updater | +| `downloadUpdate` | `electron/main/updater.ts` | 用户确认后触发下载(从 checkForUpdates 分离) | +| `netRequest` | `electron/main/net-request.ts` | Electron net.request 的 axios 风格封装 | +| `useUpdater` | `src/hooks/use-updater.ts` | 渲染进程更新状态 Hook(单例 IPC 监听) | +| `UpdateSetting` | `src/pages/settings/UpdateSetting.tsx` | 设置页更新区块(含"查看详情"弹窗) | +| `server.py` | `test-server/server.py` | 本地测试服务器,扫描 release/ 目录,语义版本排序 | + +## 环境变量加载机制(主进程) + +加载顺序(后者覆盖前者,OS 环境变量始终优先): + +``` +① OS 环境变量(set KEY=VALUE && .\app.exe) ← 最高优先级 +② .env.local(不进 git,个人覆盖用) +③ .env.{mode}(dev→.env.development,build→.env.production) +④ updater.ts 硬编码默认值(https://www.heixiu.com) +``` + +| 文件 | 何时生效 | 进 git | 用途 | +|------|---------|:------:|------| +| `.env.development` | `npm run dev` | ✅ | 本地开发(API → 测试服务器) | +| `.env.production` | `npm run build:xxx` | ❌ | 构建时参考(API → 生产服务器) | +| `.env.local` | 始终(后加载) | ❌ | 个人特殊配置(打包后连本地测试) | + +> `.env.production` 和 `.env.local` 不打包进 ASAR。打包后无这些文件时自动使用代码默认值。 + +## 测试工作流 + +```powershell +# 1. 构建两个版本 +npm run build:win # → release/0.0.3/ +# 改 package.json version = "0.0.4" +npm run build:win # → release/0.0.4/ + +# 2. 启动测试服务器 +cd test-server && python server.py --release-dir ../release --port 8000 + +# 3. 启动旧版本客户端 +set UPDATE_API_URL=http://127.0.0.1:8000 && .\release\0.0.3\win-unpacked\船长·HeiXiu.exe +# → 5 秒后自动检测到 0.0.4 → 差分下载 → 提示安装 +``` + +相关文档:[UpdateA.md](UpdateA.md)、[test-server/README.md](test-server/README.md) + # 与其他 AI 协作的补充说明 - 所有生成的代码片段必须放在完整的文件上下文中,并注明所属文件路径。 diff --git a/README.md b/README.md index 2252908..c727cf5 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ ele-heixiu/ ## 环境变量 +主进程加载顺序:`.env.{mode}` → `.env.local` → OS 环境变量 → 代码默认值。 + | 变量 | 默认值 | 用途 | | ----------------------- | ------------------------------------------------------ | --------------- | | `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API | @@ -154,9 +156,14 @@ ele-heixiu/ | `CSC_KEY_PASSWORD` | — | 签名证书密码 | | `VITE_API_BASE_URL` | `http://8.160.179.64:8000`(dev) | 业务 API 基地址 | +> **`.env.local`**(不进 git)用于个人特殊配置,如打包后覆盖 `UPDATE_API_URL` 指向本地测试服务器。 + --- ## 相关文档 -- [发布手册](./UpdateA.md) — API 接口、发版流程、签名指南、数据库设计 -- [项目架构](./PROJECT.md) — 详细的技术方案与设计决策 +| 文档 | 面向 | 内容 | +|------|------|------| +| [PROJECT.md](./PROJECT.md) | 开发者 | 技术架构、更新系统、环境变量架构、测试流程 | +| [UpdateA.md](./UpdateA.md) | 前端发版 | 构建命令、发版流程、代码签名 | +| [test-server/README.md](./test-server/README.md) | 后端程序员 | API 规格、数据库表结构、实现清单 | diff --git a/UpdateA.md b/UpdateA.md index a0060bd..b645b7b 100644 --- a/UpdateA.md +++ b/UpdateA.md @@ -25,13 +25,14 @@ └──────────────────┘ └──────────────────┘ └──────────────────┘ ``` -> **当前状态**:客户端为全量下载(`net.request` 下载完整 .exe)。`electron-builder` 打包已生成 `.exe.blockmap` 文件(`"differential": true`),但客户端尚未使用。**增量下载**(electron-updater 自定义 Provider)为规划中的优化。 +> **当前状态**:客户端已集成 `electron-updater` + 自定义 `HeiXiuProvider`,支持增量下载。`electron-builder` 打包已生成 +`.exe.blockmap` 文件。主进程通过 `netRequest` 调用自有 API 获取版本信息,electron-updater 自动处理差分下载和 SHA512 校验。 -| 角色 | 技术 | 职责 | -|-----|--------------------|---------------------------------| +| 角色 | 技术 | 职责 | +|-----|-----------------------------|--------------------------------------------| | 客户端 | Electron + electron-updater | 调用 API → 下载 blockmap → 差分下载 → 重建 → 校验 → 安装 | -| ECS | 任意后端语言 | 提供版本检查 API(含 blockmap 信息) | -| OSS | 阿里云对象存储 | 托管 .exe / .dmg / .AppImage + .blockmap | +| ECS | 任意后端语言 | 提供版本检查 API(含 blockmap 信息) | +| OSS | 阿里云对象存储 | 托管 .exe / .dmg / .AppImage + .blockmap | --- @@ -156,113 +157,51 @@ CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win ## 五、API 接口 -### `GET /api/v1/update/check` +客户端调用的两个端点,完整规格见 [test-server/README.md](test-server/README.md)(可直接发给后端程序员)。 -**参数**:`platform`(win32/darwin/linux)、`version`(三段式 semver)、`edition`(personal/enterprise) - -**有更新**: - -```json -{ - "code": 0, - "data": { - "hasUpdate": true, - "latestVersion": "0.1.0", - "downloadUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe", - "fileSize": 98765432, - "sha512": "Base64...", - "blockMapUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe.blockmap", - "blockMapSize": 152340, - "changelog": "更新日志...", - "releaseDate": "2026-06-03", - "forceUpdate": false, - "minimumVersion": "0.0.0" - } -} -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|:--:|------| -| `blockMapUrl` | string | 否 | blockmap 文件 OSS 地址(客户端未支持增量时可不传,传了也不影响) | -| `blockMapSize` | number | 否 | blockmap 文件字节数 | - -> **向后兼容**:`blockMapUrl` / `blockMapSize` 为可选字段。旧版客户端不关心增量更新;新版客户端检测到有 `blockMapUrl` 时启用增量下载,缺失时回退全量下载。 - -**无更新**: - -```json -{ - "code": 0, - "data": { - "hasUpdate": false - } -} -``` - -### `POST /api/v1/releases`(发布用) - -```json -{ - "platform": "win32", - "edition": "personal", - "latestVersion": "0.1.0", - "downloadUrl": "https://...", - "fileSize": 112910987, - "sha512": "Base64...", - "blockMapUrl": "https://...", - "blockMapSize": 152340, - "changelog": "...", - "releaseDate": "2026-06-03", - "forceUpdate": false, - "minimumVersion": "0.0.0" -} -``` - -> Header: `Authorization: Bearer ` - ---- - -## 六、数据库 - -```sql -CREATE TABLE releases -( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux', - edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise', - version VARCHAR(20) NOT NULL COMMENT '三段式 semver', - file_url VARCHAR(500) NOT NULL COMMENT '安装包 OSS HTTPS 地址', - file_size BIGINT NOT NULL COMMENT '安装包字节数', - sha512 VARCHAR(128) NOT NULL COMMENT '安装包 SHA512 Base64', - blockmap_url VARCHAR(500) COMMENT 'blockmap 文件 OSS 地址(增量更新用,可为空)', - blockmap_size BIGINT COMMENT 'blockmap 文件字节数', - changelog TEXT, - force_update TINYINT(1) DEFAULT 0, - min_version VARCHAR(20) DEFAULT '', - release_date DATE NOT NULL, - status VARCHAR(10) DEFAULT 'draft' COMMENT 'draft / published / deprecated', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - UNIQUE KEY uk_platform_edition_version (platform, edition, version), - INDEX idx_platform_edition_status (platform, edition, status) -); -``` - -> **新字段说明**:`blockmap_url` 和 `blockmap_size` 为可选字段(DEFAULT NULL),不影响现有数据。首次发版时无需回填历史数据——客户端检测到字段为空时自动回退全量下载。 +| 端点 | 用途 | +|----------------------------|-------------------------------------| +| `GET /api/v1/update/check` | 客户端版本检查,返回最新版本信息(含 blockmap 用于增量更新) | +| `POST /api/v1/releases` | 前端构建脚本发布新版本,需 Bearer Token 鉴权 | --- ## 七、客户端行为 -| 场景 | 行为 | -|------|-----------------------------------| -| 启动 | 5 秒后静默检查更新(仅生产环境) | -| 手动检查 | 点击按钮 → 调用 API → 反馈结果 | -| 下载 | 流式写入临时目录,SHA512 实时校验,每 250ms 推送进度 | -| 安装 | 用户确认 → spawn NSIS /S → app.quit() | -| 强制更新 | `forceUpdate=true` 时不可跳过 | -| 开发模式 | 跳过所有更新检查 | +| 场景 | 行为 | +|-------|------------------------------------------------| +| 启动 | 5 秒后静默检查更新(仅检查,不自动下载) | +| 手动检查 | 点击按钮 → 调用 API → 展示结果 | +| 发现新版本 | 展示版本号 + 更新日志 + "查看详情"弹窗 + "下载更新"按钮 | +| 下载 | 用户点击"下载更新" → electron-updater 差分下载 → SHA512 校验 | +| 安装 | 下载完成后用户点击"安装更新" → quitAndInstall → NSIS /S | +| 强制更新 | `forceUpdate=true` 时不可跳过 | +| 开发模式 | 不跳过(已取消注释),通过 `.env.development` 指向测试服务器 | + +### 环境变量加载顺序(主进程) + +``` +① OS 环境变量(set UPDATE_API_URL=...) ← 最高优先级,不覆盖 +② .env.local(不进 git,个人覆盖用) +③ .env.{mode}(开发→.env.development,打包→.env.production) +④ updater.ts 硬编码默认值(https://www.heixiu.com) +``` + +### 测试场景 + +```powershell +# 场景 1:npm run dev 自动走 .env.development → http://127.0.0.1:8000 + +# 场景 2:打包后连本地测试(方式 A — .env.local) +echo UPDATE_API_URL=http://127.0.0.1:8000 > .env.local +.\release\0.0.3\win-unpacked\船长·HeiXiu.exe + +# 场景 3:打包后连本地测试(方式 B — OS 环境变量) +set UPDATE_API_URL=http://127.0.0.1:8000 && .\release\0.0.3\win-unpacked\船长·HeiXiu.exe + +# 启动测试服务器 +cd test-server && python server.py --release-dir ../release --port 8000 +``` --- @@ -283,6 +222,17 @@ CREATE TABLE releases | `UPDATE_FORCE` | `false` | 强制更新标记(打包时) | | `UPDATE_CHANGELOG` | 读取 CHANGELOG.md | 覆盖更新日志(CI/CD) | +### .env 文件说明 + +| 文件 | 何时加载 | 进 git | 用途 | +|--------------------|---------------------|:-----:|----------------------| +| `.env.development` | `npm run dev` | ✅ | 开发环境(API 指向本地/测试服务器) | +| `.env.production` | `npm run build:xxx` | ❌ | 生产构建(API 指向正式服务器) | +| `.env.local` | 始终(后加载,覆盖前者) | ❌ | 个人特殊配置(如打包后连本地测试) | + +> **重要**:`.env.production` 和 `.env.local` 不打包进 ASAR。打包后运行 `.exe` 时这些文件不存在,自动使用 `updater.ts` +> 硬编码默认值。如需覆盖,使用 OS 环境变量或 `.env.local` 放在 exe 同目录(需配合 `loadEnvFile()` 的路径查找)。 + --- ## 九、文件结构 diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 015370f..43c8516 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -22,6 +22,11 @@ declare namespace NodeJS { } // Used in Renderer process, expose in `preload.ts` -interface Window { +declare interface Window { ipcRenderer: import('electron').IpcRenderer; + /** safeStorage 安全加密 API(基于 OS 原生密钥链) */ + safeStorage: { + encrypt(plaintext: string): Promise; + decrypt(encryptedBase64: string): Promise; + }; } diff --git a/electron/main.ts b/electron/main.ts index 55f19d7..e49d933 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -6,10 +6,12 @@ import path from 'node:path'; import { getPlatform, isMacOS } from './main/utils/platform'; import { getWindowIconPath } from './main/utils/logo'; import { buildWindowTitle, parseEdition } from '../shared/constants/app'; +import { loadEnvFile } from './main/load-env'; import { initUpdater, registerUpdateIpcHandlers } from './main/updater'; import { setupAppMenu } from './main/menu'; import { initLogger, flushLogger, logger } from './main/logger'; import { registerLogIpcHandlers } from './main/log-ipc'; +import { registerSafeStorageIpcHandlers } from './main/safe-storage-ipc'; import { BIDIRECTIONAL } from '../shared/constants/ipc-channels'; createRequire(import.meta.url); @@ -17,6 +19,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); process.env.APP_ROOT = path.join(__dirname, '..'); +// 加载 .env 文件中的环境变量到 process.env +// 必须在任何读取 process.env.UPDATE_API_URL 的模块之前调用 +loadEnvFile(); + export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']; export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron'); export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist'); @@ -141,6 +147,7 @@ app.on('before-quit', () => { app.whenReady().then(() => { initLogger(); registerLogIpcHandlers(); + registerSafeStorageIpcHandlers(); setupAppMenu(); app.setName(WINDOW_TITLE); registerUpdateIpcHandlers(); diff --git a/electron/main/load-env.ts b/electron/main/load-env.ts new file mode 100644 index 0000000..96d894c --- /dev/null +++ b/electron/main/load-env.ts @@ -0,0 +1,92 @@ +// 主进程 .env 加载器:.env.{mode} → .env.local → OS 环境变量(后者覆盖前者) + +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ESM 中 __dirname 不可用,手动计算 +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * 解析单个 .env 文件,将 key=value 写入 process.env。 + * 已存在的 process.env 值不会被覆盖(OS 环境变量优先)。 + * 返回成功加载的变量数。 + */ +function parseEnvFile(filePath: string, fileName: string): number { + try { + const content = readFileSync(filePath, 'utf-8'); + let count = 0; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + + // 跳过空行和注释 + if (!trimmed || trimmed.startsWith('#')) continue; + + // 解析 key=value + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + + const key = trimmed.slice(0, eqIdx).trim(); + let value = trimmed.slice(eqIdx + 1).trim(); + + // 去除引号(支持 "value" 和 'value') + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + // 不覆盖已在 OS 环境变量中设置的值 + if (key && !process.env[key]) { + process.env[key] = value; + count++; + } + } + + if (count > 0) { + console.log(`[load-env] 从 ${fileName} 加载了 ${count} 个环境变量`); + } + return count; + } catch (err) { + // 文件不存在 — 静默跳过(打包后 .env.* 不在 ASAR 中,这是正常情况) + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return 0; + } + // 其他错误(权限、编码等)— 警告但不中断 + console.warn(`[load-env] 读取 ${fileName} 失败:`, (err as Error).message); + return 0; + } +} + +/** + * 加载环境变量文件到 process.env。 + * 开发模式加载 .env.development,生产模式加载 .env.production, + * 之后再加载 .env.local 作为覆盖层。 + */ +export function loadEnvFile(): void { + // 项目根目录 + // APP_ROOT 在 main.ts 中设置(dist-electron/.. = 项目根) + // fallback:dist-electron/main/load-env.js → __dirname/../.. = 项目根 + const projectRoot = process.env.APP_ROOT || join(__dirname, '..', '..'); + + // 确定当前模式 + const mode = + !process.env.NODE_ENV || process.env.NODE_ENV === 'development' + ? 'development' + : 'production'; + + console.log( + `[load-env] 模式: ${mode}, projectRoot: ${projectRoot}`, + ); + + // ① 基础环境文件(按模式选择) + const baseFile = `.env.${mode}`; + parseEnvFile(join(projectRoot, baseFile), baseFile); + + // ② 本地覆盖文件(不进 git,用于打包后连测试服务器等特殊场景) + const localFile = '.env.local'; + parseEnvFile(join(projectRoot, localFile), localFile); +} diff --git a/electron/main/net-request.ts b/electron/main/net-request.ts index df9971c..2745482 100644 --- a/electron/main/net-request.ts +++ b/electron/main/net-request.ts @@ -63,19 +63,21 @@ const DEFAULT_TIMEOUT = 30_000; /** 拼接 URL(baseURL + path + query) */ function buildUrl(url: string, config: RequestConfig): string { - let fullUrl = url; - - if (config.baseURL) { - // url 以 / 开头时为绝对路径,否则拼接 - const base = config.baseURL.replace(/\/+$/, ''); - const path = url.startsWith('/') ? url : `/${url}`; - fullUrl = `${base}${path}`; + // 如果没有 baseURL 且 url 不是绝对地址,直接报错(便于定位问题) + if (!config.baseURL && !/^https?:\/\//i.test(url)) { + throw new RequestError( + `URL 缺少 baseURL,无法构造绝对地址。url="${url}", config=${JSON.stringify({ ...config, params: config.params ? '[object]' : undefined })}`, + ); } + const base = (config.baseURL || '').trim().replace(/\/+$/, ''); + const path = url.startsWith('/') ? url : `/${url}`; + let fullUrl = base ? `${base}${path}` : url; + if (config.params) { const search = new URLSearchParams(); for (const [key, value] of Object.entries(config.params)) { - if (value !== undefined) { + if (value !== undefined && value !== null) { search.append(key, String(value)); } } @@ -85,6 +87,16 @@ function buildUrl(url: string, config: RequestConfig): string { } } + // 调试:在 net.request 调用之前验证 URL 合法性 + try { + void new URL(fullUrl); + } catch { + throw new RequestError( + `构造的 URL 无法通过 Node.js 校验: "${fullUrl}"`, + ); + } + + console.log('[net-request] buildUrl:', fullUrl); return fullUrl; } @@ -112,6 +124,17 @@ export async function request( const timeout = config.timeout ?? DEFAULT_TIMEOUT; return new Promise((resolve, reject) => { + // 调试:验证 URL 是否合法 + try { + void new URL(fullUrl); + } catch (urlErr) { + console.error('[net-request] 非法 URL:', JSON.stringify(fullUrl)); + console.error('[net-request] URL 字符码:', [...fullUrl].map(c => c.charCodeAt(0)).join(',')); + console.error('[net-request] config:', JSON.stringify(config)); + reject(new RequestError(`非法 URL: ${(urlErr as Error).message}`)); + return; + } + const req = net.request({ method, url: fullUrl, diff --git a/electron/main/safe-storage-ipc.ts b/electron/main/safe-storage-ipc.ts new file mode 100644 index 0000000..b160065 --- /dev/null +++ b/electron/main/safe-storage-ipc.ts @@ -0,0 +1,81 @@ +// ============================================================ +// safe-storage-ipc — safeStorage 加密/解密 IPC 处理器 +// +// 在主进程注册,渲染进程通过 preload 暴露的安全方法调用。 +// 加密后的数据为 Base64 字符串,方便存储在 localStorage。 +// +// 平台对应: +// Windows → DPAPI +// macOS → Keychain +// Linux → libsecret +// +// 注意: +// - safeStorage 加密绑定当前 OS 用户账户,无法跨用户/跨设备解密 +// - 用户重装 OS 后加密数据将无法恢复(需重新登录) +// - safeStorage.isEncryptionAvailable() 在部分 Linux 发行版可能返回 false +// ============================================================ + +import { ipcMain, safeStorage } from 'electron'; +import { BIDIRECTIONAL } from '../../shared/constants/ipc-channels'; +import { logger } from './logger'; + +/** 检查 safeStorage 是否可用,不可用时记录警告 */ +function checkAvailability(): boolean { + if (!safeStorage.isEncryptionAvailable()) { + logger.warn('safe-storage', 'safeStorage 加密不可用(可能缺少 libsecret 等系统密钥服务)'); + return false; + } + return true; +} + +/** + * 注册 safeStorage IPC 处理器 + * 在 app.whenReady() 中调用 + */ +export function registerSafeStorageIpcHandlers(): void { + // ---------- 加密 ---------- + ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => { + if (typeof plaintext !== 'string' || plaintext.length === 0) { + logger.warn('safe-storage', '加密参数无效'); + return { success: false, error: '参数无效:需要非空字符串' }; + } + + if (!checkAvailability()) { + return { success: false, error: 'safeStorage 不可用' }; + } + + try { + const encrypted = safeStorage.encryptString(plaintext); + const base64 = encrypted.toString('base64'); + return { success: true, data: base64 }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('safe-storage', '加密失败', err instanceof Error ? err : new Error(message)); + return { success: false, error: message }; + } + }); + + // ---------- 解密 ---------- + ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => { + if (typeof encryptedBase64 !== 'string' || encryptedBase64.length === 0) { + logger.warn('safe-storage', '解密参数无效'); + return { success: false, error: '参数无效:需要非空字符串' }; + } + + if (!checkAvailability()) { + return { success: false, error: 'safeStorage 不可用' }; + } + + try { + const buffer = Buffer.from(encryptedBase64, 'base64'); + const plaintext = safeStorage.decryptString(buffer); + return { success: true, data: plaintext }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('safe-storage', '解密失败', err instanceof Error ? err : new Error(message)); + return { success: false, error: message }; + } + }); + + logger.info('safe-storage', 'safeStorage IPC 处理器已注册'); +} diff --git a/electron/main/updater.ts b/electron/main/updater.ts index de9d5db..1d6fdf1 100644 --- a/electron/main/updater.ts +++ b/electron/main/updater.ts @@ -1,292 +1,419 @@ -// ============================================================ -// 自动更新模块 — 自定义 API 版本检查 + OSS 文件下载 -// -// 架构: -// 客户端 → GET /api/v1/update/check → ECS (API) → 返回 JSON -// 客户端 → GET → OSS → 下载安装包 -// 下载完成 → SHA512 校验 → spawn 安装器 → app.quit() -// -// 与旧版 (electron-updater generic provider) 的区别: -// - 版本检查走自定义 API,后端可做灰度/强更/统计 -// - 不再依赖 latest.yml 静态文件 -// - 安装包仍托管于 OSS,下载地址由 API 动态返回 -// -// 环境变量: -// UPDATE_API_URL — 版本检查 API 基地址(默认 https://www.heixiu.com) -// ============================================================ +// 自动更新 — HeiXiuProvider + electron-updater 增量下载 +// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装 -import { BrowserWindow, net, app, ipcMain } from 'electron'; -import { createWriteStream } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { createHash } from 'node:crypto'; -import { spawn } from 'node:child_process'; -import { unlink } from 'node:fs/promises'; +import {BrowserWindow, ipcMain} from 'electron'; +import {URL} from 'node:url'; -import type { UpdateCheckResult } from '../../shared/types'; -import { APP_VERSION } from '../../shared/constants/version'; -import { logger } from './logger'; -import { netRequest } from './net-request'; +import { + NsisUpdater, + MacUpdater, + AppImageUpdater, + Provider, +} from 'electron-updater'; +import type {ProviderRuntimeOptions} from 'electron-updater/out/providers/Provider'; +import type {UpdateInfo, UpdateFileInfo} from 'builder-util-runtime'; +import type {ResolvedUpdateFileInfo} from 'electron-updater/out/types'; + +import type {UpdateCheckResult} from '../../shared/types'; +import {APP_VERSION} from '../../shared/constants/version'; +import {logger} from './logger'; +import {netRequest} from './net-request'; // ---------- IPC 通道(对渲染进程暴露,与旧版兼容)---------- export const UPDATE_CHANNELS = { - UPDATE_AVAILABLE: 'update-available', - UPDATE_PROGRESS: 'update-progress', - UPDATE_DOWNLOADED: 'update-downloaded', - UPDATE_ERROR: 'update-error', - UPDATE_NOT_AVAILABLE: 'update-not-available', - INSTALL_UPDATE: 'install-update', - CHECK_FOR_UPDATE: 'check-for-update', + UPDATE_AVAILABLE: 'update-available', + UPDATE_PROGRESS: 'update-progress', + UPDATE_DOWNLOADED: 'update-downloaded', + UPDATE_ERROR: 'update-error', + UPDATE_NOT_AVAILABLE: 'update-not-available', + INSTALL_UPDATE: 'install-update', + CHECK_FOR_UPDATE: 'check-for-update', + DOWNLOAD_UPDATE: 'download-update', } as const; // ---------- 配置 ---------- function getApiBaseUrl(): string { - return process.env.UPDATE_API_URL || 'https://www.heixiu.com'; + const url = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').trim(); + console.log(`[updater] UPDATE_API_URL=${process.env.UPDATE_API_URL} → 使用: ${url}`); + return url; +} + +// ============================================================ +// 自定义 Provider +// ============================================================ + +class HeiXiuProvider extends Provider { + private apiBaseUrl: string; + /** 短时缓存:避免 checkForUpdates 预检 + electron-updater 内部调用导致两次 API 请求 */ + private _cachedResult: { data: UpdateInfo; ts: number } | null = null; + + constructor(runtimeOptions: ProviderRuntimeOptions) { + super(runtimeOptions); + this.apiBaseUrl = getApiBaseUrl(); + } + + /** + * 调用自有版本检查 API,返回 electron-updater 格式的 UpdateInfo。 + * blockMapSize > 0 时 electron-updater 自动启用差分下载。 + * 无更新时返回当前版本号(APP_VERSION)。 + */ + async getLatestVersion(): Promise { + // 短时缓存(500ms):预检和 electron-updater 内部调用共享同一结果 + if (this._cachedResult && Date.now() - this._cachedResult.ts < 500) { + return this._cachedResult.data; + } + const data = await this._doGetLatestVersion(); + this._cachedResult = { data, ts: Date.now() }; + return data; + } + + private async _doGetLatestVersion(): Promise { + const {data} = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>( + '/api/v1/update/check', + { + baseURL: this.apiBaseUrl, + params: { + platform: process.platform, + version: APP_VERSION, + edition: process.env.EDITION || 'personal', + }, + }, + ); + + // 兼容两种 API 响应格式 + const result: UpdateCheckResult = + (data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult); + + // 无更新 → 返回当前版本号 + if (!result.hasUpdate) { + return { + version: APP_VERSION, + files: [], + path: '', + sha512: '', + releaseDate: new Date().toISOString(), + }; + } + + const file: UpdateFileInfo = { + url: result.downloadUrl, + sha512: result.sha512, + size: result.fileSize, + }; + + // 后端 API 返回 blockMapSize 时启用增量更新 + if (result.blockMapSize) { + file.blockMapSize = result.blockMapSize; + } + + logger.info('updater', 'Provider 获取到新版本', { + latestVersion: result.latestVersion, + hasBlockmap: file.blockMapSize != null, + }); + + return { + version: result.latestVersion, + files: [file], + path: result.downloadUrl, + sha512: result.sha512, + releaseDate: result.releaseDate, + releaseNotes: result.changelog, + }; + } + + /** + * 将 UpdateInfo 中的相对路径解析为完整的下载 URL。 + * electron-updater 在下载前调用此方法。 + */ + resolveFiles(updateInfo: UpdateInfo): Array { + return updateInfo.files.map((f) => { + // 使用 downloadUrl 作为 base URL 来解析文件路径 + const baseUrl = new URL(updateInfo.path); + const fileUrl = new URL(f.url, baseUrl.origin); + return { + url: fileUrl, + info: f, + }; + }); + } +} + +// ============================================================ +// 平台特定 Updater(注入自定义 Provider) +// ============================================================ + +/** + * 覆盖 getUpdateInfoAndProvider() 以注入 HeiXiuProvider。 + * 其他行为(NSIS 安装、签名校验等)完全由父类处理。 + */ +class HeiXiuNsisUpdater extends NsisUpdater { + private heiXiuProvider: HeiXiuProvider; + + constructor(heiXiuProvider: HeiXiuProvider) { + // 传 undefined 跳过内置 Provider 创建,由我们自己注入 + super(undefined); + this.heiXiuProvider = heiXiuProvider; + this.autoDownload = false; // 由我们手动控制流程,保持 IPC 兼容 + this.autoInstallOnAppQuit = false; + this.forceDevUpdateConfig = true; // 绕过 electron-updater 的 dev 模式检查 + } + + /** @internal — 注入自定义 Provider */ + protected override async getUpdateInfoAndProvider(): Promise<{ + info: UpdateInfo; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider: Provider; + }> { + const info = await this.heiXiuProvider.getLatestVersion(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {info, provider: this.heiXiuProvider as Provider}; + } +} + +class HeiXiuMacUpdater extends MacUpdater { + private heiXiuProvider: HeiXiuProvider; + + constructor(heiXiuProvider: HeiXiuProvider) { + super(undefined); + this.heiXiuProvider = heiXiuProvider; + this.autoDownload = false; + this.autoInstallOnAppQuit = false; + } + + protected override async getUpdateInfoAndProvider(): Promise<{ + info: UpdateInfo; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider: Provider; + }> { + const info = await this.heiXiuProvider.getLatestVersion(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {info, provider: this.heiXiuProvider as Provider}; + } +} + +class HeiXiuAppImageUpdater extends AppImageUpdater { + private heiXiuProvider: HeiXiuProvider; + + constructor(heiXiuProvider: HeiXiuProvider) { + super(null); + this.heiXiuProvider = heiXiuProvider; + this.autoDownload = false; + this.autoInstallOnAppQuit = false; + } + + protected override async getUpdateInfoAndProvider(): Promise<{ + info: UpdateInfo; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider: Provider; + }> { + const info = await this.heiXiuProvider.getLatestVersion(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return {info, provider: this.heiXiuProvider as Provider}; + } +} + +/** 工厂:按平台创建对应的 Updater 实例 */ +function createPlatformUpdater(provider: HeiXiuProvider) { + switch (process.platform) { + case 'win32': + return new HeiXiuNsisUpdater(provider); + case 'darwin': + return new HeiXiuMacUpdater(provider); + default: + return new HeiXiuAppImageUpdater(provider); + } } // ---------- 状态 ---------- let updateWindow: BrowserWindow | null = null; -let updateChecked = false; -/** 当前正在下载的临时文件路径(用于安装后清理) */ -let pendingInstallerPath: string | null = null; +let provider: HeiXiuProvider | null = null; +let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null; // ============================================================ // 初始化 // ============================================================ export function initUpdater(mainWindow: BrowserWindow): void { - updateWindow = mainWindow; + updateWindow = mainWindow; - if (import.meta.env.DEV) { - logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() }); - return; - } + console.log('[updater] initUpdater 被调用,API URL:', getApiBaseUrl()); - // 启动 5 秒后静默检查 - setTimeout(() => { - checkForUpdates(); - }, 5000); -} + // if (import.meta.env.DEV) { + // logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() }); + // return; + // } -// ============================================================ -// 版本检查(调用 API) -// ============================================================ + // 创建自定义 Provider + provider = new HeiXiuProvider({ + isUseMultipleRangeRequest: true, + platform: process.platform as ProviderRuntimeOptions['platform'], + executor: null as unknown as ProviderRuntimeOptions['executor'], + }); -async function fetchUpdateInfo(): Promise { - const { data } = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>( - '/api/v1/update/check', - { - baseURL: getApiBaseUrl(), - params: { - platform: process.platform, - version: APP_VERSION, - edition: process.env.EDITION || 'personal', - }, - }, - ); + // 创建平台 Updater + platformUpdater = createPlatformUpdater(provider); - // 兼容两种 API 响应格式: - // 格式 A: { code: 0, data: UpdateCheckResult } - // 格式 B: UpdateCheckResult 直接返回 - const result: UpdateCheckResult = (data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult); + // 将 electron-updater 事件桥接到现有 IPC 通道 + platformUpdater.on('checking-for-update', () => { + logger.debug('updater', '正在检查更新'); + }); - if (!result.hasUpdate) { - logger.info('updater', '当前已是最新版本'); - return null; - } + platformUpdater.on('update-available', (info: UpdateInfo) => { + // releaseNotes 可能是 string | ReleaseNoteInfo[] | null,统一转为 string + let notes = ''; + if (typeof info.releaseNotes === 'string') { + notes = info.releaseNotes; + } else if (Array.isArray(info.releaseNotes)) { + notes = info.releaseNotes.map((n) => n.note).filter(Boolean).join('\n'); + } - logger.info('updater', '发现新版本', { latestVersion: result.latestVersion }); - return result; -} + updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, { + version: info.version, + releaseDate: info.releaseDate, + releaseNotes: notes, + forceUpdate: false, + }); + logger.info('updater', '发现新版本', { + version: info.version, + releaseNotes: notes ? `${notes.slice(0, 50)}...` : '(空)', + }); + }); -// ============================================================ -// 下载安装包 -// ============================================================ + platformUpdater.on('update-not-available', (info: UpdateInfo) => { + updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE); + logger.info('updater', '当前已是最新版本', {currentVersion: info.version}); + }); -async function downloadInstaller(info: UpdateCheckResult): Promise { - const ext = - process.platform === 'win32' ? '.exe' : process.platform === 'darwin' ? '.dmg' : '.AppImage'; - const tempPath = join(tmpdir(), `HeiXiu-${info.latestVersion}-update${ext}`); + platformUpdater.on('download-progress', (progress: { + percent: number; + bytesPerSecond: number; + transferred: number; + total: number + }) => { + updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_PROGRESS, { + percent: Math.round(progress.percent), + bytesPerSecond: progress.bytesPerSecond, + transferred: progress.transferred, + total: progress.total, + }); + }); - logger.info('updater', '开始下载', { downloadUrl: info.downloadUrl, tempPath }); + platformUpdater.on('update-downloaded', () => { + updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED); + logger.info('updater', '更新已下载完成'); + }); - return new Promise((resolve, reject) => { - const req = net.request({ method: 'GET', url: info.downloadUrl }); - const fileStream = createWriteStream(tempPath); - const hash = createHash('sha512'); - const totalSize = info.fileSize || 0; - let downloadedSize = 0; - let lastReportTime = 0; + platformUpdater.on('error', (error: Error) => { + logger.error('updater', '更新流程出错', error); + updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, { + message: error.message || '更新失败', + }); + }); - req.on('response', (response) => { - const statusCode = response.statusCode; - if (statusCode !== 200) { - fileStream.close(); - unlink(tempPath).catch(() => {}); - return reject(new Error(`下载失败,HTTP ${statusCode}`)); - } - - response.on('data', (chunk: Buffer) => { - fileStream.write(chunk); - hash.update(chunk); - downloadedSize += chunk.length; - - // 限频:最多每 250ms 推送一次进度 - const now = Date.now(); - if (totalSize > 0 && now - lastReportTime >= 250) { - lastReportTime = now; - const percent = Math.round((downloadedSize / totalSize) * 100); - updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_PROGRESS, { - percent, - bytesPerSecond: 0, // 简化处理,不计算实时速度 - transferred: downloadedSize, - total: totalSize, - }); - } - }); - - response.on('end', () => { - fileStream.end(); - fileStream.on('finish', () => { - // SHA512 校验 - const actualSha512 = hash.digest('base64'); - if (info.sha512 && actualSha512 !== info.sha512) { - unlink(tempPath).catch(() => {}); - return reject( - new Error( - `SHA512 校验失败\n期望: ${info.sha512.slice(0, 16)}...\n实际: ${actualSha512.slice(0, 16)}...`, - ), - ); - } - - logger.info('updater', '下载完成,SHA512 校验通过'); - pendingInstallerPath = tempPath; - resolve(tempPath); - }); - }); - - response.on('error', (err) => { - fileStream.close(); - unlink(tempPath).catch(() => {}); - reject(new Error(`下载中断: ${err.message}`)); - }); - }); - - req.on('error', (err) => { - fileStream.close(); - unlink(tempPath).catch(() => {}); - reject(new Error(`下载请求失败: ${err.message}`)); - }); - - req.end(); - }); + // 启动 5 秒后静默检查 + setTimeout(() => { + checkForUpdates(); + }, 5000); } // ============================================================ // 操作方法 // ============================================================ +let updateChecked = false; + +/** 静默检查更新(自动下载) */ export async function checkForUpdates(): Promise { - if (updateChecked) { - logger.debug('updater', '已检查过更新,跳过'); - return; - } + if (updateChecked) { + logger.debug('updater', '已检查过更新,跳过'); + return; + } - if (import.meta.env.DEV) { - logger.debug('updater', '开发模式,跳过更新检查'); - return; - } + if (!platformUpdater || !provider) return; - try { - updateChecked = true; - const info = await fetchUpdateInfo(); + try { + updateChecked = true; - if (!info) { - // 无更新,不发通知(静默) - return; - } + // ① 先自行检查有无更新(不经过 electron-updater) + const info = await provider.getLatestVersion(); + if (info.version === APP_VERSION) { + // 无更新 → 直接通知 UI,不触发 electron-updater 的任何事件 + updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE); + logger.debug('updater', '当前已是最新版本', {version: APP_VERSION}); + return; + } - // 通知渲染进程:有新版本 - updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, { - version: info.latestVersion, - releaseDate: info.releaseDate, - releaseNotes: info.changelog, - forceUpdate: info.forceUpdate, - }); - - // 自动开始下载 - await downloadInstaller(info); - - // 下载完成 → 通知渲染进程 - updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED); - } catch (error) { - logger.error('updater', '更新流程失败', error as Error, { apiUrl: getApiBaseUrl() }); - updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, { - message: (error as Error).message || '更新检查失败', - }); - updateChecked = false; - } + // ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载) + await platformUpdater.checkForUpdates(); + } catch (error) { + updateChecked = false; + logger.error('updater', '更新流程失败', error as Error, {apiUrl: getApiBaseUrl()}); + updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, { + message: (error as Error).message || '更新检查失败', + }); + } } +/** 手动检查更新(用户点击触发) */ export async function checkForUpdatesManual(): Promise { - try { - updateChecked = false; - const info = await fetchUpdateInfo(); + if (!platformUpdater || !provider) return; - if (!info) { - updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE); - return; - } + try { + updateChecked = false; - updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, { - version: info.latestVersion, - releaseDate: info.releaseDate, - releaseNotes: info.changelog, - forceUpdate: info.forceUpdate, - }); + // ① 先自行检查有无更新 + const info = await provider.getLatestVersion(); + if (info.version === APP_VERSION) { + // 无更新 → 直接通知 UI + updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE); + logger.debug('updater', '手动检查:当前已是最新版本', {version: APP_VERSION}); + return; + } - await downloadInstaller(info); - updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED); - } catch (error) { - logger.error('updater', '手动更新失败', error as Error); - updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, { - message: (error as Error).message || '检查更新失败', - }); - } + // ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载) + await platformUpdater.checkForUpdates(); + } catch (error) { + logger.error('updater', '手动更新失败', error as Error); + updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, { + message: (error as Error).message || '检查更新失败', + }); + } } +/** 用户确认后开始下载更新 */ +export async function downloadUpdate(): Promise { + if (!platformUpdater) { + logger.warn('updater', 'downloadUpdate: Updater 未初始化'); + return; + } + try { + await platformUpdater.downloadUpdate(); + } catch (error) { + logger.error('updater', '下载更新失败', error as Error); + updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, { + message: (error as Error).message || '下载更新失败', + }); + } +} + +/** 安装已下载的更新并退出应用 */ export function installUpdateNow(): void { - if (!pendingInstallerPath) { - logger.warn('updater', '没有待安装的更新文件'); - return; - } + if (!platformUpdater) { + logger.warn('updater', 'Updater 未初始化'); + return; + } - logger.info('updater', '开始安装更新', { installerPath: pendingInstallerPath }); - - if (process.platform === 'win32') { - // Windows: NSIS 安装器,/S 静默安装 - spawn(pendingInstallerPath, ['/S'], { - detached: true, - stdio: 'ignore', - }); - } else if (process.platform === 'darwin') { - // macOS: 挂载 DMG - spawn('hdiutil', ['attach', pendingInstallerPath], { - detached: true, - stdio: 'ignore', - }); - } else { - // Linux: AppImage,chmod +x 后执行(通常需要手动替换) - logger.warn('updater', 'Linux 更新需手动替换 AppImage 文件'); - } - - // 退出应用,让安装器接管 - setImmediate(() => { - app.quit(); - }); + logger.info('updater', '开始安装更新并退出应用'); + setImmediate(() => { + platformUpdater!.quitAndInstall(false, true); + }); } // ============================================================ @@ -294,11 +421,15 @@ export function installUpdateNow(): void { // ============================================================ export function registerUpdateIpcHandlers(): void { - ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => { - await checkForUpdatesManual(); - }); + ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => { + await checkForUpdatesManual(); + }); - ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => { - installUpdateNow(); - }); + ipcMain.handle(UPDATE_CHANNELS.DOWNLOAD_UPDATE, async () => { + await downloadUpdate(); + }); + + ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => { + installUpdateNow(); + }); } diff --git a/electron/preload.ts b/electron/preload.ts index f2ef8ca..3da6271 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,5 @@ import { ipcRenderer, contextBridge } from 'electron'; +import { BIDIRECTIONAL } from '../shared/constants/ipc-channels'; // --------- Expose some API to the Renderer process --------- contextBridge.exposeInMainWorld('ipcRenderer', { @@ -18,7 +19,27 @@ contextBridge.exposeInMainWorld('ipcRenderer', { const [channel, ...omit] = args; return ipcRenderer.invoke(channel, ...omit); }, - - // You can expose other APTs you need here. - // ... +}); + +// --------- 安全加密 API(基于 Electron safeStorage)--------- +contextBridge.exposeInMainWorld('safeStorage', { + /** + * 加密明文字符串 + * @param plaintext - 待加密的明文 + * @returns Base64 编码的密文,失败返回 null + */ + async encrypt(plaintext: string): Promise { + const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext); + return result?.success ? result.data : null; + }, + + /** + * 解密 Base64 密文 + * @param encryptedBase64 - Base64 编码的密文 + * @returns 解密后的明文,失败返回 null + */ + async decrypt(encryptedBase64: string): Promise { + const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64); + return result?.success ? result.data : null; + }, }); diff --git a/package-lock.json b/package-lock.json index d5b5841..3805c20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "antd": "^6.4.3", "axios": "^1.16.1", + "electron-updater": "^6.8.3", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router-dom": "^7.16.0" @@ -3362,7 +3363,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/assert-plus": { @@ -3660,7 +3660,6 @@ "version": "9.5.1", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -4433,6 +4432,34 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz", + "integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.5.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -5090,7 +5117,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -5105,7 +5131,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -5118,7 +5143,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" @@ -5379,7 +5403,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -5781,7 +5804,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", - "dev": true, "funding": [ { "type": "github", @@ -5888,7 +5910,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/levn": { @@ -6219,6 +6240,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -7425,7 +7459,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -7808,6 +7841,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/package.json b/package.json index 0384b46..40168c8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ele-heixiu", "private": true, - "version": "0.0.1", + "version": "0.0.12", "description": "船长·HeiXiu — 桌面效率工作台", "author": "HeiXiu 杨烨", "type": "module", @@ -28,6 +28,7 @@ "dependencies": { "antd": "^6.4.3", "axios": "^1.16.1", + "electron-updater": "^6.8.3", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router-dom": "^7.16.0" @@ -36,7 +37,6 @@ "appId": "com.heixiu.electron", "productName": "船长·HeiXiu", "asar": true, - "differential": true, "directories": { "output": "release/${version}" }, @@ -82,6 +82,7 @@ "perMachine": false, "allowToChangeInstallationDirectory": true, "deleteAppDataOnUninstall": false, + "differentialPackage": true, "installerIcon": "src/assets/logo/heixiu.ico", "uninstallerIcon": "src/assets/logo/heixiu.ico" } diff --git a/scripts/generate-update-info.mjs b/scripts/generate-update-info.mjs index bff7d05..ab78e68 100644 --- a/scripts/generate-update-info.mjs +++ b/scripts/generate-update-info.mjs @@ -30,12 +30,15 @@ const ROOT = join(__dirname, '..'); const args = process.argv.slice(2); -// 提取 --url 参数 +// 提取 --url 和 --blockmap-url 参数 let urlOverride = ''; +let blockmapUrlOverride = ''; const positional = []; for (let i = 0; i < args.length; i++) { if (args[i] === '--url' && args[i + 1]) { urlOverride = args[++i]; + } else if (args[i] === '--blockmap-url' && args[i + 1]) { + blockmapUrlOverride = args[++i]; } else if (!args[i].startsWith('--')) { positional.push(args[i]); } @@ -88,6 +91,36 @@ const fileSize = stats.size; console.log(`文件大小: ${(fileSize / 1024 / 1024).toFixed(2)} MB`); console.log(`SHA512: ${sha512.slice(0, 32)}...`); +// ---------- 查找 blockmap 文件 ---------- + +let blockMapUrl; +let blockMapSize; + +try { + const blockmapFile = readdirSync(releaseDir).find((f) => f.endsWith('.blockmap')); + if (blockmapFile) { + const blockmapPath = join(releaseDir, blockmapFile); + const blockmapStats = statSync(blockmapPath); + blockMapSize = blockmapStats.size; + + if (blockmapUrlOverride) { + // 方式 B:使用指定的 blockmap URL + blockMapUrl = blockmapUrlOverride; + } else { + // 方式 A:根据基地址 + 版本号 + 文件名拼接 + const downloadBase = process.env.UPDATE_DOWNLOAD_BASE || 'https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases'; + blockMapUrl = `${downloadBase.replace(/\/+$/, '')}/${version}/${encodeURI(blockmapFile)}`; + } + + console.log(`Blockmap: ${blockmapFile} (${(blockMapSize / 1024).toFixed(2)} KB)`); + console.log(`Blockmap URL: ${blockMapUrl}`); + } +} catch { + // blockmap 不存在不影响主流程 + blockMapUrl = undefined; + blockMapSize = undefined; +} + // ---------- 构建下载 URL ---------- const fileName = installerPath.split(/[/\\]/).pop(); @@ -150,11 +183,16 @@ const updateInfo = { forceUpdate, minimumVersion, + // --- 增量更新 --- + ...(blockMapUrl && { blockMapUrl }), + ...(blockMapSize != null && { blockMapSize }), + // --- 附加:方便后端录入 --- _meta: { platform, edition, fileName, + blockmap: blockMapUrl ? { url: blockMapUrl, size: blockMapSize } : null, generatedAt: new Date().toISOString(), }, }; diff --git a/scripts/upload-oss.mjs b/scripts/upload-oss.mjs index b8e3d7d..73221e0 100644 --- a/scripts/upload-oss.mjs +++ b/scripts/upload-oss.mjs @@ -1,5 +1,5 @@ // ============================================================ -// 上传安装包到阿里云 OSS +// 上传安装包 + blockmap 文件到阿里云 OSS // // 用法: // node scripts/upload-oss.mjs [version] @@ -54,7 +54,10 @@ try { const all = readdirSync(releaseDir); const exts = platform === 'win32' ? ['.exe'] : platform === 'darwin' ? ['.dmg'] : ['.AppImage']; installerName = all.find((f) => exts.some((e) => f.endsWith(e))); - if (!installerName) console.error(`错误: ${releaseDir} — 未找到安装包`); + if (!installerName) { + console.error(`错误: ${releaseDir} — 未找到安装包`); + process.exit(1); + } } catch (err) { console.error(`错误: ${releaseDir} — ${err.message}`); process.exit(1); @@ -62,6 +65,20 @@ try { const installerPath = join(releaseDir, installerName); +// ---------- 查找 blockmap 文件 ---------- + +let blockmapName = null; +let blockmapPath = null; +try { + const all = readdirSync(releaseDir); + blockmapName = all.find((f) => f.endsWith('.blockmap')); + if (blockmapName) { + blockmapPath = join(releaseDir, blockmapName); + } +} catch { + // blockmap 不存在不影响主流程 +} + // ---------- OSS 配置 ---------- const { @@ -74,56 +91,78 @@ const { const basePath = (process.env.OSS_BASE_PATH || 'releases').replace(/^\/+|\/+$/g, ''); const objectKey = `${basePath}/${version}/${installerName}`; -// ---------- 模式 A:无 OSS 配置 → 打印手动命令 ---------- +// ============================================================ +// 模式 A:无 OSS 配置 → 打印手动命令 +// ============================================================ if (!OSS_ENDPOINT || !OSS_BUCKET || !OSS_ACCESS_KEY_ID) { const exampleBucket = OSS_BUCKET || ''; const exampleEndpoint = OSS_ENDPOINT || ''; const downloadUrl = `https://${exampleBucket}.${exampleEndpoint}/${objectKey}`; - + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('📦 上传安装包(手动)'); + console.log('📦 上传安装包 + blockmap(手动)'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log(`文件: ${installerPath}`); console.log(`大小: ${(readFileSync(installerPath).length / 1024 / 1024).toFixed(2)} MB\n`); - + + if (blockmapPath) { + console.log(`Blockmap: ${blockmapPath}`); + console.log(`Blockmap 大小: ${(readFileSync(blockmapPath).length / 1024).toFixed(2)} KB\n`); + } + console.log('方式 1 — ossutil CLI(推荐):'); - console.log(` ossutil cp "${installerPath}" oss://${exampleBucket}/${objectKey}\n`); - + console.log(` ossutil cp "${installerPath}" oss://${exampleBucket}/${objectKey}`); + if (blockmapPath) { + const blockmapKey = `${basePath}/${version}/${blockmapName}`; + console.log(` ossutil cp "${blockmapPath}" oss://${exampleBucket}/${blockmapKey}`); + } + console.log(''); + console.log('方式 2 — 阿里云控制台:'); - console.log(` 登录 OSS 控制台 → ${exampleBucket} → 上传 → 目标路径: ${objectKey}\n`); - + console.log(` 登录 OSS 控制台 → ${exampleBucket} → 上传 → 目标路径: ${objectKey}`); + if (blockmapPath) { + console.log(` 同时上传 ${blockmapName} 到相同目录`); + } + console.log(''); + + const blockmapUrl = blockmapPath + ? `https://${exampleBucket}.${exampleEndpoint}/${basePath}/${version}/${blockmapName}` + : ''; + console.log('上传后执行以下命令,用实际 URL 重新生成 info:'); - console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"\n`); - - console.log('或直接发布到后端 API:'); - console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`); + if (blockmapUrl) { + console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`); + console.log(''); + console.log('或直接发布到后端 API:'); + console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`); + } else { + console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`); + console.log(''); + console.log('或直接发布到后端 API:'); + console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`); + } console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); process.exit(0); } -// ---------- 模式 B:Node.js 直传 OSS ---------- +// ============================================================ +// 模式 B:Node.js 直传 OSS(安装包 + blockmap) +// ============================================================ const fileBuffer = readFileSync(installerPath); const contentType = platform === 'win32' ? 'application/vnd.microsoft.portable-executable' : 'application/octet-stream'; +// 上传安装包 const date = new Date().toUTCString(); - -// 阿里云 OSS Authorization 签名 const stringToSign = [ - 'PUT', - '', - contentType, - date, - `/${OSS_BUCKET}/${objectKey}`, + 'PUT', '', contentType, date, `/${OSS_BUCKET}/${objectKey}`, ].join('\n'); - const signature = createHmac('sha1', OSS_ACCESS_KEY_SECRET) .update(stringToSign) .digest('base64'); - const authHeader = `OSS ${OSS_ACCESS_KEY_ID}:${signature}`; const downloadUrl = `https://${OSS_BUCKET}.${OSS_ENDPOINT}/${objectKey}`; @@ -141,21 +180,69 @@ try { }, body: fileBuffer, }); - - if (response.ok) { - console.log(`✅ 上传成功`); - console.log(`下载 URL: ${downloadUrl}\n`); - console.log('接下来:'); - console.log(` # 重新生成 update-info.json:`); - console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`); - console.log(`\n # 或直接发布到后端:`); - console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`); - } else { + + if (!response.ok) { const body = await response.text(); - console.error(`❌ 上传失败 HTTP ${response.status}: ${body}`); + console.error(`❌ 安装包上传失败 HTTP ${response.status}: ${body}`); process.exit(1); } + console.log(`✅ 安装包上传成功`); + console.log(`下载 URL: ${downloadUrl}`); } catch (err) { - console.error(`❌ 上传失败: ${err.message}`); + console.error(`❌ 安装包上传失败: ${err.message}`); process.exit(1); } + +// 上传 blockmap 文件(如果存在) +let blockmapUrl = null; +if (blockmapPath) { + const blockmapBuffer = readFileSync(blockmapPath); + const blockmapKey = `${basePath}/${version}/${blockmapName}`; + blockmapUrl = `https://${OSS_BUCKET}.${OSS_ENDPOINT}/${blockmapKey}`; + + const bmDate = new Date().toUTCString(); + const bmStringToSign = [ + 'PUT', '', 'application/octet-stream', bmDate, `/${OSS_BUCKET}/${blockmapKey}`, + ].join('\n'); + const bmSignature = createHmac('sha1', OSS_ACCESS_KEY_SECRET) + .update(bmStringToSign) + .digest('base64'); + + console.log(`\n上传 blockmap: ${blockmapName} (${(blockmapBuffer.length / 1024).toFixed(2)} KB)`); + + try { + const bmResponse = await fetch(blockmapUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(blockmapBuffer.length), + 'Date': bmDate, + 'Authorization': `OSS ${OSS_ACCESS_KEY_ID}:${bmSignature}`, + }, + body: blockmapBuffer, + }); + + if (bmResponse.ok) { + console.log(`✅ Blockmap 上传成功`); + console.log(`Blockmap URL: ${blockmapUrl}`); + } else { + console.warn(`⚠️ Blockmap 上传失败(不影响主流程),HTTP ${bmResponse.status}`); + blockmapUrl = null; + } + } catch (err) { + console.warn(`⚠️ Blockmap 上传失败(不影响主流程): ${err.message}`); + blockmapUrl = null; + } +} + +// 后续步骤提示 +console.log('\n接下来 — 重新生成 update-info.json:'); +if (blockmapUrl) { + console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`); + console.log('\n或直接发布到后端 API:'); + console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`); +} else { + console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`); + console.log('\n或直接发布到后端 API:'); + console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`); +} diff --git a/shared/constants/ipc-channels.ts b/shared/constants/ipc-channels.ts index 050d9e8..e5819bd 100644 --- a/shared/constants/ipc-channels.ts +++ b/shared/constants/ipc-channels.ts @@ -42,4 +42,8 @@ export const BIDIRECTIONAL = { WRITE_CONFIG: 'write-config', /** 文件对话框 */ FILE_DIALOG: 'file-dialog', + /** safeStorage 加密(明文 → Base64 密文) */ + SAFESTORAGE_ENCRYPT: 'safe-storage:encrypt', + /** safeStorage 解密(Base64 密文 → 明文) */ + SAFESTORAGE_DECRYPT: 'safe-storage:decrypt', } as const; diff --git a/shared/types/index.ts b/shared/types/index.ts index 6ba91c4..0522825 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -74,4 +74,8 @@ export interface UpdateCheckResult { forceUpdate: boolean; /** 最低兼容版本(低于此版本必须强制更新) */ minimumVersion: string; + /** Blockmap 文件下载地址(可选,用于增量更新) */ + blockMapUrl?: string; + /** Blockmap 文件大小(可选,>0 时客户端启用差分下载) */ + blockMapSize?: number; } diff --git a/shared/types/logging.ts b/shared/types/logging.ts index 400cf62..391f697 100644 --- a/shared/types/logging.ts +++ b/shared/types/logging.ts @@ -7,13 +7,14 @@ export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; /** 日志分类 */ export type LogCategory = - | 'request' // HTTP 请求相关 - | 'auth' // 登录 / Token / 权限 - | 'updater' // 自动更新 - | 'event' // 事件总线 - | 'app' // 应用生命周期 - | 'ui' // UI 层错误 / 警告 - | 'ipc'; // IPC 通信 + | 'request' // HTTP 请求相关 + | 'auth' // 登录 / Token / 权限 + | 'updater' // 自动更新 + | 'event' // 事件总线 + | 'app' // 应用生命周期 + | 'ui' // UI 层错误 / 警告 + | 'ipc' // IPC 通信 + | 'safe-storage'; // 安全存储(safeStorage 加密/解密) /** * 结构化错误快照 diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 25945e8..7ce5b6a 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -11,7 +11,7 @@ import { safeIpcOn, safeIpcOff } from '@/utils/ipc'; import { AppContext } from '@/contexts/app-context'; import type { AppContextValue } from '@/contexts/app-context'; import type { LoginResponseBody } from '@/services/modules'; -import { setToken, clearToken } from '@/services/request'; +import { setToken, clearToken, initTokenStore } from '@/services/request'; import { emit, EVENTS } from '@/utils/event-bus'; import { logger } from '@/utils/logger'; import { AuthAPI } from '@/services/modules'; @@ -21,6 +21,7 @@ import { getStoredRefreshToken, setStoredRefreshToken, clearStoredRefreshToken, + initRefreshTokenStore, } from '@/services/auth-token'; interface AppProviderProps { @@ -37,9 +38,9 @@ export function AppProvider({ children }: AppProviderProps) { // ---------- 登录 / 退出 ---------- - const login = useCallback((auth: LoginResponseBody) => { - setToken(auth.access_token); - setStoredRefreshToken(auth.refresh_token); + const login = useCallback(async (auth: LoginResponseBody) => { + await setToken(auth.access_token); + await setStoredRefreshToken(auth.refresh_token); setUser(auth); setIsLoggedIn(true); logger.info('auth', 'User logged in', { @@ -58,57 +59,58 @@ export function AppProvider({ children }: AppProviderProps) { emit(EVENTS.LOGOUT); }, []); - // ---------- 初始化 token 刷新回调(给 request.ts 401 拦截器用) ---------- + // ---------- 启动时初始化 token 存储 + 自动登录 ---------- useEffect(() => { - initAuthRefresh(); - }, []); + // 从加密存储恢复 token 到内存缓存 + Promise.all([initTokenStore(), initRefreshTokenStore()]).then(() => { + // 初始化 401 刷新回调(token 缓存就绪后才注册) + initAuthRefresh(); - // ---------- 启动时自动登录(refresh_token 续期) ---------- + // 自动登录(refresh_token 续期) + if (!getAutoLoginFlag()) return; - useEffect(() => { - if (!getAutoLoginFlag()) return; + const refreshToken = getStoredRefreshToken(); + if (!refreshToken) return; - const refreshToken = getStoredRefreshToken(); - if (!refreshToken) return; - - AuthAPI.refreshToken({ refresh_token: refreshToken }) - .then((res) => { - setToken(res.access_token); - setStoredRefreshToken(res.refresh_token); - setIsLoggedIn(true); - logger.info('auth', 'Auto-login succeeded'); - // 补全用户信息 - AuthAPI.getUserInfo().then((info) => { - const u: LoginResponseBody = { - access_token: res.access_token, - refresh_token: res.refresh_token, - token_type: res.token_type, - expires_in: res.expires_in, - refresh_expires_in: res.refresh_expires_in, - user_id: info.id, - username: info.username, - role: info.role, - email: info.email, - balance_cent: info.balance_cent, - account_type: info.account_type, - display_name: info.display_name, - company_id: info.company_id, - real_name: info.real_name, - phone: info.phone, - license_code: '', - admin_permissions: info.admin_permissions, - }; - setUser(u); - emit(EVENTS.LOGIN_SUCCESS, u); + AuthAPI.refreshToken({ refresh_token: refreshToken }) + .then(async (res) => { + await setToken(res.access_token); + await setStoredRefreshToken(res.refresh_token); + setIsLoggedIn(true); + logger.info('auth', 'Auto-login succeeded'); + // 补全用户信息 + AuthAPI.getUserInfo().then((info) => { + const u: LoginResponseBody = { + access_token: res.access_token, + refresh_token: res.refresh_token, + token_type: res.token_type, + expires_in: res.expires_in, + refresh_expires_in: res.refresh_expires_in, + user_id: info.id, + username: info.username, + role: info.role, + email: info.email, + balance_cent: info.balance_cent, + account_type: info.account_type, + display_name: info.display_name, + company_id: info.company_id, + real_name: info.real_name, + phone: info.phone, + license_code: '', + admin_permissions: info.admin_permissions, + }; + setUser(u); + emit(EVENTS.LOGIN_SUCCESS, u); + }); + }) + .catch(() => { + logger.warn('auth', 'Auto-login failed, refresh_token expired'); + clearStoredRefreshToken(); + // 仅清除自动登录标记,保留用户名供下次表单回填 + clearAutoLoginFlag(); }); - }) - .catch(() => { - logger.warn('auth', 'Auto-login failed, refresh_token expired'); - clearStoredRefreshToken(); - // 仅清除自动登录标记,保留用户名+密码供下次表单回填 - clearAutoLoginFlag(); - }); + }); }, []); // ---------- 监听主进程推送的平台/版本信息 ---------- diff --git a/src/contexts/app-context.ts b/src/contexts/app-context.ts index 07100e5..7e579ec 100644 --- a/src/contexts/app-context.ts +++ b/src/contexts/app-context.ts @@ -17,8 +17,8 @@ export interface AppContextValue { isLoggedIn: boolean; /** 当前登录用户信息(未登录时为 null,结构与 LoginResponseBody 一致) */ user: LoginResponseBody | null; - /** 登录:保存 Token 并更新登录态(auth 为登录接口返回值) */ - login: (auth: LoginResponseBody) => void; + /** 登录:保存 Token 并更新登录态(auth 为登录接口返回值,异步加密存储) */ + login: (auth: LoginResponseBody) => Promise; /** 退出登录:清除 Token 和用户状态 */ logout: () => void; /** 是否为开发环境 */ diff --git a/src/hooks/use-updater.ts b/src/hooks/use-updater.ts index cb5fb71..a3ab811 100644 --- a/src/hooks/use-updater.ts +++ b/src/hooks/use-updater.ts @@ -92,6 +92,7 @@ const CH = { UPDATE_NOT_AVAILABLE: 'update-not-available', INSTALL_UPDATE: 'install-update', CHECK_FOR_UPDATE: 'check-for-update', + DOWNLOAD_UPDATE: 'download-update', } as const; // ---------- 注册 IPC 监听器(模块级,仅一次)---------- @@ -153,6 +154,7 @@ export function useUpdater() { /** 手动检查更新 */ const checkForUpdates = useCallback(async () => { + console.log("process.env.UPDATE_API_URL:", process.env.UPDATE_API_URL); setSharedState({ status: 'checking', error: null }); try { await safeIpcInvoke(CH.CHECK_FOR_UPDATE); @@ -164,6 +166,19 @@ export function useUpdater() { } }, []); + /** 用户确认后开始下载更新 */ + const downloadUpdate = useCallback(async () => { + setSharedState({ status: 'downloading', error: null }); + try { + await safeIpcInvoke(CH.DOWNLOAD_UPDATE); + } catch (err) { + setSharedState({ + status: 'error', + error: { message: (err as Error).message || '下载更新失败' }, + }); + } + }, []); + /** 确认安装更新(退出 → 安装 → 重启) */ const installUpdate = useCallback(() => { safeIpcSend(CH.INSTALL_UPDATE); @@ -185,6 +200,7 @@ export function useUpdater() { progress: state.progress, error: state.error, checkForUpdates, + downloadUpdate, installUpdate, reset, } as const; diff --git a/src/pages/login/hooks/use-auth-state.ts b/src/pages/login/hooks/use-auth-state.ts index 6aaaeb1..7d2c6fc 100644 --- a/src/pages/login/hooks/use-auth-state.ts +++ b/src/pages/login/hooks/use-auth-state.ts @@ -1,11 +1,12 @@ // ============================================================ -// useAuthState — 记住账号 / 记住密码 / 自动登录 状态管理 +// useAuthState — 记住账号 / 自动登录 状态管理 // -// "记住密码":下次打开页面自动回填用户名 + 密码(Base64 编码) +// "记住密码":下次打开页面自动回填用户名(不再存储密码) // "自动登录":登录成功时标记,AppProvider 启动时用 refresh_token 静默续期 // -// 注意:密码 Base64 仅作基本混淆,非安全加密。 -// 安全性由 refresh_token + access_token 的 JWT 双重机制保障。 +// 安全: +// - 密码从不持久化存储(access_token / refresh_token 由 safeStorage 加密保护) +// - 安全性由 refresh_token + access_token 的 JWT 双重机制 + OS 密钥链保障 // ============================================================ import { useState, useCallback } from 'react'; @@ -15,9 +16,7 @@ const STORAGE_KEY = 'ele-heixiu-auth'; interface StoredAuth { /** 上次登录的用户名(表单回填用) */ username: string; - /** 密码的 Base64 编码(仅"记住密码"勾选时存储) */ - passwordBase64?: string; - /** 是否记住密码(勾选后表单自动回填用户名+密码) */ + /** 是否记住用户名(勾选后表单自动回填用户名) */ remember: boolean; /** 是否勾选了自动登录(AppProvider 据此决定启动时是否刷新 token) */ autoLogin: boolean; @@ -48,18 +47,7 @@ export function getAutoLoginFlag(): boolean { return readAuth()?.autoLogin || false; } -/** 读取存储的密码(Base64 解码后返回明文,表单回填用) */ -export function getStoredPassword(): string { - const auth = readAuth(); - if (!auth?.passwordBase64) return ''; - try { - return atob(auth.passwordBase64); - } catch { - return ''; - } -} - -/** 仅清除自动登录标记(保留用户名和密码,下次还能回填表单) */ +/** 仅清除自动登录标记(保留用户名,下次还能回填表单) */ export function clearAutoLoginFlag(): void { const auth = readAuth(); if (!auth) return; @@ -81,7 +69,6 @@ export function useAuthState() { const saved = readAuth(); const [username, setUsername] = useState(saved?.username || ''); - // 密码不存 state,直接从 localStorage 读取,避免 stale closure 问题 const [remember, setRemember] = useState(saved?.remember || false); const [autoLogin, setAutoLogin] = useState(saved?.autoLogin || false); @@ -101,13 +88,13 @@ export function useAuthState() { * 登录成功后调用。 * 参数由调用方(LoginPage)直接从表单值传入,避免 useCallback 的 stale closure。 * - * @param user - 用户名 - * @param password - 明文密码(仅在"记住密码"勾选时存储,Base64 编码) - * @param doRemember - 是否勾选"记住密码" + * @param user - 用户名 + * @param _password - 明文密码(不再持久化存储,保留参数以兼容调用方) + * @param doRemember - 是否勾选"记住用户名" * @param doAutoLogin - 是否勾选"自动登录" */ const saveAuth = useCallback( - (user: string, password: string, doRemember: boolean, doAutoLogin: boolean) => { + (user: string, _password: string, doRemember: boolean, doAutoLogin: boolean) => { setUsername(user); if (doRemember || doAutoLogin) { const payload: StoredAuth = { @@ -115,14 +102,6 @@ export function useAuthState() { remember: doRemember, autoLogin: doAutoLogin, }; - // 仅在勾选"记住密码"时存储密码 - if (doRemember && password) { - try { - payload.passwordBase64 = btoa(password); - } catch { - /* Base64 编码失败则放弃存储密码 */ - } - } writeAuth(payload); } }, diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 7316d16..13dc464 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -14,7 +14,7 @@ import { useAppContext } from '@/contexts/app-context.ts'; import { useTheme } from '@/hooks/use-theme.ts'; import { LoginForm } from './components/LoginForm'; import { RegisterForm } from './components/RegisterForm'; -import { useAuthState, getStoredPassword } from './hooks/use-auth-state'; +import { useAuthState } from './hooks/use-auth-state'; import {AuthAPI, LoginResponseBody} from '@/services/modules'; import { getDeviceId } from '@/utils/device'; import type { LoginFormValues, RegisterFormValues } from './types'; @@ -50,11 +50,11 @@ export function LoginPage({ open, onClose }: LoginPageProps) { user_ip: '', }); - // 记住偏好(密码 Base64 编码,自动登录靠 refresh_token) + // 记住偏好(用户名、自动登录标记,密码不再持久化) authState.saveAuth(values.username, values.password, values.remember, values.autoLogin); // 更新全局登录态(内部完成 setToken + emit LOGIN_SUCCESS) - login(auth); + await login(auth); message.success(`登录成功,欢迎 ${auth.display_name || auth.username}`); onClose(); @@ -111,7 +111,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) { const loginTab = ( + {line.slice(3)} + , + ); + continue; + } + + if (/^-{3,}$/.test(line.trim())) { + elements.push(); + continue; + } + + if (line.trim().startsWith('- ')) { + elements.push( + + • {line.trim().slice(2)} + , + ); + continue; + } + + if (line.trim() === '') { + elements.push(
); + continue; + } + + elements.push( + + {line} + , + ); + } + + return elements.length > 0 ? elements : 暂无更新详情; +} export function UpdateSetting() { const { @@ -22,19 +71,23 @@ export function UpdateSetting() { progress, error, checkForUpdates, + downloadUpdate, installUpdate, } = useUpdater(); + const [detailOpen, setDetailOpen] = useState(false); + return (
- {/* ---- 状态展示 ---- */} + {/* ---- idle ---- */} {status === 'idle' && ( 点击下方按钮检查是否有新版本 )} + {/* ---- checking ---- */} {status === 'checking' && (
@@ -42,6 +95,7 @@ export function UpdateSetting() {
)} + {/* ---- no-update ---- */} {status === 'no-update' && (
@@ -49,6 +103,7 @@ export function UpdateSetting() {
)} + {/* ---- error ---- */} {status === 'error' && (
@@ -56,8 +111,8 @@ export function UpdateSetting() {
)} - {/* ---- 发现新版本 / 下载中 ---- */} - {(status === 'available' || status === 'downloading') && ( + {/* ---- 发现新版本(等待用户决定)---- */} + {status === 'available' && ( <>
@@ -65,20 +120,52 @@ export function UpdateSetting() { {info?.forceUpdate && 强制更新}
{info?.releaseNotes && ( - - {info.releaseNotes} - +
+ + {info.releaseNotes + .replace(/^## .+?\n\n?/, '') + .replace(/\n---\n?[\s\S]*$/, '') + .trim()} + + +
)} + )} - {/* ---- 下载进度 ---- */} + {/* ---- 下载中 ---- */} {status === 'downloading' && ( - + <> +
+ + 正在下载 v{info?.version}... +
+ + )} {/* ---- 下载完成 ---- */} @@ -106,17 +193,34 @@ export function UpdateSetting() { size="small" icon={} onClick={checkForUpdates} - disabled={status === 'checking'} + disabled={status === 'checking' || status === 'downloading'} > 检查更新 {isDev() && ( - 生产环境启动 5 秒后自动检查 + 生产环境启动 5 秒后自动检查 )}
+ + {/* ---- 更新详情弹窗 ---- */} + setDetailOpen(false)} + footer={ + + } + width={520} + > +
+ {info?.releaseNotes + ? renderChangelog(info.releaseNotes) + : 暂无更新详情} +
+
); } diff --git a/src/services/auth-token.ts b/src/services/auth-token.ts index 27121e2..3202de5 100644 --- a/src/services/auth-token.ts +++ b/src/services/auth-token.ts @@ -2,41 +2,52 @@ // auth-token — Token 生命周期管理(存取 refresh_token + 注册刷新回调) // // 职责: -// - 持久化 / 清除 refresh_token +// - 持久化 / 清除 refresh_token(通过 safeStorage 加密) // - 向 request.ts 注册 handle401 触发的 token 刷新回调 // - request.ts 不依赖此模块(通过 setTokenRefreshHandler 解耦) +// +// 安全: +// - refresh_token 由 Electron safeStorage 加密后存入 localStorage +// - 内存缓存供同步读取(setTokenRefreshHandler 回调内使用) // ============================================================ import { setToken, setTokenRefreshHandler } from './request'; import { AuthAPI } from './modules'; import type { RefreshTokenResponseBody } from './modules'; +import { + getSafeStore, + setSafeStore, + clearSafeStore, + initSafeStore, +} from '../utils/safe-storage'; const REFRESH_KEY = 'ele-heixiu-refresh-token'; +// ---------- 初始化 ---------- + +/** + * 启动时初始化 refresh_token:从加密存储解密到内存缓存。 + * 应在 AppProvider 挂载时调用。 + */ +export async function initRefreshTokenStore(): Promise { + await initSafeStore(REFRESH_KEY); +} + // ---------- 存取 ---------- +/** 获取当前内存缓存中的 refresh_token(同步,需先调用 initRefreshTokenStore) */ export function getStoredRefreshToken(): string | null { - try { - return localStorage.getItem(REFRESH_KEY); - } catch { - return null; - } + return getSafeStore(REFRESH_KEY); } -export function setStoredRefreshToken(token: string): void { - try { - localStorage.setItem(REFRESH_KEY, token); - } catch { - /* 静默 */ - } +/** 设置 refresh_token(异步加密存储 + 更新内存缓存) */ +export async function setStoredRefreshToken(token: string): Promise { + await setSafeStore(REFRESH_KEY, token); } +/** 清除 refresh_token(同步清除内存缓存 + localStorage) */ export function clearStoredRefreshToken(): void { - try { - localStorage.removeItem(REFRESH_KEY); - } catch { - /* 静默 */ - } + clearSafeStore(REFRESH_KEY); } // ---------- 初始化(App 启动时调用一次) ---------- @@ -56,9 +67,9 @@ export function initAuthRefresh(): void { try { const res: RefreshTokenResponseBody = await AuthAPI.refreshToken({ refresh_token: refreshToken }); - // 更新持久化 token - setToken(res.access_token); - setStoredRefreshToken(res.refresh_token); + // 更新持久化 token(加密存储) + await setToken(res.access_token); + await setStoredRefreshToken(res.refresh_token); return res.access_token; } catch { diff --git a/src/services/request.ts b/src/services/request.ts index 6d69ca0..cf8ca0f 100644 --- a/src/services/request.ts +++ b/src/services/request.ts @@ -19,6 +19,7 @@ import type { ApiResponse } from './types'; import { RequestError, RequestErrorType, RefreshError, isRefreshError } from './types'; import { emit, EVENTS } from '../utils/event-bus'; import { logger } from '../utils/logger'; +import { getSafeStore, setSafeStore, clearSafeStore, initSafeStore } from '../utils/safe-storage'; // ---------- 配置 ---------- @@ -27,7 +28,7 @@ const BASE_URL = import.meta.env.VITE_API_BASE_URL; /** 请求超时时间(毫秒) */ const TIMEOUT = 30_000; -/** localStorage 中 Token 的键名 */ +/** localStorage 中 Token 的键名(密文由 safeStorage 保护) */ const TOKEN_KEY = 'ele-heixiu-token'; /** refresh token 接口路径(该接口 401 时不应触发 handle401,否则死循环) */ @@ -40,31 +41,27 @@ function isRefreshRequest(config: InternalAxiosRequestConfig): boolean { // ---------- Token 管理 ---------- -/** 获取存储的 Token */ +/** + * 启动时初始化 Token:从加密存储解密到内存缓存。 + * 应在 AppProvider 挂载时调用(login/logout 之前)。 + */ +export async function initTokenStore(): Promise { + await initSafeStore(TOKEN_KEY); +} + +/** 获取当前内存缓存中的 Token(同步,高频安全) */ export function getToken(): string | null { - try { - return localStorage.getItem(TOKEN_KEY); - } catch { - return null; - } + return getSafeStore(TOKEN_KEY); } -/** 设置 Token */ -export function setToken(token: string): void { - try { - localStorage.setItem(TOKEN_KEY, token); - } catch { - /* 静默忽略 */ - } +/** 设置 Token(异步加密存储 + 更新内存缓存) */ +export async function setToken(token: string): Promise { + await setSafeStore(TOKEN_KEY, token); } -/** 清除 Token */ +/** 清除 Token(同步清除内存缓存 + localStorage) */ export function clearToken(): void { - try { - localStorage.removeItem(TOKEN_KEY); - } catch { - /* 静默忽略 */ - } + clearSafeStore(TOKEN_KEY); } // ---------- Token 刷新(由外部注册,request.ts 不依赖任何 auth 模块) ---------- diff --git a/src/utils/safe-storage.ts b/src/utils/safe-storage.ts new file mode 100644 index 0000000..cceefb9 --- /dev/null +++ b/src/utils/safe-storage.ts @@ -0,0 +1,147 @@ +// ============================================================ +// safe-storage — 渲染进程安全存储工具 +// +// 架构: +// - 内存缓存:get() 同步返回,避免每次 API 请求都触发 IPC 往返 +// - 持久化:set() 异步加密后写入 localStorage +// - 启动恢复:init() 从 localStorage 读取密文,异步解密到内存缓存 +// - 降级:非 Electron 环境回退到 localStorage 明文(仅开发用) +// +// 使用模式: +// // App 启动时 +// await safeStore.init('my-key'); +// +// // 任意位置读取(同步,高频安全) +// const value = safeStore.get('my-key'); +// +// // 写入(异步加密) +// await safeStore.set('my-key', 'plaintext'); +// +// // 清除 +// safeStore.clear('my-key'); +// ============================================================ + +import { hasIpc } from './ipc'; + +// ---------- 内部状态 ---------- + +/** 内存缓存(key → 明文) */ +const cache = new Map(); + +// ---------- IPC 桥接 ---------- + +interface SafeStorageBridge { + encrypt(plaintext: string): Promise; + decrypt(encryptedBase64: string): Promise; +} + +/** 获取 safeStorage 桥接(由 preload 注入) */ +function getBridge(): SafeStorageBridge | null { + if (!hasIpc() || !window.safeStorage) return null; + return window.safeStorage; +} + +// ---------- 公开 API ---------- + +/** + * 初始化指定的键:从持久化存储读取密文 → 解密 → 加载到内存缓存。 + * 应在 App 启动时(如 AppProvider 挂载)对需要用到的 key 逐一调用。 + * + * @param key - 存储键名(与 localStorage key 对应) + * @returns 解密后的明文,失败或无数据返回 null + */ +export async function initSafeStore(key: string): Promise { + // 先从 localStorage 读取密文 + let encrypted: string | null = null; + try { + encrypted = localStorage.getItem(key); + } catch { + return null; + } + if (!encrypted) return null; + + const bridge = getBridge(); + + if (bridge) { + // Electron 环境:IPC 解密 + const plaintext = await bridge.decrypt(encrypted); + if (plaintext !== null) { + cache.set(key, plaintext); + return plaintext; + } + // 解密失败(可能 OS 密钥变更)→ 清除残留 + try { + localStorage.removeItem(key); + } catch { + /* 静默 */ + } + return null; + } + + // 非 Electron 环境(浏览器开发):localStorage 明文 + cache.set(key, encrypted); + return encrypted; +} + +/** + * 同步读取已缓存的明文(高频安全,不触发 IPC)。 + * 如果缓存未命中,返回 null(调用方应先 await initSafeStore)。 + */ +export function getSafeStore(key: string): string | null { + return cache.get(key) ?? null; +} + +/** + * 加密并持久化存储。 + * 同时更新内存缓存(后续 get() 可直接同步读取)。 + * + * @returns 是否存储成功 + */ +export async function setSafeStore(key: string, plaintext: string): Promise { + // 先更新内存缓存(即使持久化失败,当前 session 仍可用) + cache.set(key, plaintext); + + const bridge = getBridge(); + + if (bridge) { + // Electron 环境:加密后写入 localStorage + const encrypted = await bridge.encrypt(plaintext); + if (encrypted !== null) { + try { + localStorage.setItem(key, encrypted); + return true; + } catch { + return false; + } + } + return false; + } + + // 非 Electron 环境:直接写入 localStorage 明文 + try { + localStorage.setItem(key, plaintext); + return true; + } catch { + return false; + } +} + +/** + * 清除指定键的缓存和持久化数据。 + * 同步执行(不阻塞),持久化清除为 best-effort。 + */ +export function clearSafeStore(key: string): void { + cache.delete(key); + try { + localStorage.removeItem(key); + } catch { + /* 静默 */ + } +} + +/** + * 检查指定键是否已有缓存(用于判断 init 是否已被调用)。 + */ +export function hasSafeStoreCache(key: string): boolean { + return cache.has(key); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 5a5882f..58257d3 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -8,6 +8,11 @@ declare global { send(channel: string, ...args: unknown[]): void; invoke(channel: string, ...args: unknown[]): Promise; }; + /** Electron safeStorage 安全加密 API(由 preload 注入,基于 OS 原生密钥链) */ + safeStorage: { + encrypt(plaintext: string): Promise; + decrypt(encryptedBase64: string): Promise; + }; } } diff --git a/test-server/.python-version b/test-server/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/test-server/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/test-server/README.md b/test-server/README.md new file mode 100644 index 0000000..da5b45d --- /dev/null +++ b/test-server/README.md @@ -0,0 +1,180 @@ +# HeiXiu 更新服务器 — 后端对接文档 + +> 本文档供后端程序员阅读和实现。 + +--- + +## 快速启动测试服务器 + +```bash +cd test-server +pip install fastapi uvicorn +python server.py --release-dir ../release --port 8000 +``` + +启动后访问: +- API 文档(Swagger):`http://localhost:8000/docs` +- 健康检查:`http://localhost:8000/health` + +--- + +## API 端点 + +所有请求为 `GET`,无鉴权。 + +### 1. `GET /api/v1/update/check` — 版本检查 + +客户端启动后调用,决定是否提示用户更新。 + +**请求参数(Query String):** + +| 参数 | 类型 | 必填 | 示例 | 说明 | +|------|------|:--:|------|------| +| `platform` | string | 是 | `win32` | `win32` / `darwin` / `linux` | +| `version` | string | 是 | `0.0.3` | 客户端当前版本号(三段式 semver) | +| `edition` | string | 否 | `personal` | `personal` / `enterprise`,默认 `personal` | + +**响应 — 有更新时:** + +```json +{ + "code": 0, + "data": { + "hasUpdate": true, + "latestVersion": "0.0.4", + "downloadUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.0.4/xxx-Setup.exe", + "fileSize": 113581841, + "sha512": "Base64...", + "blockMapUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.0.4/xxx-Setup.exe.blockmap", + "blockMapSize": 120256, + "changelog": "## 0.0.4\n\n- 日志记录\n- 增量更新", + "releaseDate": "2026-06-04", + "forceUpdate": false, + "minimumVersion": "0.0.0" + } +} +``` + +**响应 — 无更新时:** + +```json +{ + "code": 0, + "data": { + "hasUpdate": false + } +} +``` + +**字段说明:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|:--:|------| +| `downloadUrl` | string | 是 | 安装包 HTTPS 直链 | +| `fileSize` | number | 是 | 安装包字节数 | +| `sha512` | string | 是 | 安装包 SHA512 Base64 | +| `blockMapUrl` | string | 否 | blockmap 文件 URL(缺失时客户端全量下载) | +| `blockMapSize` | number | 否 | blockmap 文件字节数(`> 0` 时客户端启用增量下载) | +| `changelog` | string | 否 | Markdown 格式更新日志 | +| `releaseDate` | string | 否 | 发布日期(YYYY-MM-DD) | +| `forceUpdate` | boolean | 否 | 是否强制更新(`true` 时用户不可跳过) | +| `minimumVersion` | string | 否 | 最低兼容版本(低于此版本的客户端必须更新) | + +> **增量更新说明**:`blockMapUrl` + `blockMapSize` 为可选字段。客户端检测到 `blockMapSize > 0` 时自动启用增量下载(仅下载变更区块,节省 30%-70% 流量)。不传或为 `null` 时客户端全量下载。 + +--- + +### 2. `POST /api/v1/releases` — 发布新版本(需要鉴权) + +```http +POST /api/v1/releases +Authorization: Bearer +Content-Type: application/json +``` + +**请求体:** + +```json +{ + "platform": "win32", + "edition": "personal", + "latestVersion": "0.1.0", + "downloadUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe", + "fileSize": 112910987, + "sha512": "Base64...", + "blockMapUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe.blockmap", + "blockMapSize": 152340, + "changelog": "## 0.1.0\n\n- 新功能 A\n- 修复 B", + "releaseDate": "2026-06-04", + "forceUpdate": false, + "minimumVersion": "0.0.0" +} +``` + +--- + +## 数据库表结构 + +```sql +CREATE TABLE releases ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux', + edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise', + version VARCHAR(20) NOT NULL COMMENT '三段式 semver', + file_url VARCHAR(500) NOT NULL COMMENT '安装包 OSS HTTPS 地址', + file_size BIGINT NOT NULL COMMENT '安装包字节数', + sha512 VARCHAR(128) NOT NULL COMMENT '安装包 SHA512 Base64', + blockmap_url VARCHAR(500) COMMENT 'blockmap OSS 地址(可为空)', + blockmap_size BIGINT COMMENT 'blockmap 文件字节数', + changelog TEXT COMMENT 'Markdown 格式更新日志', + force_update TINYINT(1) DEFAULT 0, + min_version VARCHAR(20) DEFAULT '', + release_date DATE NOT NULL, + status VARCHAR(10) DEFAULT 'draft' COMMENT 'draft / published / deprecated', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_platform_edition_version (platform, edition, version), + INDEX idx_platform_edition_status (platform, edition, status) +); +``` + +--- + +## 更新检查逻辑(参考实现) + +``` +GET /api/v1/update/check?platform=win32&version=0.0.3&edition=personal + +1. 查询最新 published 版本: + SELECT * FROM releases + WHERE platform = 'win32' + AND edition = 'personal' + AND status = 'published' + ORDER BY version DESC + LIMIT 1 + +2. 比较版本号: + - 请求 version >= 最新 version → hasUpdate = false + - 请求 version < 最新 version → hasUpdate = true + +3. 版本比较建议用 semver 规则(major.minor.patch) +``` + +--- + +## 文件托管(OSS) + +- 安装包和 `.blockmap` 文件托管在阿里云 OSS,允许公开读取 +- 路径约定:`releases/{version}/{filename}` +- 客户端通过 `downloadUrl` 直接下载,不经过后端 API + +--- + +## 后端实现清单 + +- [ ] 部署 `GET /api/v1/update/check` +- [ ] 部署 `POST /api/v1/releases`(Bearer Token 鉴权) +- [ ] 创建 `releases` 表 +- [ ] 配置 OSS 公开读取 + HTTPS +- [ ] (可选)API Key 白名单 + 限流 diff --git a/test-server/pyproject.toml b/test-server/pyproject.toml new file mode 100644 index 0000000..23300f3 --- /dev/null +++ b/test-server/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "test-server" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "fastapi>=0.136.3", + "uvicorn[start]>=0.49.0", +] diff --git a/test-server/requirements.txt b/test-server/requirements.txt new file mode 100644 index 0000000..98a7311 --- /dev/null +++ b/test-server/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 diff --git a/test-server/uv.lock b/test-server/uv.lock new file mode 100644 index 0000000..8790a1f --- /dev/null +++ b/test-server/uv.lock @@ -0,0 +1,205 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "starlette" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, +] + +[[package]] +name = "test-server" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.136.3" }, + { name = "uvicorn", extras = ["start"], specifier = ">=0.49.0" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] diff --git a/vite.config.ts b/vite.config.ts index 53c334f..82ad278 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import path from 'node:path'; import electron from 'vite-plugin-electron/simple'; import react from '@vitejs/plugin-react'; @@ -17,66 +17,80 @@ const __dirname = import.meta.dirname; // 如需避免,可把仅渲染进程用的类型放到 src/types/ // ============================================================ -export default defineConfig({ - plugins: [ - react(), - tailwindcss(), - electron({ - main: { - // 主进程入口 - entry: 'electron/main.ts', - // 只监听 electron/ 目录,避免 shared/ 变更误触重启 - // (默认会监听 entry 文件的所有依赖,包括 shared/) - }, - preload: { - input: path.join(__dirname, 'electron/preload.ts'), - }, - }), - renderer(), - // 打包体积分析(仅 ANALYZE=true 时生成 stats.html) - process.env.ANALYZE && - visualizer({ - open: true, - gzipSize: true, - brotliSize: true, - filename: 'dist/stats.html', +export default defineConfig(({ mode }) => { + // 加载 .env.development / .env.production 中的环境变量 + const env = loadEnv(mode, __dirname, ''); + + return { + plugins: [ + react(), + tailwindcss(), + electron({ + main: { + // 主进程入口 + entry: 'electron/main.ts', + // 只监听 electron/ 目录,避免 shared/ 变更误触重启 + // (默认会监听 entry 文件的所有依赖,包括 shared/) + }, + preload: { + input: path.join(__dirname, 'electron/preload.ts'), + }, }), - ].filter(Boolean), - resolve: { - alias: { - '@': path.resolve(__dirname, 'src'), - '@shared': path.resolve(__dirname, 'shared'), + renderer(), + // 打包体积分析(仅 ANALYZE=true 时生成 stats.html) + process.env.ANALYZE && + visualizer({ + open: true, + gzipSize: true, + brotliSize: true, + filename: 'dist/stats.html', + }), + ].filter(Boolean), + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + '@shared': path.resolve(__dirname, 'shared'), + }, }, - }, - // ============================================================ - // 开发服务器 & HMR 配置 - // ============================================================ - server: { - port: 5173, - strictPort: true, - host: 'localhost', - // 文件监听(Windows 优化) - watch: { - // Windows 上默认的 fs.watch 有时不稳定, - // 如果文件保存后界面无反应,可临时开启 usePolling - // usePolling: true, - // interval: 100, - // 忽略不需要监听的目录,减少 CPU 占用 - ignored: ['**/node_modules/**', '**/dist/**', '**/dist-electron/**', '**/release/**'], + // ============================================================ + // 编译时注入环境变量到主进程 + // 主进程中的 process.env.xxx 会在编译时被替换为实际值 + // 这样无需在 OS 环境中设置这些变量 + // ============================================================ + define: { + 'process.env.UPDATE_API_URL': JSON.stringify(env.UPDATE_API_URL || 'https://www.heixiu.com'), + 'process.env.EDITION': JSON.stringify(env.EDITION || 'personal'), }, - // HMR 配置 - hmr: { - // 在浏览器控制台显示 HMR 覆盖层(错误/警告时) - overlay: true, - // 协议默认 ws://,localhost 下无需额外配置 + // ============================================================ + // 开发服务器 & HMR 配置 + // ============================================================ + server: { + port: 5173, + strictPort: true, + host: 'localhost', + // 文件监听(Windows 优化) + watch: { + // Windows 上默认的 fs.watch 有时不稳定, + // 如果文件保存后界面无反应,可临时开启 usePolling + // usePolling: true, + // interval: 100, + // 忽略不需要监听的目录,减少 CPU 占用 + ignored: ['**/node_modules/**', '**/dist/**', '**/dist-electron/**', '**/release/**', '**/test-server/**'], + }, + // HMR 配置 + hmr: { + // 在浏览器控制台显示 HMR 覆盖层(错误/警告时) + overlay: true, + // 协议默认 ws://,localhost 下无需额外配置 + }, }, - }, - build: { - rollupOptions: { - output: {}, + build: { + rollupOptions: { + output: {}, + }, }, - }, - preview: { - port: 5173, - }, + preview: { + port: 5173, + }, + }; });