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

+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}`);