Files
PS5_RetroArch/webui/app.js
T
Mihawk 90aaa75abe WebUI: a parallel transfer engine (batches, upload sessions, ranges, six lanes)
Measured first: one libmicrohttpd thread with 64 KiB a connection held uploads to
13 MB/s, and four connections to 15.3, on a link where FTP reached 25 / 30.5 MB/s.
PS5-Suite's design was reviewed (persistent connection pool, small files per
connection, large files as pwrite ranges, big socket buffers) and an engine built
from scratch on the WebUI's HTTP (src/webui_transfer.{h,cpp}, docs/WEBUI.md):

- a pool of four server threads, 512 KiB a connection, 4 MiB socket buffers, writes
  in 1 MiB blocks; non-transfer routes under one lock as on the single thread;
- PUT /api/upload/batch: many small files in one request (a framed stream), each
  renamed into place when whole, existing names skipped and named unless replace;
- upload sessions (/api/upload/session, /part, /commit): a large file's ranges in
  any order on any number of connections, committed once covered;
- Range downloads (206); every upload still atomic, never over an existing name;
- the page plans batches, sessions and single uploads onto six lanes.

Evidence: evidence/webui-transfer-engine (base PS5 13.40, PC on Wi-Fi 6). Uploads
26.0 MB/s on one connection and 29.9 MB/s for one file in six parts (FTP: 25 /
30.5); 400 x 32 KiB at 215 files/s (FTP 105). The page against the console: 605
files in 20.1 s, all equal by FTP readback, duplicates refused, a cancelled 200 MiB
file left nothing. ThreadSanitizer under a mixed parallel load: no report.
Command: build/tmp/transfer/bench.py; node tests/webui_transfer_browser.cjs;
tools/verify.sh: all five PASS.
2026-10-07 08:23:21 -04:00

714 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* RetroArch PS5 WebUI. No external runtime or browser storage of console credentials. */
'use strict';
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const repo = 'mihawk-99/PS5_RetroArch';
let token = '', connected = false, freeBytes = null, uploadLimit = 64 * 1024 ** 3;
let currentPath = '', entries = [], folderRequest = 0, settingsValues = {};
let sessionRequest = null, sending = false, nextTransfer = 0, frontend = 'retroarch';
const transfers = [];
function element(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function icon(kind) {
const selectors = { folder: '.library-panel .section-icon', file: '.dropzone .icon', check: '.release-icon' };
const node = $(selectors[kind] || selectors.file).cloneNode(true);
node.setAttribute('class', 'icon');
return node;
}
function bytes(n) {
if (n === null || !Number.isFinite(n)) return 'Unknown';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return `${n.toLocaleString(undefined, { maximumFractionDigits: i ? 1 : 0 })} ${units[i]}`;
}
function announce(message, failure = false) {
const node = $('#announcement'); node.textContent = message;
node.classList.toggle('error', failure);
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (options.method && options.method !== 'GET') headers.set('X-RetroArch-Token', token);
const response = await fetch(path, { ...options, headers, cache: 'no-store', signal: options.signal || AbortSignal.timeout(15000) });
const data = await response.json();
if (!response.ok) throw new Error(data.error || `Request failed (${response.status}). Try again.`);
return data;
}
function setConnection(ok) {
connected = ok;
$('#connection').classList.toggle('offline', !ok);
// The WebUI stays up while the title changes frontend (src/webui_link.h): say which one shows.
const showing = { retroarch: 'RetroArch is running', picker: 'Frontend picker is open', 'es-de': 'EmulationStation is open', title: 'PS5 RetroArch is starting' }[frontend] || 'PS5 RetroArch is closed';
$('#connection span:last-child').textContent = ok ? showing : 'Console disconnected';
$('#connection-notice').hidden = ok;
for (const id of ['destination', 'dropzone', 'browse-files', 'upload-here', 'folder-name', 'quick-volume', 'quick-rumble', 'quick-frontend', 'settings-fields', 'save-settings']) {
$('#' + id).disabled = !ok || (['settings-fields', 'save-settings'].includes(id) && !editorRevision) || (['quick-volume', 'quick-rumble', 'quick-frontend', 'settings-fields', 'save-settings'].includes(id) && !Object.keys(settingsValues).length);
}
$('#folder-form button').disabled = !ok;
updateButton();
$('#install-update').disabled = !ok;
}
async function reconnect() {
if (sessionRequest) return sessionRequest;
sessionRequest = (async () => {
try {
const state = await api('/api/status');
if (token && token !== state.token && updateState === 'installing') { location.reload(); return false; }
const recovered = !connected || token !== state.token;
token = state.token; freeBytes = state.freeBytes; uploadLimit = state.uploadLimit; frontend = state.frontend ?? 'retroarch';
setConnection(true);
$('#storage-info').textContent = freeBytes === null ? 'Games and files stored on your PS5' : `${bytes(freeBytes)} free on the console`;
if (recovered) await Promise.all([loadLibrary(), loadSettings(), loadContent(currentPath), loadAlerts(), loadUpdate()]);
return true;
} catch (error) { setConnection(false); return false; }
finally { sessionRequest = null; }
})();
return sessionRequest;
}
function pageFromHash() {
const name = location.hash.slice(1).split('?')[0];
return ['content', 'transfers', 'settings'].includes(name) ? name : 'overview';
}
function navigate() {
const page = pageFromHash();
$$('.page').forEach(node => { node.hidden = node.id !== page; });
$$('[data-page]').forEach(node => { if (node.dataset.page === page) node.setAttribute('aria-current', 'page'); else node.removeAttribute('aria-current'); });
$('#page-title').textContent = page[0].toUpperCase() + page.slice(1);
if (page === 'content') loadContent(currentPath);
$('#main').focus({ preventScroll: true });
}
function openFolder(path) {
++folderRequest; // Ignore a previous folder response while the route changes.
currentPath = path;
if (location.hash !== '#content') location.hash = 'content';
else loadContent(path);
}
async function loadLibrary() {
const target = $('#library-folders');
try {
const data = await api('/api/content');
const folders = data.entries.filter(e => e.directory);
target.replaceChildren();
const select = $('#destination'), selected = select.value;
select.replaceChildren(new Option('Content folder', ''));
for (const entry of folders) {
select.add(new Option(entry.name, entry.name));
if (target.childElementCount < 3) {
const row = element('a', undefined, 'folder-row'); row.href = '#content';
row.append(icon('folder'), element('strong', entry.name), element('span', 'Open content'));
row.addEventListener('click', event => { event.preventDefault(); openFolder(entry.name); });
target.append(row);
}
}
if ([...select.options].some(o => o.value === selected)) select.value = selected;
if (!folders.length) {
const box = element('div', undefined, 'list-message');
box.append(element('p', 'Your content starts here. Upload a file or create a folder.'));
const link = element('a', 'Open content'); link.href = '#content'; box.append(link); target.append(box);
} else if (folders.length > 3) {
const more = element('a', `View all ${folders.length} folders`, 'list-message'); more.href = '#content';
more.addEventListener('click', event => { event.preventDefault(); openFolder(''); }); target.append(more);
}
} catch (error) { target.replaceChildren(element('p', 'Library unavailable. Reconnect or open Content to retry.', 'list-message')); }
}
function renderBreadcrumbs() {
const nav = $('#breadcrumbs'); nav.replaceChildren();
const parts = currentPath ? currentPath.split('/') : [];
['', ...parts].forEach((part, index) => {
const path = parts.slice(0, index).join('/');
const button = element('button', index ? part : 'Content', 'text-button');
button.type = 'button'; button.disabled = index === parts.length;
button.addEventListener('click', () => openFolder(path)); nav.append(button);
});
}
async function loadContent(path) {
const request = ++folderRequest;
$('#content-error').hidden = true;
$('#content-list').setAttribute('aria-busy', 'true');
try {
const data = await api('/api/content?path=' + encodeURIComponent(path));
if (request !== folderRequest) return;
currentPath = data.path; entries = data.entries; renderBreadcrumbs();
$('#file-search').value = '';
$('#folder-caption').textContent = data.truncated ? 'Showing the first 10,000 items. Organize files into smaller folders to see more.' : `${entries.length} ${entries.length === 1 ? 'item' : 'items'}`;
renderContent();
} catch (error) {
if (request !== folderRequest) return;
$('#content-error').textContent = error.message; $('#content-error').hidden = false;
$('#content-list').replaceChildren();
} finally { if (request === folderRequest) $('#content-list').removeAttribute('aria-busy'); }
}
function renderContent() {
const target = $('#content-list'); target.replaceChildren();
const search = $('#file-search').value.toLocaleLowerCase();
for (const entry of entries.filter(e => e.name.toLocaleLowerCase().includes(search))) {
const row = element('div', undefined, 'content-row');
const path = [currentPath, entry.name].filter(Boolean).join('/');
const name = element(entry.directory ? 'button' : 'a', entry.name, 'file-name');
if (entry.directory) { name.type = 'button'; name.addEventListener('click', () => openFolder(path)); }
else {
name.href = '/api/download?path=' + encodeURIComponent(path); name.download = entry.name;
name.addEventListener('click', () => recordDownload(entry.name));
}
row.append(icon(entry.directory ? 'folder' : 'file'), name, element('span', entry.directory ? 'Folder' : bytes(entry.size), 'file-size'));
if (!entry.directory) {
const link = element('a', 'Download'); link.href = name.href; link.download = entry.name;
link.addEventListener('click', () => recordDownload(entry.name)); row.append(link);
}
target.append(row);
}
if (!target.childElementCount) target.append(element('p', search ? 'No matching files. Try another name.' : 'This folder is empty. Upload files here to get started.', 'list-message'));
}
function validName(name) { return !!name && name[0] !== '.' && !/[\x00-\x1f\x7f\\/:]/.test(name) && new TextEncoder().encode(name).length <= 255; }
$('#folder-form').addEventListener('submit', async event => {
event.preventDefault(); const input = $('#folder-name'), name = input.value.trim();
if (!validName(name)) { announce('Use a folder name without slashes, a leading dot, or control characters.', true); return; }
const button = $('#folder-form button'); button.disabled = true;
try {
await api('/api/folder?path=' + encodeURIComponent([currentPath, name].filter(Boolean).join('/')), { method: 'POST' });
input.value = ''; announce(`Created ${name}.`); await Promise.all([loadLibrary(), loadContent(currentPath)]);
} catch (error) { announce(error.message, true); }
finally { button.disabled = !connected; }
});
function drawTransfers() {
for (const selector of ['#recent-transfers', '#all-transfers']) {
const target = $(selector);
const shown = selector === '#recent-transfers' ? transfers.slice(-3).reverse() : [...transfers].reverse();
const visible = new Set(shown.map(transfer => String(transfer.id)));
for (const child of [...target.children]) if (!visible.has(child.dataset.transfer)) child.remove();
if (!shown.length) { const empty = element('div', undefined, 'empty-state'); empty.append(icon('file'), element('p', 'Your transfers will appear here.')); target.append(empty); }
for (const [index, transfer] of shown.entries()) {
let row = target.querySelector(`[data-transfer="${transfer.id}"]`);
if (!row) {
row = element('div', undefined, 'transfer-row'); row.dataset.transfer = transfer.id;
const details = element('div', undefined, 'transfer-details');
details.append(element('strong', transfer.name), element('span', '', 'muted')); row.append(details);
target.insertBefore(row, target.children[index] || null);
}
const details = row.firstElementChild, message = details.children[1];
message.textContent = transfer.message; message.className = transfer.state === 'failed' ? 'inline-error' : 'muted';
if (['queued', 'uploading'].includes(transfer.state)) {
let progress = details.querySelector('progress');
if (!progress) {
progress = document.createElement('progress'); progress.max = 100;
progress.setAttribute('aria-label', `Upload progress for ${transfer.name}`); details.append(progress);
const cancel = element('button', 'Cancel', 'secondary');
cancel.addEventListener('click', () => cancelTransfer(transfer));
row.append(cancel);
}
progress.value = transfer.percent || 0;
} else { details.querySelector('progress')?.remove(); row.querySelector('button')?.remove(); }
}
}
}
function recordDownload(name) {
transfers.push({ id: ++nextTransfer, name, state: 'download', message: 'Download requested · check your browser’s download manager' }); drawTransfers();
}
function queueFiles(files, destination) {
if (!connected) { announce('Reconnect to the console before uploading files.', true); return; }
for (const file of files) {
if (!validName(file.name) || file.size > uploadLimit) { announce(`${file.name}: choose a valid filename and a file no larger than ${bytes(uploadLimit)}.`, true); continue; }
transfers.push({ id: ++nextTransfer, name: file.name, file, path: [destination, file.name].filter(Boolean).join('/'), state: 'queued', message: `Queued · ${bytes(file.size)}`, percent: 0 });
}
drawTransfers(); sendNext();
}
// The transfer engine (src/webui_transfer.h on the console): six lanes run at once.
// Small files travel many to a request (a batch), large files as parts on every free
// lane (an upload session), the rest one to a request. Every file is renamed into
// place on the console only once whole.
const LANES = 6, SMALL = 4 * 1024 ** 2, LARGE = 64 * 1024 ** 2, PART = 32 * 1024 ** 2;
const BATCH_FILES = 256, BATCH_BYTES = 16 * 1024 ** 2, PART_TRIES = 3;
let jobs = [], opening = 0; // sessions being opened: their parts are about to queue
function cancelTransfer(transfer) {
if (!['queued', 'uploading'].includes(transfer.state)) return;
transfer.state = 'cancelled'; transfer.message = 'Cancelled';
for (const xhr of transfer.requests || []) xhr.abort();
if (transfer.session) api('/api/upload/session?id=' + encodeURIComponent(transfer.session), { method: 'DELETE' }).catch(() => {});
transfer.file = null; drawTransfers();
}
function showProgress(transfer, sent) {
transfer.sent = Math.min(sent, transfer.file?.size ?? sent);
const size = transfer.file?.size || 0, percent = size ? Math.floor(transfer.sent / size * 100) : 100;
if (percent === transfer.percent && transfer.state === 'uploading') return;
transfer.percent = percent; transfer.state = 'uploading';
transfer.message = percent >= 100 ? 'Finishing on the console…' : `${percent}% · ${bytes(transfer.sent)} of ${bytes(size)}`;
const now = Date.now(); if (now - (transfer.drawn || 0) > 150 || percent >= 100) { transfer.drawn = now; drawTransfers(); }
}
function finish(transfer, state, message) {
if (transfer.state === 'cancelled') return;
transfer.state = state; transfer.message = message; transfer.file = null; transfer.requests = [];
transfer.session = null; drawTransfers();
}
// One request on a lane: resolves with {status, body}, or rejects when the connection fails.
function send(method, url, body, transfers, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
for (const transfer of transfers) (transfer.requests ||= []).push(xhr);
const done = () => { for (const transfer of transfers) transfer.requests = (transfer.requests || []).filter(x => x !== xhr); };
xhr.open(method, url); xhr.setRequestHeader('X-RetroArch-Token', token);
if (onProgress) xhr.upload.onprogress = event => onProgress(event.loaded);
xhr.onload = () => { done(); let parsed = {}; try { parsed = JSON.parse(xhr.responseText); } catch {} resolve({ status: xhr.status, body: parsed }); };
xhr.onerror = () => { done(); reject(new Error('Connection lost. Reconnect, then choose this file again.')); };
xhr.onabort = () => { done(); reject(new Error('Cancelled')); };
xhr.send(body);
});
}
// The batch stream: per file a u16 name length, the name (UTF-8), a u64 size and the
// file itself, then a zero length. A Blob of the files' own Blobs: nothing is copied.
function batchBody(transfers) {
const parts = [], encoder = new TextEncoder();
for (const transfer of transfers) {
const name = encoder.encode(transfer.path), head = new Uint8Array(2 + name.length + 8), view = new DataView(head.buffer);
view.setUint16(0, name.length, true); head.set(name, 2); view.setBigUint64(2 + name.length, BigInt(transfer.file.size), true);
parts.push(head, transfer.file);
}
parts.push(new Uint8Array(2));
return new Blob(parts);
}
async function runBatch(transfers) {
transfers = transfers.filter(transfer => transfer.state === 'queued');
if (!transfers.length) return;
const encoder = new TextEncoder(), starts = []; let at = 0;
for (const transfer of transfers) { at += 2 + encoder.encode(transfer.path).length + 8; starts.push(at); at += transfer.file.size; }
for (const transfer of transfers) showProgress(transfer, 0);
try {
const { status, body: result } = await send('PUT', '/api/upload/batch', batchBody(transfers), transfers,
loaded => transfers.forEach((transfer, i) => showProgress(transfer, loaded - starts[i])));
const failed = new Map((result.failed || []).map(entry => [entry.name, entry.error]));
const skipped = new Set(result.skippedNames || []);
for (const transfer of transfers) {
if (transfer.state !== 'uploading') continue;
if (skipped.has(transfer.path)) finish(transfer, 'failed', 'A file with this name already exists. Rename your file first.');
else if (failed.has(transfer.path)) finish(transfer, 'failed', `Upload failed (${failed.get(transfer.path)}). Choose the file again to retry.`);
else if (status === 200) finish(transfer, 'complete', `Uploaded · ${bytes(transfer.file.size)}`);
else finish(transfer, 'failed', result.error || result.problem || 'Upload failed. Choose the file again to retry.');
}
} catch (error) {
for (const transfer of transfers) if (transfer.state === 'uploading') finish(transfer, 'failed', error.message);
if (error.message !== 'Cancelled') setConnection(false);
}
}
async function runSingle(transfer) {
if (transfer.state !== 'queued') return;
showProgress(transfer, 0);
try {
const { status, body } = await send('PUT', '/api/upload?path=' + encodeURIComponent(transfer.path), transfer.file, [transfer], loaded => showProgress(transfer, loaded));
if (status === 201) finish(transfer, 'complete', `Uploaded · ${bytes(transfer.file.size)}`);
else finish(transfer, 'failed', body.error || 'Upload failed. Choose the file again to retry.');
} catch (error) { finish(transfer, error.message === 'Cancelled' ? 'cancelled' : 'failed', error.message); if (error.message !== 'Cancelled') setConnection(false); }
}
// A large file: a session, then its parts as jobs any lane takes. The part that
// completes the file commits it, on its own lane: no lane ever waits on another.
async function runLarge(transfer) {
if (transfer.state !== 'queued') return;
showProgress(transfer, 0);
let opened;
opening++;
try { opened = await send('POST', `/api/upload/session?path=${encodeURIComponent(transfer.path)}&size=${transfer.file.size}`, null, [transfer]); }
catch (error) { finish(transfer, 'failed', error.message); return; }
finally { opening--; }
if (opened.status !== 201) { finish(transfer, 'failed', opened.body.error || 'Upload failed. Choose the file again to retry.'); return; }
transfer.session = opened.body.id;
const size = transfer.file.size, part = Math.max(4 * 1024 ** 2, Math.min(opened.body.partSize || PART, Math.ceil(size / LANES)));
const sent = new Map(), parts = [];
for (let start = 0; start < size; start += part) parts.push([start, Math.min(start + part, size)]);
let left = parts.length, failure = null;
const abandon = message => {
if (failure) return;
failure = message;
api('/api/upload/session?id=' + encodeURIComponent(transfer.session), { method: 'DELETE' }).catch(() => {});
if (message !== 'Cancelled') finish(transfer, 'failed', message);
};
const commit = async () => {
try {
const { status, body } = await send('POST', '/api/upload/commit?id=' + encodeURIComponent(transfer.session), null, [transfer]);
if (status === 201) finish(transfer, 'complete', `Uploaded · ${bytes(size)}`);
else finish(transfer, 'failed', body.error || 'Upload failed. Choose the file again to retry.');
} catch (error) { finish(transfer, 'failed', error.message); }
};
const partJob = ([start, end]) => async () => {
for (let attempt = 1; !failure; attempt++) {
if (transfer.state !== 'uploading') { abandon('Cancelled'); return; }
try {
const { status, body } = await send('PUT', `/api/upload/part?id=${encodeURIComponent(transfer.session)}&offset=${start}`, transfer.file.slice(start, end), [transfer],
loaded => { sent.set(start, loaded); showProgress(transfer, [...sent.values()].reduce((a, b) => a + b, 0)); });
if (status === 200) { sent.set(start, end - start); break; }
if (attempt >= PART_TRIES || status === 404 || status === 416) { abandon(body.error || 'A part could not be written.'); return; }
} catch (error) {
if (error.message === 'Cancelled') { abandon('Cancelled'); return; }
if (attempt >= PART_TRIES) { abandon(error.message); return; }
}
}
if (!failure && --left === 0) await commit();
};
// First in the queue, so every free lane joins this file at once.
jobs.unshift(...parts.map(partJob));
}
// Turns the queued transfers into jobs: batches of small files, sessions, single files.
function planJobs() {
let batch = [], batchBytes = 0;
const flush = () => { if (batch.length) { const group = batch; jobs.push(() => runBatch(group)); } batch = []; batchBytes = 0; };
for (const transfer of transfers) {
if (transfer.state !== 'queued' || transfer.planned) continue;
transfer.planned = true;
const size = transfer.file.size;
if (size < SMALL) {
if (batch.length >= BATCH_FILES || batchBytes + size > BATCH_BYTES) flush();
batch.push(transfer); batchBytes += size;
} else if (size >= LARGE) jobs.push(() => runLarge(transfer));
else jobs.push(() => runSingle(transfer));
}
flush();
}
async function sendNext() {
planJobs();
if (sending) return;
sending = true;
try {
if (!connected && !await reconnect()) {
for (const transfer of transfers) if (transfer.state === 'queued') finish(transfer, 'failed', 'Console disconnected. Reconnect, then choose this file again.');
jobs = []; return;
}
// Each lane runs one job at a time until none is left; a job is one request (or a
// part and its file's commit), so no lane waits on another lane's work.
const lane = async () => {
for (;;) {
planJobs();
const job = jobs.shift();
if (job) await job();
else if (opening) await new Promise(resolve => setTimeout(resolve, 25));
else return;
}
};
await Promise.all(Array.from({ length: LANES }, lane));
} finally { sending = false; await Promise.all([loadLibrary(), loadContent(currentPath), reconnect()]); }
}
let pickerDestination = '';
function pickFiles(destination) { pickerDestination = destination; $('#file-input').click(); }
$('#browse-files').addEventListener('click', () => pickFiles($('#destination').value));
$('#dropzone').addEventListener('click', () => pickFiles($('#destination').value));
$('#upload-here').addEventListener('click', () => pickFiles(currentPath));
$('#file-input').addEventListener('change', event => { queueFiles([...event.target.files], pickerDestination); event.target.value = ''; });
for (const name of ['dragenter', 'dragover']) $('#dropzone').addEventListener(name, event => { event.preventDefault(); $('#dropzone').classList.add('dragging'); });
for (const name of ['dragleave', 'drop']) $('#dropzone').addEventListener(name, event => { event.preventDefault(); $('#dropzone').classList.remove('dragging'); });
$('#dropzone').addEventListener('drop', event => queueFiles([...event.dataTransfer.files], $('#destination').value));
window.addEventListener('dragover', event => event.preventDefault());
window.addEventListener('drop', event => event.preventDefault());
window.addEventListener('beforeunload', event => { if (Object.keys(editorDraft).length || transfers.some(t => ['queued', 'uploading'].includes(t.state))) { event.preventDefault(); event.returnValue = ''; } });
$('#clear-transfers').addEventListener('click', () => { for (let i = transfers.length - 1; i >= 0; i--) if (!['queued', 'uploading'].includes(transfers[i].state)) transfers.splice(i, 1); drawTransfers(); });
function updateQuick() {
for (const [id, key, unit] of [['quick-volume', 'audio_volume', ' dB'], ['quick-rumble', 'input_rumble_gain', '%']]) {
$('#' + id).value = settingsValues[key]; $(`output[for="${id}"]`).textContent = Number(settingsValues[key]) + unit;
}
// The frontend the title opens on: the picker every time, or one straight away.
if (settingsValues.frontend_start) $('#quick-frontend').value = settingsValues.frontend_start;
}
let editorSettings = [], editorValues = {}, editorDraft = {}, editorRevision = '', editorPage = 0, editorRequest = 0;
let editorProfile = '', editorKind = 'core-options', editorMode = 'guided', editorCategory = '';
let editorMetadata = {}, editorCategories = [], metadataUnavailable = false;
const pageSize = 40;
function settingTitle(key) {
const known = { audio_volume: 'Audio volume', input_rumble_gain: 'Rumble strength', video_smooth: 'Smooth image scaling', video_vsync: 'Vertical sync', menu_driver: 'Console menu' };
return known[key] || key.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).replace(/\b(ppsspp|snes|nes|gba|gpu|cpu|msaa|fps|vsync|xmb|rgui)\b/gi, word => word.toUpperCase());
}
function editorUrl() { return '/api/config?scope=' + (editorProfile ? editorKind : 'global') + '&core=' + encodeURIComponent(editorProfile); }
function markEdits() {
const count = Object.keys(editorDraft).length;
$('#settings-result').textContent = count ? `${count} unsaved ${count === 1 ? 'change' : 'changes'}` : '';
$('#refresh-settings').textContent = count ? 'Discard changes & refresh' : 'Refresh settings';
}
function settingGuide(setting) {
if (!editorProfile || editorKind === 'core-settings') return globalGuide[setting.key];
const meta = editorMetadata[setting.key];
if (!meta) return null;
return { ...meta, category: meta.category || 'general', control: meta.choices?.length ? 'select' : 'text', description: coreHelp(setting.key) || meta.description || 'This core provides the available choices but no explanation for this option. Keep its current value unless you know the change you need.' };
}
function choiceLabel(value, label) {
if (['enabled', 'true'].includes(label)) return 'On';
if (['disabled', 'Disabled', 'false'].includes(label)) return 'Off';
return label || value;
}
function renderSettings() {
const guided = editorMode === 'guided', query = $('#settings-search').value.trim().toLocaleLowerCase();
const available = editorSettings.filter(s => !guided || settingGuide(s));
if (guided) {
const order = Object.keys(!editorProfile || editorKind === 'core-settings' ? globalGuide : editorMetadata);
const rank = new Map(order.map((key, index) => [key, index]));
available.sort((a, b) => rank.get(a.key) - rank.get(b.key));
}
const categories = (!editorProfile || editorKind === 'core-settings' ? globalCategories : [...editorCategories, { key: 'general', label: 'General', description: 'Options supplied by this core.' }])
.filter(c => available.some(s => settingGuide(s)?.category === c.key));
if (!categories.some(c => c.key === editorCategory)) editorCategory = categories[0]?.key || '';
const nav = $('#settings-categories'); nav.replaceChildren(); nav.hidden = !guided || !categories.length;
for (const category of categories) {
const button = element('button', category.label); button.type = 'button'; button.dataset.category = category.key;
button.setAttribute('aria-pressed', String(category.key === editorCategory && !query));
button.addEventListener('click', () => { editorCategory = category.key; editorPage = 0; $('#settings-search').value = ''; renderSettings(); [...nav.children].find(n => n.dataset.category === category.key)?.focus(); }); nav.append(button);
}
const active = categories.find(c => c.key === editorCategory);
$('#settings-category-title').textContent = guided ? (query ? 'Search results' : active?.label || 'Guided settings') : 'All settings';
$('#settings-category-help').textContent = guided ? (query ? 'Matching settings from every category.' : active?.description || 'No option catalog is available for this core.') : 'Technical names and exact saved values. Use this view for settings not covered by the guide.';
const uncovered = editorSettings.length - available.length;
$('#settings-coverage').textContent = !guided ? '' : metadataUnavailable ? 'Core guidance could not be loaded. Refresh to try again, or use Advanced.' : uncovered ? `${uncovered} additional ${uncovered === 1 ? 'setting is' : 'settings are'} available in Advanced.` : '';
$('#settings-coverage').hidden = !$('#settings-coverage').textContent;
const matched = available.filter(s => {
const meta = settingGuide(s);
return (query ? `${meta?.label || settingTitle(s.key)} ${s.key} ${meta?.description || ''}`.toLocaleLowerCase().includes(query) : !guided || meta?.category === editorCategory);
});
const pages = Math.max(1, Math.ceil(matched.length / pageSize)); editorPage = Math.min(editorPage, pages - 1);
const fields = $('#settings-fields'); fields.replaceChildren();
$('#settings-count').textContent = `${matched.length} settings${query ? ' matching your search' : ''}`;
for (const setting of matched.slice(editorPage * pageSize, (editorPage + 1) * pageSize)) {
const meta = settingGuide(setting), value = editorDraft[setting.key] ?? setting.value;
const row = element('div', undefined, 'setting-row'), details = element('div', undefined, 'setting-details');
const label = element('label', guided ? meta.label : settingTitle(setting.key), 'setting-label');
const id = 'setting-' + setting.key; label.htmlFor = id;
if (!guided) label.append(element('small', setting.key));
const description = element('p', meta?.description || 'No description is available for this technical setting. Keep its value unless you know the configuration change you need.', 'setting-description');
description.id = id + '-help'; details.append(label, description);
let input, control = element('div', undefined, 'setting-control'), output;
const choices = guided ? meta.choices : setting.key === 'menu_driver' ? [['xmb', 'XMB'], ['rgui', 'RGUI']] : null;
if (choices?.length) {
input = document.createElement('select');
for (const [value, text] of choices) input.add(new Option(choiceLabel(value, text), value));
if (![...input.options].some(o => o.value === value)) input.add(new Option(`Current: ${value || '(empty)'}`, value));
input.value = value;
} else {
input = document.createElement('input');
input.type = guided && ['volume', 'rumble'].includes(meta.control) ? 'range' : (guided && meta.control === 'toggle') || setting.kind === 'bool' ? 'checkbox' : setting.kind;
if (input.type === 'checkbox') input.checked = value === 'true';
else { input.value = value; input.maxLength = 4096; if (setting.kind === 'number') { input.step = 'any'; input.required = true; } }
if (input.type === 'range') { input.min = meta.control === 'volume' ? -80 : 0; input.max = meta.control === 'volume' ? 12 : 100; input.step = meta.control === 'volume' ? '0.1' : '1'; input.value = value; }
if (['checkbox', 'range'].includes(input.type)) {
output = element('output'); output.htmlFor = id;
const updateOutput = () => { output.textContent = input.type === 'checkbox' ? (input.checked ? 'On' : 'Off') : input.value + (meta?.control === 'volume' ? ' dB' : '%'); };
updateOutput(); input.addEventListener('input', updateOutput);
}
}
if (setting.key === 'audio_volume') { input.min = -80; input.max = 12; }
if (setting.key === 'input_rumble_gain') { input.min = 0; input.max = 100; }
input.id = id; input.name = setting.key; input.setAttribute('aria-describedby', description.id);
input.addEventListener('input', () => {
const updated = input.type === 'checkbox' ? String(input.checked) : input.value;
if (updated === editorValues[setting.key]) delete editorDraft[setting.key]; else editorDraft[setting.key] = updated;
row.classList.toggle('setting-edited', Object.hasOwn(editorDraft, setting.key)); markEdits();
});
row.classList.toggle('setting-edited', Object.hasOwn(editorDraft, setting.key));
control.append(input); if (output) control.append(output); row.append(details, control); fields.append(row);
}
if (!matched.length) fields.append(element('p', query ? 'No matching settings. Try another search or switch to Advanced.' : guided ? 'No guided options are available. Refresh to try again, or use Advanced for saved values.' : 'No options are available for this profile.', 'list-message'));
$('#settings-page').textContent = `Page ${editorPage + 1} of ${pages}`;
$('.settings-paging').hidden = pages <= 1;
$('#settings-previous').disabled = editorPage === 0; $('#settings-next').disabled = editorPage >= pages - 1;
}
async function loadEditor() {
const request = ++editorRequest; editorRevision = '';
for (const id of ['settings-profile', 'settings-kind', 'refresh-settings']) $('#' + id).disabled = true;
$('#settings-fields').disabled = true; $('#save-settings').disabled = true;
$('#settings-result').textContent = 'Loading settings…';
try {
const [data, metadata] = await Promise.all([api(editorUrl()), editorProfile && editorKind === 'core-options' ? api('/api/core-metadata?core=' + encodeURIComponent(editorProfile)).catch(() => null) : Promise.resolve({ categories: [], settings: [] })]);
if (request !== editorRequest) return;
metadataUnavailable = !metadata;
// The previous running title may serve new assets before its next restart.
const catalogs = [metadata?.bundled || {}, metadata?.runtime || metadata || {}];
editorMetadata = Object.fromEntries(catalogs.flatMap(m => m.settings || []).map(s => [s.key, s]));
editorCategories = [...new Map(catalogs.flatMap(m => m.categories || []).map(c => [c.key, c])).values()];
editorSettings = data.settings; editorValues = Object.fromEntries(data.settings.map(s => [s.key, s.value]));
editorRevision = data.revision; editorDraft = {}; editorPage = 0;
$('#settings-heading').textContent = editorProfile ? `${editorProfile} · ${editorKind === 'core-options' ? 'Core options' : 'RetroArch overrides'}` : 'Global RetroArch settings';
$('#settings-help').textContent = editorProfile
? 'Saved changes apply when you restart RetroArch. Game-specific settings may take priority. Core options control the emulator; RetroArch overrides change shared preferences for just this core.'
: 'Start with everyday preferences in Guided, or switch to Advanced for the full configuration. Saved changes apply when you restart RetroArch. Core and game preferences may take priority.';
renderSettings(); markEdits();
} catch (error) { if (request === editorRequest) { editorRevision = ''; $('#settings-fields').replaceChildren(); $('#settings-result').textContent = error.message; } }
finally { if (request === editorRequest) { for (const id of ['settings-profile', 'settings-kind', 'refresh-settings']) $('#' + id).disabled = false; $('#settings-fields').disabled = !connected || !editorRevision; $('#save-settings').disabled = !connected || !editorRevision; } }
}
async function loadSettings() {
try {
const [data, profiles] = await Promise.all([api('/api/settings'), api('/api/cores')]);
settingsValues = Object.fromEntries(data.settings.map(s => [s.key, s.value]));
const select = $('#settings-profile'); select.replaceChildren(new Option('Global RetroArch', ''));
for (const name of profiles.cores) select.add(new Option(name, name)); select.value = editorProfile;
setConnection(connected); updateQuick(); if (!Object.keys(editorDraft).length) await loadEditor();
} catch (error) { $('#settings-result').textContent = 'Settings unavailable. Reconnect to try again.'; }
}
async function saveSettings(changes) {
const body = Object.entries(changes).map(([key, value]) => `${key}=${value}`).join('\n');
if (!body) return;
await api('/api/settings', { method: 'POST', body, headers: { 'Content-Type': 'text/plain' } });
Object.assign(settingsValues, changes); updateQuick();
if (!editorProfile && !Object.keys(editorDraft).length) await loadEditor();
announce('Settings saved. They will apply the next time you open RetroArch.');
}
$('#settings-form').addEventListener('submit', async event => {
event.preventDefault(); if (!Object.keys(editorDraft).length) { $('#settings-result').textContent = 'No changes to save.'; return; }
$('#save-settings').disabled = true; $('#settings-fields').disabled = true;
for (const id of ['settings-profile', 'settings-kind', 'refresh-settings']) $('#' + id).disabled = true;
try {
const body = Object.entries(editorDraft).map(([key, value]) => `${key}=${value}`).join('\n');
await api(editorUrl(), { method: 'POST', body, headers: { 'Content-Type': 'text/plain', 'X-RetroArch-Revision': editorRevision } });
if (!editorProfile) { Object.assign(settingsValues, editorDraft); updateQuick(); }
await loadEditor(); $('#settings-result').textContent = 'Saved for next launch.'; announce('Settings saved. Restart RetroArch to apply them.');
} catch (error) { $('#settings-result').textContent = error.message; announce(error.message, true); }
finally { for (const id of ['settings-profile', 'settings-kind', 'refresh-settings']) $('#' + id).disabled = false; $('#save-settings').disabled = !connected || !editorRevision; $('#settings-fields').disabled = !connected || !editorRevision; }
});
function changeProfile() {
if (Object.keys(editorDraft).length) { $('#settings-profile').value = editorProfile; $('#settings-kind').value = editorKind; announce('Save your changes or discard them with Refresh before switching profiles.', true); return; }
editorProfile = $('#settings-profile').value; editorKind = $('#settings-kind').value;
$('#settings-kind-label').hidden = !editorProfile; $('#settings-search').value = ''; loadEditor();
}
for (const mode of ['guided', 'advanced']) $('#settings-' + mode).addEventListener('click', () => {
editorMode = mode; editorPage = 0;
for (const name of ['guided', 'advanced']) $('#settings-' + name).setAttribute('aria-pressed', String(name === mode));
$('#settings-mode-help').textContent = mode === 'guided' ? 'Clear explanations and ready-to-use choices.' : 'Full configuration with technical names and exact values.';
renderSettings();
});
$('#settings-profile').addEventListener('change', changeProfile); $('#settings-kind').addEventListener('change', changeProfile);
$('#refresh-settings').addEventListener('click', () => { editorDraft = {}; loadSettings(); });
$('#settings-search').addEventListener('input', () => { editorPage = 0; renderSettings(); });
$('#settings-previous').addEventListener('click', () => { --editorPage; renderSettings(); });
$('#settings-next').addEventListener('click', () => { ++editorPage; renderSettings(); });
for (const [id, key, unit] of [['quick-volume', 'audio_volume', ' dB'], ['quick-rumble', 'input_rumble_gain', '%']]) {
$('#' + id).addEventListener('input', event => { $(`output[for="${id}"]`).textContent = event.target.value + unit; });
$('#' + id).addEventListener('change', async event => {
const input = event.target; input.disabled = true;
try { await saveSettings({ [key]: input.value }); const full = $('#setting-' + key); if (full) full.value = settingsValues[key]; }
catch (error) { announce(error.message, true); updateQuick(); }
finally { input.disabled = !connected; }
});
}
try { if (localStorage.getItem('retroarch-theme') === 'dark') { document.documentElement.dataset.theme = 'dark'; $('#theme').value = 'dark'; } } catch { /* Browser storage may be disabled. */ }
$('#quick-frontend').addEventListener('change', async event => {
const input = event.target; input.disabled = true;
try { await saveSettings({ frontend_start: input.value }); }
catch (error) { announce(error.message, true); updateQuick(); }
finally { input.disabled = !connected; }
});
$('#theme').addEventListener('change', event => { document.documentElement.dataset.theme = event.target.value; try { localStorage.setItem('retroarch-theme', event.target.value); } catch { /* Theme still works for this visit. */ } });
// Numeric identifiers and prerelease ordering follow SemVer; alphas are published releases too.
function compareVersions(left, right) {
const parse = value => /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
const a = parse(left), b = parse(right); if (!a || !b) return null;
for (let i = 1; i <= 3; i++) { if (BigInt(a[i]) !== BigInt(b[i])) return BigInt(a[i]) > BigInt(b[i]) ? 1 : -1; }
if (!a[4] || !b[4]) return a[4] === b[4] ? 0 : !a[4] ? 1 : -1;
const aa = a[4].split('.'), bb = b[4].split('.');
for (let i = 0; i < Math.max(aa.length, bb.length); i++) {
if (aa[i] === bb[i]) continue;
if (aa[i] === undefined || bb[i] === undefined) return aa[i] === undefined ? -1 : 1;
const an = /^\d+$/.test(aa[i]), bn = /^\d+$/.test(bb[i]);
if (an && bn) { if (BigInt(aa[i]) !== BigInt(bb[i])) return BigInt(aa[i]) > BigInt(bb[i]) ? 1 : -1; else continue; }
if (an !== bn) return an ? -1 : 1;
return aa[i] > bb[i] ? 1 : -1;
}
return 0;
}
let installed = null, latestUpdate = null, updateState = 'idle', updatePolling = false;
function updateButton() {
const busy = ['downloading', 'verifying', 'ready', 'installing'].includes(updateState);
$('#download-update').disabled = !connected || !latestUpdate || busy;
$('#download-update').textContent = busy ? 'Update in progress' : installed && !installed.release ? 'Install latest release' : 'Update RetroArch';
}
function renderUpdate(data) {
updateState = data.state;
$('#update-panel').hidden = data.state === 'idle' && $('#update-error').hidden;
$('#update-tag').textContent = data.tag || '';
$('#update-development-note').hidden = !installed || Boolean(installed.release);
const downloading = data.state === 'downloading';
$('#update-message').textContent = data.message + (downloading && data.received ? ` ${bytes(data.received)} received.` : '');
$('#update-progress').hidden = !['downloading', 'verifying'].includes(data.state);
$('#install-update').hidden = data.state !== 'ready';
$('#install-update').disabled = !connected;
updateButton();
}
async function loadUpdate() {
if (updatePolling) return;
updatePolling = true;
try { renderUpdate(await api('/api/update')); }
catch (error) {
if (updateState === 'installing') {
$('#update-message').textContent = 'PS5 RetroArch has closed for installation. Reopen it from your launcher when installation finishes, then check the version above.';
} else if (updateState !== 'idle') {
$('#update-message').textContent = 'Update status is unavailable. Reconnect to check its progress before retrying.';
}
} finally { updatePolling = false; }
}
async function loadAlerts() {
const button = $('#refresh-alerts'); button.disabled = true;
try {
const data = await api('/api/alerts'), list = $('#alerts-list'); list.replaceChildren();
const groups = new Map();
for (const alert of data.alerts) {
if (!groups.has(alert.core)) { const group = element('section', undefined, 'alert-group'); group.append(element('h3', alert.core)); groups.set(alert.core, group); list.append(group); }
const row = element('div', undefined, 'alert-item');
row.append(element('strong', alert.title));
if (alert.path) row.append(element('code', alert.path));
else row.append(element('p', alert.message));
groups.get(alert.core).append(row);
}
const count = data.alerts.length;
$('#alerts-count').textContent = count; $('#alerts-count').hidden = !count;
$('#alerts-summary').textContent = count ? `${count} ${count === 1 ? 'item needs' : 'items need'} attention across your installed cores.` : 'No missing required BIOS or system files found.';
$('#alerts-details').hidden = !count;
$('.alerts-panel').dataset.state = count ? 'warning' : 'clear';
} catch (error) { $('#alerts-summary').textContent = 'Couldn’t check required files. Reconnect and try again.'; $('#alerts-details').hidden = true; $('#alerts-count').hidden = true; }
finally { button.disabled = false; }
}
$('#refresh-alerts').addEventListener('click', loadAlerts);
$('#download-update').addEventListener('click', async () => {
if (!latestUpdate) return;
$('#download-update').disabled = true; $('#update-error').hidden = true;
try { renderUpdate(await api(`/api/update/download?tag=${encodeURIComponent(latestUpdate)}`, { method: 'POST' })); }
catch (error) { $('#update-panel').hidden = false; $('#update-error').textContent = error.message; $('#update-error').hidden = false; updateButton(); }
});
$('#install-update').addEventListener('click', async () => {
$('#install-update').disabled = true; $('#update-error').hidden = true;
renderUpdate({ state: 'installing', tag: $('#update-tag').textContent, message: 'Requesting installation…' });
try { renderUpdate(await api('/api/update/install', { method: 'POST' })); }
catch (error) { $('#update-error').textContent = error.message + ' Rechecking installation status…'; $('#update-error').hidden = false; await loadUpdate(); }
});
setInterval(() => { if (!document.hidden && connected) loadUpdate(); }, 2000);
async function checkRelease() {
const button = $('#check-release'), bar = $('.release-bar'); button.disabled = true;
latestUpdate = null; updateButton();
$('#release-summary').textContent = 'Checking releases…'; bar.dataset.state = 'checking';
try {
{ const response = await fetch('/version.json', { cache: 'no-store', signal: AbortSignal.timeout(10000) }); if (!response.ok) throw new Error(); installed = await response.json(); }
$('#release-title').textContent = installed.release || 'Development build';
const response = await fetch(`https://api.github.com/repos/${repo}/releases?per_page=30`, { headers: { Accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(12000) });
if (!response.ok) throw new Error();
const releases = await response.json(); if (!Array.isArray(releases)) throw new Error();
const published = releases.filter(r => !r.draft && r.published_at && typeof r.tag_name === 'string');
published.sort((a, b) => { const order = compareVersions(a.tag_name, b.tag_name); return order === null ? Date.parse(b.published_at) - Date.parse(a.published_at) : -order; });
const latest = published[0];
if (!latest) { $('#release-summary').textContent = 'No published releases yet'; $('#show-notes').hidden = true; bar.dataset.state = 'unknown'; return; }
const comparison = installed.release ? compareVersions(installed.release, latest.tag_name) : null;
const assetNames = new Set((latest.assets || []).map(asset => asset.name));
const archive = `PS5_RetroArch-${latest.tag_name}.zip`;
if ((comparison === null || comparison < 0) && assetNames.has(archive) && assetNames.has(archive + '.sha256')) latestUpdate = latest.tag_name;
bar.dataset.state = comparison === null ? 'development' : comparison < 0 ? 'update' : 'current';
$('#release-summary').textContent = comparison === null ? `Latest release: ${latest.tag_name}` : comparison < 0 ? `Update available · ${latest.tag_name}` : 'You’re up to date';
$('.release-icon').innerHTML = comparison !== null && comparison >= 0 ? '<circle cx="12" cy="12" r="10"/><path d="m7 12 3 3 7-7"/>' : '<circle cx="12" cy="12" r="10"/><path d="M12 11v6 M12 7v.1"/>';
$('#notes-title').textContent = `What’s new in ${latest.tag_name}`;
$('#release-date').textContent = new Date(latest.published_at).toLocaleDateString(undefined, { dateStyle: 'long' });
$('#release-notes').textContent = latest.body || 'No release notes were provided for this version.';
$('#release-link').href = `https://github.com/${repo}/releases/tag/${encodeURIComponent(latest.tag_name)}`;
$('#show-notes').hidden = false;
} catch { bar.dataset.state = 'unknown'; $('#release-summary').textContent = 'Couldn’t check updates. Try again.'; }
finally { button.disabled = false; updateButton(); }
}
function showNotes(show) { $('#release-details').hidden = !show; $('#show-notes').setAttribute('aria-expanded', String(show)); if (show) $('#release-details').scrollIntoView({ behavior: 'smooth', block: 'start' }); }
$('#show-notes').addEventListener('click', () => showNotes($('#release-details').hidden));
$('#close-notes').addEventListener('click', () => { showNotes(false); $('#show-notes').focus(); });
$('#check-release').addEventListener('click', checkRelease);
$('#reconnect').addEventListener('click', reconnect);
$('#refresh-content').addEventListener('click', () => loadContent(currentPath));
$('#file-search').addEventListener('input', renderContent);
window.addEventListener('hashchange', navigate);
setInterval(() => { if (!document.hidden) reconnect(); }, 10000);
navigate(); drawTransfers(); reconnect(); checkRelease();