'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, };