Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e6c9483f | ||
|
|
63ff32817e | ||
|
|
78544af8de | ||
|
|
7d73592d41 | ||
|
|
bacdd2d82f | ||
|
|
4add295e44 | ||
|
|
88b2e8d355 | ||
|
|
fe5ad13f88 | ||
|
|
c8ce67dc75 | ||
|
|
9add23f6ed | ||
|
|
6d4b5efc95 | ||
|
|
2216720e24 | ||
|
|
36304b88e1 | ||
|
|
6ad9463e73 | ||
|
|
7644b8d4ea | ||
|
|
5e50a8fbcf |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,6 +15,8 @@ out
|
||||
# Test outputs
|
||||
coverage/
|
||||
downloads/
|
||||
release-output/
|
||||
build/bin/
|
||||
*.xlsx
|
||||
*.parsed.json
|
||||
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
# Playwright 浏览器部署指南
|
||||
|
||||
## 概述
|
||||
|
||||
本文档为开发人员提供 ERPAuto 应用程序中 Playwright Chromium 浏览器的部署指南。文档说明如何将浏览器文件从开发机复制到目标用户机器,确保应用程序能够正常运行浏览器自动化任务。
|
||||
|
||||
**重要提示**:部署前请先阅读 [BROWSER_VERSIONS.md](./BROWSER_VERSIONS.md) 了解版本信息。
|
||||
|
||||
## 目标路径
|
||||
|
||||
浏览器文件必须部署在以下路径:
|
||||
|
||||
```
|
||||
%APPDATA%\erpauto\ms-playwright\chromium-1208\
|
||||
```
|
||||
|
||||
完整展开路径示例(Windows):
|
||||
|
||||
```
|
||||
C:\Users\<用户名>\AppData\Roaming\erpauto\ms-playwright\chromium-1208\
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
部署完成后,目标目录结构应如下所示:
|
||||
|
||||
```
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
└── chromium-1208/
|
||||
└── chrome-win/
|
||||
├── chrome.exe # 浏览器可执行文件
|
||||
├── chrome_dll.dll
|
||||
├── resources/
|
||||
├── locales/
|
||||
└── ... # 其他浏览器文件
|
||||
```
|
||||
|
||||
**关键文件**:`chrome-win/chrome.exe` 必须存在,否则浏览器无法启动。
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 步骤 1:在开发机上准备
|
||||
|
||||
1. 确保开发机已安装正确版本的 Playwright:
|
||||
|
||||
```bash
|
||||
npm install playwright@1.58.2
|
||||
```
|
||||
|
||||
2. 下载 Chromium 浏览器:
|
||||
|
||||
```bash
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
3. 定位开发机上的浏览器缓存目录:
|
||||
|
||||
```
|
||||
C:\Users\<开发机用户名>\AppData\Local\ms-playwright\chromium-1208
|
||||
```
|
||||
|
||||
### 步骤 2:复制文件
|
||||
|
||||
1. **复制整个浏览器目录**
|
||||
- 将开发机上的 `chromium-1208` 目录完整复制
|
||||
- 不要只复制部分文件,确保所有子目录和文件都包含在内
|
||||
|
||||
2. **粘贴到目标路径**
|
||||
- 在目标机器上创建目录:`%APPDATA%\erpauto\ms-playwright\`
|
||||
- 将 `chromium-1208` 目录粘贴到该路径下
|
||||
|
||||
3. **验证文件完整性**
|
||||
- 确认目标路径存在:`%APPDATA%\erpauto\ms-playwright\chromium-1208\chrome-win\chrome.exe`
|
||||
- 检查文件大小约为 280MB
|
||||
|
||||
### 步骤 3:配置环境变量(可选)
|
||||
|
||||
如需确保应用程序使用正确的浏览器路径,可设置以下环境变量:
|
||||
|
||||
```batch
|
||||
set PLAYWRIGHT_BROWSERS_PATH=%APPDATA%\erpauto\ms-playwright
|
||||
```
|
||||
|
||||
或在应用程序代码中设置:
|
||||
|
||||
```javascript
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(app.getPath('userData'), 'ms-playwright')
|
||||
```
|
||||
|
||||
## 验证步骤
|
||||
|
||||
部署完成后,执行以下验证步骤:
|
||||
|
||||
### 验证 1:检查目录结构
|
||||
|
||||
在目标机器上运行:
|
||||
|
||||
```batch
|
||||
dir %APPDATA%\erpauto\ms-playwright\chromium-1208\chrome-win\chrome.exe
|
||||
```
|
||||
|
||||
应显示文件存在。
|
||||
|
||||
### 验证 2:启动浏览器测试
|
||||
|
||||
运行 ERPAuto 应用程序,执行以下操作:
|
||||
|
||||
1. 登录应用程序
|
||||
2. 进入「数据提取」页面
|
||||
3. 输入一个有效订单号
|
||||
4. 点击「开始提取」
|
||||
5. 观察浏览器是否正常启动并执行任务
|
||||
|
||||
### 验证 3:检查日志
|
||||
|
||||
查看应用程序日志,确认没有浏览器相关的错误信息:
|
||||
|
||||
- 无 "browser not found" 错误
|
||||
- 无 "chromium revision not found" 错误
|
||||
- 无 "PLAYWRIGHT_BROWSERS_PATH" 相关警告
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题 1:浏览器无法启动
|
||||
|
||||
**症状**:应用程序报错,提示找不到浏览器或启动失败。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 确认修订号匹配(必须是 1208)
|
||||
2. 检查 `chrome-win/chrome.exe` 文件是否存在
|
||||
3. 验证 `PLAYWRIGHT_BROWSERS_PATH` 环境变量设置正确
|
||||
4. 确认目标机器具有相同的 Playwright 版本(1.58.2)
|
||||
|
||||
### 问题 2:版本不匹配错误
|
||||
|
||||
**症状**:应用程序启动时报出版本冲突错误。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 检查 `package.json` 中的 Playwright 版本是否为 1.58.2
|
||||
2. 确认复制的 Chromium 修订号为 1208
|
||||
3. 参考 [BROWSER_VERSIONS.md](./BROWSER_VERSIONS.md) 核对所有版本信息
|
||||
|
||||
### 问题 3:权限不足
|
||||
|
||||
**症状**:无法写入或读取浏览器目录。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 确保目标目录具有适当的读写权限
|
||||
2. 以管理员身份运行应用程序进行测试
|
||||
3. 检查防病毒软件是否阻止了浏览器执行
|
||||
|
||||
### 问题 4:路径错误
|
||||
|
||||
**症状**:应用程序在错误的位置查找浏览器文件。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 确认 `%APPDATA%` 环境变量指向正确的用户目录
|
||||
2. 检查应用程序是否正确解析了 `userData` 路径
|
||||
3. 在代码中硬编码浏览器路径进行调试
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **仅部署 Chromium**:ERPAuto 只需要 Chromium 浏览器,不需要 Firefox 或 WebKit
|
||||
- **版本一致性**:开发机和目标机器的 Playwright 版本必须一致
|
||||
- **修订号匹配**:Chromium 修订号(1208)必须完全匹配,否则可能出现兼容性问题
|
||||
- **文件完整性**:复制时确保所有文件完整,损坏的浏览器文件会导致启动失败
|
||||
- **网络隔离环境**:目标机器如果无法访问互联网,必须提前部署浏览器文件,因为无法自动下载
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [BROWSER_VERSIONS.md](./BROWSER_VERSIONS.md) - 版本信息和目录结构详情
|
||||
- [README.md](./README.md) - 项目总体说明
|
||||
@@ -1,96 +0,0 @@
|
||||
# Playwright 浏览器版本信息
|
||||
|
||||
本文档记录 ERPAuto 项目使用的 Playwright 浏览器版本和部署信息。
|
||||
|
||||
## 版本信息
|
||||
|
||||
| 组件 | 版本号 |
|
||||
| --------------- | ------------ |
|
||||
| Playwright | 1.58.2 |
|
||||
| Chromium | 145.0.7632.6 |
|
||||
| Chromium 修订号 | 1208 |
|
||||
|
||||
## 浏览器目录结构
|
||||
|
||||
Playwright 将浏览器文件缓存在以下位置:
|
||||
|
||||
### Windows 开发环境
|
||||
|
||||
```
|
||||
C:\Users\<用户名>\AppData\Local\ms-playwright\
|
||||
└── chromium-1208/
|
||||
└── chrome-win/
|
||||
├── chrome.exe
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 目标部署环境
|
||||
|
||||
```
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
└── chromium-1208/
|
||||
└── chrome-win/
|
||||
├── chrome.exe
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 部署指南
|
||||
|
||||
### 开发环境准备
|
||||
|
||||
1. 安装 Playwright 1.58.2
|
||||
|
||||
```bash
|
||||
npm install playwright@1.58.2
|
||||
```
|
||||
|
||||
2. 下载 Chromium 浏览器
|
||||
```bash
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
### 浏览器文件复制步骤
|
||||
|
||||
1. **定位源目录**
|
||||
- 开发机上找到 Playwright 浏览器缓存目录
|
||||
- 默认路径:`C:\Users\<用户名>\AppData\Local\ms-playwright\chromium-1208`
|
||||
|
||||
2. **复制浏览器文件**
|
||||
- 将整个 `chromium-1208` 目录复制到部署目标
|
||||
- 目标路径:`%APPDATA%\erpauto\ms-playwright\chromium-1208`
|
||||
|
||||
3. **验证目录结构**
|
||||
- 确认目标路径包含 `chrome-win/chrome.exe`
|
||||
- 确保所有子文件完整复制
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
如需要自定义浏览器路径,可设置环境变量:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
set PLAYWRIGHT_BROWSERS_PATH=%APPDATA%\erpauto\ms-playwright
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 仅包含 Chromium 浏览器(Firefox 和 WebKit 不需要)
|
||||
- 浏览器文件体积约为 280MB
|
||||
- 部署时确保目标机器具有相同的 Playwright 版本(1.58.2)
|
||||
- 修订号必须匹配(1208),否则可能出现兼容性问题
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 浏览器无法启动
|
||||
|
||||
1. 检查修订号是否匹配(1208)
|
||||
2. 确认 `chrome-win/chrome.exe` 文件存在
|
||||
3. 验证 PLAYWRIGHT_BROWSERS_PATH 环境变量设置
|
||||
|
||||
### 版本不匹配错误
|
||||
|
||||
确保以下版本一致:
|
||||
|
||||
- package.json 中的 Playwright 版本
|
||||
- 下载的 Chromium 修订号
|
||||
- browsers.json 中定义版本号
|
||||
214
CLAUDE.md
214
CLAUDE.md
@@ -1,141 +1,165 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
本文件用于给 AI 编程代理提供本项目的最小必要指导。
|
||||
目标不是替代 `README` 或 `docs/`,而是帮助代理快速理解项目结构、工作方式和关键约束。
|
||||
|
||||
## Development Commands
|
||||
## 项目概览
|
||||
|
||||
### Running the Application
|
||||
ERPAuto 是一个基于 Electron 的桌面应用,用于自动化处理 ERP 系统中的数据提取、清理、校验和配置管理。
|
||||
|
||||
技术栈:
|
||||
|
||||
- Electron 39
|
||||
- React 19
|
||||
- TypeScript 5.9
|
||||
- electron-vite / Vite 7
|
||||
- Playwright 1.58
|
||||
- Vitest
|
||||
|
||||
## 常用命令
|
||||
|
||||
开发与构建:
|
||||
|
||||
```bash
|
||||
npm run dev # Start development server with hot reload
|
||||
npm run build # Full build with type checking
|
||||
npm run build:win # Build Windows executable
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
质量检查:
|
||||
|
||||
```bash
|
||||
npm run lint # ESLint check
|
||||
npm run format # Prettier format
|
||||
npm run typecheck # TypeScript check (both main and renderer)
|
||||
npm run typecheck:node # TypeScript check for main process only
|
||||
npm run typecheck:web # TypeScript check for renderer only
|
||||
npm run typecheck
|
||||
npm run typecheck:node
|
||||
npm run typecheck:web
|
||||
npm run lint
|
||||
npm run format
|
||||
```
|
||||
|
||||
### Testing
|
||||
测试:
|
||||
|
||||
```bash
|
||||
npm run test # Run unit tests (Vitest)
|
||||
npm run test:coverage # Run tests with coverage report
|
||||
npm run test:e2e # Run E2E tests (Playwright)
|
||||
npm run test:e2e:ui # Run E2E tests with UI
|
||||
npm run test:e2e:report # Show E2E test report
|
||||
npm run test
|
||||
npm run test:coverage
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
发布:
|
||||
|
||||
ERPAuto is an **Electron desktop application** for automating ERP system data processing. The application follows the classic Electron architecture with three distinct processes:
|
||||
```bash
|
||||
npm run release:publish -- --channel stable
|
||||
npm run release:publish -- --channel preview
|
||||
```
|
||||
|
||||
### Process Structure
|
||||
详细发布流程见:
|
||||
[docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md)
|
||||
|
||||
1. **Main Process** (`src/main/`)
|
||||
- Node.js environment managing application lifecycle
|
||||
- Entry point: `src/main/index.ts`
|
||||
- Registers all IPC handlers via `registerIpcHandlers()`
|
||||
- Loads configuration from `config.yaml` via ConfigManager at startup
|
||||
## 代码结构
|
||||
|
||||
2. **Preload Script** (`src/preload/`)
|
||||
- Security bridge between main and renderer processes
|
||||
- Exposes type-safe APIs via `contextBridge` as `window.electron` and `window.api`
|
||||
- Central API surface organized by domain (auth, extractor, cleaner, database, etc.)
|
||||
### 主进程
|
||||
|
||||
3. **Renderer Process** (`src/renderer/`)
|
||||
- React 19 + TypeScript UI
|
||||
- Uses exposed preload APIs for all main process communication
|
||||
- Authentication-based routing with role-based access control
|
||||
- 路径:`src/main/`
|
||||
- 入口:`src/main/index.ts`
|
||||
- 职责:
|
||||
- 应用生命周期管理
|
||||
- 配置加载
|
||||
- IPC 注册
|
||||
- 更新服务初始化
|
||||
|
||||
### Service Architecture
|
||||
### 预加载层
|
||||
|
||||
The main process is organized around domain-specific services in `src/main/services/`:
|
||||
- 路径:`src/preload/`
|
||||
- 职责:
|
||||
- 暴露 `window.electron` API
|
||||
- 作为 renderer 和 main 之间的安全桥
|
||||
|
||||
- **ERP Services** (`services/erp/`): Browser automation using Playwright
|
||||
- `ExtractorService` - Downloads material plan data
|
||||
- `CleanerService` - Deletes specified materials with dry-run support
|
||||
- `ErpAuthService` - Handles ERP authentication
|
||||
- `OrderResolverService` - Validates and resolves order numbers
|
||||
- `locators.ts` - ERP element selectors
|
||||
### 渲染进程
|
||||
|
||||
- **Database Services** (`services/database/`): Dual database support
|
||||
- `MySqlService` / `mysql.ts` - MySQL operations
|
||||
- `SqlServerService` / `sql-server.ts` - SQL Server operations
|
||||
- DAO pattern: `discrete-material-plan-dao.ts`, `materials-to-be-deleted-dao.ts`
|
||||
- 路径:`src/renderer/`
|
||||
- 技术:React + TypeScript
|
||||
- 特点:
|
||||
- 通过 preload 暴露的 API 调用主进程
|
||||
- 以登录状态和角色控制主要功能入口
|
||||
|
||||
- **User Services** (`services/user/`): Authentication and session management
|
||||
- `BipUsersDao` - User data access
|
||||
- `SessionManager` - Active session tracking
|
||||
### 服务层
|
||||
|
||||
- **Other Services**:
|
||||
- `config/` - Configuration management
|
||||
- `excel/` - Excel file parsing
|
||||
主进程服务集中在 `src/main/services/`,按领域拆分:
|
||||
|
||||
### IPC Handler Pattern
|
||||
- `erp/`:ERP 浏览器自动化
|
||||
- `database/`:MySQL / SQL Server
|
||||
- `user/`:登录、会话、用户切换
|
||||
- `config/`:YAML 配置管理
|
||||
- `update/`:便携版更新
|
||||
- `excel/`:Excel 处理
|
||||
|
||||
All IPC communication follows a consistent pattern:
|
||||
## 关键事实
|
||||
|
||||
- Handlers are in `src/main/ipc/`, organized by domain (8 modules)
|
||||
- Each handler module exports a `register*Handlers()` function
|
||||
- All handlers are registered in `src/main/ipc/index.ts`
|
||||
- Channel naming follows `domain:action` convention (e.g., `extractor:run`, `auth:login`)
|
||||
### 配置系统
|
||||
|
||||
### Authentication Flow
|
||||
- 使用 YAML 配置
|
||||
- 模板文件:`config.template.yaml`
|
||||
- 开发环境通常使用项目根目录下的 `config.yaml`
|
||||
- 生产环境会将配置放到用户目录
|
||||
|
||||
The application implements a multi-stage authentication system:
|
||||
### IPC 组织方式
|
||||
|
||||
1. **Silent Login**: On startup, attempts automatic login using computer name
|
||||
2. **Fallback**: Shows login dialog if silent login fails
|
||||
3. **Admin User Selection**: Admin users can switch to other user accounts
|
||||
4. **Session Management**: Persistent sessions with role-based permissions (Admin/User/Guest)
|
||||
- 所有 IPC handler 在 `src/main/ipc/`
|
||||
- 每个领域一个 handler 模块
|
||||
- 统一在 `src/main/ipc/index.ts` 注册
|
||||
- channel 命名遵循 `domain:action`
|
||||
|
||||
Admin users see logout buttons and can access user switching. Non-admin users have restricted access based on the user who initiated their session.
|
||||
### 认证与角色
|
||||
|
||||
### Type System
|
||||
当前主要角色是:
|
||||
|
||||
- Separate TypeScript configs: `tsconfig.node.json` (main/preload) and `tsconfig.web.json` (renderer)
|
||||
- Types are co-located with features: `src/main/types/` contains domain-specific type definitions
|
||||
- The preload script exposes a typed API surface that's available in renderer
|
||||
- `Admin`
|
||||
- `User`
|
||||
- `Guest`
|
||||
|
||||
### Path Aliases
|
||||
登录流程支持:
|
||||
|
||||
- `@renderer` → `src/renderer/src` (renderer process)
|
||||
- `@main` → `src/main` (main process, tests only)
|
||||
- `@services` → `src/main/services` (main process, tests only)
|
||||
- `@types` → `src/main/types` (main process, tests only)
|
||||
- 静默登录
|
||||
- 普通登录
|
||||
- 管理员切换用户
|
||||
|
||||
## Configuration Management
|
||||
### 便携版自动更新
|
||||
|
||||
The application uses a YAML-based configuration system (`config.yaml`) managed by `ConfigManager`:
|
||||
项目已实现 Windows 便携版更新,关键点:
|
||||
|
||||
- **Development**: `config.yaml` in project root (easy to edit and version control)
|
||||
- **Production**: `config.yaml` in user data directory (AppData on Windows)
|
||||
- 更新检查基于登录用户角色
|
||||
- 支持 `stable` / `preview` 双通道
|
||||
- `User` 只看 `stable`
|
||||
- `Admin` 同时看 `stable` 和 `preview`
|
||||
- 使用原生 `portable-updater.exe` 完成替换,不依赖 PowerShell
|
||||
|
||||
Key configurations in `config.yaml`:
|
||||
相关文档:
|
||||
|
||||
- **ERP Settings**: URL (fixed infrastructure)
|
||||
- **Database**: MySQL and SQL Server connection configs (dual support)
|
||||
- **Paths**: Data directory and output file settings
|
||||
- **Extraction**: Batch size, verbosity, persistence options
|
||||
- **Validation**: Data source, batch size, match mode
|
||||
- **Order Resolution**: Database table and field names for order number lookup
|
||||
- [docs/portable-auto-update-architecture.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/portable-auto-update-architecture.md)
|
||||
- [docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md)
|
||||
|
||||
Note: ERP credentials (username/password) are stored in the database (`dbo_BIPUsers` table) per user, managed via the Settings UI.
|
||||
## 代理工作约束
|
||||
|
||||
## Key Technologies
|
||||
1. 优先修改现有文件,不要随意新建同类文件。
|
||||
2. 变更前先理解对应模块的现有模式,尽量保持风格一致。
|
||||
3. renderer 不要直接访问 Node/Electron 能力,统一走 preload。
|
||||
4. 配置、IPC、类型定义通常需要同步更新,避免只改一层。
|
||||
5. 涉及发布、更新、构建链路时,优先复用现有脚本,不要重复实现。
|
||||
6. 涉及浏览器部署或更新流程时,先看 `docs/` 里的专题文档。
|
||||
|
||||
- **Electron 39** - Desktop framework
|
||||
- **React 19** - UI framework
|
||||
- **TypeScript 5.9** - Type safety
|
||||
- **Playwright 1.58** - Browser automation for ERP interaction
|
||||
- **electron-vite + Vite 7** - Build tooling
|
||||
- **Zod** - Runtime validation
|
||||
- **Vitest** - Unit tests
|
||||
- **Playwright Test** - E2E tests
|
||||
## 代理优先查看的文档
|
||||
|
||||
- [README.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/README.md)
|
||||
- [docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md)
|
||||
- [docs/portable-auto-update-architecture.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/portable-auto-update-architecture.md)
|
||||
- [docs/browser/PLAYWRIGHT_DEPLOYMENT.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/browser/PLAYWRIGHT_DEPLOYMENT.md)
|
||||
|
||||
## 不放在这里的内容
|
||||
|
||||
以下内容不应继续堆在本文件中:
|
||||
|
||||
- 详细用户使用说明
|
||||
- 大段业务流程说明
|
||||
- 重复的架构长文
|
||||
- 版本发布记录
|
||||
|
||||
这些内容应继续放在 `README` 或 `docs/` 下的专题文档中。
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# Playwright 部署说明
|
||||
|
||||
## 浏览器路径问题修复
|
||||
|
||||
### 问题描述
|
||||
|
||||
应用启动时提示"浏览器文件未找到",但浏览器文件已经放置在正确路径下。
|
||||
|
||||
**原因**:Playwright 1.48+ 改变了浏览器目录命名规则:
|
||||
|
||||
- **旧格式**:`chromium-win32/chrome.exe`
|
||||
- **新格式**:`chromium-1208/chrome-win64/chrome.exe`(revision 号可能不同)
|
||||
|
||||
### 解决方案
|
||||
|
||||
代码已更新为自动检测新旧两种格式,并显示当前目录内容以便调试。
|
||||
|
||||
## 快速部署
|
||||
|
||||
### 方法 1:使用 Playwright CLI(推荐)
|
||||
|
||||
```bash
|
||||
# 在应用目录运行
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
浏览器会自动下载并安装到正确位置:
|
||||
|
||||
- **用户数据目录**:`%APPDATA%\erpauto\ms-playwright\`
|
||||
- **完整路径**:`C:\Users\pengq\AppData\Roaming\erpauto\ms-playwright\chromium-<revision>\chrome-win64\chrome.exe`
|
||||
|
||||
### 方法 2:手动复制
|
||||
|
||||
如果你已经有 Playwright 浏览器文件,可以复制到应用的用户数据目录:
|
||||
|
||||
1. 找到现有浏览器文件(通常在 `%USERPROFILE%\AppData\Local\ms-playwright`)
|
||||
2. 复制到 `%APPDATA%\erpauto\ms-playwright\`
|
||||
3. 确保目录结构正确:
|
||||
```
|
||||
ms-playwright/
|
||||
├── chromium-1208/
|
||||
│ ├── chrome-win64/
|
||||
│ │ └── chrome.exe
|
||||
│ └── INSTALLATION_COMPLETE
|
||||
└── chromium_headless_shell-1208/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 方法 3:使用 PLAYWRIGHT_BROWSERS_PATH 环境变量
|
||||
|
||||
将浏览器文件放在共享位置,然后设置环境变量:
|
||||
|
||||
```bash
|
||||
# 系统环境变量
|
||||
setx PLAYWRIGHT_BROWSERS_PATH "D:\shared\playwright-browsers"
|
||||
```
|
||||
|
||||
或在应用启动脚本中设置。
|
||||
|
||||
## 验证安装
|
||||
|
||||
运行应用后,检查是否还有错误提示。如果没有浏览器错误,说明安装成功。
|
||||
|
||||
你也可以在应用日志中查找:
|
||||
|
||||
- `Found Chromium revision: chromium-1208` - 表示成功找到浏览器
|
||||
- `Playwright browser not found` - 表示未找到,会显示可用目录列表
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 显示"浏览器文件未找到"但文件确实在那里
|
||||
|
||||
检查目录结构是否正确:
|
||||
|
||||
```powershell
|
||||
# 查看当前目录内容
|
||||
Get-ChildItem $env:APPDATA\erpauto\ms-playwright
|
||||
|
||||
# 检查 chrome.exe 是否存在
|
||||
Test-Path "$env:APPDATA\erpauto\ms-playwright\chromium-*/chrome-win64/chrome.exe"
|
||||
```
|
||||
|
||||
### Q: 不同用户使用同一个浏览器文件
|
||||
|
||||
使用环境变量 `PLAYWRIGHT_BROWSERS_PATH` 指向共享目录。
|
||||
|
||||
### Q: 离线部署
|
||||
|
||||
1. 在有网络的机器上运行 `npx playwright install chromium`
|
||||
2. 复制整个 `ms-playwright` 目录
|
||||
3. 在目标机器上设置 `PLAYWRIGHT_BROWSERS_PATH` 指向该目录
|
||||
|
||||
## 下次构建
|
||||
|
||||
只需运行:
|
||||
|
||||
```bash
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
所有配置已保存,Playwright 模块和浏览器路径检查会自动处理。
|
||||
247
build/PortableUpdater.cs
Normal file
247
build/PortableUpdater.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
|
||||
internal static class PortableUpdater
|
||||
{
|
||||
private static string _logPath = string.Empty;
|
||||
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = ParseArgs(args);
|
||||
|
||||
var targetExe = Require(options, "--targetExe");
|
||||
var downloadedExe = Require(options, "--downloadedExe");
|
||||
var parentPid = int.Parse(Require(options, "--parentPid"));
|
||||
_logPath = Require(options, "--logPath");
|
||||
var argsBase64 = options.ContainsKey("--argsBase64") ? options["--argsBase64"] : string.Empty;
|
||||
|
||||
WriteLog("Portable updater started");
|
||||
WriteLog("Target exe: " + targetExe);
|
||||
WriteLog("Downloaded exe: " + downloadedExe);
|
||||
WriteLog("Parent pid: " + parentPid);
|
||||
|
||||
var appArgs = DecodeArgs(argsBase64);
|
||||
|
||||
WaitForProcessExit(parentPid, 120);
|
||||
WaitForFileAvailable(targetExe, 120);
|
||||
|
||||
var backupExe = targetExe + ".bak";
|
||||
if (File.Exists(backupExe))
|
||||
{
|
||||
WriteLog("Removing stale backup: " + backupExe);
|
||||
File.Delete(backupExe);
|
||||
}
|
||||
|
||||
WriteLog("Backing up current executable");
|
||||
File.Move(targetExe, backupExe);
|
||||
|
||||
try
|
||||
{
|
||||
WriteLog("Replacing executable");
|
||||
File.Move(downloadedExe, targetExe);
|
||||
}
|
||||
catch (Exception replaceError)
|
||||
{
|
||||
WriteLog("Replace failed: " + replaceError.Message);
|
||||
if (File.Exists(backupExe) && !File.Exists(targetExe))
|
||||
{
|
||||
File.Move(backupExe, targetExe);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = targetExe,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(targetExe) ?? Environment.CurrentDirectory,
|
||||
Arguments = BuildArgumentString(appArgs)
|
||||
};
|
||||
|
||||
WriteLog("Launching updated executable");
|
||||
Process.Start(startInfo);
|
||||
|
||||
if (File.Exists(backupExe))
|
||||
{
|
||||
WriteLog("Removing backup file");
|
||||
File.Delete(backupExe);
|
||||
}
|
||||
|
||||
WriteLog("Portable update completed successfully");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteLog("Portable update failed: " + ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseArgs(string[] args)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var key = args[i];
|
||||
if (!key.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = i + 1 < args.Length ? args[i + 1] : string.Empty;
|
||||
if (value.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
result[key] = string.Empty;
|
||||
continue;
|
||||
}
|
||||
|
||||
result[key] = value;
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Require(Dictionary<string, string> options, string key)
|
||||
{
|
||||
if (!options.ContainsKey(key) || string.IsNullOrWhiteSpace(options[key]))
|
||||
{
|
||||
throw new InvalidOperationException("Missing required argument: " + key);
|
||||
}
|
||||
|
||||
return options[key];
|
||||
}
|
||||
|
||||
private static string[] DecodeArgs(string argsBase64)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(argsBase64))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var raw = Encoding.UTF8.GetString(Convert.FromBase64String(argsBase64));
|
||||
return raw.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
private static void WaitForProcessExit(int pid, int timeoutSeconds)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var process = Process.GetProcessById(pid))
|
||||
{
|
||||
if (process.HasExited)
|
||||
{
|
||||
WriteLog("Parent process exited");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
WriteLog("Parent process already exited");
|
||||
return;
|
||||
}
|
||||
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Timed out waiting for process exit: " + pid);
|
||||
}
|
||||
|
||||
private static void WaitForFileAvailable(string filePath, int timeoutSeconds)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (File.Open(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
|
||||
{
|
||||
WriteLog("Target executable is no longer locked");
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
|
||||
throw new TimeoutException("Timed out waiting for target executable to become writable: " + filePath);
|
||||
}
|
||||
|
||||
private static void WriteLog(string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_logPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_logPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.AppendAllText(
|
||||
_logPath,
|
||||
DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff") + " " + message + Environment.NewLine,
|
||||
Encoding.UTF8
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort logging only.
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildArgumentString(IEnumerable<string> args)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.Append(' ');
|
||||
}
|
||||
|
||||
builder.Append(QuoteArgument(arg));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string arg)
|
||||
{
|
||||
if (string.IsNullOrEmpty(arg))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (arg.IndexOfAny(new[] { ' ', '\t', '"' }) < 0)
|
||||
{
|
||||
return arg;
|
||||
}
|
||||
|
||||
return "\"" + arg.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
}
|
||||
@@ -70,3 +70,16 @@ rustfs:
|
||||
secretKey: '<YOUR_SECRET_KEY>' # 密钥
|
||||
bucket: 'erpauto' # 存储桶名称
|
||||
region: 'us-east-1' # 区域(S3 兼容,默认即可)
|
||||
|
||||
# 便携版自动更新配置
|
||||
update:
|
||||
enabled: false
|
||||
allowDevMode: false
|
||||
endpoint: 'http://192.168.110.114:9000'
|
||||
accessKey: '<YOUR_ACCESS_KEY>'
|
||||
secretKey: '<YOUR_SECRET_KEY>'
|
||||
bucket: 'erpauto'
|
||||
region: 'us-east-1'
|
||||
basePrefix: 'updates/win-portable'
|
||||
checkIntervalMinutes: 30
|
||||
maxAdminHistoryPerChannel: 10
|
||||
|
||||
139
docs/browser/BROWSER_VERSIONS.md
Normal file
139
docs/browser/BROWSER_VERSIONS.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Playwright 浏览器版本信息
|
||||
|
||||
本文档记录 ERPAuto 当前使用的 Playwright 版本,以及代码中采用的浏览器目录约定。
|
||||
|
||||
## 当前版本
|
||||
|
||||
根据 [package.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package.json),项目当前依赖为:
|
||||
|
||||
| 组件 | 当前版本 |
|
||||
| --- | --- |
|
||||
| `playwright` | `^1.58.2` |
|
||||
| `playwright-core` | `^1.58.2` |
|
||||
| `@playwright/test` | `^1.58.2` |
|
||||
|
||||
## 当前代码中的目录约定
|
||||
|
||||
根据 [src/main/index.ts](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/index.ts),应用启动时会把:
|
||||
|
||||
```text
|
||||
PLAYWRIGHT_BROWSERS_PATH = %APPDATA%\erpauto\ms-playwright
|
||||
```
|
||||
|
||||
随后按以下顺序检查 Chromium:
|
||||
|
||||
1. 优先检查:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\chromium-1208\chrome-win64\chrome.exe
|
||||
```
|
||||
|
||||
2. 兼容旧结构:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\chromium-win32\chrome.exe
|
||||
```
|
||||
|
||||
3. 如果仍然找不到,则继续扫描任意 `chromium-*` 目录,并尝试:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\chromium-<revision>\chrome-win64\chrome.exe
|
||||
```
|
||||
|
||||
这意味着:
|
||||
|
||||
- `chromium-1208` 是当前代码优先查找的默认 revision
|
||||
- 但应用并不只接受 `1208`
|
||||
- 当前主目录结构是 `chrome-win64`
|
||||
|
||||
## 推荐目录结构
|
||||
|
||||
### Windows 开发环境
|
||||
|
||||
```text
|
||||
C:\Users\<用户名>\AppData\Local\ms-playwright\
|
||||
└── chromium-1208/
|
||||
└── chrome-win64/
|
||||
├── chrome.exe
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 目标部署环境
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
└── chromium-1208/
|
||||
└── chrome-win64/
|
||||
├── chrome.exe
|
||||
└── ...
|
||||
```
|
||||
|
||||
如果不是 `1208`,只要目录名满足 `chromium-*` 且内部存在 `chrome-win64\chrome.exe`,当前代码也能识别。
|
||||
|
||||
## 使用建议
|
||||
|
||||
### 开发环境准备
|
||||
|
||||
1. 安装项目依赖:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. 下载 Chromium 浏览器:
|
||||
|
||||
```bash
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
### 复制浏览器文件
|
||||
|
||||
1. 在开发机上找到 Playwright 浏览器缓存目录:
|
||||
|
||||
```text
|
||||
C:\Users\<用户名>\AppData\Local\ms-playwright\
|
||||
```
|
||||
|
||||
2. 复制完整的 `chromium-*` 目录到目标路径:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
```
|
||||
|
||||
3. 确保内部存在:
|
||||
|
||||
```text
|
||||
chrome-win64\chrome.exe
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ERPAuto 当前只依赖 Chromium,不需要 Firefox 和 WebKit
|
||||
- 浏览器文件体积较大,建议按整个 revision 目录复制
|
||||
- 当前代码会优先尝试 `chromium-1208`
|
||||
- 但从实现角度看,“revision 严格等于 1208”不是唯一成功条件
|
||||
- 真正关键的是目录结构与可执行文件路径满足当前查找规则
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 浏览器无法启动
|
||||
|
||||
优先检查:
|
||||
|
||||
1. `%APPDATA%\erpauto\ms-playwright\` 是否存在
|
||||
2. 是否存在 `chromium-1208\chrome-win64\chrome.exe`
|
||||
3. 是否存在其他 `chromium-*` 目录,且包含 `chrome-win64\chrome.exe`
|
||||
4. 是否误用了过时的 `chrome-win\chrome.exe` 路径
|
||||
|
||||
### 版本不匹配或路径不匹配
|
||||
|
||||
建议同时确认:
|
||||
|
||||
- `package.json` 中的 Playwright 版本
|
||||
- 目标机器上实际部署的 Chromium 目录
|
||||
- 当前目录结构是否为 `chrome-win64\chrome.exe`
|
||||
- 应用启动时是否能在 `%APPDATA%\erpauto\ms-playwright\` 下扫描到有效 revision
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [PLAYWRIGHT_DEPLOYMENT.md](./PLAYWRIGHT_DEPLOYMENT.md)
|
||||
179
docs/browser/PLAYWRIGHT_DEPLOYMENT.md
Normal file
179
docs/browser/PLAYWRIGHT_DEPLOYMENT.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Playwright 部署说明
|
||||
|
||||
本文档聚焦“如何让 ERPAuto 在目标机器上拥有可用的 Playwright Chromium 浏览器”,适合作为实际部署操作说明。
|
||||
|
||||
如果你想看版本信息,请同时参考:
|
||||
|
||||
- [BROWSER_VERSIONS.md](./BROWSER_VERSIONS.md)
|
||||
|
||||
## 背景
|
||||
|
||||
ERPAuto 启动时会把 `PLAYWRIGHT_BROWSERS_PATH` 设置到:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright
|
||||
```
|
||||
|
||||
随后在该目录下查找 Chromium 浏览器文件。当前代码支持:
|
||||
|
||||
- `chromium-1208\chrome-win64\chrome.exe`
|
||||
- `chromium-win32\chrome.exe`
|
||||
- 任意 `chromium-*` 目录下的 `chrome-win64\chrome.exe`
|
||||
|
||||
因此,部署的本质就是把一个完整可用的 Chromium revision 目录放到这个位置。
|
||||
|
||||
当前查找顺序是:
|
||||
|
||||
1. 先查 `%APPDATA%\erpauto\ms-playwright\chromium-1208\chrome-win64\chrome.exe`
|
||||
2. 再查 `%APPDATA%\erpauto\ms-playwright\chromium-win32\chrome.exe`
|
||||
3. 最后扫描任意 `chromium-*` 目录下的 `chrome-win64\chrome.exe`
|
||||
|
||||
所以从部署角度看,真正重要的不是目录名一定等于 `1208`,而是目录结构满足当前实现的查找规则。
|
||||
|
||||
## 推荐部署方式
|
||||
|
||||
### 方式 1:使用 Playwright CLI
|
||||
|
||||
如果目标机器能联网,最简单的方式是在项目目录执行:
|
||||
|
||||
```bash
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
执行后,需要把下载得到的 `chromium-*` 目录放到:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- Playwright 默认下载目录通常是 `%LOCALAPPDATA%\ms-playwright\`
|
||||
- ERPAuto 运行时查找的是 `%APPDATA%\erpauto\ms-playwright\`
|
||||
- 所以“下载成功”不等于“应用一定能找到”,最终还是要确保文件落在 ERPAuto 使用的目录下
|
||||
|
||||
### 方式 2:手动复制
|
||||
|
||||
这是离线环境或最稳定的部署方式。
|
||||
|
||||
1. 在一台已完成 `npx playwright install chromium` 的机器上找到:
|
||||
|
||||
```text
|
||||
C:\Users\<用户名>\AppData\Local\ms-playwright\
|
||||
```
|
||||
|
||||
2. 复制完整的 `chromium-*` 目录,例如:
|
||||
|
||||
```text
|
||||
chromium-1208
|
||||
```
|
||||
|
||||
3. 将该目录复制到目标机器:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
```
|
||||
|
||||
4. 确认内部存在:
|
||||
|
||||
```text
|
||||
chrome-win64\chrome.exe
|
||||
```
|
||||
|
||||
## 推荐目录示例
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
└── chromium-1208/
|
||||
└── chrome-win64/
|
||||
├── chrome.exe
|
||||
├── chrome.dll
|
||||
├── locales/
|
||||
├── resources/
|
||||
└── ...
|
||||
```
|
||||
|
||||
如果你使用的是旧结构,也可兼容:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright\
|
||||
└── chromium-win32/
|
||||
└── chrome.exe
|
||||
```
|
||||
|
||||
## 验证方式
|
||||
|
||||
### 验证 1:检查文件
|
||||
|
||||
执行:
|
||||
|
||||
```powershell
|
||||
Get-ChildItem $env:APPDATA\erpauto\ms-playwright
|
||||
```
|
||||
|
||||
如果使用默认 revision,再执行:
|
||||
|
||||
```powershell
|
||||
Test-Path "$env:APPDATA\erpauto\ms-playwright\chromium-1208\chrome-win64\chrome.exe"
|
||||
```
|
||||
|
||||
如果你部署的是其他 revision,请按实际目录替换。
|
||||
|
||||
### 验证 2:启动应用
|
||||
|
||||
启动 ERPAuto,观察是否仍弹出“浏览器文件未找到”错误框。
|
||||
|
||||
如果没有弹窗,通常说明启动时的浏览器路径检查已经通过。
|
||||
|
||||
### 验证 3:执行真实业务
|
||||
|
||||
进入依赖 Playwright 的功能页面,执行一次真实流程,确认浏览器可以正常启动。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:文件明明存在,但应用还是报找不到
|
||||
|
||||
常见原因:
|
||||
|
||||
1. 文件放在 `%LOCALAPPDATA%\ms-playwright\`,而不是 `%APPDATA%\erpauto\ms-playwright\`
|
||||
2. 目录结构是旧文档里的 `chrome-win\chrome.exe`
|
||||
3. revision 目录名不符合 `chromium-*`
|
||||
4. 缺少 `chrome-win64\chrome.exe`
|
||||
|
||||
### 问题 2:想多个用户共用同一份浏览器文件
|
||||
|
||||
当前代码在应用启动时会直接把 `PLAYWRIGHT_BROWSERS_PATH` 设为:
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\ms-playwright
|
||||
```
|
||||
|
||||
所以默认行为是“每个用户使用自己的用户目录”。
|
||||
如果要改成共享目录,需要同时改代码,而不是只改系统环境变量。
|
||||
|
||||
### 问题 3:构建时是否会自动打包 Chromium
|
||||
|
||||
不会。
|
||||
|
||||
当前 [package.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package.json) 的 `build:win` 明确设置了:
|
||||
|
||||
```text
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
```
|
||||
|
||||
也就是说:
|
||||
|
||||
- 构建过程不会自动下载浏览器
|
||||
- 打包产物也不自带 Chromium
|
||||
- 浏览器仍需要单独部署到目标机器
|
||||
|
||||
## 结论
|
||||
|
||||
当前最稳妥的部署方式是:
|
||||
|
||||
1. 在联网机器下载 Chromium
|
||||
2. 复制完整 `chromium-*` 目录
|
||||
3. 放到 `%APPDATA%\erpauto\ms-playwright\`
|
||||
4. 确保内部有 `chrome-win64\chrome.exe`
|
||||
|
||||
只要满足这几个条件,ERPAuto 当前实现就能正确识别并使用浏览器。
|
||||
199
docs/build-and-release-guide.md
Normal file
199
docs/build-and-release-guide.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# 构建与发布流程
|
||||
|
||||
本文档说明 ERPAuto Windows 便携版的当前构建与发布方式,包括推荐的一键发布命令、分步命令,以及发布产物在对象存储中的结构。
|
||||
|
||||
## 概览
|
||||
|
||||
当前发布链路分为 3 个阶段:
|
||||
|
||||
1. 构建 Windows 包
|
||||
2. 生成本地发布物料和索引
|
||||
3. 上传到对象存储并校验远端索引
|
||||
|
||||
现在已经提供一键总控脚本:
|
||||
|
||||
```bash
|
||||
npm run release:publish -- --channel stable
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
npm run release:publish -- --channel preview
|
||||
```
|
||||
|
||||
## 发布前准备
|
||||
|
||||
发布前需要确认:
|
||||
|
||||
1. [package.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package.json) 和 [package-lock.json](/d:/FileLib/Projects/CodeMigration/ERPAuto/package-lock.json) 的版本号已经改到目标版本
|
||||
2. [config.yaml](/d:/FileLib/Projects/CodeMigration/ERPAuto/config.yaml) 中 `update` 配置正确,且对象存储可访问
|
||||
3. 对应版本的 changelog 已存在于 `docs/releases/`
|
||||
|
||||
changelog 自动查找规则如下:
|
||||
|
||||
1. 优先查找 `docs/releases/<version>-rebuild.md`
|
||||
2. 如果不存在,再查找 `docs/releases/<version>.md`
|
||||
|
||||
例如当前版本是 `1.3.6`,脚本会按顺序尝试:
|
||||
|
||||
```text
|
||||
docs/releases/1.3.6-rebuild.md
|
||||
docs/releases/1.3.6.md
|
||||
```
|
||||
|
||||
## 推荐流程:一键发布
|
||||
|
||||
### 发布 Stable
|
||||
|
||||
```bash
|
||||
npm run release:publish -- --channel stable
|
||||
```
|
||||
|
||||
### 发布 Preview
|
||||
|
||||
```bash
|
||||
npm run release:publish -- --channel preview
|
||||
```
|
||||
|
||||
这个命令会自动完成:
|
||||
|
||||
1. 读取当前 `package.json` 版本号
|
||||
2. 校验 `package.json` / `package-lock.json` 版本一致
|
||||
3. 校验 changelog 文件存在
|
||||
4. 设置 `APP_CHANNEL`
|
||||
5. 执行 `build:win`
|
||||
6. 执行 `release:prepare`
|
||||
7. 执行 `release:upload --verify`
|
||||
8. 输出本次发布摘要
|
||||
|
||||
## 分步流程
|
||||
|
||||
如果需要调试,也可以手工分步执行。
|
||||
|
||||
### 1. 构建
|
||||
|
||||
Stable:
|
||||
|
||||
```bash
|
||||
$env:APP_CHANNEL="stable"
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
Preview:
|
||||
|
||||
```bash
|
||||
$env:APP_CHANNEL="preview"
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
### 2. 生成发布物料
|
||||
|
||||
```bash
|
||||
npm run release:prepare -- --channel stable --changelog docs/releases/1.3.6.md
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
npm run release:prepare -- --channel preview --changelog docs/releases/1.3.6.md
|
||||
```
|
||||
|
||||
这一步会生成:
|
||||
|
||||
- `release-output/updates/win-portable/<channel>/artifacts/...`
|
||||
- `release-output/updates/win-portable/<channel>/changelogs/...`
|
||||
- `release-output/updates/win-portable/<channel>/index.json`
|
||||
|
||||
### 3. 上传并校验
|
||||
|
||||
```bash
|
||||
npm run release:upload -- --channel stable --verify
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
npm run release:upload -- --channel preview --verify
|
||||
```
|
||||
|
||||
## 上传策略
|
||||
|
||||
当前上传脚本默认采用“增量上传”:
|
||||
|
||||
- 上传当前版本对应的 `artifact`
|
||||
- 上传当前版本对应的 `changelog`
|
||||
- 上传最新的 `index.json`
|
||||
|
||||
也就是说,它不会再把旧版本的 exe 和旧 changelog 全部重复上传。
|
||||
|
||||
如果确实需要整条通道做一次全量同步,可以显式使用:
|
||||
|
||||
```bash
|
||||
npm run release:upload -- --channel stable --full-sync
|
||||
```
|
||||
|
||||
## 对象存储目录结构
|
||||
|
||||
发布到对象存储后的目录结构如下:
|
||||
|
||||
```text
|
||||
updates/win-portable/
|
||||
├── stable/
|
||||
│ ├── artifacts/
|
||||
│ │ └── erpauto-<version>-stable-portable.exe
|
||||
│ ├── changelogs/
|
||||
│ │ └── <version>.md
|
||||
│ └── index.json
|
||||
└── preview/
|
||||
├── artifacts/
|
||||
│ └── erpauto-<version>-preview-portable.exe
|
||||
├── changelogs/
|
||||
│ └── <version>.md
|
||||
└── index.json
|
||||
```
|
||||
|
||||
## 相关脚本
|
||||
|
||||
- [publish-release.js](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/publish-release.js)
|
||||
一键总控脚本,负责串起 build、prepare、upload。
|
||||
- [prepare-release.js](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/prepare-release.js)
|
||||
负责整理本地发布物料和生成 `index.json`。
|
||||
- [upload-release.js](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/upload-release.js)
|
||||
负责上传当前版本物料和 `index.json`,并可回读远端索引。
|
||||
- [compile-updater.js](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/compile-updater.js)
|
||||
负责构建原生 `portable-updater.exe`。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 缺少 `--channel`
|
||||
|
||||
会直接失败,因为发布必须显式指定 `stable` 或 `preview`。
|
||||
|
||||
### 2. 找不到 changelog
|
||||
|
||||
说明 `docs/releases/` 下没有当前版本对应的 Markdown 文件。
|
||||
先补 changelog,再执行发布。
|
||||
|
||||
### 3. 版本不是当前通道最新
|
||||
|
||||
总控脚本在 `prepare` 后会检查本地生成的 `index.json`。
|
||||
如果当前版本不是该通道索引的第一项,脚本会停止,避免把旧版本误当成当前发布版本。
|
||||
|
||||
### 4. 只想调试某一步
|
||||
|
||||
可以直接使用分步命令:
|
||||
|
||||
- `npm run build:win`
|
||||
- `npm run release:prepare -- ...`
|
||||
- `npm run release:upload -- ...`
|
||||
|
||||
## 建议
|
||||
|
||||
日常发布优先使用:
|
||||
|
||||
```bash
|
||||
npm run release:publish -- --channel <stable|preview>
|
||||
```
|
||||
|
||||
只有在排查问题或需要特殊处理时,再退回分步命令。
|
||||
274
docs/portable-auto-update-architecture.md
Normal file
274
docs/portable-auto-update-architecture.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# ERPAuto 便携版自动更新说明
|
||||
|
||||
## 概览
|
||||
|
||||
当前实现的是一套面向 Windows 便携版的自定义更新系统,核心特点如下:
|
||||
|
||||
- 基于 S3 兼容对象存储分发更新包
|
||||
- 按登录用户角色决定更新通道和行为
|
||||
- `User` 只跟随 `Stable`
|
||||
- `Admin` 同时可见 `Stable` 和 `Preview`
|
||||
- 更新包可后台下载,但安装必须由用户触发
|
||||
- 安装阶段使用独立的原生 `portable-updater.exe` 完成 exe 替换
|
||||
|
||||
## 核心组件
|
||||
|
||||
- 主进程更新服务
|
||||
路径:[`src/main/services/update/update-service.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/services/update/update-service.ts)
|
||||
- 更新规则工具
|
||||
路径:[`src/main/services/update/update-utils.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/services/update/update-utils.ts)
|
||||
- 更新 IPC
|
||||
路径:[`src/main/ipc/update-handler.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/ipc/update-handler.ts)
|
||||
- 前端更新弹窗
|
||||
路径:[`src/renderer/src/components/UpdateDialog.tsx`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/renderer/src/components/UpdateDialog.tsx)
|
||||
- 前端更新入口
|
||||
路径:[`src/renderer/src/App.tsx`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/renderer/src/App.tsx)
|
||||
- 原生更新器
|
||||
路径:[`build/PortableUpdater.cs`](/d:/FileLib/Projects/CodeMigration/ERPAuto/build/PortableUpdater.cs)
|
||||
- 更新器编译脚本
|
||||
路径:[`scripts/compile-updater.js`](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/compile-updater.js)
|
||||
- 发布准备脚本
|
||||
路径:[`scripts/prepare-release.js`](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/prepare-release.js)
|
||||
- 发布上传脚本
|
||||
路径:[`scripts/upload-release.js`](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/upload-release.js)
|
||||
|
||||
## 角色策略
|
||||
|
||||
### `User`
|
||||
|
||||
- 只读取 `stable/index.json`
|
||||
- 目标版本永远是最新 `Stable`
|
||||
- 如果当前客户端是 `Preview`,即使本地版本号更高,也会被视为“需要更新回稳定版”
|
||||
- 后台会自动下载推荐的 `Stable`
|
||||
- 用户点击后执行安装
|
||||
|
||||
### `Admin`
|
||||
|
||||
- 同时读取 `stable/index.json` 和 `preview/index.json`
|
||||
- 不自动下载
|
||||
- 只展示可选版本和更新说明
|
||||
- 由管理员手动选择版本并触发下载、安装
|
||||
|
||||
## 当前安装包身份
|
||||
|
||||
构建时会注入 `__APP_CHANNEL__`,用于标识当前客户端自身是 `stable` 还是 `preview`。
|
||||
|
||||
这个值的作用非常关键:
|
||||
|
||||
- 决定 `User` 是否需要从 `Preview` 洗回 `Stable`
|
||||
- 决定 `Admin` 当前处于哪条版本线
|
||||
- 决定更新弹窗中当前通道的展示
|
||||
|
||||
相关声明:
|
||||
|
||||
- [`src/shared/app-env.d.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/shared/app-env.d.ts)
|
||||
- [`src/renderer/src/env.d.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/renderer/src/env.d.ts)
|
||||
|
||||
## 远端目录结构
|
||||
|
||||
更新目录按通道分开:
|
||||
|
||||
```text
|
||||
updates/win-portable/stable/index.json
|
||||
updates/win-portable/stable/artifacts/erpauto-<version>-stable-portable.exe
|
||||
updates/win-portable/stable/changelogs/<version>.md
|
||||
|
||||
updates/win-portable/preview/index.json
|
||||
updates/win-portable/preview/artifacts/erpauto-<version>-preview-portable.exe
|
||||
updates/win-portable/preview/changelogs/<version>.md
|
||||
```
|
||||
|
||||
`index.json` 的每个条目至少包含:
|
||||
|
||||
- `version`
|
||||
- `channel`
|
||||
- `artifactKey`
|
||||
- `sha256`
|
||||
- `size`
|
||||
- `publishedAt`
|
||||
- `changelogKey`
|
||||
- `notesSummary`
|
||||
|
||||
## 总体架构图
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Electron Renderer] -->|IPC| B[Update Handler]
|
||||
B --> C[Update Service]
|
||||
C --> D[S3-Compatible Object Storage]
|
||||
C --> E[Local Cache<br/>pending-update]
|
||||
C --> F[portable-updater.exe]
|
||||
F --> G[Replace Old EXE]
|
||||
G --> H[Launch New EXE]
|
||||
```
|
||||
|
||||
## 登录后的更新时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User/Admin
|
||||
participant R as Renderer
|
||||
participant A as Auth Handler
|
||||
participant S as Update Service
|
||||
participant O as Object Storage
|
||||
|
||||
U->>R: 登录 / 无感登录
|
||||
R->>A: auth.login / auth.silentLogin
|
||||
A->>S: setUserContext(userType)
|
||||
S->>O: 读取 stable/index.json
|
||||
alt Admin
|
||||
S->>O: 读取 preview/index.json
|
||||
end
|
||||
S->>S: 计算推荐版本与状态
|
||||
alt User 且需要更新
|
||||
S->>O: 下载最新 Stable
|
||||
S->>S: 校验 sha256
|
||||
S-->>R: UPDATE_STATUS_CHANGED(downloaded)
|
||||
else Admin
|
||||
S-->>R: UPDATE_STATUS_CHANGED(available)
|
||||
end
|
||||
```
|
||||
|
||||
## `User` 更新决策图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[当前用户是 User] --> B[读取 stable 最新版本]
|
||||
B --> C{当前通道是 preview?}
|
||||
C -- 是 --> D[强制推荐回 Stable]
|
||||
C -- 否 --> E{当前版本 != 最新 Stable?}
|
||||
E -- 是 --> F[推荐最新 Stable]
|
||||
E -- 否 --> G[不提示更新]
|
||||
D --> H[后台自动下载]
|
||||
F --> H
|
||||
H --> I[校验 sha256]
|
||||
I --> J[导航栏显示 立即更新]
|
||||
```
|
||||
|
||||
## `Admin` 更新决策图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[当前用户是 Admin] --> B[读取 stable 目录]
|
||||
A --> C[读取 preview 目录]
|
||||
B --> D[合并目录]
|
||||
C --> D
|
||||
D --> E{是否存在更高版本?}
|
||||
E -- 是 --> F[推荐更高版本]
|
||||
E -- 否 --> G{是否存在跨通道可切换版本?}
|
||||
G -- 是 --> H[推荐跨通道版本]
|
||||
G -- 否 --> I[不展示更新]
|
||||
F --> J[打开弹窗后手动下载]
|
||||
H --> J
|
||||
```
|
||||
|
||||
## 安装阶段时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant R as Renderer
|
||||
participant S as Update Service
|
||||
participant P as portable-updater.exe
|
||||
participant X as Current Portable EXE
|
||||
participant N as New Downloaded EXE
|
||||
|
||||
R->>S: installDownloaded()
|
||||
S->>P: 启动 portable-updater.exe
|
||||
S->>X: app.quit()
|
||||
P->>X: 等待旧进程退出
|
||||
P->>X: 等待文件解锁
|
||||
P->>X: 备份为 .bak
|
||||
P->>N: 移动到目标路径
|
||||
P->>X: 启动新版本
|
||||
P->>X: 删除 .bak
|
||||
```
|
||||
|
||||
## 本地目录与日志
|
||||
|
||||
### 下载缓存
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\pending-update\
|
||||
```
|
||||
|
||||
### 更新器运行文件与日志
|
||||
|
||||
```text
|
||||
%APPDATA%\erpauto\updates\
|
||||
portable-updater.exe
|
||||
portable-update.log
|
||||
portable-launch.log
|
||||
```
|
||||
|
||||
## 发布流程
|
||||
|
||||
### 1. 构建
|
||||
|
||||
```powershell
|
||||
$env:APP_CHANNEL="stable"
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
### 2. 准备发布目录
|
||||
|
||||
```powershell
|
||||
node scripts/prepare-release.js --channel stable --changelog docs/releases/1.3.2-rebuild.md
|
||||
```
|
||||
|
||||
### 3. 上传到对象存储
|
||||
|
||||
```powershell
|
||||
npm run release:upload -- --channel stable --verify
|
||||
```
|
||||
|
||||
## 发布流程图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[build:win] --> B[生成 dist/erpauto-portable.exe]
|
||||
B --> C[prepare-release]
|
||||
C --> D[复制 artifact]
|
||||
C --> E[复制 changelog]
|
||||
C --> F[生成 index.json]
|
||||
F --> G[release-upload]
|
||||
G --> H[上传到对象存储]
|
||||
H --> I[verify 远端 index.json]
|
||||
```
|
||||
|
||||
## 版本排序规则
|
||||
|
||||
为了避免“后发布低版本覆盖高版本”的问题,当前排序规则是:
|
||||
|
||||
- 优先按版本号降序
|
||||
- 同版本再按 `publishedAt` 降序
|
||||
|
||||
这条规则同时存在于:
|
||||
|
||||
- [`src/main/services/update/update-utils.ts`](/d:/FileLib/Projects/CodeMigration/ERPAuto/src/main/services/update/update-utils.ts)
|
||||
- [`scripts/prepare-release.js`](/d:/FileLib/Projects/CodeMigration/ERPAuto/scripts/prepare-release.js)
|
||||
|
||||
## 失败保护
|
||||
|
||||
当前实现包含这些基本保护:
|
||||
|
||||
- 缺失 `preview/index.json` 时,按空列表处理,不中断整体更新检查
|
||||
- 更新包下载完成后必须校验 `sha256`
|
||||
- `portable-updater.exe` 会等待旧进程退出和目标文件解锁
|
||||
- 替换前先备份旧 exe 为 `.bak`
|
||||
- 替换失败时尝试回滚
|
||||
|
||||
## 当前已验证通过的能力
|
||||
|
||||
- `1.3.1 -> 1.3.2` 的 `Stable` 发布链路已打通
|
||||
- 新分支实现能够成功构建 Windows 便携版
|
||||
- 原生 `portable-updater.exe` 能成功编译并被打包带入资源目录
|
||||
- 本地发布目录生成正常
|
||||
- 上传脚本可将更新包和索引发布到对象存储
|
||||
- 客户端真实升级流程已验证通过
|
||||
|
||||
## 后续可继续优化的点
|
||||
|
||||
- 将 `UpdateService` 进一步拆分,降低文件复杂度
|
||||
- 将日志策略区分成“正式日志”和“诊断日志”
|
||||
- 增加更多针对下载与安装阶段的单测
|
||||
- 将完整构建发布链路整理为一键化脚本
|
||||
11
docs/releases/1.3.1-rebuild.md
Normal file
11
docs/releases/1.3.1-rebuild.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 1.3.1 Rebuild
|
||||
|
||||
## Highlights
|
||||
|
||||
- Rebuilt the portable auto-update flow on a clean branch.
|
||||
- Added role-aware update catalog handling for `Stable` and `Preview`.
|
||||
- Added native `portable-updater.exe` handoff for portable upgrades.
|
||||
|
||||
## Notes
|
||||
|
||||
- This release is intended for rebuild validation on the new implementation branch.
|
||||
11
docs/releases/1.3.2-rebuild.md
Normal file
11
docs/releases/1.3.2-rebuild.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 1.3.2 Rebuild
|
||||
|
||||
## Highlights
|
||||
|
||||
- Published the clean-branch portable auto-update implementation.
|
||||
- Added role-aware update checks, changelog loading, and update dialog UI.
|
||||
- Added native `portable-updater.exe` build and packaging flow.
|
||||
|
||||
## Notes
|
||||
|
||||
- This release is intended to validate `1.3.1 -> 1.3.2` upgrade flow on the rebuilt implementation.
|
||||
44
docs/releases/1.4.0.md
Normal file
44
docs/releases/1.4.0.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 1.4.0
|
||||
|
||||
## 亮点
|
||||
|
||||
- 新增 Windows 便携版自动更新能力,支持 `stable` / `preview` 双通道发布。
|
||||
- 更新策略与登录用户角色联动:
|
||||
- `User` 只接收稳定版更新
|
||||
- `Admin` 可查看并切换稳定版与预览版
|
||||
- 更新包下载完成后,可在应用内查看更新说明并执行自动替换升级。
|
||||
|
||||
## 自动更新
|
||||
|
||||
- 新增便携版更新服务,支持:
|
||||
- 登录后自动检查更新
|
||||
- 后台下载更新包
|
||||
- 展示更新状态与更新日志
|
||||
- 退出后自动替换旧版本并重启
|
||||
- 更新器采用原生 `portable-updater.exe`,不再依赖 PowerShell 脚本。
|
||||
- 支持预览版与稳定版分通道发布,并兼容普通用户从 `preview` 回退到 `stable` 的场景。
|
||||
|
||||
## 界面与交互
|
||||
|
||||
- 顶部导航新增更新入口。
|
||||
- 新增更新对话框,可展示 changelog 并执行安装。
|
||||
- 报告查看器增强了 Markdown 渲染体验,支持 GitHub 风格样式与代码高亮。
|
||||
|
||||
## 发布与维护
|
||||
|
||||
- 新增一键发布命令:
|
||||
|
||||
```bash
|
||||
npm run release:publish -- --channel stable
|
||||
npm run release:publish -- --channel preview
|
||||
```
|
||||
|
||||
- 发布脚本会自动串联构建、整理发布物料、上传和远端索引校验。
|
||||
- 上传逻辑已优化为默认增量上传,只上传当前版本的 artifact、changelog 和 `index.json`。
|
||||
- 补充了构建发布文档和自动更新架构文档,方便后续维护。
|
||||
|
||||
## 文档整理
|
||||
|
||||
- 浏览器部署文档已迁移并整理到 `docs/browser/`。
|
||||
- 新增构建与发布流程说明文档。
|
||||
- 精简了 `CLAUDE.md`,让 AI 代理指导文档更聚焦、更易维护。
|
||||
11
docs/releases/1.4.1.md
Normal file
11
docs/releases/1.4.1.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 1.4.1
|
||||
|
||||
## 改进
|
||||
|
||||
- 报告查看器的报告选择器升级为可搜索下拉框。
|
||||
- 报表较多时,可以通过输入关键字快速筛选目标报告,减少滚动查找成本。
|
||||
|
||||
## 体验优化
|
||||
|
||||
- 优化了报告选择交互,选择流程更适合长列表场景。
|
||||
- 同步合并 `dev` 分支中已完成的报告查看器可用性改进。
|
||||
@@ -2,6 +2,9 @@ appId: com.electron.app
|
||||
productName: erpauto
|
||||
directories:
|
||||
buildResources: build
|
||||
extraResources:
|
||||
- from: build/bin/portable-updater.exe
|
||||
to: portable-updater.exe
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
|
||||
@@ -18,14 +18,24 @@ const getGitHash = (): string => {
|
||||
const require = createRequire(import.meta.url)
|
||||
const version = require('./package.json').version
|
||||
const gitHash = getGitHash()
|
||||
const appChannel = process.env.APP_CHANNEL === 'preview' ? 'preview' : 'stable'
|
||||
|
||||
export default defineConfig({
|
||||
main: {},
|
||||
preload: {},
|
||||
main: {
|
||||
define: {
|
||||
__APP_CHANNEL__: JSON.stringify(appChannel)
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
define: {
|
||||
__APP_CHANNEL__: JSON.stringify(appChannel)
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
__GIT_HASH__: JSON.stringify(gitHash)
|
||||
__GIT_HASH__: JSON.stringify(gitHash),
|
||||
__APP_CHANNEL__: JSON.stringify(appChannel)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
408
package-lock.json
generated
408
package-lock.json
generated
@@ -1,22 +1,24 @@
|
||||
{
|
||||
"name": "erpauto",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "erpauto",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.929.0",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"chromium-bidi": "^15.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mssql": "^12.2.0",
|
||||
@@ -26,6 +28,9 @@
|
||||
"react-focus-lock": "^2.13.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"uuid": "^13.0.0",
|
||||
@@ -1041,7 +1046,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/core-tracing": {
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz",
|
||||
"integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==",
|
||||
"license": "MIT",
|
||||
@@ -2753,6 +2758,79 @@
|
||||
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.26.28",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
|
||||
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.2",
|
||||
"@floating-ui/utils": "^0.2.8",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
|
||||
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@headlessui/react": {
|
||||
"version": "2.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz",
|
||||
"integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.16",
|
||||
"@react-aria/focus": "^3.20.2",
|
||||
"@react-aria/interactions": "^3.25.0",
|
||||
"@tanstack/react-virtual": "^3.13.9",
|
||||
"use-sync-external-store": "^1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@@ -3132,6 +3210,103 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/focus": {
|
||||
"version": "3.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.5.tgz",
|
||||
"integrity": "sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/interactions": "^3.27.1",
|
||||
"@react-aria/utils": "^3.33.1",
|
||||
"@react-types/shared": "^3.33.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/interactions": {
|
||||
"version": "3.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.1.tgz",
|
||||
"integrity": "sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-aria/utils": "^3.33.1",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-types/shared": "^3.33.1",
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/ssr": {
|
||||
"version": "3.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
|
||||
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/utils": {
|
||||
"version": "3.33.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.1.tgz",
|
||||
"integrity": "sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.10",
|
||||
"@react-stately/flags": "^3.1.2",
|
||||
"@react-stately/utils": "^3.11.0",
|
||||
"@react-types/shared": "^3.33.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/flags": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
|
||||
"integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/utils": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz",
|
||||
"integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.33.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.1.tgz",
|
||||
"integrity": "sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||
@@ -4232,6 +4407,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
|
||||
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@szmarczak/http-timer": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
|
||||
@@ -4501,6 +4685,33 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
|
||||
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.23"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
|
||||
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tediousjs/connection-string": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.6.0.tgz",
|
||||
@@ -6653,6 +6864,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
||||
@@ -8975,6 +9195,24 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/github-markdown-css": {
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.9.0.tgz",
|
||||
"integrity": "sha512-tmT5sY+zvg2302XLYEfH2mtkViIM1SWf2nvYoF5N1ZsO0V6B2qZTiw3GOzw4vpjLygK/KG35qRlPFweHqfzz5w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/github-slugger": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
|
||||
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -9231,6 +9469,32 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-heading-rank": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
|
||||
"integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-is-element": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
|
||||
"integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-jsx-runtime": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
||||
@@ -9258,6 +9522,35 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-string": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
|
||||
"integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-text": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
|
||||
"integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"hast-util-is-element": "^3.0.0",
|
||||
"unist-util-find-after": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-whitespace": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
||||
@@ -9288,6 +9581,15 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hosted-git-info": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||
@@ -10950,6 +11252,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
|
||||
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"highlight.js": "~11.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -13235,8 +13552,8 @@
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -13423,6 +13740,58 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-autolink-headings": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz",
|
||||
"integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@ungap/structured-clone": "^1.0.0",
|
||||
"hast-util-heading-rank": "^3.0.0",
|
||||
"hast-util-is-element": "^3.0.0",
|
||||
"unified": "^11.0.0",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-highlight": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz",
|
||||
"integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hast-util-to-text": "^4.0.0",
|
||||
"lowlight": "^3.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vfile": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-slug": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
|
||||
"integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
"hast-util-heading-rank": "^3.0.0",
|
||||
"hast-util-to-string": "^3.0.0",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||
@@ -13815,7 +14184,6 @@
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
@@ -14527,6 +14895,12 @@
|
||||
"url": "https://opencollective.com/synckit"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
||||
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||
@@ -15825,6 +16199,20 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-find-after": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
|
||||
"integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"unist-util-is": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/unist-util-is": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
|
||||
@@ -16040,6 +16428,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utf8-byte-length": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
||||
|
||||
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "erpauto",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.1",
|
||||
"description": "An Electron application with React and TypeScript",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "example.com",
|
||||
@@ -15,10 +15,14 @@
|
||||
"dev": "chcp 65001 && electron-vite dev",
|
||||
"build": "chcp 65001 && npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build && electron-builder --dir",
|
||||
"build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run prebuild && npm run build && electron-builder --win",
|
||||
"build:updater": "node scripts/compile-updater.js",
|
||||
"build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build:updater && npm run build && electron-builder --dir",
|
||||
"build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run prebuild && npm run build && npm run build:updater && electron-builder --win",
|
||||
"build:mac": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --mac",
|
||||
"build:linux": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --linux",
|
||||
"release:prepare": "node scripts/prepare-release.js",
|
||||
"release:publish": "node scripts/publish-release.js",
|
||||
"release:upload": "node scripts/upload-release.js",
|
||||
"prebuild": "node -e \"const fs=require('fs');['dist','out'].forEach(d=>{try{fs.rmSync(d,{recursive:true})}catch(e){}})\"",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
@@ -34,11 +38,13 @@
|
||||
"@aws-sdk/client-s3": "^3.929.0",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"chromium-bidi": "^15.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mssql": "^12.2.0",
|
||||
@@ -48,6 +54,9 @@
|
||||
"react-focus-lock": "^2.13.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"uuid": "^13.0.0",
|
||||
|
||||
57
scripts/compile-updater.js
Normal file
57
scripts/compile-updater.js
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { spawnSync } = require('child_process')
|
||||
|
||||
const rootDir = process.cwd()
|
||||
const sourcePath = path.join(rootDir, 'build', 'PortableUpdater.cs')
|
||||
const outputDir = path.join(rootDir, 'build', 'bin')
|
||||
const outputPath = path.join(outputDir, 'portable-updater.exe')
|
||||
|
||||
function findCompiler() {
|
||||
const candidates = [
|
||||
'C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe',
|
||||
'C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'
|
||||
]
|
||||
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) || null
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.platform !== 'win32') {
|
||||
console.log('Skipping updater compilation on non-Windows platform.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
throw new Error(`Updater source not found: ${sourcePath}`)
|
||||
}
|
||||
|
||||
const compiler = findCompiler()
|
||||
if (!compiler) {
|
||||
throw new Error('Unable to find csc.exe for compiling portable-updater.exe')
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
|
||||
const compileArgs = ['/nologo', '/target:exe', '/optimize+', `/out:${outputPath}`, sourcePath]
|
||||
|
||||
const result = spawnSync(compiler, compileArgs, {
|
||||
cwd: rootDir,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`csc.exe failed with exit code ${result.status}`)
|
||||
}
|
||||
|
||||
console.log(`Portable updater compiled successfully: ${outputPath}`)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(`compile-updater failed: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
193
scripts/prepare-release.js
Normal file
193
scripts/prepare-release.js
Normal file
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Usage:
|
||||
node scripts/prepare-release.js --channel <stable|preview> --changelog <file> [options]
|
||||
|
||||
Options:
|
||||
--channel <stable|preview> Release channel. Required.
|
||||
--changelog <file> Markdown changelog file. Required.
|
||||
--artifact <file> Portable exe path. Default: dist/erpauto-portable.exe
|
||||
--version <x.y.z> Release version. Default: package.json version
|
||||
--summary <text> Optional short summary for notesSummary
|
||||
--published-at <ISO date> Optional publish time. Default: current time
|
||||
--base-prefix <prefix> Default: updates/win-portable
|
||||
--output <dir> Default: release-output
|
||||
--existing-index <file> Existing index.json to merge with
|
||||
`)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {}
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i]
|
||||
if (!token.startsWith('--')) continue
|
||||
const key = token.slice(2)
|
||||
const value = argv[i + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
args[key] = true
|
||||
continue
|
||||
}
|
||||
args[key] = value
|
||||
i += 1
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
|
||||
function sha256File(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256')
|
||||
const stream = fs.createReadStream(filePath)
|
||||
stream.on('data', (chunk) => hash.update(chunk))
|
||||
stream.on('error', reject)
|
||||
stream.on('end', () => resolve(hash.digest('hex')))
|
||||
})
|
||||
}
|
||||
|
||||
function loadExistingIndex(existingIndexPath, outputIndexPath) {
|
||||
const candidate = existingIndexPath || (fs.existsSync(outputIndexPath) ? outputIndexPath : null)
|
||||
if (!candidate) {
|
||||
return { releases: [] }
|
||||
}
|
||||
|
||||
const parsed = readJson(candidate)
|
||||
if (!parsed || !Array.isArray(parsed.releases)) {
|
||||
throw new Error(`Invalid index file: ${candidate}`)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function sortReleases(releases) {
|
||||
return [...releases].sort((left, right) => {
|
||||
const leftParts = String(left.version)
|
||||
.split('.')
|
||||
.map((part) => Number.parseInt(part, 10) || 0)
|
||||
const rightParts = String(right.version)
|
||||
.split('.')
|
||||
.map((part) => Number.parseInt(part, 10) || 0)
|
||||
|
||||
const length = Math.max(leftParts.length, rightParts.length)
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const l = leftParts[i] || 0
|
||||
const r = rightParts[i] || 0
|
||||
if (r !== l) {
|
||||
return r - l
|
||||
}
|
||||
}
|
||||
|
||||
const leftTime = new Date(left.publishedAt).getTime()
|
||||
const rightTime = new Date(right.publishedAt).getTime()
|
||||
if (rightTime !== leftTime) {
|
||||
return rightTime - leftTime
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (args.help || args.h) {
|
||||
printUsage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const packageJson = readJson(path.resolve(process.cwd(), 'package.json'))
|
||||
const version = args.version || packageJson.version
|
||||
const channel = args.channel
|
||||
const changelogPath = args.changelog
|
||||
const artifactPath = path.resolve(process.cwd(), args.artifact || 'dist/erpauto-portable.exe')
|
||||
const basePrefix = args['base-prefix'] || 'updates/win-portable'
|
||||
const outputRoot = path.resolve(process.cwd(), args.output || 'release-output')
|
||||
const publishedAt = args['published-at'] || new Date().toISOString()
|
||||
const summary = args.summary
|
||||
|
||||
assert(channel === 'stable' || channel === 'preview', 'Missing or invalid --channel')
|
||||
assert(changelogPath, 'Missing --changelog')
|
||||
assert(fs.existsSync(artifactPath), `Artifact not found: ${artifactPath}`)
|
||||
|
||||
const resolvedChangelogPath = path.resolve(process.cwd(), changelogPath)
|
||||
assert(fs.existsSync(resolvedChangelogPath), `Changelog not found: ${resolvedChangelogPath}`)
|
||||
|
||||
const artifactStat = fs.statSync(artifactPath)
|
||||
const sha256 = await sha256File(artifactPath)
|
||||
const fileName = `erpauto-${version}-${channel}-portable.exe`
|
||||
const changelogFileName = `${version}.md`
|
||||
|
||||
const channelDir = path.join(outputRoot, basePrefix, channel)
|
||||
const artifactsDir = path.join(channelDir, 'artifacts')
|
||||
const changelogDir = path.join(channelDir, 'changelogs')
|
||||
const outputIndexPath = path.join(channelDir, 'index.json')
|
||||
|
||||
ensureDir(artifactsDir)
|
||||
ensureDir(changelogDir)
|
||||
|
||||
const targetArtifactPath = path.join(artifactsDir, fileName)
|
||||
const targetChangelogPath = path.join(changelogDir, changelogFileName)
|
||||
|
||||
fs.copyFileSync(artifactPath, targetArtifactPath)
|
||||
fs.copyFileSync(resolvedChangelogPath, targetChangelogPath)
|
||||
|
||||
const releaseEntry = {
|
||||
version,
|
||||
channel,
|
||||
artifactKey: `${basePrefix}/${channel}/artifacts/${fileName}`,
|
||||
sha256,
|
||||
size: artifactStat.size,
|
||||
publishedAt,
|
||||
changelogKey: `${basePrefix}/${channel}/changelogs/${changelogFileName}`,
|
||||
...(summary ? { notesSummary: summary } : {})
|
||||
}
|
||||
|
||||
const existingIndex = loadExistingIndex(
|
||||
args['existing-index'] ? path.resolve(process.cwd(), args['existing-index']) : null,
|
||||
outputIndexPath
|
||||
)
|
||||
|
||||
const mergedReleases = sortReleases([
|
||||
releaseEntry,
|
||||
...existingIndex.releases.filter(
|
||||
(item) => !(item.version === version && item.channel === channel)
|
||||
)
|
||||
])
|
||||
|
||||
fs.writeFileSync(
|
||||
outputIndexPath,
|
||||
`${JSON.stringify({ releases: mergedReleases }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
console.log('Release package prepared successfully.')
|
||||
console.log(`Channel: ${channel}`)
|
||||
console.log(`Version: ${version}`)
|
||||
console.log(`Artifact: ${targetArtifactPath}`)
|
||||
console.log(`Changelog: ${targetChangelogPath}`)
|
||||
console.log(`Index: ${outputIndexPath}`)
|
||||
console.log(`SHA256: ${sha256}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`prepare-release failed: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
229
scripts/publish-release.js
Normal file
229
scripts/publish-release.js
Normal file
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const yaml = require('js-yaml')
|
||||
const { spawnSync } = require('child_process')
|
||||
|
||||
function usage() {
|
||||
console.log(`
|
||||
Usage:
|
||||
node scripts/publish-release.js --channel <stable|preview> [options]
|
||||
|
||||
Options:
|
||||
--channel <stable|preview> Release channel. Required.
|
||||
--changelog <file> Optional changelog file. Default: auto-resolve from version
|
||||
--config <file> Config file path. Default: config.yaml
|
||||
--help Show this help message
|
||||
`)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {}
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i]
|
||||
if (!token.startsWith('--')) continue
|
||||
const key = token.slice(2)
|
||||
const value = argv[i + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
args[key] = true
|
||||
continue
|
||||
}
|
||||
args[key] = value
|
||||
i += 1
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
function readYaml(filePath) {
|
||||
return yaml.load(fs.readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
function resolveChangelogPath(version, explicitPath) {
|
||||
if (explicitPath) {
|
||||
return path.resolve(process.cwd(), explicitPath)
|
||||
}
|
||||
|
||||
const rebuildPath = path.resolve(process.cwd(), 'docs', 'releases', `${version}-rebuild.md`)
|
||||
if (fs.existsSync(rebuildPath)) {
|
||||
return rebuildPath
|
||||
}
|
||||
|
||||
return path.resolve(process.cwd(), 'docs', 'releases', `${version}.md`)
|
||||
}
|
||||
|
||||
function validateUpdateConfig(configPath) {
|
||||
assert(fs.existsSync(configPath), `Config file not found: ${configPath}`)
|
||||
|
||||
const parsed = readYaml(configPath)
|
||||
const update = parsed && parsed.update
|
||||
|
||||
assert(update, 'Missing update config in config file')
|
||||
assert(update.enabled, 'update.enabled is false')
|
||||
assert(update.endpoint, 'update.endpoint is required')
|
||||
assert(update.accessKey, 'update.accessKey is required')
|
||||
assert(update.secretKey, 'update.secretKey is required')
|
||||
assert(update.bucket, 'update.bucket is required')
|
||||
assert(update.basePrefix, 'update.basePrefix is required')
|
||||
|
||||
return update
|
||||
}
|
||||
|
||||
function hasConflictMarker(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
return (
|
||||
content.includes('<<<<<<<') || content.includes('=======') || content.includes('>>>>>>>')
|
||||
)
|
||||
}
|
||||
|
||||
function validateVersionFiles(version) {
|
||||
const packageJsonPath = path.resolve(process.cwd(), 'package.json')
|
||||
const packageLockPath = path.resolve(process.cwd(), 'package-lock.json')
|
||||
|
||||
assert(fs.existsSync(packageLockPath), `package-lock.json not found: ${packageLockPath}`)
|
||||
assert(!hasConflictMarker(packageJsonPath), 'package.json contains merge conflict markers')
|
||||
assert(!hasConflictMarker(packageLockPath), 'package-lock.json contains merge conflict markers')
|
||||
|
||||
const packageLock = readJson(packageLockPath)
|
||||
assert(packageLock.version === version, 'package-lock.json version does not match package.json')
|
||||
|
||||
const rootPackage = packageLock.packages && packageLock.packages['']
|
||||
if (rootPackage && rootPackage.version) {
|
||||
assert(rootPackage.version === version, 'package-lock root package version does not match package.json')
|
||||
}
|
||||
}
|
||||
|
||||
function runStep(name, command, args, envOverrides = {}) {
|
||||
console.log('')
|
||||
console.log(`==> ${name}`)
|
||||
console.log(`$ ${command} ${args.join(' ')}`)
|
||||
|
||||
let result
|
||||
if (process.platform === 'win32') {
|
||||
const shellCommand = [command, ...args]
|
||||
.map((arg) => (/\s|"/.test(arg) ? `"${String(arg).replace(/"/g, '\\"')}"` : arg))
|
||||
.join(' ')
|
||||
|
||||
result = spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', shellCommand], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...envOverrides },
|
||||
stdio: 'inherit'
|
||||
})
|
||||
} else {
|
||||
result = spawnSync(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...envOverrides },
|
||||
stdio: 'inherit'
|
||||
})
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${name} failed with exit code ${result.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
function readPreparedIndex(channel, basePrefix) {
|
||||
const indexPath = path.resolve(
|
||||
process.cwd(),
|
||||
'release-output',
|
||||
...basePrefix.split('/'),
|
||||
channel,
|
||||
'index.json'
|
||||
)
|
||||
|
||||
assert(fs.existsSync(indexPath), `Prepared index not found: ${indexPath}`)
|
||||
const parsed = readJson(indexPath)
|
||||
assert(parsed && Array.isArray(parsed.releases), `Invalid prepared index: ${indexPath}`)
|
||||
return { indexPath, parsed }
|
||||
}
|
||||
|
||||
function summarizeRelease(version, channel, releaseEntry, indexPath) {
|
||||
console.log('')
|
||||
console.log('Release published successfully.')
|
||||
console.log(`Version: ${version}`)
|
||||
console.log(`Channel: ${channel}`)
|
||||
console.log(`Artifact: ${releaseEntry.artifactKey}`)
|
||||
console.log(`SHA256: ${releaseEntry.sha256}`)
|
||||
console.log(`Changelog: ${releaseEntry.changelogKey}`)
|
||||
console.log(`Prepared Index: ${indexPath}`)
|
||||
console.log(`Published At: ${releaseEntry.publishedAt}`)
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (args.help || args.h) {
|
||||
usage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const channel = args.channel
|
||||
assert(channel === 'stable' || channel === 'preview', 'Missing or invalid --channel')
|
||||
|
||||
const packageJsonPath = path.resolve(process.cwd(), 'package.json')
|
||||
assert(fs.existsSync(packageJsonPath), `package.json not found: ${packageJsonPath}`)
|
||||
const packageJson = readJson(packageJsonPath)
|
||||
const version = packageJson.version
|
||||
assert(version, 'package.json version is required')
|
||||
|
||||
const configPath = path.resolve(process.cwd(), args.config || 'config.yaml')
|
||||
const updateConfig = validateUpdateConfig(configPath)
|
||||
validateVersionFiles(version)
|
||||
|
||||
const changelogPath = resolveChangelogPath(version, args.changelog)
|
||||
assert(fs.existsSync(changelogPath), `Changelog not found: ${changelogPath}`)
|
||||
|
||||
runStep('Build Windows package', 'npm', ['run', 'build:win'], {
|
||||
APP_CHANNEL: channel
|
||||
})
|
||||
|
||||
runStep('Prepare release package', 'npm', [
|
||||
'run',
|
||||
'release:prepare',
|
||||
'--',
|
||||
'--channel',
|
||||
channel,
|
||||
'--changelog',
|
||||
changelogPath
|
||||
])
|
||||
|
||||
const { indexPath, parsed } = readPreparedIndex(channel, updateConfig.basePrefix)
|
||||
const latestRelease = parsed.releases[0]
|
||||
assert(latestRelease, 'Prepared index does not contain any release entry')
|
||||
assert(
|
||||
latestRelease.version === version && latestRelease.channel === channel,
|
||||
`Prepared index latest entry mismatch: expected ${version}/${channel}, got ${latestRelease.version}/${latestRelease.channel}`
|
||||
)
|
||||
|
||||
runStep('Upload release package', 'npm', [
|
||||
'run',
|
||||
'release:upload',
|
||||
'--',
|
||||
'--channel',
|
||||
channel,
|
||||
'--verify'
|
||||
])
|
||||
|
||||
summarizeRelease(version, channel, latestRelease, indexPath)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(`publish-release failed: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
217
scripts/upload-release.js
Normal file
217
scripts/upload-release.js
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const yaml = require('js-yaml')
|
||||
const { GetObjectCommand, PutObjectCommand, S3Client } = require('@aws-sdk/client-s3')
|
||||
|
||||
function usage() {
|
||||
console.log(`
|
||||
Usage:
|
||||
node scripts/upload-release.js --channel <stable|preview> [options]
|
||||
|
||||
Options:
|
||||
--channel <stable|preview> Release channel. Required.
|
||||
--source <dir> Local release root. Default: release-output
|
||||
--config <file> Config file path. Default: config.yaml
|
||||
--version <x.y.z> Release version. Default: package.json version
|
||||
--full-sync Upload the whole channel directory instead of current release only
|
||||
--verify Read back remote index.json after upload
|
||||
`)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {}
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i]
|
||||
if (!token.startsWith('--')) continue
|
||||
const key = token.slice(2)
|
||||
const value = argv[i + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
args[key] = true
|
||||
continue
|
||||
}
|
||||
args[key] = value
|
||||
i += 1
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
function loadConfig(configPath) {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8')
|
||||
const parsed = yaml.load(raw)
|
||||
const update = parsed && parsed.update
|
||||
|
||||
assert(update, 'Missing update config in config.yaml')
|
||||
assert(update.enabled, 'update.enabled is false')
|
||||
assert(update.endpoint, 'update.endpoint is required')
|
||||
assert(update.accessKey, 'update.accessKey is required')
|
||||
assert(update.secretKey, 'update.secretKey is required')
|
||||
assert(update.bucket, 'update.bucket is required')
|
||||
|
||||
return update
|
||||
}
|
||||
|
||||
function getContentType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
if (ext === '.json') return 'application/json; charset=utf-8'
|
||||
if (ext === '.md') return 'text/markdown; charset=utf-8'
|
||||
if (ext === '.exe') return 'application/vnd.microsoft.portable-executable'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
async function readRemoteText(client, bucket, key) {
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key
|
||||
})
|
||||
)
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of response.Body) {
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf-8')
|
||||
}
|
||||
|
||||
function buildFileDescriptor(sourceRoot, absolutePath) {
|
||||
return {
|
||||
absolutePath,
|
||||
relativeKey: path.relative(sourceRoot, absolutePath).replace(/\\/g, '/')
|
||||
}
|
||||
}
|
||||
|
||||
function collectUploadFiles(sourceRoot, channelRoot, channel, version, fullSync, basePrefix) {
|
||||
const indexPath = path.join(channelRoot, 'index.json')
|
||||
assert(fs.existsSync(indexPath), `Index not found: ${indexPath}`)
|
||||
|
||||
if (fullSync) {
|
||||
const results = []
|
||||
const entries = fs.readdirSync(channelRoot, { withFileTypes: true })
|
||||
|
||||
function walk(dirPath) {
|
||||
const dirEntries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
for (const entry of dirEntries) {
|
||||
const fullPath = path.join(dirPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath)
|
||||
} else {
|
||||
results.push(buildFileDescriptor(sourceRoot, fullPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(channelRoot, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath)
|
||||
} else {
|
||||
results.push(buildFileDescriptor(sourceRoot, fullPath))
|
||||
}
|
||||
}
|
||||
|
||||
return { files: results, indexKey: `${basePrefix}/${channel}/index.json` }
|
||||
}
|
||||
|
||||
const parsedIndex = readJson(indexPath)
|
||||
assert(parsedIndex && Array.isArray(parsedIndex.releases), `Invalid index file: ${indexPath}`)
|
||||
|
||||
const releaseEntry = parsedIndex.releases.find(
|
||||
(release) => release.version === version && release.channel === channel
|
||||
)
|
||||
assert(releaseEntry, `Release entry not found in index for ${channel}/${version}`)
|
||||
|
||||
const artifactPath = path.resolve(sourceRoot, releaseEntry.artifactKey)
|
||||
const changelogPath = path.resolve(sourceRoot, releaseEntry.changelogKey)
|
||||
|
||||
assert(fs.existsSync(artifactPath), `Artifact not found: ${artifactPath}`)
|
||||
assert(fs.existsSync(changelogPath), `Changelog not found: ${changelogPath}`)
|
||||
|
||||
return {
|
||||
files: [
|
||||
buildFileDescriptor(sourceRoot, artifactPath),
|
||||
buildFileDescriptor(sourceRoot, changelogPath),
|
||||
buildFileDescriptor(sourceRoot, indexPath)
|
||||
],
|
||||
indexKey: `${basePrefix}/${channel}/index.json`
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (args.help || args.h) {
|
||||
usage()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const channel = args.channel
|
||||
assert(channel === 'stable' || channel === 'preview', 'Missing or invalid --channel')
|
||||
|
||||
const sourceRoot = path.resolve(process.cwd(), args.source || 'release-output')
|
||||
const configPath = path.resolve(process.cwd(), args.config || 'config.yaml')
|
||||
const updateConfig = loadConfig(configPath)
|
||||
const packageJson = readJson(path.resolve(process.cwd(), 'package.json'))
|
||||
const version = args.version || packageJson.version
|
||||
assert(version, 'Missing release version')
|
||||
|
||||
const channelRoot = path.join(sourceRoot, updateConfig.basePrefix, channel)
|
||||
assert(fs.existsSync(channelRoot), `Upload root not found: ${channelRoot}`)
|
||||
|
||||
const client = new S3Client({
|
||||
region: updateConfig.region || 'us-east-1',
|
||||
endpoint: updateConfig.endpoint,
|
||||
credentials: {
|
||||
accessKeyId: updateConfig.accessKey,
|
||||
secretAccessKey: updateConfig.secretKey
|
||||
},
|
||||
forcePathStyle: true
|
||||
})
|
||||
|
||||
const { files, indexKey } = collectUploadFiles(
|
||||
sourceRoot,
|
||||
channelRoot,
|
||||
channel,
|
||||
version,
|
||||
Boolean(args['full-sync']),
|
||||
updateConfig.basePrefix
|
||||
)
|
||||
assert(files.length > 0, `No files found under ${channelRoot}`)
|
||||
|
||||
for (const file of files) {
|
||||
const body = fs.readFileSync(file.absolutePath)
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: updateConfig.bucket,
|
||||
Key: file.relativeKey,
|
||||
Body: body,
|
||||
ContentType: getContentType(file.absolutePath)
|
||||
})
|
||||
)
|
||||
|
||||
console.log(`Uploaded: ${file.relativeKey}`)
|
||||
}
|
||||
|
||||
if (args.verify) {
|
||||
const remoteIndex = await readRemoteText(client, updateConfig.bucket, indexKey)
|
||||
console.log('')
|
||||
console.log(`Verified remote index: ${indexKey}`)
|
||||
console.log(remoteIndex)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`upload-release failed: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { logAudit } from './services/logger/audit-logger'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
import fs from 'fs'
|
||||
import { UpdateService } from './services/update/update-service'
|
||||
|
||||
// Set Playwright browsers path BEFORE any playwright import
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = join(app.getPath('userData'), 'ms-playwright')
|
||||
@@ -110,6 +111,7 @@ app.whenReady().then(async () => {
|
||||
try {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
await configManager.initialize()
|
||||
UpdateService.getInstance().initialize()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize ConfigManager:', error)
|
||||
// Continue anyway - default config will be created
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { UserInfo } from '../types/user.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { UpdateService } from '../services/update/update-service'
|
||||
|
||||
const log = createLogger('AuthHandler')
|
||||
|
||||
@@ -70,6 +71,7 @@ export interface CurrentUserResponse {
|
||||
*/
|
||||
export function registerAuthHandlers(): void {
|
||||
const sessionManager = SessionManager.getInstance()
|
||||
const updateService = UpdateService.getInstance()
|
||||
|
||||
/**
|
||||
* Get computer name
|
||||
@@ -93,6 +95,8 @@ export function registerAuthHandlers(): void {
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
|
||||
if (success && userInfo) {
|
||||
await updateService.setUserContext(userInfo.userType)
|
||||
|
||||
// Check if admin needs user selection
|
||||
const requiresUserSelection = userInfo.userType === 'Admin'
|
||||
|
||||
@@ -119,6 +123,7 @@ export function registerAuthHandlers(): void {
|
||||
}
|
||||
}
|
||||
|
||||
await updateService.setUserContext(null)
|
||||
throw new ValidationError('无感登录失败:未找到匹配用户', 'VAL_INVALID_INPUT')
|
||||
}, 'auth:silentLogin')
|
||||
}
|
||||
@@ -144,6 +149,7 @@ export function registerAuthHandlers(): void {
|
||||
|
||||
if (success && userInfo) {
|
||||
log.info('Login successful', { username, userType: userInfo.userType })
|
||||
await updateService.setUserContext(userInfo.userType)
|
||||
|
||||
// Audit log: LOGIN success (non-blocking)
|
||||
const os = await import('os')
|
||||
@@ -172,6 +178,7 @@ export function registerAuthHandlers(): void {
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
|
||||
log.warn('Login failed - invalid credentials', { username })
|
||||
await updateService.setUserContext(null)
|
||||
throw new ValidationError('用户名或密码错误', 'VAL_INVALID_INPUT')
|
||||
}, 'auth:login')
|
||||
}
|
||||
@@ -198,6 +205,7 @@ export function registerAuthHandlers(): void {
|
||||
}
|
||||
|
||||
sessionManager.logout()
|
||||
await updateService.setUserContext(null)
|
||||
}, 'auth:logout')
|
||||
})
|
||||
|
||||
@@ -241,6 +249,7 @@ export function registerAuthHandlers(): void {
|
||||
if (success) {
|
||||
const newUser = sessionManager.getUserInfo()
|
||||
log.info('User switch successful', { newUsername: newUser?.username })
|
||||
await updateService.setUserContext(newUser?.userType ?? null)
|
||||
return {
|
||||
success: true,
|
||||
userInfo: newUser ?? undefined
|
||||
|
||||
@@ -15,6 +15,7 @@ import { registerMaterialTypeHandlers } from './material-type-handler'
|
||||
import { registerUserErpConfigHandlers } from './user-erp-config-handler'
|
||||
import { registerLoggerHandlers } from './logger-handler'
|
||||
import { registerReportHandlers } from './report-handler'
|
||||
import { registerUpdateHandlers } from './update-handler'
|
||||
import { createLogger, logError } from '../services/logger'
|
||||
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||
@@ -105,5 +106,6 @@ export function registerIpcHandlers(): void {
|
||||
registerUserErpConfigHandlers()
|
||||
registerLoggerHandlers()
|
||||
registerReportHandlers()
|
||||
registerUpdateHandlers()
|
||||
log.info('All IPC handlers registered')
|
||||
}
|
||||
|
||||
55
src/main/ipc/update-handler.ts
Normal file
55
src/main/ipc/update-handler.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { UpdateService } from '../services/update/update-service'
|
||||
import type {
|
||||
DownloadReleaseRequest,
|
||||
UpdateDialogCatalog,
|
||||
UpdateStatus
|
||||
} from '../types/update.types'
|
||||
|
||||
export function registerUpdateHandlers(): void {
|
||||
const updateService = UpdateService.getInstance()
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_GET_STATUS, async (): Promise<IpcResult<UpdateStatus>> => {
|
||||
return withErrorHandling(async () => updateService.getStatus(), 'update:getStatus')
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_CHECK_NOW, async (): Promise<IpcResult<UpdateStatus>> => {
|
||||
return withErrorHandling(async () => updateService.checkForUpdates(), 'update:checkNow')
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.UPDATE_GET_CATALOG,
|
||||
async (): Promise<IpcResult<UpdateDialogCatalog>> => {
|
||||
return withErrorHandling(async () => updateService.getCatalog(), 'update:getCatalog')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.UPDATE_GET_CHANGELOG,
|
||||
async (_event, request: DownloadReleaseRequest): Promise<IpcResult<string>> => {
|
||||
return withErrorHandling(
|
||||
async () => updateService.getChangelog(request),
|
||||
'update:getChangelog'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.UPDATE_DOWNLOAD_RELEASE,
|
||||
async (_event, request: DownloadReleaseRequest): Promise<IpcResult<UpdateStatus>> => {
|
||||
return withErrorHandling(
|
||||
async () => updateService.downloadRelease(request),
|
||||
'update:downloadRelease'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_INSTALL_DOWNLOADED, async (): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(
|
||||
async () => updateService.installDownloadedRelease(),
|
||||
'update:installDownloaded'
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -102,6 +102,18 @@ const DEFAULT_CONFIG: FullConfig = {
|
||||
secretKey: '',
|
||||
bucket: 'erpauto',
|
||||
region: 'us-east-1'
|
||||
},
|
||||
update: {
|
||||
enabled: false,
|
||||
allowDevMode: false,
|
||||
endpoint: '',
|
||||
accessKey: '',
|
||||
secretKey: '',
|
||||
bucket: '',
|
||||
region: 'us-east-1',
|
||||
basePrefix: 'updates/win-portable',
|
||||
checkIntervalMinutes: 30,
|
||||
maxAdminHistoryPerChannel: 10
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
650
src/main/services/update/update-service.ts
Normal file
650
src/main/services/update/update-service.ts
Normal file
@@ -0,0 +1,650 @@
|
||||
import { BrowserWindow, app } from 'electron'
|
||||
import { createHash } from 'crypto'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { spawn } from 'child_process'
|
||||
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import { ConfigManager } from '../config/config-manager'
|
||||
import { createLogger } from '../logger'
|
||||
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
|
||||
import type { UpdateConfig } from '../../types/config.schema'
|
||||
import type { UserType } from '../../types/user.types'
|
||||
import type {
|
||||
DownloadReleaseRequest,
|
||||
DownloadedRelease,
|
||||
ReleaseChannel,
|
||||
UpdateCatalog,
|
||||
UpdateDialogCatalog,
|
||||
UpdateRelease,
|
||||
UpdateStatus
|
||||
} from '../../types/update.types'
|
||||
import {
|
||||
compareVersions,
|
||||
limitCatalogHistory,
|
||||
normalizeReleases,
|
||||
resolveAdminDecision,
|
||||
resolveUserDecision
|
||||
} from './update-utils'
|
||||
|
||||
const log = createLogger('UpdateService')
|
||||
|
||||
function appendPortableLaunchLog(logPath: string, message: string, meta?: Record<string, unknown>): void {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true })
|
||||
const timestamp = new Date().toISOString()
|
||||
const suffix = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
fs.appendFileSync(logPath, `${timestamp} ${message}${suffix}\n`, 'utf-8')
|
||||
} catch {
|
||||
// Best-effort debug log only.
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentAppVersion(): string {
|
||||
return typeof app.getVersion === 'function' ? app.getVersion() : '0.0.0'
|
||||
}
|
||||
|
||||
function getCurrentChannel(): ReleaseChannel {
|
||||
return typeof __APP_CHANNEL__ !== 'undefined' ? __APP_CHANNEL__ : 'stable'
|
||||
}
|
||||
|
||||
function isMissingObjectError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = error as {
|
||||
name?: string
|
||||
Code?: string
|
||||
code?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
return (
|
||||
candidate.name === 'NoSuchKey' ||
|
||||
candidate.Code === 'NoSuchKey' ||
|
||||
candidate.code === 'NoSuchKey' ||
|
||||
candidate.message?.includes('The specified key does not exist') === true
|
||||
)
|
||||
}
|
||||
|
||||
function getSupportState(config: UpdateConfig | null): {
|
||||
supported: boolean
|
||||
reason?: string
|
||||
} {
|
||||
if (process.platform !== 'win32') {
|
||||
return { supported: false, reason: '当前仅支持 Windows 自动更新' }
|
||||
}
|
||||
|
||||
if (app.isPackaged) {
|
||||
return { supported: true }
|
||||
}
|
||||
|
||||
if (config?.allowDevMode) {
|
||||
return { supported: true, reason: '开发模式调试已启用更新检查' }
|
||||
}
|
||||
|
||||
return { supported: false, reason: '开发模式默认禁用自动更新检查' }
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS: UpdateStatus = {
|
||||
enabled: false,
|
||||
supported: false,
|
||||
phase: 'idle',
|
||||
currentVersion: getCurrentAppVersion(),
|
||||
currentChannel: getCurrentChannel(),
|
||||
currentUserType: null
|
||||
}
|
||||
|
||||
class UpdateStorageClient {
|
||||
private client: S3Client
|
||||
private bucket: string
|
||||
|
||||
constructor(config: UpdateConfig) {
|
||||
this.bucket = config.bucket
|
||||
this.client = new S3Client({
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKey,
|
||||
secretAccessKey: config.secretKey
|
||||
},
|
||||
forcePathStyle: true
|
||||
})
|
||||
}
|
||||
|
||||
async readText(key: string): Promise<string> {
|
||||
const response = await this.client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key
|
||||
})
|
||||
)
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf-8')
|
||||
}
|
||||
|
||||
async downloadToFile(key: string, destination: string): Promise<void> {
|
||||
const response = await this.client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key
|
||||
})
|
||||
)
|
||||
|
||||
await fs.promises.mkdir(path.dirname(destination), { recursive: true })
|
||||
const output = fs.createWriteStream(destination)
|
||||
const body = response.Body as NodeJS.ReadableStream
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
body.on('error', reject)
|
||||
output.on('error', reject)
|
||||
output.on('finish', resolve)
|
||||
body.pipe(output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateService {
|
||||
private static instance: UpdateService | null = null
|
||||
|
||||
private config: UpdateConfig | null = null
|
||||
private storageClient: UpdateStorageClient | null = null
|
||||
private status: UpdateStatus = { ...DEFAULT_STATUS }
|
||||
private catalog: UpdateCatalog = { stable: [], preview: [] }
|
||||
private changelogCache = new Map<string, string>()
|
||||
private intervalHandle: NodeJS.Timeout | null = null
|
||||
private initialized = false
|
||||
|
||||
public static getInstance(): UpdateService {
|
||||
if (!UpdateService.instance) {
|
||||
UpdateService.instance = new UpdateService()
|
||||
}
|
||||
|
||||
return UpdateService.instance
|
||||
}
|
||||
|
||||
public initialize(): void {
|
||||
if (this.initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
this.config = ConfigManager.getInstance().getConfig().update ?? null
|
||||
const supportState = getSupportState(this.config)
|
||||
const enabled = Boolean(this.config?.enabled && supportState.supported)
|
||||
|
||||
this.status = {
|
||||
...this.status,
|
||||
enabled,
|
||||
supported: supportState.supported,
|
||||
message: supportState.reason
|
||||
}
|
||||
|
||||
if (enabled && this.config) {
|
||||
this.storageClient = new UpdateStorageClient(this.config)
|
||||
}
|
||||
|
||||
log.info('Update service initialized', {
|
||||
enabled,
|
||||
supported: supportState.supported,
|
||||
currentVersion: this.status.currentVersion,
|
||||
currentChannel: this.status.currentChannel
|
||||
})
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
public getStatus(): UpdateStatus {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
public getCatalog(): UpdateDialogCatalog {
|
||||
const currentUserType = this.status.currentUserType
|
||||
if (!this.status.enabled || !currentUserType || currentUserType === 'Guest') {
|
||||
return { mode: 'disabled' }
|
||||
}
|
||||
|
||||
if (currentUserType === 'User') {
|
||||
return {
|
||||
mode: 'user',
|
||||
recommendedRelease: this.status.recommendedRelease
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'admin',
|
||||
recommendedRelease: this.status.recommendedRelease,
|
||||
channels: limitCatalogHistory(
|
||||
this.catalog,
|
||||
this.config?.maxAdminHistoryPerChannel ?? 10
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public async getChangelog(release: DownloadReleaseRequest): Promise<string> {
|
||||
this.ensureInitialized()
|
||||
if (!this.status.enabled || !this.storageClient) {
|
||||
throw new Error('自动更新不可用')
|
||||
}
|
||||
|
||||
const cacheKey = `${release.channel}:${release.version}`
|
||||
const cached = this.changelogCache.get(cacheKey)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const markdown = await this.storageClient.readText(release.changelogKey)
|
||||
this.changelogCache.set(cacheKey, markdown)
|
||||
return markdown
|
||||
}
|
||||
|
||||
public async setUserContext(userType: UserType | null): Promise<void> {
|
||||
this.ensureInitialized()
|
||||
this.status.currentUserType = userType
|
||||
|
||||
if (!this.status.enabled || !userType || userType === 'Guest') {
|
||||
this.clearPolling()
|
||||
this.catalog = { stable: [], preview: [] }
|
||||
this.publishStatus({
|
||||
phase: 'idle',
|
||||
currentUserType: userType,
|
||||
recommendedRelease: undefined,
|
||||
latestVersion: undefined,
|
||||
latestChannel: undefined,
|
||||
downloadedRelease: undefined,
|
||||
progress: undefined,
|
||||
adminHasAnyRelease: false,
|
||||
error: undefined,
|
||||
message: this.status.supported ? undefined : this.status.message
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await this.checkForUpdates()
|
||||
this.startPolling()
|
||||
}
|
||||
|
||||
public async checkForUpdates(): Promise<UpdateStatus> {
|
||||
this.ensureInitialized()
|
||||
if (!this.status.enabled || !this.storageClient || !this.config) {
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
this.publishStatus({
|
||||
phase: 'checking',
|
||||
error: undefined,
|
||||
message: '正在检查更新...'
|
||||
})
|
||||
|
||||
try {
|
||||
const currentUserType = this.status.currentUserType
|
||||
this.catalog = {
|
||||
stable: await this.fetchChannelIndex('stable'),
|
||||
preview: currentUserType === 'Admin' ? await this.fetchChannelIndex('preview') : []
|
||||
}
|
||||
|
||||
if (currentUserType === 'User') {
|
||||
await this.processUserCatalog()
|
||||
} else if (currentUserType === 'Admin') {
|
||||
this.processAdminCatalog()
|
||||
} else {
|
||||
this.publishStatus({
|
||||
phase: 'idle',
|
||||
message: undefined,
|
||||
error: undefined
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '检查更新失败'
|
||||
log.error('Failed to check for updates', { error: message })
|
||||
this.publishStatus({
|
||||
phase: 'error',
|
||||
error: message,
|
||||
message: '检查更新失败'
|
||||
})
|
||||
}
|
||||
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
public async downloadRelease(request: DownloadReleaseRequest): Promise<UpdateStatus> {
|
||||
this.ensureInitialized()
|
||||
if (!this.status.enabled || !this.storageClient) {
|
||||
throw new Error('自动更新不可用')
|
||||
}
|
||||
|
||||
this.publishStatus({
|
||||
phase: 'downloading',
|
||||
progress: 0,
|
||||
latestVersion: request.version,
|
||||
latestChannel: request.channel,
|
||||
recommendedRelease: request,
|
||||
message: `正在下载 ${request.channel} ${request.version}...`,
|
||||
error: undefined
|
||||
})
|
||||
|
||||
const downloadPath = this.getDownloadPath(request)
|
||||
await this.storageClient.downloadToFile(request.artifactKey, downloadPath)
|
||||
const hash = await this.calculateSha256(downloadPath)
|
||||
|
||||
if (hash.toLowerCase() !== request.sha256.toLowerCase()) {
|
||||
await fs.promises.rm(downloadPath, { force: true })
|
||||
throw new Error('更新包校验失败,文件哈希不匹配')
|
||||
}
|
||||
|
||||
this.publishStatus({
|
||||
phase: 'downloaded',
|
||||
progress: 100,
|
||||
downloadedRelease: {
|
||||
version: request.version,
|
||||
channel: request.channel,
|
||||
localPath: downloadPath
|
||||
},
|
||||
latestVersion: request.version,
|
||||
latestChannel: request.channel,
|
||||
message: `已下载 ${request.channel} ${request.version}`
|
||||
})
|
||||
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
public async installDownloadedRelease(): Promise<void> {
|
||||
this.ensureInitialized()
|
||||
|
||||
const downloaded = this.status.downloadedRelease
|
||||
if (!this.status.enabled || !downloaded) {
|
||||
throw new Error('没有可安装的更新包')
|
||||
}
|
||||
|
||||
const updaterSourcePath = this.resolveUpdaterBinaryPath()
|
||||
const updaterPath = await this.prepareUpdaterBinary(updaterSourcePath)
|
||||
const targetExe = process.env.PORTABLE_EXECUTABLE_FILE || process.execPath
|
||||
const logPath = path.join(app.getPath('userData'), 'updates', 'portable-update.log')
|
||||
const launchLogPath = path.join(app.getPath('userData'), 'updates', 'portable-launch.log')
|
||||
const appArgs = process.argv.slice(1)
|
||||
const argsBase64 =
|
||||
appArgs.length > 0 ? Buffer.from(appArgs.join('\0'), 'utf-8').toString('base64') : ''
|
||||
|
||||
const spawnArgs = [
|
||||
'--targetExe',
|
||||
targetExe,
|
||||
'--downloadedExe',
|
||||
downloaded.localPath,
|
||||
'--parentPid',
|
||||
String(process.pid),
|
||||
'--logPath',
|
||||
logPath
|
||||
]
|
||||
|
||||
if (argsBase64) {
|
||||
spawnArgs.push('--argsBase64', argsBase64)
|
||||
}
|
||||
|
||||
this.publishStatus({
|
||||
phase: 'installing',
|
||||
latestVersion: downloaded.version,
|
||||
latestChannel: downloaded.channel,
|
||||
message: `正在安装 ${downloaded.version}...`,
|
||||
error: undefined
|
||||
})
|
||||
|
||||
appendPortableLaunchLog(launchLogPath, 'Preparing portable updater launch', {
|
||||
updaterSourcePath,
|
||||
updaterPath,
|
||||
targetExe,
|
||||
downloadedExe: downloaded.localPath,
|
||||
parentPid: process.pid,
|
||||
logPath,
|
||||
appArgs,
|
||||
updaterExists: fs.existsSync(updaterPath),
|
||||
spawnArgs
|
||||
})
|
||||
|
||||
const child = spawn(updaterPath, spawnArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
appendPortableLaunchLog(launchLogPath, 'Spawn returned for portable updater', {
|
||||
childPid: child.pid ?? null
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawn error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
log.error('Failed to launch portable updater executable', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
})
|
||||
|
||||
child.once('spawn', () => {
|
||||
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawned', {
|
||||
childPid: child.pid ?? null
|
||||
})
|
||||
})
|
||||
|
||||
child.unref()
|
||||
app.quit()
|
||||
}
|
||||
|
||||
private ensureInitialized(): void {
|
||||
if (!this.initialized) {
|
||||
this.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
private publishStatus(next: Partial<UpdateStatus>): void {
|
||||
this.status = {
|
||||
...this.status,
|
||||
...next
|
||||
}
|
||||
|
||||
BrowserWindow.getAllWindows().forEach((window) => {
|
||||
window.webContents.send(IPC_CHANNELS.UPDATE_STATUS_CHANGED, this.status)
|
||||
})
|
||||
}
|
||||
|
||||
private async fetchChannelIndex(channel: ReleaseChannel): Promise<UpdateRelease[]> {
|
||||
if (!this.storageClient || !this.config) {
|
||||
return []
|
||||
}
|
||||
|
||||
const key = `${this.config.basePrefix}/${channel}/index.json`
|
||||
|
||||
try {
|
||||
const raw = await this.storageClient.readText(key)
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return normalizeReleases(parsed, channel)
|
||||
} catch (error) {
|
||||
if (isMissingObjectError(error)) {
|
||||
log.warn('Update channel index not found, treating as empty', { channel, key })
|
||||
return []
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async processUserCatalog(): Promise<void> {
|
||||
const decision = resolveUserDecision({
|
||||
currentVersion: this.status.currentVersion,
|
||||
currentChannel: this.status.currentChannel,
|
||||
catalog: this.catalog
|
||||
})
|
||||
|
||||
if (!decision.recommendedRelease || !decision.shouldOffer) {
|
||||
this.publishStatus({
|
||||
phase: 'idle',
|
||||
recommendedRelease: undefined,
|
||||
latestVersion: undefined,
|
||||
latestChannel: undefined,
|
||||
downloadedRelease: undefined,
|
||||
progress: undefined,
|
||||
adminHasAnyRelease: false,
|
||||
error: undefined,
|
||||
message: undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const recommended = decision.recommendedRelease
|
||||
this.publishStatus({
|
||||
phase: 'available',
|
||||
recommendedRelease: recommended,
|
||||
latestVersion: recommended.version,
|
||||
latestChannel: recommended.channel,
|
||||
adminHasAnyRelease: false,
|
||||
message:
|
||||
this.status.currentChannel === 'preview'
|
||||
? `当前为预览版,可切换回稳定版 ${recommended.version}`
|
||||
: `发现稳定版 ${recommended.version}`
|
||||
})
|
||||
|
||||
const existing = await this.getValidDownloadedRelease(recommended)
|
||||
if (existing) {
|
||||
this.publishStatus({
|
||||
phase: 'downloaded',
|
||||
progress: 100,
|
||||
downloadedRelease: {
|
||||
version: existing.version,
|
||||
channel: existing.channel,
|
||||
localPath: existing.localPath
|
||||
},
|
||||
message:
|
||||
this.status.currentChannel === 'preview'
|
||||
? `稳定版 ${recommended.version} 已准备安装`
|
||||
: `发现稳定版 ${recommended.version}`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.downloadRelease(recommended)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '下载更新失败'
|
||||
this.publishStatus({
|
||||
phase: 'error',
|
||||
error: message,
|
||||
message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private processAdminCatalog(): void {
|
||||
const decision = resolveAdminDecision({
|
||||
currentVersion: this.status.currentVersion,
|
||||
currentChannel: this.status.currentChannel,
|
||||
catalog: this.catalog,
|
||||
maxHistoryPerChannel: this.config?.maxAdminHistoryPerChannel ?? 10
|
||||
})
|
||||
|
||||
const hasHigherVersion = [...this.catalog.stable, ...this.catalog.preview].some(
|
||||
(release) => compareVersions(release.version, this.status.currentVersion) > 0
|
||||
)
|
||||
const hasCrossChannelOption = [...this.catalog.stable, ...this.catalog.preview].some(
|
||||
(release) => release.channel !== this.status.currentChannel
|
||||
)
|
||||
const shouldOffer = hasHigherVersion || hasCrossChannelOption
|
||||
|
||||
this.publishStatus({
|
||||
phase: shouldOffer ? 'available' : 'idle',
|
||||
recommendedRelease: decision.recommendedRelease,
|
||||
latestVersion: decision.recommendedRelease?.version,
|
||||
latestChannel: decision.recommendedRelease?.channel,
|
||||
adminHasAnyRelease: shouldOffer,
|
||||
downloadedRelease: undefined,
|
||||
progress: undefined,
|
||||
error: undefined,
|
||||
message: shouldOffer ? '发现可用版本' : undefined
|
||||
})
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
this.clearPolling()
|
||||
if (!this.config) {
|
||||
return
|
||||
}
|
||||
|
||||
this.intervalHandle = setInterval(() => {
|
||||
this.checkForUpdates().catch((error) => {
|
||||
log.warn('Periodic update check failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
})
|
||||
}, this.config.checkIntervalMinutes * 60 * 1000)
|
||||
}
|
||||
|
||||
private clearPolling(): void {
|
||||
if (this.intervalHandle) {
|
||||
clearInterval(this.intervalHandle)
|
||||
this.intervalHandle = null
|
||||
}
|
||||
}
|
||||
|
||||
private async getValidDownloadedRelease(
|
||||
release: UpdateRelease
|
||||
): Promise<DownloadedRelease | null> {
|
||||
const downloadPath = this.getDownloadPath(release)
|
||||
if (!fs.existsSync(downloadPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hash = await this.calculateSha256(downloadPath)
|
||||
if (hash.toLowerCase() !== release.sha256.toLowerCase()) {
|
||||
await fs.promises.rm(downloadPath, { force: true })
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...release,
|
||||
localPath: downloadPath
|
||||
}
|
||||
}
|
||||
|
||||
private getDownloadPath(release: UpdateRelease): string {
|
||||
return path.join(app.getPath('userData'), 'pending-update', `${release.channel}-${release.version}.exe`)
|
||||
}
|
||||
|
||||
private async calculateSha256(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
const input = fs.createReadStream(filePath)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
input.on('data', (chunk) => hash.update(chunk))
|
||||
input.on('error', reject)
|
||||
input.on('end', resolve)
|
||||
})
|
||||
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
private resolveUpdaterBinaryPath(): string {
|
||||
const packagedPath = path.join(process.resourcesPath, 'portable-updater.exe')
|
||||
const devPath = path.resolve(process.cwd(), 'build', 'bin', 'portable-updater.exe')
|
||||
|
||||
if (fs.existsSync(packagedPath)) {
|
||||
return packagedPath
|
||||
}
|
||||
|
||||
if (fs.existsSync(devPath)) {
|
||||
return devPath
|
||||
}
|
||||
|
||||
throw new Error('未找到便携版更新器')
|
||||
}
|
||||
|
||||
private async prepareUpdaterBinary(sourcePath: string): Promise<string> {
|
||||
const updatesDir = path.join(app.getPath('userData'), 'updates')
|
||||
const stagedPath = path.join(updatesDir, 'portable-updater.exe')
|
||||
|
||||
await fs.promises.mkdir(updatesDir, { recursive: true })
|
||||
await fs.promises.copyFile(sourcePath, stagedPath)
|
||||
|
||||
return stagedPath
|
||||
}
|
||||
}
|
||||
123
src/main/services/update/update-utils.ts
Normal file
123
src/main/services/update/update-utils.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
ReleaseChannel,
|
||||
UpdateCatalog,
|
||||
UpdateDialogCatalog,
|
||||
UpdateRelease
|
||||
} from '../../types/update.types'
|
||||
|
||||
export function compareVersions(left: string, right: string): number {
|
||||
const leftParts = left.split('.').map((part) => Number.parseInt(part, 10) || 0)
|
||||
const rightParts = right.split('.').map((part) => Number.parseInt(part, 10) || 0)
|
||||
const length = Math.max(leftParts.length, rightParts.length)
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftValue = leftParts[index] ?? 0
|
||||
const rightValue = rightParts[index] ?? 0
|
||||
|
||||
if (leftValue > rightValue) return 1
|
||||
if (leftValue < rightValue) return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export function normalizeReleases(input: unknown, channel: ReleaseChannel): UpdateRelease[] {
|
||||
const list = Array.isArray(input)
|
||||
? input
|
||||
: input && typeof input === 'object' && Array.isArray((input as { releases?: unknown[] }).releases)
|
||||
? (input as { releases: unknown[] }).releases
|
||||
: []
|
||||
|
||||
return list
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
version: String(item.version ?? ''),
|
||||
channel,
|
||||
artifactKey: String(item.artifactKey ?? ''),
|
||||
sha256: String(item.sha256 ?? ''),
|
||||
size: Number(item.size ?? 0),
|
||||
publishedAt: String(item.publishedAt ?? ''),
|
||||
changelogKey: String(item.changelogKey ?? ''),
|
||||
notesSummary: item.notesSummary ? String(item.notesSummary) : undefined
|
||||
}))
|
||||
.filter(
|
||||
(item) =>
|
||||
!!item.version &&
|
||||
!!item.artifactKey &&
|
||||
!!item.sha256 &&
|
||||
!!item.changelogKey &&
|
||||
!!item.publishedAt
|
||||
)
|
||||
}
|
||||
|
||||
export function sortReleases(releases: UpdateRelease[]): UpdateRelease[] {
|
||||
return [...releases].sort((left, right) => {
|
||||
const versionCompare = compareVersions(right.version, left.version)
|
||||
if (versionCompare !== 0) {
|
||||
return versionCompare
|
||||
}
|
||||
|
||||
return new Date(right.publishedAt).getTime() - new Date(left.publishedAt).getTime()
|
||||
})
|
||||
}
|
||||
|
||||
export function pickLatestRelease(releases: UpdateRelease[]): UpdateRelease | undefined {
|
||||
return sortReleases(releases)[0]
|
||||
}
|
||||
|
||||
export function limitCatalogHistory(catalog: UpdateCatalog, maxPerChannel: number): UpdateCatalog {
|
||||
return {
|
||||
stable: sortReleases(catalog.stable).slice(0, maxPerChannel),
|
||||
preview: sortReleases(catalog.preview).slice(0, maxPerChannel)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveUserDecision(params: {
|
||||
currentVersion: string
|
||||
currentChannel: ReleaseChannel
|
||||
catalog: UpdateCatalog
|
||||
}): {
|
||||
recommendedRelease?: UpdateRelease
|
||||
shouldOffer: boolean
|
||||
} {
|
||||
const latestStable = pickLatestRelease(params.catalog.stable)
|
||||
if (!latestStable) {
|
||||
return { recommendedRelease: undefined, shouldOffer: false }
|
||||
}
|
||||
|
||||
if (params.currentChannel === 'preview') {
|
||||
return { recommendedRelease: latestStable, shouldOffer: true }
|
||||
}
|
||||
|
||||
return {
|
||||
recommendedRelease: latestStable,
|
||||
shouldOffer: params.currentVersion !== latestStable.version
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAdminDecision(params: {
|
||||
currentVersion: string
|
||||
currentChannel: ReleaseChannel
|
||||
catalog: UpdateCatalog
|
||||
maxHistoryPerChannel: number
|
||||
}): UpdateDialogCatalog {
|
||||
const channels = limitCatalogHistory(params.catalog, params.maxHistoryPerChannel)
|
||||
const allReleases = sortReleases([...channels.stable, ...channels.preview])
|
||||
|
||||
const higherVersionRelease = allReleases.find(
|
||||
(release) => compareVersions(release.version, params.currentVersion) > 0
|
||||
)
|
||||
|
||||
const preferredCrossChannelRelease = allReleases.find(
|
||||
(release) => release.channel !== params.currentChannel
|
||||
)
|
||||
|
||||
return {
|
||||
mode: 'admin',
|
||||
recommendedRelease:
|
||||
higherVersionRelease ??
|
||||
preferredCrossChannelRelease ??
|
||||
pickLatestRelease(channels[params.currentChannel]),
|
||||
channels
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,22 @@ export const rustfsConfigSchema = z.object({
|
||||
region: z.string().default('us-east-1')
|
||||
})
|
||||
|
||||
/**
|
||||
* Update service configuration schema
|
||||
*/
|
||||
export const updateConfigSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
allowDevMode: z.boolean().default(false),
|
||||
endpoint: z.string().default(''),
|
||||
accessKey: z.string().default(''),
|
||||
secretKey: z.string().default(''),
|
||||
bucket: z.string().default(''),
|
||||
region: z.string().default('us-east-1'),
|
||||
basePrefix: z.string().default('updates/win-portable'),
|
||||
checkIntervalMinutes: z.number().int().min(1).max(1440).default(30),
|
||||
maxAdminHistoryPerChannel: z.number().int().min(1).max(100).default(10)
|
||||
})
|
||||
|
||||
/**
|
||||
* 完整应用配置 Schema
|
||||
*/
|
||||
@@ -155,7 +171,8 @@ export const fullConfigSchema = z.object({
|
||||
cleaner: cleanerConfigSchema,
|
||||
orderResolution: orderResolutionSchema,
|
||||
logging: loggingConfigSchema,
|
||||
rustfs: rustfsConfigSchema.optional()
|
||||
rustfs: rustfsConfigSchema.optional(),
|
||||
update: updateConfigSchema.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -168,6 +185,7 @@ export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
|
||||
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
|
||||
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
|
||||
export type RustfsConfig = z.infer<typeof rustfsConfigSchema>
|
||||
export type UpdateConfig = z.infer<typeof updateConfigSchema>
|
||||
|
||||
/**
|
||||
* 验证并解析配置
|
||||
|
||||
66
src/main/types/update.types.ts
Normal file
66
src/main/types/update.types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { UserType } from './user.types'
|
||||
|
||||
export type ReleaseChannel = 'stable' | 'preview'
|
||||
|
||||
export type UpdatePhase =
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
| 'available'
|
||||
| 'downloading'
|
||||
| 'downloaded'
|
||||
| 'installing'
|
||||
| 'error'
|
||||
|
||||
export interface UpdateRelease {
|
||||
version: string
|
||||
channel: ReleaseChannel
|
||||
artifactKey: string
|
||||
sha256: string
|
||||
size: number
|
||||
publishedAt: string
|
||||
changelogKey: string
|
||||
notesSummary?: string
|
||||
}
|
||||
|
||||
export interface UpdateCatalog {
|
||||
stable: UpdateRelease[]
|
||||
preview: UpdateRelease[]
|
||||
}
|
||||
|
||||
export interface DownloadedRelease extends UpdateRelease {
|
||||
localPath: string
|
||||
}
|
||||
|
||||
export interface UpdateStatus {
|
||||
enabled: boolean
|
||||
supported: boolean
|
||||
phase: UpdatePhase
|
||||
currentVersion: string
|
||||
currentChannel: ReleaseChannel
|
||||
currentUserType: UserType | null
|
||||
message?: string
|
||||
latestVersion?: string
|
||||
latestChannel?: ReleaseChannel
|
||||
progress?: number
|
||||
downloadedRelease?: Pick<DownloadedRelease, 'version' | 'channel' | 'localPath'>
|
||||
recommendedRelease?: UpdateRelease
|
||||
error?: string
|
||||
adminHasAnyRelease?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateDialogCatalog {
|
||||
mode: 'user' | 'admin' | 'disabled'
|
||||
recommendedRelease?: UpdateRelease
|
||||
channels?: UpdateCatalog
|
||||
}
|
||||
|
||||
export interface DownloadReleaseRequest {
|
||||
version: string
|
||||
channel: ReleaseChannel
|
||||
artifactKey: string
|
||||
sha256: string
|
||||
size: number
|
||||
publishedAt: string
|
||||
changelogKey: string
|
||||
notesSummary?: string
|
||||
}
|
||||
16
src/preload/index.d.ts
vendored
16
src/preload/index.d.ts
vendored
@@ -22,6 +22,11 @@ import type {
|
||||
import type { IpcResult } from '../main/ipc'
|
||||
import type { LogLevel } from '../shared/ipc-channels'
|
||||
import type { CleanerConfig } from '../main/types/config.schema'
|
||||
import type {
|
||||
DownloadReleaseRequest,
|
||||
UpdateDialogCatalog,
|
||||
UpdateStatus
|
||||
} from '../main/types/update.types'
|
||||
|
||||
export interface ResolverAPI {
|
||||
resolve: (input: ResolverInput) => Promise<IpcResult<ResolverResponse>>
|
||||
@@ -120,6 +125,16 @@ export interface LoggerAPI {
|
||||
log: (level: LogLevel, message: string, context?: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
export interface UpdateAPI {
|
||||
getStatus: () => Promise<IpcResult<UpdateStatus>>
|
||||
checkNow: () => Promise<IpcResult<UpdateStatus>>
|
||||
getCatalog: () => Promise<IpcResult<UpdateDialogCatalog>>
|
||||
getChangelog: (release: DownloadReleaseRequest) => Promise<IpcResult<string>>
|
||||
downloadRelease: (release: DownloadReleaseRequest) => Promise<IpcResult<UpdateStatus>>
|
||||
installDownloaded: () => Promise<IpcResult<void>>
|
||||
onStatusChanged: (callback: (data: UpdateStatus) => void) => () => void
|
||||
}
|
||||
|
||||
export interface ProcessAPI {
|
||||
versions: {
|
||||
electron: string
|
||||
@@ -146,6 +161,7 @@ declare global {
|
||||
config: ConfigAPI
|
||||
logger: LoggerAPI
|
||||
report: ReportAPI
|
||||
update: UpdateAPI
|
||||
}
|
||||
api: unknown
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
import type { IpcResult } from '../main/ipc'
|
||||
import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels'
|
||||
import type { CleanerConfig } from '../main/types/config.schema'
|
||||
import type { DownloadReleaseRequest, UpdateStatus } from '../main/types/update.types'
|
||||
import type { UpdateDialogCatalog } from '../main/types/update.types'
|
||||
|
||||
type ErpSettingsPayload = {
|
||||
erp?: {
|
||||
@@ -218,6 +220,25 @@ const api = {
|
||||
listByUser: (username: string): Promise<IpcResult> =>
|
||||
invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username),
|
||||
download: (key: string): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key)
|
||||
},
|
||||
|
||||
update: {
|
||||
getStatus: (): Promise<IpcResult<UpdateStatus>> => invokeIpc(IPC_CHANNELS.UPDATE_GET_STATUS),
|
||||
checkNow: (): Promise<IpcResult<UpdateStatus>> => invokeIpc(IPC_CHANNELS.UPDATE_CHECK_NOW),
|
||||
getCatalog: (): Promise<IpcResult<UpdateDialogCatalog>> =>
|
||||
invokeIpc(IPC_CHANNELS.UPDATE_GET_CATALOG),
|
||||
getChangelog: (release: DownloadReleaseRequest): Promise<IpcResult<string>> =>
|
||||
invokeIpc(IPC_CHANNELS.UPDATE_GET_CHANGELOG, release),
|
||||
downloadRelease: (release: DownloadReleaseRequest): Promise<IpcResult<UpdateStatus>> =>
|
||||
invokeIpc(IPC_CHANNELS.UPDATE_DOWNLOAD_RELEASE, release),
|
||||
installDownloaded: (): Promise<IpcResult<void>> =>
|
||||
invokeIpc(IPC_CHANNELS.UPDATE_INSTALL_DOWNLOADED),
|
||||
onStatusChanged: (callback: (data: UpdateStatus) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: UpdateStatus) =>
|
||||
callback(data)
|
||||
ipcRenderer.on(IPC_CHANNELS.UPDATE_STATUS_CHANGED, subscription)
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_STATUS_CHANGED, subscription)
|
||||
}
|
||||
}
|
||||
} as const
|
||||
|
||||
|
||||
@@ -9,7 +9,17 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { LayoutDashboard, Download, Trash2, Settings, Database, User, LogOut } from 'lucide-react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Download,
|
||||
Trash2,
|
||||
Settings,
|
||||
Database,
|
||||
User,
|
||||
LogOut,
|
||||
ArrowUpCircle,
|
||||
LoaderCircle
|
||||
} from 'lucide-react'
|
||||
import { useLogger } from './hooks/useLogger'
|
||||
import LoginDialog from './components/LoginDialog'
|
||||
import UserSelectionDialog, {
|
||||
@@ -19,6 +29,12 @@ import { Toast } from './components/ui/Toast'
|
||||
import ExtractorPage from './pages/ExtractorPage'
|
||||
import CleanerPage from './pages/CleanerPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import UpdateDialog from './components/UpdateDialog'
|
||||
import type {
|
||||
DownloadReleaseRequest,
|
||||
UpdateDialogCatalog,
|
||||
UpdateStatus
|
||||
} from '../../main/types/update.types'
|
||||
|
||||
type Page = 'home' | 'extractor' | 'cleaner' | 'settings'
|
||||
|
||||
@@ -51,6 +67,9 @@ function App(): React.JSX.Element {
|
||||
|
||||
// Navigation state
|
||||
const [currentPage, setCurrentPage] = useState<Page>('extractor') // Default to extractor for the new layout
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
|
||||
const [updateCatalog, setUpdateCatalog] = useState<UpdateDialogCatalog | null>(null)
|
||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
|
||||
|
||||
// Load error message from sessionStorage
|
||||
const showError = (message: string) => {
|
||||
@@ -65,6 +84,46 @@ function App(): React.JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.update.onStatusChanged((status) => {
|
||||
setUpdateStatus(status)
|
||||
if (status.phase === 'available' || status.phase === 'downloaded' || status.phase === 'idle') {
|
||||
void refreshUpdateCatalog()
|
||||
}
|
||||
})
|
||||
|
||||
void refreshUpdateState()
|
||||
|
||||
return unsubscribe
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
void refreshUpdateState()
|
||||
void refreshUpdateCatalog()
|
||||
return
|
||||
}
|
||||
|
||||
setUpdateStatus(null)
|
||||
setUpdateCatalog(null)
|
||||
setShowUpdateDialog(false)
|
||||
}, [isAuthenticated])
|
||||
|
||||
const refreshUpdateState = async () => {
|
||||
const result = await window.electron.update.getStatus()
|
||||
if (result.success && result.data) {
|
||||
setUpdateStatus(result.data)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshUpdateCatalog = async () => {
|
||||
const result = await window.electron.update.getCatalog()
|
||||
if (result.success && result.data) {
|
||||
setUpdateCatalog(result.data)
|
||||
}
|
||||
}
|
||||
|
||||
const initializeAuth = async () => {
|
||||
logger.info('=== Starting initializeAuth ===')
|
||||
try {
|
||||
@@ -195,6 +254,44 @@ function App(): React.JSX.Element {
|
||||
setShowLoginDialog(true)
|
||||
}
|
||||
|
||||
const openUpdateDialog = async () => {
|
||||
await refreshUpdateCatalog()
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
|
||||
const handleInstallUserRelease = async () => {
|
||||
if (!updateCatalog?.recommendedRelease) {
|
||||
showError('暂无可安装更新')
|
||||
return
|
||||
}
|
||||
|
||||
if (updateStatus?.phase !== 'downloaded') {
|
||||
const downloadResult = await window.electron.update.downloadRelease(updateCatalog.recommendedRelease)
|
||||
if (!downloadResult.success) {
|
||||
showError(downloadResult.error || '下载更新失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const installResult = await window.electron.update.installDownloaded()
|
||||
if (!installResult.success) {
|
||||
showError(installResult.error || '启动安装失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdminDownloadAndInstall = async (release: DownloadReleaseRequest) => {
|
||||
const downloadResult = await window.electron.update.downloadRelease(release)
|
||||
if (!downloadResult.success) {
|
||||
showError(downloadResult.error || '下载更新失败')
|
||||
return
|
||||
}
|
||||
|
||||
const installResult = await window.electron.update.installDownloaded()
|
||||
if (!installResult.success) {
|
||||
showError(installResult.error || '启动安装失败')
|
||||
}
|
||||
}
|
||||
|
||||
// Check if should show logout button
|
||||
// Show logout if: user is Admin, OR user was switched by Admin
|
||||
const shouldShowLogout = currentUser?.userType === 'Admin' || isSwitchedByAdmin
|
||||
@@ -317,6 +414,24 @@ function App(): React.JSX.Element {
|
||||
{ id: 'settings', label: '系统设置 (Settings)', icon: <Settings size={18} /> }
|
||||
]
|
||||
|
||||
const showUpdateEntry =
|
||||
!!updateStatus &&
|
||||
((currentUser?.userType === 'User' && updateStatus.phase === 'downloaded') ||
|
||||
(currentUser?.userType === 'Admin' &&
|
||||
(updateStatus.adminHasAnyRelease ||
|
||||
updateStatus.phase === 'downloading' ||
|
||||
updateStatus.phase === 'downloaded' ||
|
||||
updateStatus.phase === 'installing')))
|
||||
|
||||
const updateButtonLabel =
|
||||
currentUser?.userType === 'Admin'
|
||||
? updateStatus?.phase === 'downloading'
|
||||
? '下载更新中...'
|
||||
: '有可用版本'
|
||||
: updateStatus?.phase === 'downloaded'
|
||||
? `发现稳定版 V${updateStatus.latestVersion}`
|
||||
: '发现新版本'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
|
||||
{/* ================= 顶部导航与标题栏 ================= */}
|
||||
@@ -370,6 +485,20 @@ function App(): React.JSX.Element {
|
||||
className="flex items-center gap-4 text-sm"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as any}
|
||||
>
|
||||
{showUpdateEntry && (
|
||||
<button
|
||||
onClick={() => void openUpdateDialog()}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-blue-500/40 bg-blue-500/10 px-3 py-1.5 text-xs font-medium text-blue-100 transition hover:bg-blue-500/20"
|
||||
title={updateStatus?.message || '查看更新'}
|
||||
>
|
||||
{updateStatus?.phase === 'downloading' || updateStatus?.phase === 'installing' ? (
|
||||
<LoaderCircle size={15} className="animate-spin" />
|
||||
) : (
|
||||
<ArrowUpCircle size={15} />
|
||||
)}
|
||||
<span>{updateButtonLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs bg-slate-800 px-3 py-1.5 rounded-full border border-slate-700">
|
||||
<Database size={14} className="text-green-500" />
|
||||
<span className="text-slate-300">数据库已连接</span>
|
||||
@@ -418,6 +547,22 @@ function App(): React.JSX.Element {
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<Toast />
|
||||
<UpdateDialog
|
||||
isOpen={showUpdateDialog}
|
||||
userType={currentUser?.userType ?? null}
|
||||
status={updateStatus}
|
||||
catalog={updateCatalog}
|
||||
onClose={() => setShowUpdateDialog(false)}
|
||||
onInstallUserRelease={handleInstallUserRelease}
|
||||
onDownloadAndInstallAdminRelease={async (release) =>
|
||||
handleAdminDownloadAndInstall(release)
|
||||
}
|
||||
onRefreshCatalog={async () => {
|
||||
await window.electron.update.checkNow()
|
||||
await refreshUpdateCatalog()
|
||||
await refreshUpdateState()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { X, FileText, Loader2 } from 'lucide-react'
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { X, FileText, Loader2, ChevronDown } from 'lucide-react'
|
||||
import { Combobox, Transition } from '@headlessui/react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
import 'github-markdown-css/github-markdown-light.css'
|
||||
import 'highlight.js/styles/github.css'
|
||||
|
||||
interface ReportMetadata {
|
||||
key: string
|
||||
@@ -25,11 +31,12 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
|
||||
currentUsername
|
||||
}) => {
|
||||
const [reports, setReports] = useState<ReportMetadata[]>([])
|
||||
const [selectedReportKey, setSelectedReportKey] = useState<string>('')
|
||||
const [selectedReport, setSelectedReport] = useState<ReportMetadata | null>(null)
|
||||
const [reportContent, setReportContent] = useState<string>('')
|
||||
const [isLoadingList, setIsLoadingList] = useState<boolean>(false)
|
||||
const [isLoadingContent, setIsLoadingContent] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -37,7 +44,7 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
|
||||
} else {
|
||||
// Reset state when closed
|
||||
setReports([])
|
||||
setSelectedReportKey('')
|
||||
setSelectedReport(null)
|
||||
setReportContent('')
|
||||
setError(null)
|
||||
}
|
||||
@@ -66,11 +73,10 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handleReportChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const key = e.target.value
|
||||
setSelectedReportKey(key)
|
||||
const handleReportChange = async (report: ReportMetadata | null) => {
|
||||
setSelectedReport(report)
|
||||
|
||||
if (!key) {
|
||||
if (!report) {
|
||||
setReportContent('')
|
||||
return
|
||||
}
|
||||
@@ -78,7 +84,7 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
|
||||
setIsLoadingContent(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.electron.report.download(key)
|
||||
const result = await window.electron.report.download(report.key)
|
||||
if (result.success && result.data) {
|
||||
setReportContent(result.data)
|
||||
} else {
|
||||
@@ -93,6 +99,12 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Filter reports based on search query
|
||||
const filteredReports = useMemo(() => {
|
||||
if (!query) return reports
|
||||
return reports.filter((report) => report.filename.toLowerCase().includes(query.toLowerCase()))
|
||||
}, [reports, query])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
// Format date to local string
|
||||
@@ -131,33 +143,90 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium text-slate-700 flex-shrink-0">选择报告:</label>
|
||||
<div className="relative flex-1 max-w-2xl">
|
||||
<select
|
||||
value={selectedReportKey}
|
||||
onChange={handleReportChange}
|
||||
<Combobox value={selectedReport} onChange={handleReportChange} nullable>
|
||||
<div className="relative">
|
||||
<Combobox.Input
|
||||
className="w-full bg-slate-50 border border-slate-300 text-slate-700 py-2 pl-3 pr-10 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 disabled:opacity-50 text-sm"
|
||||
displayValue={(report: ReportMetadata | null) => {
|
||||
if (!report) return '请选择要查看的报告...'
|
||||
return `${isAdmin ? `[${report.username}] ` : ''}${report.filename} (${formatDate(report.lastModified)})`
|
||||
}}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="请选择要查看的报告..."
|
||||
disabled={isLoadingList}
|
||||
className="w-full appearance-none bg-slate-50 border border-slate-300 text-slate-700 py-2 pl-3 pr-10 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 disabled:opacity-50 text-sm"
|
||||
>
|
||||
<option value="">请选择要查看的报告...</option>
|
||||
{reports.map((report) => (
|
||||
<option key={report.key} value={report.key}>
|
||||
{isAdmin ? `[${report.username}] ` : ''}
|
||||
{report.filename} ({formatDate(report.lastModified)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500">
|
||||
<svg
|
||||
className="fill-current h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" />
|
||||
</svg>
|
||||
/>
|
||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center px-2 text-slate-500 hover:text-slate-700 transition-colors">
|
||||
<ChevronDown size={20} className="h-5 w-5" />
|
||||
</Combobox.Button>
|
||||
</div>
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Combobox.Options className="absolute z-50 w-full mt-1 bg-white border border-slate-200 rounded-lg shadow-lg max-h-60 overflow-auto focus:outline-none">
|
||||
{filteredReports.length === 0 && query ? (
|
||||
<div className="px-4 py-8 text-center text-slate-400 text-sm">
|
||||
未找到匹配的报告
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Empty option to allow clearing selection */}
|
||||
<Combobox.Option
|
||||
value={null}
|
||||
className={({ active }) =>
|
||||
`px-4 py-2.5 text-sm cursor-pointer ${
|
||||
active ? 'bg-slate-100 text-slate-900' : 'text-slate-500 italic'
|
||||
}`
|
||||
}
|
||||
>
|
||||
请选择要查看的报告...
|
||||
</Combobox.Option>
|
||||
{filteredReports.map((report) => (
|
||||
<Combobox.Option
|
||||
key={report.key}
|
||||
value={report}
|
||||
className={({ active, selected }) =>
|
||||
`px-4 py-2.5 text-sm cursor-pointer transition-colors ${
|
||||
selected
|
||||
? 'bg-blue-500 text-white'
|
||||
: active
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-slate-900'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate flex-1">
|
||||
{isAdmin ? `[${report.username}] ` : ''}
|
||||
{report.filename}
|
||||
</span>
|
||||
<span
|
||||
className={`ml-2 text-xs ${selected ? 'text-blue-100' : 'text-slate-400'}`}
|
||||
>
|
||||
{formatDate(report.lastModified)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</Combobox>
|
||||
</div>
|
||||
{isLoadingList && <Loader2 size={16} className="text-blue-500 animate-spin" />}
|
||||
</div>
|
||||
{error && <div className="mt-3 text-sm text-red-600 flex items-center gap-1.5 bg-red-50 p-2 rounded">{error}</div>}
|
||||
{error && (
|
||||
<div className="mt-3 text-sm text-red-600 flex items-center gap-1.5 bg-red-50 p-2 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -169,8 +238,13 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
|
||||
</div>
|
||||
) : reportContent ? (
|
||||
<div className="h-full overflow-y-auto p-8">
|
||||
<div className="prose prose-slate prose-sm max-w-none bg-white p-8 rounded-xl shadow-sm border border-slate-200">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{reportContent}</ReactMarkdown>
|
||||
<div className="markdown-body bg-white p-8 rounded-xl shadow-sm border border-slate-200">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSlug, rehypeAutolinkHeadings, rehypeHighlight]}
|
||||
>
|
||||
{reportContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
267
src/renderer/src/components/UpdateDialog.tsx
Normal file
267
src/renderer/src/components/UpdateDialog.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { DownloadCloud, LoaderCircle, RefreshCw } from 'lucide-react'
|
||||
import Modal from './ui/Modal'
|
||||
import type { UserType } from '../../../main/types/user.types'
|
||||
import type {
|
||||
DownloadReleaseRequest,
|
||||
UpdateDialogCatalog,
|
||||
UpdateRelease,
|
||||
UpdateStatus
|
||||
} from '../../../main/types/update.types'
|
||||
|
||||
interface UpdateDialogProps {
|
||||
isOpen: boolean
|
||||
userType: UserType | null
|
||||
status: UpdateStatus | null
|
||||
catalog: UpdateDialogCatalog | null
|
||||
onClose: () => void
|
||||
onInstallUserRelease: () => Promise<void>
|
||||
onDownloadAndInstallAdminRelease: (release: DownloadReleaseRequest) => Promise<void>
|
||||
onRefreshCatalog: () => Promise<void>
|
||||
}
|
||||
|
||||
function releaseKey(release: UpdateRelease): string {
|
||||
return `${release.channel}:${release.version}`
|
||||
}
|
||||
|
||||
export default function UpdateDialog({
|
||||
isOpen,
|
||||
userType,
|
||||
status,
|
||||
catalog,
|
||||
onClose,
|
||||
onInstallUserRelease,
|
||||
onDownloadAndInstallAdminRelease,
|
||||
onRefreshCatalog
|
||||
}: UpdateDialogProps): React.JSX.Element {
|
||||
const [selectedRelease, setSelectedRelease] = useState<UpdateRelease | undefined>(undefined)
|
||||
const [changelog, setChangelog] = useState<string>('')
|
||||
const [isLoadingChangelog, setIsLoadingChangelog] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !catalog) return
|
||||
|
||||
if (catalog.mode === 'user') {
|
||||
setSelectedRelease(catalog.recommendedRelease)
|
||||
return
|
||||
}
|
||||
|
||||
if (catalog.mode === 'admin') {
|
||||
setSelectedRelease(
|
||||
catalog.recommendedRelease ?? catalog.channels?.stable[0] ?? catalog.channels?.preview[0]
|
||||
)
|
||||
}
|
||||
}, [catalog, isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !selectedRelease) {
|
||||
setChangelog('')
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
setIsLoadingChangelog(true)
|
||||
|
||||
window.electron.update
|
||||
.getChangelog(selectedRelease)
|
||||
.then((result) => {
|
||||
if (!active) return
|
||||
setChangelog(result.success && result.data ? result.data : '暂无更新说明。')
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return
|
||||
setChangelog('暂无更新说明。')
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setIsLoadingChangelog(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [isOpen, selectedRelease])
|
||||
|
||||
const isBusy =
|
||||
status?.phase === 'downloading' || status?.phase === 'installing' || status?.phase === 'checking'
|
||||
|
||||
const renderReleaseList = (title: string, releases: UpdateRelease[]) => {
|
||||
if (releases.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 px-3 py-4 text-sm text-slate-500">
|
||||
暂无版本
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</div>
|
||||
{releases.map((release) => {
|
||||
const selected = selectedRelease && releaseKey(selectedRelease) === releaseKey(release)
|
||||
return (
|
||||
<button
|
||||
key={releaseKey(release)}
|
||||
onClick={() => setSelectedRelease(release)}
|
||||
className={`w-full rounded-lg border px-3 py-3 text-left transition ${
|
||||
selected
|
||||
? 'border-blue-500 bg-blue-50 text-blue-900'
|
||||
: 'border-slate-200 bg-white text-slate-700 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium">V{release.version}</span>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs uppercase text-slate-600">
|
||||
{release.channel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{release.publishedAt}</div>
|
||||
{release.notesSummary && (
|
||||
<div className="mt-2 text-xs text-slate-600">{release.notesSummary}</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={isBusy ? () => undefined : onClose}
|
||||
title="应用更新"
|
||||
size="3xl"
|
||||
disableBackdropClick={isBusy}
|
||||
disableEscapeKey={isBusy}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg bg-slate-50 px-4 py-3 text-sm">
|
||||
<div className="text-slate-600">
|
||||
当前版本 <span className="font-semibold text-slate-900">V{status?.currentVersion}</span>
|
||||
<span className="ml-2 rounded-full bg-slate-200 px-2 py-0.5 text-xs uppercase text-slate-700">
|
||||
{status?.currentChannel ?? __APP_CHANNEL__}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void onRefreshCatalog()}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-slate-200 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
|
||||
disabled={isBusy}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{catalog?.mode === 'admin' ? (
|
||||
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
|
||||
<div className="space-y-4">
|
||||
{renderReleaseList('Stable', catalog.channels?.stable ?? [])}
|
||||
{renderReleaseList('Preview', catalog.channels?.preview ?? [])}
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-4 py-3">
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{selectedRelease
|
||||
? `${selectedRelease.channel.toUpperCase()} V${selectedRelease.version}`
|
||||
: '请选择一个版本'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
{selectedRelease?.notesSummary ?? '选择版本后可查看详细更新说明。'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-auto px-4 py-4 prose prose-slate max-w-none prose-headings:mb-2 prose-p:my-2 prose-li:my-1">
|
||||
{isLoadingChangelog ? (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<LoaderCircle size={16} className="animate-spin" />
|
||||
正在加载更新说明...
|
||||
</div>
|
||||
) : (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{changelog || '暂无更新说明。'}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-4 py-3">
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{selectedRelease ? `发现稳定版 V${selectedRelease.version}` : '暂无可用更新'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
{status?.currentChannel === 'preview'
|
||||
? '当前设备正在运行预览版,普通用户将更新回稳定版。'
|
||||
: '更新包已在后台准备完成,确认后将自动关闭并重启应用。'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-auto px-4 py-4 prose prose-slate max-w-none prose-headings:mb-2 prose-p:my-2 prose-li:my-1">
|
||||
{isLoadingChangelog ? (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<LoaderCircle size={16} className="animate-spin" />
|
||||
正在加载更新说明...
|
||||
</div>
|
||||
) : (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{changelog || '暂无更新说明。'}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.error && (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{status.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-200 pt-4">
|
||||
<div className="text-sm text-slate-500">{status?.message ?? ' '}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
|
||||
disabled={isBusy}
|
||||
>
|
||||
稍后
|
||||
</button>
|
||||
{userType === 'Admin' ? (
|
||||
<button
|
||||
onClick={() =>
|
||||
selectedRelease ? void onDownloadAndInstallAdminRelease(selectedRelease) : undefined
|
||||
}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
|
||||
disabled={!selectedRelease || isBusy}
|
||||
>
|
||||
{isBusy ? (
|
||||
<LoaderCircle size={16} className="animate-spin" />
|
||||
) : (
|
||||
<DownloadCloud size={16} />
|
||||
)}
|
||||
下载并更新
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void onInstallUserRelease()}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
|
||||
disabled={!selectedRelease || isBusy}
|
||||
>
|
||||
{isBusy ? (
|
||||
<LoaderCircle size={16} className="animate-spin" />
|
||||
) : (
|
||||
<DownloadCloud size={16} />
|
||||
)}
|
||||
立即更新
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
1
src/renderer/src/env.d.ts
vendored
1
src/renderer/src/env.d.ts
vendored
@@ -2,3 +2,4 @@
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
declare const __GIT_HASH__: string
|
||||
declare const __APP_CHANNEL__: 'stable' | 'preview'
|
||||
|
||||
1
src/shared/app-env.d.ts
vendored
Normal file
1
src/shared/app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare const __APP_CHANNEL__: 'stable' | 'preview'
|
||||
@@ -96,7 +96,16 @@ export const IPC_CHANNELS = {
|
||||
// Report
|
||||
REPORT_LIST_ALL: 'report:listAll',
|
||||
REPORT_LIST_BY_USER: 'report:listByUser',
|
||||
REPORT_DOWNLOAD: 'report:download'
|
||||
REPORT_DOWNLOAD: 'report:download',
|
||||
|
||||
// Update
|
||||
UPDATE_GET_STATUS: 'update:getStatus',
|
||||
UPDATE_CHECK_NOW: 'update:checkNow',
|
||||
UPDATE_GET_CATALOG: 'update:getCatalog',
|
||||
UPDATE_GET_CHANGELOG: 'update:getChangelog',
|
||||
UPDATE_DOWNLOAD_RELEASE: 'update:downloadRelease',
|
||||
UPDATE_INSTALL_DOWNLOADED: 'update:installDownloaded',
|
||||
UPDATE_STATUS_CHANGED: 'update:onStatusChanged'
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
217
tests/unit/update-utils.test.ts
Normal file
217
tests/unit/update-utils.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
compareVersions,
|
||||
normalizeReleases,
|
||||
pickLatestRelease,
|
||||
resolveAdminDecision,
|
||||
resolveUserDecision
|
||||
} from '../../src/main/services/update/update-utils'
|
||||
import type { UpdateCatalog } from '../../src/main/types/update.types'
|
||||
|
||||
describe('update-utils', () => {
|
||||
it('compares semver-like numeric versions correctly', () => {
|
||||
expect(compareVersions('1.3.1', '1.3.1')).toBe(0)
|
||||
expect(compareVersions('1.3.2', '1.3.1')).toBe(1)
|
||||
expect(compareVersions('1.10.0', '1.9.9')).toBe(1)
|
||||
expect(compareVersions('1.2.0', '1.3.0')).toBe(-1)
|
||||
})
|
||||
|
||||
it('pads missing version segments with zero', () => {
|
||||
expect(compareVersions('1.3', '1.3.0')).toBe(0)
|
||||
expect(compareVersions('1.3.1', '1.3')).toBe(1)
|
||||
})
|
||||
|
||||
it('normalizes channel indexes and drops invalid entries', () => {
|
||||
const releases = normalizeReleases(
|
||||
{
|
||||
releases: [
|
||||
{
|
||||
version: '1.3.2',
|
||||
artifactKey: 'stable/1.3.2.exe',
|
||||
sha256: 'abc',
|
||||
size: 123,
|
||||
publishedAt: '2026-03-20T10:00:00Z',
|
||||
changelogKey: 'stable/1.3.2.md'
|
||||
},
|
||||
{
|
||||
version: '1.3.1',
|
||||
artifactKey: '',
|
||||
sha256: 'missing',
|
||||
size: 12,
|
||||
publishedAt: '2026-03-19T10:00:00Z',
|
||||
changelogKey: 'stable/1.3.1.md'
|
||||
}
|
||||
]
|
||||
},
|
||||
'stable'
|
||||
)
|
||||
|
||||
expect(releases).toHaveLength(1)
|
||||
expect(releases[0].channel).toBe('stable')
|
||||
expect(releases[0].version).toBe('1.3.2')
|
||||
})
|
||||
|
||||
it('keeps higher version newer even if its publishedAt is older', () => {
|
||||
const latest = pickLatestRelease([
|
||||
{
|
||||
version: '1.3.1',
|
||||
channel: 'stable',
|
||||
artifactKey: 'stable/1.3.1.exe',
|
||||
sha256: 'a',
|
||||
size: 1,
|
||||
publishedAt: '2026-03-20T11:00:00Z',
|
||||
changelogKey: 'stable/1.3.1.md'
|
||||
},
|
||||
{
|
||||
version: '1.3.2',
|
||||
channel: 'stable',
|
||||
artifactKey: 'stable/1.3.2.exe',
|
||||
sha256: 'b',
|
||||
size: 1,
|
||||
publishedAt: '2026-03-20T10:00:00Z',
|
||||
changelogKey: 'stable/1.3.2.md'
|
||||
}
|
||||
])
|
||||
|
||||
expect(latest?.version).toBe('1.3.2')
|
||||
})
|
||||
|
||||
it('forces User on preview back to latest stable', () => {
|
||||
const catalog: UpdateCatalog = {
|
||||
stable: [
|
||||
{
|
||||
version: '1.3.2',
|
||||
channel: 'stable',
|
||||
artifactKey: 'stable/1.3.2.exe',
|
||||
sha256: 'a',
|
||||
size: 1,
|
||||
publishedAt: '2026-03-20T10:00:00Z',
|
||||
changelogKey: 'stable/1.3.2.md'
|
||||
}
|
||||
],
|
||||
preview: [
|
||||
{
|
||||
version: '1.4.0',
|
||||
channel: 'preview',
|
||||
artifactKey: 'preview/1.4.0.exe',
|
||||
sha256: 'b',
|
||||
size: 1,
|
||||
publishedAt: '2026-03-20T11:00:00Z',
|
||||
changelogKey: 'preview/1.4.0.md'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const decision = resolveUserDecision({
|
||||
currentVersion: '1.4.0',
|
||||
currentChannel: 'preview',
|
||||
catalog
|
||||
})
|
||||
|
||||
expect(decision.shouldOffer).toBe(true)
|
||||
expect(decision.recommendedRelease?.channel).toBe('stable')
|
||||
expect(decision.recommendedRelease?.version).toBe('1.3.2')
|
||||
})
|
||||
|
||||
it('treats stable version mismatch as update for User', () => {
|
||||
const catalog: UpdateCatalog = {
|
||||
stable: [
|
||||
{
|
||||
version: '1.3.2',
|
||||
channel: 'stable',
|
||||
artifactKey: 'stable/1.3.2.exe',
|
||||
sha256: 'a',
|
||||
size: 1,
|
||||
publishedAt: '2026-03-20T10:00:00Z',
|
||||
changelogKey: 'stable/1.3.2.md'
|
||||
}
|
||||
],
|
||||
preview: []
|
||||
}
|
||||
|
||||
const decision = resolveUserDecision({
|
||||
currentVersion: '1.3.1',
|
||||
currentChannel: 'stable',
|
||||
catalog
|
||||
})
|
||||
|
||||
expect(decision.shouldOffer).toBe(true)
|
||||
expect(decision.recommendedRelease?.version).toBe('1.3.2')
|
||||
})
|
||||
|
||||
it('recommends the highest newer version for Admin', () => {
|
||||
const catalog: UpdateCatalog = {
|
||||
stable: [
|
||||
{
|
||||
version: '1.3.2',
|
||||
channel: 'stable',
|
||||
artifactKey: 'stable/1.3.2.exe',
|
||||
sha256: 'a',
|
||||
size: 1,
|
||||
publishedAt: '2026-03-20T10:00:00Z',
|
||||
changelogKey: 'stable/1.3.2.md'
|
||||
}
|
||||
],
|
||||
preview: [
|
||||
{
|
||||
version: '1.4.0',
|
||||
channel: 'preview',
|
||||
artifactKey: 'preview/1.4.0.exe',
|
||||
sha256: 'b',
|
||||
size: 1,
|
||||
publishedAt: '2026-03-20T09:00:00Z',
|
||||
changelogKey: 'preview/1.4.0.md'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const decision = resolveAdminDecision({
|
||||
currentVersion: '1.3.1',
|
||||
currentChannel: 'stable',
|
||||
catalog,
|
||||
maxHistoryPerChannel: 10
|
||||
})
|
||||
|
||||
expect(decision.mode).toBe('admin')
|
||||
expect(decision.recommendedRelease?.channel).toBe('preview')
|
||||
expect(decision.recommendedRelease?.version).toBe('1.4.0')
|
||||
expect(decision.channels?.preview).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('still exposes a cross-channel choice for Admin when no newer version exists', () => {
|
||||
const catalog: UpdateCatalog = {
|
||||
stable: [
|
||||
{
|
||||
version: '1.4.0',
|
||||
channel: 'stable',
|
||||
artifactKey: 'stable/1.4.0.exe',
|
||||
sha256: 'a',
|
||||
size: 1,
|
||||
publishedAt: '2026-03-20T10:00:00Z',
|
||||
changelogKey: 'stable/1.4.0.md'
|
||||
}
|
||||
],
|
||||
preview: [
|
||||
{
|
||||
version: '1.4.0',
|
||||
channel: 'preview',
|
||||
artifactKey: 'preview/1.4.0.exe',
|
||||
sha256: 'b',
|
||||
size: 1,
|
||||
publishedAt: '2026-03-20T09:00:00Z',
|
||||
changelogKey: 'preview/1.4.0.md'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const decision = resolveAdminDecision({
|
||||
currentVersion: '1.4.0',
|
||||
currentChannel: 'stable',
|
||||
catalog,
|
||||
maxHistoryPerChannel: 10
|
||||
})
|
||||
|
||||
expect(decision.recommendedRelease?.channel).toBe('preview')
|
||||
expect(decision.recommendedRelease?.version).toBe('1.4.0')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user