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/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/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..f7ad7ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "erpauto", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "erpauto", - "version": "1.3.1", + "version": "1.3.2", "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..a150559 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "erpauto", - "version": "1.3.1", + "version": "1.3.2", "description": "An Electron application with React and TypeScript", "main": "./out/main/index.js", "author": "example.com", @@ -15,10 +15,13 @@ "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: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/upload-release.js b/scripts/upload-release.js new file mode 100644 index 0000000..613ab25 --- /dev/null +++ b/scripts/upload-release.js @@ -0,0 +1,156 @@ +#!/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 + --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 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 walkFiles(dirPath) { + const results = [] + const entries = fs.readdirSync(dirPath, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name) + if (entry.isDirectory()) { + results.push(...walkFiles(fullPath)) + } else { + results.push(fullPath) + } + } + + return results +} + +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') +} + +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 uploadRoot = path.join(sourceRoot, updateConfig.basePrefix, channel) + assert(fs.existsSync(uploadRoot), `Upload root not found: ${uploadRoot}`) + + const client = new S3Client({ + region: updateConfig.region || 'us-east-1', + endpoint: updateConfig.endpoint, + credentials: { + accessKeyId: updateConfig.accessKey, + secretAccessKey: updateConfig.secretKey + }, + forcePathStyle: true + }) + + const files = walkFiles(uploadRoot) + assert(files.length > 0, `No files found under ${uploadRoot}`) + + for (const filePath of files) { + const relative = path.relative(sourceRoot, filePath).replace(/\\/g, '/') + const body = fs.readFileSync(filePath) + + await client.send( + new PutObjectCommand({ + Bucket: updateConfig.bucket, + Key: relative, + Body: body, + ContentType: getContentType(filePath) + }) + ) + + console.log(`Uploaded: ${relative}`) + } + + if (args.verify) { + const indexKey = `${updateConfig.basePrefix}/${channel}/index.json` + 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') + }) +})