feat: rebuild portable auto-update flow

This commit is contained in:
Misaka
2026-03-20 21:20:28 +08:00
parent 5e50a8fbcf
commit 6ad9463e73
29 changed files with 2333 additions and 13 deletions

2
.gitignore vendored
View File

@@ -15,6 +15,8 @@ out
# Test outputs
coverage/
downloads/
release-output/
build/bin/
*.xlsx
*.parsed.json

247
build/PortableUpdater.cs Normal file
View File

@@ -0,0 +1,247 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
internal static class PortableUpdater
{
private static string _logPath = string.Empty;
private static int Main(string[] args)
{
try
{
var options = ParseArgs(args);
var targetExe = Require(options, "--targetExe");
var downloadedExe = Require(options, "--downloadedExe");
var parentPid = int.Parse(Require(options, "--parentPid"));
_logPath = Require(options, "--logPath");
var argsBase64 = options.ContainsKey("--argsBase64") ? options["--argsBase64"] : string.Empty;
WriteLog("Portable updater started");
WriteLog("Target exe: " + targetExe);
WriteLog("Downloaded exe: " + downloadedExe);
WriteLog("Parent pid: " + parentPid);
var appArgs = DecodeArgs(argsBase64);
WaitForProcessExit(parentPid, 120);
WaitForFileAvailable(targetExe, 120);
var backupExe = targetExe + ".bak";
if (File.Exists(backupExe))
{
WriteLog("Removing stale backup: " + backupExe);
File.Delete(backupExe);
}
WriteLog("Backing up current executable");
File.Move(targetExe, backupExe);
try
{
WriteLog("Replacing executable");
File.Move(downloadedExe, targetExe);
}
catch (Exception replaceError)
{
WriteLog("Replace failed: " + replaceError.Message);
if (File.Exists(backupExe) && !File.Exists(targetExe))
{
File.Move(backupExe, targetExe);
}
throw;
}
var startInfo = new ProcessStartInfo
{
FileName = targetExe,
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(targetExe) ?? Environment.CurrentDirectory,
Arguments = BuildArgumentString(appArgs)
};
WriteLog("Launching updated executable");
Process.Start(startInfo);
if (File.Exists(backupExe))
{
WriteLog("Removing backup file");
File.Delete(backupExe);
}
WriteLog("Portable update completed successfully");
return 0;
}
catch (Exception ex)
{
WriteLog("Portable update failed: " + ex);
return 1;
}
}
private static Dictionary<string, string> ParseArgs(string[] args)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var key = args[i];
if (!key.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var value = i + 1 < args.Length ? args[i + 1] : string.Empty;
if (value.StartsWith("--", StringComparison.Ordinal))
{
result[key] = string.Empty;
continue;
}
result[key] = value;
i++;
}
return result;
}
private static string Require(Dictionary<string, string> options, string key)
{
if (!options.ContainsKey(key) || string.IsNullOrWhiteSpace(options[key]))
{
throw new InvalidOperationException("Missing required argument: " + key);
}
return options[key];
}
private static string[] DecodeArgs(string argsBase64)
{
if (string.IsNullOrWhiteSpace(argsBase64))
{
return Array.Empty<string>();
}
var raw = Encoding.UTF8.GetString(Convert.FromBase64String(argsBase64));
return raw.Split(new[] { '\0' }, StringSplitOptions.RemoveEmptyEntries);
}
private static void WaitForProcessExit(int pid, int timeoutSeconds)
{
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (DateTime.UtcNow < deadline)
{
try
{
using (var process = Process.GetProcessById(pid))
{
if (process.HasExited)
{
WriteLog("Parent process exited");
return;
}
}
}
catch (ArgumentException)
{
WriteLog("Parent process already exited");
return;
}
Thread.Sleep(500);
}
throw new TimeoutException("Timed out waiting for process exit: " + pid);
}
private static void WaitForFileAvailable(string filePath, int timeoutSeconds)
{
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (DateTime.UtcNow < deadline)
{
try
{
using (File.Open(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
WriteLog("Target executable is no longer locked");
return;
}
}
catch (IOException)
{
Thread.Sleep(500);
}
catch (UnauthorizedAccessException)
{
Thread.Sleep(500);
}
}
throw new TimeoutException("Timed out waiting for target executable to become writable: " + filePath);
}
private static void WriteLog(string message)
{
if (string.IsNullOrWhiteSpace(_logPath))
{
return;
}
try
{
var directory = Path.GetDirectoryName(_logPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
File.AppendAllText(
_logPath,
DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff") + " " + message + Environment.NewLine,
Encoding.UTF8
);
}
catch
{
// Best effort logging only.
}
}
private static string BuildArgumentString(IEnumerable<string> args)
{
var builder = new StringBuilder();
foreach (var arg in args)
{
if (builder.Length > 0)
{
builder.Append(' ');
}
builder.Append(QuoteArgument(arg));
}
return builder.ToString();
}
private static string QuoteArgument(string arg)
{
if (string.IsNullOrEmpty(arg))
{
return "\"\"";
}
if (arg.IndexOfAny(new[] { ' ', '\t', '"' }) < 0)
{
return arg;
}
return "\"" + arg.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
}
}

View File

@@ -70,3 +70,16 @@ rustfs:
secretKey: '<YOUR_SECRET_KEY>' # 密钥
bucket: 'erpauto' # 存储桶名称
region: 'us-east-1' # 区域S3 兼容,默认即可)
# 便携版自动更新配置
update:
enabled: false
allowDevMode: false
endpoint: 'http://192.168.110.114:9000'
accessKey: '<YOUR_ACCESS_KEY>'
secretKey: '<YOUR_SECRET_KEY>'
bucket: 'erpauto'
region: 'us-east-1'
basePrefix: 'updates/win-portable'
checkIntervalMinutes: 30
maxAdminHistoryPerChannel: 10

View File

@@ -0,0 +1,11 @@
# 1.3.1 Rebuild
## Highlights
- Rebuilt the portable auto-update flow on a clean branch.
- Added role-aware update catalog handling for `Stable` and `Preview`.
- Added native `portable-updater.exe` handoff for portable upgrades.
## Notes
- This release is intended for rebuild validation on the new implementation branch.

View File

@@ -0,0 +1,11 @@
# 1.3.2 Rebuild
## Highlights
- Published the clean-branch portable auto-update implementation.
- Added role-aware update checks, changelog loading, and update dialog UI.
- Added native `portable-updater.exe` build and packaging flow.
## Notes
- This release is intended to validate `1.3.1 -> 1.3.2` upgrade flow on the rebuilt implementation.

View File

@@ -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/*'

View File

@@ -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: {

6
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const rootDir = process.cwd()
const sourcePath = path.join(rootDir, 'build', 'PortableUpdater.cs')
const outputDir = path.join(rootDir, 'build', 'bin')
const outputPath = path.join(outputDir, 'portable-updater.exe')
function findCompiler() {
const candidates = [
'C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe',
'C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'
]
return candidates.find((candidate) => fs.existsSync(candidate)) || null
}
function main() {
if (process.platform !== 'win32') {
console.log('Skipping updater compilation on non-Windows platform.')
return
}
if (!fs.existsSync(sourcePath)) {
throw new Error(`Updater source not found: ${sourcePath}`)
}
const compiler = findCompiler()
if (!compiler) {
throw new Error('Unable to find csc.exe for compiling portable-updater.exe')
}
fs.mkdirSync(outputDir, { recursive: true })
const compileArgs = ['/nologo', '/target:exe', '/optimize+', `/out:${outputPath}`, sourcePath]
const result = spawnSync(compiler, compileArgs, {
cwd: rootDir,
stdio: 'inherit'
})
if (result.status !== 0) {
throw new Error(`csc.exe failed with exit code ${result.status}`)
}
console.log(`Portable updater compiled successfully: ${outputPath}`)
}
try {
main()
} catch (error) {
console.error(`compile-updater failed: ${error.message}`)
process.exit(1)
}

193
scripts/prepare-release.js Normal file
View File

@@ -0,0 +1,193 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
function printUsage() {
console.log(`
Usage:
node scripts/prepare-release.js --channel <stable|preview> --changelog <file> [options]
Options:
--channel <stable|preview> Release channel. Required.
--changelog <file> Markdown changelog file. Required.
--artifact <file> Portable exe path. Default: dist/erpauto-portable.exe
--version <x.y.z> Release version. Default: package.json version
--summary <text> Optional short summary for notesSummary
--published-at <ISO date> Optional publish time. Default: current time
--base-prefix <prefix> Default: updates/win-portable
--output <dir> Default: release-output
--existing-index <file> Existing index.json to merge with
`)
}
function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i]
if (!token.startsWith('--')) continue
const key = token.slice(2)
const value = argv[i + 1]
if (!value || value.startsWith('--')) {
args[key] = true
continue
}
args[key] = value
i += 1
}
return args
}
function assert(condition, message) {
if (!condition) {
throw new Error(message)
}
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true })
}
function sha256File(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256')
const stream = fs.createReadStream(filePath)
stream.on('data', (chunk) => hash.update(chunk))
stream.on('error', reject)
stream.on('end', () => resolve(hash.digest('hex')))
})
}
function loadExistingIndex(existingIndexPath, outputIndexPath) {
const candidate = existingIndexPath || (fs.existsSync(outputIndexPath) ? outputIndexPath : null)
if (!candidate) {
return { releases: [] }
}
const parsed = readJson(candidate)
if (!parsed || !Array.isArray(parsed.releases)) {
throw new Error(`Invalid index file: ${candidate}`)
}
return parsed
}
function sortReleases(releases) {
return [...releases].sort((left, right) => {
const leftParts = String(left.version)
.split('.')
.map((part) => Number.parseInt(part, 10) || 0)
const rightParts = String(right.version)
.split('.')
.map((part) => Number.parseInt(part, 10) || 0)
const length = Math.max(leftParts.length, rightParts.length)
for (let i = 0; i < length; i += 1) {
const l = leftParts[i] || 0
const r = rightParts[i] || 0
if (r !== l) {
return r - l
}
}
const leftTime = new Date(left.publishedAt).getTime()
const rightTime = new Date(right.publishedAt).getTime()
if (rightTime !== leftTime) {
return rightTime - leftTime
}
return 0
})
}
async function main() {
const args = parseArgs(process.argv.slice(2))
if (args.help || args.h) {
printUsage()
process.exit(0)
}
const packageJson = readJson(path.resolve(process.cwd(), 'package.json'))
const version = args.version || packageJson.version
const channel = args.channel
const changelogPath = args.changelog
const artifactPath = path.resolve(process.cwd(), args.artifact || 'dist/erpauto-portable.exe')
const basePrefix = args['base-prefix'] || 'updates/win-portable'
const outputRoot = path.resolve(process.cwd(), args.output || 'release-output')
const publishedAt = args['published-at'] || new Date().toISOString()
const summary = args.summary
assert(channel === 'stable' || channel === 'preview', 'Missing or invalid --channel')
assert(changelogPath, 'Missing --changelog')
assert(fs.existsSync(artifactPath), `Artifact not found: ${artifactPath}`)
const resolvedChangelogPath = path.resolve(process.cwd(), changelogPath)
assert(fs.existsSync(resolvedChangelogPath), `Changelog not found: ${resolvedChangelogPath}`)
const artifactStat = fs.statSync(artifactPath)
const sha256 = await sha256File(artifactPath)
const fileName = `erpauto-${version}-${channel}-portable.exe`
const changelogFileName = `${version}.md`
const channelDir = path.join(outputRoot, basePrefix, channel)
const artifactsDir = path.join(channelDir, 'artifacts')
const changelogDir = path.join(channelDir, 'changelogs')
const outputIndexPath = path.join(channelDir, 'index.json')
ensureDir(artifactsDir)
ensureDir(changelogDir)
const targetArtifactPath = path.join(artifactsDir, fileName)
const targetChangelogPath = path.join(changelogDir, changelogFileName)
fs.copyFileSync(artifactPath, targetArtifactPath)
fs.copyFileSync(resolvedChangelogPath, targetChangelogPath)
const releaseEntry = {
version,
channel,
artifactKey: `${basePrefix}/${channel}/artifacts/${fileName}`,
sha256,
size: artifactStat.size,
publishedAt,
changelogKey: `${basePrefix}/${channel}/changelogs/${changelogFileName}`,
...(summary ? { notesSummary: summary } : {})
}
const existingIndex = loadExistingIndex(
args['existing-index'] ? path.resolve(process.cwd(), args['existing-index']) : null,
outputIndexPath
)
const mergedReleases = sortReleases([
releaseEntry,
...existingIndex.releases.filter(
(item) => !(item.version === version && item.channel === channel)
)
])
fs.writeFileSync(
outputIndexPath,
`${JSON.stringify({ releases: mergedReleases }, null, 2)}\n`,
'utf-8'
)
console.log('Release package prepared successfully.')
console.log(`Channel: ${channel}`)
console.log(`Version: ${version}`)
console.log(`Artifact: ${targetArtifactPath}`)
console.log(`Changelog: ${targetChangelogPath}`)
console.log(`Index: ${outputIndexPath}`)
console.log(`SHA256: ${sha256}`)
}
main().catch((error) => {
console.error(`prepare-release failed: ${error.message}`)
process.exit(1)
})

156
scripts/upload-release.js Normal file
View File

@@ -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 <stable|preview> [options]
Options:
--channel <stable|preview> Release channel. Required.
--source <dir> Local release root. Default: release-output
--config <file> Config file path. Default: config.yaml
--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)
})

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { withErrorHandling, type IpcResult } from './index'
import { UpdateService } from '../services/update/update-service'
import type {
DownloadReleaseRequest,
UpdateDialogCatalog,
UpdateStatus
} from '../types/update.types'
export function registerUpdateHandlers(): void {
const updateService = UpdateService.getInstance()
ipcMain.handle(IPC_CHANNELS.UPDATE_GET_STATUS, async (): Promise<IpcResult<UpdateStatus>> => {
return withErrorHandling(async () => updateService.getStatus(), 'update:getStatus')
})
ipcMain.handle(IPC_CHANNELS.UPDATE_CHECK_NOW, async (): Promise<IpcResult<UpdateStatus>> => {
return withErrorHandling(async () => updateService.checkForUpdates(), 'update:checkNow')
})
ipcMain.handle(
IPC_CHANNELS.UPDATE_GET_CATALOG,
async (): Promise<IpcResult<UpdateDialogCatalog>> => {
return withErrorHandling(async () => updateService.getCatalog(), 'update:getCatalog')
}
)
ipcMain.handle(
IPC_CHANNELS.UPDATE_GET_CHANGELOG,
async (_event, request: DownloadReleaseRequest): Promise<IpcResult<string>> => {
return withErrorHandling(
async () => updateService.getChangelog(request),
'update:getChangelog'
)
}
)
ipcMain.handle(
IPC_CHANNELS.UPDATE_DOWNLOAD_RELEASE,
async (_event, request: DownloadReleaseRequest): Promise<IpcResult<UpdateStatus>> => {
return withErrorHandling(
async () => updateService.downloadRelease(request),
'update:downloadRelease'
)
}
)
ipcMain.handle(IPC_CHANNELS.UPDATE_INSTALL_DOWNLOADED, async (): Promise<IpcResult<void>> => {
return withErrorHandling(
async () => updateService.installDownloadedRelease(),
'update:installDownloaded'
)
})
}

View File

@@ -102,6 +102,18 @@ const DEFAULT_CONFIG: FullConfig = {
secretKey: '',
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
}
}

View File

@@ -0,0 +1,650 @@
import { BrowserWindow, app } from 'electron'
import { createHash } from 'crypto'
import * as fs from 'fs'
import * as path from 'path'
import { spawn } from 'child_process'
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { ConfigManager } from '../config/config-manager'
import { createLogger } from '../logger'
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
import type { UpdateConfig } from '../../types/config.schema'
import type { UserType } from '../../types/user.types'
import type {
DownloadReleaseRequest,
DownloadedRelease,
ReleaseChannel,
UpdateCatalog,
UpdateDialogCatalog,
UpdateRelease,
UpdateStatus
} from '../../types/update.types'
import {
compareVersions,
limitCatalogHistory,
normalizeReleases,
resolveAdminDecision,
resolveUserDecision
} from './update-utils'
const log = createLogger('UpdateService')
function appendPortableLaunchLog(logPath: string, message: string, meta?: Record<string, unknown>): void {
try {
fs.mkdirSync(path.dirname(logPath), { recursive: true })
const timestamp = new Date().toISOString()
const suffix = meta ? ` ${JSON.stringify(meta)}` : ''
fs.appendFileSync(logPath, `${timestamp} ${message}${suffix}\n`, 'utf-8')
} catch {
// Best-effort debug log only.
}
}
function getCurrentAppVersion(): string {
return typeof app.getVersion === 'function' ? app.getVersion() : '0.0.0'
}
function getCurrentChannel(): ReleaseChannel {
return typeof __APP_CHANNEL__ !== 'undefined' ? __APP_CHANNEL__ : 'stable'
}
function isMissingObjectError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false
}
const candidate = error as {
name?: string
Code?: string
code?: string
message?: string
}
return (
candidate.name === 'NoSuchKey' ||
candidate.Code === 'NoSuchKey' ||
candidate.code === 'NoSuchKey' ||
candidate.message?.includes('The specified key does not exist') === true
)
}
function getSupportState(config: UpdateConfig | null): {
supported: boolean
reason?: string
} {
if (process.platform !== 'win32') {
return { supported: false, reason: '当前仅支持 Windows 自动更新' }
}
if (app.isPackaged) {
return { supported: true }
}
if (config?.allowDevMode) {
return { supported: true, reason: '开发模式调试已启用更新检查' }
}
return { supported: false, reason: '开发模式默认禁用自动更新检查' }
}
const DEFAULT_STATUS: UpdateStatus = {
enabled: false,
supported: false,
phase: 'idle',
currentVersion: getCurrentAppVersion(),
currentChannel: getCurrentChannel(),
currentUserType: null
}
class UpdateStorageClient {
private client: S3Client
private bucket: string
constructor(config: UpdateConfig) {
this.bucket = config.bucket
this.client = new S3Client({
region: config.region,
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey
},
forcePathStyle: true
})
}
async readText(key: string): Promise<string> {
const response = await this.client.send(
new GetObjectCommand({
Bucket: this.bucket,
Key: key
})
)
const chunks: Buffer[] = []
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(Buffer.from(chunk))
}
return Buffer.concat(chunks).toString('utf-8')
}
async downloadToFile(key: string, destination: string): Promise<void> {
const response = await this.client.send(
new GetObjectCommand({
Bucket: this.bucket,
Key: key
})
)
await fs.promises.mkdir(path.dirname(destination), { recursive: true })
const output = fs.createWriteStream(destination)
const body = response.Body as NodeJS.ReadableStream
await new Promise<void>((resolve, reject) => {
body.on('error', reject)
output.on('error', reject)
output.on('finish', resolve)
body.pipe(output)
})
}
}
export class UpdateService {
private static instance: UpdateService | null = null
private config: UpdateConfig | null = null
private storageClient: UpdateStorageClient | null = null
private status: UpdateStatus = { ...DEFAULT_STATUS }
private catalog: UpdateCatalog = { stable: [], preview: [] }
private changelogCache = new Map<string, string>()
private intervalHandle: NodeJS.Timeout | null = null
private initialized = false
public static getInstance(): UpdateService {
if (!UpdateService.instance) {
UpdateService.instance = new UpdateService()
}
return UpdateService.instance
}
public initialize(): void {
if (this.initialized) {
return
}
this.config = ConfigManager.getInstance().getConfig().update ?? null
const supportState = getSupportState(this.config)
const enabled = Boolean(this.config?.enabled && supportState.supported)
this.status = {
...this.status,
enabled,
supported: supportState.supported,
message: supportState.reason
}
if (enabled && this.config) {
this.storageClient = new UpdateStorageClient(this.config)
}
log.info('Update service initialized', {
enabled,
supported: supportState.supported,
currentVersion: this.status.currentVersion,
currentChannel: this.status.currentChannel
})
this.initialized = true
}
public getStatus(): UpdateStatus {
return { ...this.status }
}
public getCatalog(): UpdateDialogCatalog {
const currentUserType = this.status.currentUserType
if (!this.status.enabled || !currentUserType || currentUserType === 'Guest') {
return { mode: 'disabled' }
}
if (currentUserType === 'User') {
return {
mode: 'user',
recommendedRelease: this.status.recommendedRelease
}
}
return {
mode: 'admin',
recommendedRelease: this.status.recommendedRelease,
channels: limitCatalogHistory(
this.catalog,
this.config?.maxAdminHistoryPerChannel ?? 10
)
}
}
public async getChangelog(release: DownloadReleaseRequest): Promise<string> {
this.ensureInitialized()
if (!this.status.enabled || !this.storageClient) {
throw new Error('自动更新不可用')
}
const cacheKey = `${release.channel}:${release.version}`
const cached = this.changelogCache.get(cacheKey)
if (cached) {
return cached
}
const markdown = await this.storageClient.readText(release.changelogKey)
this.changelogCache.set(cacheKey, markdown)
return markdown
}
public async setUserContext(userType: UserType | null): Promise<void> {
this.ensureInitialized()
this.status.currentUserType = userType
if (!this.status.enabled || !userType || userType === 'Guest') {
this.clearPolling()
this.catalog = { stable: [], preview: [] }
this.publishStatus({
phase: 'idle',
currentUserType: userType,
recommendedRelease: undefined,
latestVersion: undefined,
latestChannel: undefined,
downloadedRelease: undefined,
progress: undefined,
adminHasAnyRelease: false,
error: undefined,
message: this.status.supported ? undefined : this.status.message
})
return
}
await this.checkForUpdates()
this.startPolling()
}
public async checkForUpdates(): Promise<UpdateStatus> {
this.ensureInitialized()
if (!this.status.enabled || !this.storageClient || !this.config) {
return this.getStatus()
}
this.publishStatus({
phase: 'checking',
error: undefined,
message: '正在检查更新...'
})
try {
const currentUserType = this.status.currentUserType
this.catalog = {
stable: await this.fetchChannelIndex('stable'),
preview: currentUserType === 'Admin' ? await this.fetchChannelIndex('preview') : []
}
if (currentUserType === 'User') {
await this.processUserCatalog()
} else if (currentUserType === 'Admin') {
this.processAdminCatalog()
} else {
this.publishStatus({
phase: 'idle',
message: undefined,
error: undefined
})
}
} catch (error) {
const message = error instanceof Error ? error.message : '检查更新失败'
log.error('Failed to check for updates', { error: message })
this.publishStatus({
phase: 'error',
error: message,
message: '检查更新失败'
})
}
return this.getStatus()
}
public async downloadRelease(request: DownloadReleaseRequest): Promise<UpdateStatus> {
this.ensureInitialized()
if (!this.status.enabled || !this.storageClient) {
throw new Error('自动更新不可用')
}
this.publishStatus({
phase: 'downloading',
progress: 0,
latestVersion: request.version,
latestChannel: request.channel,
recommendedRelease: request,
message: `正在下载 ${request.channel} ${request.version}...`,
error: undefined
})
const downloadPath = this.getDownloadPath(request)
await this.storageClient.downloadToFile(request.artifactKey, downloadPath)
const hash = await this.calculateSha256(downloadPath)
if (hash.toLowerCase() !== request.sha256.toLowerCase()) {
await fs.promises.rm(downloadPath, { force: true })
throw new Error('更新包校验失败,文件哈希不匹配')
}
this.publishStatus({
phase: 'downloaded',
progress: 100,
downloadedRelease: {
version: request.version,
channel: request.channel,
localPath: downloadPath
},
latestVersion: request.version,
latestChannel: request.channel,
message: `已下载 ${request.channel} ${request.version}`
})
return this.getStatus()
}
public async installDownloadedRelease(): Promise<void> {
this.ensureInitialized()
const downloaded = this.status.downloadedRelease
if (!this.status.enabled || !downloaded) {
throw new Error('没有可安装的更新包')
}
const updaterSourcePath = this.resolveUpdaterBinaryPath()
const updaterPath = await this.prepareUpdaterBinary(updaterSourcePath)
const targetExe = process.env.PORTABLE_EXECUTABLE_FILE || process.execPath
const logPath = path.join(app.getPath('userData'), 'updates', 'portable-update.log')
const launchLogPath = path.join(app.getPath('userData'), 'updates', 'portable-launch.log')
const appArgs = process.argv.slice(1)
const argsBase64 =
appArgs.length > 0 ? Buffer.from(appArgs.join('\0'), 'utf-8').toString('base64') : ''
const spawnArgs = [
'--targetExe',
targetExe,
'--downloadedExe',
downloaded.localPath,
'--parentPid',
String(process.pid),
'--logPath',
logPath
]
if (argsBase64) {
spawnArgs.push('--argsBase64', argsBase64)
}
this.publishStatus({
phase: 'installing',
latestVersion: downloaded.version,
latestChannel: downloaded.channel,
message: `正在安装 ${downloaded.version}...`,
error: undefined
})
appendPortableLaunchLog(launchLogPath, 'Preparing portable updater launch', {
updaterSourcePath,
updaterPath,
targetExe,
downloadedExe: downloaded.localPath,
parentPid: process.pid,
logPath,
appArgs,
updaterExists: fs.existsSync(updaterPath),
spawnArgs
})
const child = spawn(updaterPath, spawnArgs, {
detached: true,
stdio: 'ignore',
windowsHide: true
})
appendPortableLaunchLog(launchLogPath, 'Spawn returned for portable updater', {
childPid: child.pid ?? null
})
child.on('error', (error) => {
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawn error', {
error: error instanceof Error ? error.message : String(error)
})
log.error('Failed to launch portable updater executable', {
error: error instanceof Error ? error.message : String(error)
})
})
child.once('spawn', () => {
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawned', {
childPid: child.pid ?? null
})
})
child.unref()
app.quit()
}
private ensureInitialized(): void {
if (!this.initialized) {
this.initialize()
}
}
private publishStatus(next: Partial<UpdateStatus>): void {
this.status = {
...this.status,
...next
}
BrowserWindow.getAllWindows().forEach((window) => {
window.webContents.send(IPC_CHANNELS.UPDATE_STATUS_CHANGED, this.status)
})
}
private async fetchChannelIndex(channel: ReleaseChannel): Promise<UpdateRelease[]> {
if (!this.storageClient || !this.config) {
return []
}
const key = `${this.config.basePrefix}/${channel}/index.json`
try {
const raw = await this.storageClient.readText(key)
const parsed = JSON.parse(raw) as unknown
return normalizeReleases(parsed, channel)
} catch (error) {
if (isMissingObjectError(error)) {
log.warn('Update channel index not found, treating as empty', { channel, key })
return []
}
throw error
}
}
private async processUserCatalog(): Promise<void> {
const decision = resolveUserDecision({
currentVersion: this.status.currentVersion,
currentChannel: this.status.currentChannel,
catalog: this.catalog
})
if (!decision.recommendedRelease || !decision.shouldOffer) {
this.publishStatus({
phase: 'idle',
recommendedRelease: undefined,
latestVersion: undefined,
latestChannel: undefined,
downloadedRelease: undefined,
progress: undefined,
adminHasAnyRelease: false,
error: undefined,
message: undefined
})
return
}
const recommended = decision.recommendedRelease
this.publishStatus({
phase: 'available',
recommendedRelease: recommended,
latestVersion: recommended.version,
latestChannel: recommended.channel,
adminHasAnyRelease: false,
message:
this.status.currentChannel === 'preview'
? `当前为预览版,可切换回稳定版 ${recommended.version}`
: `发现稳定版 ${recommended.version}`
})
const existing = await this.getValidDownloadedRelease(recommended)
if (existing) {
this.publishStatus({
phase: 'downloaded',
progress: 100,
downloadedRelease: {
version: existing.version,
channel: existing.channel,
localPath: existing.localPath
},
message:
this.status.currentChannel === 'preview'
? `稳定版 ${recommended.version} 已准备安装`
: `发现稳定版 ${recommended.version}`
})
return
}
try {
await this.downloadRelease(recommended)
} catch (error) {
const message = error instanceof Error ? error.message : '下载更新失败'
this.publishStatus({
phase: 'error',
error: message,
message
})
}
}
private processAdminCatalog(): void {
const decision = resolveAdminDecision({
currentVersion: this.status.currentVersion,
currentChannel: this.status.currentChannel,
catalog: this.catalog,
maxHistoryPerChannel: this.config?.maxAdminHistoryPerChannel ?? 10
})
const hasHigherVersion = [...this.catalog.stable, ...this.catalog.preview].some(
(release) => compareVersions(release.version, this.status.currentVersion) > 0
)
const hasCrossChannelOption = [...this.catalog.stable, ...this.catalog.preview].some(
(release) => release.channel !== this.status.currentChannel
)
const shouldOffer = hasHigherVersion || hasCrossChannelOption
this.publishStatus({
phase: shouldOffer ? 'available' : 'idle',
recommendedRelease: decision.recommendedRelease,
latestVersion: decision.recommendedRelease?.version,
latestChannel: decision.recommendedRelease?.channel,
adminHasAnyRelease: shouldOffer,
downloadedRelease: undefined,
progress: undefined,
error: undefined,
message: shouldOffer ? '发现可用版本' : undefined
})
}
private startPolling(): void {
this.clearPolling()
if (!this.config) {
return
}
this.intervalHandle = setInterval(() => {
this.checkForUpdates().catch((error) => {
log.warn('Periodic update check failed', {
error: error instanceof Error ? error.message : String(error)
})
})
}, this.config.checkIntervalMinutes * 60 * 1000)
}
private clearPolling(): void {
if (this.intervalHandle) {
clearInterval(this.intervalHandle)
this.intervalHandle = null
}
}
private async getValidDownloadedRelease(
release: UpdateRelease
): Promise<DownloadedRelease | null> {
const downloadPath = this.getDownloadPath(release)
if (!fs.existsSync(downloadPath)) {
return null
}
const hash = await this.calculateSha256(downloadPath)
if (hash.toLowerCase() !== release.sha256.toLowerCase()) {
await fs.promises.rm(downloadPath, { force: true })
return null
}
return {
...release,
localPath: downloadPath
}
}
private getDownloadPath(release: UpdateRelease): string {
return path.join(app.getPath('userData'), 'pending-update', `${release.channel}-${release.version}.exe`)
}
private async calculateSha256(filePath: string): Promise<string> {
const hash = createHash('sha256')
const input = fs.createReadStream(filePath)
await new Promise<void>((resolve, reject) => {
input.on('data', (chunk) => hash.update(chunk))
input.on('error', reject)
input.on('end', resolve)
})
return hash.digest('hex')
}
private resolveUpdaterBinaryPath(): string {
const packagedPath = path.join(process.resourcesPath, 'portable-updater.exe')
const devPath = path.resolve(process.cwd(), 'build', 'bin', 'portable-updater.exe')
if (fs.existsSync(packagedPath)) {
return packagedPath
}
if (fs.existsSync(devPath)) {
return devPath
}
throw new Error('未找到便携版更新器')
}
private async prepareUpdaterBinary(sourcePath: string): Promise<string> {
const updatesDir = path.join(app.getPath('userData'), 'updates')
const stagedPath = path.join(updatesDir, 'portable-updater.exe')
await fs.promises.mkdir(updatesDir, { recursive: true })
await fs.promises.copyFile(sourcePath, stagedPath)
return stagedPath
}
}

View File

@@ -0,0 +1,123 @@
import type {
ReleaseChannel,
UpdateCatalog,
UpdateDialogCatalog,
UpdateRelease
} from '../../types/update.types'
export function compareVersions(left: string, right: string): number {
const leftParts = left.split('.').map((part) => Number.parseInt(part, 10) || 0)
const rightParts = right.split('.').map((part) => Number.parseInt(part, 10) || 0)
const length = Math.max(leftParts.length, rightParts.length)
for (let index = 0; index < length; index += 1) {
const leftValue = leftParts[index] ?? 0
const rightValue = rightParts[index] ?? 0
if (leftValue > rightValue) return 1
if (leftValue < rightValue) return -1
}
return 0
}
export function normalizeReleases(input: unknown, channel: ReleaseChannel): UpdateRelease[] {
const list = Array.isArray(input)
? input
: input && typeof input === 'object' && Array.isArray((input as { releases?: unknown[] }).releases)
? (input as { releases: unknown[] }).releases
: []
return list
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
.map((item) => ({
version: String(item.version ?? ''),
channel,
artifactKey: String(item.artifactKey ?? ''),
sha256: String(item.sha256 ?? ''),
size: Number(item.size ?? 0),
publishedAt: String(item.publishedAt ?? ''),
changelogKey: String(item.changelogKey ?? ''),
notesSummary: item.notesSummary ? String(item.notesSummary) : undefined
}))
.filter(
(item) =>
!!item.version &&
!!item.artifactKey &&
!!item.sha256 &&
!!item.changelogKey &&
!!item.publishedAt
)
}
export function sortReleases(releases: UpdateRelease[]): UpdateRelease[] {
return [...releases].sort((left, right) => {
const versionCompare = compareVersions(right.version, left.version)
if (versionCompare !== 0) {
return versionCompare
}
return new Date(right.publishedAt).getTime() - new Date(left.publishedAt).getTime()
})
}
export function pickLatestRelease(releases: UpdateRelease[]): UpdateRelease | undefined {
return sortReleases(releases)[0]
}
export function limitCatalogHistory(catalog: UpdateCatalog, maxPerChannel: number): UpdateCatalog {
return {
stable: sortReleases(catalog.stable).slice(0, maxPerChannel),
preview: sortReleases(catalog.preview).slice(0, maxPerChannel)
}
}
export function resolveUserDecision(params: {
currentVersion: string
currentChannel: ReleaseChannel
catalog: UpdateCatalog
}): {
recommendedRelease?: UpdateRelease
shouldOffer: boolean
} {
const latestStable = pickLatestRelease(params.catalog.stable)
if (!latestStable) {
return { recommendedRelease: undefined, shouldOffer: false }
}
if (params.currentChannel === 'preview') {
return { recommendedRelease: latestStable, shouldOffer: true }
}
return {
recommendedRelease: latestStable,
shouldOffer: params.currentVersion !== latestStable.version
}
}
export function resolveAdminDecision(params: {
currentVersion: string
currentChannel: ReleaseChannel
catalog: UpdateCatalog
maxHistoryPerChannel: number
}): UpdateDialogCatalog {
const channels = limitCatalogHistory(params.catalog, params.maxHistoryPerChannel)
const allReleases = sortReleases([...channels.stable, ...channels.preview])
const higherVersionRelease = allReleases.find(
(release) => compareVersions(release.version, params.currentVersion) > 0
)
const preferredCrossChannelRelease = allReleases.find(
(release) => release.channel !== params.currentChannel
)
return {
mode: 'admin',
recommendedRelease:
higherVersionRelease ??
preferredCrossChannelRelease ??
pickLatestRelease(channels[params.currentChannel]),
channels
}
}

View File

@@ -143,6 +143,22 @@ export const rustfsConfigSchema = z.object({
region: z.string().default('us-east-1')
})
/**
* Update service configuration schema
*/
export const updateConfigSchema = z.object({
enabled: z.boolean().default(false),
allowDevMode: z.boolean().default(false),
endpoint: z.string().default(''),
accessKey: z.string().default(''),
secretKey: z.string().default(''),
bucket: z.string().default(''),
region: z.string().default('us-east-1'),
basePrefix: z.string().default('updates/win-portable'),
checkIntervalMinutes: z.number().int().min(1).max(1440).default(30),
maxAdminHistoryPerChannel: z.number().int().min(1).max(100).default(10)
})
/**
* 完整应用配置 Schema
*/
@@ -155,7 +171,8 @@ export const fullConfigSchema = z.object({
cleaner: cleanerConfigSchema,
orderResolution: orderResolutionSchema,
logging: loggingConfigSchema,
rustfs: rustfsConfigSchema.optional()
rustfs: rustfsConfigSchema.optional(),
update: updateConfigSchema.optional()
})
/**
@@ -168,6 +185,7 @@ export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
export type RustfsConfig = z.infer<typeof rustfsConfigSchema>
export type UpdateConfig = z.infer<typeof updateConfigSchema>
/**
* 验证并解析配置

View File

@@ -0,0 +1,66 @@
import type { UserType } from './user.types'
export type ReleaseChannel = 'stable' | 'preview'
export type UpdatePhase =
| 'idle'
| 'checking'
| 'available'
| 'downloading'
| 'downloaded'
| 'installing'
| 'error'
export interface UpdateRelease {
version: string
channel: ReleaseChannel
artifactKey: string
sha256: string
size: number
publishedAt: string
changelogKey: string
notesSummary?: string
}
export interface UpdateCatalog {
stable: UpdateRelease[]
preview: UpdateRelease[]
}
export interface DownloadedRelease extends UpdateRelease {
localPath: string
}
export interface UpdateStatus {
enabled: boolean
supported: boolean
phase: UpdatePhase
currentVersion: string
currentChannel: ReleaseChannel
currentUserType: UserType | null
message?: string
latestVersion?: string
latestChannel?: ReleaseChannel
progress?: number
downloadedRelease?: Pick<DownloadedRelease, 'version' | 'channel' | 'localPath'>
recommendedRelease?: UpdateRelease
error?: string
adminHasAnyRelease?: boolean
}
export interface UpdateDialogCatalog {
mode: 'user' | 'admin' | 'disabled'
recommendedRelease?: UpdateRelease
channels?: UpdateCatalog
}
export interface DownloadReleaseRequest {
version: string
channel: ReleaseChannel
artifactKey: string
sha256: string
size: number
publishedAt: string
changelogKey: string
notesSummary?: string
}

View File

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

View File

@@ -13,6 +13,8 @@ import type {
import type { IpcResult } from '../main/ipc'
import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels'
import type { CleanerConfig } from '../main/types/config.schema'
import type { DownloadReleaseRequest, UpdateStatus } from '../main/types/update.types'
import type { UpdateDialogCatalog } from '../main/types/update.types'
type ErpSettingsPayload = {
erp?: {
@@ -218,6 +220,25 @@ const api = {
listByUser: (username: string): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username),
download: (key: string): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key)
},
update: {
getStatus: (): Promise<IpcResult<UpdateStatus>> => invokeIpc(IPC_CHANNELS.UPDATE_GET_STATUS),
checkNow: (): Promise<IpcResult<UpdateStatus>> => invokeIpc(IPC_CHANNELS.UPDATE_CHECK_NOW),
getCatalog: (): Promise<IpcResult<UpdateDialogCatalog>> =>
invokeIpc(IPC_CHANNELS.UPDATE_GET_CATALOG),
getChangelog: (release: DownloadReleaseRequest): Promise<IpcResult<string>> =>
invokeIpc(IPC_CHANNELS.UPDATE_GET_CHANGELOG, release),
downloadRelease: (release: DownloadReleaseRequest): Promise<IpcResult<UpdateStatus>> =>
invokeIpc(IPC_CHANNELS.UPDATE_DOWNLOAD_RELEASE, release),
installDownloaded: (): Promise<IpcResult<void>> =>
invokeIpc(IPC_CHANNELS.UPDATE_INSTALL_DOWNLOADED),
onStatusChanged: (callback: (data: UpdateStatus) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, data: UpdateStatus) =>
callback(data)
ipcRenderer.on(IPC_CHANNELS.UPDATE_STATUS_CHANGED, subscription)
return () => ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_STATUS_CHANGED, subscription)
}
}
} as const

View File

@@ -9,7 +9,17 @@
*/
import React, { useState, useEffect } from 'react'
import { LayoutDashboard, Download, Trash2, Settings, Database, User, LogOut } from 'lucide-react'
import {
LayoutDashboard,
Download,
Trash2,
Settings,
Database,
User,
LogOut,
ArrowUpCircle,
LoaderCircle
} from 'lucide-react'
import { useLogger } from './hooks/useLogger'
import LoginDialog from './components/LoginDialog'
import UserSelectionDialog, {
@@ -19,6 +29,12 @@ import { Toast } from './components/ui/Toast'
import ExtractorPage from './pages/ExtractorPage'
import CleanerPage from './pages/CleanerPage'
import SettingsPage from './pages/SettingsPage'
import UpdateDialog from './components/UpdateDialog'
import type {
DownloadReleaseRequest,
UpdateDialogCatalog,
UpdateStatus
} from '../../main/types/update.types'
type Page = 'home' | 'extractor' | 'cleaner' | 'settings'
@@ -51,6 +67,9 @@ function App(): React.JSX.Element {
// Navigation state
const [currentPage, setCurrentPage] = useState<Page>('extractor') // Default to extractor for the new layout
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
const [updateCatalog, setUpdateCatalog] = useState<UpdateDialogCatalog | null>(null)
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
// Load error message from sessionStorage
const showError = (message: string) => {
@@ -65,6 +84,46 @@ function App(): React.JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const unsubscribe = window.electron.update.onStatusChanged((status) => {
setUpdateStatus(status)
if (status.phase === 'available' || status.phase === 'downloaded' || status.phase === 'idle') {
void refreshUpdateCatalog()
}
})
void refreshUpdateState()
return unsubscribe
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (isAuthenticated) {
void refreshUpdateState()
void refreshUpdateCatalog()
return
}
setUpdateStatus(null)
setUpdateCatalog(null)
setShowUpdateDialog(false)
}, [isAuthenticated])
const refreshUpdateState = async () => {
const result = await window.electron.update.getStatus()
if (result.success && result.data) {
setUpdateStatus(result.data)
}
}
const refreshUpdateCatalog = async () => {
const result = await window.electron.update.getCatalog()
if (result.success && result.data) {
setUpdateCatalog(result.data)
}
}
const initializeAuth = async () => {
logger.info('=== Starting initializeAuth ===')
try {
@@ -195,6 +254,44 @@ function App(): React.JSX.Element {
setShowLoginDialog(true)
}
const openUpdateDialog = async () => {
await refreshUpdateCatalog()
setShowUpdateDialog(true)
}
const handleInstallUserRelease = async () => {
if (!updateCatalog?.recommendedRelease) {
showError('暂无可安装更新')
return
}
if (updateStatus?.phase !== 'downloaded') {
const downloadResult = await window.electron.update.downloadRelease(updateCatalog.recommendedRelease)
if (!downloadResult.success) {
showError(downloadResult.error || '下载更新失败')
return
}
}
const installResult = await window.electron.update.installDownloaded()
if (!installResult.success) {
showError(installResult.error || '启动安装失败')
}
}
const handleAdminDownloadAndInstall = async (release: DownloadReleaseRequest) => {
const downloadResult = await window.electron.update.downloadRelease(release)
if (!downloadResult.success) {
showError(downloadResult.error || '下载更新失败')
return
}
const installResult = await window.electron.update.installDownloaded()
if (!installResult.success) {
showError(installResult.error || '启动安装失败')
}
}
// Check if should show logout button
// Show logout if: user is Admin, OR user was switched by Admin
const shouldShowLogout = currentUser?.userType === 'Admin' || isSwitchedByAdmin
@@ -317,6 +414,24 @@ function App(): React.JSX.Element {
{ id: 'settings', label: '系统设置 (Settings)', icon: <Settings size={18} /> }
]
const showUpdateEntry =
!!updateStatus &&
((currentUser?.userType === 'User' && updateStatus.phase === 'downloaded') ||
(currentUser?.userType === 'Admin' &&
(updateStatus.adminHasAnyRelease ||
updateStatus.phase === 'downloading' ||
updateStatus.phase === 'downloaded' ||
updateStatus.phase === 'installing')))
const updateButtonLabel =
currentUser?.userType === 'Admin'
? updateStatus?.phase === 'downloading'
? '下载更新中...'
: '有可用版本'
: updateStatus?.phase === 'downloaded'
? `发现稳定版 V${updateStatus.latestVersion}`
: '发现新版本'
return (
<div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
{/* ================= 顶部导航与标题栏 ================= */}
@@ -370,6 +485,20 @@ function App(): React.JSX.Element {
className="flex items-center gap-4 text-sm"
style={{ WebkitAppRegion: 'no-drag' } as any}
>
{showUpdateEntry && (
<button
onClick={() => void openUpdateDialog()}
className="inline-flex items-center gap-2 rounded-full border border-blue-500/40 bg-blue-500/10 px-3 py-1.5 text-xs font-medium text-blue-100 transition hover:bg-blue-500/20"
title={updateStatus?.message || '查看更新'}
>
{updateStatus?.phase === 'downloading' || updateStatus?.phase === 'installing' ? (
<LoaderCircle size={15} className="animate-spin" />
) : (
<ArrowUpCircle size={15} />
)}
<span>{updateButtonLabel}</span>
</button>
)}
<div className="flex items-center gap-2 text-xs bg-slate-800 px-3 py-1.5 rounded-full border border-slate-700">
<Database size={14} className="text-green-500" />
<span className="text-slate-300"></span>
@@ -418,6 +547,22 @@ function App(): React.JSX.Element {
{/* Toast Notifications */}
<Toast />
<UpdateDialog
isOpen={showUpdateDialog}
userType={currentUser?.userType ?? null}
status={updateStatus}
catalog={updateCatalog}
onClose={() => setShowUpdateDialog(false)}
onInstallUserRelease={handleInstallUserRelease}
onDownloadAndInstallAdminRelease={async (release) =>
handleAdminDownloadAndInstall(release)
}
onRefreshCatalog={async () => {
await window.electron.update.checkNow()
await refreshUpdateCatalog()
await refreshUpdateState()
}}
/>
</div>
)
}

View File

@@ -0,0 +1,267 @@
import React, { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { DownloadCloud, LoaderCircle, RefreshCw } from 'lucide-react'
import Modal from './ui/Modal'
import type { UserType } from '../../../main/types/user.types'
import type {
DownloadReleaseRequest,
UpdateDialogCatalog,
UpdateRelease,
UpdateStatus
} from '../../../main/types/update.types'
interface UpdateDialogProps {
isOpen: boolean
userType: UserType | null
status: UpdateStatus | null
catalog: UpdateDialogCatalog | null
onClose: () => void
onInstallUserRelease: () => Promise<void>
onDownloadAndInstallAdminRelease: (release: DownloadReleaseRequest) => Promise<void>
onRefreshCatalog: () => Promise<void>
}
function releaseKey(release: UpdateRelease): string {
return `${release.channel}:${release.version}`
}
export default function UpdateDialog({
isOpen,
userType,
status,
catalog,
onClose,
onInstallUserRelease,
onDownloadAndInstallAdminRelease,
onRefreshCatalog
}: UpdateDialogProps): React.JSX.Element {
const [selectedRelease, setSelectedRelease] = useState<UpdateRelease | undefined>(undefined)
const [changelog, setChangelog] = useState<string>('')
const [isLoadingChangelog, setIsLoadingChangelog] = useState(false)
useEffect(() => {
if (!isOpen || !catalog) return
if (catalog.mode === 'user') {
setSelectedRelease(catalog.recommendedRelease)
return
}
if (catalog.mode === 'admin') {
setSelectedRelease(
catalog.recommendedRelease ?? catalog.channels?.stable[0] ?? catalog.channels?.preview[0]
)
}
}, [catalog, isOpen])
useEffect(() => {
if (!isOpen || !selectedRelease) {
setChangelog('')
return
}
let active = true
setIsLoadingChangelog(true)
window.electron.update
.getChangelog(selectedRelease)
.then((result) => {
if (!active) return
setChangelog(result.success && result.data ? result.data : '暂无更新说明。')
})
.catch(() => {
if (!active) return
setChangelog('暂无更新说明。')
})
.finally(() => {
if (active) {
setIsLoadingChangelog(false)
}
})
return () => {
active = false
}
}, [isOpen, selectedRelease])
const isBusy =
status?.phase === 'downloading' || status?.phase === 'installing' || status?.phase === 'checking'
const renderReleaseList = (title: string, releases: UpdateRelease[]) => {
if (releases.length === 0) {
return (
<div className="rounded-lg border border-dashed border-slate-200 px-3 py-4 text-sm text-slate-500">
</div>
)
}
return (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</div>
{releases.map((release) => {
const selected = selectedRelease && releaseKey(selectedRelease) === releaseKey(release)
return (
<button
key={releaseKey(release)}
onClick={() => setSelectedRelease(release)}
className={`w-full rounded-lg border px-3 py-3 text-left transition ${
selected
? 'border-blue-500 bg-blue-50 text-blue-900'
: 'border-slate-200 bg-white text-slate-700 hover:border-slate-300'
}`}
>
<div className="flex items-center justify-between gap-3">
<span className="font-medium">V{release.version}</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs uppercase text-slate-600">
{release.channel}
</span>
</div>
<div className="mt-1 text-xs text-slate-500">{release.publishedAt}</div>
{release.notesSummary && (
<div className="mt-2 text-xs text-slate-600">{release.notesSummary}</div>
)}
</button>
)
})}
</div>
)
}
return (
<Modal
isOpen={isOpen}
onClose={isBusy ? () => undefined : onClose}
title="应用更新"
size="3xl"
disableBackdropClick={isBusy}
disableEscapeKey={isBusy}
>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg bg-slate-50 px-4 py-3 text-sm">
<div className="text-slate-600">
<span className="font-semibold text-slate-900">V{status?.currentVersion}</span>
<span className="ml-2 rounded-full bg-slate-200 px-2 py-0.5 text-xs uppercase text-slate-700">
{status?.currentChannel ?? __APP_CHANNEL__}
</span>
</div>
<button
onClick={() => void onRefreshCatalog()}
className="inline-flex items-center gap-2 rounded-md border border-slate-200 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
disabled={isBusy}
>
<RefreshCw size={14} />
</button>
</div>
{catalog?.mode === 'admin' ? (
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
<div className="space-y-4">
{renderReleaseList('Stable', catalog.channels?.stable ?? [])}
{renderReleaseList('Preview', catalog.channels?.preview ?? [])}
</div>
<div className="rounded-xl border border-slate-200 bg-white">
<div className="border-b border-slate-200 px-4 py-3">
<div className="text-sm font-semibold text-slate-900">
{selectedRelease
? `${selectedRelease.channel.toUpperCase()} V${selectedRelease.version}`
: '请选择一个版本'}
</div>
<div className="mt-1 text-xs text-slate-500">
{selectedRelease?.notesSummary ?? '选择版本后可查看详细更新说明。'}
</div>
</div>
<div className="max-h-[420px] overflow-auto px-4 py-4 prose prose-slate max-w-none prose-headings:mb-2 prose-p:my-2 prose-li:my-1">
{isLoadingChangelog ? (
<div className="flex items-center gap-2 text-sm text-slate-500">
<LoaderCircle size={16} className="animate-spin" />
...
</div>
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{changelog || '暂无更新说明。'}
</ReactMarkdown>
)}
</div>
</div>
</div>
) : (
<div className="rounded-xl border border-slate-200 bg-white">
<div className="border-b border-slate-200 px-4 py-3">
<div className="text-sm font-semibold text-slate-900">
{selectedRelease ? `发现稳定版 V${selectedRelease.version}` : '暂无可用更新'}
</div>
<div className="mt-1 text-xs text-slate-500">
{status?.currentChannel === 'preview'
? '当前设备正在运行预览版,普通用户将更新回稳定版。'
: '更新包已在后台准备完成,确认后将自动关闭并重启应用。'}
</div>
</div>
<div className="max-h-[420px] overflow-auto px-4 py-4 prose prose-slate max-w-none prose-headings:mb-2 prose-p:my-2 prose-li:my-1">
{isLoadingChangelog ? (
<div className="flex items-center gap-2 text-sm text-slate-500">
<LoaderCircle size={16} className="animate-spin" />
...
</div>
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{changelog || '暂无更新说明。'}
</ReactMarkdown>
)}
</div>
</div>
)}
{status?.error && (
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{status.error}
</div>
)}
<div className="flex items-center justify-between border-t border-slate-200 pt-4">
<div className="text-sm text-slate-500">{status?.message ?? ' '}</div>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
disabled={isBusy}
>
</button>
{userType === 'Admin' ? (
<button
onClick={() =>
selectedRelease ? void onDownloadAndInstallAdminRelease(selectedRelease) : undefined
}
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
disabled={!selectedRelease || isBusy}
>
{isBusy ? (
<LoaderCircle size={16} className="animate-spin" />
) : (
<DownloadCloud size={16} />
)}
</button>
) : (
<button
onClick={() => void onInstallUserRelease()}
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
disabled={!selectedRelease || isBusy}
>
{isBusy ? (
<LoaderCircle size={16} className="animate-spin" />
) : (
<DownloadCloud size={16} />
)}
</button>
)}
</div>
</div>
</div>
</Modal>
)
}

View File

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

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

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

View File

@@ -96,7 +96,16 @@ export const IPC_CHANNELS = {
// Report
REPORT_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
/**

View File

@@ -0,0 +1,217 @@
import { describe, expect, it } from 'vitest'
import {
compareVersions,
normalizeReleases,
pickLatestRelease,
resolveAdminDecision,
resolveUserDecision
} from '../../src/main/services/update/update-utils'
import type { UpdateCatalog } from '../../src/main/types/update.types'
describe('update-utils', () => {
it('compares semver-like numeric versions correctly', () => {
expect(compareVersions('1.3.1', '1.3.1')).toBe(0)
expect(compareVersions('1.3.2', '1.3.1')).toBe(1)
expect(compareVersions('1.10.0', '1.9.9')).toBe(1)
expect(compareVersions('1.2.0', '1.3.0')).toBe(-1)
})
it('pads missing version segments with zero', () => {
expect(compareVersions('1.3', '1.3.0')).toBe(0)
expect(compareVersions('1.3.1', '1.3')).toBe(1)
})
it('normalizes channel indexes and drops invalid entries', () => {
const releases = normalizeReleases(
{
releases: [
{
version: '1.3.2',
artifactKey: 'stable/1.3.2.exe',
sha256: 'abc',
size: 123,
publishedAt: '2026-03-20T10:00:00Z',
changelogKey: 'stable/1.3.2.md'
},
{
version: '1.3.1',
artifactKey: '',
sha256: 'missing',
size: 12,
publishedAt: '2026-03-19T10:00:00Z',
changelogKey: 'stable/1.3.1.md'
}
]
},
'stable'
)
expect(releases).toHaveLength(1)
expect(releases[0].channel).toBe('stable')
expect(releases[0].version).toBe('1.3.2')
})
it('keeps higher version newer even if its publishedAt is older', () => {
const latest = pickLatestRelease([
{
version: '1.3.1',
channel: 'stable',
artifactKey: 'stable/1.3.1.exe',
sha256: 'a',
size: 1,
publishedAt: '2026-03-20T11:00:00Z',
changelogKey: 'stable/1.3.1.md'
},
{
version: '1.3.2',
channel: 'stable',
artifactKey: 'stable/1.3.2.exe',
sha256: 'b',
size: 1,
publishedAt: '2026-03-20T10:00:00Z',
changelogKey: 'stable/1.3.2.md'
}
])
expect(latest?.version).toBe('1.3.2')
})
it('forces User on preview back to latest stable', () => {
const catalog: UpdateCatalog = {
stable: [
{
version: '1.3.2',
channel: 'stable',
artifactKey: 'stable/1.3.2.exe',
sha256: 'a',
size: 1,
publishedAt: '2026-03-20T10:00:00Z',
changelogKey: 'stable/1.3.2.md'
}
],
preview: [
{
version: '1.4.0',
channel: 'preview',
artifactKey: 'preview/1.4.0.exe',
sha256: 'b',
size: 1,
publishedAt: '2026-03-20T11:00:00Z',
changelogKey: 'preview/1.4.0.md'
}
]
}
const decision = resolveUserDecision({
currentVersion: '1.4.0',
currentChannel: 'preview',
catalog
})
expect(decision.shouldOffer).toBe(true)
expect(decision.recommendedRelease?.channel).toBe('stable')
expect(decision.recommendedRelease?.version).toBe('1.3.2')
})
it('treats stable version mismatch as update for User', () => {
const catalog: UpdateCatalog = {
stable: [
{
version: '1.3.2',
channel: 'stable',
artifactKey: 'stable/1.3.2.exe',
sha256: 'a',
size: 1,
publishedAt: '2026-03-20T10:00:00Z',
changelogKey: 'stable/1.3.2.md'
}
],
preview: []
}
const decision = resolveUserDecision({
currentVersion: '1.3.1',
currentChannel: 'stable',
catalog
})
expect(decision.shouldOffer).toBe(true)
expect(decision.recommendedRelease?.version).toBe('1.3.2')
})
it('recommends the highest newer version for Admin', () => {
const catalog: UpdateCatalog = {
stable: [
{
version: '1.3.2',
channel: 'stable',
artifactKey: 'stable/1.3.2.exe',
sha256: 'a',
size: 1,
publishedAt: '2026-03-20T10:00:00Z',
changelogKey: 'stable/1.3.2.md'
}
],
preview: [
{
version: '1.4.0',
channel: 'preview',
artifactKey: 'preview/1.4.0.exe',
sha256: 'b',
size: 1,
publishedAt: '2026-03-20T09:00:00Z',
changelogKey: 'preview/1.4.0.md'
}
]
}
const decision = resolveAdminDecision({
currentVersion: '1.3.1',
currentChannel: 'stable',
catalog,
maxHistoryPerChannel: 10
})
expect(decision.mode).toBe('admin')
expect(decision.recommendedRelease?.channel).toBe('preview')
expect(decision.recommendedRelease?.version).toBe('1.4.0')
expect(decision.channels?.preview).toHaveLength(1)
})
it('still exposes a cross-channel choice for Admin when no newer version exists', () => {
const catalog: UpdateCatalog = {
stable: [
{
version: '1.4.0',
channel: 'stable',
artifactKey: 'stable/1.4.0.exe',
sha256: 'a',
size: 1,
publishedAt: '2026-03-20T10:00:00Z',
changelogKey: 'stable/1.4.0.md'
}
],
preview: [
{
version: '1.4.0',
channel: 'preview',
artifactKey: 'preview/1.4.0.exe',
sha256: 'b',
size: 1,
publishedAt: '2026-03-20T09:00:00Z',
changelogKey: 'preview/1.4.0.md'
}
]
}
const decision = resolveAdminDecision({
currentVersion: '1.4.0',
currentChannel: 'stable',
catalog,
maxHistoryPerChannel: 10
})
expect(decision.recommendedRelease?.channel).toBe('preview')
expect(decision.recommendedRelease?.version).toBe('1.4.0')
})
})