ci: add Windows quality and packaging gates

Teach ESLint the classic renderer and mixed CommonJS harness runtimes while retaining actionable rules. Add deterministic credential, lockfile, release-manifest, and lint contracts plus equivalent GitHub and Gitea Windows pipelines. Gate clean installs, focused offline smoke coverage, builds, packaged launches, and silent installer verification without enabling authenticated network tests.
This commit is contained in:
Sucukdeluxe
2026-08-12 02:32:07 +02:00
parent e5114b814f
commit 66e84508c8
21 changed files with 708 additions and 39 deletions
+4 -1
View File
@@ -59,7 +59,10 @@ function restartElectron() {
function isElectronRestartTarget(fileName) {
const baseName = fileName.replaceAll('\\', '/').split('/').at(-1) ?? '';
return !/^renderer(?:[-.].+)?\.js$/.test(baseName);
if (baseName === 'renderer.js') return false;
if (!baseName.startsWith('renderer') || !baseName.endsWith('.js')) return true;
const suffix = baseName.slice(8);
return suffix.length <= 4 || (suffix[0] !== '-' && suffix[0] !== '.');
}
function scheduleRestart(fileName) {
-16
View File
@@ -6,9 +6,6 @@ const OFFLINE_PROXY = 'http://127.0.0.1:1';
function buildSafeConfig(downloadsDir, overrides = {}) {
return {
client_id: '',
download_path: downloadsDir,
streamers: [],
theme: 'twitch',
download_mode: 'full',
part_minutes: 120,
@@ -22,30 +19,17 @@ function buildSafeConfig(downloadsDir, overrides = {}) {
persist_queue_on_restart: false,
metadata_cache_minutes: 10,
parallel_downloads: 1,
auto_resume_queue_on_startup: false,
downloaded_vod_ids: [],
streamlink_quality: 'best',
notify_on_each_completion: false,
streamlink_disable_ads: true,
auto_record_streamers: [],
auto_record_poll_seconds: 90,
download_chat_replay: false,
capture_live_chat: false,
discord_notify_live_start: false,
discord_notify_live_end: false,
discord_notify_vod_complete: false,
discord_notify_vod_auto_queued: false,
auto_cleanup_enabled: false,
auto_cleanup_days: 30,
auto_cleanup_target: 'live_only',
auto_cleanup_action: 'archive',
log_stream_events: false,
auto_vod_download_streamers: [],
auto_vod_download_poll_minutes: 15,
auto_vod_max_age_hours: 24,
auto_resume_live_recording: false,
auto_merge_resumed_parts: false,
delete_parts_after_merge: false,
...overrides,
client_id: '',
download_path: downloadsDir,
+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { ESLint } from 'eslint';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const eslint = new ESLint({ cwd: root, overrideConfigFile: path.join(root, 'eslint.config.mjs') });
async function messagesFor(source, filePath) {
const [result] = await eslint.lintText(source, { filePath: path.join(root, filePath) });
return result.messages;
}
test('accepts classic renderer declarations and browser globals', async () => {
const messages = await messagesFor('function openPanel() { return document.title; }\n', 'src/renderer-contract-fixture.ts');
assert.deepEqual(messages, []);
});
test('keeps recommended renderer errors active', async () => {
const messages = await messagesFor('function openPanel() { debugger; return document.title; }\n', 'src/renderer-contract-fixture.ts');
assert.ok(messages.some((message) => message.ruleId === 'no-debugger' && message.severity === 2));
});
test('accepts CommonJS imports and rejects unused script bindings', async () => {
const messages = await messagesFor("const fs = require('node:fs');\nconst unused = fs;\n", 'scripts/contract-fixture.js');
assert.ok(messages.some((message) => message.ruleId === 'no-unused-vars' && message.severity === 2));
assert.ok(!messages.some((message) => message.ruleId === '@typescript-eslint/no-require-imports'));
});
+45
View File
@@ -1,5 +1,7 @@
{
"files": [
".gitea/workflows/windows-ci.yml",
".github/workflows/windows-ci.yml",
".gitignore",
"CHANGELOG.md",
"LICENSE",
@@ -12,13 +14,21 @@
"package-lock.json",
"package.json",
"scripts/e2e-test-environment.js",
"scripts/file-capability-contract.js",
"scripts/lint-config.test.mjs",
"scripts/capture-readme-screenshot.js",
"scripts/dev.mjs",
"scripts/public-release-files.json",
"scripts/security-check.js",
"scripts/security-check.test.js",
"scripts/smoke-test-ci-contract.js",
"scripts/smoke-test-cutter.js",
"scripts/smoke-test-e2e-isolation-contract.js",
"scripts/smoke-test-file-capability-contract.js",
"scripts/smoke-test-full.js",
"scripts/smoke-test-installer.js",
"scripts/smoke-test-merge-split-logic.js",
"scripts/smoke-test-packaged-launch.js",
"scripts/smoke-test-public-release-config.js",
"scripts/smoke-test-settings-autosave.js",
"scripts/smoke-test-template-guide.js",
@@ -26,6 +36,33 @@
"scripts/smoke-test-workspace-ui.js",
"scripts/smoke-test.js",
"src/index.html",
"src/main/domain/app-state-store.test.ts",
"src/main/domain/app-state-store.ts",
"src/main/domain/chat-reader.test.ts",
"src/main/domain/chat-reader.ts",
"src/main/domain/config-export.test.ts",
"src/main/domain/config-export.ts",
"src/main/domain/cutter-project.test.ts",
"src/main/domain/cutter-project.ts",
"src/main/domain/download-policy.test.ts",
"src/main/domain/download-policy.ts",
"src/main/domain/file-capability.test.ts",
"src/main/domain/file-capability.ts",
"src/main/domain/managed-tools.test.ts",
"src/main/domain/managed-tools.ts",
"src/main/domain/persistence-commit.test.ts",
"src/main/domain/persistence-commit.ts",
"src/main/domain/privileged-ipc.test.ts",
"src/main/domain/privileged-ipc.ts",
"src/main/domain/renderer-queue-input.test.ts",
"src/main/domain/renderer-queue-input.ts",
"src/main/domain/secret-input.test.ts",
"src/main/domain/secret-input.ts",
"src/main/domain/secret-store.test.ts",
"src/main/domain/secret-store.ts",
"src/main/domain/tool-manifest.ts",
"src/main/domain/update-check-operation.test.ts",
"src/main/domain/update-check-operation.ts",
"src/main/domain/archive-files-store.test.ts",
"src/main/domain/archive-files-store.ts",
"src/main/domain/chunk-index-store.test.ts",
@@ -76,12 +113,18 @@
"src/main/infra/schema-v5.ts",
"src/main/infra/secure-storage.test.ts",
"src/main/infra/secure-storage.ts",
"src/main/queue/process-lifecycle.integration.test.ts",
"src/main/queue/process-registry.test.ts",
"src/main/queue/process-registry.ts",
"src/main.ts",
"src/main/dev-executable.test.ts",
"src/main/dev-executable.ts",
"src/main/dev-reload.test.ts",
"src/main/dev-reload.ts",
"src/preload.ts",
"src/renderer-accessibility.integration.test.ts",
"src/renderer-accessibility.test.ts",
"src/renderer-accessibility.ts",
"src/renderer-archive.ts",
"src/renderer-command-palette.ts",
"src/renderer-cutter.ts",
@@ -89,8 +132,10 @@
"src/renderer-locale-de.ts",
"src/renderer-locale-en.ts",
"src/renderer-profile.ts",
"src/renderer-production-path.integration.test.ts",
"src/renderer-queue.ts",
"src/renderer-settings.ts",
"src/renderer-settings-autosave.test.ts",
"src/renderer-shared.ts",
"src/renderer-stats.ts",
"src/renderer-streamers.ts",
+121
View File
@@ -0,0 +1,121 @@
const fs = require('fs');
const path = require('path');
const textExtensions = new Set([
'', '.cjs', '.css', '.html', '.js', '.json', '.md', '.mjs', '.nsh', '.ps1', '.ts', '.tsx', '.txt', '.yaml', '.yml'
]);
const sensitivePatterns = [
['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{20,255})\b/g],
['aws-access-key', /\bAKIA[0-9A-Z]{16}\b/g],
['slack-token', /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g],
['discord-webhook', /https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/api\/webhooks\/\d{8,}\/[A-Za-z0-9._-]{20,}/gi],
['url-credentials', /https?:\/\/[^\s/@:]+:[^\s/@]+@/gi],
['windows-user-path', /\b[A-Za-z]:\\Users\\[^\\/\s]+\\/g]
];
function lineNumberAt(source, index) {
return source.slice(0, index).split('\n').length;
}
function scanText(relativePath, source) {
const findings = [];
for (const header of ['PRIVATE KEY', 'RSA PRIVATE KEY', 'EC PRIVATE KEY', 'DSA PRIVATE KEY', 'OPENSSH PRIVATE KEY', 'ENCRYPTED PRIVATE KEY']) {
const marker = `-----BEGIN ${header}-----`;
let index = source.indexOf(marker);
while (index >= 0) {
findings.push({ file: relativePath, line: lineNumberAt(source, index), rule: 'private-key' });
index = source.indexOf(marker, index + marker.length);
}
}
for (const [rule, pattern] of sensitivePatterns) {
pattern.lastIndex = 0;
for (const match of source.matchAll(pattern)) {
findings.push({ file: relativePath, line: lineNumberAt(source, match.index ?? 0), rule });
}
}
return findings;
}
function isContainedPath(root, target) {
const relative = path.relative(root, target);
return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
}
function inspectPublicFiles(root, entries) {
const findings = [];
const resolvedRoot = path.resolve(root);
for (const rawEntry of entries) {
const entry = String(rawEntry || '').replace(/\\/g, '/');
const absolutePath = path.resolve(resolvedRoot, ...entry.split('/'));
if (!entry || path.isAbsolute(entry) || !isContainedPath(resolvedRoot, absolutePath)) {
findings.push({ file: entry || '<empty>', line: 0, rule: 'manifest-path' });
continue;
}
if (!fs.existsSync(absolutePath)) {
findings.push({ file: entry, line: 0, rule: 'manifest-missing' });
continue;
}
const stat = fs.lstatSync(absolutePath);
if (stat.isSymbolicLink()) {
findings.push({ file: entry, line: 0, rule: 'manifest-symlink' });
continue;
}
if (!stat.isFile()) {
findings.push({ file: entry, line: 0, rule: 'manifest-file-type' });
continue;
}
if (!textExtensions.has(path.extname(entry).toLowerCase())) continue;
findings.push(...scanText(entry, fs.readFileSync(absolutePath, 'utf8')));
}
return findings;
}
function inspectLockfile(lockfile) {
const findings = [];
if (!Number.isInteger(lockfile?.lockfileVersion) || lockfile.lockfileVersion < 3) {
findings.push({ file: 'package-lock.json', line: 0, rule: 'lockfile-version' });
}
const rootPackage = lockfile?.packages?.[''] || {};
for (const [name, specifier] of Object.entries({
...(rootPackage.dependencies || {}),
...(rootPackage.devDependencies || {})
})) {
if (/^(?:file:|git(?:\+|:)|https?:)/i.test(String(specifier))) {
findings.push({ file: 'package-lock.json', line: 0, rule: 'dependency-source', package: name });
}
}
for (const [packagePath, metadata] of Object.entries(lockfile?.packages || {})) {
if (!packagePath || metadata?.link) continue;
const resolved = typeof metadata?.resolved === 'string' ? metadata.resolved : '';
if (/^https:\/\/registry\.npmjs\.org\//i.test(resolved) && !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(String(metadata.integrity || ''))) {
findings.push({ file: 'package-lock.json', line: 0, rule: 'dependency-integrity', package: packagePath });
}
if (resolved && !/^https:\/\/registry\.npmjs\.org\//i.test(resolved)) {
findings.push({ file: 'package-lock.json', line: 0, rule: 'dependency-source', package: packagePath });
}
}
return findings;
}
function run(root = process.cwd()) {
const manifestPath = path.join(root, 'scripts', 'public-release-files.json');
const lockfilePath = path.join(root, 'package-lock.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const lockfile = JSON.parse(fs.readFileSync(lockfilePath, 'utf8'));
const entries = Array.isArray(manifest.files) ? manifest.files : [];
return [...inspectPublicFiles(root, entries), ...inspectLockfile(lockfile)];
}
if (require.main === module) {
try {
const failures = run();
console.log(JSON.stringify({ failures }, null, 2));
if (failures.length) process.exitCode = 1;
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
module.exports = { inspectLockfile, inspectPublicFiles, run, scanText };
+62
View File
@@ -0,0 +1,62 @@
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const test = require('node:test');
const securityCheck = require('./security-check');
test('detects credential material and private machine paths', () => {
const githubToken = ['gh', 'p_', 'a'.repeat(40)].join('');
const privateKey = ['-----BEGIN ', 'PRIVATE KEY-----'].join('');
const source = `${githubToken}\n${privateKey}\nC:\\Users\\real-user\\AppData`;
const findings = securityCheck.scanText('fixture.txt', source);
assert.deepEqual(findings.map((finding) => finding.rule).sort(), [
'github-token',
'private-key',
'windows-user-path'
]);
});
test('accepts public source with secret field names but no credential value', () => {
const findings = securityCheck.scanText('fixture.ts', "const client_secret = config.client_secret;\nconst token = '';\n");
assert.deepEqual(findings, []);
});
test('rejects public manifest traversal and symbolic links', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-security-'));
const outside = path.join(path.dirname(root), `${path.basename(root)}-outside.txt`);
try {
fs.writeFileSync(path.join(root, 'safe.txt'), 'safe', 'utf8');
fs.writeFileSync(outside, 'outside', 'utf8');
const entries = ['safe.txt', '../outside.txt'];
const findings = securityCheck.inspectPublicFiles(root, entries);
assert.ok(findings.some((finding) => finding.rule === 'manifest-path'));
if (process.platform === 'win32') {
fs.symlinkSync(outside, path.join(root, 'linked.txt'), 'file');
const linkedFindings = securityCheck.inspectPublicFiles(root, ['linked.txt']);
assert.ok(linkedFindings.some((finding) => finding.rule === 'manifest-symlink'));
}
} finally {
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(outside, { force: true });
}
});
test('requires registry dependency integrity in the lockfile', () => {
const lockfile = {
lockfileVersion: 3,
packages: {
'': { dependencies: { example: '^1.0.0' } },
'node_modules/example': {
version: '1.0.0',
resolved: 'https://registry.npmjs.org/example/-/example-1.0.0.tgz'
}
}
};
const findings = securityCheck.inspectLockfile(lockfile);
assert.ok(findings.some((finding) => finding.rule === 'dependency-integrity'));
});
+72
View File
@@ -0,0 +1,72 @@
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const failures = [];
function check(condition, message) {
if (!condition) failures.push(message);
}
const requiredScripts = {
lint: 'eslint .',
'security:check': 'node scripts/security-check.js && node scripts/smoke-test-public-release-config.js',
'test:security': 'node --test scripts/security-check.test.js',
'test:lint-config': 'node --test scripts/lint-config.test.mjs',
'test:ci-contract': 'node scripts/smoke-test-ci-contract.js',
'test:e2e:focused': 'npm run test:e2e:isolation && npm run test:e2e:workspace-ui',
'test:packaged-launch': 'node scripts/smoke-test-packaged-launch.js',
'test:installer': 'node scripts/smoke-test-installer.js',
'dist:ci': 'electron-builder --win nsis'
};
for (const [name, command] of Object.entries(requiredScripts)) {
check(packageJson.scripts?.[name] === command, `package script ${name} is missing or changed`);
}
for (const relativePath of ['.github/workflows/windows-ci.yml', '.gitea/workflows/windows-ci.yml']) {
const absolutePath = path.join(root, relativePath);
check(fs.existsSync(absolutePath), `${relativePath} is missing`);
if (!fs.existsSync(absolutePath)) continue;
const source = fs.readFileSync(absolutePath, 'utf8');
const requiredCommands = [
'npm ci',
'npm run lint',
'npm run test:lint-config',
'npm run security:check',
'npm run test:security',
'npm run test:ci-contract',
'npm run test:unit',
'npm run test:e2e:focused',
'npm run build',
'npm run pack',
'npm run test:packaged-launch',
'npm run dist:ci',
'npm run test:installer'
];
check(/runs-on:\s*windows-latest/.test(source), `${relativePath} does not use a Windows runner`);
check(/node-version:\s*['"]?22\.13\.0['"]?/.test(source), `${relativePath} does not pin Node 22.13.0`);
for (const command of requiredCommands) {
check(source.includes(`run: ${command}`), `${relativePath} is missing ${command}`);
}
const runSteps = source.split(/\r?\n/).filter((line) => /^\s+run:\s+/.test(line));
const timeoutSteps = source.split(/\r?\n/).filter((line) => /^\s+timeout-minutes:\s*10\s*$/.test(line));
check(timeoutSteps.length >= runSteps.length, `${relativePath} does not cap every command at ten minutes`);
check(!/test:[^\s]*authenticated|TWITCH_CLIENT_SECRET|DISCORD_WEBHOOK/i.test(source), `${relativePath} includes authenticated integration inputs`);
}
for (const relativePath of [
'scripts/security-check.js',
'scripts/security-check.test.js',
'scripts/lint-config.test.mjs',
'scripts/smoke-test-packaged-launch.js',
'scripts/smoke-test-installer.js'
]) {
check(fs.existsSync(path.join(root, relativePath)), `${relativePath} is missing`);
}
console.log(JSON.stringify({ failures }, null, 2));
if (failures.length) process.exitCode = 1;
+1 -2
View File
@@ -175,7 +175,7 @@ async function run() {
const staleCutterDirectories = ['media', 'waveform', 'preview'].map((kind) => path.join(os.tmpdir(), `tvm-editor-${kind}-2147483647-${Date.now()}-${Math.random().toString(36).slice(2)}`));
staleCutterDirectories.forEach((directory) => fs.mkdirSync(directory));
let realMaximumZoomState = null;
let replacementPromptState = null;
let replacementPromptState;
let replacementPlaybackState = null;
let app;
const check = (condition, message) => { if (!condition) failures.push(message); };
@@ -431,7 +431,6 @@ async function run() {
const tiles = [...strip.querySelectorAll('img:not(.cutter-thumbnail-sprite), .cutter-thumbnail-tile')];
const waveform = document.getElementById('cutterWaveform');
await Promise.all([...images, waveform].map((image) => image.decode()));
const scroll = document.getElementById('cutterTimelineScroll');
const timeline = document.getElementById('timeline');
const targetWidth = Math.min(32000, Math.ceil(timeline.getBoundingClientRect().width * window.devicePixelRatio));
const firstFrameRect = images[0].getBoundingClientRect();
+1 -5
View File
@@ -107,7 +107,7 @@ async function run() {
await win.waitForTimeout(2200);
const summary = await win.evaluate(async ({ mediaA, mediaB, tmpDir }) => {
const summary = await win.evaluate(async () => {
const failures = [];
const checks = {};
@@ -357,10 +357,6 @@ async function run() {
}
return { checks, failures };
}, {
mediaA: mediaA.replace(/\\/g, '/'),
mediaB: mediaB.replace(/\\/g, '/'),
tmpDir: environment.mediaDir.replace(/\\/g, '/')
});
await app.close();
+68
View File
@@ -0,0 +1,68 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const root = path.resolve(__dirname, '..');
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
...options,
encoding: 'utf8',
timeout: 240000,
windowsHide: true
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`${path.basename(command)} failed: ${JSON.stringify({ status: result.status, signal: result.signal, stdout: result.stdout, stderr: result.stderr })}`);
}
return result;
}
function findUninstaller(installationDirectory) {
return fs.readdirSync(installationDirectory)
.filter((name) => /^uninstall.*\.exe$/i.test(name))
.map((name) => path.join(installationDirectory, name))[0] || '';
}
function main() {
if (process.platform !== 'win32') throw new Error('Installer smoke requires Windows');
if (process.env.CI !== 'true' && process.env.TWITCH_VOD_MANAGER_INSTALLER_SMOKE !== '1') {
throw new Error('Installer smoke is restricted to CI or explicit TWITCH_VOD_MANAGER_INSTALLER_SMOKE=1 opt-in');
}
const installerPath = path.join(root, 'release', `Twitch-VOD-Manager-Setup-${packageJson.version}.exe`);
if (!fs.statSync(installerPath).isFile()) throw new Error(`Installer is missing: ${installerPath}`);
const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-installer-'));
const installationDirectory = path.join(smokeRoot, 'app');
const executablePath = path.join(installationDirectory, `${packageJson.build.productName}.exe`);
let uninstallerPath = '';
try {
run(installerPath, ['/S', `/D=${installationDirectory}`], { cwd: smokeRoot });
if (!fs.statSync(executablePath).isFile()) throw new Error(`Installed executable is missing: ${executablePath}`);
uninstallerPath = findUninstaller(installationDirectory);
if (!uninstallerPath) throw new Error('Installed uninstaller is missing');
run(process.execPath, [path.join(__dirname, 'smoke-test-packaged-launch.js')], {
cwd: root,
env: { ...process.env, PACKAGED_APP_PATH: executablePath }
});
run(uninstallerPath, ['/S'], { cwd: smokeRoot });
if (fs.existsSync(executablePath)) throw new Error('Silent uninstall left the packaged executable installed');
console.log(JSON.stringify({ failures: [], installerPath }, null, 2));
} finally {
if (uninstallerPath && fs.existsSync(uninstallerPath)) {
spawnSync(uninstallerPath, ['/S'], { cwd: smokeRoot, timeout: 240000, windowsHide: true, stdio: 'ignore' });
}
fs.rmSync(smokeRoot, { recursive: true, force: true });
}
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
+91
View File
@@ -0,0 +1,91 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const root = path.resolve(__dirname, '..');
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
function packagedExecutablePath() {
if (process.env.PACKAGED_APP_PATH) return path.resolve(process.env.PACKAGED_APP_PATH);
return path.join(root, 'release', 'win-unpacked', `${packageJson.build.productName}.exe`);
}
function terminateProcessTree(child) {
if (!child || child.exitCode !== null) return;
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' });
return;
}
child.kill('SIGTERM');
}
async function verifyPackagedLaunch(executablePath = packagedExecutablePath(), readyMs = 5000) {
if (process.platform !== 'win32') throw new Error('Packaged launch smoke requires Windows');
if (!fs.statSync(executablePath).isFile()) throw new Error(`Packaged executable is missing: ${executablePath}`);
const environmentRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-packaged-launch-'));
const userDataDir = path.join(environmentRoot, 'userdata');
const programDataDir = path.join(environmentRoot, 'programdata');
const appDataDir = path.join(environmentRoot, 'appdata');
const localAppDataDir = path.join(environmentRoot, 'localappdata');
const tempDir = path.join(environmentRoot, 'temp');
for (const directory of [userDataDir, programDataDir, appDataDir, localAppDataDir, tempDir]) {
fs.mkdirSync(directory, { recursive: true });
}
let output = '';
const child = spawn(executablePath, [
`--user-data-dir=${userDataDir}`,
'--proxy-server=http://127.0.0.1:1',
'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost'
], {
cwd: path.dirname(executablePath),
env: {
...process.env,
PROGRAMDATA: programDataDir,
APPDATA: appDataDir,
LOCALAPPDATA: localAppDataDir,
TEMP: tempDir,
TMP: tempDir,
HTTP_PROXY: 'http://127.0.0.1:1',
HTTPS_PROXY: 'http://127.0.0.1:1',
ALL_PROXY: 'http://127.0.0.1:1',
NO_PROXY: ''
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
});
const capture = (chunk) => {
output = `${output}${chunk}`.slice(-32768);
};
child.stdout.on('data', capture);
child.stderr.on('data', capture);
try {
const result = await Promise.race([
new Promise((resolve, reject) => {
child.once('error', reject);
child.once('exit', (code, signal) => resolve({ code, signal }));
}),
new Promise((resolve) => setTimeout(() => resolve(null), readyMs))
]);
if (result) throw new Error(`Packaged app exited before readiness: ${JSON.stringify({ ...result, output })}`);
return { executablePath, readyMs };
} finally {
terminateProcessTree(child);
fs.rmSync(environmentRoot, { recursive: true, force: true });
}
}
if (require.main === module) {
verifyPackagedLaunch()
.then((result) => console.log(JSON.stringify({ failures: [], result }, null, 2)))
.catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
module.exports = { packagedExecutablePath, verifyPackagedLaunch };