fix(update): 修复云端安装交接并完善发布验收
修复隐藏缓存目录下载和响应收尾,使用原生助手校验安装包并确认交接。 补齐真实静默升级、便携版及中文路径验收,并按现行设备和科目授权契约更新回归。 服务端安装时生成独立密钥,覆盖升级保留既有配置;补齐安装包副本验证说明。
This commit is contained in:
1 parent
66be3a63b1
commit
71cc8eb605
19 files changed
+1424
-91
No files matched your search
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,19 @@ ClassHelper 是 pnpm workspace:Express/Prisma/Socket.IO 服务端、Vue Web
|
|||||||
客户端先维护 IndexedDB 缓存,断网读取缓存;主窗口通过 preload 与主进程通信,主进程管理唯一原生岛窗口。
|
客户端先维护 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`。
|
- 三端契约只放 shared,改后先 `pnpm build:shared`。
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版
|
|||||||
| releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe | ClassHelper-<版本>-x64-server-setup.exe |
|
| releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe | ClassHelper-<版本>-x64-server-setup.exe |
|
||||||
| releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz | 同名 |
|
| 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;
|
Linux 还需同名 .tar.gz.sha256。正式 Linux 包在 Linux runner 运行 pnpm dist:server:linux;
|
||||||
Windows 上的交叉构建仅作验证,可能缺目标平台的 Prisma engine。
|
Windows 上的交叉构建仅作验证,可能缺目标平台的 Prisma engine。
|
||||||
构建中间物可再生成,不将 win-unpacked、server staging 或解包副本当交付物保留。
|
构建中间物可再生成,不将 win-unpacked、server staging 或解包副本当交付物保留。
|
||||||
|
|||||||
@@ -84,3 +84,13 @@ pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的
|
|||||||
- 服务器与教室机器之间能互相 ping 通吗?防火墙放行 4000 了吗?
|
- 服务器与教室机器之间能互相 ping 通吗?防火墙放行 4000 了吗?
|
||||||
|
|
||||||
排查清单见[常见问题](../faq.md)。
|
排查清单见[常见问题](../faq.md)。
|
||||||
|
|
||||||
|
## 7. 接收统一更新
|
||||||
|
|
||||||
|
管理员在设备管理中缓存并推送客户端安装包,客户端会后台下载、校验文件,再按立即、放学后或空闲策略安装。
|
||||||
|
关闭本机自动更新后,不会执行服务端下发的安装。安装助手未能就绪时,旧客户端会继续运行并显示失败原因。
|
||||||
|
|
||||||
|
服务端下发的是安装版:便携运行的客户端接收更新后转为安装版,原便携文件不会原地替换,后续请使用安装版入口。
|
||||||
|
新版重新连接服务端后,设备管理中的更新状态才会变为完成。
|
||||||
|
|
||||||
|
已发布旧版若在拉起安装器时失败,需要先手动安装 1.4.2 以获得新的安装助手;服务端推送安装包不能替换旧客户端中已经损坏的交接逻辑。
|
||||||
+5
-1
@@ -33,6 +33,9 @@
|
|||||||
"verify:v14": "node scripts/verify-v14.mjs",
|
"verify:v14": "node scripts/verify-v14.mjs",
|
||||||
"verify:lifecycle": "node scripts/verify-lifecycle.mjs",
|
"verify:lifecycle": "node scripts/verify-lifecycle.mjs",
|
||||||
"verify:client-agent": "node scripts/verify-client-agent.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-client": "node scripts/verify-review-client.mjs",
|
||||||
"verify:review-api": "node scripts/verify-e2e-isolated.mjs --review-api",
|
"verify:review-api": "node scripts/verify-e2e-isolated.mjs --review-api",
|
||||||
"verify:clients-api": "node scripts/verify-e2e-isolated.mjs --clients-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:visual": "node packages/desktop-client/scripts/smoke.mjs --visual",
|
||||||
"verify:desktop:online": "node packages/desktop-client/scripts/smoke.mjs --online",
|
"verify:desktop:online": "node packages/desktop-client/scripts/smoke.mjs --online",
|
||||||
"verify:authz": "node scripts/verify-authz.mjs",
|
"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": {
|
"devDependencies": {
|
||||||
"concurrently": "^10.0.5",
|
"concurrently": "^10.0.5",
|
||||||
|
|||||||
@@ -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<Manifest>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { build } from 'esbuild';
|
import { build } from 'esbuild';
|
||||||
|
import { buildUpdateHelper } from './build-update-helper.mjs';
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
buildUpdateHelper(path.join(root, 'dist/main/update-helper.exe'));
|
||||||
|
|
||||||
const shared = {
|
const shared = {
|
||||||
bundle: true,
|
bundle: true,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import crypto from 'node:crypto';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import fsp from 'node:fs/promises';
|
import fsp from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { BrowserWindow, app } from 'electron';
|
import { BrowserWindow, app } from 'electron';
|
||||||
import {
|
import {
|
||||||
isNewerVersion,
|
isNewerVersion,
|
||||||
@@ -15,6 +14,7 @@ import { getConfig, saveConfig } from './config.js';
|
|||||||
import { island } from './island.js';
|
import { island } from './island.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { connectionEvents } from './config-events.js';
|
import { connectionEvents } from './config-events.js';
|
||||||
|
import { prepareUpdateInstaller } from './update-installer.js';
|
||||||
|
|
||||||
let updateEpoch = 0;
|
let updateEpoch = 0;
|
||||||
let downloadAbort = new AbortController();
|
let downloadAbort = new AbortController();
|
||||||
@@ -43,15 +43,17 @@ connectionEvents.on('changed', () => {
|
|||||||
* 空闲安装要求不在上课且主窗口隐藏满五分钟;
|
* 空闲安装要求不在上课且主窗口隐藏满五分钟;
|
||||||
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
|
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
|
||||||
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
|
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
|
||||||
* 3. **安装脚本脱离本进程**:PowerShell 等旧进程退出、安装器完成后重新拉起应用,
|
* 3. **独立安装助手**:助手就绪后才退出旧进程,由助手执行安装并确认新版启动;
|
||||||
* 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`,
|
* 助手从更新目录运行,不锁住正在被安装器替换的应用文件。
|
||||||
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let policy:
|
let policy:
|
||||||
| (Pick<ClientHeartbeatResponse, 'updateTargetVersion' | 'updateMode' | 'updateNotBefore'> & {
|
| (Pick<ClientHeartbeatResponse, 'updateTargetVersion' | 'updateMode' | 'updateNotBefore'> & {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
updateSha256: string;
|
||||||
|
updateSize: number;
|
||||||
|
updateFileName: string;
|
||||||
})
|
})
|
||||||
| null = null;
|
| null = null;
|
||||||
function policyPath(): string {
|
function policyPath(): string {
|
||||||
@@ -107,6 +109,9 @@ export function syncUpdatePolicy(next: ClientHeartbeatResponse): void {
|
|||||||
updateTargetVersion: next.updateTargetVersion,
|
updateTargetVersion: next.updateTargetVersion,
|
||||||
updateMode: next.updateMode ?? 'idle',
|
updateMode: next.updateMode ?? 'idle',
|
||||||
updateNotBefore: next.updateNotBefore ?? null,
|
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.mkdirSync(pendingUpdateDir(), { recursive: true });
|
||||||
fs.writeFileSync(policyPath(), JSON.stringify(policy), 'utf8');
|
fs.writeFileSync(policyPath(), JSON.stringify(policy), 'utf8');
|
||||||
@@ -458,6 +463,14 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
|
|||||||
}
|
}
|
||||||
const active = loadPolicy();
|
const active = loadPolicy();
|
||||||
if (!active || active.updateTargetVersion !== version) return;
|
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') {
|
if (active.updateMode === 'after-school') {
|
||||||
const at = Date.parse(active.updateNotBefore ?? '');
|
const at = Date.parse(active.updateNotBefore ?? '');
|
||||||
if (!Number.isFinite(at) || Date.now() < at) return;
|
if (!Number.isFinite(at) || Date.now() < at) return;
|
||||||
@@ -520,33 +533,29 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
|
|||||||
logger.info(`开始静默安装更新 ${version}:${installer}`);
|
logger.info(`开始静默安装更新 ${version}:${installer}`);
|
||||||
try {
|
try {
|
||||||
const exe = process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe');
|
const exe = process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe');
|
||||||
const quote = (value: string): string => "'" + value.replaceAll("'", "''") + "'";
|
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
const resultFile = path.join(pendingUpdateDir(), 'install-result.json');
|
const helper = await prepareUpdateInstaller({
|
||||||
// 上一次失败的结果不能冒充这一次:安装开始时先清掉,只有本轮失败才重新写入。
|
directory: pendingUpdateDir(),
|
||||||
fs.rmSync(resultFile, { force: true });
|
installer,
|
||||||
const script =
|
version,
|
||||||
`$ErrorActionPreference = 'Stop'; try { Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` +
|
sha256: active.updateSha256,
|
||||||
`$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -WindowStyle Hidden -Wait -PassThru; ` +
|
size: active.updateSize,
|
||||||
`if ($installer.ExitCode -ne 0) { throw ('安装器退出码:' + $installer.ExitCode) } ` +
|
serverUrl: config.serverUrl,
|
||||||
`} 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; ` +
|
username: config.username,
|
||||||
`if (Test-Path -LiteralPath ${quote(exe)}) { Start-Process -FilePath ${quote(exe)} -WindowStyle Hidden } }`;
|
restartExe: exe,
|
||||||
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
portable: Boolean(process.env.PORTABLE_EXECUTABLE_FILE),
|
||||||
const child = spawn(
|
|
||||||
'powershell.exe',
|
|
||||||
['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', encoded],
|
|
||||||
{
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore',
|
|
||||||
windowsHide: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
child.once('spawn', resolve);
|
|
||||||
child.once('error', reject);
|
|
||||||
});
|
});
|
||||||
child.unref();
|
if (
|
||||||
// 安装器从实际安装目录启动新版;免安装版的旧 exe 可能随退出消失,只在安装失败时尝试恢复它。
|
installEpoch !== updateEpoch ||
|
||||||
|
loadPolicy()?.updateTargetVersion !== version ||
|
||||||
|
getConfig().autoUpdate === false
|
||||||
|
) {
|
||||||
|
helper.cancel();
|
||||||
|
installing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
helper.commit();
|
||||||
|
logger.info(`更新 ${version} 的独立安装助手已就绪,确认退出旧客户端`);
|
||||||
if (quitHandler) {
|
if (quitHandler) {
|
||||||
quitHandler();
|
quitHandler();
|
||||||
} else {
|
} else {
|
||||||
@@ -555,6 +564,8 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
installing = false;
|
installing = false;
|
||||||
|
if (installEpoch !== updateEpoch) return;
|
||||||
|
installationRetryAt = Date.now() + 5 * 60_000;
|
||||||
publishProgress({ stage: 'failed', error: String(error).slice(0, 1000) });
|
publishProgress({ stage: 'failed', error: String(error).slice(0, 1000) });
|
||||||
logger.error(
|
logger.error(
|
||||||
`启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`,
|
`启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
|||||||
@@ -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<typeof spawn>;
|
||||||
|
try {
|
||||||
|
child = spawn(helper, [`${prefix}.json`], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true,
|
||||||
|
cwd: options.directory,
|
||||||
|
});
|
||||||
|
await new Promise<void>((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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1624,11 +1624,23 @@ async function main() {
|
|||||||
stateNote: '待机',
|
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 heartbeatNoAuth = await api('/clients/heartbeat', { method: 'POST', body: heartbeatBase });
|
||||||
const heartbeat = await api('/clients/heartbeat', {
|
const heartbeat = await api('/clients/heartbeat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: adminToken,
|
token: deviceToken,
|
||||||
body: heartbeatBase,
|
body: heartbeatBase,
|
||||||
});
|
});
|
||||||
const heartbeatData = heartbeat.payload?.data;
|
const heartbeatData = heartbeat.payload?.data;
|
||||||
@@ -1675,7 +1687,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
const heartbeatWithCommand = await api('/clients/heartbeat', {
|
const heartbeatWithCommand = await api('/clients/heartbeat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: adminToken,
|
token: deviceToken,
|
||||||
body: { ...heartbeatBase, stateNote: '待机' },
|
body: { ...heartbeatBase, stateNote: '待机' },
|
||||||
});
|
});
|
||||||
const delivered = heartbeatWithCommand.payload?.data?.command;
|
const delivered = heartbeatWithCommand.payload?.data?.command;
|
||||||
@@ -1688,13 +1700,13 @@ async function main() {
|
|||||||
const ackBody = { deviceKey, ok: true, result: 'e2e' };
|
const ackBody = { deviceKey, ok: true, result: 'e2e' };
|
||||||
const ackFirst = await api(`/clients/commands/${delivered?.id}/ack`, {
|
const ackFirst = await api(`/clients/commands/${delivered?.id}/ack`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: adminToken,
|
token: deviceToken,
|
||||||
body: ackBody,
|
body: ackBody,
|
||||||
});
|
});
|
||||||
// 幂等:客户端重试回执不报错、也不覆盖第一次结果
|
// 幂等:客户端重试回执不报错、也不覆盖第一次结果
|
||||||
const ackSecond = await api(`/clients/commands/${delivered?.id}/ack`, {
|
const ackSecond = await api(`/clients/commands/${delivered?.id}/ack`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: adminToken,
|
token: deviceToken,
|
||||||
body: { deviceKey, ok: false, result: '重试不应覆盖结果' },
|
body: { deviceKey, ok: false, result: '重试不应覆盖结果' },
|
||||||
});
|
});
|
||||||
record(
|
record(
|
||||||
@@ -1716,13 +1728,13 @@ async function main() {
|
|||||||
const otherDeviceKey = `${deviceKey}-other`;
|
const otherDeviceKey = `${deviceKey}-other`;
|
||||||
const otherHeartbeat = await api('/clients/heartbeat', {
|
const otherHeartbeat = await api('/clients/heartbeat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: adminToken,
|
token: deviceToken,
|
||||||
body: { ...heartbeatBase, deviceKey: otherDeviceKey, name: 'E2E 另一台测试机' },
|
body: { ...heartbeatBase, deviceKey: otherDeviceKey, name: 'E2E 另一台测试机' },
|
||||||
});
|
});
|
||||||
const otherDeviceId = otherHeartbeat.payload?.data?.deviceId ?? null;
|
const otherDeviceId = otherHeartbeat.payload?.data?.deviceId ?? null;
|
||||||
const ackWrongDevice = await api(`/clients/commands/${delivered?.id}/ack`, {
|
const ackWrongDevice = await api(`/clients/commands/${delivered?.id}/ack`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: adminToken,
|
token: deviceToken,
|
||||||
body: { deviceKey: otherDeviceKey, ok: true, result: 'e2e-other' },
|
body: { deviceKey: otherDeviceKey, ok: true, result: 'e2e-other' },
|
||||||
});
|
});
|
||||||
record(
|
record(
|
||||||
@@ -2496,13 +2508,14 @@ async function main() {
|
|||||||
// 8.3.1 科目归属越权回归(修复"非该科科任老师可以布置/导入其他科内容"):
|
// 8.3.1 科目归属越权回归(修复"非该科科任老师可以布置/导入其他科内容"):
|
||||||
// ① 新建成同名课程必须被拒绝 —— 否则谁都能借"新建科目"把自己写成该科任课老师,
|
// ① 新建成同名课程必须被拒绝 —— 否则谁都能借"新建科目"把自己写成该科任课老师,
|
||||||
// 后面那道 assertCanManageSubjectContent 的科目门随即失效;
|
// 后面那道 assertCanManageSubjectContent 的科目门随即失效;
|
||||||
// ② 班主任导入**别人任教科目**的成绩必须逐行拒绝(原先只判到班级这一层)。
|
// ② 班主任导入**别人任教科目**的成绩必须拒绝(原先只判到班级这一层)。
|
||||||
{
|
{
|
||||||
const guardedCourseName = `越权校验科目${Date.now().toString(36).slice(-5).toUpperCase()}`;
|
const guardedCourseName = `越权校验科目${Date.now().toString(36).slice(-5).toUpperCase()}`;
|
||||||
|
const otherTeacher = await api('/auth/me', { token: teacher2Token });
|
||||||
const firstCourse = await api('/courses', {
|
const firstCourse = await api('/courses', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: adminToken,
|
token: adminToken,
|
||||||
body: { classId, name: guardedCourseName },
|
body: { classId, name: guardedCourseName, teacherId: otherTeacher.payload?.data?.id },
|
||||||
});
|
});
|
||||||
const firstCourseId = firstCourse.payload?.data?.id;
|
const firstCourseId = firstCourse.payload?.data?.id;
|
||||||
record(
|
record(
|
||||||
@@ -2524,8 +2537,7 @@ async function main() {
|
|||||||
`status=${duplicateCourse.status} code=${duplicateCourse.payload?.code} details=${JSON.stringify(duplicateCourse.payload?.details ?? {})}`,
|
`status=${duplicateCourse.status} code=${duplicateCourse.payload?.code} details=${JSON.stringify(duplicateCourse.payload?.details ?? {})}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 这门课的任课老师是**管理员**(course.create 会把创建者写成任课老师):
|
// 显式配置另一位教师任课;班主任身份不能代替科目写权限。
|
||||||
// 班主任导入它的成绩必须被拒,且不能落库。
|
|
||||||
const guardedCsv = [
|
const guardedCsv = [
|
||||||
'学号,考试名称,分数,总分,科目',
|
'学号,考试名称,分数,总分,科目',
|
||||||
`${targetStudent?.studentNo ?? '202601'},越权校验考试,88,100,${guardedCourseName}`,
|
`${targetStudent?.studentNo ?? '202601'},越权校验考试,88,100,${guardedCourseName}`,
|
||||||
@@ -2548,14 +2560,12 @@ async function main() {
|
|||||||
mode: 'append',
|
mode: 'append',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const guardedErrors = guardedImport.payload?.data?.errors ?? [];
|
const guardedRows = await api('/grades?classId=' + classId, { token: adminToken });
|
||||||
record(
|
record(
|
||||||
'班主任导入他人任教科目成绩被逐行拒绝(不落库)',
|
'班主任不能导入他人任教科目成绩,拒绝后不落库',
|
||||||
guardedImport.status === 200 &&
|
guardedImport.status === 403 &&
|
||||||
guardedImport.payload?.data?.inserted === 0 &&
|
!(guardedRows.payload?.data ?? []).some((item) => item.examName === '越权校验考试'),
|
||||||
guardedImport.payload?.data?.failed === 1 &&
|
'status=' + guardedImport.status + ' message=' + guardedImport.payload?.message,
|
||||||
String(guardedErrors[0]?.message ?? '').includes('不是你任教的科目'),
|
|
||||||
`新增=${guardedImport.payload?.data?.inserted} 失败=${guardedImport.payload?.data?.failed} 提示=${guardedErrors[0]?.message ?? '-'}`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (firstCourseId) {
|
if (firstCourseId) {
|
||||||
@@ -2588,7 +2598,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 8.5 需求 6:表格导入在"老师端"可用且不越权
|
// 8.5 需求 6:表格导入在"老师端"可用且不越权
|
||||||
// 班主任 → 本班成绩可预览/导入;科任 → 403;班主任 → 名单导入仍 403(人员管理仅管理员)
|
// 授课教师可导入本人科目;他人科目与班级范围之外仍拒绝,名单导入另走人员管理权限。
|
||||||
const teacherGradesPreview = await api('/imports/table/preview', {
|
const teacherGradesPreview = await api('/imports/table/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: teacherToken,
|
token: teacherToken,
|
||||||
@@ -2628,32 +2638,66 @@ async function main() {
|
|||||||
`status=${teacherGradesCommit.status} 新增=${teacherGradesCommit.payload?.data?.inserted} 更新=${teacherGradesCommit.payload?.data?.updated} 失败=${teacherGradesCommit.payload?.data?.failed}`,
|
`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', {
|
const subjectGradesImport = await api('/imports/table/commit', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: teacher2Token,
|
token: teacher2Token,
|
||||||
body: {
|
body: subjectBody,
|
||||||
kind: 'grades',
|
|
||||||
classId: subjectOnlyClass?.id ?? classId,
|
|
||||||
fileName: 'grades.csv',
|
|
||||||
contentBase64: gradesCsvBase64,
|
|
||||||
mapping: {
|
|
||||||
studentNo: '学号',
|
|
||||||
name: '姓名',
|
|
||||||
examName: '考试名称',
|
|
||||||
score: '分数',
|
|
||||||
totalScore: '总分',
|
|
||||||
courseName: '科目',
|
|
||||||
},
|
|
||||||
mode: 'upsert',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
record(
|
record(
|
||||||
'科任老师导入本班成绩被拒绝(403,需求 7)',
|
'科任老师可导入本人授课科目成绩',
|
||||||
Boolean(subjectOnlyClass) && subjectGradesImport.status === 403,
|
subjectGradesImport.status === 200 && subjectGradesImport.payload?.data?.inserted === 1,
|
||||||
`班级=${subjectOnlyClass?.name ?? '未找到2025级3班'} status=${subjectGradesImport.status}`,
|
'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) {
|
if (foreignClass) {
|
||||||
const crossGradesImport = await api('/imports/table/commit', {
|
const crossGradesImport = await api('/imports/table/commit', {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { assertClassDevice, assertPermission, classIdsFor } from '../../lib/authz/index.js';
|
import { assertClassDevice, assertPermission, classIdsFor } from '../../lib/authz/index.js';
|
||||||
import { getReleaseProgress, setReleaseProgress } from './release-progress.js';
|
import { getReleaseProgress, setReleaseProgress } from './release-progress.js';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import path from 'node:path';
|
||||||
import { sendCreated, sendOk, ApiError } from '../../lib/http.js';
|
import { sendCreated, sendOk, ApiError } from '../../lib/http.js';
|
||||||
import {
|
import {
|
||||||
authenticate,
|
authenticate,
|
||||||
@@ -132,7 +133,7 @@ router.delete(
|
|||||||
router.get(
|
router.get(
|
||||||
'/client/download/:version',
|
'/client/download/:version',
|
||||||
validate({ params: clientReleaseVersionParamSchema }),
|
validate({ params: clientReleaseVersionParamSchema }),
|
||||||
async (req, res) => {
|
async (req, res, next) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
if (user.role === 'CLASS_DEVICE') assertClassDevice(user, user.classId ?? user.sub);
|
if (user.role === 'CLASS_DEVICE') assertClassDevice(user, user.classId ?? user.sub);
|
||||||
else {
|
else {
|
||||||
@@ -143,12 +144,25 @@ router.get(
|
|||||||
const { version } = validatedParams<{ version: string }>(req);
|
const { version } = validatedParams<{ version: string }>(req);
|
||||||
const found = await clientReleases.resolveReleaseFile(version);
|
const found = await clientReleases.resolveReleaseFile(version);
|
||||||
if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包');
|
if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包');
|
||||||
res.download(found.file, found.fileName, (error) => {
|
res.download(
|
||||||
if (error) {
|
path.basename(found.file),
|
||||||
// 客户端中途取消下载也会走到这里,只记日志,不要往已发出的响应里再写东西
|
found.fileName,
|
||||||
res.locals.downloadError = error instanceof Error ? error.message : String(error);
|
{ 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -455,7 +455,6 @@ function pruneRuntime() {
|
|||||||
/* ------------------------------------------------------------ 4. 配置与脚本 */
|
/* ------------------------------------------------------------ 4. 配置与脚本 */
|
||||||
|
|
||||||
function writeEnvironmentFile() {
|
function writeEnvironmentFile() {
|
||||||
const jwtSecret = crypto.randomBytes(48).toString('hex');
|
|
||||||
const content = `# ClassHelper 服务端(安装程序生成,请妥善保管)
|
const content = `# ClassHelper 服务端(安装程序生成,请妥善保管)
|
||||||
# 修改后需重启服务生效(开始菜单 → ClassHelper 服务端 → 重启服务)
|
# 修改后需重启服务生效(开始菜单 → ClassHelper 服务端 → 重启服务)
|
||||||
|
|
||||||
@@ -469,8 +468,8 @@ DATABASE_URL="file:../data/classhelper.db"
|
|||||||
# 首次启动时自动建表(数据库为空时执行随包的迁移 SQL)
|
# 首次启动时自动建表(数据库为空时执行随包的迁移 SQL)
|
||||||
AUTO_MIGRATE=true
|
AUTO_MIGRATE=true
|
||||||
|
|
||||||
# 登录令牌签名密钥(首次安装随机生成,请勿泄露/更换,更换后所有用户需重新登录)
|
# 登录令牌签名密钥由安装程序首次运行时生成;升级保留原值
|
||||||
JWT_SECRET=${jwtSecret}
|
JWT_SECRET=CLASSHELPER_GENERATE_SECRET_ON_INSTALL
|
||||||
JWT_EXPIRES_IN=7d
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
# 允许跨域来源:* 表示全部允许;若通过域名访问建议改成具体来源,例如 https://class.example.com
|
# 允许跨域来源:* 表示全部允许;若通过域名访问建议改成具体来源,例如 https://class.example.com
|
||||||
@@ -494,6 +493,7 @@ TERM_START_DATE=
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeRuntimeScripts() {
|
function writeRuntimeScripts() {
|
||||||
|
fs.copyFileSync(path.join(deployDir, 'bootstrap-env.cjs'), path.join(staging, 'bootstrap-env.cjs'));
|
||||||
// 注意:.cmd 内容保持 ASCII,避免 cmd.exe 按 OEM 代码页(GBK)解析 UTF-8 中文出错;
|
// 注意:.cmd 内容保持 ASCII,避免 cmd.exe 按 OEM 代码页(GBK)解析 UTF-8 中文出错;
|
||||||
// 中文说明放在 README.txt 与安装向导里。
|
// 中文说明放在 README.txt 与安装向导里。
|
||||||
// 启动采用 PowerShell Start-Process 分离进程 + 输出重定向到日志文件,
|
// 启动采用 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"
|
if not exist "logs" mkdir "logs"
|
||||||
echo [ClassHelper] Starting server...
|
echo [ClassHelper] Starting server...
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,16 @@ Section "主程序" SecMain
|
|||||||
Delete "$INSTDIR\.env"
|
Delete "$INSTDIR\.env"
|
||||||
Rename "$INSTDIR\.env.previous" "$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" "InstallDir" "$INSTDIR"
|
||||||
WriteRegStr HKCU "Software\ClassHelperServer" "Version" "${VERSION}"
|
WriteRegStr HKCU "Software\ClassHelperServer" "Version" "${VERSION}"
|
||||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const h = {
|
|||||||
acked: [],
|
acked: [],
|
||||||
ackFails: false,
|
ackFails: false,
|
||||||
quitCount: 0,
|
quitCount: 0,
|
||||||
|
committed: 0,
|
||||||
|
cancelled: 0,
|
||||||
};
|
};
|
||||||
globalThis.__clientAgentTest = h;
|
globalThis.__clientAgentTest = h;
|
||||||
function check(name, fn) {
|
function check(name, fn) {
|
||||||
@@ -68,6 +70,13 @@ const mockSources = {
|
|||||||
courseware: `export const pollCourseware=async()=>{};`,
|
courseware: `export const pollCourseware=async()=>{};`,
|
||||||
logger: `export const logger={info:()=>{},warn:()=>{},error:()=>{},debug:()=>{}};`,
|
logger: `export const logger={info:()=>{},warn:()=>{},error:()=>{},debug:()=>{}};`,
|
||||||
process: `export const spawn=(...args)=>globalThis.__clientAgentTest.spawn(...args);`,
|
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 {
|
try {
|
||||||
const bundle = path.join(temp, 'agent.mjs');
|
const bundle = path.join(temp, 'agent.mjs');
|
||||||
@@ -86,7 +95,7 @@ try {
|
|||||||
path: args.path === 'electron' ? 'electron' : 'process',
|
path: args.path === 'electron' ? 'electron' : 'process',
|
||||||
namespace: 'mock',
|
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'),
|
path: path.basename(args.path, '.js'),
|
||||||
namespace: 'mock',
|
namespace: 'mock',
|
||||||
}));
|
}));
|
||||||
@@ -210,7 +219,7 @@ try {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
check('慢速下载不阻塞心跳', () => assert.equal(agent.getClientAgentStatus().updateTargetVersion, '2.0.0'));
|
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.mkdirSync(path.dirname(installer), { recursive: true });
|
||||||
fs.writeFileSync(installer, '模拟安装器');
|
fs.writeFileSync(installer, '模拟安装器');
|
||||||
Object.assign(h.config, { pendingUpdateVersion: '2.0.0', pendingUpdateFile: installer });
|
Object.assign(h.config, { pendingUpdateVersion: '2.0.0', pendingUpdateFile: installer });
|
||||||
@@ -243,19 +252,29 @@ try {
|
|||||||
h.visible = true;
|
h.visible = true;
|
||||||
h.inClass = true;
|
h.inClass = true;
|
||||||
await agent.maybeInstallPendingUpdate();
|
await agent.maybeInstallPendingUpdate();
|
||||||
check('立即更新在校验包就绪后启动等待旧进程退出的安装助手', () => {
|
check('安装助手确认就绪后提交交接,再退出旧客户端', () => {
|
||||||
assert.equal(h.quitCount, 1);
|
assert.equal(h.quitCount, 1);
|
||||||
assert.equal(h.spawned.at(-1).file, 'powershell.exe');
|
assert.equal(h.spawned.at(-1).file, 'update-helper.exe');
|
||||||
const script = Buffer.from(h.spawned.at(-1).args.at(-1), 'base64').toString('utf16le');
|
assert.equal(h.spawned.at(-1).options.sha256, responseData.update.sha256);
|
||||||
assert.match(script, /Wait-Process -Id/);
|
assert.equal(h.committed, 1);
|
||||||
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.progressReports.at(-1).progress.stage, 'installing');
|
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(
|
fs.writeFileSync(
|
||||||
path.join(temp, 'updates', 'install-result.json'),
|
path.join(temp, 'updates', 'install-result.json'),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -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] = '<REDACTED>';
|
||||||
|
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;
|
||||||
@@ -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 服务端配置初始化验证通过');
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
Reference in new issue
Block a user