feat: 重构更新流程 — 无更新不抛异常、用户决定下载、更新详情展示(md文件中的)
【核心重构】
- 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 同步更新架构图和流程说明
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<LoginResponseBody> {
|
||||
@@ -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` |
|
||||
|
||||
16
CHANGELOG.md
16
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' 事件拦截,无更新不再爆红
|
||||
|
||||
---
|
||||
|
||||
|
||||
65
PROJECT.md
65
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 协作的补充说明
|
||||
|
||||
- 所有生成的代码片段必须放在完整的文件上下文中,并注明所属文件路径。
|
||||
|
||||
11
README.md
11
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 规格、数据库表结构、实现清单 |
|
||||
|
||||
162
UpdateA.md
162
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 <UPDATE_API_KEY>`
|
||||
|
||||
---
|
||||
|
||||
## 六、数据库
|
||||
|
||||
```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()` 的路径查找)。
|
||||
|
||||
---
|
||||
|
||||
## 九、文件结构
|
||||
|
||||
7
electron/electron-env.d.ts
vendored
7
electron/electron-env.d.ts
vendored
@@ -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<string | null>;
|
||||
decrypt(encryptedBase64: string): Promise<string | null>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
92
electron/main/load-env.ts
Normal file
92
electron/main/load-env.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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<T = unknown>(
|
||||
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,
|
||||
|
||||
81
electron/main/safe-storage-ipc.ts
Normal file
81
electron/main/safe-storage-ipc.ts
Normal file
@@ -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 处理器已注册');
|
||||
}
|
||||
@@ -1,292 +1,419 @@
|
||||
// ============================================================
|
||||
// 自动更新模块 — 自定义 API 版本检查 + OSS 文件下载
|
||||
//
|
||||
// 架构:
|
||||
// 客户端 → GET /api/v1/update/check → ECS (API) → 返回 JSON
|
||||
// 客户端 → GET <downloadUrl> → 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<UpdateInfo> {
|
||||
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<UpdateInfo> {
|
||||
// 短时缓存(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<UpdateInfo> {
|
||||
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<ResolvedUpdateFileInfo> {
|
||||
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<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return {info, provider: this.heiXiuProvider as Provider<any>};
|
||||
}
|
||||
}
|
||||
|
||||
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<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return {info, provider: this.heiXiuProvider as Provider<any>};
|
||||
}
|
||||
}
|
||||
|
||||
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<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return {info, provider: this.heiXiuProvider as Provider<any>};
|
||||
}
|
||||
}
|
||||
|
||||
/** 工厂:按平台创建对应的 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<UpdateCheckResult | null> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<string | null> {
|
||||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64);
|
||||
return result?.success ? result.data : null;
|
||||
},
|
||||
});
|
||||
|
||||
57
package-lock.json
generated
57
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ============================================================
|
||||
// 上传安装包到阿里云 OSS
|
||||
// 上传安装包 + blockmap 文件到阿里云 OSS
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/upload-oss.mjs <platform> <edition> [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 || '<bucket>';
|
||||
const exampleEndpoint = OSS_ENDPOINT || '<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}"`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,4 +74,8 @@ export interface UpdateCheckResult {
|
||||
forceUpdate: boolean;
|
||||
/** 最低兼容版本(低于此版本必须强制更新) */
|
||||
minimumVersion: string;
|
||||
/** Blockmap 文件下载地址(可选,用于增量更新) */
|
||||
blockMapUrl?: string;
|
||||
/** Blockmap 文件大小(可选,>0 时客户端启用差分下载) */
|
||||
blockMapSize?: number;
|
||||
}
|
||||
|
||||
@@ -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 加密/解密)
|
||||
|
||||
/**
|
||||
* 结构化错误快照
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ---------- 监听主进程推送的平台/版本信息 ----------
|
||||
|
||||
@@ -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<void>;
|
||||
/** 退出登录:清除 Token 和用户状态 */
|
||||
logout: () => void;
|
||||
/** 是否为开发环境 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 = (
|
||||
<LoginForm
|
||||
initialUsername={authState.username}
|
||||
initialPassword={getStoredPassword()}
|
||||
|
||||
initialRemember={authState.remember}
|
||||
initialAutoLogin={authState.autoLogin}
|
||||
submitting={submitting}
|
||||
|
||||
@@ -2,18 +2,67 @@
|
||||
// UpdateSetting — 版本更新区块
|
||||
// ============================================================
|
||||
|
||||
import { Card, Button, Typography, Progress, Tag } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { Card, Button, Typography, Progress, Tag, Modal, Divider } from 'antd';
|
||||
import {
|
||||
SyncOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
RocketOutlined,
|
||||
DownloadOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useUpdater } from '@/hooks/use-updater';
|
||||
import { isDev } from '@/utils/platform';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
/** 简单渲染 changelog(Markdown → JSX),支持 ## / - / --- */
|
||||
function renderChangelog(markdown: string) {
|
||||
const lines = markdown.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const key = `cl-${i}`;
|
||||
|
||||
if (line.startsWith('## ')) {
|
||||
elements.push(
|
||||
<Title key={key} level={5} className="!mt-3! !mb-1!">
|
||||
{line.slice(3)}
|
||||
</Title>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^-{3,}$/.test(line.trim())) {
|
||||
elements.push(<Divider key={key} className="!my-2!" />);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim().startsWith('- ')) {
|
||||
elements.push(
|
||||
<Text key={key} className="block pl-3 text-sm leading-relaxed">
|
||||
• {line.trim().slice(2)}
|
||||
</Text>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === '') {
|
||||
elements.push(<div key={key} className="h-2" />);
|
||||
continue;
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<Text key={key} className="block text-sm leading-relaxed">
|
||||
{line}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
return elements.length > 0 ? elements : <Text type="secondary">暂无更新详情</Text>;
|
||||
}
|
||||
|
||||
export function UpdateSetting() {
|
||||
const {
|
||||
@@ -22,19 +71,23 @@ export function UpdateSetting() {
|
||||
progress,
|
||||
error,
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
installUpdate,
|
||||
} = useUpdater();
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Card size="small" title="版本更新" className="rounded-md!">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* ---- 状态展示 ---- */}
|
||||
{/* ---- idle ---- */}
|
||||
{status === 'idle' && (
|
||||
<Text type="secondary" className="text-sm">
|
||||
点击下方按钮检查是否有新版本
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* ---- checking ---- */}
|
||||
{status === 'checking' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<SyncOutlined spin style={{ color: 'var(--color-primary, #4F46E5)' }} />
|
||||
@@ -42,6 +95,7 @@ export function UpdateSetting() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- no-update ---- */}
|
||||
{status === 'no-update' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleOutlined style={{ color: 'var(--color-success, #52c41a)' }} />
|
||||
@@ -49,6 +103,7 @@ export function UpdateSetting() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- error ---- */}
|
||||
{status === 'error' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ExclamationCircleOutlined style={{ color: 'var(--color-error, #ff4d4f)' }} />
|
||||
@@ -56,8 +111,8 @@ export function UpdateSetting() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 发现新版本 / 下载中 ---- */}
|
||||
{(status === 'available' || status === 'downloading') && (
|
||||
{/* ---- 发现新版本(等待用户决定)---- */}
|
||||
{status === 'available' && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<RocketOutlined style={{ color: 'var(--color-primary, #4F46E5)' }} />
|
||||
@@ -65,20 +120,52 @@ export function UpdateSetting() {
|
||||
{info?.forceUpdate && <Tag color="red">强制更新</Tag>}
|
||||
</div>
|
||||
{info?.releaseNotes && (
|
||||
<Text type="secondary" className="text-xs whitespace-pre-line">
|
||||
{info.releaseNotes}
|
||||
</Text>
|
||||
<div className="flex items-start gap-2">
|
||||
<Text
|
||||
type="secondary"
|
||||
className="text-xs whitespace-pre-line line-clamp-2 flex-1"
|
||||
>
|
||||
{info.releaseNotes
|
||||
.replace(/^## .+?\n\n?/, '')
|
||||
.replace(/\n---\n?[\s\S]*$/, '')
|
||||
.trim()}
|
||||
</Text>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => setDetailOpen(true)}
|
||||
className="!px-1! flex-shrink-0"
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={downloadUpdate}
|
||||
className="self-start"
|
||||
>
|
||||
下载更新
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- 下载进度 ---- */}
|
||||
{/* ---- 下载中 ---- */}
|
||||
{status === 'downloading' && (
|
||||
<Progress
|
||||
percent={progress.percent}
|
||||
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
|
||||
size="small"
|
||||
/>
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<DownloadOutlined style={{ color: 'var(--color-primary, #4F46E5)' }} />
|
||||
<Text>正在下载 v{info?.version}...</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={progress.percent}
|
||||
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
|
||||
size="small"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- 下载完成 ---- */}
|
||||
@@ -106,17 +193,34 @@ export function UpdateSetting() {
|
||||
size="small"
|
||||
icon={<SyncOutlined spin={status === 'checking'} />}
|
||||
onClick={checkForUpdates}
|
||||
disabled={status === 'checking'}
|
||||
disabled={status === 'checking' || status === 'downloading'}
|
||||
>
|
||||
检查更新
|
||||
</Button>
|
||||
{isDev() && (
|
||||
<Text type="secondary" className="text-xs">
|
||||
生产环境启动 5 秒后自动检查
|
||||
生产环境启动 5 秒后自动检查
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- 更新详情弹窗 ---- */}
|
||||
<Modal
|
||||
title={`更新详情 — v${info?.version || ''}`}
|
||||
open={detailOpen}
|
||||
onCancel={() => setDetailOpen(false)}
|
||||
footer={
|
||||
<Button onClick={() => setDetailOpen(false)}>关闭</Button>
|
||||
}
|
||||
width={520}
|
||||
>
|
||||
<div className="max-h-96 overflow-y-auto py-2">
|
||||
{info?.releaseNotes
|
||||
? renderChangelog(info.releaseNotes)
|
||||
: <Text type="secondary">暂无更新详情</Text>}
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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 模块) ----------
|
||||
|
||||
147
src/utils/safe-storage.ts
Normal file
147
src/utils/safe-storage.ts
Normal file
@@ -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<string, string>();
|
||||
|
||||
// ---------- IPC 桥接 ----------
|
||||
|
||||
interface SafeStorageBridge {
|
||||
encrypt(plaintext: string): Promise<string | null>;
|
||||
decrypt(encryptedBase64: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
/** 获取 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<string | null> {
|
||||
// 先从 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<boolean> {
|
||||
// 先更新内存缓存(即使持久化失败,当前 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);
|
||||
}
|
||||
5
src/vite-env.d.ts
vendored
5
src/vite-env.d.ts
vendored
@@ -8,6 +8,11 @@ declare global {
|
||||
send(channel: string, ...args: unknown[]): void;
|
||||
invoke(channel: string, ...args: unknown[]): Promise<unknown>;
|
||||
};
|
||||
/** Electron safeStorage 安全加密 API(由 preload 注入,基于 OS 原生密钥链) */
|
||||
safeStorage: {
|
||||
encrypt(plaintext: string): Promise<string | null>;
|
||||
decrypt(encryptedBase64: string): Promise<string | null>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1
test-server/.python-version
Normal file
1
test-server/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
180
test-server/README.md
Normal file
180
test-server/README.md
Normal file
@@ -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 <UPDATE_API_KEY>
|
||||
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 白名单 + 限流
|
||||
10
test-server/pyproject.toml
Normal file
10
test-server/pyproject.toml
Normal file
@@ -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",
|
||||
]
|
||||
2
test-server/requirements.txt
Normal file
2
test-server/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
205
test-server/uv.lock
generated
Normal file
205
test-server/uv.lock
generated
Normal file
@@ -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" },
|
||||
]
|
||||
130
vite.config.ts
130
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user