Initial curated chimera source from a8ae5b1db07fb49926c94585e2718e5130330974
This commit is contained in:
commit
c737a37f71
531 files changed
+119270
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.'
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user