16 Commits

Author SHA1 Message Date
Misaka
68e6c9483f 1.4.1 2026-03-20 23:30:05 +08:00
Misaka
63ff32817e docs: add 1.4.1 release notes 2026-03-20 23:29:55 +08:00
Misaka
78544af8de Merge branch 'dev' 2026-03-20 23:28:24 +08:00
Misaka
7d73592d41 Merge remote-tracking branch 'origin/dev' into dev 2026-03-20 23:25:32 +08:00
Misaka
bacdd2d82f 1.4.0 2026-03-20 23:22:53 +08:00
Misaka
4add295e44 docs: add 1.4.0 release notes 2026-03-20 23:22:45 +08:00
Misaka
88b2e8d355 Merge branch 'dev' 2026-03-20 23:17:38 +08:00
Misaka
fe5ad13f88 Merge branch 'portable-update-rebuild' into dev 2026-03-20 23:16:32 +08:00
Misaka
c8ce67dc75 docs: update agent and release guides 2026-03-20 23:15:41 +08:00
Misaka
9add23f6ed feat: add one-click release publishing 2026-03-20 22:56:31 +08:00
Misaka
6d4b5efc95 docs: remove migrated root browser docs 2026-03-20 21:52:25 +08:00
Misaka
2216720e24 docs: refine browser deployment docs 2026-03-20 21:47:55 +08:00
Misaka
36304b88e1 docs: add portable update architecture guide 2026-03-20 21:31:52 +08:00
Misaka
6ad9463e73 feat: rebuild portable auto-update flow 2026-03-20 21:20:28 +08:00
Misaka_Company
7644b8d4ea feat(report-viewer): add searchable combobox for report selection
Replace native select dropdown with Headless UI Combobox component to enable:
- Search/filter functionality for reports
- Better UX with keyboard navigation
- Improved visual feedback for selected items
- Empty state handling for no matches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 17:33:16 +08:00
test
5e50a8fbcf feat(report-viewer): add syntax highlighting and GitHub-style markdown rendering
Add rehype plugins for enhanced markdown rendering:
- rehype-highlight: syntax highlighting for code blocks
- rehype-slug: generates heading IDs for anchoring
- rehype-autolink-headings: auto-links headings
- github-markdown-css: GitHub-flavored markdown styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 20:13:40 +08:00
41 changed files with 4102 additions and 517 deletions

4
.gitignore vendored
View File

@@ -15,6 +15,8 @@ out
# Test outputs # Test outputs
coverage/ coverage/
downloads/ downloads/
release-output/
build/bin/
*.xlsx *.xlsx
*.parsed.json *.parsed.json
@@ -41,4 +43,4 @@ logs
nul nul
# TypeScript incremental compilation cache # TypeScript incremental compilation cache
*.tsbuildinfo *.tsbuildinfo

View File

@@ -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) - 项目总体说明

View File

@@ -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
View File

@@ -1,141 +1,165 @@
# CLAUDE.md # 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 ```bash
npm run dev # Start development server with hot reload npm run dev
npm run build # Full build with type checking npm run build
npm run build:win # Build Windows executable npm run build:win
``` ```
### Code Quality 质量检查:
```bash ```bash
npm run lint # ESLint check npm run typecheck
npm run format # Prettier format npm run typecheck:node
npm run typecheck # TypeScript check (both main and renderer) npm run typecheck:web
npm run typecheck:node # TypeScript check for main process only npm run lint
npm run typecheck:web # TypeScript check for renderer only npm run format
``` ```
### Testing 测试:
```bash ```bash
npm run test # Run unit tests (Vitest) npm run test
npm run test:coverage # Run tests with coverage report npm run test:coverage
npm run test:e2e # Run E2E tests (Playwright) npm run test:e2e
npm run test:e2e:ui # Run E2E tests with UI
npm run test:e2e:report # Show E2E test report
``` ```
## 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/`) - 路径:`src/main/`
- React 19 + TypeScript UI - 入口:`src/main/index.ts`
- Uses exposed preload APIs for all main process communication - 职责:
- Authentication-based routing with role-based access control - 应用生命周期管理
- 配置加载
- 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 - 路径:`src/renderer/`
- `MySqlService` / `mysql.ts` - MySQL operations - 技术React + TypeScript
- `SqlServerService` / `sql-server.ts` - SQL Server operations - 特点:
- DAO pattern: `discrete-material-plan-dao.ts`, `materials-to-be-deleted-dao.ts` - 通过 preload 暴露的 API 调用主进程
- 以登录状态和角色控制主要功能入口
- **User Services** (`services/user/`): Authentication and session management ### 服务层
- `BipUsersDao` - User data access
- `SessionManager` - Active session tracking
- **Other Services**: 主进程服务集中在 `src/main/services/`,按领域拆分:
- `config/` - Configuration management
- `excel/` - Excel file parsing
### 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 - 所有 IPC handler 在 `src/main/ipc/`
2. **Fallback**: Shows login dialog if silent login fails - 每个领域一个 handler 模块
3. **Admin User Selection**: Admin users can switch to other user accounts - 统一在 `src/main/ipc/index.ts` 注册
4. **Session Management**: Persistent sessions with role-based permissions (Admin/User/Guest) - 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) - `Admin`
- Types are co-located with features: `src/main/types/` contains domain-specific type definitions - `User`
- The preload script exposes a typed API surface that's available in renderer - `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) - [docs/portable-auto-update-architecture.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/portable-auto-update-architecture.md)
- **Database**: MySQL and SQL Server connection configs (dual support) - [docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md)
- **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
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 - [README.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/README.md)
- **Playwright 1.58** - Browser automation for ERP interaction - [docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md)
- **electron-vite + Vite 7** - Build tooling - [docs/portable-auto-update-architecture.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/portable-auto-update-architecture.md)
- **Zod** - Runtime validation - [docs/browser/PLAYWRIGHT_DEPLOYMENT.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/browser/PLAYWRIGHT_DEPLOYMENT.md)
- **Vitest** - Unit tests
- **Playwright Test** - E2E tests ## 不放在这里的内容
以下内容不应继续堆在本文件中:
- 详细用户使用说明
- 大段业务流程说明
- 重复的架构长文
- 版本发布记录
这些内容应继续放在 `README``docs/` 下的专题文档中。

View File

@@ -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
View 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("\"", "\\\"") + "\"";
}
}

View File

@@ -70,3 +70,16 @@ rustfs:
secretKey: '<YOUR_SECRET_KEY>' # 密钥 secretKey: '<YOUR_SECRET_KEY>' # 密钥
bucket: 'erpauto' # 存储桶名称 bucket: 'erpauto' # 存储桶名称
region: 'us-east-1' # 区域S3 兼容,默认即可) 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

View 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)

View 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 当前实现就能正确识别并使用浏览器。

View 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>
```
只有在排查问题或需要特殊处理时,再退回分步命令。

View 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` 进一步拆分,降低文件复杂度
- 将日志策略区分成“正式日志”和“诊断日志”
- 增加更多针对下载与安装阶段的单测
- 将完整构建发布链路整理为一键化脚本

View 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.

View 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
View 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
View File

@@ -0,0 +1,11 @@
# 1.4.1
## 改进
- 报告查看器的报告选择器升级为可搜索下拉框。
- 报表较多时,可以通过输入关键字快速筛选目标报告,减少滚动查找成本。
## 体验优化
- 优化了报告选择交互,选择流程更适合长列表场景。
- 同步合并 `dev` 分支中已完成的报告查看器可用性改进。

View File

@@ -2,6 +2,9 @@ appId: com.electron.app
productName: erpauto productName: erpauto
directories: directories:
buildResources: build buildResources: build
extraResources:
- from: build/bin/portable-updater.exe
to: portable-updater.exe
files: files:
- '!**/.vscode/*' - '!**/.vscode/*'
- '!src/*' - '!src/*'

View File

@@ -18,14 +18,24 @@ const getGitHash = (): string => {
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
const version = require('./package.json').version const version = require('./package.json').version
const gitHash = getGitHash() const gitHash = getGitHash()
const appChannel = process.env.APP_CHANNEL === 'preview' ? 'preview' : 'stable'
export default defineConfig({ export default defineConfig({
main: {}, main: {
preload: {}, define: {
__APP_CHANNEL__: JSON.stringify(appChannel)
}
},
preload: {
define: {
__APP_CHANNEL__: JSON.stringify(appChannel)
}
},
renderer: { renderer: {
define: { define: {
__APP_VERSION__: JSON.stringify(version), __APP_VERSION__: JSON.stringify(version),
__GIT_HASH__: JSON.stringify(gitHash) __GIT_HASH__: JSON.stringify(gitHash),
__APP_CHANNEL__: JSON.stringify(appChannel)
}, },
resolve: { resolve: {
alias: { alias: {

408
package-lock.json generated
View File

@@ -1,22 +1,24 @@
{ {
"name": "erpauto", "name": "erpauto",
"version": "1.3.1", "version": "1.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "erpauto", "name": "erpauto",
"version": "1.3.1", "version": "1.4.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.929.0", "@aws-sdk/client-s3": "^3.929.0",
"@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@headlessui/react": "^2.2.9",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"chromium-bidi": "^15.0.0", "chromium-bidi": "^15.0.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"github-markdown-css": "^5.9.0",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"mssql": "^12.2.0", "mssql": "^12.2.0",
@@ -26,6 +28,9 @@
"react-focus-lock": "^2.13.7", "react-focus-lock": "^2.13.7",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"reflect-metadata": "^0.2.2", "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", "remark-gfm": "^4.0.1",
"typeorm": "^0.3.28", "typeorm": "^0.3.28",
"uuid": "^13.0.0", "uuid": "^13.0.0",
@@ -1041,7 +1046,7 @@
} }
}, },
"node_modules/@azure/core-tracing": { "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", "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz",
"integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==",
"license": "MIT", "license": "MIT",
@@ -2753,6 +2758,79 @@
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -3132,6 +3210,103 @@
"node": ">=18" "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3", "version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -4232,6 +4407,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@szmarczak/http-timer": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
@@ -4501,6 +4685,33 @@
"vite": "^5.2.0 || ^6 || ^7" "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": { "node_modules/@tediousjs/connection-string": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.6.0.tgz",
@@ -6653,6 +6864,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/color": {
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
@@ -8975,6 +9195,24 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" "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": { "node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -9231,6 +9469,32 @@
"node": ">= 0.4" "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": { "node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6", "version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", "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" "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": { "node_modules/hast-util-whitespace": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -9288,6 +9581,15 @@
"hermes-estree": "0.25.1" "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": { "node_modules/hosted-git-info": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -10950,6 +11252,21 @@
"node": ">=8" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -13235,8 +13552,8 @@
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -13423,6 +13740,58 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/remark-gfm": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -13815,7 +14184,6 @@
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": { "node_modules/semver": {
@@ -14527,6 +14895,12 @@
"url": "https://opencollective.com/synckit" "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": { "node_modules/tailwindcss": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
@@ -15825,6 +16199,20 @@
"node": "^18.17.0 || >=20.5.0" "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": { "node_modules/unist-util-is": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", "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": { "node_modules/utf8-byte-length": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "erpauto", "name": "erpauto",
"version": "1.3.1", "version": "1.4.1",
"description": "An Electron application with React and TypeScript", "description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "example.com", "author": "example.com",
@@ -15,10 +15,14 @@
"dev": "chcp 65001 && electron-vite dev", "dev": "chcp 65001 && electron-vite dev",
"build": "chcp 65001 && npm run typecheck && electron-vite build", "build": "chcp 65001 && npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build && electron-builder --dir", "build:updater": "node scripts/compile-updater.js",
"build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run prebuild && npm run build && electron-builder --win", "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: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", "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){}})\"", "prebuild": "node -e \"const fs=require('fs');['dist','out'].forEach(d=>{try{fs.rmSync(d,{recursive:true})}catch(e){}})\"",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
@@ -34,11 +38,13 @@
"@aws-sdk/client-s3": "^3.929.0", "@aws-sdk/client-s3": "^3.929.0",
"@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@headlessui/react": "^2.2.9",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"chromium-bidi": "^15.0.0", "chromium-bidi": "^15.0.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"github-markdown-css": "^5.9.0",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"mssql": "^12.2.0", "mssql": "^12.2.0",
@@ -48,6 +54,9 @@
"react-focus-lock": "^2.13.7", "react-focus-lock": "^2.13.7",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"reflect-metadata": "^0.2.2", "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", "remark-gfm": "^4.0.1",
"typeorm": "^0.3.28", "typeorm": "^0.3.28",
"uuid": "^13.0.0", "uuid": "^13.0.0",

View 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
View 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
View 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
View 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)
})

View File

@@ -9,6 +9,7 @@ import { logAudit } from './services/logger/audit-logger'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { dirname } from 'path' import { dirname } from 'path'
import fs from 'fs' import fs from 'fs'
import { UpdateService } from './services/update/update-service'
// Set Playwright browsers path BEFORE any playwright import // Set Playwright browsers path BEFORE any playwright import
process.env.PLAYWRIGHT_BROWSERS_PATH = join(app.getPath('userData'), 'ms-playwright') process.env.PLAYWRIGHT_BROWSERS_PATH = join(app.getPath('userData'), 'ms-playwright')
@@ -110,6 +111,7 @@ app.whenReady().then(async () => {
try { try {
const configManager = ConfigManager.getInstance() const configManager = ConfigManager.getInstance()
await configManager.initialize() await configManager.initialize()
UpdateService.getInstance().initialize()
} catch (error) { } catch (error) {
console.error('Failed to initialize ConfigManager:', error) console.error('Failed to initialize ConfigManager:', error)
// Continue anyway - default config will be created // Continue anyway - default config will be created

View File

@@ -18,6 +18,7 @@ import type { UserInfo } from '../types/user.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels' import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors' import { ValidationError } from '../types/errors'
import { withErrorHandling, type IpcResult } from './index' import { withErrorHandling, type IpcResult } from './index'
import { UpdateService } from '../services/update/update-service'
const log = createLogger('AuthHandler') const log = createLogger('AuthHandler')
@@ -70,6 +71,7 @@ export interface CurrentUserResponse {
*/ */
export function registerAuthHandlers(): void { export function registerAuthHandlers(): void {
const sessionManager = SessionManager.getInstance() const sessionManager = SessionManager.getInstance()
const updateService = UpdateService.getInstance()
/** /**
* Get computer name * Get computer name
@@ -93,6 +95,8 @@ export function registerAuthHandlers(): void {
const userInfo = sessionManager.getUserInfo() const userInfo = sessionManager.getUserInfo()
if (success && userInfo) { if (success && userInfo) {
await updateService.setUserContext(userInfo.userType)
// Check if admin needs user selection // Check if admin needs user selection
const requiresUserSelection = userInfo.userType === 'Admin' const requiresUserSelection = userInfo.userType === 'Admin'
@@ -119,6 +123,7 @@ export function registerAuthHandlers(): void {
} }
} }
await updateService.setUserContext(null)
throw new ValidationError('无感登录失败:未找到匹配用户', 'VAL_INVALID_INPUT') throw new ValidationError('无感登录失败:未找到匹配用户', 'VAL_INVALID_INPUT')
}, 'auth:silentLogin') }, 'auth:silentLogin')
} }
@@ -144,6 +149,7 @@ export function registerAuthHandlers(): void {
if (success && userInfo) { if (success && userInfo) {
log.info('Login successful', { username, userType: userInfo.userType }) log.info('Login successful', { username, userType: userInfo.userType })
await updateService.setUserContext(userInfo.userType)
// Audit log: LOGIN success (non-blocking) // Audit log: LOGIN success (non-blocking)
const os = await import('os') const os = await import('os')
@@ -172,6 +178,7 @@ export function registerAuthHandlers(): void {
}).catch((err) => log.warn('Failed to write audit log', { err })) }).catch((err) => log.warn('Failed to write audit log', { err }))
log.warn('Login failed - invalid credentials', { username }) log.warn('Login failed - invalid credentials', { username })
await updateService.setUserContext(null)
throw new ValidationError('用户名或密码错误', 'VAL_INVALID_INPUT') throw new ValidationError('用户名或密码错误', 'VAL_INVALID_INPUT')
}, 'auth:login') }, 'auth:login')
} }
@@ -198,6 +205,7 @@ export function registerAuthHandlers(): void {
} }
sessionManager.logout() sessionManager.logout()
await updateService.setUserContext(null)
}, 'auth:logout') }, 'auth:logout')
}) })
@@ -241,6 +249,7 @@ export function registerAuthHandlers(): void {
if (success) { if (success) {
const newUser = sessionManager.getUserInfo() const newUser = sessionManager.getUserInfo()
log.info('User switch successful', { newUsername: newUser?.username }) log.info('User switch successful', { newUsername: newUser?.username })
await updateService.setUserContext(newUser?.userType ?? null)
return { return {
success: true, success: true,
userInfo: newUser ?? undefined userInfo: newUser ?? undefined

View File

@@ -15,6 +15,7 @@ import { registerMaterialTypeHandlers } from './material-type-handler'
import { registerUserErpConfigHandlers } from './user-erp-config-handler' import { registerUserErpConfigHandlers } from './user-erp-config-handler'
import { registerLoggerHandlers } from './logger-handler' import { registerLoggerHandlers } from './logger-handler'
import { registerReportHandlers } from './report-handler' import { registerReportHandlers } from './report-handler'
import { registerUpdateHandlers } from './update-handler'
import { createLogger, logError } from '../services/logger' import { createLogger, logError } from '../services/logger'
import { serializeError, sanitizeError } from '../services/logger/error-utils' import { serializeError, sanitizeError } from '../services/logger/error-utils'
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors' import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
@@ -105,5 +106,6 @@ export function registerIpcHandlers(): void {
registerUserErpConfigHandlers() registerUserErpConfigHandlers()
registerLoggerHandlers() registerLoggerHandlers()
registerReportHandlers() registerReportHandlers()
registerUpdateHandlers()
log.info('All IPC handlers registered') log.info('All IPC handlers registered')
} }

View 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'
)
})
}

View File

@@ -102,6 +102,18 @@ const DEFAULT_CONFIG: FullConfig = {
secretKey: '', secretKey: '',
bucket: 'erpauto', bucket: 'erpauto',
region: 'us-east-1' 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
} }
} }

View 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
}
}

View 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
}
}

View File

@@ -143,6 +143,22 @@ export const rustfsConfigSchema = z.object({
region: z.string().default('us-east-1') 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 * 完整应用配置 Schema
*/ */
@@ -155,7 +171,8 @@ export const fullConfigSchema = z.object({
cleaner: cleanerConfigSchema, cleaner: cleanerConfigSchema,
orderResolution: orderResolutionSchema, orderResolution: orderResolutionSchema,
logging: loggingConfigSchema, 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 ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
export type LoggingConfig = z.infer<typeof loggingConfigSchema> export type LoggingConfig = z.infer<typeof loggingConfigSchema>
export type RustfsConfig = z.infer<typeof rustfsConfigSchema> export type RustfsConfig = z.infer<typeof rustfsConfigSchema>
export type UpdateConfig = z.infer<typeof updateConfigSchema>
/** /**
* 验证并解析配置 * 验证并解析配置

View 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
}

View File

@@ -22,6 +22,11 @@ import type {
import type { IpcResult } from '../main/ipc' import type { IpcResult } from '../main/ipc'
import type { LogLevel } from '../shared/ipc-channels' import type { LogLevel } from '../shared/ipc-channels'
import type { CleanerConfig } from '../main/types/config.schema' import type { CleanerConfig } from '../main/types/config.schema'
import type {
DownloadReleaseRequest,
UpdateDialogCatalog,
UpdateStatus
} from '../main/types/update.types'
export interface ResolverAPI { export interface ResolverAPI {
resolve: (input: ResolverInput) => Promise<IpcResult<ResolverResponse>> resolve: (input: ResolverInput) => Promise<IpcResult<ResolverResponse>>
@@ -120,6 +125,16 @@ export interface LoggerAPI {
log: (level: LogLevel, message: string, context?: Record<string, unknown>) => void 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 { export interface ProcessAPI {
versions: { versions: {
electron: string electron: string
@@ -146,6 +161,7 @@ declare global {
config: ConfigAPI config: ConfigAPI
logger: LoggerAPI logger: LoggerAPI
report: ReportAPI report: ReportAPI
update: UpdateAPI
} }
api: unknown api: unknown
} }

View File

@@ -13,6 +13,8 @@ import type {
import type { IpcResult } from '../main/ipc' import type { IpcResult } from '../main/ipc'
import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels' import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels'
import type { CleanerConfig } from '../main/types/config.schema' 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 = { type ErpSettingsPayload = {
erp?: { erp?: {
@@ -218,6 +220,25 @@ const api = {
listByUser: (username: string): Promise<IpcResult> => listByUser: (username: string): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username), invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username),
download: (key: string): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key) 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 } as const

View File

@@ -9,7 +9,17 @@
*/ */
import React, { useState, useEffect } from 'react' 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 { useLogger } from './hooks/useLogger'
import LoginDialog from './components/LoginDialog' import LoginDialog from './components/LoginDialog'
import UserSelectionDialog, { import UserSelectionDialog, {
@@ -19,6 +29,12 @@ import { Toast } from './components/ui/Toast'
import ExtractorPage from './pages/ExtractorPage' import ExtractorPage from './pages/ExtractorPage'
import CleanerPage from './pages/CleanerPage' import CleanerPage from './pages/CleanerPage'
import SettingsPage from './pages/SettingsPage' 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' type Page = 'home' | 'extractor' | 'cleaner' | 'settings'
@@ -51,6 +67,9 @@ function App(): React.JSX.Element {
// Navigation state // Navigation state
const [currentPage, setCurrentPage] = useState<Page>('extractor') // Default to extractor for the new layout 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 // Load error message from sessionStorage
const showError = (message: string) => { const showError = (message: string) => {
@@ -65,6 +84,46 @@ function App(): React.JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 () => { const initializeAuth = async () => {
logger.info('=== Starting initializeAuth ===') logger.info('=== Starting initializeAuth ===')
try { try {
@@ -195,6 +254,44 @@ function App(): React.JSX.Element {
setShowLoginDialog(true) 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 // Check if should show logout button
// Show logout if: user is Admin, OR user was switched by Admin // Show logout if: user is Admin, OR user was switched by Admin
const shouldShowLogout = currentUser?.userType === 'Admin' || isSwitchedByAdmin const shouldShowLogout = currentUser?.userType === 'Admin' || isSwitchedByAdmin
@@ -317,6 +414,24 @@ function App(): React.JSX.Element {
{ id: 'settings', label: '系统设置 (Settings)', icon: <Settings size={18} /> } { 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 ( return (
<div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans overflow-hidden"> <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" className="flex items-center gap-4 text-sm"
style={{ WebkitAppRegion: 'no-drag' } as any} 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"> <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" /> <Database size={14} className="text-green-500" />
<span className="text-slate-300"></span> <span className="text-slate-300"></span>
@@ -418,6 +547,22 @@ function App(): React.JSX.Element {
{/* Toast Notifications */} {/* Toast Notifications */}
<Toast /> <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> </div>
) )
} }

View File

@@ -1,7 +1,13 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState, useMemo } from 'react'
import { X, FileText, Loader2 } from 'lucide-react' import { X, FileText, Loader2, ChevronDown } from 'lucide-react'
import { Combobox, Transition } from '@headlessui/react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' 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 { interface ReportMetadata {
key: string key: string
@@ -25,11 +31,12 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
currentUsername currentUsername
}) => { }) => {
const [reports, setReports] = useState<ReportMetadata[]>([]) const [reports, setReports] = useState<ReportMetadata[]>([])
const [selectedReportKey, setSelectedReportKey] = useState<string>('') const [selectedReport, setSelectedReport] = useState<ReportMetadata | null>(null)
const [reportContent, setReportContent] = useState<string>('') const [reportContent, setReportContent] = useState<string>('')
const [isLoadingList, setIsLoadingList] = useState<boolean>(false) const [isLoadingList, setIsLoadingList] = useState<boolean>(false)
const [isLoadingContent, setIsLoadingContent] = useState<boolean>(false) const [isLoadingContent, setIsLoadingContent] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -37,7 +44,7 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
} else { } else {
// Reset state when closed // Reset state when closed
setReports([]) setReports([])
setSelectedReportKey('') setSelectedReport(null)
setReportContent('') setReportContent('')
setError(null) setError(null)
} }
@@ -66,11 +73,10 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
} }
} }
const handleReportChange = async (e: React.ChangeEvent<HTMLSelectElement>) => { const handleReportChange = async (report: ReportMetadata | null) => {
const key = e.target.value setSelectedReport(report)
setSelectedReportKey(key)
if (!key) { if (!report) {
setReportContent('') setReportContent('')
return return
} }
@@ -78,7 +84,7 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
setIsLoadingContent(true) setIsLoadingContent(true)
setError(null) setError(null)
try { try {
const result = await window.electron.report.download(key) const result = await window.electron.report.download(report.key)
if (result.success && result.data) { if (result.success && result.data) {
setReportContent(result.data) setReportContent(result.data)
} else { } 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 if (!isOpen) return null
// Format date to local string // Format date to local string
@@ -131,33 +143,90 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<label className="text-sm font-medium text-slate-700 flex-shrink-0">:</label> <label className="text-sm font-medium text-slate-700 flex-shrink-0">:</label>
<div className="relative flex-1 max-w-2xl"> <div className="relative flex-1 max-w-2xl">
<select <Combobox value={selectedReport} onChange={handleReportChange} nullable>
value={selectedReportKey} <div className="relative">
onChange={handleReportChange} <Combobox.Input
disabled={isLoadingList} 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"
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" displayValue={(report: ReportMetadata | null) => {
> if (!report) return '请选择要查看的报告...'
<option value="">...</option> return `${isAdmin ? `[${report.username}] ` : ''}${report.filename} (${formatDate(report.lastModified)})`
{reports.map((report) => ( }}
<option key={report.key} value={report.key}> onChange={(event) => setQuery(event.target.value)}
{isAdmin ? `[${report.username}] ` : ''} placeholder="请选择要查看的报告..."
{report.filename} ({formatDate(report.lastModified)}) disabled={isLoadingList}
</option> />
))} <Combobox.Button className="absolute inset-y-0 right-0 flex items-center px-2 text-slate-500 hover:text-slate-700 transition-colors">
</select> <ChevronDown size={20} className="h-5 w-5" />
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500"> </Combobox.Button>
<svg </div>
className="fill-current h-4 w-4" <Transition
xmlns="http://www.w3.org/2000/svg" enter="transition duration-100 ease-out"
viewBox="0 0 20 20" 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"
> >
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" /> <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">
</svg> {filteredReports.length === 0 && query ? (
</div> <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> </div>
{isLoadingList && <Loader2 size={16} className="text-blue-500 animate-spin" />} {isLoadingList && <Loader2 size={16} className="text-blue-500 animate-spin" />}
</div> </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> </div>
{/* Content */} {/* Content */}
@@ -169,8 +238,13 @@ export const ReportViewerDialog: React.FC<ReportViewerDialogProps> = ({
</div> </div>
) : reportContent ? ( ) : reportContent ? (
<div className="h-full overflow-y-auto p-8"> <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"> <div className="markdown-body bg-white p-8 rounded-xl shadow-sm border border-slate-200">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{reportContent}</ReactMarkdown> <ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, rehypeAutolinkHeadings, rehypeHighlight]}
>
{reportContent}
</ReactMarkdown>
</div> </div>
</div> </div>
) : ( ) : (

View 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>
)
}

View File

@@ -2,3 +2,4 @@
declare const __APP_VERSION__: string declare const __APP_VERSION__: string
declare const __GIT_HASH__: string declare const __GIT_HASH__: string
declare const __APP_CHANNEL__: 'stable' | 'preview'

1
src/shared/app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare const __APP_CHANNEL__: 'stable' | 'preview'

View File

@@ -96,7 +96,16 @@ export const IPC_CHANNELS = {
// Report // Report
REPORT_LIST_ALL: 'report:listAll', REPORT_LIST_ALL: 'report:listAll',
REPORT_LIST_BY_USER: 'report:listByUser', 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 } as const
/** /**

View 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')
})
})