Initial curated chimera source from a8ae5b1db07fb49926c94585e2718e5130330974

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:38:08 +02:00
commit c737a37f71
531 files changed
+119270

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
CHIMERA REMOTE PLAY WORKER
==========================
Doel
----
Chimera draait headless op Unraid. Beeld, audio, controller en DualSense-
functies moeten daarom op je Windows-pc draaien. Deze kleine worker laat
Chimera uitsluitend vaste chiaki-ng-acties op die pc starten en bewaken.
Installatie
-----------
1. Pak deze ZIP volledig uit.
2. Rechtsklik op install.ps1 en kies "Uitvoeren met PowerShell". Als Node.js
LTS ontbreekt, installeert het script dit automatisch via Windows Package
Manager.
3. Ga terug naar Console -> Remote Play in Chimera en klik Vernieuwen.
4. Gebruik Installeren/Bijwerken om de actuele stabiele chiaki-ng portable
release met grootte- en SHA-256-controle onder je eigen profiel te beheren.
5. Open chiaki-ng vanuit Chimera en registreer de PS5 eenmalig met de Link
Device-PIN. Account-ID en PIN blijven uitsluitend in chiaki-ng.
Beveiliging
-----------
- pairing.json bevat alleen een kortlevend, eenmalig inschrijftoken.
- Na inschrijving krijgt de worker via HTTPS een permanente HMAC-key en wist
hij het inschrijftoken.
- De worker accepteert geen shellcommando's, scripts, vrije argumenten of
willekeurige paden.
- De PS5 registration key, PSN Account-ID, Link Device-PIN en Chimera-cookie
worden nooit naar de worker gestuurd.
- De worker kan alleen chiaki-ng diagnosticeren, openen, hash-geverifieerd
installeren/bijwerken, een vooraf geconfigureerde sessie starten of die
sessie stoppen.
- Updates weigeren prereleases, afwijkende hosts/paden, ontbrekende SHA-256,
te grote downloads, archive-traversal, links en onverwachte payloadvormen.
Handmatig starten
-----------------
Als Taakplanner niet beschikbaar is, dubbelklik je op "Start worker.cmd".
Laat het venster dan open zolang je Remote Play vanuit Chimera gebruikt.
Verwijderen
-----------
Voer uninstall.ps1 uit vanuit:
%LOCALAPPDATA%\Chimera\RemotePlayWorker
Dit verwijdert ook de beheerde portable client, maar niet een afzonderlijke
systeeminstallatie of je lokale chiaki-profiel. Verwijder daarna de worker in
Chimera om de serverkey onmiddellijk in te trekken.
+10
View File
@@ -0,0 +1,10 @@
@echo off
setlocal
where node.exe >nul 2>nul
if errorlevel 1 (
echo Node.js ontbreekt. Installeer Node.js 22 of nieuwer.
pause
exit /b 1
)
node.exe "%~dp0worker.js" --config "%~dp0pairing.json"
if errorlevel 1 pause
+38
View File
@@ -0,0 +1,38 @@
$ErrorActionPreference = 'Stop'
$taskName = 'Chimera Remote Play Worker'
$target = Join-Path $env:LOCALAPPDATA 'Chimera\RemotePlayWorker'
$node = Get-Command node.exe -ErrorAction SilentlyContinue
if (-not $node) {
$winget = Get-Command winget.exe -ErrorAction SilentlyContinue
if (-not $winget) { throw 'Node.js ontbreekt en Windows Package Manager is niet beschikbaar om het automatisch te installeren.' }
Write-Host 'Node.js LTS ontbreekt; Chimera installeert het nu via Windows Package Manager.' -ForegroundColor Yellow
& $winget.Source install --id OpenJS.NodeJS.LTS -e --accept-package-agreements --accept-source-agreements --disable-interactivity
if ($LASTEXITCODE -ne 0) { throw "Node.js-installatie mislukte met exitcode $LASTEXITCODE." }
$nodePath = Join-Path $env:ProgramFiles 'nodejs\node.exe'
if (Test-Path -LiteralPath $nodePath) { $node = Get-Command $nodePath -ErrorAction SilentlyContinue } else { $node = Get-Command node.exe -ErrorAction SilentlyContinue }
}
if (-not $node) { throw 'Node.js LTS kon na installatie niet worden gevonden.' }
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'pairing.json'))) { throw 'pairing.json ontbreekt. Download een nieuw workerpakket vanuit Chimera.' }
New-Item -ItemType Directory -Force -Path $target | Out-Null
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'worker.js') -Destination (Join-Path $target 'worker.js') -Force
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'pairing.json') -Destination (Join-Path $target 'pairing.json') -Force
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'uninstall.ps1') -Destination (Join-Path $target 'uninstall.ps1') -Force
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
& icacls.exe (Join-Path $target 'pairing.json') /inheritance:r /grant:r "${identity}:(M)" | Out-Null
$arguments = '"' + (Join-Path $target 'worker.js') + '" --config "' + (Join-Path $target 'pairing.json') + '"'
$action = New-ScheduledTaskAction -Execute $node.Source -Argument $arguments -WorkingDirectory $target
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $identity
$principal = New-ScheduledTaskPrincipal -UserId $identity -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
Start-ScheduledTask -TaskName $taskName
Remove-Item -LiteralPath (Join-Path $PSScriptRoot 'pairing.json') -Force
Write-Host ''
Write-Host 'Chimera Remote Play Worker is geinstalleerd en gestart.' -ForegroundColor Green
Write-Host "Installatiemap: $target"
Write-Host 'Ga terug naar Chimera en klik op Vernieuwen of Diagnose.'
+15
View File
@@ -0,0 +1,15 @@
$ErrorActionPreference = 'SilentlyContinue'
$taskName = 'Chimera Remote Play Worker'
$target = Join-Path $env:LOCALAPPDATA 'Chimera\RemotePlayWorker'
$clientTarget = Join-Path $env:LOCALAPPDATA 'Chimera\RemotePlayClient'
Stop-ScheduledTask -TaskName $taskName
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
foreach ($candidate in @($clientTarget, $target)) {
if (-not (Test-Path -LiteralPath $candidate)) { continue }
$resolved = (Resolve-Path -LiteralPath $candidate).Path
$localRoot = [IO.Path]::GetFullPath($env:LOCALAPPDATA).TrimEnd('\') + '\'
if ($resolved -eq [IO.Path]::GetFullPath($candidate) -and $resolved.StartsWith($localRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
Remove-Item -LiteralPath $resolved -Recurse -Force
}
}
Write-Host 'Chimera Remote Play Worker en beheerde client zijn verwijderd.' -ForegroundColor Green
+606
View File
@@ -0,0 +1,606 @@
'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,
};