diff --git a/.gitignore b/.gitignore index ff9a4a0..23d7db7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ out # Test outputs coverage/ downloads/ +release-output/ +build/bin/ *.xlsx *.parsed.json @@ -41,4 +43,4 @@ logs nul # TypeScript incremental compilation cache -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo diff --git a/BROWSER_DEPLOYMENT.md b/BROWSER_DEPLOYMENT.md deleted file mode 100644 index 019a81a..0000000 --- a/BROWSER_DEPLOYMENT.md +++ /dev/null @@ -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) - 项目总体说明 diff --git a/BROWSER_VERSIONS.md b/BROWSER_VERSIONS.md deleted file mode 100644 index fa8b7d3..0000000 --- a/BROWSER_VERSIONS.md +++ /dev/null @@ -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 中定义版本号 diff --git a/CLAUDE.md b/CLAUDE.md index 87cb544..62311f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,141 +1,165 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +本文件用于给 AI 编程代理提供本项目的最小必要指导。 +目标不是替代 `README` 或 `docs/`,而是帮助代理快速理解项目结构、工作方式和关键约束。 -## Development Commands +## 项目概览 -### Running the Application +ERPAuto 是一个基于 Electron 的桌面应用,用于自动化处理 ERP 系统中的数据提取、清理、校验和配置管理。 + +技术栈: + +- Electron 39 +- React 19 +- TypeScript 5.9 +- electron-vite / Vite 7 +- Playwright 1.58 +- Vitest + +## 常用命令 + +开发与构建: ```bash -npm run dev # Start development server with hot reload -npm run build # Full build with type checking -npm run build:win # Build Windows executable +npm run dev +npm run build +npm run build:win ``` -### Code Quality +质量检查: ```bash -npm run lint # ESLint check -npm run format # Prettier format -npm run typecheck # TypeScript check (both main and renderer) -npm run typecheck:node # TypeScript check for main process only -npm run typecheck:web # TypeScript check for renderer only +npm run typecheck +npm run typecheck:node +npm run typecheck:web +npm run lint +npm run format ``` -### Testing +测试: ```bash -npm run test # Run unit tests (Vitest) -npm run test:coverage # Run tests with coverage report -npm run test:e2e # Run E2E tests (Playwright) -npm run test:e2e:ui # Run E2E tests with UI -npm run test:e2e:report # Show E2E test report +npm run test +npm run test:coverage +npm run test:e2e ``` -## Architecture Overview +发布: -ERPAuto is an **Electron desktop application** for automating ERP system data processing. The application follows the classic Electron architecture with three distinct processes: +```bash +npm run release:publish -- --channel stable +npm run release:publish -- --channel preview +``` -### Process Structure +详细发布流程见: +[docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md) -1. **Main Process** (`src/main/`) - - Node.js environment managing application lifecycle - - Entry point: `src/main/index.ts` - - Registers all IPC handlers via `registerIpcHandlers()` - - Loads configuration from `config.yaml` via ConfigManager at startup +## 代码结构 -2. **Preload Script** (`src/preload/`) - - Security bridge between main and renderer processes - - Exposes type-safe APIs via `contextBridge` as `window.electron` and `window.api` - - Central API surface organized by domain (auth, extractor, cleaner, database, etc.) +### 主进程 -3. **Renderer Process** (`src/renderer/`) - - React 19 + TypeScript UI - - Uses exposed preload APIs for all main process communication - - Authentication-based routing with role-based access control +- 路径:`src/main/` +- 入口:`src/main/index.ts` +- 职责: + - 应用生命周期管理 + - 配置加载 + - IPC 注册 + - 更新服务初始化 -### Service Architecture +### 预加载层 -The main process is organized around domain-specific services in `src/main/services/`: +- 路径:`src/preload/` +- 职责: + - 暴露 `window.electron` API + - 作为 renderer 和 main 之间的安全桥 -- **ERP Services** (`services/erp/`): Browser automation using Playwright - - `ExtractorService` - Downloads material plan data - - `CleanerService` - Deletes specified materials with dry-run support - - `ErpAuthService` - Handles ERP authentication - - `OrderResolverService` - Validates and resolves order numbers - - `locators.ts` - ERP element selectors +### 渲染进程 -- **Database Services** (`services/database/`): Dual database support - - `MySqlService` / `mysql.ts` - MySQL operations - - `SqlServerService` / `sql-server.ts` - SQL Server operations - - DAO pattern: `discrete-material-plan-dao.ts`, `materials-to-be-deleted-dao.ts` +- 路径:`src/renderer/` +- 技术:React + TypeScript +- 特点: + - 通过 preload 暴露的 API 调用主进程 + - 以登录状态和角色控制主要功能入口 -- **User Services** (`services/user/`): Authentication and session management - - `BipUsersDao` - User data access - - `SessionManager` - Active session tracking +### 服务层 -- **Other Services**: - - `config/` - Configuration management - - `excel/` - Excel file parsing +主进程服务集中在 `src/main/services/`,按领域拆分: -### IPC Handler Pattern +- `erp/`:ERP 浏览器自动化 +- `database/`:MySQL / SQL Server +- `user/`:登录、会话、用户切换 +- `config/`:YAML 配置管理 +- `update/`:便携版更新 +- `excel/`:Excel 处理 -All IPC communication follows a consistent pattern: +## 关键事实 -- Handlers are in `src/main/ipc/`, organized by domain (8 modules) -- Each handler module exports a `register*Handlers()` function -- All handlers are registered in `src/main/ipc/index.ts` -- Channel naming follows `domain:action` convention (e.g., `extractor:run`, `auth:login`) +### 配置系统 -### Authentication Flow +- 使用 YAML 配置 +- 模板文件:`config.template.yaml` +- 开发环境通常使用项目根目录下的 `config.yaml` +- 生产环境会将配置放到用户目录 -The application implements a multi-stage authentication system: +### IPC 组织方式 -1. **Silent Login**: On startup, attempts automatic login using computer name -2. **Fallback**: Shows login dialog if silent login fails -3. **Admin User Selection**: Admin users can switch to other user accounts -4. **Session Management**: Persistent sessions with role-based permissions (Admin/User/Guest) +- 所有 IPC handler 在 `src/main/ipc/` +- 每个领域一个 handler 模块 +- 统一在 `src/main/ipc/index.ts` 注册 +- channel 命名遵循 `domain:action` -Admin users see logout buttons and can access user switching. Non-admin users have restricted access based on the user who initiated their session. +### 认证与角色 -### Type System +当前主要角色是: -- Separate TypeScript configs: `tsconfig.node.json` (main/preload) and `tsconfig.web.json` (renderer) -- Types are co-located with features: `src/main/types/` contains domain-specific type definitions -- The preload script exposes a typed API surface that's available in renderer +- `Admin` +- `User` +- `Guest` -### Path Aliases +登录流程支持: -- `@renderer` → `src/renderer/src` (renderer process) -- `@main` → `src/main` (main process, tests only) -- `@services` → `src/main/services` (main process, tests only) -- `@types` → `src/main/types` (main process, tests only) +- 静默登录 +- 普通登录 +- 管理员切换用户 -## Configuration Management +### 便携版自动更新 -The application uses a YAML-based configuration system (`config.yaml`) managed by `ConfigManager`: +项目已实现 Windows 便携版更新,关键点: -- **Development**: `config.yaml` in project root (easy to edit and version control) -- **Production**: `config.yaml` in user data directory (AppData on Windows) +- 更新检查基于登录用户角色 +- 支持 `stable` / `preview` 双通道 +- `User` 只看 `stable` +- `Admin` 同时看 `stable` 和 `preview` +- 使用原生 `portable-updater.exe` 完成替换,不依赖 PowerShell -Key configurations in `config.yaml`: +相关文档: -- **ERP Settings**: URL (fixed infrastructure) -- **Database**: MySQL and SQL Server connection configs (dual support) -- **Paths**: Data directory and output file settings -- **Extraction**: Batch size, verbosity, persistence options -- **Validation**: Data source, batch size, match mode -- **Order Resolution**: Database table and field names for order number lookup +- [docs/portable-auto-update-architecture.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/portable-auto-update-architecture.md) +- [docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md) -Note: ERP credentials (username/password) are stored in the database (`dbo_BIPUsers` table) per user, managed via the Settings UI. +## 代理工作约束 -## Key Technologies +1. 优先修改现有文件,不要随意新建同类文件。 +2. 变更前先理解对应模块的现有模式,尽量保持风格一致。 +3. renderer 不要直接访问 Node/Electron 能力,统一走 preload。 +4. 配置、IPC、类型定义通常需要同步更新,避免只改一层。 +5. 涉及发布、更新、构建链路时,优先复用现有脚本,不要重复实现。 +6. 涉及浏览器部署或更新流程时,先看 `docs/` 里的专题文档。 -- **Electron 39** - Desktop framework -- **React 19** - UI framework -- **TypeScript 5.9** - Type safety -- **Playwright 1.58** - Browser automation for ERP interaction -- **electron-vite + Vite 7** - Build tooling -- **Zod** - Runtime validation -- **Vitest** - Unit tests -- **Playwright Test** - E2E tests +## 代理优先查看的文档 + +- [README.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/README.md) +- [docs/build-and-release-guide.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/build-and-release-guide.md) +- [docs/portable-auto-update-architecture.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/portable-auto-update-architecture.md) +- [docs/browser/PLAYWRIGHT_DEPLOYMENT.md](/d:/FileLib/Projects/CodeMigration/ERPAuto/docs/browser/PLAYWRIGHT_DEPLOYMENT.md) + +## 不放在这里的内容 + +以下内容不应继续堆在本文件中: + +- 详细用户使用说明 +- 大段业务流程说明 +- 重复的架构长文 +- 版本发布记录 + +这些内容应继续放在 `README` 或 `docs/` 下的专题文档中。 diff --git a/PLAYWRIGHT_DEPLOYMENT.md b/PLAYWRIGHT_DEPLOYMENT.md deleted file mode 100644 index 3e36a68..0000000 --- a/PLAYWRIGHT_DEPLOYMENT.md +++ /dev/null @@ -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-\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 模块和浏览器路径检查会自动处理。 diff --git a/build/PortableUpdater.cs b/build/PortableUpdater.cs new file mode 100644 index 0000000..5b5c968 --- /dev/null +++ b/build/PortableUpdater.cs @@ -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 ParseArgs(string[] args) + { + var result = new Dictionary(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 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(); + } + + 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 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("\"", "\\\"") + "\""; + } +} diff --git a/config.template.yaml b/config.template.yaml index 5696e51..179e859 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -70,3 +70,16 @@ rustfs: secretKey: '' # 密钥 bucket: 'erpauto' # 存储桶名称 region: 'us-east-1' # 区域(S3 兼容,默认即可) + +# 便携版自动更新配置 +update: + enabled: false + allowDevMode: false + endpoint: 'http://192.168.110.114:9000' + accessKey: '' + secretKey: '' + bucket: 'erpauto' + region: 'us-east-1' + basePrefix: 'updates/win-portable' + checkIntervalMinutes: 30 + maxAdminHistoryPerChannel: 10 diff --git a/docs/browser/BROWSER_VERSIONS.md b/docs/browser/BROWSER_VERSIONS.md new file mode 100644 index 0000000..70ff9e5 --- /dev/null +++ b/docs/browser/BROWSER_VERSIONS.md @@ -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-\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) diff --git a/docs/browser/PLAYWRIGHT_DEPLOYMENT.md b/docs/browser/PLAYWRIGHT_DEPLOYMENT.md new file mode 100644 index 0000000..c1efe86 --- /dev/null +++ b/docs/browser/PLAYWRIGHT_DEPLOYMENT.md @@ -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 当前实现就能正确识别并使用浏览器。 diff --git a/docs/build-and-release-guide.md b/docs/build-and-release-guide.md new file mode 100644 index 0000000..4584bdf --- /dev/null +++ b/docs/build-and-release-guide.md @@ -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/-rebuild.md` +2. 如果不存在,再查找 `docs/releases/.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//artifacts/...` +- `release-output/updates/win-portable//changelogs/...` +- `release-output/updates/win-portable//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--stable-portable.exe +│ ├── changelogs/ +│ │ └── .md +│ └── index.json +└── preview/ + ├── artifacts/ + │ └── erpauto--preview-portable.exe + ├── changelogs/ + │ └── .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 +``` + +只有在排查问题或需要特殊处理时,再退回分步命令。 diff --git a/docs/portable-auto-update-architecture.md b/docs/portable-auto-update-architecture.md new file mode 100644 index 0000000..1d76333 --- /dev/null +++ b/docs/portable-auto-update-architecture.md @@ -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--stable-portable.exe +updates/win-portable/stable/changelogs/.md + +updates/win-portable/preview/index.json +updates/win-portable/preview/artifacts/erpauto--preview-portable.exe +updates/win-portable/preview/changelogs/.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
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` 进一步拆分,降低文件复杂度 +- 将日志策略区分成“正式日志”和“诊断日志” +- 增加更多针对下载与安装阶段的单测 +- 将完整构建发布链路整理为一键化脚本 diff --git a/docs/releases/1.3.1-rebuild.md b/docs/releases/1.3.1-rebuild.md new file mode 100644 index 0000000..5688d95 --- /dev/null +++ b/docs/releases/1.3.1-rebuild.md @@ -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. diff --git a/docs/releases/1.3.2-rebuild.md b/docs/releases/1.3.2-rebuild.md new file mode 100644 index 0000000..25ec6e6 --- /dev/null +++ b/docs/releases/1.3.2-rebuild.md @@ -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. diff --git a/electron-builder.yml b/electron-builder.yml index fc5ef83..7217ef5 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -2,6 +2,9 @@ appId: com.electron.app productName: erpauto directories: buildResources: build +extraResources: + - from: build/bin/portable-updater.exe + to: portable-updater.exe files: - '!**/.vscode/*' - '!src/*' diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 5a6d959..e5082ca 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -18,14 +18,24 @@ const getGitHash = (): string => { const require = createRequire(import.meta.url) const version = require('./package.json').version const gitHash = getGitHash() +const appChannel = process.env.APP_CHANNEL === 'preview' ? 'preview' : 'stable' export default defineConfig({ - main: {}, - preload: {}, + main: { + define: { + __APP_CHANNEL__: JSON.stringify(appChannel) + } + }, + preload: { + define: { + __APP_CHANNEL__: JSON.stringify(appChannel) + } + }, renderer: { define: { __APP_VERSION__: JSON.stringify(version), - __GIT_HASH__: JSON.stringify(gitHash) + __GIT_HASH__: JSON.stringify(gitHash), + __APP_CHANNEL__: JSON.stringify(appChannel) }, resolve: { alias: { diff --git a/package-lock.json b/package-lock.json index 8f02c4e..64bdf87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "erpauto", - "version": "1.3.1", + "version": "1.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "erpauto", - "version": "1.3.1", + "version": "1.3.6", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-s3": "^3.929.0", @@ -1045,7 +1045,7 @@ } }, "node_modules/@azure/core-tracing": { - "version": "1.3.1", + "version": "1.3.2", "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", "license": "MIT", diff --git a/package.json b/package.json index d1e9b49..8326b0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "erpauto", - "version": "1.3.1", + "version": "1.3.6", "description": "An Electron application with React and TypeScript", "main": "./out/main/index.js", "author": "example.com", @@ -15,10 +15,14 @@ "dev": "chcp 65001 && electron-vite dev", "build": "chcp 65001 && npm run typecheck && electron-vite build", "postinstall": "electron-builder install-app-deps", - "build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build && electron-builder --dir", - "build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run prebuild && npm run build && electron-builder --win", + "build:updater": "node scripts/compile-updater.js", + "build:unpack": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run build:updater && npm run build && electron-builder --dir", + "build:win": "chcp 65001 && set PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 && npm run prebuild && npm run build && npm run build:updater && electron-builder --win", "build:mac": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --mac", "build:linux": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --linux", + "release:prepare": "node scripts/prepare-release.js", + "release:publish": "node scripts/publish-release.js", + "release:upload": "node scripts/upload-release.js", "prebuild": "node -e \"const fs=require('fs');['dist','out'].forEach(d=>{try{fs.rmSync(d,{recursive:true})}catch(e){}})\"", "test": "vitest", "test:run": "vitest run", diff --git a/scripts/compile-updater.js b/scripts/compile-updater.js new file mode 100644 index 0000000..9342816 --- /dev/null +++ b/scripts/compile-updater.js @@ -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) +} diff --git a/scripts/prepare-release.js b/scripts/prepare-release.js new file mode 100644 index 0000000..a303214 --- /dev/null +++ b/scripts/prepare-release.js @@ -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 --changelog [options] + +Options: + --channel Release channel. Required. + --changelog Markdown changelog file. Required. + --artifact Portable exe path. Default: dist/erpauto-portable.exe + --version Release version. Default: package.json version + --summary Optional short summary for notesSummary + --published-at Optional publish time. Default: current time + --base-prefix Default: updates/win-portable + --output Default: release-output + --existing-index 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) +}) diff --git a/scripts/publish-release.js b/scripts/publish-release.js new file mode 100644 index 0000000..d00559f --- /dev/null +++ b/scripts/publish-release.js @@ -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 [options] + +Options: + --channel Release channel. Required. + --changelog Optional changelog file. Default: auto-resolve from version + --config 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) +} diff --git a/scripts/upload-release.js b/scripts/upload-release.js new file mode 100644 index 0000000..627e750 --- /dev/null +++ b/scripts/upload-release.js @@ -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 [options] + +Options: + --channel Release channel. Required. + --source Local release root. Default: release-output + --config Config file path. Default: config.yaml + --version 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) +}) diff --git a/src/main/index.ts b/src/main/index.ts index e6da2de..5ad3023 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,6 +9,7 @@ import { logAudit } from './services/logger/audit-logger' import { fileURLToPath } from 'url' import { dirname } from 'path' import fs from 'fs' +import { UpdateService } from './services/update/update-service' // Set Playwright browsers path BEFORE any playwright import process.env.PLAYWRIGHT_BROWSERS_PATH = join(app.getPath('userData'), 'ms-playwright') @@ -110,6 +111,7 @@ app.whenReady().then(async () => { try { const configManager = ConfigManager.getInstance() await configManager.initialize() + UpdateService.getInstance().initialize() } catch (error) { console.error('Failed to initialize ConfigManager:', error) // Continue anyway - default config will be created diff --git a/src/main/ipc/auth-handler.ts b/src/main/ipc/auth-handler.ts index df03c81..d7bcddc 100644 --- a/src/main/ipc/auth-handler.ts +++ b/src/main/ipc/auth-handler.ts @@ -18,6 +18,7 @@ import type { UserInfo } from '../types/user.types' import { IPC_CHANNELS } from '../../shared/ipc-channels' import { ValidationError } from '../types/errors' import { withErrorHandling, type IpcResult } from './index' +import { UpdateService } from '../services/update/update-service' const log = createLogger('AuthHandler') @@ -70,6 +71,7 @@ export interface CurrentUserResponse { */ export function registerAuthHandlers(): void { const sessionManager = SessionManager.getInstance() + const updateService = UpdateService.getInstance() /** * Get computer name @@ -93,6 +95,8 @@ export function registerAuthHandlers(): void { const userInfo = sessionManager.getUserInfo() if (success && userInfo) { + await updateService.setUserContext(userInfo.userType) + // Check if admin needs user selection const requiresUserSelection = userInfo.userType === 'Admin' @@ -119,6 +123,7 @@ export function registerAuthHandlers(): void { } } + await updateService.setUserContext(null) throw new ValidationError('无感登录失败:未找到匹配用户', 'VAL_INVALID_INPUT') }, 'auth:silentLogin') } @@ -144,6 +149,7 @@ export function registerAuthHandlers(): void { if (success && userInfo) { log.info('Login successful', { username, userType: userInfo.userType }) + await updateService.setUserContext(userInfo.userType) // Audit log: LOGIN success (non-blocking) const os = await import('os') @@ -172,6 +178,7 @@ export function registerAuthHandlers(): void { }).catch((err) => log.warn('Failed to write audit log', { err })) log.warn('Login failed - invalid credentials', { username }) + await updateService.setUserContext(null) throw new ValidationError('用户名或密码错误', 'VAL_INVALID_INPUT') }, 'auth:login') } @@ -198,6 +205,7 @@ export function registerAuthHandlers(): void { } sessionManager.logout() + await updateService.setUserContext(null) }, 'auth:logout') }) @@ -241,6 +249,7 @@ export function registerAuthHandlers(): void { if (success) { const newUser = sessionManager.getUserInfo() log.info('User switch successful', { newUsername: newUser?.username }) + await updateService.setUserContext(newUser?.userType ?? null) return { success: true, userInfo: newUser ?? undefined diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index aa30e72..a274a00 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -15,6 +15,7 @@ import { registerMaterialTypeHandlers } from './material-type-handler' import { registerUserErpConfigHandlers } from './user-erp-config-handler' import { registerLoggerHandlers } from './logger-handler' import { registerReportHandlers } from './report-handler' +import { registerUpdateHandlers } from './update-handler' import { createLogger, logError } from '../services/logger' import { serializeError, sanitizeError } from '../services/logger/error-utils' import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors' @@ -105,5 +106,6 @@ export function registerIpcHandlers(): void { registerUserErpConfigHandlers() registerLoggerHandlers() registerReportHandlers() + registerUpdateHandlers() log.info('All IPC handlers registered') } diff --git a/src/main/ipc/update-handler.ts b/src/main/ipc/update-handler.ts new file mode 100644 index 0000000..0d91f57 --- /dev/null +++ b/src/main/ipc/update-handler.ts @@ -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> => { + return withErrorHandling(async () => updateService.getStatus(), 'update:getStatus') + }) + + ipcMain.handle(IPC_CHANNELS.UPDATE_CHECK_NOW, async (): Promise> => { + return withErrorHandling(async () => updateService.checkForUpdates(), 'update:checkNow') + }) + + ipcMain.handle( + IPC_CHANNELS.UPDATE_GET_CATALOG, + async (): Promise> => { + return withErrorHandling(async () => updateService.getCatalog(), 'update:getCatalog') + } + ) + + ipcMain.handle( + IPC_CHANNELS.UPDATE_GET_CHANGELOG, + async (_event, request: DownloadReleaseRequest): Promise> => { + return withErrorHandling( + async () => updateService.getChangelog(request), + 'update:getChangelog' + ) + } + ) + + ipcMain.handle( + IPC_CHANNELS.UPDATE_DOWNLOAD_RELEASE, + async (_event, request: DownloadReleaseRequest): Promise> => { + return withErrorHandling( + async () => updateService.downloadRelease(request), + 'update:downloadRelease' + ) + } + ) + + ipcMain.handle(IPC_CHANNELS.UPDATE_INSTALL_DOWNLOADED, async (): Promise> => { + return withErrorHandling( + async () => updateService.installDownloadedRelease(), + 'update:installDownloaded' + ) + }) +} diff --git a/src/main/services/config/config-manager.ts b/src/main/services/config/config-manager.ts index 128d0f9..8b7dd2b 100644 --- a/src/main/services/config/config-manager.ts +++ b/src/main/services/config/config-manager.ts @@ -102,6 +102,18 @@ const DEFAULT_CONFIG: FullConfig = { secretKey: '', bucket: 'erpauto', region: 'us-east-1' + }, + update: { + enabled: false, + allowDevMode: false, + endpoint: '', + accessKey: '', + secretKey: '', + bucket: '', + region: 'us-east-1', + basePrefix: 'updates/win-portable', + checkIntervalMinutes: 30, + maxAdminHistoryPerChannel: 10 } } diff --git a/src/main/services/update/update-service.ts b/src/main/services/update/update-service.ts new file mode 100644 index 0000000..3d7229e --- /dev/null +++ b/src/main/services/update/update-service.ts @@ -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): 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 { + 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) { + chunks.push(Buffer.from(chunk)) + } + return Buffer.concat(chunks).toString('utf-8') + } + + async downloadToFile(key: string, destination: string): Promise { + 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((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() + 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 { + 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 { + 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 { + 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 { + 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 { + 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): 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 { + 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 { + 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 { + 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 { + const hash = createHash('sha256') + const input = fs.createReadStream(filePath) + + await new Promise((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 { + 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 + } +} diff --git a/src/main/services/update/update-utils.ts b/src/main/services/update/update-utils.ts new file mode 100644 index 0000000..f26d012 --- /dev/null +++ b/src/main/services/update/update-utils.ts @@ -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 => !!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 + } +} diff --git a/src/main/types/config.schema.ts b/src/main/types/config.schema.ts index 6995d63..4d1cb83 100644 --- a/src/main/types/config.schema.ts +++ b/src/main/types/config.schema.ts @@ -143,6 +143,22 @@ export const rustfsConfigSchema = z.object({ region: z.string().default('us-east-1') }) +/** + * Update service configuration schema + */ +export const updateConfigSchema = z.object({ + enabled: z.boolean().default(false), + allowDevMode: z.boolean().default(false), + endpoint: z.string().default(''), + accessKey: z.string().default(''), + secretKey: z.string().default(''), + bucket: z.string().default(''), + region: z.string().default('us-east-1'), + basePrefix: z.string().default('updates/win-portable'), + checkIntervalMinutes: z.number().int().min(1).max(1440).default(30), + maxAdminHistoryPerChannel: z.number().int().min(1).max(100).default(10) +}) + /** * 完整应用配置 Schema */ @@ -155,7 +171,8 @@ export const fullConfigSchema = z.object({ cleaner: cleanerConfigSchema, orderResolution: orderResolutionSchema, logging: loggingConfigSchema, - rustfs: rustfsConfigSchema.optional() + rustfs: rustfsConfigSchema.optional(), + update: updateConfigSchema.optional() }) /** @@ -168,6 +185,7 @@ export type SqlServerConfig = z.infer export type ErpSystemConfig = z.infer export type LoggingConfig = z.infer export type RustfsConfig = z.infer +export type UpdateConfig = z.infer /** * 验证并解析配置 diff --git a/src/main/types/update.types.ts b/src/main/types/update.types.ts new file mode 100644 index 0000000..b2c0b97 --- /dev/null +++ b/src/main/types/update.types.ts @@ -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 + 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 +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index d5ff63e..496e16b 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -22,6 +22,11 @@ import type { import type { IpcResult } from '../main/ipc' import type { LogLevel } from '../shared/ipc-channels' import type { CleanerConfig } from '../main/types/config.schema' +import type { + DownloadReleaseRequest, + UpdateDialogCatalog, + UpdateStatus +} from '../main/types/update.types' export interface ResolverAPI { resolve: (input: ResolverInput) => Promise> @@ -120,6 +125,16 @@ export interface LoggerAPI { log: (level: LogLevel, message: string, context?: Record) => void } +export interface UpdateAPI { + getStatus: () => Promise> + checkNow: () => Promise> + getCatalog: () => Promise> + getChangelog: (release: DownloadReleaseRequest) => Promise> + downloadRelease: (release: DownloadReleaseRequest) => Promise> + installDownloaded: () => Promise> + onStatusChanged: (callback: (data: UpdateStatus) => void) => () => void +} + export interface ProcessAPI { versions: { electron: string @@ -146,6 +161,7 @@ declare global { config: ConfigAPI logger: LoggerAPI report: ReportAPI + update: UpdateAPI } api: unknown } diff --git a/src/preload/index.ts b/src/preload/index.ts index f0c7a77..6c32efe 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -13,6 +13,8 @@ import type { import type { IpcResult } from '../main/ipc' import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels' import type { CleanerConfig } from '../main/types/config.schema' +import type { DownloadReleaseRequest, UpdateStatus } from '../main/types/update.types' +import type { UpdateDialogCatalog } from '../main/types/update.types' type ErpSettingsPayload = { erp?: { @@ -218,6 +220,25 @@ const api = { listByUser: (username: string): Promise => invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username), download: (key: string): Promise => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key) + }, + + update: { + getStatus: (): Promise> => invokeIpc(IPC_CHANNELS.UPDATE_GET_STATUS), + checkNow: (): Promise> => invokeIpc(IPC_CHANNELS.UPDATE_CHECK_NOW), + getCatalog: (): Promise> => + invokeIpc(IPC_CHANNELS.UPDATE_GET_CATALOG), + getChangelog: (release: DownloadReleaseRequest): Promise> => + invokeIpc(IPC_CHANNELS.UPDATE_GET_CHANGELOG, release), + downloadRelease: (release: DownloadReleaseRequest): Promise> => + invokeIpc(IPC_CHANNELS.UPDATE_DOWNLOAD_RELEASE, release), + installDownloaded: (): Promise> => + 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 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 63bbeb6..06d42a0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,7 +9,17 @@ */ import React, { useState, useEffect } from 'react' -import { LayoutDashboard, Download, Trash2, Settings, Database, User, LogOut } from 'lucide-react' +import { + LayoutDashboard, + Download, + Trash2, + Settings, + Database, + User, + LogOut, + ArrowUpCircle, + LoaderCircle +} from 'lucide-react' import { useLogger } from './hooks/useLogger' import LoginDialog from './components/LoginDialog' import UserSelectionDialog, { @@ -19,6 +29,12 @@ import { Toast } from './components/ui/Toast' import ExtractorPage from './pages/ExtractorPage' import CleanerPage from './pages/CleanerPage' import SettingsPage from './pages/SettingsPage' +import UpdateDialog from './components/UpdateDialog' +import type { + DownloadReleaseRequest, + UpdateDialogCatalog, + UpdateStatus +} from '../../main/types/update.types' type Page = 'home' | 'extractor' | 'cleaner' | 'settings' @@ -51,6 +67,9 @@ function App(): React.JSX.Element { // Navigation state const [currentPage, setCurrentPage] = useState('extractor') // Default to extractor for the new layout + const [updateStatus, setUpdateStatus] = useState(null) + const [updateCatalog, setUpdateCatalog] = useState(null) + const [showUpdateDialog, setShowUpdateDialog] = useState(false) // Load error message from sessionStorage const showError = (message: string) => { @@ -65,6 +84,46 @@ function App(): React.JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + useEffect(() => { + const unsubscribe = window.electron.update.onStatusChanged((status) => { + setUpdateStatus(status) + if (status.phase === 'available' || status.phase === 'downloaded' || status.phase === 'idle') { + void refreshUpdateCatalog() + } + }) + + void refreshUpdateState() + + return unsubscribe + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (isAuthenticated) { + void refreshUpdateState() + void refreshUpdateCatalog() + return + } + + setUpdateStatus(null) + setUpdateCatalog(null) + setShowUpdateDialog(false) + }, [isAuthenticated]) + + const refreshUpdateState = async () => { + const result = await window.electron.update.getStatus() + if (result.success && result.data) { + setUpdateStatus(result.data) + } + } + + const refreshUpdateCatalog = async () => { + const result = await window.electron.update.getCatalog() + if (result.success && result.data) { + setUpdateCatalog(result.data) + } + } + const initializeAuth = async () => { logger.info('=== Starting initializeAuth ===') try { @@ -195,6 +254,44 @@ function App(): React.JSX.Element { setShowLoginDialog(true) } + const openUpdateDialog = async () => { + await refreshUpdateCatalog() + setShowUpdateDialog(true) + } + + const handleInstallUserRelease = async () => { + if (!updateCatalog?.recommendedRelease) { + showError('暂无可安装更新') + return + } + + if (updateStatus?.phase !== 'downloaded') { + const downloadResult = await window.electron.update.downloadRelease(updateCatalog.recommendedRelease) + if (!downloadResult.success) { + showError(downloadResult.error || '下载更新失败') + return + } + } + + const installResult = await window.electron.update.installDownloaded() + if (!installResult.success) { + showError(installResult.error || '启动安装失败') + } + } + + const handleAdminDownloadAndInstall = async (release: DownloadReleaseRequest) => { + const downloadResult = await window.electron.update.downloadRelease(release) + if (!downloadResult.success) { + showError(downloadResult.error || '下载更新失败') + return + } + + const installResult = await window.electron.update.installDownloaded() + if (!installResult.success) { + showError(installResult.error || '启动安装失败') + } + } + // Check if should show logout button // Show logout if: user is Admin, OR user was switched by Admin const shouldShowLogout = currentUser?.userType === 'Admin' || isSwitchedByAdmin @@ -317,6 +414,24 @@ function App(): React.JSX.Element { { id: 'settings', label: '系统设置 (Settings)', icon: } ] + 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 (
{/* ================= 顶部导航与标题栏 ================= */} @@ -370,6 +485,20 @@ function App(): React.JSX.Element { className="flex items-center gap-4 text-sm" style={{ WebkitAppRegion: 'no-drag' } as any} > + {showUpdateEntry && ( + + )}
数据库已连接 @@ -418,6 +547,22 @@ function App(): React.JSX.Element { {/* Toast Notifications */} + setShowUpdateDialog(false)} + onInstallUserRelease={handleInstallUserRelease} + onDownloadAndInstallAdminRelease={async (release) => + handleAdminDownloadAndInstall(release) + } + onRefreshCatalog={async () => { + await window.electron.update.checkNow() + await refreshUpdateCatalog() + await refreshUpdateState() + }} + />
) } diff --git a/src/renderer/src/components/UpdateDialog.tsx b/src/renderer/src/components/UpdateDialog.tsx new file mode 100644 index 0000000..cf93498 --- /dev/null +++ b/src/renderer/src/components/UpdateDialog.tsx @@ -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 + onDownloadAndInstallAdminRelease: (release: DownloadReleaseRequest) => Promise + onRefreshCatalog: () => Promise +} + +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(undefined) + const [changelog, setChangelog] = useState('') + 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 ( +
+ 暂无版本 +
+ ) + } + + return ( +
+
{title}
+ {releases.map((release) => { + const selected = selectedRelease && releaseKey(selectedRelease) === releaseKey(release) + return ( + + ) + })} +
+ ) + } + + return ( + undefined : onClose} + title="应用更新" + size="3xl" + disableBackdropClick={isBusy} + disableEscapeKey={isBusy} + > +
+
+
+ 当前版本 V{status?.currentVersion} + + {status?.currentChannel ?? __APP_CHANNEL__} + +
+ +
+ + {catalog?.mode === 'admin' ? ( +
+
+ {renderReleaseList('Stable', catalog.channels?.stable ?? [])} + {renderReleaseList('Preview', catalog.channels?.preview ?? [])} +
+
+
+
+ {selectedRelease + ? `${selectedRelease.channel.toUpperCase()} V${selectedRelease.version}` + : '请选择一个版本'} +
+
+ {selectedRelease?.notesSummary ?? '选择版本后可查看详细更新说明。'} +
+
+
+ {isLoadingChangelog ? ( +
+ + 正在加载更新说明... +
+ ) : ( + + {changelog || '暂无更新说明。'} + + )} +
+
+
+ ) : ( +
+
+
+ {selectedRelease ? `发现稳定版 V${selectedRelease.version}` : '暂无可用更新'} +
+
+ {status?.currentChannel === 'preview' + ? '当前设备正在运行预览版,普通用户将更新回稳定版。' + : '更新包已在后台准备完成,确认后将自动关闭并重启应用。'} +
+
+
+ {isLoadingChangelog ? ( +
+ + 正在加载更新说明... +
+ ) : ( + + {changelog || '暂无更新说明。'} + + )} +
+
+ )} + + {status?.error && ( +
+ {status.error} +
+ )} + +
+
{status?.message ?? ' '}
+
+ + {userType === 'Admin' ? ( + + ) : ( + + )} +
+
+
+
+ ) +} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 1bd8bea..ee8e275 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -2,3 +2,4 @@ declare const __APP_VERSION__: string declare const __GIT_HASH__: string +declare const __APP_CHANNEL__: 'stable' | 'preview' diff --git a/src/shared/app-env.d.ts b/src/shared/app-env.d.ts new file mode 100644 index 0000000..64d6cc7 --- /dev/null +++ b/src/shared/app-env.d.ts @@ -0,0 +1 @@ +declare const __APP_CHANNEL__: 'stable' | 'preview' diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index eae2013..baacf94 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -96,7 +96,16 @@ export const IPC_CHANNELS = { // Report REPORT_LIST_ALL: 'report:listAll', REPORT_LIST_BY_USER: 'report:listByUser', - REPORT_DOWNLOAD: 'report:download' + REPORT_DOWNLOAD: 'report:download', + + // Update + UPDATE_GET_STATUS: 'update:getStatus', + UPDATE_CHECK_NOW: 'update:checkNow', + UPDATE_GET_CATALOG: 'update:getCatalog', + UPDATE_GET_CHANGELOG: 'update:getChangelog', + UPDATE_DOWNLOAD_RELEASE: 'update:downloadRelease', + UPDATE_INSTALL_DOWNLOADED: 'update:installDownloaded', + UPDATE_STATUS_CHANGED: 'update:onStatusChanged' } as const /** diff --git a/tests/unit/update-utils.test.ts b/tests/unit/update-utils.test.ts new file mode 100644 index 0000000..369175d --- /dev/null +++ b/tests/unit/update-utils.test.ts @@ -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') + }) +})