607 lines
31 KiB
JavaScript
607 lines
31 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('node:crypto');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { spawn } = require('node:child_process');
|
|
const { once: onceEvent } = require('node:events');
|
|
|
|
const VERSION = '1.1.0';
|
|
const API_PREFIX = '/api/remote-play-worker/v1';
|
|
const CLOCK_SKEW_MS = 2 * 60_000;
|
|
const MAX_ARCHIVE_BYTES = 300 * 1024 * 1024;
|
|
const RELEASE_API = 'https://api.github.com/repos/streetpea/chiaki-ng/releases/latest';
|
|
const DISPLAY_MODES = new Set(['windowed', 'fullscreen', 'zoom', 'stretch']);
|
|
const ACTIONS = new Set(['diagnose', 'open-client', 'start-session', 'stop-session', 'install-client', 'update-client']);
|
|
const configArg = process.argv.indexOf('--config');
|
|
let configPath = path.resolve(configArg >= 0 && process.argv[configArg + 1] ? process.argv[configArg + 1] : path.join(__dirname, 'pairing.json'));
|
|
const once = process.argv.includes('--once');
|
|
let logPath = path.join(path.dirname(configPath), 'worker.log');
|
|
|
|
let config;
|
|
let client = { installed: false, version: '', executable: '', managed: false };
|
|
let registeredHosts = [];
|
|
let session = { active: false, pid: null, nickname: '', startedAt: null, lastExitCode: null };
|
|
let streamHealth = null;
|
|
let sessionChild = null;
|
|
let busyJobId = null;
|
|
let pendingResult = null;
|
|
let lastError = null;
|
|
let lastProbeAt = 0;
|
|
const completedJobs = new Set();
|
|
|
|
function safeText(value, limit = 500) { return String(value == null ? '' : value).replace(/[\0\r\n]+/g, ' ').trim().slice(0, limit); }
|
|
function log(level, message) {
|
|
const line = `${new Date().toISOString()} [${level}] ${safeText(message, 2_000)}\n`;
|
|
try { fs.appendFileSync(logPath, line, { encoding: 'utf8' }); } catch (_) {}
|
|
if (process.stdout.isTTY) process.stdout.write(line);
|
|
}
|
|
function hmac(key, value) { return crypto.createHmac('sha256', key).update(value, 'utf8').digest('hex'); }
|
|
function timingSafeHex(actual, expected) {
|
|
if (!/^[a-f0-9]{64}$/i.test(String(actual || '')) || !/^[a-f0-9]{64}$/i.test(String(expected || ''))) return false;
|
|
return crypto.timingSafeEqual(Buffer.from(actual, 'hex'), Buffer.from(expected, 'hex'));
|
|
}
|
|
function encodePayload(value) { return Buffer.from(JSON.stringify(value == null ? {} : value), 'utf8').toString('base64'); }
|
|
function decodePayload(value) {
|
|
if (typeof value !== 'string' || value.length > 96 * 1024) throw new Error('Chimera-antwoord is te groot.');
|
|
return JSON.parse(Buffer.from(value, 'base64').toString('utf8'));
|
|
}
|
|
function saveConfig() {
|
|
const temporary = `${configPath}.tmp`;
|
|
fs.writeFileSync(temporary, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
fs.renameSync(temporary, configPath);
|
|
}
|
|
function loadConfig() {
|
|
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
const origin = new URL(String(parsed.serverOrigin || ''));
|
|
const loopback = ['localhost', '127.0.0.1', '::1'].includes(origin.hostname);
|
|
if ((origin.protocol !== 'https:' && !(loopback && origin.protocol === 'http:')) || origin.origin !== parsed.serverOrigin) throw new Error('pairing.json bevat geen geldige HTTPS-origin.');
|
|
if (!/^[a-z0-9][a-z0-9-]{7,63}$/i.test(String(parsed.workerId || ''))) throw new Error('pairing.json bevat geen geldige worker-ID.');
|
|
return parsed;
|
|
}
|
|
|
|
function managedRoot() {
|
|
if (!process.env.LOCALAPPDATA) throw new Error('LOCALAPPDATA ontbreekt; de beheerde chiaki-ng-client kan niet worden geplaatst.');
|
|
return path.join(process.env.LOCALAPPDATA, 'Chimera', 'RemotePlayClient');
|
|
}
|
|
|
|
function parseVersion(value) {
|
|
const match = String(value || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:\.\d+)?$/);
|
|
return match ? match.slice(1).map(Number) : null;
|
|
}
|
|
|
|
function normalizeVersion(value) {
|
|
const parsed = parseVersion(value);
|
|
return parsed ? parsed.join('.') : '';
|
|
}
|
|
|
|
function compareVersions(left, right) {
|
|
const a = parseVersion(left);
|
|
const b = parseVersion(right);
|
|
if (!a || !b) return null;
|
|
for (let index = 0; index < 3; index += 1) {
|
|
if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function isWithin(root, candidate) {
|
|
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function readManagedManifest() {
|
|
try {
|
|
const root = managedRoot();
|
|
const manifestPath = path.join(root, 'manifest.json');
|
|
const stat = fs.statSync(manifestPath);
|
|
if (!stat.isFile() || stat.size < 2 || stat.size > 16 * 1024) return null;
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
if (manifest.schema !== 1 || !parseVersion(manifest.version) || !/^[a-f0-9]{64}$/i.test(String(manifest.sha256 || ''))) return null;
|
|
const executable = path.resolve(root, String(manifest.executable || ''));
|
|
if (!isWithin(root, executable) || !fs.statSync(executable).isFile()) return null;
|
|
return { ...manifest, executable };
|
|
} catch (_) { return null; }
|
|
}
|
|
|
|
function assetNameForArchitecture(architecture = process.arch) {
|
|
if (architecture === 'x64') return 'chiaki-ng-win_x64-MSYS2-Release-portable.zip';
|
|
if (architecture === 'arm64') return 'chiaki-ng-win_arm64-MSYS2-Release-portable.zip';
|
|
throw new Error(`Windows-architectuur ${safeText(architecture, 20)} wordt niet ondersteund door de beheerde chiaki-ng-client.`);
|
|
}
|
|
|
|
function validateReleaseMetadata(release, architecture = process.arch) {
|
|
if (!release || release.draft === true || release.prerelease === true) throw new Error('Upstream gaf geen stabiele chiaki-ng-release terug.');
|
|
const version = normalizeVersion(release.tag_name);
|
|
if (!version) throw new Error('De upstream chiaki-ng-versie is ongeldig.');
|
|
const name = assetNameForArchitecture(architecture);
|
|
const asset = Array.isArray(release.assets) ? release.assets.find(item => item?.name === name) : null;
|
|
if (!asset || !Number.isSafeInteger(Number(asset.size)) || Number(asset.size) < 5 * 1024 * 1024 || Number(asset.size) > MAX_ARCHIVE_BYTES) throw new Error('De verwachte Windows portable release ontbreekt of heeft een ongeldige grootte.');
|
|
const digest = String(asset.digest || '').match(/^sha256:([a-f0-9]{64})$/i)?.[1]?.toLowerCase();
|
|
if (!digest) throw new Error('Upstream publiceerde geen bruikbare SHA-256 voor deze chiaki-ng-release.');
|
|
let download;
|
|
try { download = new URL(String(asset.browser_download_url || '')); } catch (_) { throw new Error('De upstream download-URL is ongeldig.'); }
|
|
const expectedPath = `/streetpea/chiaki-ng/releases/download/${release.tag_name}/${name}`;
|
|
if (download.protocol !== 'https:' || download.hostname !== 'github.com' || download.pathname !== expectedPath || download.username || download.password || download.search || download.hash) {
|
|
throw new Error('De chiaki-ng-download wijst niet naar het verwachte officiële releasepad.');
|
|
}
|
|
return { version, tag: release.tag_name, name, size: Number(asset.size), sha256: digest, url: download.href };
|
|
}
|
|
|
|
function validateArchiveEntries(listing) {
|
|
const entries = String(listing || '').split(/\r?\n/).map(value => value.trim()).filter(Boolean);
|
|
if (!entries.length || entries.length > 10_000) throw new Error('Het chiaki-ng-archief bevat geen geldige bestandslijst.');
|
|
for (const entry of entries) {
|
|
const normalized = entry.replace(/\\/g, '/');
|
|
const parts = normalized.split('/').filter(Boolean);
|
|
if (entry.includes('\0') || normalized.startsWith('/') || /^[a-z]:/i.test(normalized) || parts.includes('..')) throw new Error('Het chiaki-ng-archief bevat een onveilig pad.');
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function walkFiles(root, { maxFiles = 20_000 } = {}) {
|
|
const files = [];
|
|
const pending = [root];
|
|
while (pending.length) {
|
|
const directory = pending.pop();
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const fullPath = path.join(directory, entry.name);
|
|
const stat = fs.lstatSync(fullPath);
|
|
if (stat.isSymbolicLink()) throw new Error('Het chiaki-ng-archief bevat een niet-toegestane symbolische link.');
|
|
if (stat.isDirectory()) pending.push(fullPath);
|
|
else if (stat.isFile()) files.push(fullPath);
|
|
else throw new Error('Het chiaki-ng-archief bevat een niet-ondersteund bestandstype.');
|
|
if (files.length + pending.length > maxFiles) throw new Error('Het chiaki-ng-archief bevat te veel bestanden.');
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function parseChiakiLog(text, modifiedAt = null) {
|
|
const value = String(text || '').slice(-2 * 1024 * 1024);
|
|
const resolution = value.match(/Switched to profile \d+, resolution:\s*(\d{2,5}x\d{2,5})/i)?.[1] || null;
|
|
const rttMs = Number(value.match(/average RTT\s*=\s*([\d.]+)\s*ms/i)?.[1]);
|
|
const decoder = safeText(value.match(/Using hardware decoder\s+"([^"]+)"/i)?.[1], 32) || null;
|
|
const count = pattern => (value.match(pattern) || []).length;
|
|
const packetErrors = count(/Detected missing or corrupt frame|FEC failed|Missing unit|Video receiver could not (?:put unit|flush frame)/gi);
|
|
const duplicateUnits = count(/Received duplicate unit/gi);
|
|
const decoderDrops = count(/AVCodec internal buffer is full/gi);
|
|
return {
|
|
checkedAt: modifiedAt ? Number(modifiedAt) : Date.now(), resolution, rttMs: Number.isFinite(rttMs) ? Math.round(rttMs * 10) / 10 : null,
|
|
decoder, packetErrors, duplicateUnits, decoderDrops,
|
|
status: decoderDrops > 0 || packetErrors >= 3 ? 'degraded' : packetErrors > 0 || duplicateUnits > 3 ? 'warning' : 'healthy',
|
|
};
|
|
}
|
|
|
|
function probeStreamHealth() {
|
|
try {
|
|
if (!process.env.APPDATA) return null;
|
|
const directory = path.join(process.env.APPDATA, 'Chiaki', 'Chiaki', 'log');
|
|
const latest = fs.readdirSync(directory, { withFileTypes: true })
|
|
.filter(entry => entry.isFile() && /^chiaki_session_.*\.log$/i.test(entry.name))
|
|
.map(entry => ({ path: path.join(directory, entry.name), stat: fs.statSync(path.join(directory, entry.name)) }))
|
|
.filter(entry => entry.stat.size > 0)
|
|
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)[0];
|
|
if (!latest) return null;
|
|
const handle = fs.openSync(latest.path, 'r');
|
|
try {
|
|
const length = Math.min(latest.stat.size, 2 * 1024 * 1024);
|
|
const buffer = Buffer.alloc(length);
|
|
fs.readSync(handle, buffer, 0, length, Math.max(0, latest.stat.size - length));
|
|
return parseChiakiLog(buffer.toString('utf8'), latest.stat.mtimeMs);
|
|
} finally { fs.closeSync(handle); }
|
|
} catch (_) { return null; }
|
|
}
|
|
|
|
async function fetchJson(url, options) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 15_000);
|
|
try {
|
|
const response = await fetch(url, { ...options, signal: controller.signal, redirect: 'error', headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(options?.headers || {}) } });
|
|
const body = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(safeText(body.error || `HTTP ${response.status}`));
|
|
return body;
|
|
} finally { clearTimeout(timer); }
|
|
}
|
|
|
|
async function enroll() {
|
|
if (/^[a-f0-9]{64}$/i.test(String(config.pairingKey || ''))) return;
|
|
if (!/^[a-f0-9]{64}$/i.test(String(config.enrollmentToken || ''))) throw new Error('Geen bruikbaar inschrijftoken in pairing.json. Download een nieuw workerpakket.');
|
|
const result = await fetchJson(`${config.serverOrigin}${API_PREFIX}/enroll`, { method: 'POST', body: JSON.stringify({ workerId: config.workerId, enrollmentToken: config.enrollmentToken }) });
|
|
if (result.workerId !== config.workerId || !/^[a-f0-9]{64}$/i.test(String(result.pairingKey || ''))) throw new Error('Chimera gaf geen geldige permanente workerkey terug.');
|
|
config.pairingKey = result.pairingKey;
|
|
delete config.enrollmentToken;
|
|
saveConfig();
|
|
log('INFO', 'Worker veilig ingeschreven; het eenmalige token is verwijderd.');
|
|
}
|
|
|
|
function requestEnvelope(pathname, payload) {
|
|
const timestamp = Date.now();
|
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
const encoded = encodePayload(payload);
|
|
const key = Buffer.from(config.pairingKey, 'hex');
|
|
return {
|
|
schema: 1, workerId: config.workerId, timestamp, nonce, payload: encoded,
|
|
hmac: hmac(key, `request|${pathname}|${config.workerId}|${timestamp}|${nonce}|${encoded}`),
|
|
};
|
|
}
|
|
|
|
function verifyResponse(envelope, requestNonce) {
|
|
if (!envelope || envelope.schema !== 1 || envelope.workerId !== config.workerId || envelope.responseTo !== requestNonce) throw new Error('Ongeldig antwoord van Chimera.');
|
|
if (!Number.isSafeInteger(Number(envelope.timestamp)) || Math.abs(Date.now() - Number(envelope.timestamp)) > CLOCK_SKEW_MS) throw new Error('Chimera-antwoord valt buiten het tijdvenster.');
|
|
const expected = hmac(Buffer.from(config.pairingKey, 'hex'), `response|${config.workerId}|${envelope.timestamp}|${envelope.nonce}|${requestNonce}|${envelope.payload}`);
|
|
if (!timingSafeHex(envelope.hmac, expected)) throw new Error('Chimera-antwoord heeft een ongeldige HMAC.');
|
|
return decodePayload(envelope.payload);
|
|
}
|
|
|
|
function runCapture(command, args, { timeoutMs = 15_000, env = process.env, maxCaptureBytes = 256 * 1024, returnBytes = 32 * 1024 } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
const output = [];
|
|
let size = 0;
|
|
let settled = false;
|
|
const finish = (error, value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
if (error) reject(error); else resolve(value);
|
|
};
|
|
const consume = chunk => {
|
|
size += chunk.length;
|
|
if (size <= maxCaptureBytes) output.push(Buffer.from(chunk));
|
|
else if (!settled) {
|
|
try { child.kill(); } catch (_) {}
|
|
finish(new Error(`${path.basename(command)} produceerde te veel uitvoer.`));
|
|
}
|
|
};
|
|
child.stdout.on('data', consume);
|
|
child.stderr.on('data', consume);
|
|
child.once('error', finish);
|
|
child.once('close', code => {
|
|
const text = Buffer.concat(output).toString('utf8').slice(-returnBytes);
|
|
if (code === 0) finish(null, text); else finish(new Error(`${path.basename(command)} stopte met code ${code}: ${safeText(text, 1_000)}`));
|
|
});
|
|
const timer = setTimeout(() => {
|
|
try { child.kill(); } catch (_) {}
|
|
finish(new Error(`${path.basename(command)} reageerde niet tijdig.`));
|
|
}, timeoutMs);
|
|
});
|
|
}
|
|
|
|
async function fetchLatestRelease(fetchImpl = fetch) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 20_000);
|
|
try {
|
|
const response = await fetchImpl(RELEASE_API, {
|
|
signal: controller.signal, redirect: 'error',
|
|
headers: { Accept: 'application/vnd.github+json', 'User-Agent': `Chimera-Remote-Play-Worker/${VERSION}` },
|
|
});
|
|
if (!response.ok) throw new Error(`Upstream releasecontrole gaf HTTP ${response.status}.`);
|
|
const declared = Number(response.headers.get('content-length'));
|
|
if (Number.isFinite(declared) && declared > 2 * 1024 * 1024) throw new Error('Het upstream releaseantwoord is groter dan toegestaan.');
|
|
const body = await response.text();
|
|
if (Buffer.byteLength(body, 'utf8') > 2 * 1024 * 1024) throw new Error('Het upstream releaseantwoord is groter dan toegestaan.');
|
|
return validateReleaseMetadata(JSON.parse(body));
|
|
} finally { clearTimeout(timer); }
|
|
}
|
|
|
|
async function downloadVerifiedRelease(release, target, fetchImpl = fetch) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 10 * 60_000);
|
|
const hash = crypto.createHash('sha256');
|
|
let bytes = 0;
|
|
let stream;
|
|
try {
|
|
const response = await fetchImpl(release.url, {
|
|
signal: controller.signal, redirect: 'follow',
|
|
headers: { Accept: 'application/octet-stream', 'User-Agent': `Chimera-Remote-Play-Worker/${VERSION}` },
|
|
});
|
|
if (!response.ok || !response.body) throw new Error(`Download van chiaki-ng gaf HTTP ${response.status}.`);
|
|
const declared = Number(response.headers.get('content-length'));
|
|
if (Number.isFinite(declared) && declared > MAX_ARCHIVE_BYTES) throw new Error('De chiaki-ng-download is groter dan toegestaan.');
|
|
stream = fs.createWriteStream(target, { flags: 'wx', mode: 0o600 });
|
|
let streamFailure = null;
|
|
stream.on('error', error => { streamFailure = error; });
|
|
for await (const chunkValue of response.body) {
|
|
const chunk = Buffer.from(chunkValue);
|
|
bytes += chunk.length;
|
|
if (bytes > MAX_ARCHIVE_BYTES || bytes > release.size) throw new Error('De chiaki-ng-download overschrijdt de gepubliceerde grootte.');
|
|
hash.update(chunk);
|
|
if (!stream.write(chunk)) await onceEvent(stream, 'drain');
|
|
if (streamFailure) throw streamFailure;
|
|
}
|
|
await new Promise(resolve => stream.end(resolve));
|
|
if (streamFailure) throw streamFailure;
|
|
stream = null;
|
|
const digest = hash.digest('hex');
|
|
if (bytes !== release.size || digest !== release.sha256) throw new Error('De gedownloade chiaki-ng-release wijkt af van de gepubliceerde SHA-256 of grootte.');
|
|
return { bytes, digest };
|
|
} catch (error) {
|
|
if (stream) stream.destroy();
|
|
try { fs.rmSync(target, { force: true }); } catch (_) {}
|
|
throw error;
|
|
} finally { clearTimeout(timer); }
|
|
}
|
|
|
|
async function extractVerifiedZip(archive, destination) {
|
|
fs.mkdirSync(destination, { recursive: false, mode: 0o700 });
|
|
const listing = await runCapture('tar.exe', ['-tf', archive], { timeoutMs: 60_000, maxCaptureBytes: 1024 * 1024, returnBytes: 1024 * 1024 });
|
|
validateArchiveEntries(listing);
|
|
await runCapture('tar.exe', ['-xf', archive, '-C', destination], { timeoutMs: 3 * 60_000 });
|
|
walkFiles(destination);
|
|
}
|
|
|
|
async function installManagedChiaki({ fetchImpl = fetch } = {}) {
|
|
if (process.platform !== 'win32') throw new Error('De beheerde chiaki-ng-update is momenteel uitsluitend voor Windows-workers beschikbaar.');
|
|
if (session.active) throw new Error('Stop eerst de door Chimera gestarte Remote Play-sessie voordat je chiaki-ng bijwerkt.');
|
|
const release = await fetchLatestRelease(fetchImpl);
|
|
const existing = readManagedManifest();
|
|
if (existing && compareVersions(existing.version, release.version) === 0) {
|
|
if (config) {
|
|
config.chiakiPath = existing.executable;
|
|
config.managedChiakiVersion = existing.version;
|
|
saveConfig();
|
|
}
|
|
await probeChiaki(true);
|
|
return { changed: false, version: existing.version, message: `De beheerde chiaki-ng ${existing.version} is al actueel.` };
|
|
}
|
|
|
|
const root = managedRoot();
|
|
const versions = path.join(root, 'versions');
|
|
const nonce = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
|
const archive = path.join(root, `.download-${nonce}.zip`);
|
|
const outer = path.join(root, `.outer-${nonce}`);
|
|
const inner = path.join(root, `.inner-${nonce}`);
|
|
const finalDirectory = path.join(versions, release.version);
|
|
fs.mkdirSync(versions, { recursive: true, mode: 0o700 });
|
|
try {
|
|
await downloadVerifiedRelease(release, archive, fetchImpl);
|
|
await extractVerifiedZip(archive, outer);
|
|
let payloadRoot = outer;
|
|
let files = walkFiles(outer);
|
|
let executables = files.filter(file => /(?:^|[\\/])chiaki(?:-ng)?\.exe$/i.test(file));
|
|
let executable = executables.length === 1 ? executables[0] : null;
|
|
if (!executable) {
|
|
if (executables.length > 1) throw new Error('De portable chiaki-ng-release bevat meerdere onverwachte clientexecutables.');
|
|
const nested = files.filter(file => /\.zip$/i.test(file));
|
|
if (nested.length !== 1) throw new Error('De portable chiaki-ng-release bevat niet exact één verwacht payloadarchief.');
|
|
await extractVerifiedZip(nested[0], inner);
|
|
payloadRoot = inner;
|
|
files = walkFiles(inner);
|
|
executables = files.filter(file => /(?:^|[\\/])chiaki(?:-ng)?\.exe$/i.test(file));
|
|
if (executables.length !== 1) throw new Error('De portable chiaki-ng-payload bevat niet exact één clientexecutable.');
|
|
executable = executables[0];
|
|
}
|
|
if (!executable || !isWithin(payloadRoot, executable)) throw new Error('De chiaki-ng executable ontbreekt in het geverifieerde portable archief.');
|
|
const executableDirectory = path.dirname(executable);
|
|
if (fs.existsSync(finalDirectory)) fs.rmSync(finalDirectory, { recursive: true, force: true });
|
|
fs.renameSync(executableDirectory, finalDirectory);
|
|
const installedExecutable = path.join(finalDirectory, path.basename(executable));
|
|
if (!fs.statSync(installedExecutable).isFile()) throw new Error('De chiaki-ng-installatie kon niet atomair worden voltooid.');
|
|
const manifest = {
|
|
schema: 1, version: release.version, tag: release.tag, asset: release.name, sha256: release.sha256,
|
|
installedAt: new Date().toISOString(), executable: path.relative(root, installedExecutable),
|
|
};
|
|
const temporaryManifest = path.join(root, `manifest-${nonce}.tmp`);
|
|
fs.writeFileSync(temporaryManifest, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
fs.renameSync(temporaryManifest, path.join(root, 'manifest.json'));
|
|
if (config) {
|
|
config.chiakiPath = installedExecutable;
|
|
config.managedChiakiVersion = release.version;
|
|
saveConfig();
|
|
}
|
|
await probeChiaki(true);
|
|
return { changed: true, version: release.version, message: `De geverifieerde chiaki-ng ${release.version} is als Chimera-client geïnstalleerd.` };
|
|
} finally {
|
|
for (const target of [archive, outer, inner]) {
|
|
try { fs.rmSync(target, { recursive: true, force: true }); } catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
function chiakiCandidates() {
|
|
const managed = readManagedManifest();
|
|
return [...new Set([
|
|
managed?.executable,
|
|
config?.chiakiPath,
|
|
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WindowsApps', 'chiaki-ng.exe'),
|
|
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Programs', 'chiaki-ng', 'chiaki-ng.exe'),
|
|
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Programs', 'chiaki-ng', 'bin', 'chiaki-ng.exe'),
|
|
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Programs', 'chiaki-ng', 'chiaki.exe'),
|
|
process.env.ProgramFiles && path.join(process.env.ProgramFiles, 'chiaki-ng', 'chiaki-ng.exe'),
|
|
process.env.ProgramFiles && path.join(process.env.ProgramFiles, 'chiaki-ng', 'bin', 'chiaki-ng.exe'),
|
|
process.env.ProgramFiles && path.join(process.env.ProgramFiles, 'chiaki-ng', 'chiaki.exe'),
|
|
process.env['ProgramFiles(x86)'] && path.join(process.env['ProgramFiles(x86)'], 'chiaki-ng', 'chiaki-ng.exe'),
|
|
process.platform === 'win32' ? 'chiaki-ng.exe' : 'chiaki-ng',
|
|
].filter(Boolean))];
|
|
}
|
|
|
|
async function executableVersion(candidate, managed) {
|
|
if (managed && path.resolve(candidate) === path.resolve(managed.executable)) return managed.version;
|
|
if (process.platform !== 'win32' || !path.isAbsolute(candidate)) return '';
|
|
try {
|
|
const script = "(Get-Item -LiteralPath $env:CHIMERA_CHIAKI_EXE).VersionInfo.ProductVersion";
|
|
return normalizeVersion(await runCapture('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
timeoutMs: 5_000, env: { ...process.env, CHIMERA_CHIAKI_EXE: candidate }, returnBytes: 1_024,
|
|
}));
|
|
} catch (_) { return ''; }
|
|
}
|
|
|
|
async function probeChiaki(force = false) {
|
|
if (!force && Date.now() - lastProbeAt < 30_000) return client;
|
|
lastProbeAt = Date.now();
|
|
let failure = null;
|
|
const managed = readManagedManifest();
|
|
for (const candidate of chiakiCandidates()) {
|
|
try {
|
|
const list = await runCapture(candidate, ['list']);
|
|
registeredHosts = [...new Set([...list.matchAll(/^Host:\s*(.+?)\s*$/gmi)].map(match => safeText(match[1], 80)).filter(Boolean))];
|
|
const version = await executableVersion(candidate, managed);
|
|
if (config) config.chiakiPath = candidate;
|
|
client = { installed: true, version, executable: path.basename(candidate) || 'chiaki-ng', managed: Boolean(managed && path.resolve(candidate) === path.resolve(managed.executable)) };
|
|
streamHealth = probeStreamHealth();
|
|
return client;
|
|
} catch (error) { failure = error; }
|
|
}
|
|
client = { installed: false, version: '', executable: '', managed: false };
|
|
registeredHosts = [];
|
|
streamHealth = probeStreamHealth();
|
|
if (failure && !/ENOENT|not found|cannot find/i.test(String(failure.message))) lastError = safeText(failure.message);
|
|
return client;
|
|
}
|
|
|
|
function spawnGui(args, { trackSession = false, nickname = '' } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!client.installed || !config.chiakiPath) return reject(new Error('chiaki-ng is niet geïnstalleerd of niet gevonden.'));
|
|
const child = spawn(config.chiakiPath, args, { shell: false, windowsHide: false, stdio: 'ignore' });
|
|
child.once('error', reject);
|
|
child.once('spawn', () => {
|
|
if (trackSession) {
|
|
sessionChild = child;
|
|
session = { active: true, pid: child.pid, nickname, startedAt: Date.now(), lastExitCode: null };
|
|
child.once('close', code => {
|
|
sessionChild = null;
|
|
session = { ...session, active: false, pid: null, lastExitCode: Number.isInteger(code) ? code : null };
|
|
log('INFO', `Remote Play-sessie gesloten met code ${code == null ? 'onbekend' : code}.`);
|
|
});
|
|
} else child.unref();
|
|
resolve({ pid: child.pid });
|
|
});
|
|
});
|
|
}
|
|
|
|
function validHost(value) {
|
|
const host = String(value || '').trim();
|
|
if (!host || host.length > 253 || /[\s\0/:]/.test(host)) throw new Error('Ongeldig PS5-hostadres in workerjob.');
|
|
return host;
|
|
}
|
|
|
|
async function executeJob(job) {
|
|
if (!job || !/^[a-f0-9-]{36}$/i.test(String(job.id || '')) || !ACTIONS.has(job.action)) throw new Error('Ongeldige of onbekende workerjob.');
|
|
if (completedJobs.has(job.id)) throw new Error('Deze workerjob is al uitgevoerd.');
|
|
if (Number(job.expiresAt) < Date.now()) throw new Error('Workerjob is verlopen.');
|
|
const host = ['diagnose', 'start-session'].includes(job.action) ? validHost(job.payload?.host) : null;
|
|
if (job.action === 'diagnose') {
|
|
await probeChiaki(true);
|
|
let discovery = '';
|
|
if (client.installed) discovery = safeText(await runCapture(config.chiakiPath, ['discover', '-h', host], { timeoutMs: 10_000 }).catch(error => error.message), 2_000);
|
|
return { message: client.installed ? 'chiaki-ng, lokale PS5-discovery en de laatste streamgezondheid zijn gecontroleerd.' : 'Worker is online, maar chiaki-ng ontbreekt.', client, registeredHosts, discovery, streamHealth };
|
|
}
|
|
if (job.action === 'open-client') {
|
|
await probeChiaki(true);
|
|
const opened = await spawnGui([]);
|
|
return { message: 'chiaki-ng is lokaal geopend voor registratie of configuratie.', client, registeredHosts, pid: opened.pid };
|
|
}
|
|
if (job.action === 'start-session') {
|
|
if (session.active) throw new Error('Er draait al een Remote Play-sessie op deze worker.');
|
|
await probeChiaki(true);
|
|
const nickname = safeText(job.payload?.clientNickname, 80);
|
|
const mode = DISPLAY_MODES.has(job.payload?.displayMode) ? job.payload.displayMode : 'fullscreen';
|
|
if (!nickname) throw new Error('De chiaki-ng consolenaam ontbreekt.');
|
|
if (registeredHosts.length && !registeredHosts.includes(nickname)) throw new Error(`chiaki-ng kent de console “${nickname}” niet.`);
|
|
const args = ['--exit-app-on-stream-exit'];
|
|
if (mode !== 'windowed') args.push(`--${mode}`);
|
|
args.push('stream', nickname, host);
|
|
const launched = await spawnGui(args, { trackSession: true, nickname });
|
|
return { message: `Remote Play gestart voor ${nickname}.`, client, registeredHosts, pid: launched.pid };
|
|
}
|
|
if (job.action === 'stop-session') {
|
|
if (!sessionChild || !session.active) return { message: 'Er draaide geen door Chimera gestarte sessie.', client, registeredHosts };
|
|
sessionChild.kill();
|
|
return { message: 'Stopverzoek naar de door Chimera gestarte sessie gestuurd.', client, registeredHosts };
|
|
}
|
|
if (job.action === 'install-client') {
|
|
const result = await installManagedChiaki();
|
|
return { message: result.message, client, registeredHosts, streamHealth };
|
|
}
|
|
if (job.action === 'update-client') {
|
|
const result = await installManagedChiaki();
|
|
return { message: result.message, client, registeredHosts, streamHealth };
|
|
}
|
|
throw new Error('Workeractie niet geïmplementeerd.');
|
|
}
|
|
|
|
function workerStatus() {
|
|
return {
|
|
version: VERSION, platform: `${process.platform}/${process.arch}`, hostname: os.hostname(), busyJobId,
|
|
client, registeredHosts, session, streamHealth, lastError,
|
|
};
|
|
}
|
|
|
|
function startJob(job) {
|
|
if (busyJobId || !job || completedJobs.has(job.id)) return;
|
|
busyJobId = job.id;
|
|
lastError = null;
|
|
executeJob(job).then(result => {
|
|
completedJobs.add(job.id);
|
|
if (completedJobs.size > 100) completedJobs.delete(completedJobs.values().next().value);
|
|
pendingResult = { jobId: job.id, success: true, ...result };
|
|
log('INFO', `${job.action} voltooid.`);
|
|
}).catch(error => {
|
|
completedJobs.add(job.id);
|
|
lastError = safeText(error.message);
|
|
pendingResult = { jobId: job.id, success: false, error: lastError };
|
|
log('ERROR', `${job.action} mislukt: ${lastError}`);
|
|
}).finally(() => { busyJobId = null; });
|
|
}
|
|
|
|
async function poll() {
|
|
await probeChiaki(false);
|
|
const pathname = `${API_PREFIX}/poll`;
|
|
const reportedResult = pendingResult;
|
|
const envelope = requestEnvelope(pathname, { status: workerStatus(), result: reportedResult });
|
|
const response = await fetchJson(`${config.serverOrigin}${pathname}`, { method: 'POST', body: JSON.stringify(envelope), headers: { 'X-Chimera-Worker-Id': config.workerId } });
|
|
const payload = verifyResponse(response, envelope.nonce);
|
|
if (reportedResult && pendingResult?.jobId === reportedResult.jobId) pendingResult = null;
|
|
if (payload.job) startJob(payload.job);
|
|
return Math.max(1_000, Math.min(30_000, Number(payload.pollAfterMs) || Number(config.pollIntervalMs) || 2_000));
|
|
}
|
|
|
|
async function runOnce(configFile = configPath) {
|
|
configPath = path.resolve(configFile);
|
|
logPath = path.join(path.dirname(configPath), 'worker.log');
|
|
config = loadConfig();
|
|
client = { installed: false, version: '', executable: '', managed: false };
|
|
registeredHosts = [];
|
|
session = { active: false, pid: null, nickname: '', startedAt: null, lastExitCode: null };
|
|
streamHealth = null;
|
|
sessionChild = null;
|
|
busyJobId = null;
|
|
pendingResult = null;
|
|
lastError = null;
|
|
lastProbeAt = 0;
|
|
completedJobs.clear();
|
|
await enroll();
|
|
log('INFO', `Chimera Remote Play Worker ${VERSION} gestart als ${config.workerId}.`);
|
|
await poll();
|
|
}
|
|
|
|
async function main() {
|
|
config = loadConfig(); await enroll();
|
|
log('INFO', `Chimera Remote Play Worker ${VERSION} gestart als ${config.workerId}.`);
|
|
let delay = 1_000;
|
|
do {
|
|
try {
|
|
delay = await poll();
|
|
lastError = null;
|
|
} catch (error) {
|
|
lastError = safeText(error.message);
|
|
log('WARN', `Poll mislukt: ${lastError}`);
|
|
delay = Math.min(30_000, Math.max(2_000, delay * 2));
|
|
}
|
|
if (!once) await new Promise(resolve => setTimeout(resolve, delay));
|
|
} while (!once);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
process.on('uncaughtException', error => { log('FATAL', error.message); process.exitCode = 1; });
|
|
process.on('unhandledRejection', error => { log('FATAL', error?.message || error); process.exitCode = 1; });
|
|
main().catch(error => { log('FATAL', error.message); process.exitCode = 1; });
|
|
}
|
|
|
|
module.exports = {
|
|
VERSION, compareVersions, installManagedChiaki, parseChiakiLog, runOnce,
|
|
validateArchiveEntries, validateReleaseMetadata,
|
|
};
|