fix(update): 修复云端安装交接并完善发布验收

修复隐藏缓存目录下载和响应收尾,使用原生助手校验安装包并确认交接。
补齐真实静默升级、便携版及中文路径验收,并按现行设备和科目授权契约更新回归。
服务端安装时生成独立密钥,覆盖升级保留既有配置;补齐安装包副本验证说明。
This commit is contained in:
LoveChengke committed 2026-10-31 17:07:17 +08:00
1 parent 66be3a63b1
commit 71cc8eb605
19 files changed
+1424 -91

No files matched your search

+6 -3
View File
@@ -455,7 +455,6 @@ function pruneRuntime() {
/* ------------------------------------------------------------ 4. 配置与脚本 */
function writeEnvironmentFile() {
const jwtSecret = crypto.randomBytes(48).toString('hex');
const content = `# ClassHelper 服务端(安装程序生成,请妥善保管)
# 修改后需重启服务生效(开始菜单 → ClassHelper 服务端 → 重启服务)
@@ -469,8 +468,8 @@ DATABASE_URL="file:../data/classhelper.db"
# 首次启动时自动建表(数据库为空时执行随包的迁移 SQL)
AUTO_MIGRATE=true
# 登录令牌签名密钥(首次安装随机生成,请勿泄露/更换,更换后所有用户需重新登录)
JWT_SECRET=${jwtSecret}
# 登录令牌签名密钥由安装程序首次运行时生成;升级保留原值
JWT_SECRET=CLASSHELPER_GENERATE_SECRET_ON_INSTALL
JWT_EXPIRES_IN=7d
# 允许跨域来源:* 表示全部允许;若通过域名访问建议改成具体来源,例如 https://class.example.com
@@ -494,6 +493,7 @@ TERM_START_DATE=
}
function writeRuntimeScripts() {
fs.copyFileSync(path.join(deployDir, 'bootstrap-env.cjs'), path.join(staging, 'bootstrap-env.cjs'));
// 注意:.cmd 内容保持 ASCII,避免 cmd.exe 按 OEM 代码页(GBK)解析 UTF-8 中文出错;
// 中文说明放在 README.txt 与安装向导里。
// 启动采用 PowerShell Start-Process 分离进程 + 输出重定向到日志文件,
@@ -514,6 +514,9 @@ if defined PID (
)
)
"%~dp0node.exe" "%~dp0bootstrap-env.cjs"
if errorlevel 1 exit /b 1
if not exist "logs" mkdir "logs"
echo [ClassHelper] Starting server...
+10
View File
@@ -88,6 +88,16 @@ Section "主程序" SecMain
Delete "$INSTDIR\.env"
Rename "$INSTDIR\.env.previous" "$INSTDIR\.env"
; 配置模板不携带可用密钥;首次安装独立生成,覆盖升级保留原密钥。
nsExec::ExecToStack /TIMEOUT=30000 '"$INSTDIR\node.exe" "$INSTDIR\bootstrap-env.cjs"'
Pop $0
Pop $1
StrCmp $0 "0" configuration_ready
DetailPrint "配置初始化失败:$1"
SetErrorLevel 1
Abort
configuration_ready:
WriteRegStr HKCU "Software\ClassHelperServer" "InstallDir" "$INSTDIR"
WriteRegStr HKCU "Software\ClassHelperServer" "Version" "${VERSION}"
WriteUninstaller "$INSTDIR\uninstall.exe"
+31 -12
View File
@@ -33,6 +33,8 @@ const h = {
acked: [],
ackFails: false,
quitCount: 0,
committed: 0,
cancelled: 0,
};
globalThis.__clientAgentTest = h;
function check(name, fn) {
@@ -68,6 +70,13 @@ const mockSources = {
courseware: `export const pollCourseware=async()=>{};`,
logger: `export const logger={info:()=>{},warn:()=>{},error:()=>{},debug:()=>{}};`,
process: `export const spawn=(...args)=>globalThis.__clientAgentTest.spawn(...args);`,
'update-installer': `const h=globalThis.__clientAgentTest;
export async function prepareUpdateInstaller(options) {
h.spawned.push({file:'update-helper.exe', options});
if(h.spawnError)throw new Error('模拟安装助手启动失败');
if(h.waitHelper)await new Promise(resolve=>h.releaseHelper=resolve);
return {commit:()=>h.committed++,cancel:()=>h.cancelled++};
}`,
};
try {
const bundle = path.join(temp, 'agent.mjs');
@@ -86,7 +95,7 @@ try {
path: args.path === 'electron' ? 'electron' : 'process',
namespace: 'mock',
}));
b.onResolve({ filter: /^\.\/(config|island|logger|courseware)\.js$/ }, (args) => ({
b.onResolve({ filter: /^\.\/(config|island|logger|courseware|update-installer)\.js$/ }, (args) => ({
path: path.basename(args.path, '.js'),
namespace: 'mock',
}));
@@ -210,7 +219,7 @@ try {
}),
]);
check('慢速下载不阻塞心跳', () => assert.equal(agent.getClientAgentStatus().updateTargetVersion, '2.0.0'));
const installer = path.join(temp, 'updates', 'verified-setup.exe');
const installer = path.join(temp, 'updates', 'test-setup.exe');
fs.mkdirSync(path.dirname(installer), { recursive: true });
fs.writeFileSync(installer, '模拟安装器');
Object.assign(h.config, { pendingUpdateVersion: '2.0.0', pendingUpdateFile: installer });
@@ -243,19 +252,29 @@ try {
h.visible = true;
h.inClass = true;
await agent.maybeInstallPendingUpdate();
check('立即更新在校验包就绪后启动等待旧进程退出的安装助手', () => {
check('安装助手确认就绪后提交交接,再退出旧客户端', () => {
assert.equal(h.quitCount, 1);
assert.equal(h.spawned.at(-1).file, 'powershell.exe');
const script = Buffer.from(h.spawned.at(-1).args.at(-1), 'base64').toString('utf16le');
assert.match(script, /Wait-Process -Id/);
assert.match(script, /-ArgumentList '\/S','--updated','--force-run'/);
assert.match(script, /if \(\$installer.ExitCode -ne 0/);
assert.match(script, /-WindowStyle|Start-Process/);
assert.equal(h.spawned.at(-1).file, 'update-helper.exe');
assert.equal(h.spawned.at(-1).options.sha256, responseData.update.sha256);
assert.equal(h.committed, 1);
assert.equal(h.progressReports.at(-1).progress.stage, 'installing');
assert.match(script, /catch \{/);
assert.match(script, /install-result\.json/);
fs.writeFileSync(path.join(root, '.cache', 'verified-update-helper.ps1'), script, 'utf8');
});
const cancelledInstall = await import(`${pathToFileURL(bundle).href}?cancel-before-ready=1`);
cancelledInstall.setQuitHandler(() => h.quitCount++);
cancelledInstall.syncUpdatePolicy({ ...responseData, updateMode: 'immediate' });
h.waitHelper = true;
const pendingInstall = cancelledInstall.maybeInstallPendingUpdate();
while (!h.releaseHelper) await new Promise((resolve) => setTimeout(resolve, 1));
h.config.autoUpdate = false;
h.releaseHelper();
await pendingInstall;
check('等待助手就绪时关闭自动更新会取消交接,旧客户端继续运行', () => {
assert.equal(h.quitCount, 1);
assert.equal(h.committed, 1);
assert.equal(h.cancelled, 1);
});
h.waitHelper = false;
h.config.autoUpdate = true;
fs.writeFileSync(
path.join(temp, 'updates', 'install-result.json'),
JSON.stringify({
+605
View File
@@ -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;
+37
View File
@@ -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 服务端配置初始化验证通过');
+96
View File
@@ -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;
}
+123
View File
@@ -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}`);