From 71cc8eb605008860dc8ad6f5ab5b3f779d1baf15 Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sat, 31 Oct 2026 17:07:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(update):=20=E4=BF=AE=E5=A4=8D=E4=BA=91?= =?UTF-8?q?=E7=AB=AF=E5=AE=89=E8=A3=85=E4=BA=A4=E6=8E=A5=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=8F=91=E5=B8=83=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复隐藏缓存目录下载和响应收尾,使用原生助手校验安装包并确认交接。 补齐真实静默升级、便携版及中文路径验收,并按现行设备和科目授权契约更新回归。 服务端安装时生成独立密钥,覆盖升级保留既有配置;补齐安装包副本验证说明。 --- deploy/bootstrap-env.cjs | 32 + docs/dev/architecture.md | 13 + docs/dev/release.md | 2 + docs/get-started/client.md | 10 + package.json | 6 +- .../desktop-client/native/update-helper.cs | 172 +++++ .../desktop-client/scripts/build-main.mjs | 2 + .../scripts/build-update-helper.mjs | 31 + .../desktop-client/src/main/auto-update.ts | 69 +- .../src/main/update-installer.ts | 105 +++ packages/server/scripts/verify-e2e.mjs | 122 ++-- .../src/modules/update/update.module.ts | 28 +- scripts/dist-server.mjs | 9 +- scripts/nsis/server-installer.nsi | 10 + scripts/verify-client-agent.mjs | 43 +- scripts/verify-client-update-real.mjs | 605 ++++++++++++++++++ scripts/verify-server-bootstrap.mjs | 37 ++ scripts/verify-update-download.mjs | 96 +++ scripts/verify-update-installer.mjs | 123 ++++ 19 files changed, 1424 insertions(+), 91 deletions(-) create mode 100644 deploy/bootstrap-env.cjs create mode 100644 packages/desktop-client/native/update-helper.cs create mode 100644 packages/desktop-client/scripts/build-update-helper.mjs create mode 100644 packages/desktop-client/src/main/update-installer.ts create mode 100644 scripts/verify-client-update-real.mjs create mode 100644 scripts/verify-server-bootstrap.mjs create mode 100644 scripts/verify-update-download.mjs create mode 100644 scripts/verify-update-installer.mjs diff --git a/deploy/bootstrap-env.cjs b/deploy/bootstrap-env.cjs new file mode 100644 index 0000000..a7cc3e3 --- /dev/null +++ b/deploy/bootstrap-env.cjs @@ -0,0 +1,32 @@ +/** 首次安装生成独立密钥;覆盖升级不改变已有配置。 */ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const MARKER = 'CLASSHELPER_GENERATE_SECRET_ON_INSTALL'; +function initialiseEnvironment(file = path.join(__dirname, '.env')) { + const content = fs.readFileSync(file, 'utf8'); + const line = /^JWT_SECRET=CLASSHELPER_GENERATE_SECRET_ON_INSTALL\r?$/m; + if (!line.test(content)) return false; + const value = crypto.randomBytes(48).toString('hex'); + const temporary = file + '.initialising-' + crypto.randomUUID(); + try { + fs.writeFileSync(temporary, content.replace(line, 'JWT_SECRET=' + value), { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); + fs.renameSync(temporary, file); + } finally { + fs.rmSync(temporary, { force: true }); + } + return true; +} +module.exports = { initialiseEnvironment, MARKER }; +if (require.main === module) { + try { + initialiseEnvironment(); + } catch (error) { + console.error('服务端配置初始化失败:' + error.message); + process.exitCode = 1; + } +} diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 2dada24..3780785 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -37,6 +37,19 @@ ClassHelper 是 pnpm workspace:Express/Prisma/Socket.IO 服务端、Vue Web 客户端先维护 IndexedDB 缓存,断网读取缓存;主窗口通过 preload 与主进程通信,主进程管理唯一原生岛窗口。 课件先流式上传到服务端文件目录,再由客户端下载、验摘要并独立回执。 +客户端统一更新由服务端缓存安装包、校验设备权限并通过心跳下发。客户端下载并校验 SHA256 后, +把安装交给独立的 Windows 原生助手;助手从本机 `updates` 目录运行,再次验包并确认就绪, +主进程复核更新策略后才退出。助手等待旧进程结束、执行安装并检查安装版的新版本及启动状态, +失败写入 `install-result.json` 并尝试恢复客户端;成功仍以新版心跳为最终回执。 +排障日志为同目录下的 `install-helper.log`。Windows 构建使用系统 .NET Framework 编译器生成助手, +教室机运行时不需要安装开发工具;助手与安装包均随客户端构建,不能只替换主进程 JS。 + +更新专项验证使用 `pnpm verify:update-download`、Windows 下的 `pnpm verify:update-installer`。 +显式执行 `pnpm verify:client-update:real` 会创建临时数据库、班级和独立应用标识,真实安装并升级 +测试副本,再卸载测试安装,报告保存在 `.cache/client-update-real/`;它不属于普通冒烟, +不会使用真实班级或桌面自动化,也不修改根版本号。 +传入 `--portable --unicode-paths --prefix-version` 可覆盖便携运行转安装版、中文空格路径和带 `v` 的目标版本号。 + ## 工程边界 - 三端契约只放 shared,改后先 `pnpm build:shared`。 diff --git a/docs/dev/release.md b/docs/dev/release.md index 8a31ae9..f61eb5c 100644 --- a/docs/dev/release.md +++ b/docs/dev/release.md @@ -41,6 +41,8 @@ pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版 | releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe | ClassHelper-<版本>-x64-server-setup.exe | | releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz | 同名 | +Windows 服务端安装包只携带配置占位符,首次安装时生成独立的 JWT 密钥;覆盖升级保留原有 `.env`。`pnpm verify:server-bootstrap` 验证密钥独立性和配置保留,打包后还需在解包副本中复验。 + Linux 还需同名 .tar.gz.sha256。正式 Linux 包在 Linux runner 运行 pnpm dist:server:linux; Windows 上的交叉构建仅作验证,可能缺目标平台的 Prisma engine。 构建中间物可再生成,不将 win-unpacked、server staging 或解包副本当交付物保留。 diff --git a/docs/get-started/client.md b/docs/get-started/client.md index 32f3c2f..278760a 100644 --- a/docs/get-started/client.md +++ b/docs/get-started/client.md @@ -84,3 +84,13 @@ pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的 - 服务器与教室机器之间能互相 ping 通吗?防火墙放行 4000 了吗? 排查清单见[常见问题](../faq.md)。 + +## 7. 接收统一更新 + +管理员在设备管理中缓存并推送客户端安装包,客户端会后台下载、校验文件,再按立即、放学后或空闲策略安装。 +关闭本机自动更新后,不会执行服务端下发的安装。安装助手未能就绪时,旧客户端会继续运行并显示失败原因。 + +服务端下发的是安装版:便携运行的客户端接收更新后转为安装版,原便携文件不会原地替换,后续请使用安装版入口。 +新版重新连接服务端后,设备管理中的更新状态才会变为完成。 + +已发布旧版若在拉起安装器时失败,需要先手动安装 1.4.2 以获得新的安装助手;服务端推送安装包不能替换旧客户端中已经损坏的交接逻辑。 diff --git a/package.json b/package.json index d669922..49132e9 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,9 @@ "verify:v14": "node scripts/verify-v14.mjs", "verify:lifecycle": "node scripts/verify-lifecycle.mjs", "verify:client-agent": "node scripts/verify-client-agent.mjs", + "verify:update-download": "node scripts/verify-update-download.mjs", + "verify:update-installer": "node scripts/verify-update-installer.mjs", + "verify:client-update:real": "node scripts/verify-client-update-real.mjs --real-install", "verify:review-client": "node scripts/verify-review-client.mjs", "verify:review-api": "node scripts/verify-e2e-isolated.mjs --review-api", "verify:clients-api": "node scripts/verify-e2e-isolated.mjs --clients-api", @@ -56,7 +59,8 @@ "verify:desktop:visual": "node packages/desktop-client/scripts/smoke.mjs --visual", "verify:desktop:online": "node packages/desktop-client/scripts/smoke.mjs --online", "verify:authz": "node scripts/verify-authz.mjs", - "check:authz": "node scripts/check-authz.mjs" + "check:authz": "node scripts/check-authz.mjs", + "verify:server-bootstrap": "node scripts/verify-server-bootstrap.mjs" }, "devDependencies": { "concurrently": "^10.0.5", diff --git a/packages/desktop-client/native/update-helper.cs b/packages/desktop-client/native/update-helper.cs new file mode 100644 index 0000000..39bf1ac --- /dev/null +++ b/packages/desktop-client/native/update-helper.cs @@ -0,0 +1,172 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Web.Script.Serialization; + +// 独立 WinExe:从 updates 目录运行,安装器覆盖应用时不会锁住安装目录。 +// 只接收主进程生成的清单;不接受任意命令或安装参数。 +internal static class UpdateHelper +{ + public sealed class Manifest + { + public string Attempt; + public int ParentId; + public string ParentExe; + public string Installer; + public string RestartExe; + public string Version; + public string ServerUrl; + public string Username; + public string Sha256; + public long Size; + public bool Portable; + } + + private static readonly JavaScriptSerializer Json = new JavaScriptSerializer(); + private static readonly Encoding Utf8 = new UTF8Encoding(false); + private static string directory; + private static string prefix; + private static Manifest manifest; + private static Process parent; + private static bool installerRunning; + + [STAThread] + private static int Main(string[] args) + { + try + { + if (args.Length != 1) return 2; + var file = Path.GetFullPath(args[0]); + directory = Path.GetDirectoryName(file); + if (!String.Equals(Path.GetFileName(directory), "updates", StringComparison.OrdinalIgnoreCase)) return 2; + manifest = Json.Deserialize(File.ReadAllText(file, Utf8)); + if (manifest == null || !Regex.IsMatch(manifest.Attempt ?? "", "^[0-9a-f-]{36}$")) return 2; + prefix = Path.Combine(directory, "install-" + manifest.Attempt); + if (!SamePath(file, prefix + ".json")) return 2; + Log("助手启动,目标版本 " + manifest.Version); + parent = Process.GetProcessById(manifest.ParentId); + if (!SamePath(parent.MainModule.FileName, manifest.ParentExe)) throw new Exception("旧进程身份不匹配"); + var installer = Path.GetFullPath(manifest.Installer); + if (!SamePath(Path.GetDirectoryName(installer), directory) || + !String.Equals(Path.GetExtension(installer), ".exe", StringComparison.OrdinalIgnoreCase) || + (File.GetAttributes(installer) & FileAttributes.ReparsePoint) != 0) + throw new Exception("安装包不在受管理目录内"); + if (!Regex.IsMatch(manifest.Sha256 ?? "", "^[0-9a-fA-F]{64}$")) throw new Exception("缺少有效安装包摘要"); + if (manifest.Size <= 0 || new FileInfo(installer).Length != manifest.Size) throw new Exception("安装包长度不匹配"); + using (var stream = File.OpenRead(installer)) + using (var hash = SHA256.Create()) + { + var actual = BitConverter.ToString(hash.ComputeHash(stream)).Replace("-", ""); + if (!String.Equals(actual, manifest.Sha256, StringComparison.OrdinalIgnoreCase)) throw new Exception("安装包 SHA256 校验失败"); + } + File.WriteAllText(prefix + ".ready", manifest.Attempt, Utf8); + Log("校验通过,等待主进程确认交接"); + var deadline = DateTime.UtcNow.AddSeconds(30); + while (!File.Exists(prefix + ".start")) + { + if (File.Exists(prefix + ".cancel")) { Log("主进程取消交接"); return 0; } + if (parent.HasExited) throw new Exception("主进程在确认交接前退出"); + if (DateTime.UtcNow >= deadline) throw new Exception("等待主进程确认超时"); + Thread.Sleep(50); + } + if (File.ReadAllText(prefix + ".start", Utf8) != manifest.Attempt) throw new Exception("交接标识不匹配"); + if (!parent.WaitForExit(60000)) throw new Exception("等待旧客户端退出超时"); + Log("旧进程已退出,开始安装"); + int exitCode; + using (var installerProcess = Process.Start(new ProcessStartInfo(installer, "/S --updated --force-run") + { + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = directory + })) + { + if (installerProcess == null) throw new Exception("安装器未能启动"); + installerRunning = true; + if (!installerProcess.WaitForExit(10 * 60 * 1000)) throw new Exception("安装器执行超时,请查看安装日志"); + installerRunning = false; + exitCode = installerProcess.ExitCode; + } + Log("安装器退出码:" + exitCode); + if (exitCode != 0) throw new Exception("安装器退出码:" + exitCode); + if (!manifest.Portable) + { + var expected = ParseVersion(manifest.Version); + var actual = ParseVersion(FileVersionInfo.GetVersionInfo(manifest.RestartExe).ProductVersion); + if (actual.CompareTo(expected) < 0) throw new Exception("安装器结束后客户端版本仍未更新"); + // 安装器通常会启动新版;未启动时补启一次,避免留在“安装中”。 + var startedAt = DateTime.UtcNow; + while (!IsClientRunning(manifest.RestartExe) && DateTime.UtcNow - startedAt < TimeSpan.FromSeconds(3)) Thread.Sleep(100); + if (!IsClientRunning(manifest.RestartExe)) RestartClient(); + } + Result("installed", "", exitCode); + Log("安装交接完成,等待新版客户端心跳确认"); + return 0; + } + catch (Exception error) + { + try { Log("更新失败:" + error.Message); Result("failed", error.Message, null); } catch { } + // 超时的安装器可能仍在替换文件,不可在它退出之前重新打开客户端。 + try { if (!installerRunning && parent != null && parent.HasExited && manifest != null && File.Exists(manifest.RestartExe) && !IsClientRunning(manifest.RestartExe)) RestartClient(); } + catch (Exception restoreError) { try { Log("恢复客户端失败:" + restoreError.Message); } catch { } } + return 1; + } + finally + { + if (prefix != null) foreach (var suffix in new[] { ".ready", ".start", ".cancel", ".json" }) + try { File.Delete(prefix + suffix); } catch { } + if (parent != null) parent.Dispose(); + } + } + + private static bool SamePath(string a, string b) + { + return String.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase); + } + + private static Version ParseVersion(string value) + { + Version version; + if (!Version.TryParse((value ?? "").Trim().TrimStart('v', 'V').Split('-', '+')[0], out version)) throw new Exception("无法识别客户端版本:" + value); + return version; + } + + private static bool IsClientRunning(string exe) + { + foreach (var process in Process.GetProcessesByName(Path.GetFileNameWithoutExtension(exe))) + { + using (process) try { if (!process.HasExited && SamePath(process.MainModule.FileName, exe)) return true; } catch { } + } + return false; + } + + private static void RestartClient() + { + Process.Start(new ProcessStartInfo(manifest.RestartExe, "--hidden") + { + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(manifest.RestartExe) + }); + Log("已启动客户端"); + } + + private static void Log(string message) + { + if (directory != null) File.AppendAllText(Path.Combine(directory, "install-helper.log"), DateTime.UtcNow.ToString("o") + " " + message + Environment.NewLine, Utf8); + } + + private static void Result(string stage, string error, int? exitCode) + { + if (manifest == null || prefix == null) return; + var value = new { version = manifest.Version, serverUrl = manifest.ServerUrl, username = manifest.Username, attempt = manifest.Attempt, stage = stage, error = error, installerExitCode = exitCode, at = DateTime.UtcNow.ToString("o") }; + var target = Path.Combine(directory, "install-result.json"); + var temp = prefix + ".result"; + File.WriteAllText(temp, Json.Serialize(value), Utf8); + if (File.Exists(target)) File.Delete(target); + File.Move(temp, target); + } +} diff --git a/packages/desktop-client/scripts/build-main.mjs b/packages/desktop-client/scripts/build-main.mjs index b40df21..950f696 100644 --- a/packages/desktop-client/scripts/build-main.mjs +++ b/packages/desktop-client/scripts/build-main.mjs @@ -6,8 +6,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { build } from 'esbuild'; +import { buildUpdateHelper } from './build-update-helper.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +buildUpdateHelper(path.join(root, 'dist/main/update-helper.exe')); const shared = { bundle: true, diff --git a/packages/desktop-client/scripts/build-update-helper.mjs b/packages/desktop-client/scripts/build-update-helper.mjs new file mode 100644 index 0000000..78684f4 --- /dev/null +++ b/packages/desktop-client/scripts/build-update-helper.mjs @@ -0,0 +1,31 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +/** Windows 自带 .NET Framework 编译器;构建产物随客户端分发,不在教室机上编译。 */ +export function buildUpdateHelper(output) { + if (process.platform !== 'win32') return; + const framework = path.join( + process.env.SystemRoot ?? 'C:\\Windows', + 'Microsoft.NET', + 'Framework64', + 'v4.0.30319', + ); + const compiler = path.join(framework, 'csc.exe'); + if (!fs.existsSync(compiler)) throw new Error('缺少 Windows .NET Framework 编译器,无法构建更新助手'); + fs.mkdirSync(path.dirname(output), { recursive: true }); + execFileSync( + compiler, + [ + '/nologo', + '/target:winexe', + '/optimize+', + '/platform:anycpu', + `/reference:${path.join(framework, 'System.Web.Extensions.dll')}`, + `/out:${output}`, + fileURLToPath(new URL('../native/update-helper.cs', import.meta.url)), + ], + { stdio: 'pipe', windowsHide: true }, + ); +} diff --git a/packages/desktop-client/src/main/auto-update.ts b/packages/desktop-client/src/main/auto-update.ts index 37f62c2..9339c72 100644 --- a/packages/desktop-client/src/main/auto-update.ts +++ b/packages/desktop-client/src/main/auto-update.ts @@ -3,7 +3,6 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; -import { spawn } from 'node:child_process'; import { BrowserWindow, app } from 'electron'; import { isNewerVersion, @@ -15,6 +14,7 @@ import { getConfig, saveConfig } from './config.js'; import { island } from './island.js'; import { logger } from './logger.js'; import { connectionEvents } from './config-events.js'; +import { prepareUpdateInstaller } from './update-installer.js'; let updateEpoch = 0; let downloadAbort = new AbortController(); @@ -43,15 +43,17 @@ connectionEvents.on('changed', () => { * 空闲安装要求不在上课且主窗口隐藏满五分钟; * 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行** * (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里); - * 3. **安装脚本脱离本进程**:PowerShell 等旧进程退出、安装器完成后重新拉起应用, - * 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`, - * 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。 + * 3. **独立安装助手**:助手就绪后才退出旧进程,由助手执行安装并确认新版启动; + * 助手从更新目录运行,不锁住正在被安装器替换的应用文件。 */ let policy: | (Pick & { serverUrl: string; username: string; + updateSha256: string; + updateSize: number; + updateFileName: string; }) | null = null; function policyPath(): string { @@ -107,6 +109,9 @@ export function syncUpdatePolicy(next: ClientHeartbeatResponse): void { updateTargetVersion: next.updateTargetVersion, updateMode: next.updateMode ?? 'idle', updateNotBefore: next.updateNotBefore ?? null, + updateSha256: next.update?.version === next.updateTargetVersion ? next.update.sha256 : '', + updateSize: next.update?.version === next.updateTargetVersion ? next.update.size : 0, + updateFileName: next.update?.version === next.updateTargetVersion ? next.update.fileName : '', }; fs.mkdirSync(pendingUpdateDir(), { recursive: true }); fs.writeFileSync(policyPath(), JSON.stringify(policy), 'utf8'); @@ -458,6 +463,14 @@ export async function maybeInstallPendingUpdate(): Promise { } const active = loadPolicy(); if (!active || active.updateTargetVersion !== version) return; + if ( + !/^[0-9a-f]{64}$/i.test(active.updateSha256 ?? '') || + active.updateSize <= 0 || + active.updateFileName !== path.basename(installer) + ) { + logger.warn(`更新 ${version} 尚无有效的服务端摘要,等待心跳重新确认安装包`); + return; + } if (active.updateMode === 'after-school') { const at = Date.parse(active.updateNotBefore ?? ''); if (!Number.isFinite(at) || Date.now() < at) return; @@ -520,33 +533,29 @@ export async function maybeInstallPendingUpdate(): Promise { logger.info(`开始静默安装更新 ${version}:${installer}`); try { const exe = process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe'); - const quote = (value: string): string => "'" + value.replaceAll("'", "''") + "'"; const config = getConfig(); - const resultFile = path.join(pendingUpdateDir(), 'install-result.json'); - // 上一次失败的结果不能冒充这一次:安装开始时先清掉,只有本轮失败才重新写入。 - fs.rmSync(resultFile, { force: true }); - const script = - `$ErrorActionPreference = 'Stop'; try { Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` + - `$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -WindowStyle Hidden -Wait -PassThru; ` + - `if ($installer.ExitCode -ne 0) { throw ('安装器退出码:' + $installer.ExitCode) } ` + - `} catch { @{ version=${quote(version)}; serverUrl=${quote(config.serverUrl)}; username=${quote(config.username)}; error=$_.Exception.Message } | ConvertTo-Json -Compress | Set-Content -LiteralPath ${quote(resultFile)} -Encoding UTF8; ` + - `if (Test-Path -LiteralPath ${quote(exe)}) { Start-Process -FilePath ${quote(exe)} -WindowStyle Hidden } }`; - const encoded = Buffer.from(script, 'utf16le').toString('base64'); - const child = spawn( - 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', encoded], - { - detached: true, - stdio: 'ignore', - windowsHide: true, - }, - ); - await new Promise((resolve, reject) => { - child.once('spawn', resolve); - child.once('error', reject); + const helper = await prepareUpdateInstaller({ + directory: pendingUpdateDir(), + installer, + version, + sha256: active.updateSha256, + size: active.updateSize, + serverUrl: config.serverUrl, + username: config.username, + restartExe: exe, + portable: Boolean(process.env.PORTABLE_EXECUTABLE_FILE), }); - child.unref(); - // 安装器从实际安装目录启动新版;免安装版的旧 exe 可能随退出消失,只在安装失败时尝试恢复它。 + if ( + installEpoch !== updateEpoch || + loadPolicy()?.updateTargetVersion !== version || + getConfig().autoUpdate === false + ) { + helper.cancel(); + installing = false; + return; + } + helper.commit(); + logger.info(`更新 ${version} 的独立安装助手已就绪,确认退出旧客户端`); if (quitHandler) { quitHandler(); } else { @@ -555,6 +564,8 @@ export async function maybeInstallPendingUpdate(): Promise { } } catch (error) { installing = false; + if (installEpoch !== updateEpoch) return; + installationRetryAt = Date.now() + 5 * 60_000; publishProgress({ stage: 'failed', error: String(error).slice(0, 1000) }); logger.error( `启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/desktop-client/src/main/update-installer.ts b/packages/desktop-client/src/main/update-installer.ts new file mode 100644 index 0000000..95dc0d8 --- /dev/null +++ b/packages/desktop-client/src/main/update-installer.ts @@ -0,0 +1,105 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; + +interface InstallerOptions { + directory: string; + installer: string; + version: string; + sha256: string; + size: number; + serverUrl: string; + username: string; + restartExe: string; + portable: boolean; +} + +/** 先确认独立助手就绪;调用方复核策略后 commit,再退出应用。 */ +export async function prepareUpdateInstaller(options: InstallerOptions) { + const attempt = crypto.randomUUID(); + const prefix = path.join(options.directory, `install-${attempt}`); + const helper = path.join(options.directory, `updater-${attempt}.exe`); + const resultFile = path.join(options.directory, 'install-result.json'); + const logFile = path.join(options.directory, 'install-helper.log'); + fs.mkdirSync(options.directory, { recursive: true }); + for (const name of fs.readdirSync(options.directory)) { + if (/^updater-[0-9a-f-]{36}\.exe$/i.test(name)) { + try { + fs.rmSync(path.join(options.directory, name)); + } catch { + /* 仍在运行的助手留待下次清理 */ + } + } + } + fs.copyFileSync(path.join(__dirname, 'update-helper.exe'), helper); + fs.writeFileSync( + `${prefix}.json`, + JSON.stringify({ + Attempt: attempt, + ParentId: process.pid, + ParentExe: process.execPath, + Installer: options.installer, + RestartExe: options.restartExe, + Version: options.version, + ServerUrl: options.serverUrl, + Username: options.username, + Sha256: options.sha256, + Size: options.size, + Portable: options.portable, + }), + 'utf8', + ); + fs.rmSync(resultFile, { force: true }); + fs.writeFileSync(logFile, `${new Date().toISOString()} 准备安装 ${options.version}\n`, 'utf8'); + let child: ReturnType; + try { + child = spawn(helper, [`${prefix}.json`], { + detached: true, + stdio: 'ignore', + windowsHide: true, + cwd: options.directory, + }); + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => finish(new Error('更新助手就绪超时,请查看 install-helper.log')), + 15_000, + ); + const poll = setInterval(() => { + try { + if (fs.readFileSync(`${prefix}.ready`, 'utf8').trim() === attempt) finish(); + } catch { + /* 尚未就绪 */ + } + }, 50); + const onError = (error: Error) => finish(error); + const onExit = (code: number | null) => { + let detail = ''; + try { + detail = JSON.parse(fs.readFileSync(resultFile, 'utf8')).error ?? ''; + } catch { + /* 原生进程未能启动 */ + } + finish(new Error(detail || `更新助手提前退出(${code ?? '未知退出码'}),请查看 install-helper.log`)); + }; + function finish(error?: Error) { + clearTimeout(timeout); + clearInterval(poll); + child.removeListener('error', onError); + child.removeListener('exit', onExit); + if (error) reject(error); + else resolve(); + } + child.once('error', onError); + child.once('exit', onExit); + }); + child.unref(); + } catch (error) { + fs.writeFileSync(`${prefix}.cancel`, attempt, 'utf8'); + throw error; + } + return { + commit: () => fs.writeFileSync(`${prefix}.start`, attempt, 'utf8'), + cancel: () => fs.writeFileSync(`${prefix}.cancel`, attempt, 'utf8'), + }; +} diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index d5a85ee..3cf4a0d 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -1624,11 +1624,23 @@ async function main() { stateNote: '待机', }; - // 心跳不强求权限键(教室端会话权限集为空),但**必须登录**:没有 JWT 一律 401 + const deviceLogin = await api('/auth/class-login', { + method: 'POST', + body: { code: classes.find((item) => item.id === classId)?.code, password: '123456' }, + }); + const deviceToken = deviceLogin.payload?.data?.token; + if (!deviceToken) throw new Error('隔离班级设备登录失败,不能用员工身份替代'); + const staffHeartbeat = await api('/clients/heartbeat', { + method: 'POST', + token: adminToken, + body: heartbeatBase, + }); + record('员工会话不能冒充班级设备上报', staffHeartbeat.status === 403, 'status=' + staffHeartbeat.status); + // 只有本班 CLASS_DEVICE 可以上报和回执;管理端仍用管理员读取及下发。 const heartbeatNoAuth = await api('/clients/heartbeat', { method: 'POST', body: heartbeatBase }); const heartbeat = await api('/clients/heartbeat', { method: 'POST', - token: adminToken, + token: deviceToken, body: heartbeatBase, }); const heartbeatData = heartbeat.payload?.data; @@ -1675,7 +1687,7 @@ async function main() { }); const heartbeatWithCommand = await api('/clients/heartbeat', { method: 'POST', - token: adminToken, + token: deviceToken, body: { ...heartbeatBase, stateNote: '待机' }, }); const delivered = heartbeatWithCommand.payload?.data?.command; @@ -1688,13 +1700,13 @@ async function main() { const ackBody = { deviceKey, ok: true, result: 'e2e' }; const ackFirst = await api(`/clients/commands/${delivered?.id}/ack`, { method: 'POST', - token: adminToken, + token: deviceToken, body: ackBody, }); // 幂等:客户端重试回执不报错、也不覆盖第一次结果 const ackSecond = await api(`/clients/commands/${delivered?.id}/ack`, { method: 'POST', - token: adminToken, + token: deviceToken, body: { deviceKey, ok: false, result: '重试不应覆盖结果' }, }); record( @@ -1716,13 +1728,13 @@ async function main() { const otherDeviceKey = `${deviceKey}-other`; const otherHeartbeat = await api('/clients/heartbeat', { method: 'POST', - token: adminToken, + token: deviceToken, body: { ...heartbeatBase, deviceKey: otherDeviceKey, name: 'E2E 另一台测试机' }, }); const otherDeviceId = otherHeartbeat.payload?.data?.deviceId ?? null; const ackWrongDevice = await api(`/clients/commands/${delivered?.id}/ack`, { method: 'POST', - token: adminToken, + token: deviceToken, body: { deviceKey: otherDeviceKey, ok: true, result: 'e2e-other' }, }); record( @@ -2496,13 +2508,14 @@ async function main() { // 8.3.1 科目归属越权回归(修复"非该科科任老师可以布置/导入其他科内容"): // ① 新建成同名课程必须被拒绝 —— 否则谁都能借"新建科目"把自己写成该科任课老师, // 后面那道 assertCanManageSubjectContent 的科目门随即失效; - // ② 班主任导入**别人任教科目**的成绩必须逐行拒绝(原先只判到班级这一层)。 + // ② 班主任导入**别人任教科目**的成绩必须拒绝(原先只判到班级这一层)。 { const guardedCourseName = `越权校验科目${Date.now().toString(36).slice(-5).toUpperCase()}`; + const otherTeacher = await api('/auth/me', { token: teacher2Token }); const firstCourse = await api('/courses', { method: 'POST', token: adminToken, - body: { classId, name: guardedCourseName }, + body: { classId, name: guardedCourseName, teacherId: otherTeacher.payload?.data?.id }, }); const firstCourseId = firstCourse.payload?.data?.id; record( @@ -2524,8 +2537,7 @@ async function main() { `status=${duplicateCourse.status} code=${duplicateCourse.payload?.code} details=${JSON.stringify(duplicateCourse.payload?.details ?? {})}`, ); - // 这门课的任课老师是**管理员**(course.create 会把创建者写成任课老师): - // 班主任导入它的成绩必须被拒,且不能落库。 + // 显式配置另一位教师任课;班主任身份不能代替科目写权限。 const guardedCsv = [ '学号,考试名称,分数,总分,科目', `${targetStudent?.studentNo ?? '202601'},越权校验考试,88,100,${guardedCourseName}`, @@ -2548,14 +2560,12 @@ async function main() { mode: 'append', }, }); - const guardedErrors = guardedImport.payload?.data?.errors ?? []; + const guardedRows = await api('/grades?classId=' + classId, { token: adminToken }); record( - '班主任导入他人任教科目成绩被逐行拒绝(不落库)', - guardedImport.status === 200 && - guardedImport.payload?.data?.inserted === 0 && - guardedImport.payload?.data?.failed === 1 && - String(guardedErrors[0]?.message ?? '').includes('不是你任教的科目'), - `新增=${guardedImport.payload?.data?.inserted} 失败=${guardedImport.payload?.data?.failed} 提示=${guardedErrors[0]?.message ?? '-'}`, + '班主任不能导入他人任教科目成绩,拒绝后不落库', + guardedImport.status === 403 && + !(guardedRows.payload?.data ?? []).some((item) => item.examName === '越权校验考试'), + 'status=' + guardedImport.status + ' message=' + guardedImport.payload?.message, ); if (firstCourseId) { @@ -2588,7 +2598,7 @@ async function main() { ); // 8.5 需求 6:表格导入在"老师端"可用且不越权 - // 班主任 → 本班成绩可预览/导入;科任 → 403;班主任 → 名单导入仍 403(人员管理仅管理员) + // 授课教师可导入本人科目;他人科目与班级范围之外仍拒绝,名单导入另走人员管理权限。 const teacherGradesPreview = await api('/imports/table/preview', { method: 'POST', token: teacherToken, @@ -2628,32 +2638,66 @@ async function main() { `status=${teacherGradesCommit.status} 新增=${teacherGradesCommit.payload?.data?.inserted} 更新=${teacherGradesCommit.payload?.data?.updated} 失败=${teacherGradesCommit.payload?.data?.failed}`, ); - // teacher2 是2025级3班的科任老师(班主任是 teacher1)——这才是真正的"科任"场景 - const subjectOnlyClass = classes.find((item) => item.name?.includes('2025级3')) ?? null; + // 科任可以导入本人任教科目,不因没有班主任身份被拒。 + const subjectOnlyClass = classes.find((item) => item.name?.includes('2025级3')); + if (!subjectOnlyClass) throw new Error('隔离科任班级缺失'); + const subjectIdentity = await api('/auth/me', { token: teacher2Token }); + const subjectCourses = await api('/courses?classId=' + subjectOnlyClass.id, { token: adminToken }); + const subjectCourse = (subjectCourses.payload?.data ?? []).find( + (item) => item.teacherId === subjectIdentity.payload?.data?.id, + ); + const subjectStudents = await api('/students?classId=' + subjectOnlyClass.id, { token: adminToken }); + const subjectStudent = subjectStudents.payload?.data?.[0]; + if (!subjectCourse || !subjectStudent) throw new Error('隔离科任导入数据缺失'); + const subjectExam = '科任导入验证' + Date.now(); + const subjectCsv = [ + '学号,考试名称,分数,总分,科目', + [subjectStudent.studentNo, subjectExam, 88, 100, subjectCourse.name].join(','), + ].join(NL); + const subjectBody = { + kind: 'grades', + classId: subjectOnlyClass.id, + fileName: 'subject.csv', + contentBase64: Buffer.from('\ufeff' + subjectCsv + NL, 'utf8').toString('base64'), + mapping: { + studentNo: '学号', + examName: '考试名称', + score: '分数', + totalScore: '总分', + courseName: '科目', + }, + mode: 'append', + }; const subjectGradesImport = await api('/imports/table/commit', { method: 'POST', token: teacher2Token, - body: { - kind: 'grades', - classId: subjectOnlyClass?.id ?? classId, - fileName: 'grades.csv', - contentBase64: gradesCsvBase64, - mapping: { - studentNo: '学号', - name: '姓名', - examName: '考试名称', - score: '分数', - totalScore: '总分', - courseName: '科目', - }, - mode: 'upsert', - }, + body: subjectBody, }); record( - '科任老师导入本班成绩被拒绝(403,需求 7)', - Boolean(subjectOnlyClass) && subjectGradesImport.status === 403, - `班级=${subjectOnlyClass?.name ?? '未找到2025级3班'} status=${subjectGradesImport.status}`, + '科任老师可导入本人授课科目成绩', + subjectGradesImport.status === 200 && subjectGradesImport.payload?.data?.inserted === 1, + 'status=' + subjectGradesImport.status, ); + const otherSubject = (subjectCourses.payload?.data ?? []).find( + (item) => item.teacherId !== subjectIdentity.payload?.data?.id, + ); + if (!otherSubject) throw new Error('隔离越权科目缺失'); + const deniedCsv = [ + '学号,考试名称,分数,总分,科目', + [subjectStudent.studentNo, subjectExam + '越权', 88, 100, otherSubject.name].join(','), + ].join(NL); + const deniedImport = await api('/imports/table/commit', { + method: 'POST', + token: teacher2Token, + body: { + ...subjectBody, + contentBase64: Buffer.from('\ufeff' + deniedCsv + NL, 'utf8').toString('base64'), + }, + }); + record('科任老师仍不能导入他人任教科目成绩', deniedImport.status === 403, 'status=' + deniedImport.status); + const subjectRows = await api('/grades?classId=' + subjectOnlyClass.id, { token: adminToken }); + for (const row of subjectRows.payload?.data ?? []) + if (row.examName === subjectExam) await api('/grades/' + row.id, { method: 'DELETE', token: adminToken }); if (foreignClass) { const crossGradesImport = await api('/imports/table/commit', { diff --git a/packages/server/src/modules/update/update.module.ts b/packages/server/src/modules/update/update.module.ts index 22b5260..14282f1 100644 --- a/packages/server/src/modules/update/update.module.ts +++ b/packages/server/src/modules/update/update.module.ts @@ -1,6 +1,7 @@ import { assertClassDevice, assertPermission, classIdsFor } from '../../lib/authz/index.js'; import { getReleaseProgress, setReleaseProgress } from './release-progress.js'; import { Router } from 'express'; +import path from 'node:path'; import { sendCreated, sendOk, ApiError } from '../../lib/http.js'; import { authenticate, @@ -132,7 +133,7 @@ router.delete( router.get( '/client/download/:version', validate({ params: clientReleaseVersionParamSchema }), - async (req, res) => { + async (req, res, next) => { const user = getAuthUser(req); if (user.role === 'CLASS_DEVICE') assertClassDevice(user, user.classId ?? user.sub); else { @@ -143,12 +144,25 @@ router.get( const { version } = validatedParams<{ version: string }>(req); const found = await clientReleases.resolveReleaseFile(version); if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包'); - res.download(found.file, found.fileName, (error) => { - if (error) { - // 客户端中途取消下载也会走到这里,只记日志,不要往已发出的响应里再写东西 - res.locals.downloadError = error instanceof Error ? error.message : String(error); - } - }); + res.download( + path.basename(found.file), + found.fileName, + { root: path.dirname(found.file), dotfiles: 'allow' }, + (error) => { + if (!error || res.destroyed) return; + if (res.headersSent) { + res.destroy(error); + return; + } + // 文件可能在解析元数据后被移除;尚未发送响应时必须结束请求,不能让客户端一直等待。 + const code = (error as NodeJS.ErrnoException).code; + next( + code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR' + ? ApiError.notFound('安装包文件不存在或无法读取,请重新缓存') + : error, + ); + }, + ); }, ); diff --git a/scripts/dist-server.mjs b/scripts/dist-server.mjs index f13363b..102370a 100644 --- a/scripts/dist-server.mjs +++ b/scripts/dist-server.mjs @@ -455,7 +455,6 @@ function pruneRuntime() { /* ------------------------------------------------------------ 4. 配置与脚本 */ function writeEnvironmentFile() { - const jwtSecret = crypto.randomBytes(48).toString('hex'); const content = `# ClassHelper 服务端(安装程序生成,请妥善保管) # 修改后需重启服务生效(开始菜单 → ClassHelper 服务端 → 重启服务) @@ -469,8 +468,8 @@ DATABASE_URL="file:../data/classhelper.db" # 首次启动时自动建表(数据库为空时执行随包的迁移 SQL) AUTO_MIGRATE=true -# 登录令牌签名密钥(首次安装随机生成,请勿泄露/更换,更换后所有用户需重新登录) -JWT_SECRET=${jwtSecret} +# 登录令牌签名密钥由安装程序首次运行时生成;升级保留原值 +JWT_SECRET=CLASSHELPER_GENERATE_SECRET_ON_INSTALL JWT_EXPIRES_IN=7d # 允许跨域来源:* 表示全部允许;若通过域名访问建议改成具体来源,例如 https://class.example.com @@ -494,6 +493,7 @@ TERM_START_DATE= } function writeRuntimeScripts() { + fs.copyFileSync(path.join(deployDir, 'bootstrap-env.cjs'), path.join(staging, 'bootstrap-env.cjs')); // 注意:.cmd 内容保持 ASCII,避免 cmd.exe 按 OEM 代码页(GBK)解析 UTF-8 中文出错; // 中文说明放在 README.txt 与安装向导里。 // 启动采用 PowerShell Start-Process 分离进程 + 输出重定向到日志文件, @@ -514,6 +514,9 @@ if defined PID ( ) ) +"%~dp0node.exe" "%~dp0bootstrap-env.cjs" +if errorlevel 1 exit /b 1 + if not exist "logs" mkdir "logs" echo [ClassHelper] Starting server... diff --git a/scripts/nsis/server-installer.nsi b/scripts/nsis/server-installer.nsi index 6a1a3f1..36fb119 100644 --- a/scripts/nsis/server-installer.nsi +++ b/scripts/nsis/server-installer.nsi @@ -88,6 +88,16 @@ Section "主程序" SecMain Delete "$INSTDIR\.env" Rename "$INSTDIR\.env.previous" "$INSTDIR\.env" + ; 配置模板不携带可用密钥;首次安装独立生成,覆盖升级保留原密钥。 + nsExec::ExecToStack /TIMEOUT=30000 '"$INSTDIR\node.exe" "$INSTDIR\bootstrap-env.cjs"' + Pop $0 + Pop $1 + StrCmp $0 "0" configuration_ready + DetailPrint "配置初始化失败:$1" + SetErrorLevel 1 + Abort + configuration_ready: + WriteRegStr HKCU "Software\ClassHelperServer" "InstallDir" "$INSTDIR" WriteRegStr HKCU "Software\ClassHelperServer" "Version" "${VERSION}" WriteUninstaller "$INSTDIR\uninstall.exe" diff --git a/scripts/verify-client-agent.mjs b/scripts/verify-client-agent.mjs index e705cab..b0711fe 100644 --- a/scripts/verify-client-agent.mjs +++ b/scripts/verify-client-agent.mjs @@ -33,6 +33,8 @@ const h = { acked: [], ackFails: false, quitCount: 0, + committed: 0, + cancelled: 0, }; globalThis.__clientAgentTest = h; function check(name, fn) { @@ -68,6 +70,13 @@ const mockSources = { courseware: `export const pollCourseware=async()=>{};`, logger: `export const logger={info:()=>{},warn:()=>{},error:()=>{},debug:()=>{}};`, process: `export const spawn=(...args)=>globalThis.__clientAgentTest.spawn(...args);`, + 'update-installer': `const h=globalThis.__clientAgentTest; + export async function prepareUpdateInstaller(options) { + h.spawned.push({file:'update-helper.exe', options}); + if(h.spawnError)throw new Error('模拟安装助手启动失败'); + if(h.waitHelper)await new Promise(resolve=>h.releaseHelper=resolve); + return {commit:()=>h.committed++,cancel:()=>h.cancelled++}; + }`, }; try { const bundle = path.join(temp, 'agent.mjs'); @@ -86,7 +95,7 @@ try { path: args.path === 'electron' ? 'electron' : 'process', namespace: 'mock', })); - b.onResolve({ filter: /^\.\/(config|island|logger|courseware)\.js$/ }, (args) => ({ + b.onResolve({ filter: /^\.\/(config|island|logger|courseware|update-installer)\.js$/ }, (args) => ({ path: path.basename(args.path, '.js'), namespace: 'mock', })); @@ -210,7 +219,7 @@ try { }), ]); check('慢速下载不阻塞心跳', () => assert.equal(agent.getClientAgentStatus().updateTargetVersion, '2.0.0')); - const installer = path.join(temp, 'updates', 'verified-setup.exe'); + const installer = path.join(temp, 'updates', 'test-setup.exe'); fs.mkdirSync(path.dirname(installer), { recursive: true }); fs.writeFileSync(installer, '模拟安装器'); Object.assign(h.config, { pendingUpdateVersion: '2.0.0', pendingUpdateFile: installer }); @@ -243,19 +252,29 @@ try { h.visible = true; h.inClass = true; await agent.maybeInstallPendingUpdate(); - check('立即更新在校验包就绪后启动等待旧进程退出的安装助手', () => { + check('安装助手确认就绪后提交交接,再退出旧客户端', () => { assert.equal(h.quitCount, 1); - assert.equal(h.spawned.at(-1).file, 'powershell.exe'); - const script = Buffer.from(h.spawned.at(-1).args.at(-1), 'base64').toString('utf16le'); - assert.match(script, /Wait-Process -Id/); - assert.match(script, /-ArgumentList '\/S','--updated','--force-run'/); - assert.match(script, /if \(\$installer.ExitCode -ne 0/); - assert.match(script, /-WindowStyle|Start-Process/); + assert.equal(h.spawned.at(-1).file, 'update-helper.exe'); + assert.equal(h.spawned.at(-1).options.sha256, responseData.update.sha256); + assert.equal(h.committed, 1); assert.equal(h.progressReports.at(-1).progress.stage, 'installing'); - assert.match(script, /catch \{/); - assert.match(script, /install-result\.json/); - fs.writeFileSync(path.join(root, '.cache', 'verified-update-helper.ps1'), script, 'utf8'); }); + const cancelledInstall = await import(`${pathToFileURL(bundle).href}?cancel-before-ready=1`); + cancelledInstall.setQuitHandler(() => h.quitCount++); + cancelledInstall.syncUpdatePolicy({ ...responseData, updateMode: 'immediate' }); + h.waitHelper = true; + const pendingInstall = cancelledInstall.maybeInstallPendingUpdate(); + while (!h.releaseHelper) await new Promise((resolve) => setTimeout(resolve, 1)); + h.config.autoUpdate = false; + h.releaseHelper(); + await pendingInstall; + check('等待助手就绪时关闭自动更新会取消交接,旧客户端继续运行', () => { + assert.equal(h.quitCount, 1); + assert.equal(h.committed, 1); + assert.equal(h.cancelled, 1); + }); + h.waitHelper = false; + h.config.autoUpdate = true; fs.writeFileSync( path.join(temp, 'updates', 'install-result.json'), JSON.stringify({ diff --git a/scripts/verify-client-update-real.mjs b/scripts/verify-client-update-real.mjs new file mode 100644 index 0000000..3b632a0 --- /dev/null +++ b/scripts/verify-client-update-real.mjs @@ -0,0 +1,605 @@ +/** 真实更新验收:隔离数据库、班级、应用标识和安装目录,运行真实 NSIS;不使用桌面自动化。 */ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import net from 'node:net'; +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { buildUpdateHelper } from '../packages/desktop-client/scripts/build-update-helper.mjs'; +import assert from 'node:assert/strict'; +if (process.platform !== 'win32' || !process.argv.includes('--real-install')) + throw new Error('此验收会真实安装和卸载独立测试副本,仅在 Windows 显式传 --real-install 时执行'); +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const rootVersion = JSON.parse(fs.readFileSync(path.join(repo, 'package.json'), 'utf8')).version; +const portable = process.argv.includes('--portable'); +const targetVersion = process.argv.includes('--prefix-version') ? `v${rootVersion}` : rootVersion; +const session = path.join(repo, '.cache/client-update-real'); +const runDir = path.join( + session, + (process.argv.includes('--unicode-paths') ? '升级 验证-' : 'run-') + + new Date().toISOString().replace(/[:.]/g, '-'), +); +fs.mkdirSync(runDir, { recursive: true }); +fs.writeFileSync(path.join(session, 'latest-run.txt'), runDir); +const id = crypto.randomBytes(5).toString('hex'); +const name = 'ClassHelperUpdateProbe' + id; +const appId = 'com.classhelper.updateprobe.' + id; +const installDir = path.join(runDir, 'installed'); +const profile = path.join(runDir, 'profile'); +const serverDir = path.join(runDir, 'server'); +const traceFile = path.join(runDir, 'http-trace.jsonl'); +const bootFile = path.join(runDir, 'boots.jsonl'); +const [major, minor, patch] = rootVersion.split('-')[0].split('.').map(Number); +const previousVersion = + patch > 0 ? `${major}.${minor}.${patch - 1}` : minor > 0 ? `${major}.${minor - 1}.0` : `${major - 1}.0.0`; +const versions = [previousVersion, rootVersion]; +const report = { + runDir, + appId, + executableName: name, + versions, + portable, + targetVersion, + startedAt: new Date().toISOString(), + checks: [], + result: 'RUNNING', + fixtureDifferences: [ + '独立测试应用标识和安装目录', + '仅改变测试包版本,仓库版本不变', + '启动壳仅隐藏窗口并记录进程身份', + '不创建快捷方式', + ], + sourceHashes: {}, +}; +const writeReport = () => fs.writeFileSync(path.join(runDir, 'report.json'), JSON.stringify(report, null, 2)); +const log = (s) => { + console.log(new Date().toISOString() + ' ' + s); + fs.appendFileSync(path.join(runDir, 'runner.log'), new Date().toISOString() + ' ' + s + '\n'); +}; +const check = (label, detail = {}) => { + report.checks.push({ label, at: new Date().toISOString(), ...detail }); + writeReport(); + log('PASS ' + label); +}; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +function within(p) { + const rel = path.relative(runDir, path.resolve(p)); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) throw new Error('Unsafe fixture path: ' + p); + return p; +} +const sha = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +function childRun(exe, args, file, env = process.env, cwd = repo, timeout = 300000) { + return new Promise((resolve, reject) => { + const out = fs.createWriteStream(path.join(runDir, file), { flags: 'a' }); + const child = spawn(exe, args, { cwd, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + child.stdout?.pipe(out, { end: false }); + child.stderr?.pipe(out, { end: false }); + const timer = setTimeout(() => { + child.kill(); + reject(new Error(file + ' timed out')); + }, timeout); + child.once('error', (e) => { + clearTimeout(timer); + out.end(); + reject(e); + }); + child.once('exit', (code) => { + clearTimeout(timer); + out.end(); + if (code === 0) resolve(); + else reject(new Error(file + ' exited ' + code)); + }); + }); +} +async function waitFor(label, fn, timeout = 90000) { + const end = Date.now() + timeout; + let last; + while (Date.now() < end) { + try { + const value = await fn(); + if (value) return value; + } catch (e) { + last = e; + } + await sleep(500); + } + throw new Error('Timeout: ' + label + (last ? ' (' + last.message + ')' : '')); +} +const readLines = (file) => { + try { + return fs + .readFileSync(file, 'utf8') + .trim() + .split('\n') + .filter(Boolean) + .map((x) => JSON.parse(x)); + } catch { + return []; + } +}; +const clientRequire = createRequire(path.join(repo, 'packages/desktop-client/package.json')); +const builderRequire = createRequire(clientRequire.resolve('electron-builder')); +const asarRequire = createRequire(builderRequire.resolve('app-builder-lib')); +const asar = asarRequire('@electron/asar'); +const { build: esbuild } = clientRequire('esbuild'); +const electronDist = path.dirname(clientRequire('electron')); +const buildEnv = { + ...process.env, + ELECTRON_BUILDER_CACHE: path.join(repo, '.cache/electron-builder'), + ELECTRON_CACHE: path.join(repo, '.cache/electron'), + CSC_IDENTITY_AUTO_DISCOVERY: 'false', + TEMP: path.join(runDir, 'tmp'), + TMP: path.join(runDir, 'tmp'), +}; +function cachedTool(prefix, required) { + const cache = buildEnv.ELECTRON_BUILDER_CACHE; + if (!fs.existsSync(cache)) throw new Error('缺少 NSIS 工具缓存,请先完成一次客户端构建'); + for (const entry of fs.readdirSync(cache, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue; + const base = path.join(cache, entry.name); + for (const child of fs.readdirSync(base, { withFileTypes: true })) { + if (!child.isDirectory() || child.name.endsWith('.tmp')) continue; + const candidate = path.join(base, child.name); + if (required.every((file) => fs.existsSync(path.join(candidate, file)))) return candidate; + } + } + throw new Error('缺少完整 NSIS 构建工具缓存,请先完成一次客户端构建'); +} +buildEnv.ELECTRON_BUILDER_NSIS_DIR ??= cachedTool('nsis-', ['elevate.exe', 'Bin/makensis.exe']); +buildEnv.ELECTRON_BUILDER_NSIS_RESOURCES_DIR ??= cachedTool('nsis-resources-', ['plugins']); +for (const key of [ + 'ELECTRON_RUN_AS_NODE', + 'ELECTRON_SMOKE_TEST', + 'ELECTRON_SMOKE_MODE', + 'VITE_DEV_SERVER_URL', +]) + delete buildEnv[key]; +fs.mkdirSync(buildEnv.TEMP, { recursive: true }); +const serverEnv = { + ...buildEnv, + NODE_ENV: 'test', + DATABASE_PROVIDER: 'sqlite', + DATABASE_URL: 'file:' + path.join(runDir, 'test.db').replaceAll('\\', '/'), + JWT_SECRET: crypto.randomBytes(36).toString('hex'), + BCRYPT_ROUNDS: '4', + RATE_LIMIT_ENABLED: 'false', + LOG_LEVEL: 'warn', + TERM_START_DATE: '2026-09-07', + WEB_DIST_DIR: path.join(repo, 'packages/web-admin/dist'), +}; +let server, initialClient, adminToken, base, uninstaller; +const credentials = { + username: 'probe_' + id, + password: crypto.randomBytes(18).toString('hex'), + code: ('UP' + id).toUpperCase(), + classPassword: crypto.randomBytes(18).toString('hex'), +}; +const deviceKey = crypto.randomUUID(); +async function api(endpoint, { method = 'GET', body, token = adminToken } = {}) { + const response = await fetch(base + '/api' + endpoint, { + method, + headers: { + ...(token ? { authorization: 'Bearer ' + token } : {}), + ...(body ? { 'content-type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(10000), + }); + const data = await response.json(); + if (!response.ok || !data.success) + throw new Error(endpoint + ' HTTP ' + response.status + ': ' + JSON.stringify(data)); + return data.data; +} +async function stopClient() { + try { + const { port } = JSON.parse(fs.readFileSync(path.join(profile, 'probe-control.json'), 'utf8')); + await fetch('http://127.0.0.1:' + port + '/quit', { method: 'POST', signal: AbortSignal.timeout(2000) }); + } catch {} +} +try { + log('测试目录:' + runDir); + writeReport(); + for (const file of [ + 'src/main/auto-update.ts', + 'src/main/update-installer.ts', + 'native/update-helper.cs', + 'src/main/client-agent.ts', + 'src/main/index.ts', + 'src/main/update.ts', + ]) + report.sourceHashes[file] = sha(path.join(repo, 'packages/desktop-client', file)); + fs.mkdirSync(serverDir, { recursive: true }); + fs.cpSync(path.join(repo, 'packages/server/src'), path.join(serverDir, 'src'), { recursive: true }); + fs.mkdirSync(path.join(serverDir, 'prisma'), { recursive: true }); + fs.cpSync(path.join(repo, 'packages/server/prisma/migrations'), path.join(serverDir, 'prisma/migrations'), { + recursive: true, + }); + fs.copyFileSync( + path.join(repo, 'packages/server/prisma/schema.prisma'), + path.join(serverDir, 'prisma/schema.prisma'), + ); + fs.writeFileSync(path.join(serverDir, 'package.json'), JSON.stringify({ type: 'module' })); + if (!fs.existsSync(path.join(serverDir, 'node_modules'))) + fs.symlinkSync( + path.join(repo, 'packages/server/node_modules'), + path.join(serverDir, 'node_modules'), + 'junction', + ); + const serverRequire = createRequire(path.join(repo, 'packages/server/package.json')); + const initDb = serverRequire('@libsql/client').createClient({ url: serverEnv.DATABASE_URL }); + await initDb.execute('PRAGMA user_version=0'); + initDb.close(); + await childRun( + process.execPath, + [ + path.join(repo, 'packages/server/node_modules/prisma/build/index.js'), + 'migrate', + 'deploy', + '--config', + path.join(repo, 'packages/server/prisma.config.ts'), + ], + 'migrate.log', + serverEnv, + ); + fs.writeFileSync(path.join(runDir, 'credentials.json'), JSON.stringify(credentials)); + const seedCode = `import fs from 'node:fs'; import {prisma} from './src/lib/db.js'; import {hashPassword} from './src/lib/password.js'; const c=JSON.parse(fs.readFileSync(${JSON.stringify(path.join(runDir, 'credentials.json'))},'utf8')); await prisma.class.deleteMany({where:{code:c.code}}); await prisma.user.deleteMany({where:{username:c.username}}); await prisma.user.create({data:{username:c.username,passwordHash:await hashPassword(c.password),name:'更新验证管理员',role:'ADMIN'}}); const cls=await prisma.class.create({data:{name:'更新验证临时班级',grade:'高一',code:c.code,passwordHash:await hashPassword(c.classPassword)}}); fs.writeFileSync(${JSON.stringify(path.join(runDir, 'class.json'))},JSON.stringify({id:cls.id})); await prisma.$disconnect();`; + fs.writeFileSync(path.join(serverDir, 'seed.mts'), seedCode); + await childRun( + process.execPath, + [ + '--import', + pathToFileURL(path.join(repo, 'packages/server/node_modules/tsx/dist/loader.mjs')).href, + path.join(serverDir, 'seed.mts'), + ], + 'seed.log', + serverEnv, + ); + const listener = net.createServer(); + await new Promise((r) => listener.listen(0, '127.0.0.1', r)); + const port = listener.address().port; + await new Promise((r) => listener.close(r)); + base = 'http://127.0.0.1:' + port; + serverEnv.PORT = String(port); + report.base = base; + const serverCode = `import fs from 'node:fs'; import {createServer} from 'node:http'; import {createApp} from './src/app.js'; import {initRealtime} from './src/realtime/socket.js'; const app=createApp(); const originalFetch=globalThis.fetch; globalThis.fetch=(u,i)=>{const url=new URL(typeof u==='string'?u:u.url??u);if(!['127.0.0.1','localhost','[::1]'].includes(url.hostname))return Promise.reject(new Error('Probe blocks external requests'));return originalFetch(u,i)}; const server=createServer((req,res)=>{const requestUrl=req.url;let chunks=[]; if(requestUrl?.includes('/clients/'))req.on('data',c=>chunks.push(c));res.on('finish',()=>{const item={at:new Date().toISOString(),method:req.method,url:requestUrl,status:res.statusCode,length:res.getHeader('content-length')};try{const b=JSON.parse(Buffer.concat(chunks).toString());item.deviceKey=b.deviceKey;item.clientVersion=b.clientVersion;item.progress=b.progress??b.updateProgress;}catch{} if(requestUrl?.includes('/clients/')||requestUrl?.includes('/update/'))fs.appendFileSync(${JSON.stringify(traceFile)},JSON.stringify(item)+'\\n')});app(req,res)});initRealtime(server);server.listen(${port},'127.0.0.1',()=>console.log('PROBE_READY'));`; + fs.writeFileSync(path.join(serverDir, 'probe.mts'), serverCode); + server = spawn( + process.execPath, + [ + '--import', + pathToFileURL(path.join(repo, 'packages/server/node_modules/tsx/dist/loader.mjs')).href, + path.join(serverDir, 'probe.mts'), + ], + { cwd: serverDir, env: serverEnv, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + const serverLog = fs.createWriteStream(path.join(runDir, 'server.log')); + server.stdout.pipe(serverLog); + server.stderr.pipe(serverLog); + await waitFor( + 'isolated server ready', + async () => (await fetch(base + '/healthz', { signal: AbortSignal.timeout(1500) })).ok, + ); + adminToken = ( + await api('/auth/login', { + method: 'POST', + body: { username: credentials.username, password: credentials.password }, + token: null, + }) + ).token; + const classToken = ( + await api('/auth/class-login', { + method: 'POST', + body: { code: credentials.code, password: credentials.classPassword }, + token: null, + }) + ).token; + assert.ok(adminToken && classToken); + check('真实服务端与临时班级登录成功'); + const template = path.join(runDir, 'client-template'); + fs.mkdirSync(path.join(template, 'dist/main'), { recursive: true }); + await childRun( + process.execPath, + [ + path.join(repo, 'packages/desktop-client/node_modules/vite/bin/vite.js'), + 'build', + '--config', + path.join(repo, 'packages/desktop-client/vite.config.mts'), + '--outDir', + path.join(template, 'dist/renderer'), + ], + 'renderer-build.log', + buildEnv, + path.join(repo, 'packages/desktop-client'), + ); + buildUpdateHelper(path.join(template, 'dist/main/update-helper.exe')); + const buildCommon = { + bundle: true, + platform: 'node', + target: 'node20', + format: 'cjs', + sourcemap: false, + external: ['electron'], + logLevel: 'warning', + define: { 'process.env.NODE_ENV': JSON.stringify('production') }, + }; + for (const [src, dst] of [ + ['src/main/index.ts', 'dist/main/index.js'], + ['src/preload/index.ts', 'dist/preload/index.js'], + ['src/preload/island.ts', 'dist/preload/island.js'], + ]) + await esbuild({ + ...buildCommon, + entryPoints: [path.join(repo, 'packages/desktop-client', src)], + outfile: path.join(template, dst), + }); + const bootstrap = `const fs=require('node:fs'),path=require('node:path'),http=require('node:http'),{app}=require('electron');const profile=${JSON.stringify(profile)};fs.mkdirSync(profile,{recursive:true});app.setPath('userData',profile);app.setPath('logs',path.join(profile,'logs'));process.argv.push('--hidden');const fetchOriginal=globalThis.fetch;globalThis.fetch=(u,i)=>{const v=new URL(typeof u==='string'?u:u.url??u);if(!['127.0.0.1','localhost','[::1]'].includes(v.hostname))return Promise.reject(new Error('Probe blocks external requests'));return fetchOriginal(u,i)};const add=(x)=>fs.appendFileSync(${JSON.stringify(bootFile)},JSON.stringify({at:new Date().toISOString(),...x})+'\\n');add({event:'boot',pid:process.pid,version:app.getVersion(),exe:process.execPath,packaged:app.isPackaged,smoke:process.env.ELECTRON_SMOKE_TEST==='1'});app.on('browser-window-created',(_e,w)=>{w.on('show',()=>w.hide());w.webContents.on('did-finish-load',()=>add({event:'renderer-ready',pid:process.pid,version:app.getVersion()}));});app.on('will-quit',()=>add({event:'quit',pid:process.pid,version:app.getVersion()}));const ctl=http.createServer((q,s)=>{if(q.url==='/quit'&&q.method==='POST'){s.end('ok');setTimeout(()=>app.quit(),100)}else{s.statusCode=404;s.end()}});ctl.listen(0,'127.0.0.1',()=>fs.writeFileSync(path.join(profile,'probe-control.json'),JSON.stringify({port:ctl.address().port,pid:process.pid})));ctl.unref();require('./dist/main/index.js');`; + fs.writeFileSync(path.join(template, 'probe-bootstrap.cjs'), bootstrap); + const builderCli = path.join( + path.dirname(clientRequire.resolve('electron-builder/package.json')), + 'out/cli/cli.js', + ); + const setups = []; + for (const version of versions) { + log('构建真实 NSIS 安装包 ' + version); + const project = path.join(runDir, 'app-' + version); + fs.cpSync(template, project, { recursive: true }); + fs.writeFileSync( + path.join(project, 'package.json'), + JSON.stringify({ + name: 'classhelper-update-probe-' + id, + version, + description: '隔离更新验证', + author: 'ClassHelper', + main: 'probe-bootstrap.cjs', + private: true, + }), + ); + const output = path.join(runDir, 'build-' + version); + const config = { + appId, + productName: name, + executableName: name, + directories: { app: project, output, buildResources: path.join(repo, 'packages/desktop-client/build') }, + files: ['dist/**/*', 'probe-bootstrap.cjs', 'package.json', '!node_modules/**/*'], + asar: true, + npmRebuild: false, + compression: 'normal', + electronLanguages: ['zh-CN'], + electronDist, + electronVersion: JSON.parse(fs.readFileSync(clientRequire.resolve('electron/package.json'))).version, + win: { + target: [ + { target: 'nsis', arch: ['x64'] }, + ...(portable && version === versions[0] ? [{ target: 'portable', arch: ['x64'] }] : []), + ], + icon: path.join(repo, 'packages/desktop-client/build/classhelper.ico'), + }, + nsis: { + oneClick: false, + perMachine: false, + allowToChangeInstallationDirectory: true, + createDesktopShortcut: false, + createStartMenuShortcut: false, + artifactName: 'ClassHelper-${version}-x64-client-setup.exe', + }, + portable: { artifactName: 'ClassHelper-${version}-x64-client-portable.exe' }, + }; + const configFile = path.join(runDir, 'builder-' + version + '.json'); + fs.writeFileSync(configFile, JSON.stringify(config, null, 2)); + await childRun( + process.execPath, + [builderCli, '--win', '--x64', '--publish', 'never', '--config', configFile], + 'build-' + version + '.log', + buildEnv, + project, + 600000, + ); + const setup = path.join(output, 'ClassHelper-' + version + '-x64-client-setup.exe'); + assert.ok(fs.existsSync(setup)); + setups.push(setup); + } + report.installers = setups.map((file, i) => ({ + file, + version: versions[i], + size: fs.statSync(file).size, + sha256: sha(file), + })); + const compiledHash = sha(path.join(template, 'dist/main/index.js')); + for (const version of versions) { + const archived = asar.extractFile( + path.join(runDir, 'build-' + version, 'win-unpacked/resources/app.asar'), + path.join('dist', 'main', 'index.js'), + ); + assert.equal(crypto.createHash('sha256').update(archived).digest('hex'), compiledHash); + } + check('两份真实安装包均包含相同的当前客户端更新代码', { compiledMainSha256: compiledHash }); + const releaseDir = path.join(runDir, 'data/client-releases', targetVersion); + fs.mkdirSync(releaseDir, { recursive: true }); + fs.copyFileSync(setups[1], path.join(releaseDir, path.basename(setups[1]))); + const release = await api('/update/client/releases', { + method: 'POST', + body: { version: targetVersion, fileName: path.basename(setups[1]), notes: '隔离真实安装验证' }, + }); + assert.equal(release.sha256, report.installers[1].sha256); + check('真实 API 登记新安装包,服务端 SHA256 与构建产物一致'); + log('将旧测试版安装到 ' + installDir); + within(installDir); + await childRun(setups[0], ['/S', '/D=' + installDir], 'initial-install.log', buildEnv, runDir, 180000); + const exe = path.join(installDir, name + '.exe'); + assert.ok(fs.existsSync(exe)); + uninstaller = path.join(installDir, 'Uninstall ' + name + '.exe'); + const installedVersion = () => + JSON.parse(asar.extractFile(path.join(installDir, 'resources/app.asar'), 'package.json').toString()) + .version; + assert.equal(installedVersion(), versions[0]); + check('旧测试版真实静默安装成功', { installDir }); + fs.mkdirSync(profile, { recursive: true }); + fs.writeFileSync( + path.join(profile, 'config.json'), + JSON.stringify({ + serverUrl: base, + username: credentials.code, + token: classToken, + tokenEncrypted: false, + deviceKey, + autoUpdate: true, + autoLaunch: false, + onboardingDone: true, + developerMode: true, + island: { idleSliver: false, animations: false }, + islandLayout: { layout: 'fixed' }, + }), + ); + const clientEnv = { ...buildEnv, ELECTRON_SMOKE_PROFILE: profile }; + const launchExe = portable + ? path.join(runDir, `build-${versions[0]}`, `ClassHelper-${versions[0]}-x64-client-portable.exe`) + : exe; + initialClient = spawn(launchExe, ['--hidden'], { + cwd: installDir, + env: clientEnv, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const clientLog = fs.createWriteStream(path.join(runDir, 'client-console.log')); + initialClient.stdout.pipe(clientLog); + initialClient.stderr.pipe(clientLog); + const device = await waitFor( + 'old client heartbeat', + async () => + (await api('/clients/devices')).find( + (d) => d.deviceKey === deviceKey && d.clientVersion === versions[0], + ), + 90000, + ); + const oldBoot = readLines(bootFile).find((b) => b.event === 'boot' && b.version === versions[0]); + assert.ok(oldBoot?.packaged && !oldBoot.smoke); + check('旧版打包客户端真实启动并向服务端上报', { + pid: oldBoot.pid, + deviceId: device.id, + version: device.clientVersion, + }); + const pushed = await api('/clients/updates', { + method: 'POST', + body: { deviceIds: [device.id], version: targetVersion, mode: 'immediate' }, + }); + report.pushResult = pushed; + check('真实 API 向指定测试设备下发更新'); + let lastStatus = ''; + const upgraded = await waitFor( + 'new version heartbeat and completed receipt', + async () => { + const d = (await api('/clients/devices')).find((x) => x.deviceKey === deviceKey); + const progress = + typeof d?.updateProgress === 'string' + ? (() => { + try { + return JSON.parse(d.updateProgress); + } catch { + return null; + } + })() + : d?.updateProgress; + const status = JSON.stringify({ + version: d?.clientVersion, + stage: progress?.stage, + error: progress?.error, + }); + if (status !== lastStatus) { + lastStatus = status; + log('设备状态 ' + status); + } + if (d?.clientVersion === versions[1] && progress?.stage === 'completed') return d; + return false; + }, + 180000, + ); + const traces = readLines(traceFile); + const downloads = traces.filter((t) => t.url?.includes('/update/client/download/') && t.status === 200); + assert.ok(downloads.length); + assert.equal(Number(downloads.at(-1).length), report.installers[1].size); + const cached = path.join(profile, 'updates', path.basename(setups[1])); + if (fs.existsSync(cached)) assert.equal(sha(cached), report.installers[1].sha256); + check('客户端通过真实下载接口收完整安装包并校验', { + downloads: downloads.map((t) => ({ at: t.at, length: t.length, status: t.status })), + }); + const boots = readLines(bootFile); + const newBoot = boots.find((b) => b.event === 'boot' && b.version === versions[1]); + assert.ok(newBoot?.packaged && !newBoot.smoke && newBoot.pid !== oldBoot.pid); + assert.equal(path.resolve(newBoot.exe).toLowerCase(), path.resolve(exe).toLowerCase()); + assert.equal(installedVersion(), versions[1]); + assert.ok(boots.some((b) => b.event === 'quit' && b.pid === oldBoot.pid)); + check('旧进程退出,真实安装器覆盖原目录,新版自动启动', { + oldPid: oldBoot.pid, + newPid: newBoot.pid, + exe: newBoot.exe, + installedVersion: installedVersion(), + }); + await waitFor( + 'new renderer loaded', + () => readLines(bootFile).some((b) => b.event === 'renderer-ready' && b.pid === newBoot.pid), + 15000, + ); + check('新版界面资源加载成功,服务端确认升级完成', { + clientVersion: upgraded.clientVersion, + updateProgress: upgraded.updateProgress, + }); + report.result = 'PASS'; + assert.equal(JSON.parse(fs.readFileSync(path.join(repo, 'package.json'), 'utf8')).version, rootVersion); + const helperLog = fs.readFileSync(path.join(profile, 'updates/install-helper.log'), 'utf8'); + assert.match(helperLog, /安装器退出码:0/); + report.installerHelperLog = helperLog; +} catch (error) { + report.result = 'FAIL'; + report.error = error.stack ?? String(error); + log('FAIL ' + error.message); +} finally { + log('停止隔离进程并卸载测试副本'); + await stopClient(); + await sleep(2000); + if (initialClient?.exitCode === null) { + try { + initialClient.kill(); + } catch {} + } + if (uninstaller && fs.existsSync(uninstaller)) { + try { + within(uninstaller); + await childRun(uninstaller, ['/S'], 'uninstall.log', buildEnv, installDir, 120000); + report.uninstalled = true; + } catch (e) { + report.cleanupError = e.message; + } + } + if (server?.exitCode === null) { + server.kill(); + await sleep(500); + } + for (const file of [path.join(runDir, 'credentials.json'), path.join(profile, 'config.json')]) + if (fs.existsSync(file)) { + try { + const config = JSON.parse(fs.readFileSync(file)); + for (const key of ['password', 'classPassword', 'token']) + if (key in config) config[key] = ''; + fs.writeFileSync(file, JSON.stringify(config, null, 2)); + } catch {} + } + delete serverEnv.JWT_SECRET; + adminToken = undefined; + report.finishedAt = new Date().toISOString(); + writeReport(); + console.log( + JSON.stringify( + { + result: report.result, + checks: report.checks.length, + error: report.error?.split('\n')[0], + report: path.join(runDir, 'report.json'), + cleanup: report.uninstalled, + }, + null, + 2, + ), + ); +} +process.exitCode = report.result === 'PASS' ? 0 : 1; diff --git a/scripts/verify-server-bootstrap.mjs b/scripts/verify-server-bootstrap.mjs new file mode 100644 index 0000000..9b7ea17 --- /dev/null +++ b/scripts/verify-server-bootstrap.mjs @@ -0,0 +1,37 @@ +/** 验证安装副本生成独立密钥,并在覆盖升级和重复启动时保留配置。 */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { initialiseEnvironment, MARKER } = require('../deploy/bootstrap-env.cjs'); +const root = fs.mkdtempSync(path.resolve('.cache/server-bootstrap-')); +const template = '# test\nJWT_SECRET=' + MARKER + '\nPORT=4000\n'; +const first = path.join(root, 'first.env'); +const second = path.join(root, 'second.env'); +for (const file of [first, second]) fs.writeFileSync(file, template); +assert.equal(initialiseEnvironment(first), true); +assert.equal(initialiseEnvironment(second), true); +const key = (file) => fs.readFileSync(file, 'utf8').match(/^JWT_SECRET=(.+)$/m)?.[1]; +assert.match(key(first), /^[a-f0-9]{96}$/); +assert.match(key(second), /^[a-f0-9]{96}$/); +assert.notEqual(key(first), key(second)); +console.log('PASS 每个首次安装副本生成独立的随机密钥'); +const before = fs.readFileSync(first); +assert.equal(initialiseEnvironment(first), false); +assert.deepEqual(fs.readFileSync(first), before); +console.log('PASS 重复启动保留既有密钥及全部配置'); +const existing = path.join(root, 'existing.env'); +const config = 'JWT_SECRET=existing-private-key\r\nPORT=4400\r\n'; +fs.writeFileSync(existing, config); +assert.equal(initialiseEnvironment(existing), false); +assert.equal(fs.readFileSync(existing, 'utf8'), config); +console.log('PASS 覆盖升级不旋转用户原有密钥'); +const crlf = path.join(root, 'crlf.env'); +fs.writeFileSync(crlf, template.replaceAll('\n', '\r\n')); +assert.equal(initialiseEnvironment(crlf), true); +assert.match(key(crlf), /^[a-f0-9]{96}\r?$/); +console.log('PASS Windows 换行模板初始化成功'); +assert.throws(() => initialiseEnvironment(path.join(root, 'missing.env')), { code: 'ENOENT' }); +console.log('PASS 缺少配置时失败,不生成隐含默认配置'); +console.log('5/5 服务端配置初始化验证通过'); diff --git a/scripts/verify-update-download.mjs b/scripts/verify-update-download.mjs new file mode 100644 index 0000000..4b2bf48 --- /dev/null +++ b/scripts/verify-update-download.mjs @@ -0,0 +1,96 @@ +/** 使用实际更新路由和 Express 文件发送,聚焦隐藏路径、Range 与文件失效后的错误收尾。 */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const dir = path.join(root, '.cache', 'update-download-tests', String(Date.now()), '.hidden'); +fs.mkdirSync(dir, { recursive: true }); +const file = path.join(dir, 'client-setup.exe'); +const bytes = Buffer.alloc(4096, 0x5a); +fs.writeFileSync(file, bytes); +globalThis.__updateDownloadFixture = { file, fileName: path.basename(file) }; +const clientRequire = createRequire(path.join(root, 'packages/desktop-client/package.json')); +const serverRequire = createRequire(path.join(root, 'packages/server/package.json')); +const express = serverRequire('express'); +const pass = 'const pass=()=> (_req,_res,next)=>next();'; +const mocks = { + authz: + 'export const assertClassDevice=()=>{}; export const assertPermission=async()=>{}; export const classIdsFor=async()=>[];', + auth: + pass + + 'export const authenticate=pass,requireAnyPermission=pass,requirePermission=pass,requireAdmin=pass,requirePermissionOrClassDevice=pass; export const getAuthUser=()=>({role:"CLASS_DEVICE",sub:"fixture"}); export const hasAuthPermission=()=>false;', + validate: + pass + + 'export const validateBadRequest=pass;export const validatedBody=req=>req.body;export const validatedParams=req=>req.params;', + releases: + 'export const resolveReleaseFile=async()=>globalThis.__updateDownloadFixture;export const listReleases=async()=>[];export const fetchReleaseFromGithub=async()=>null;export const registerRelease=async()=>null;export const deleteRelease=async()=>null;', + update: 'export const checkForUpdates=async()=>null;', + progress: 'export const getReleaseProgress=()=>({});export const setReleaseProgress=()=>{};', +}; +const output = path.join(dir, 'route.cjs'); +await clientRequire('esbuild').build({ + entryPoints: [path.join(root, 'packages/server/src/modules/update/update.module.ts')], + outfile: output, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent', + plugins: [ + { + name: '隔离身份与元数据', + setup(build) { + build.onResolve({ filter: /^express$/ }, () => ({ + path: serverRequire.resolve('express'), + external: true, + })); + const rules = [ + [/authz\/index\.js$/, 'authz'], + [/middleware\/auth\.js$/, 'auth'], + [/middleware\/validate\.js$/, 'validate'], + [/client-releases\.service\.js$/, 'releases'], + [/\/update\.service\.js$/, 'update'], + [/release-progress\.js$/, 'progress'], + ]; + build.onResolve({ filter: /.*/ }, (args) => { + const rule = rules.find(([pattern]) => pattern.test(args.path)); + return rule ? { path: rule[1], namespace: 'fixture' } : undefined; + }); + build.onLoad({ filter: /.*/, namespace: 'fixture' }, (args) => ({ + contents: mocks[args.path], + loader: 'js', + })); + }, + }, + ], +}); +const { updateModule } = clientRequire(output); +const app = express(); +app.use('/api/update', updateModule.router); +app.use((error, _req, res, _next) => + res.status(error.status ?? 500).json({ code: error.code, message: error.message }), +); +const server = app.listen(0, '127.0.0.1'); +await new Promise((resolve) => server.once('listening', resolve)); +const url = `http://127.0.0.1:${server.address().port}/api/update/client/download/fixture`; +try { + const downloaded = await fetch(url, { signal: AbortSignal.timeout(2000) }); + assert.equal(downloaded.status, 200); + assert.deepEqual(Buffer.from(await downloaded.arrayBuffer()), bytes); + console.log('✓ 实际更新路由可发送隐藏目录中的安装包'); + const range = await fetch(url, { headers: { range: 'bytes=4-11' }, signal: AbortSignal.timeout(2000) }); + assert.equal(range.status, 206); + assert.equal((await range.arrayBuffer()).byteLength, 8); + console.log('✓ 安装包 Range 请求保持有效'); + fs.renameSync(file, file + '.backup'); + const missing = await fetch(url, { signal: AbortSignal.timeout(2000) }); + assert.equal(missing.status, 404); + assert.equal((await missing.json()).code, 'NOT_FOUND'); + console.log('✓ 解析元数据后文件消失会结束错误响应,不再挂起'); +} finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + delete globalThis.__updateDownloadFixture; +} diff --git a/scripts/verify-update-installer.mjs b/scripts/verify-update-installer.mjs new file mode 100644 index 0000000..665e661 --- /dev/null +++ b/scripts/verify-update-installer.mjs @@ -0,0 +1,123 @@ +/** 验证真实原生助手的就绪、取消、篡改拒绝与失败恢复;不执行 NSIS。 */ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn, execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { buildUpdateHelper } from '../packages/desktop-client/scripts/build-update-helper.mjs'; + +if (process.platform !== 'win32') throw new Error('原生更新助手验证仅支持 Windows'); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const dir = path.join(root, '.cache', 'update-installer-tests', `${Date.now()}-含空格 验证`); +fs.mkdirSync(path.join(dir, 'updates'), { recursive: true }); +const clientRequire = createRequire(path.join(root, 'packages/desktop-client/package.json')); +const { build } = clientRequire('esbuild'); +buildUpdateHelper(path.join(dir, 'update-helper.exe')); +await build({ + entryPoints: [path.join(root, 'packages/desktop-client/src/main/update-installer.ts')], + outfile: path.join(dir, 'installer.cjs'), + bundle: true, + format: 'cjs', + platform: 'node', + logLevel: 'silent', +}); +const { prepareUpdateInstaller } = await import(pathToFileURL(path.join(dir, 'installer.cjs')).href); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +async function waitFor(read, timeout = 10000) { + const end = Date.now() + timeout; + while (Date.now() < end) { + try { + if (read()) return; + } catch { + /* 等待写入 */ + } + await sleep(50); + } + throw new Error('等待原生助手结果超时'); +} +const installer = path.join(dir, 'updates', 'fixture.exe'); +fs.writeFileSync(installer, '只验证准备与取消,不执行此文件'); +const options = { + directory: path.dirname(installer), + installer, + version: '2.0.0', + sha256: crypto.createHash('sha256').update(fs.readFileSync(installer)).digest('hex'), + size: fs.statSync(installer).size, + serverUrl: 'http://127.0.0.1:1', + username: 'isolated-helper-test', + restartExe: process.execPath, + portable: false, +}; +const helper = await prepareUpdateInstaller(options); +assert.match(fs.readFileSync(path.join(dir, 'updates/install-helper.log'), 'utf8'), /校验通过/); +helper.cancel(); +await waitFor(() => !fs.readdirSync(path.dirname(installer)).some((name) => name.endsWith('.ready'))); +assert.doesNotMatch(fs.readFileSync(path.join(dir, 'updates/install-helper.log'), 'utf8'), /开始安装/); +console.log('✓ 真实助手就绪,取消交接后不执行安装器'); + +fs.appendFileSync(installer, '篡改'); +await assert.rejects(prepareUpdateInstaller(options), /长度不匹配/); +assert.match(JSON.parse(fs.readFileSync(path.join(dir, 'updates/install-result.json'))).error, /长度不匹配/); +console.log('✓ 安装前再次检查文件,拒绝已被篡改的安装包并记录原因'); +await assert.rejects( + prepareUpdateInstaller({ ...options, size: fs.statSync(installer).size }), + /SHA256 校验失败/, +); +console.log('✓ 长度合法但摘要不符的安装包也被拒绝'); + +const compiler = path.join(process.env.SystemRoot, 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'); +function compile(name, code) { + const source = path.join(dir, name + '.cs'), + exe = path.join(dir, name + '.exe'); + fs.writeFileSync(source, code); + execFileSync(compiler, ['/nologo', '/target:winexe', '/out:' + exe, source], { + windowsHide: true, + stdio: 'pipe', + }); + return exe; +} +const failure = compile('failed-installer', 'class Fixture { static int Main() { return 37; } }'); +const restoreMarker = path.join(dir, 'restored.txt'); +const restore = compile( + 'restore-client', + 'using System.IO; [assembly:System.Reflection.AssemblyInformationalVersion("1.0.0")] class Fixture { static void Main() { File.WriteAllText(@"' + + restoreMarker.replaceAll('"', '""') + + '","restored"); } }', +); +fs.copyFileSync(failure, installer); +options.sha256 = crypto.createHash('sha256').update(fs.readFileSync(installer)).digest('hex'); +options.size = fs.statSync(installer).size; +options.restartExe = restore; +const childFile = path.join(dir, 'parent.cjs'); +fs.writeFileSync( + childFile, + `const {prepareUpdateInstaller}=require('./installer.cjs');(async()=>{const helper=await prepareUpdateInstaller(${JSON.stringify(options)});helper.commit();process.exit(0)})().catch(e=>{console.error(e);process.exit(1)});`, +); +const parent = spawn(process.execPath, [childFile], { windowsHide: true, stdio: 'inherit' }); +assert.equal(await new Promise((resolve) => parent.once('exit', resolve)), 0); +await waitFor(() => fs.existsSync(restoreMarker)); +const result = JSON.parse(fs.readFileSync(path.join(dir, 'updates/install-result.json'))); +assert.equal(result.stage, 'failed'); +assert.match(result.error, /退出码:37/); +assert.match(fs.readFileSync(path.join(dir, 'updates/install-helper.log'), 'utf8'), /旧进程已退出,开始安装/); +console.log('✓ 父进程退出后助手继续运行,非零退出被记录并恢复客户端'); +const success = compile('successful-installer', 'class Fixture { static int Main() { return 0; } }'); +fs.copyFileSync(success, installer); +options.sha256 = crypto.createHash('sha256').update(fs.readFileSync(installer)).digest('hex'); +options.size = fs.statSync(installer).size; +options.version = 'v1.0.0'; +fs.rmSync(restoreMarker); +fs.writeFileSync( + childFile, + `const {prepareUpdateInstaller}=require('./installer.cjs');(async()=>{const helper=await prepareUpdateInstaller(${JSON.stringify(options)});helper.commit();process.exit(0)})().catch(e=>{console.error(e);process.exit(1)});`, +); +const successfulParent = spawn(process.execPath, [childFile], { windowsHide: true, stdio: 'inherit' }); +assert.equal(await new Promise((resolve) => successfulParent.once('exit', resolve)), 0); +await waitFor(() => fs.existsSync(restoreMarker)); +await waitFor( + () => JSON.parse(fs.readFileSync(path.join(dir, 'updates/install-result.json'))).stage === 'installed', +); +console.log('✓ 支持 v 前缀版本号,安装器未启动新版时由助手补启客户端'); +console.log(`原生更新助手验证通过,证据目录:${dir}`);