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

+476
View File
@@ -0,0 +1,476 @@
// ── PS5 Autoloader ─────────────────────────────────────────────────────────────
let _alSequence = []; // { type: 'payload'|'delay', filename?: string, ms?: number }
let _alHeader = ''; // comment block preserved from original file
const _AL_DEFAULT_HEADER =
`#
# ps5_autoloader - autoload.txt
# Edited with Chimera
# -----------------------------------------------------------------------------------------
# Usage:
# - Put one filename per line (e.g., payload.elf or script.js).
# - Supported payload types: .elf, .bin, .js
# - Lines starting with '!' are sleep commands (example: !1000 sleeps for 1000 ms).
# -----------------------------------------------------------------------------------------
`;
function _alParse(content) {
if (!content) return { header: _AL_DEFAULT_HEADER, entries: [] };
const lines = content.split(/\r?\n/);
const headerLines = [];
const entries = [];
let headerDone = false;
for (const line of lines) {
const t = line.trim();
if (!headerDone) {
if (t.startsWith('#') || t === '') { headerLines.push(line); continue; }
headerDone = true;
}
if (t === '' || t.startsWith('#')) continue;
if (t.startsWith('!')) {
const ms = parseInt(t.slice(1), 10);
if (!isNaN(ms)) entries.push({ type: 'delay', ms });
} else {
entries.push({ type: 'payload', filename: t });
}
}
const header = headerLines.join('\n').trimEnd() + '\n\n';
return { header: header || _AL_DEFAULT_HEADER, entries };
}
function _alSerialize(header, entries) {
const lines = [header.trimEnd(), ''];
for (const e of entries)
lines.push(e.type === 'delay' ? `!${e.ms}` : e.filename);
return lines.join('\n') + '\n';
}
let _alDragIdx = -1;
function _alSecsFmt(ms) {
const s = ms / 1000;
return s === Math.floor(s) ? s.toFixed(1) + 's' : s.toFixed(2) + 's';
}
function _alBuildRow(entry, i) {
const del = `<button class="al-del" data-al="remove" data-idx="${i}" title="Remove">&#10005;</button>`;
if (entry.type === 'delay') {
const capped = Math.min(entry.ms, 10000);
const presets = [250, 500, 1000, 2000, 3000, 5000].map(p => {
const lbl = p < 1000 ? `${p}ms` : `${p / 1000}s`;
const active = entry.ms === p ? ' al-preset-active' : '';
return `<button class="al-preset${active}" data-al="preset" data-idx="${i}" data-ms="${p}">${lbl}</button>`;
}).join('');
return `<div class="al-row al-delay" draggable="true" data-idx="${i}">
<span class="al-grip" title="Drag to reorder">&#8942;&#8942;</span>
<span class="al-delay-clock">&#9201;</span>
<div class="al-delay-body">
<div class="al-delay-top">
<span class="al-delay-lbl">Wait</span>
<input class="al-delay-num" type="number" min="0" max="60000" step="50"
value="${entry.ms}" data-al="delay-num" data-idx="${i}">
<span class="al-delay-ms">ms</span>
<span class="al-delay-secs" data-idx="${i}">(${_alSecsFmt(entry.ms)})</span>
<div class="al-presets">${presets}</div>
</div>
<input class="al-slider" type="range" min="0" max="10000" step="50"
value="${capped}" data-al="delay-slider" data-idx="${i}">
</div>
${del}
</div>`;
}
const ext = (entry.filename.split('.').pop() || '').toLowerCase();
const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || '';
return `<div class="al-row al-payload" draggable="true" data-idx="${i}">
<span class="al-grip" title="Drag to reorder">&#8942;&#8942;</span>
<span class="al-ext ${extClass}">${escHtml(ext || '?')}</span>
<span class="al-name" title="${escHtml(entry.filename)}">${escHtml(entry.filename)}</span>
<button class="al-ins-delay" data-al="ins-delay" data-idx="${i}" title="Insert delay after this payload">&#43;&#9201;</button>
${del}
</div>`;
}
function _alRenderSequence() {
const seq = $('autoloader-sequence');
if (!seq) return;
if (!_alSequence.length) {
seq.innerHTML = `<div class="al-empty">
<div class="al-empty-icon">&#128196;</div>
<div>No entries yet.</div>
<div class="al-empty-hint">Click a file on the left to add it to the sequence.</div>
</div>`;
return;
}
seq.innerHTML = _alSequence.map(_alBuildRow).join('');
seq.querySelectorAll('.al-row[draggable]').forEach(row => {
row.addEventListener('dragstart', e => {
_alDragIdx = parseInt(row.dataset.idx, 10);
row.classList.add('al-dragging');
e.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragend', () => {
row.classList.remove('al-dragging');
seq.querySelectorAll('.al-drag-over').forEach(r => r.classList.remove('al-drag-over'));
});
row.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
seq.querySelectorAll('.al-drag-over').forEach(r => r.classList.remove('al-drag-over'));
if (parseInt(row.dataset.idx, 10) !== _alDragIdx) row.classList.add('al-drag-over');
});
row.addEventListener('drop', e => {
e.preventDefault();
let toIdx = parseInt(row.dataset.idx, 10);
if (_alDragIdx === -1 || _alDragIdx === toIdx) return;
const [item] = _alSequence.splice(_alDragIdx, 1);
if (_alDragIdx < toIdx) toIdx--;
_alSequence.splice(toIdx, 0, item);
_alDragIdx = -1;
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
});
});
}
function _alRenderFiles(files) {
const list = $('autoloader-file-list');
if (!list) return;
const payloads = files.filter(f => !f.isDir && /\.(elf|bin|js)$/i.test(f.name));
if (!payloads.length) {
list.innerHTML = '<p class="hint autoloader-empty-hint">No .elf / .bin / .js files found in /data/ps5_autoloader.</p>';
return;
}
list.innerHTML = payloads.map(f => {
const ext = (f.name.split('.').pop() || '').toLowerCase();
const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || '';
return `<div class="al-file-card" data-al-add="${escHtml(f.name)}" title="Click to add to sequence">
<span class="al-ext ${extClass}">${escHtml(ext)}</span>
<span class="al-file-name">${escHtml(f.name)}</span>
<button class="btn-al-save-local" data-filename="${escHtml(f.name)}" title="Save to local payloads folder">&#8675;</button>
<span class="al-file-add">&#43;</span>
</div>`;
}).join('');
}
async function loadAutoloader() {
const btn = $('btn-autoloader-refresh');
const status = $('autoloader-status');
btn.disabled = true;
status.textContent = 'Loading…';
try {
const files = await window.pork.autoloaderList();
const content = await window.pork.autoloaderRead();
const { header, entries } = _alParse(content);
_alHeader = header;
_alSequence = entries;
_alRenderFiles(files);
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
status.textContent = content
? `Loaded \u2014 ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}`
: 'autoload.txt not found \u2014 sequence is empty';
} catch (e) {
status.textContent = `Error: ${e.message}`;
} finally {
btn.disabled = false;
}
}
async function saveAutoloader() {
const btn = $('btn-autoloader-save');
const status = $('autoloader-status');
btn.disabled = true;
status.textContent = 'Saving\u2026';
try {
const content = _alSerialize(_alHeader, _alSequence);
await window.pork.autoloaderSave(content);
status.textContent = 'Saved to PS5 \u2713';
} catch (e) {
status.textContent = `Error: ${e.message}`;
btn.disabled = false;
}
}
$('btn-autoloader-refresh').addEventListener('click', loadAutoloader);
$('btn-autoloader-save').addEventListener('click', saveAutoloader);
const _persistButton = $('btn-pldmgr-persist-repair');
if (_persistButton && window.pork.payloadManagerAutoloadRepairPlan) {
$('pldmgr-persistence-panel').hidden = false;
_persistButton.addEventListener('click', async () => {
const status = $('pldmgr-persistence-status');
_persistButton.disabled = true;
status.textContent = window.t('payload.persistence.preflight');
try {
const plan = await window.pork.payloadManagerAutoloadRepairPlan();
if (plan.blocked) {
throw new Error(window.tf('payload.persistence.blocked', 'Other boot entries found: {entries}. Chimera will not remove them.', { entries: plan.unsupportedEntries.join(', ') }));
}
const confirmation = await showExactConfirmation(
window.t('payload.persistence.confirm'), plan.confirmation
);
if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled'));
status.textContent = window.t('payload.persistence.applying');
const result = await window.pork.payloadManagerAutoloadRepairApply({ planDigest: plan.planDigest, confirmation });
status.textContent = window.tf('payload.persistence.success', 'Persistent v0.5.1 verified. Snapshot: {snapshot}. Reboot check required.', { snapshot: result.snapshotId });
await loadAutoloader();
} catch (error) {
status.textContent = window.tf('payload.persistence.error', 'Persistence repair failed: {message}', { message: error.message });
} finally {
_persistButton.disabled = false;
}
});
}
const _rollbackButton = $('btn-pldmgr-persist-rollback');
if (_rollbackButton && window.pork.payloadManagerAutoloadRollbackPlan) {
_rollbackButton.addEventListener('click', async () => {
const status = $('pldmgr-persistence-status');
_rollbackButton.disabled = true;
status.textContent = window.t('payload.persistence.rollbackPreflight');
try {
const plan = await window.pork.payloadManagerAutoloadRollbackPlan();
if (plan.alreadyRestored) return void (status.textContent = window.t('payload.persistence.cancelled'));
const confirmation = await showExactConfirmation(window.t('payload.persistence.rollbackConfirm'), plan.confirmation);
if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled'));
status.textContent = window.t('payload.persistence.rollbackApplying');
const result = await window.pork.payloadManagerAutoloadRollbackApply({ planDigest: plan.planDigest, confirmation });
status.textContent = window.tf('payload.persistence.rollbackSuccess', 'Original boot state restored. Safety snapshot: {snapshot}. Reboot required.', { snapshot: result.safetySnapshotId });
await loadAutoloader();
} catch (error) {
status.textContent = window.tf('payload.persistence.error', 'Persistence repair failed: {message}', { message: error.message });
} finally { _rollbackButton.disabled = false; }
});
}
function _etaHenPersistenceAction({ buttonId, plan, apply, preflight, confirm, success, snapshot = 'snapshotId' }) {
const button = $(buttonId);
if (!button || !window.pork[plan]) return;
button.addEventListener('click', async () => {
const status = $('etahen-persistence-status');
button.disabled = true;
status.textContent = window.t(preflight);
try {
const packet = await window.pork[plan]();
const entries = [...(packet.unexpectedFiles || []), ...(packet.unsupportedEntries || [])];
if (packet.blocked) throw new Error(window.tf('payload.etahenPersistence.blocked', 'Other boot content found: {entries}.', { entries: entries.join(', ') }));
if (packet.alreadyRestored) return void (status.textContent = window.t('payload.persistence.cancelled'));
const confirmation = await showExactConfirmation(window.t(confirm), packet.confirmation);
if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled'));
const result = await window.pork[apply]({ planDigest: packet.planDigest, confirmation });
status.textContent = window.tf(success, 'etaHEN 2.6B boot state updated. Snapshot: {snapshot}. Reboot required.', { snapshot: result[snapshot] });
await loadAutoloader();
} catch (error) { status.textContent = window.tf('payload.etahenPersistence.error', 'etaHEN persistence failed: {message}', { message: error.message }); }
finally { button.disabled = false; }
});
}
_etaHenPersistenceAction({ buttonId: 'btn-etahen-persist', plan: 'etaHenAutoloadPersistencePlan', apply: 'etaHenAutoloadPersistenceApply', preflight: 'payload.etahenPersistence.preflight', confirm: 'payload.etahenPersistence.confirm', success: 'payload.etahenPersistence.success' });
_etaHenPersistenceAction({ buttonId: 'btn-etahen-persist-rollback', plan: 'etaHenAutoloadPersistenceRollbackPlan', apply: 'etaHenAutoloadPersistenceRollback', preflight: 'payload.etahenPersistence.rollbackPreflight', confirm: 'payload.etahenPersistence.rollbackConfirm', success: 'payload.etahenPersistence.rollbackSuccess', snapshot: 'safetySnapshotId' });
function _fixedPayloadStart({ buttonId, api, exact, confirmKey, startingKey, startedKey }) {
const button = $(buttonId);
if (!button || !window.pork[api]) return;
button.addEventListener('click', async () => {
const status = $('etahen-persistence-status');
const confirmation = await showExactConfirmation(window.t(confirmKey), exact);
if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled'));
button.disabled = true;
status.textContent = window.t(startingKey);
try {
const result = await window.pork[api]({ confirmation });
status.textContent = window.tf(startedKey, '', { bytes: result.bytes });
} catch (error) { status.textContent = window.tf('payload.etahenPersistence.error', '', { message: error.message }); }
finally { button.disabled = false; }
});
}
_fixedPayloadStart({ buttonId: 'btn-etahen-direct-start', api: 'etaHenDirectStart', exact: 'START DIRECT ETAHEN 2.6B', confirmKey: 'payload.etahenPersistence.startConfirm', startingKey: 'payload.etahenPersistence.starting', startedKey: 'payload.etahenPersistence.started' });
_fixedPayloadStart({ buttonId: 'btn-pldmgr-verified-start', api: 'payloadManagerVerifiedStart', exact: 'START VERIFIED PS5 PAYLOAD MANAGER 0.5.1', confirmKey: 'payload.persistence.startConfirm', startingKey: 'payload.persistence.starting', startedKey: 'payload.persistence.started' });
$('btn-autoloader-add-delay').addEventListener('click', () => {
_alSequence.push({ type: 'delay', ms: 1000 });
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
});
$('autoloader-sequence').addEventListener('click', e => {
const btn = e.target.closest('[data-al]');
if (!btn) return;
const action = btn.dataset.al;
const idx = parseInt(btn.dataset.idx, 10);
if (action === 'remove') {
_alSequence.splice(idx, 1);
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
} else if (action === 'preset') {
const ms = parseInt(btn.dataset.ms, 10);
_alSequence[idx].ms = ms;
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
} else if (action === 'ins-delay') {
_alSequence.splice(idx + 1, 0, { type: 'delay', ms: 1000 });
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
}
});
$('autoloader-sequence').addEventListener('input', e => {
const el = e.target;
const al = el.dataset.al;
if (!al) return;
const idx = parseInt(el.dataset.idx, 10);
if (al === 'delay-num' || al === 'delay-slider') {
const ms = Math.max(0, parseInt(el.value, 10) || 0);
_alSequence[idx].ms = ms;
const row = el.closest('.al-row');
const num = row.querySelector('[data-al="delay-num"]');
const slider = row.querySelector('[data-al="delay-slider"]');
const secs = row.querySelector('.al-delay-secs');
if (num) num.value = ms;
if (slider) slider.value = Math.min(ms, 10000);
if (secs) secs.textContent = `(${_alSecsFmt(ms)})`;
row.querySelectorAll('.al-preset').forEach(p =>
p.classList.toggle('al-preset-active', parseInt(p.dataset.ms, 10) === ms)
);
$('btn-autoloader-save').disabled = false;
}
});
// ── Autoloader Snapshots ─────────────────────────────────────────────────────────────────────────────
async function renderAutoloaderSnapshots() {
const list = $('snapshot-list');
if (!list) return;
list.innerHTML = '<p class="hint autoloader-empty-hint">Loading…</p>';
try {
const snaps = await window.pork.snapshotList();
if (!snaps.length) {
list.innerHTML = '<p class="hint autoloader-empty-hint">No snapshots yet. Take one above.</p>';
return;
}
list.innerHTML = snaps.map(s => {
const d = new Date(s.createdAt);
const dateStr = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const fc = (s.files || []).length;
const alStr = s.hasAutoload ? ' + autoload.txt' : '';
return `<div class="snapshot-card">
<div class="snapshot-card-info">
<span class="snapshot-card-label">${escHtml(s.label || 'Snapshot')}</span>
<span class="snapshot-card-meta">${dateStr} \u2014 ${fc} payload${fc !== 1 ? 's' : ''}${alStr}</span>
</div>
<div class="snapshot-card-actions">
<button class="btn btn-teal btn-sm btn-snap-restore" data-snap-id="${escHtml(s.id)}">&uarr; Restore</button>
<button class="btn btn-sm btn-snap-export" data-snap-id="${escHtml(s.id)}" title="Export snapshot to .porksnap file">&#8600; Export</button>
<button class="btn btn-sm btn-snap-delete" data-snap-id="${escHtml(s.id)}" title="Delete snapshot">&#10005;</button>
</div>
</div>`;
}).join('');
list.querySelectorAll('.btn-snap-restore').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.snapId;
const confirmation = await showExactConfirmation(
'Restore this snapshot to your PS5?\n\nAll payload files and autoload.txt will be uploaded back to /data/ps5_autoloader, overwriting whatever is currently there.',
'RESTORE AUTOLOADER'
);
if (!confirmation) return;
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '⋯ Uploading…';
try {
const res = await window.pork.snapshotRestore(id, confirmation);
btn.textContent = '✓ Restored';
const errNote = res.errors?.length ? ` (${res.errors.length} file${res.errors.length !== 1 ? 's' : ''} failed)` : '';
showToast(`Snapshot restored ✓ — ${res.fileCount} file${res.fileCount !== 1 ? 's' : ''} uploaded to PS5${errNote}`, res.errors?.length ? 'info' : 'ok');
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 3000);
} catch (er) {
btn.textContent = orig;
btn.disabled = false;
showToast('Restore failed: ' + er.message, 'error');
}
});
});
list.querySelectorAll('.btn-snap-export').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.snapId;
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '⋯';
try {
const res = await window.pork.snapshotExport(id);
if (res?.canceled) { btn.textContent = orig; btn.disabled = false; return; }
btn.textContent = '✓ Exported';
showToast('Snapshot exported ✓', 'ok');
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 2500);
} catch (er) {
btn.textContent = orig;
btn.disabled = false;
showToast('Export failed: ' + er.message, 'error');
}
});
});
list.querySelectorAll('.btn-snap-delete').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.snapId;
const confirmation = await showExactConfirmation('Delete this snapshot? This cannot be undone.', 'VERWIJDER');
if (!confirmation) return;
try {
await window.pork.snapshotDelete(id, confirmation);
await renderAutoloaderSnapshots();
} catch (er) {
showToast('Delete failed: ' + er.message, 'error');
}
});
});
} catch (e) {
list.innerHTML = `<p class="hint autoloader-empty-hint" style="color:var(--red)">Error: ${escHtml(e.message)}</p>`;
}
}
$('btn-snapshot-import').addEventListener('click', async () => {
const btn = $('btn-snapshot-import');
const status = $('snapshot-take-status');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '⋯ Importing…';
status.textContent = '';
try {
let res = await window.pork.snapshotImport();
if (res?.canceled) { btn.textContent = orig; btn.disabled = false; return; }
if (res?.needsLabel) {
const label = await showPrompt('Enter a label for this snapshot:', 'Imported ZIP');
if (label === null) { btn.textContent = orig; btn.disabled = false; return; } // user cancelled
res = await window.pork.snapshotImport({ label: label.trim() || 'Imported ZIP', filePath: res.filePath });
if (res?.canceled) { btn.textContent = orig; btn.disabled = false; return; }
}
status.textContent = `Imported ✓ — "${res.meta.label}"`;
await renderAutoloaderSnapshots();
} catch (e) {
status.textContent = 'Import failed: ' + e.message;
} finally {
btn.textContent = orig;
btn.disabled = false;
}
});
$('btn-snapshot-take').addEventListener('click', async () => {
const btn = $('btn-snapshot-take');
const status = $('snapshot-take-status');
const label = $('snapshot-label').value.trim() || 'Snapshot';
btn.disabled = true;
status.textContent = 'Downloading from PS5\u2026';
try {
const { meta } = await window.pork.snapshotTake(label);
const fc = meta.files.length;
const alStr = meta.hasAutoload ? ' + autoload.txt' : '';
status.textContent = `Saved \u2713 \u2014 ${fc} payload${fc !== 1 ? 's' : ''}${alStr}`;
$('snapshot-label').value = '';
await renderAutoloaderSnapshots();
} catch (e) {
status.textContent = 'Error: ' + e.message;
} finally {
btn.disabled = false;
}
});
+824
View File
@@ -0,0 +1,824 @@
// ── Auto-Backpork Generator — Renderer Module ─────────────────────────────────
// Note: $ is declared in state.js and shared across all non-module scripts.
let _sdkPairs = [];
let _gameList = []; // current candidate list shown in picker
let _selectedGames = new Set(); // game_ids explicitly selected
let _running = false;
let _bpgenProgressState = { startedAt: 0, currentGameIndex: 0, totalGames: 0 };
let _knownFirmwareLabels = [];
let _bpgenSettingsHydrated = false;
// ── Fakelib Manager state ─────────────────────────────────────────────────────
let _flBaseDir = '';
let _flPairs = []; // Array<{ pair, dir, libCount, hasLibs }>
let _flSelectedPair = null; // which pair the user wants to import for
let _flExtracting = false;
function getSdkPairValue() {
const raw = $('bpgen-sdk-pair')?.value;
if (!raw) return null;
if (raw === 'all') return 'all';
const num = Number(raw);
return Number.isFinite(num) ? num : null;
}
function getActiveFakelibDir() {
const sdkPair = getSdkPairValue();
if (!sdkPair || sdkPair === 'all') return '';
const pair = _flPairs.find(p => p.pair === sdkPair);
return pair?.hasLibs ? pair.dir : '';
}
function getPairStatus(pairNumber) {
return _flPairs.find(p => p.pair === pairNumber) || null;
}
function getPreferredFirmwareLabel(pairNumber) {
return `${pairNumber}.xx`;
}
async function saveBpgenSettings(patch = {}) {
const payload = {
bpgenOutputDir: $('bpgen-output-dir')?.value.trim() || '',
bpgenFakelibBaseDir: $('bpgen-fl-basedir')?.value.trim() || '',
bpgenFakelibDir: $('bpgen-fakelib-dir')?.value.trim() || '',
bpgenSdkPair: $('bpgen-sdk-pair')?.value || '',
bpgenFirmwareLabel: $('bpgen-fw-label')?.value.trim() || '',
...patch,
};
try {
await window.pork.setSettings(payload);
} catch (e) {
console.error('[bpgen] save settings error', e);
}
}
function restoreBpgenSettings(settings = {}) {
if (!settings || typeof settings !== 'object') return;
if (settings.bpgenOutputDir) $('bpgen-output-dir').value = settings.bpgenOutputDir;
if (settings.bpgenFakelibBaseDir) {
_flBaseDir = settings.bpgenFakelibBaseDir;
$('bpgen-fl-basedir').value = settings.bpgenFakelibBaseDir;
}
if (settings.bpgenFakelibDir) {
const input = $('bpgen-fakelib-dir');
input.value = settings.bpgenFakelibDir;
input.dataset.autoDir = '';
}
if (settings.bpgenSdkPair && [...$('bpgen-sdk-pair').options].some(o => o.value === String(settings.bpgenSdkPair))) {
$('bpgen-sdk-pair').value = String(settings.bpgenSdkPair);
}
if (settings.bpgenFirmwareLabel) {
const input = $('bpgen-fw-label');
input.value = settings.bpgenFirmwareLabel;
input.dataset.autoValue = settings.bpgenFirmwareLabel;
}
}
function syncFirmwareLabel(force = false) {
const input = $('bpgen-fw-label');
const sdkPair = getSdkPairValue();
if (!input || !sdkPair) return;
if (sdkPair === 'all') {
input.dataset.autoValue = 'All';
if (force || !input.value.trim() || input.value === input.dataset.autoValue) input.value = 'All';
return;
}
const autoLabel = getPreferredFirmwareLabel(sdkPair);
const current = input.value.trim();
const shouldReplace = force || !current || current === input.dataset.autoValue;
input.dataset.autoValue = autoLabel;
if (shouldReplace) {
input.value = autoLabel;
if (_bpgenSettingsHydrated) saveBpgenSettings({ bpgenFirmwareLabel: autoLabel });
}
}
function autoSelectSdkPairFromFakelibs(force = false) {
const available = _flPairs.filter(p => p.hasLibs).sort((a, b) => a.pair - b.pair);
if (!available.length) return;
const currentPair = getSdkPairValue();
if (currentPair === 'all') return;
const currentReady = available.some(p => p.pair === currentPair);
if (!force && currentReady) return;
$('bpgen-sdk-pair').value = String(available[0].pair);
if (_bpgenSettingsHydrated) saveBpgenSettings({ bpgenSdkPair: String(available[0].pair) });
syncFakelibInput(true);
syncFirmwareLabel(true);
}
function syncFakelibInput(force = false) {
const input = $('bpgen-fakelib-dir');
if (!input) return;
const current = input.value.trim();
const autoDir = getActiveFakelibDir();
const shouldReplace = force || !current || current === input.dataset.autoDir;
input.dataset.autoDir = autoDir || '';
if (shouldReplace) {
input.value = autoDir || '';
if (_bpgenSettingsHydrated) saveBpgenSettings({ bpgenFakelibDir: autoDir || '' });
}
}
function resetBpgenActionButtons() {
[
'bpgen-btn-output',
'bpgen-btn-fakelib',
'bpgen-btn-run',
'bpgen-btn-cancel',
'bpgen-select-all',
'bpgen-select-none',
'bpgen-fl-btn-base',
'bpgen-fl-btn-pick-pup',
'bpgen-fl-btn-extract',
].forEach(id => {
const el = $(id);
if (!el || !el.parentNode) return;
const clean = el.cloneNode(true);
clean.removeAttribute('onclick');
clean.dataset.boundInBackporks = '';
el.parentNode.replaceChild(clean, el);
});
}
// ── Init ─────────────────────────────────────────────────────────────────────
async function initBackporkGen() {
try {
const [sdkPairs, knownFirmwareLabels, savedSettings] = await Promise.all([
window.pork.bpgenSdkPairs(),
window.pork.firmwareLabels().catch(() => []),
window.pork.getSettings().catch(() => ({})),
]);
_sdkPairs = sdkPairs;
_knownFirmwareLabels = knownFirmwareLabels;
renderSdkPairDropdown();
restoreBpgenSettings(savedSettings);
_bpgenSettingsHydrated = true;
syncFakelibInput(true);
syncFirmwareLabel(true);
} catch (e) {
console.error('[bpgen] init error', e);
}
// Selection mode toggle
$('bpgen-mode').addEventListener('change', () => updateGamePickerVisibility());
$('bpgen-sdk-pair').addEventListener('change', () => {
saveBpgenSettings({ bpgenSdkPair: $('bpgen-sdk-pair').value });
syncFakelibInput();
syncFirmwareLabel();
});
$('bpgen-output-dir').addEventListener('change', () => saveBpgenSettings());
$('bpgen-fakelib-dir').addEventListener('change', () => saveBpgenSettings());
$('bpgen-fl-basedir').addEventListener('change', () => saveBpgenSettings());
// Firmware filter change → refresh game list
$('bpgen-fw-filter').addEventListener('change', () => refreshGameList());
// Buttons
$('bpgen-btn-output').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const p = await window.pork.selectFolder();
if (p) {
$('bpgen-output-dir').value = p;
saveBpgenSettings({ bpgenOutputDir: p });
}
} catch (err) {
bpgenSetStatus(`Output folder browse failed: ${err.message}`, 'error');
}
});
$('bpgen-btn-fakelib').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const p = await window.pork.selectFolder();
if (p) {
const input = $('bpgen-fakelib-dir');
input.value = p;
input.dataset.autoDir = '';
saveBpgenSettings({ bpgenFakelibDir: p });
}
} catch (err) {
bpgenSetStatus(`Fakelib folder browse failed: ${err.message}`, 'error');
}
});
$('bpgen-btn-run').addEventListener('click', () => runBackporkGen());
$('bpgen-btn-cancel').addEventListener('click', () => {
window.pork.bpgenCancel();
appendLog('Cancelling…');
});
// Select all / none in game picker
$('bpgen-select-all').addEventListener('click', () => toggleAllGames(true));
$('bpgen-select-none').addEventListener('click', () => toggleAllGames(false));
// Listen for progress events
window.pork.on('bpgen:progress', handleProgress);
// ── Fakelib Manager ────────────────────────────────────────────────────────
initFakelibManager();
resetBpgenActionButtons();
}
// ── SDK Pair Dropdown ─────────────────────────────────────────────────────────
function renderSdkPairDropdown() {
const sel = $('bpgen-sdk-pair');
const current = sel.value;
sel.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = 'all';
allOpt.textContent = 'All SDK Targets';
sel.appendChild(allOpt);
for (const p of _sdkPairs) {
const opt = document.createElement('option');
opt.value = p.pair;
const pairStatus = getPairStatus(p.pair);
const suffix = pairStatus?.hasLibs ? ` [libs ready: ${pairStatus.libCount}]` : '';
opt.textContent = `SDK Pair ${p.pair} — PS5: ${p.ps5Ver} / PS4: ${p.ps4Ver}${suffix}`;
sel.appendChild(opt);
}
if (current && [...sel.options].some(o => o.value === current)) sel.value = current;
}
// ── Game Picker ───────────────────────────────────────────────────────────────
function updateGamePickerVisibility() {
const mode = $('bpgen-mode').value;
$('bpgen-game-picker').hidden = (mode !== 'specific');
$('bpgen-fw-filter-row').hidden = (mode !== 'fw-missing');
if (mode === 'specific') refreshGameList();
}
async function refreshGameList() {
const mode = $('bpgen-mode').value;
const fw = $('bpgen-fw-filter').value.trim();
try {
if (mode === 'specific' || mode === 'fw-missing') {
$('bpgen-game-list').innerHTML = '<div class="bpgen-loading">Loading…</div>';
const games = mode === 'specific'
? await window.pork.bpgenListAll()
: await window.pork.bpgenListMissing(fw);
_gameList = games;
renderGamePicker(games);
}
} catch (e) {
console.error('[bpgen] list error', e);
}
}
function renderGamePicker(games) {
const el = $('bpgen-game-list');
if (!games.length) {
el.innerHTML = '<div style="color:var(--text-dim);font-size:12px;padding:6px">No games found</div>';
return;
}
el.innerHTML = games.map(g => {
const title = escHtml(g.prospero_name || g.title || g.game_id);
const checked = _selectedGames.has(g.game_id) ? 'checked' : '';
return `<label class="bpgen-game-row">
<input type="checkbox" data-game-id="${escHtml(g.game_id)}" ${checked}/>
<span class="bpgen-game-title">${title}</span>
<span class="bpgen-game-id">${escHtml(g.game_id)}</span>
</label>`;
}).join('');
el.querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', e => {
const id = e.target.dataset.gameId;
if (e.target.checked) _selectedGames.add(id);
else _selectedGames.delete(id);
});
});
}
function toggleAllGames(select) {
_selectedGames = select ? new Set(_gameList.map(g => g.game_id)) : new Set();
$('bpgen-game-list').querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.checked = select;
});
}
// ── Run ───────────────────────────────────────────────────────────────────────
async function runBackporkGen() {
const mode = $('bpgen-mode').value;
const sdkPair = getSdkPairValue();
const outputDir = $('bpgen-output-dir').value.trim();
const firmwareLabel = $('bpgen-fw-label').value.trim();
const fakelibDir = $('bpgen-fakelib-dir').value.trim() || null;
const overwrite = $('bpgen-overwrite').checked;
if (!outputDir) { bpgenSetStatus('Select an output folder first.', 'error'); return; }
if (sdkPair !== 'all' && !firmwareLabel) { bpgenSetStatus('Enter a firmware label (e.g. 4.xx).', 'error'); return; }
if (mode === 'fw-missing' && !$('bpgen-fw-filter').value.trim()) {
bpgenSetStatus('Enter the firmware label to check for missing backporks.', 'error');
return;
}
let gameIds = [];
let filterFw = '';
if (mode === 'specific') {
gameIds = [..._selectedGames];
if (!gameIds.length) { bpgenSetStatus('Select at least one game.', 'error'); return; }
} else if (mode === 'fw-missing') {
filterFw = $('bpgen-fw-filter').value.trim();
}
// mode === 'all-missing' → gameIds empty, filterFw empty → default all-missing logic
_running = true;
$('bpgen-btn-run').disabled = true;
$('bpgen-btn-cancel').disabled = false;
$('bpgen-log').innerHTML = '';
$('bpgen-progress-wrap').hidden = false;
_bpgenProgressState = { startedAt: Date.now(), currentGameIndex: 0, totalGames: 0 };
setProgress(0, 'Starting…');
try {
const result = await window.pork.bpgenRun({
gameIds, sdkPair, outputDir, firmwareLabel, fakelibDir, filterFw, overwrite,
});
bpgenSetStatus(`Done — ${result.ok} file(s) processed, ${result.failed} failed`, result.failed ? 'warn' : 'ok');
} catch (e) {
bpgenSetStatus(`Error: ${e.message}`, 'error');
} finally {
_running = false;
$('bpgen-btn-run').disabled = false;
$('bpgen-btn-cancel').disabled = true;
}
}
// ── Progress handling ─────────────────────────────────────────────────────────
function handleProgress({ type, ...data }) {
switch (type) {
case 'start': {
_bpgenProgressState.totalRuns = data.total || 0;
_bpgenProgressState.totalGames = data.games || 0;
_bpgenProgressState.totalPairs = data.pairs || 1;
_bpgenProgressState.currentGameIndex = 0;
const pairsLabel = data.pairs > 1 ? ` × ${data.pairs} SDK pairs = ${data.total} total operations` : '';
setProgress(0, `Processing ${data.games} game(s)${pairsLabel}…`);
appendLog(`Starting: ${data.games} game(s)${pairsLabel}`);
if (data.noLocalFolder > 0) {
appendLog(` ⚠ ${data.noLocalFolder} game(s) skipped — no local folder recorded in DB`);
}
break;
}
case 'run-config':
appendLog(`Target firmware: ${data.firmwareLabel}`);
appendLog(`SDK pair: ${data.sdkPair}`);
appendLog(`Output: ${data.outputDir}`);
appendLog(`Fakelib: ${data.fakelibDir || 'none'}`);
appendLog(`Overwrite existing output: ${data.overwrite ? 'yes' : 'no'}`);
break;
case 'pair-start': {
const pct = Math.round(((data.pairIndex - 1) / (data.totalPairs || 1)) * 100);
const pairLabel = `pair ${data.pairIndex}/${data.totalPairs}`;
const gamesNote = data.gamesInPair === 0 ? ' — all games already done, skipping' : ` — ${data.gamesInPair} game(s) to process`;
// Update progress bar with pair info so the counter always advances visibly
_bpgenProgressState.currentGameLabel = `[${pairLabel}] SDK ${data.sdkPair} (FW ${data.firmwareLabel})`;
setProgress(pct, `${_bpgenProgressState.currentGameLabel} · ${formatElapsed()}`);
appendLog(`\u27A4 ${pairLabel}: SDK ${data.sdkPair}${gamesNote}`);
break;
}
case 'game-start': {
_bpgenProgressState.currentGameIndex = data.index || 0;
// Use per-pair counters when available (all-pairs mode), else fall back to overall index
const gi = data.pairGameIndex || data.index;
const gt = data.pairTotal || _bpgenProgressState.totalGames;
const pct = Math.round(((data.index - 1) / (data.total || 1)) * 100);
// Always show pair counter so single-pair runs show "pair 1/1" and all-pairs show "pair 3/10"
const pairLabel = data.totalPairs >= 1 ? ` · pair ${data.pairIndex}/${data.totalPairs}` : '';
// Store sticky label so file-progress never overwrites it with a file path
_bpgenProgressState.currentGameLabel = `[${gi}/${gt}${pairLabel}] ${data.title || data.gameId}`;
setProgress(pct, `${_bpgenProgressState.currentGameLabel} · ${formatElapsed()}`);
appendLog(`\u25B6 [${gi}/${gt}${pairLabel}] ${data.gameId} — ${data.title || ''} (SDK ${data.sdkPair}, FW ${data.firmwareLabel})`);
break;
}
case 'file-progress':
handleFileProgress(data);
break;
case 'game-done':
appendLog(` \u2713 OK (${data.ok} files processed)`);
break;
case 'game-skip':
appendLog(` \u23ED Skipped: ${data.reason}`);
break;
case 'game-error':
appendLog(` \u2717 Error: ${data.msg}`, 'error');
break;
case 'done':
setProgress(100, `Complete — ${data.ok} files OK, ${data.failed} failed`);
appendLog('');
appendLog(`Complete: total=${data.total} ok=${data.ok} failed=${data.failed} skipped=${data.skipped}`);
appendLog(`Elapsed: ${formatElapsed()}`);
// Trigger a backpork rescan so the UI updates
window.pork.backporksScanAll().catch(() => {});
break;
case 'cancelled':
setProgress(0, 'Cancelled');
appendLog(`Cancelled after ${data.processed} game(s)`);
break;
}
}
function handleFileProgress(data) {
if (typeof data.fileIndex === 'number' && typeof data.totalFiles === 'number' && _bpgenProgressState.totalRuns > 0) {
const currentGameZeroBased = Math.max(0, (_bpgenProgressState.currentGameIndex || 1) - 1);
const perGameFraction = data.totalFiles > 0 ? (data.fileIndex / data.totalFiles) : 0;
const pct = Math.round(((currentGameZeroBased + perGameFraction) / _bpgenProgressState.totalRuns) * 100);
// Always show game name + pair info — never the file path
const label = _bpgenProgressState.currentGameLabel || `[${_bpgenProgressState.currentGameIndex}/${_bpgenProgressState.totalGames}]`;
setProgress(Math.min(99, Math.max(0, pct)), `${label} · ${formatElapsed()}`);
}
switch (data.step) {
case 'scan':
appendLog(` ⟳ ${data.msg}`);
break;
case 'inventory':
appendLog(` Found ${data.executableFiles} ELF/SELF file(s) to process`);
break;
case 'copy':
appendLog(` → Copy ${data.file}`);
break;
case 'process':
appendLog(` • ${data.file}`);
break;
case 'decrypt':
case 'patch':
case 'sign':
appendLog(` ${data.step}: ${data.file} — ${data.msg}`);
break;
case 'skip':
appendLog(` ↷ ${data.file}: ${data.msg}`);
break;
case 'warn':
appendLog(` ! ${data.file || 'step'}: ${data.msg}`);
break;
case 'error':
appendLog(` \u2717 ${data.file}: ${data.msg}`, 'error');
break;
case 'libc':
appendLog(` libc: ${data.msg}${typeof data.totalFiles === 'number' ? ` (${data.totalFiles} file(s))` : ''}`);
break;
case 'libc-progress':
appendLog(` libc [${data.fileIndex}/${data.totalFiles}]: ${data.file} — ${data.msg}`);
break;
case 'fakelib':
appendLog(` fakelib: ${data.msg}`);
break;
case 'fakelib-progress':
appendLog(` fakelib [${data.fileIndex}/${data.totalFiles}]: ${data.file}`);
break;
}
}
// ── UI helpers ────────────────────────────────────────────────────────────────
function bpgenSetStatus(msg, type = '') {
const el = $('bpgen-status');
el.textContent = msg;
el.className = 'hint bpgen-status ' + (type === 'error' ? 'status-error' : type === 'ok' ? 'status-ok' : type === 'warn' ? 'status-warn' : '');
}
function setProgress(pct, label) {
$('bpgen-progress-bar').style.width = `${pct}%`;
$('bpgen-progress-label').textContent = label;
}
function appendLog(line, type = '') {
const el = $('bpgen-log');
const row = document.createElement('div');
row.className = 'bpgen-log-row' + (type === 'error' ? ' bpgen-log-error' : '');
row.textContent = line;
el.appendChild(row);
while (el.childNodes.length > 800) el.removeChild(el.firstChild);
el.scrollTop = el.scrollHeight;
}
function escHtml(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Fakelib Manager ───────────────────────────────────────────────────────────
function initFakelibManager() {
// Base dir picker
$('bpgen-fl-btn-base').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const dir = await window.pork.selectFolder();
if (!dir) return;
const result = await window.pork.bpgenFakelibSetBasedir(dir);
if (!result) return;
_flBaseDir = result.baseDir;
_flPairs = result.pairs;
$('bpgen-fl-basedir').value = _flBaseDir;
saveBpgenSettings({ bpgenFakelibBaseDir: _flBaseDir });
renderSdkPairDropdown();
renderFlGrid();
updateFlSummaryBadge();
autoSelectSdkPairFromFakelibs(true);
syncFirmwareLabel(true);
syncFakelibInput(true);
} catch (err) {
flSetStatus(`Base directory browse failed: ${err.message}`, 'error');
}
});
// Pick PUP file
$('bpgen-fl-btn-pick-pup').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const p = await window.pork.bpgenFakelibPickPup();
if (p) $('bpgen-fl-pup-path').value = p;
} catch (err) {
flSetStatus(`PUP browse failed: ${err.message}`, 'error');
}
});
// Extract button
$('bpgen-fl-btn-extract').addEventListener('click', () => runFlExtract());
// Progress events from main process
window.pork.on('bpgen:fakelib-progress', handleFlProgress);
// Load saved state
window.pork.bpgenFakelibScan().then(({ baseDir, pairs }) => {
if (!baseDir) return;
_flBaseDir = baseDir;
_flPairs = pairs;
$('bpgen-fl-basedir').value = _flBaseDir;
renderSdkPairDropdown();
renderFlGrid();
updateFlSummaryBadge();
autoSelectSdkPairFromFakelibs();
syncFakelibInput(true);
syncFirmwareLabel(true);
}).catch(() => {});
}
function renderFlGrid() {
const grid = $('bpgen-fl-grid');
if (!_flBaseDir || !_flPairs.length) {
_flSelectedPair = null;
grid.hidden = true;
$('bpgen-fl-import-row').hidden = true;
renderSdkPairDropdown();
return;
}
grid.hidden = false;
grid.innerHTML = _flPairs.map(p => {
const cls = p.hasLibs ? 'bpgen-fl-pair-ok' : 'bpgen-fl-pair-missing';
const icon = p.hasLibs ? '&#10003;' : '&#43;';
const label = p.hasLibs ? `${p.libCount} lib${p.libCount !== 1 ? 's' : ''}` : 'No libs';
return `<button class="bpgen-fl-pair ${cls}" data-pair="${p.pair}" title="${escHtml(p.dir)}">
<span class="bpgen-fl-pair-num">${p.pair}</span>
<span class="bpgen-fl-pair-icon">${icon}</span>
<span class="bpgen-fl-pair-label">${label}</span>
</button>`;
}).join('');
grid.querySelectorAll('.bpgen-fl-pair').forEach(btn => {
btn.addEventListener('click', () => {
_flSelectedPair = Number(btn.dataset.pair);
grid.querySelectorAll('.bpgen-fl-pair').forEach(b => b.classList.remove('bpgen-fl-pair-active'));
btn.classList.add('bpgen-fl-pair-active');
$('bpgen-fl-import-pair-label').textContent = _flSelectedPair;
$('bpgen-fl-import-row').hidden = false;
$('bpgen-sdk-pair').value = String(_flSelectedPair);
syncFakelibInput(true);
syncFirmwareLabel(true);
});
});
}
function updateFlSummaryBadge() {
const badge = $('bpgen-fl-summary-status');
if (!_flPairs.length) { badge.textContent = ''; return; }
const ok = _flPairs.filter(p => p.hasLibs).length;
badge.textContent = `${ok}/10 pairs ready`;
badge.className = 'bpgen-fl-badge ' + (ok > 0 ? 'bpgen-fl-badge-ok' : 'bpgen-fl-badge-warn');
}
async function runFlExtract() {
if (_flExtracting) return;
const pupPath = $('bpgen-fl-pup-path').value.trim();
if (!pupPath) { flSetStatus('Select a .PUP.dec file first.'); return; }
if (!_flSelectedPair) { flSetStatus('Select an SDK pair above first.'); return; }
if (!_flBaseDir) { flSetStatus('Set a Fakelib Base Directory first.'); return; }
_flExtracting = true;
$('bpgen-fl-btn-extract').disabled = true;
$('bpgen-fl-progress-wrap').hidden = false;
flSetProgress(0, 'Starting…');
try {
const result = await window.pork.bpgenFakelibExtract({
pupPath,
sdkPair: _flSelectedPair,
});
flSetStatus(`Done — ${result.libCount} libraries extracted to pair ${_flSelectedPair}`, 'ok');
// Refresh grid
const scan = await window.pork.bpgenFakelibScan();
_flPairs = scan.pairs;
renderSdkPairDropdown();
renderFlGrid();
updateFlSummaryBadge();
autoSelectSdkPairFromFakelibs(true);
syncFakelibInput(true);
syncFirmwareLabel(true);
// Re-select same pair to keep import row visible
const btn = $('bpgen-fl-grid')?.querySelector(`[data-pair="${_flSelectedPair}"]`);
if (btn) { btn.classList.add('bpgen-fl-pair-active'); $('bpgen-fl-import-row').hidden = false; }
} catch (e) {
flSetProgress(0, '');
flSetStatus(`Error: ${e.message}`, 'error');
} finally {
_flExtracting = false;
$('bpgen-fl-btn-extract').disabled = false;
}
}
function handleFlProgress({ type, ...data }) {
switch (type) {
case 'start':
flSetProgress(0, `Preparing extraction for pair ${data.sdkPair}…`);
break;
case 'tool-progress':
flSetProgress(data.pct, data.msg);
break;
case 'tool-manual':
flSetStatus(`Manual download required — ${data.msg}`, 'error');
break;
case 'extract-progress':
flSetProgress(data.pct, data.msg);
break;
case 'done':
flSetProgress(100, `Complete — ${data.libCount} libraries in pair ${data.sdkPair}`);
break;
case 'error':
flSetProgress(0, '');
flSetStatus(`Error: ${data.msg}`, 'error');
break;
}
}
function flSetProgress(pct, label) {
$('bpgen-fl-progress-bar').style.width = `${pct}%`;
$('bpgen-fl-progress-label').textContent = label;
}
function flSetStatus(msg, type = '') {
// Reuse bpgen-status element for simplicity, prefixed with [FL]
const el = $('bpgen-status');
el.textContent = msg ? `[Fakelib] ${msg}` : '';
el.className = 'hint bpgen-status ' +
(type === 'error' ? 'status-error' : type === 'ok' ? 'status-ok' : type === 'warn' ? 'status-warn' : '');
}
function formatElapsed() {
if (!_bpgenProgressState.startedAt) return '0s';
const totalSeconds = Math.max(0, Math.floor((Date.now() - _bpgenProgressState.startedAt) / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
}
async function browseBpgenOutputFolder() {
try {
const p = await window.pork.selectFolder();
if (p) {
$('bpgen-output-dir').value = p;
saveBpgenSettings({ bpgenOutputDir: p });
}
} catch (err) {
bpgenSetStatus(`Output folder browse failed: ${err.message}`, 'error');
}
}
async function browseBpgenFakelibFolder() {
try {
const p = await window.pork.selectFolder();
if (p) {
const input = $('bpgen-fakelib-dir');
input.value = p;
input.dataset.autoDir = '';
saveBpgenSettings({ bpgenFakelibDir: p });
}
} catch (err) {
bpgenSetStatus(`Fakelib folder browse failed: ${err.message}`, 'error');
}
}
async function browseBpgenFakelibBaseDir() {
try {
const dir = await window.pork.selectFolder();
if (!dir) return;
const result = await window.pork.bpgenFakelibSetBasedir(dir);
if (!result) return;
_flBaseDir = result.baseDir;
_flPairs = result.pairs;
$('bpgen-fl-basedir').value = _flBaseDir;
saveBpgenSettings({ bpgenFakelibBaseDir: _flBaseDir });
renderSdkPairDropdown();
renderFlGrid();
updateFlSummaryBadge();
autoSelectSdkPairFromFakelibs(true);
syncFakelibInput(true);
syncFirmwareLabel(true);
} catch (err) {
flSetStatus(`Base directory browse failed: ${err.message}`, 'error');
}
}
async function browseBpgenPupFile() {
try {
const p = await window.pork.bpgenFakelibPickPup();
if (p) $('bpgen-fl-pup-path').value = p;
} catch (err) {
flSetStatus(`PUP browse failed: ${err.message}`, 'error');
}
}
document.addEventListener('click', e => {
const btn = e.target.closest('#bpgen-btn-output, #bpgen-btn-fakelib, #bpgen-btn-run, #bpgen-btn-cancel, #bpgen-select-all, #bpgen-select-none, #bpgen-fl-btn-base, #bpgen-fl-btn-pick-pup, #bpgen-fl-btn-extract');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
switch (btn.id) {
case 'bpgen-btn-output':
browseBpgenOutputFolder();
break;
case 'bpgen-btn-fakelib':
browseBpgenFakelibFolder();
break;
case 'bpgen-btn-run':
runBackporkGen();
break;
case 'bpgen-btn-cancel':
window.pork.bpgenCancel();
appendLog('Cancelling…');
break;
case 'bpgen-select-all':
toggleAllGames(true);
break;
case 'bpgen-select-none':
toggleAllGames(false);
break;
case 'bpgen-fl-btn-base':
browseBpgenFakelibBaseDir();
break;
case 'bpgen-fl-btn-pick-pup':
browseBpgenPupFile();
break;
case 'bpgen-fl-btn-extract':
runFlExtract();
break;
}
}, true);
window._bpgenButtonClick = action => {
switch (action) {
case 'output':
browseBpgenOutputFolder();
break;
case 'fakelib':
browseBpgenFakelibFolder();
break;
case 'baseDir':
browseBpgenFakelibBaseDir();
break;
case 'pup':
browseBpgenPupFile();
break;
case 'extract':
runFlExtract();
break;
case 'run':
runBackporkGen();
break;
case 'cancel':
window.pork.bpgenCancel();
appendLog('Cancelling…');
break;
}
return false;
};
// Export for use by main renderer
window._bpgenInit = initBackporkGen;
+624
View File
@@ -0,0 +1,624 @@
// ── Backporks page ─────────────────────────────────────────────────────────────
let _bpgenPageInitStarted = false;
let _bpgenPageInitPromise = null;
let _bpApplyUpdater = null;
window.pork.on('transfer:update', st => { _bpApplyUpdater?.(st); });
function renderBpgenSdkOptions(pairs, currentValue = '') {
const sdkSelect = $('bpgen-sdk-pair');
if (!sdkSelect) return;
sdkSelect.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = 'all';
allOpt.textContent = 'All SDK Targets';
sdkSelect.appendChild(allOpt);
for (const pair of pairs || []) {
const opt = document.createElement('option');
opt.value = String(pair.pair);
opt.textContent = `SDK Pair ${pair.pair} - PS5: ${pair.ps5Ver} / PS4: ${pair.ps4Ver}`;
sdkSelect.appendChild(opt);
}
if (currentValue && [...sdkSelect.options].some(o => o.value === String(currentValue))) {
sdkSelect.value = String(currentValue);
}
}
function pickReadyBpgenPair(pairs, preferredPair = '') {
const ready = (pairs || []).filter(p => p.hasLibs).sort((a, b) => a.pair - b.pair);
if (!ready.length) return null;
return ready.find(p => String(p.pair) === String(preferredPair)) || ready[0];
}
async function hydrateBpgenCard() {
const sdkSelect = $('bpgen-sdk-pair');
if (!sdkSelect) return;
const [settings, sdkPairs, scan] = await Promise.all([
window.pork.getSettings().catch(() => ({})),
window.pork.bpgenSdkPairs().catch(() => []),
window.pork.bpgenFakelibScan().catch(() => ({ baseDir: '', pairs: [] })),
]);
const savedPair = settings.bpgenSdkPair || '';
renderBpgenSdkOptions(sdkPairs, savedPair);
$('bpgen-output-dir').value = settings.bpgenOutputDir || '';
$('bpgen-fl-basedir').value = scan.baseDir || settings.bpgenFakelibBaseDir || '';
$('bpgen-fakelib-dir').value = settings.bpgenFakelibDir || '';
$('bpgen-fw-label').value = settings.bpgenFirmwareLabel || '';
const activePair = pickReadyBpgenPair(scan.pairs, savedPair);
if (savedPair === 'all') {
sdkSelect.value = 'all';
$('bpgen-fw-label').value = 'All';
} else if (savedPair && [...sdkSelect.options].some(o => o.value === String(savedPair))) {
sdkSelect.value = String(savedPair);
} else if (activePair) {
// Prefer a ready fakelib pair for the initial UI state, but do not
// overwrite persisted settings just because the page was opened.
sdkSelect.value = String(activePair.pair);
if (!$('bpgen-fw-label').value.trim()) $('bpgen-fw-label').value = `${activePair.pair}.xx`;
if (!$('bpgen-fakelib-dir').value.trim()) $('bpgen-fakelib-dir').value = activePair.dir || '';
}
}
function ensureBpgenPageInit() {
if (_bpgenPageInitPromise) return _bpgenPageInitPromise;
_bpgenPageInitPromise = hydrateBpgenCard().catch(err => {
console.error('[backporks] bpgen hydrate failed', err);
});
return _bpgenPageInitPromise;
}
async function loadBackporks() {
ensureBpgenPageInit();
const folders = await window.pork.backporksList();
try {
const summaries = await window.pork.hashGameSummary();
_populateHashMaps(summaries);
} catch (_) {}
const list = $('backpork-list');
const empty = $('backpork-empty');
list.innerHTML = '<div class="backpork-game-row" style="color:var(--text-dim);font-size:12px">Loading firmware folders…</div>';
empty.style.display = folders.length ? 'none' : 'block';
if (!folders.length) { list.innerHTML = ''; return; }
const gamesByFolder = await Promise.all(
folders.map(async f => ({ folder: f, games: await window.pork.backporksGames(f.name) }))
);
list.innerHTML = '';
for (const { folder: f, games } of gamesByFolder) {
const card = document.createElement('div');
card.className = 'backpork-card';
const gamesHtml = games.length ? games.map(g => {
const title = g.prospero_name || g.title || g.game_id;
const porked = g.porked_firmware === f.name;
const _bhs = _backporkHashSummary.get(g.game_id + '|' + f.name) || _gameHashSummary.get(g.game_id);
const hashPill = _bhs
? _bhs.community_mismatch
? `<span class="backpork-porked-pill hash-pill-fail" title="⚠ Community hash check FAILED — may contain modified or malicious files">⚠ Hash Fail</span>`
: _bhs.community_matches > 0
? `<span class="backpork-porked-pill hash-pill-ok" title="Community verified: ${_bhs.community_matches}/${_bhs.hash_count} files">✓ Verified</span>`
: `<span class="backpork-porked-pill hash-pill-local" title="${_bhs.hash_count} file(s) hashed">⧭ Hashed</span>`
: '';
return `
<div class="backpork-game-row">
${g.prospero_icon_url
? `<img class="backpork-game-icon" src="${escHtml(g.prospero_icon_url)}" alt="" loading="lazy" data-image-error="hide"/>`
: '<div class="backpork-game-icon"></div>'}
<div class="backpork-game-info">
<div class="backpork-game-title" title="${escHtml(title)}">${escHtml(title)}</div>
<div class="backpork-game-id">${escHtml(g.game_id)}</div>
</div>
${porked ? `<span class="backpork-porked-pill">✓ Porked</span>` : ''}
${hashPill}
<div class="backpork-game-actions">
<button class="btn btn-sm" data-bp-view="${escHtml(g.game_id)}">View</button>
<button class="btn btn-sm btn-teal"
data-bp-pork="${escHtml(g.game_id)}"
data-bp-fw="${escHtml(f.name)}"
data-bp-path="${escHtml(f.path)}"
${!state.connected ? 'disabled title="Connect to PS5 first"' : ''}>
Pork ▶
</button>
</div>
</div>`;
}).join('') : '<div class="backpork-game-row" style="color:var(--text-dim);font-size:12px">No games detected — click Rescan</div>';
card.innerHTML = `
<div class="backpork-card-header">
<div class="backpork-badge">${escHtml(f.name)}</div>
<div class="backpork-info">
<div class="backpork-path" title="${escHtml(f.path)}">${escHtml(f.path)}</div>
<div class="backpork-count"><strong>${games.length}</strong> game(s) detected</div>
</div>
<div class="backpork-actions">
<button class="btn btn-sm btn-teal" data-action="scan" data-id="${f.id}">&#8635; Rescan</button>
<button class="btn btn-sm btn-teal"
data-action="pork-all"
data-id="${f.id}"
data-fw="${escHtml(f.name)}"
data-path="${escHtml(f.path)}"
${!state.connected ? 'disabled title="Connect to PS5 first"' : ''}
title="Queue all ${games.length} game(s) from ${escHtml(f.name)} for PS5 install">
&#x1F527; Apply All (${games.length})
</button>
<button class="btn btn-sm btn-danger" data-action="remove" data-id="${f.id}">Remove</button>
</div>
</div>
<div class="backpork-games">${gamesHtml}</div>`;
list.appendChild(card);
}
list.onclick = async e => {
const btn = e.target.closest('[data-action]');
if (btn) {
const id = Number(btn.dataset.id);
if (btn.dataset.action === 'scan') {
btn.disabled = true; btn.textContent = 'Scanning…';
try {
const { count } = await window.pork.backporksScan(id);
setStatus(`Rescan complete — ${count} game(s) found`, 'ok');
await loadBackporks();
await refreshFirmwareFilters();
} catch (err) { setStatus(err.message, 'error'); }
}
if (btn.dataset.action === 'remove') {
const fwName = btn.closest('.backpork-card')?.querySelector('.backpork-badge')?.textContent || String(id);
const confirmation = await showExactConfirmation(`Remove firmware folder "${fwName}" from Chimera?`, 'VERWIJDER');
if (!confirmation) return;
await window.pork.backporksRemove(id, confirmation);
setStatus('Folder removed', 'ok');
await loadBackporks();
await refreshFirmwareFilters();
}
if (btn.dataset.action === 'pork-all') {
const folderName = btn.dataset.fw;
const folderPath = btn.dataset.path;
const folderEntry = gamesByFolder.find(({ folder }) => folder.name === folderName);
const folderGames = folderEntry?.games || [];
if (!folderGames.length) { setStatus('No games in this firmware folder', 'warn'); return; }
if (!await showConfirm(`Queue all ${folderGames.length} game(s) from ${folderName} for PS5 install?\n\nProgress will be shown below in real-time.`)) return;
// ── Progress panel helpers ────────────────────────────────────────────
const panel = $('pork-all-progress-panel');
const queueFill = $('pork-all-queue-fill');
const queueLbl = $('pork-all-queue-label');
const queueCnt = $('pork-all-queue-count');
const gameLbl = $('pork-all-current-game');
const fileFill = $('pork-all-file-fill');
const fileLbl = $('pork-all-file-label');
const logEl = $('pork-all-log');
panel.style.display = '';
logEl.textContent = '';
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const ts = () => new Date().toLocaleTimeString();
function appendLog(line, color) {
const span = document.createElement('span');
if (color) span.style.color = color;
span.textContent = `[${ts()}] ${line}\n`;
logEl.appendChild(span);
logEl.scrollTop = logEl.scrollHeight;
}
function fmtBytes(b) {
if (b >= 1073741824) return (b / 1073741824).toFixed(2) + ' GB';
if (b >= 1048576) return (b / 1048576).toFixed(1) + ' MB';
if (b >= 1024) return (b / 1024).toFixed(0) + ' KB';
return b + ' B';
}
function fmtSpeed(bps) {
return fmtBytes(bps) + '/s';
}
// ── Phase 1: Resolve FTP paths (before registering listener) ────────────
btn.disabled = true;
btn.textContent = 'Resolving paths…';
appendLog(`Starting bulk apply — ${folderGames.length} game(s) from ${folderName}`);
queueLbl.textContent = `Resolving FTP paths… (0 / ${folderGames.length})`;
const readyGames = []; // { game, ftpDest }
let skipped = 0, failed = 0;
let pathsDone = 0;
for (const game of folderGames) {
try {
const ftpDest = await ensureGameFtpPath(game.game_id);
pathsDone++;
if (!ftpDest) {
skipped++;
appendLog(` SKIP ${game.game_id} — no FTP path (cancelled)`, '#f59e0b');
} else {
appendLog(` PATH ${game.game_id} → ${ftpDest}`);
readyGames.push({ game, ftpDest });
}
queueLbl.textContent = `Resolving FTP paths… (${pathsDone} / ${folderGames.length})`;
} catch (err) {
pathsDone++;
failed++;
appendLog(` ERROR ${game.game_id}: ${err.message}`, '#f87171');
console.error('[pork-all]', game.game_id, err.message);
}
}
if (!readyGames.length) {
const parts = [`Nothing queued from ${folderName}`];
if (skipped) parts.push(`${skipped} skipped`);
if (failed) parts.push(`${failed} failed`);
appendLog(`\n${parts.join(' — ')}`, '#f59e0b');
setStatus(parts.join(' — '), 'warn');
panel.style.display = 'none';
btn.disabled = false;
btn.textContent = `\u{1F527} Apply All (${folderGames.length})`;
return;
}
// ── Phase 2: Register listener BEFORE queuing so we catch all events ──
const batchIds = new Set();
const gameById = {};
const lastState = {};
let doneCount = 0;
let lastLoggedFile = {};
let batchDone = false;
const updateProgress = (transferState) => {
if (batchDone) return;
const batchJobs2 = (transferState.jobs || []).filter(j => batchIds.has(j.id));
let nowDone = 0;
let activeJob = null;
for (const j of batchJobs2) {
const game = gameById[j.id];
const prev = lastState[j.id];
if (j.status === 'active') activeJob = j;
if (j.status !== prev) {
lastState[j.id] = j.status;
if (j.status === 'active') {
appendLog(`\n▶ START #${j.id} ${game?.game_id || j.gameId} (${j.label})`);
} else if (j.status === 'done') {
nowDone++;
appendLog(`✓ DONE #${j.id} ${game?.game_id || j.gameId}`, '#4ade80');
} else if (j.status === 'error') {
nowDone++;
appendLog(`✗ ERROR #${j.id} ${game?.game_id || j.gameId}: ${j.error || 'unknown error'}`, '#f87171');
} else if (j.status === 'cancelled') {
nowDone++;
appendLog(`⊘ CANCEL #${j.id} ${game?.game_id || j.gameId}`, '#f59e0b');
}
} else if (j.status === 'done' || j.status === 'error' || j.status === 'cancelled') {
nowDone++;
}
if (j.status === 'active' && j.progress?.file && j.progress.file !== lastLoggedFile[j.id]) {
lastLoggedFile[j.id] = j.progress.file;
const fileShort = j.progress.file.replace(/\\/g, '/').split('/').pop();
const fileInfo = j.progress.filesTotal
? ` FILE [${j.progress.filesDone + 1}/${j.progress.filesTotal}] ${fileShort}`
: ` FILE ${fileShort}`;
appendLog(fileInfo);
}
}
doneCount = nowDone;
// Overall queue bar — use batchIds.size (grows as jobs are queued)
const batchTotal = batchIds.size;
const qPct = batchTotal > 0 ? Math.round((doneCount / batchTotal) * 100) : 0;
if (!batchDone) {
// Only update bar/label while we're still monitoring
queueFill.style.width = qPct + '%';
queueLbl.textContent = `Applying backporks to PS5 — ${doneCount} / ${batchTotal} complete`;
queueCnt.textContent = `${qPct}%`;
btn.textContent = `Applying… (${doneCount}/${batchTotal})`;
}
if (activeJob) {
const g = gameById[activeJob.id];
const p = activeJob.progress || {};
const pct = p.percent || 0;
fileFill.style.width = pct + '%';
const fileShort = (p.file || '').replace(/\\/g, '/').split('/').pop();
const speedStr = p.speedBps ? ` @ ${fmtSpeed(p.speedBps)}` : '';
const sizeStr = p.total ? ` — ${fmtBytes(p.transferred)} / ${fmtBytes(p.total)}` : '';
const filesStr = p.filesTotal ? ` [file ${p.filesDone + 1}/${p.filesTotal}]` : '';
gameLbl.textContent = `${g?.game_id || activeJob.gameId}${filesStr}: ${fileShort || 'preparing…'}`;
fileLbl.textContent = `${pct.toFixed(1)}%${sizeStr}${speedStr}`;
} else if (doneCount < batchTotal) {
gameLbl.textContent = 'Waiting for next job to start…';
fileLbl.textContent = '';
fileFill.style.width = '0%';
}
// All batch jobs finished (or no more in queue)
if (batchIds.size > 0 && doneCount >= batchIds.size) {
batchDone = true;
const errCount = batchJobs2.filter(j => j.status === 'error').length;
const cancelCount = batchJobs2.filter(j => j.status === 'cancelled').length;
const parts = [`Applied ${batchIds.size - errCount - cancelCount} of ${batchIds.size}`];
if (skipped) parts.push(`${skipped} skipped`);
if (failed) parts.push(`${failed} path errors`);
if (errCount) parts.push(`${errCount} transfer errors`);
appendLog(`\n━━━ All done — ${parts.join(', ')} ━━━`, '#4ade80');
queueFill.style.width = '100%';
queueLbl.textContent = `Bulk apply complete — ${parts.join(', ')}`;
queueCnt.textContent = '100%';
gameLbl.textContent = '';
fileLbl.textContent = '';
fileFill.style.width = '0%';
btn.disabled = false;
btn.textContent = `\u{1F527} Apply All (${folderGames.length})`;
setStatus(parts.join(' — '), errCount || failed ? 'warn' : 'ok');
loadBackporks().catch(() => {});
_bpApplyUpdater = null; // stop forwarding transfer:update to this run
}
};
_bpApplyUpdater = updateProgress;
// ── Phase 3: Queue jobs now that listener is active ───────────────────
appendLog(`\nQueuing ${readyGames.length} job(s)…`);
queueLbl.textContent = `Queuing ${readyGames.length} job(s)…`;
btn.textContent = `Queuing… (0/${readyGames.length})`;
let queuedCount = 0;
for (const { game } of readyGames) {
try {
const result = await window.pork.backporksPork(game.game_id, folderName, folderPath);
batchIds.add(result.jobId);
gameById[result.jobId] = game;
queuedCount++;
appendLog(` QUEUE #${result.jobId} ${game.game_id}`);
btn.textContent = `Queuing… (${queuedCount}/${readyGames.length})`;
} catch (err) {
failed++;
appendLog(` QUEUE-ERR ${game.game_id}: ${err.message}`, '#f87171');
console.error('[pork-all queue]', game.game_id, err.message);
}
}
if (!batchIds.size) {
batchDone = true;
_bpApplyUpdater = null; // nothing queued — stop forwarding
appendLog('\nNothing was queued — all queue attempts failed', '#f59e0b');
setStatus('Queue failed — check Transfers tab', 'error');
panel.style.display = 'none';
btn.disabled = false;
btn.textContent = `\u{1F527} Apply All (${folderGames.length})`;
return;
}
appendLog(`\nAll ${batchIds.size} job(s) queued — monitoring transfers…\n`);
queueLbl.textContent = `Applying backporks to PS5 — 0 / ${batchIds.size} complete`;
queueFill.style.width = '0%';
btn.textContent = `Applying… (0/${batchIds.size})`;
return;
}
return;
}
const viewBtn = e.target.closest('[data-bp-view]');
if (viewBtn) { openModal(viewBtn.dataset.bpView); return; }
const porkBtn = e.target.closest('[data-bp-pork]');
if (porkBtn && !porkBtn.disabled) {
const gameId = porkBtn.dataset.bpPork;
const folderName = porkBtn.dataset.bpFw;
const folderPath = porkBtn.dataset.bpPath;
const ftpDest = await ensureGameFtpPath(gameId);
if (!ftpDest) return; // user cancelled picker
porkBtn.disabled = true;
porkBtn.textContent = 'Queuing…';
setStatus(`Queuing ${gameId} (${folderName}) for install…`);
try {
await window.pork.backporksPork(gameId, folderName, folderPath);
setStatus(`${gameId} queued — see Transfers tab for progress`, 'ok');
await loadBackporks();
} catch (err) {
setStatus(`Pork failed: ${err.message}`, 'error');
porkBtn.disabled = false;
porkBtn.textContent = 'Pork ▶';
}
}
};
}
window.pork.on('backporks:scan:complete', async ({ folders, count, uniqueGames }) => {
if (count > 0) {
const folderLabel = `${folders} firmware folder${folders !== 1 ? 's' : ''}`;
const buildLabel = `${count} build${count !== 1 ? 's' : ''}`;
const uniqueLabel = Number.isFinite(uniqueGames) && uniqueGames > 0
? ` across ${uniqueGames} unique game${uniqueGames !== 1 ? 's' : ''}`
: '';
showToast(`Backporks: ${buildLabel} in ${folderLabel}${uniqueLabel}`, 'info');
}
if (document.querySelector('.page.active')?.id === 'page-backporks') await loadBackporks();
await refreshFirmwareFilters();
});
$('btn-create-fw-folder').addEventListener('click', () => {
$('new-fw-form').hidden = !$('new-fw-form').hidden;
if (!$('new-fw-form').hidden) $('new-fw-name').focus();
});
$('btn-new-fw-cancel').addEventListener('click', () => {
$('new-fw-form').hidden = true;
$('new-fw-name').value = '';
$('new-fw-parent').value = '';
});
$('btn-new-fw-browse').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (folder) $('new-fw-parent').value = folder;
});
$('btn-new-fw-create').addEventListener('click', async () => {
const name = $('new-fw-name').value.trim();
const parentPath = $('new-fw-parent').value.trim();
if (!name) { setStatus('Enter a firmware version (e.g. 11.50.0)', 'error'); return; }
if (!parentPath) { setStatus('Select a parent folder', 'error'); return; }
if (!/^\d+\.\d+\.\d+$/.test(name)) { setStatus('Version must follow x.x.x format (e.g. 11.50.0)', 'error'); return; }
try {
$('btn-new-fw-create').disabled = true;
const { path: created } = await window.pork.backporksCreateFolder(name, parentPath);
showToast(`Created firmware folder ${name}`, 'ok');
$('new-fw-form').hidden = true;
$('new-fw-name').value = '';
$('new-fw-parent').value = '';
await loadBackporks();
await refreshFirmwareFilters();
} catch (err) {
setStatus(err.message, 'error');
} finally {
$('btn-new-fw-create').disabled = false;
}
});
$('btn-add-backpork').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (!folder) return;
$('btn-add-backpork').disabled = true;
setStatus('Scanning firmware folders…');
try {
const { folders, count } = await window.pork.backporksAddRoot(folder);
setStatus(`Found ${folders} firmware folder(s) — ${count} game(s) detected`, 'ok');
showToast(`Registered ${folders} firmware folder(s)`, 'ok');
await loadBackporks();
await refreshFirmwareFilters();
} catch (err) {
setStatus(err.message, 'error');
} finally {
$('btn-add-backpork').disabled = false;
}
});
// Auto-Backporks card browse actions are wired here as a fallback because this
// page module is known-good: the main "Select Backpork Folder" button on the
// same page already uses the same picker bridge successfully.
function applyBpgenAutoConfigFromPairs(pairs) {
if (!Array.isArray(pairs) || !pairs.length) return;
const ready = pairs.filter(p => p.hasLibs).sort((a, b) => a.pair - b.pair);
if (!ready.length) return;
const pair = ready[0].pair;
const sdkSelect = $('bpgen-sdk-pair');
const fwInput = $('bpgen-fw-label');
const fakelibInput = $('bpgen-fakelib-dir');
if (sdkSelect && [...sdkSelect.options].some(o => Number(o.value) === pair)) {
sdkSelect.value = String(pair);
}
if (fwInput) fwInput.value = `${pair}.xx`;
if (fakelibInput && !fakelibInput.value.trim()) {
fakelibInput.value = ready[0].dir || '';
}
window.pork.setSettings({
bpgenSdkPair: String(pair),
bpgenFirmwareLabel: `${pair}.xx`,
bpgenFakelibDir: fakelibInput?.value.trim() || '',
}).catch(() => {});
}
if (!_bpgenPageInitStarted) {
_bpgenPageInitStarted = true;
$('bpgen-sdk-pair')?.addEventListener('change', async () => {
const pair = $('bpgen-sdk-pair').value;
const baseDir = $('bpgen-fl-basedir').value.trim().replace(/[\\\/]+$/, '');
if (pair === 'all') {
$('bpgen-fw-label').value = 'All';
if (baseDir) $('bpgen-fakelib-dir').value = '';
} else {
$('bpgen-fw-label').value = pair ? `${pair}.xx` : '';
if (baseDir && pair) $('bpgen-fakelib-dir').value = `${baseDir}\\${pair}`;
}
await window.pork.setSettings({
bpgenSdkPair: pair,
bpgenFirmwareLabel: $('bpgen-fw-label').value.trim(),
bpgenFakelibDir: $('bpgen-fakelib-dir').value.trim(),
}).catch(() => {});
});
}
if (!$('bpgen-btn-output')?.dataset.boundInBackporks) {
$('bpgen-btn-output').dataset.boundInBackporks = '1';
$('bpgen-btn-output').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const folder = await window.pork.selectFolder();
if (folder) {
$('bpgen-output-dir').value = folder;
await window.pork.setSettings({ bpgenOutputDir: folder });
}
} catch (err) {
setStatus(`Output folder browse failed: ${err.message}`, 'error');
}
});
}
if (!$('bpgen-btn-fakelib')?.dataset.boundInBackporks) {
$('bpgen-btn-fakelib').dataset.boundInBackporks = '1';
$('bpgen-btn-fakelib').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const folder = await window.pork.selectFolder();
if (folder) {
$('bpgen-fakelib-dir').value = folder;
const save = { bpgenFakelibDir: folder };
const pairMatch = String(folder).match(/[\\\/](\d+)\s*$/);
if (pairMatch) {
const pair = Number(pairMatch[1]);
if (Number.isFinite(pair)) {
if ([...$('bpgen-sdk-pair').options].some(o => Number(o.value) === pair)) {
$('bpgen-sdk-pair').value = String(pair);
}
$('bpgen-fw-label').value = `${pair}.xx`;
save.bpgenSdkPair = String(pair);
save.bpgenFirmwareLabel = `${pair}.xx`;
}
}
await window.pork.setSettings(save);
}
} catch (err) {
setStatus(`Fakelib folder browse failed: ${err.message}`, 'error');
}
});
}
if (!$('bpgen-fl-btn-base')?.dataset.boundInBackporks) {
$('bpgen-fl-btn-base').dataset.boundInBackporks = '1';
$('bpgen-fl-btn-base').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const folder = await window.pork.selectFolder();
if (!folder) return;
$('bpgen-fl-basedir').value = folder;
await window.pork.setSettings({ bpgenFakelibBaseDir: folder });
if (window.pork.bpgenFakelibSetBasedir) {
const result = await window.pork.bpgenFakelibSetBasedir(folder);
applyBpgenAutoConfigFromPairs(result?.pairs || []);
}
} catch (err) {
setStatus(`Fakelib base dir browse failed: ${err.message}`, 'error');
}
});
}
+161
View File
@@ -0,0 +1,161 @@
// ── Backups page ──────────────────────────────────────────────────────────────
function protectionText(key, fallback) {
if (typeof window.t !== 'function') return fallback;
const translated = window.t(key);
return translated && translated !== key ? translated : fallback;
}
async function loadBackups() {
try {
const gameContext = consumeGameWorkspaceContext('backups');
if (gameContext && $('backups-search')) $('backups-search').value = gameContext;
const titles = [];
let offset = 0;
let total = 0;
do {
const packet = await window.pork.listLibraryTitles({ limit: 200, offset });
const page = window.chimeraLibraryModel.items(packet);
titles.push(...page);
total = Number(packet && packet.total || page.length);
offset += page.length;
if (!page.length) break;
} while (offset < total);
state.backups = window.chimeraProtectionModel.flatten(titles);
populateBackupsRegionFilter();
renderBackups();
} catch (e) {
setStatus(e.message, 'error');
}
}
function populateBackupsRegionFilter() {
const sel = $('backups-region-filter');
const cur = sel.value;
const regions = [...new Set(
state.backups.map(copy => copy.region || '').filter(Boolean).sort()
)];
sel.innerHTML = '<option value="">All Regions</option>'
+ regions.map(r => `<option value="${escHtml(r)}"${r === cur ? ' selected' : ''}>${escHtml(r)}</option>`).join('');
}
function renderBackups() {
const search = ($('backups-search')?.value || '').toLowerCase();
const region = $('backups-region-filter')?.value || '';
const list = $('backups-list');
const empty = $('backups-empty');
const filtered = state.backups.filter(copy => {
if (region && copy.region !== region) return false;
if (search) {
const haystack = [copy.titleName, copy.titleId, copy.deviceName, copy.path, copy.hash].filter(Boolean).join(' ').toLowerCase();
if (!haystack.includes(search)) return false;
}
return true;
});
empty.hidden = filtered.length > 0;
list.innerHTML = filtered.map(copy => {
const verified = copy.lastVerifiedAt ? new Date(copy.lastVerifiedAt).toLocaleString(window.i18n?.current || 'en') : protectionText('protection.copy.never', 'Never');
const hash = copy.hash ? `${copy.hashAlgorithm.toUpperCase()} ${copy.hash}` : `${protectionText('protection.copy.notHashed', 'Not hashed')} (${copy.hashState})`;
const integrity = copy.missing
? protectionText('protection.copy.missing', 'Missing copy')
: copy.integrityFailed
? `${protectionText('protection.copy.failed', 'Failed')} (${copy.integrityState})`
: copy.integrityState === 'unknown' ? protectionText('protection.copy.notVerified', 'Not verified') : copy.integrityState;
const policyClass = copy.policy.healthy ? 'is-healthy' : copy.policy.status === 'needs-diversity' ? 'needs-diversity' : 'is-degraded';
const policyLabel = copy.policy.healthy
? `${copy.policy.distinctDeviceCount}/${copy.policy.requiredDistinctDevices} ${protectionText('protection.policy.devicesProtected', 'devices protected')}`
: `${copy.policy.distinctDeviceCount}/${copy.policy.requiredDistinctDevices} ${protectionText('protection.policy.distinctDevices', 'distinct devices')}`;
return `<article class="backup-card protection-copy-card${copy.missing ? ' is-missing' : ''}${copy.integrityFailed ? ' has-integrity-failure' : ''}" data-copy-id="${escHtml(copy.copyId)}">
<div class="protection-copy-title">
<div class="backup-card-icon backup-card-icon-empty"></div>
<div class="protection-copy-title-copy"><strong>${escHtml(copy.titleName)}</strong><code>${escHtml(copy.titleId)} · copy ${escHtml(copy.copyId)}</code></div>
</div>
<div class="protection-copy-facts">
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.device', 'Device'))}</span><button class="protection-device-label" type="button" data-label-device-id="${escHtml(copy.deviceId || '')}" data-device-label="${escHtml(copy.deviceName)}" title="${escHtml(protectionText('protection.copy.renameDevice', 'Rename this drive'))}">${escHtml(copy.deviceName)}</button></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.path', 'Path'))}</span><code title="${escHtml(copy.path)}">${escHtml(copy.path)}</code></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.hash', 'Hash'))}</span><code title="${escHtml(hash)}">${escHtml(hash)}</code></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.lastVerified', 'Last verified'))}</span><strong>${escHtml(verified)}</strong></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.integrity', 'Integrity'))}</span><strong>${escHtml(integrity)}</strong></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.size', 'Size'))}</span><strong>${escHtml(copy.sizeBytes ? fmt(copy.sizeBytes) : protectionText('protection.copy.unknown', 'Unknown'))}</strong></div>
</div>
<div class="protection-copy-actions">
<span class="protection-copy-policy ${policyClass}">${escHtml(policyLabel)}</span>
<button class="btn btn-sm btn-accent" type="button" data-restore-copy-id="${escHtml(copy.copyId)}"${copy.missing || copy.integrityFailed ? ' disabled' : ''}>${escHtml(protectionText('protection.copy.prepareRestore', 'Prepare restore'))}</button>
<button class="btn btn-sm backup-card-view" type="button" data-view-game="${escHtml(copy.titleId)}">${escHtml(protectionText('protection.copy.titleDetails', 'Title details'))}</button>
</div>
</article>`;
}).join('');
list.onclick = async e => {
const labelButton = e.target.closest('[data-label-device-id]');
if (labelButton) {
const label = await showPrompt(protectionText('protection.copy.renamePrompt', 'Friendly name for this drive:'), labelButton.dataset.deviceLabel || '');
if (label == null) return;
await window.pork.labelStorageDevice({ deviceId: Number(labelButton.dataset.labelDeviceId), label });
await loadBackups();
setStatus(protectionText('protection.copy.renamed', 'Drive label saved.'), 'ok');
return;
}
const restore = e.target.closest('[data-restore-copy-id]');
if (restore) {
const copy = state.backups.find(item => item.copyId === restore.dataset.restoreCopyId);
if (!copy) return;
state.protectionRestoreTarget = copy.restoreTarget;
list.querySelectorAll('[data-restore-copy-id]').forEach(button => {
button.setAttribute('aria-pressed', String(button === restore));
if (!button.disabled) button.textContent = button === restore
? protectionText('protection.copy.restoreSelected', 'Restore source selected')
: protectionText('protection.copy.prepareRestore', 'Prepare restore');
});
setStatus(`${protectionText('protection.copy.restoreSelected', 'Restore source selected')}: ${copy.copyId} · ${copy.deviceName}. ${protectionText('protection.copy.noMutation', 'No restore has started.')}`, 'ok');
return;
}
const details = e.target.closest('[data-view-game]');
if (details) openModal(details.dataset.viewGame);
};
}
$('backups-search')?.addEventListener('input', renderBackups);
$('backups-region-filter')?.addEventListener('change', renderBackups);
$('btn-scan-local').addEventListener('click', async () => {
$('btn-scan-local').disabled = true;
setStatus('Scanning local game source folders…');
try {
const { total, added } = await window.pork.scanBackups();
await loadBackups();
await loadDashboard();
const msg = added > 0
? `Found ${total} game(s) — ${added} new, fetching metadata…`
: `Scan complete: ${total} game(s) found, none new`;
setStatus(msg, 'ok');
} catch (e) {
setStatus(e.message, 'error');
} finally {
$('btn-scan-local').disabled = false;
}
});
$('btn-rescan-all-backporks').addEventListener('click', async () => {
const btn = $('btn-rescan-all-backporks');
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = 'Scanning…';
setStatus('Rescanning all backpork folders…');
try {
const { folders, count, uniqueGames } = await window.pork.backporksScanAll();
await loadBackporks();
await refreshFirmwareFilters();
const uniqueLabel = Number.isFinite(uniqueGames) && uniqueGames > 0
? ` across ${uniqueGames} unique game(s)`
: '';
setStatus(`Rescan complete — ${count} build(s) in ${folders} firmware folder(s)${uniqueLabel}`, 'ok');
} catch (e) {
setStatus(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = orig;
}
});
+60
View File
@@ -0,0 +1,60 @@
// BDJB Genny
const bdjbPayloads = [];
const bdjbEntries = [];
function renderBdjbEntries() {
const list = $('bdjbgen-list');
if (!bdjbEntries.length) { list.innerHTML = '<p class="hint autoloader-empty-hint">No entries yet. Add payload files, then adjust their autoload order and delays.</p>'; return; }
list.innerHTML = bdjbEntries.map((entry, index) => `<div class="autoloader-row"><span>${entry.type === 'delay' ? `Delay: ${entry.value} ms` : `Payload: ${escHtml(entry.value)}`}</span><span><button class="btn btn-sm" data-bdjb-up="${index}" ${index === 0 ? 'disabled' : ''}>Up</button><button class="btn btn-sm" data-bdjb-down="${index}" ${index === bdjbEntries.length - 1 ? 'disabled' : ''}>Down</button><button class="btn btn-sm btn-red" data-bdjb-remove="${index}">Remove</button></span></div>`).join('');
}
function bdjbLog(message) {
const log = $('bdjbgen-log');
log.textContent += `${message}\n`;
log.scrollTop = log.scrollHeight;
}
$('btn-bdjbgen-add-payloads').addEventListener('click', async () => {
const added = await window.pork.bdjbGenSelectPayloads();
for (const payload of added) {
if (bdjbPayloads.some((existing) => existing.name === payload.name)) { bdjbLog(`Skipped duplicate filename: ${payload.name}`); continue; }
bdjbPayloads.push(payload); bdjbEntries.push({ type: 'payload', value: payload.name });
}
renderBdjbEntries();
});
$('btn-bdjbgen-add-delay').addEventListener('click', async () => {
const value = await showPrompt('Delay in milliseconds:', '1000');
if (value === null || value === '') return;
bdjbEntries.push({ type: 'delay', value: String(value).trim() }); renderBdjbEntries();
});
$('bdjbgen-list').addEventListener('click', (event) => {
const button = event.target.closest('button'); if (!button) return;
const remove = button.dataset.bdjbRemove, up = button.dataset.bdjbUp, down = button.dataset.bdjbDown;
if (remove !== undefined) bdjbEntries.splice(Number(remove), 1);
else if (up !== undefined) { const i = Number(up); [bdjbEntries[i - 1], bdjbEntries[i]] = [bdjbEntries[i], bdjbEntries[i - 1]]; }
else if (down !== undefined) { const i = Number(down); [bdjbEntries[i + 1], bdjbEntries[i]] = [bdjbEntries[i], bdjbEntries[i + 1]]; }
renderBdjbEntries();
});
window.pork.on('bdjbgen:progress', ({ stage, message, percent }) => {
$('bdjbgen-progress').value = percent || 0;
$('bdjbgen-status').textContent = `${stage}: ${message}`;
bdjbLog(`[${stage}] ${message}`);
});
$('btn-bdjbgen-build').addEventListener('click', async () => {
const button = $('btn-bdjbgen-build');
button.disabled = true; $('bdjbgen-log').textContent = ''; $('bdjbgen-progress').value = 0;
try {
const result = await window.pork.bdjbGenBuild({ entries: bdjbEntries, payloads: bdjbPayloads, discTitle: $('bdjbgen-title').value });
if (result.canceled) { $('bdjbgen-status').textContent = 'Build canceled.'; return; }
$('bdjbgen-status').textContent = `ISO ready: ${result.outputIso} (${result.bytes} bytes)`;
bdjbLog(`Validated ISO with ${result.payloadCount} payload(s).`);
} catch (error) {
$('bdjbgen-status').textContent = `Build failed: ${error.message}`;
bdjbLog(`ERROR: ${error.message}`);
} finally { button.disabled = false; }
});
+496
View File
@@ -0,0 +1,496 @@
// ── Cheats page ───────────────────────────────────────────────────────────────
let _cheatsDownloading = false;
function fmtEta(sec) {
if (!sec || sec <= 0) return '—';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
let _cheatsSearchTimer = null;
// Map of cusa_id → prospero_icon_url, built once per loadCheats() call
let _cheatIconMap = new Map();
const _cheatIconAttempted = new Set();
async function refreshCheatStats() {
try {
const stats = await window.pork.cheatsStats();
$('cheats-stat-files').textContent = `${stats.files ?? 0} files cached`;
$('cheats-stat-games').textContent = `covering ${stats.games ?? 0} games`;
const badge = $('nav-cheats-badge');
if (stats.files > 0) {
badge.textContent = stats.files;
badge.hidden = false;
} else {
badge.hidden = true;
}
} catch (_) {}
}
async function loadCheats({ background = false } = {}) {
// Hide the progress panel unless a download is actively running
if (!_cheatsDownloading) {
$('cheats-dl-panel').hidden = true;
$('btn-cheats-download-all').disabled = false;
}
await refreshCheatStats();
// Background warming keeps the badge current without building thousands of
// hidden cards or starting artwork requests for a page the user did not open.
if (background) return;
// Build icon map from games — reuse state.games if already loaded, otherwise fetch
try {
const games = state.games.length ? state.games : await window.pork.listGames({});
_cheatIconMap = new Map(games.map(g => [g.game_id, g.prospero_icon_url || '']));
} catch (_) {}
// Render with current search
const q = $('cheats-search').value.trim();
await renderCheatResults(q);
// Unmatched section (only shown when not searching)
if (!q) await renderUnmatchedCheats();
else $('cheats-unmatched-section').hidden = true;
}
// ── GarlicSaves ──────────────────────────────────────────────────────────────
let _garlicBusy = false;
let _garlicHooked = false;
async function loadGarlic() {
if (!_garlicHooked) {
_garlicHooked = true;
$('btn-garlic-site').addEventListener('click', e => {
e.preventDefault();
window.pork.openShell('https://garlicsaves.com');
});
$('garlic-contribute-link').addEventListener('click', e => {
e.preventDefault();
window.pork.openShell('https://garlicsaves.com/contribute');
});
// Start Working — show confirm modal
$('btn-garlic-start').addEventListener('click', () => {
if (_garlicBusy) return;
openAccessibleDialog($('garlic-confirm-overlay'), $('btn-garlic-confirm-cancel'));
});
// Modal: Cancel
$('btn-garlic-confirm-cancel').addEventListener('click', () => {
closeAccessibleDialog($('garlic-confirm-overlay'));
});
// Modal: Confirm — run the worker
$('btn-garlic-confirm-ok').addEventListener('click', async () => {
closeAccessibleDialog($('garlic-confirm-overlay'));
if (_garlicBusy) return;
_garlicBusy = true;
$('btn-garlic-start').disabled = true;
$('garlic-progress-area').hidden = false;
$('garlic-progress-text').textContent = 'Starting…';
$('garlic-progress-bar').style.width = '0%';
try {
await window.pork.garlicStart();
$('garlic-status-badge').hidden = false;
showToast('GarlicSaves worker started! Restart PS5 to stop.', 'success');
} catch (e) {
$('garlic-progress-text').textContent = 'Error: ' + e.message;
showToast('Failed to start worker: ' + e.message, 'error');
} finally {
_garlicBusy = false;
$('btn-garlic-start').disabled = false;
}
});
// Live progress events
window.pork.on('garlic:progress', ({ msg, percent }) => {
const txt = $('garlic-progress-text');
const bar = $('garlic-progress-bar');
if (txt) txt.textContent = msg;
if (bar && percent != null) bar.style.width = Math.min(100, percent) + '%';
});
}
}
function _garlicShowProgress(show) {
$('garlic-progress-area').hidden = !show;
if (show) {
$('garlic-progress-text').textContent = 'Starting…';
$('garlic-progress-bar').style.width = '0%';
}
}
let _lazyIconCancel = false;
// Fetch icons from prosperopatches.com for cheat entries not already in the icon map.
// Throttled at one request per 500 ms, capped at 100 entries, cancellable.
async function lazyFetchCheatIcons(cusaIds) {
_lazyIconCancel = true; // cancel any prior run
await new Promise(r => setTimeout(r, 20)); // let prior loop see the flag
_lazyIconCancel = false;
const toFetch = [...new Set(cusaIds)]
.filter(id => /^(?:CUSA|PPSA)\d{5}$/i.test(String(id || '')) && !_cheatIconAttempted.has(id))
.slice(0, 40);
for (let i = 0; i < toFetch.length; i++) {
if (_lazyIconCancel) break;
const id = toFetch[i];
_cheatIconAttempted.add(id);
try {
const { iconUrl } = await window.pork.prosperoFetchIcon(id);
if (_lazyIconCancel) break;
if (iconUrl) {
_cheatIconMap.set(id, iconUrl);
// Update every visible card for this game
document.querySelectorAll(`.cheat-card[data-cusa="${id}"]`).forEach(card => {
const empty = card.querySelector('.cheat-card-icon-empty');
if (!empty) return;
const img = document.createElement('img');
img.className = 'cheat-card-icon';
img.alt = '';
img.src = iconUrl;
img.onerror = () => { img.style.display = 'none'; };
empty.replaceWith(img);
});
}
} catch (_) {}
if (!_lazyIconCancel && i < toFetch.length - 1) await new Promise(r => setTimeout(r, 500));
}
}
async function renderCheatResults(query) {
const list = $('cheats-list');
const empty = $('cheats-empty');
list.innerHTML = '<div class="empty-msg">Loading…</div>';
empty.hidden = true;
try {
// Both cheatsAll() and cheatsSearch() return flat rows: { cusa_id, title, file_count }
const rows = query
? await window.pork.cheatsSearch(query)
: await window.pork.cheatsAll();
list.innerHTML = '';
if (!rows || rows.length === 0) {
empty.hidden = false;
return;
}
for (const row of rows) {
const card = document.createElement('div');
card.className = 'cheat-card';
card.dataset.cusa = row.cusa_id;
const fc = row.file_count ?? 1;
const iconUrl = _cheatIconMap.get(row.cusa_id) || '';
const iconHtml = iconUrl
? `<img src="${escHtml(iconUrl)}" class="cheat-card-icon" alt="" loading="lazy" data-image-error="hide">`
: `<div class="cheat-card-icon cheat-card-icon-empty"></div>`;
card.innerHTML = `
<div class="cheat-card-header">
${iconHtml}
<div class="cheat-card-info">
<div class="cheat-card-title-row">
<span class="cheat-card-id">${escHtml(row.cusa_id)}</span>
<span class="cheat-card-title">${escHtml(row.title || row.cusa_id)}</span>
</div>
<span class="cheat-card-meta">${fc} version${fc !== 1 ? 's' : ''} cached</span>
</div>
</div>`;
list.appendChild(card);
}
// Background-fetch icons for entries that have no icon in the map
const missing = rows.filter(r => r.cusa_id && !_cheatIconMap.get(r.cusa_id)).map(r => r.cusa_id);
if (missing.length > 0) lazyFetchCheatIcons(missing);
} catch (e) {
list.innerHTML = `<div class="empty-msg" style="color:var(--red)">${escHtml(e.message)}</div>`;
}
}
// Renders per-version file block inside modal cheat panel (has full data field)
function renderCheatFileBlock(f) {
let parsed;
try { parsed = typeof f.data === 'string' ? JSON.parse(f.data) : f.data; } catch (_) { parsed = null; }
const mods = parsed?.mods ?? [];
const modsHtml = mods.map(m => `
<div class="cheat-mod-row">
<span class="cheat-mod-name">${escHtml(m.name || '?')}</span>
<span class="cheat-mod-type">${escHtml(m.type || '')}</span>
</div>`).join('');
return `
<div class="cheat-file-block">
<div class="cheat-file-ver">v${escHtml(f.version || '?')} <span style="opacity:.55;font-size:.8em">${escHtml(f.filename || '')}</span></div>
<div class="cheat-mod-list">${modsHtml || '<span style="opacity:.45">No mods parsed</span>'}</div>
</div>`;
}
async function renderUnmatchedCheats() {
const section = $('cheats-unmatched-section');
const list = $('cheats-unmatched-list');
const badge = $('cheats-unmatched-count');
try {
const rows = await window.pork.cheatsUnmatched();
if (!rows || rows.length === 0) {
section.hidden = true;
return;
}
section.hidden = false;
badge.textContent = rows.length;
list.innerHTML = rows.map(r => `
<div class="cheat-unmatched-row" data-filename="${escHtml(r.filename)}">
<div class="cheat-unmatched-info">
<span class="cheat-unmatched-title">${escHtml(r.title || r.filename)}</span>
<span class="cheat-unmatched-file">${escHtml(r.filename)}</span>
</div>
<div class="cheat-unmatched-assign">
<input type="text" class="unmatched-cusa-input" placeholder="CUSA00000" maxlength="9" spellcheck="false" aria-label="Assign game ID"/>
<button class="btn btn-sm btn-accent btn-assign-cusa">Assign</button>
</div>
</div>`).join('');
} catch (e) {
section.hidden = false;
list.innerHTML = `<div class="empty-msg" style="color:var(--red)">${escHtml(e.message)}</div>`;
}
}
// Assign CUSA ID delegation
$('cheats-unmatched-list').addEventListener('click', async e => {
const btn = e.target.closest('.btn-assign-cusa');
if (!btn) return;
const row = btn.closest('.cheat-unmatched-row');
const filename = row?.dataset.filename;
const input = row?.querySelector('.unmatched-cusa-input');
const cusaId = input?.value.trim().toUpperCase();
if (!filename || !cusaId || !/^[A-Z]{4}\d{5}$/.test(cusaId)) {
input?.focus();
input?.classList.add('input-error');
setTimeout(() => input?.classList.remove('input-error'), 1200);
return;
}
btn.disabled = true;
btn.textContent = '…';
try {
await window.pork.cheatsAssign(filename, cusaId);
row.style.opacity = '0';
row.style.transition = 'opacity .3s';
setTimeout(() => renderUnmatchedCheats(), 320);
// Refresh stats / matched list
loadCheats();
} catch (err) {
btn.disabled = false;
btn.textContent = 'Assign';
setStatus(`Assign failed: ${err.message}`, 'error');
}
});
// Search input (debounced)
$('cheats-search').addEventListener('input', () => {
clearTimeout(_cheatsSearchTimer);
_cheatsSearchTimer = setTimeout(() => {
const q = $('cheats-search').value.trim();
renderCheatResults(q);
if (!q) renderUnmatchedCheats();
else $('cheats-unmatched-section').hidden = true;
}, 350);
});
// Download-all
$('btn-cheats-download-all').addEventListener('click', async () => {
_cheatsDownloading = true;
$('cheats-dl-panel').hidden = false;
$('cheats-dl-title').textContent = 'Fetching cheat index…';
$('cheats-dl-bar').style.width = '0%';
$('cheats-dl-count').textContent = '0 / 0';
$('cheats-dl-pct').textContent = '0%';
$('cheats-dl-saved').textContent = 'Saved: 0';
$('cheats-dl-failed').textContent = 'Failed: 0';
$('cheats-dl-eta').textContent = 'ETA: —';
$('cheats-dl-current').textContent = '';
$('btn-cheats-download-all').disabled = true;
try {
await window.pork.cheatsDownloadAll();
} catch (e) {
_cheatsDownloading = false;
$('cheats-dl-title').textContent = `Error: ${e.message}`;
$('btn-cheats-download-all').disabled = false;
}
});
// Cancel download
$('btn-cheats-dl-cancel').addEventListener('click', async () => {
await window.pork.cheatsCancel();
$('cheats-dl-title').textContent = 'Cancelling…';
});
// Progress IPC events
window.pork.on('cheats:dl:start', ({ total }) => {
$('cheats-dl-title').textContent = `Downloading cheat database…`;
$('cheats-dl-count').textContent = `0 / ${total}`;
$('cheats-dl-bar').style.width = '0%';
});
window.pork.on('cheats:dl:progress', ({ done, total, saved, failed, etaSec, current, pct }) => {
$('cheats-dl-bar').style.width = `${pct}%`;
$('cheats-dl-count').textContent = `${done} / ${total}`;
$('cheats-dl-pct').textContent = `${pct}%`;
$('cheats-dl-saved').textContent = `Saved: ${saved}`;
$('cheats-dl-failed').textContent = `Failed: ${failed}`;
$('cheats-dl-eta').textContent = `ETA: ${fmtEta(etaSec)}`;
$('cheats-dl-current').textContent = current || '';
});
window.pork.on('cheats:dl:done', ({ saved, failed, cancelled }) => {
_cheatsDownloading = false;
$('cheats-dl-bar').style.width = cancelled ? $('cheats-dl-bar').style.width : '100%';
$('cheats-dl-title').textContent = cancelled
? `Download cancelled — ${saved} saved, ${failed} failed`
: `Done — ${saved} saved, ${failed} failed`;
$('cheats-dl-pct').textContent = cancelled ? '' : '100%';
$('cheats-dl-eta').textContent = '';
$('cheats-dl-current').textContent = '';
$('btn-cheats-download-all').disabled = false;
// Scheduled server syncs also emit this event. Keep hidden-page work cheap;
// the full list and its artwork hydrate only when Cheats is actually open.
const active = $('page-cheats').classList.contains('active');
loadCheats({ background: !active });
});
// ── Cheats FTP install ─────────────────────────────────────────────────────────
$('btn-cheats-ftp-install').addEventListener('click', async () => {
// Reset panel to instruction state
$('cheats-ftp-progress-wrap').hidden = true;
$('cheats-ftp-bar').style.width = '0%';
$('cheats-ftp-phase').textContent = 'Preparing…';
$('cheats-ftp-count').textContent = '';
$('cheats-ftp-pct').textContent = '0%';
$('cheats-ftp-current').textContent = '';
$('cheats-ftp-title').textContent = 'Install Cheats via FTP';
$('btn-cheats-ftp-go').disabled = false;
$('btn-cheats-ftp-go').textContent = '↑ Install Now';
// Populate path from settings
const s = await window.pork.getSettings();
const configuredPath = (s.cheatsRemotePath || '').trim();
$('cheats-ftp-path').value = configuredPath || '/data/etaHEN/cheats/';
$('cheats-ftp-no-path-warn').hidden = !!configuredPath;
$('cheats-ftp-panel').hidden = false;
});
$('btn-cheats-ftp-browse').addEventListener('click', async () => {
const cur = $('cheats-ftp-path').value.trim() || '/';
const p = await openFtpBrowser(cur);
if (p) $('cheats-ftp-path').value = p;
});
$('btn-cheats-ftp-close').addEventListener('click', () => {
$('cheats-ftp-panel').hidden = true;
});
$('btn-cheats-ftp-go').addEventListener('click', async () => {
$('btn-cheats-ftp-go').disabled = true;
$('btn-cheats-ftp-go').textContent = 'Installing…';
$('cheats-ftp-progress-wrap').hidden = false;
try {
const remotePath = $('cheats-ftp-path').value.trim() || '/data/etaHEN/cheats/';
await window.pork.cheatsFtpInstall(remotePath);
} catch (e) {
$('cheats-ftp-title').textContent = `Error: ${e.message}`;
$('btn-cheats-ftp-go').disabled = false;
$('btn-cheats-ftp-go').textContent = '↑ Retry';
}
});
window.pork.on('cheats:ftp:start', ({ total }) => {
$('cheats-ftp-count').textContent = `0 / ${total}`;
});
window.pork.on('cheats:ftp:progress', ({ phase, done, total, pct, current }) => {
$('cheats-ftp-bar').style.width = `${pct}%`;
$('cheats-ftp-pct').textContent = `${pct}%`;
$('cheats-ftp-count').textContent = `${done} / ${total}`;
$('cheats-ftp-phase').textContent = phase === 'write' ? 'Writing files…' : 'Uploading to PS5…';
$('cheats-ftp-current').textContent = current || '';
});
window.pork.on('cheats:ftp:done', ({ count }) => {
$('cheats-ftp-bar').style.width = '100%';
$('cheats-ftp-pct').textContent = '100%';
$('cheats-ftp-phase').textContent = `Done — ${count} cheats installed`;
$('cheats-ftp-current').textContent = '';
$('cheats-ftp-title').textContent = 'Install complete ✓';
$('btn-cheats-ftp-go').disabled = false;
$('btn-cheats-ftp-go').textContent = '↑ Install Again';
});
// ── Cheats modal panel ─────────────────────────────────────────────────────────
async function renderCheatsPanel(game) {
const body = $('modal-cheats-body');
const badge = $('mtab-cheats-count');
body.innerHTML = '<div class="prospero-placeholder">Loading cheats…</div>';
badge.textContent = '';
try {
const cusaId = game.game_id;
const files = await window.pork.cheatsForGame(cusaId);
if (!files || files.length === 0) {
// Nothing cached — offer to fetch on-demand
body.innerHTML = `
<div class="modal-cheat-fetch">
<p>No cached cheats found for <code>${escHtml(cusaId)}</code>.</p>
<button id="btn-modal-fetch-cheats" class="btn btn-accent">Fetch from Repository</button>
</div>`;
$('btn-modal-fetch-cheats').addEventListener('click', async () => {
const fetchBtn = $('btn-modal-fetch-cheats');
fetchBtn.disabled = true;
fetchBtn.textContent = 'Fetching…';
try {
const index = await window.pork.cheatsFetchIndex();
const matching = index.filter(e => e.cusaId === cusaId);
if (matching.length === 0) {
body.innerHTML = `<div class="prospero-placeholder">No cheats found in repository for ${escHtml(cusaId)}.</div>`;
return;
}
// Download the cheat database (cached, with progress on the Cheats page),
// then re-render this game's cheats from the freshly populated cache.
fetchBtn.textContent = `Downloading ${matching.length} file(s)…`;
await window.pork.cheatsDownloadAll();
const cached = await window.pork.cheatsForGame(cusaId);
if (cached && cached.length) {
badge.textContent = cached.length;
body.innerHTML = cached.map(f => renderCheatFileBlock(f)).join('');
} else {
body.innerHTML = `<div class="prospero-placeholder">Downloaded, but no cheats cached for ${escHtml(cusaId)}.</div>`;
}
} catch (e) {
body.innerHTML = `<div class="prospero-placeholder" style="color:var(--red)">Fetch failed: ${escHtml(e.message)}</div>`;
}
});
return;
}
badge.textContent = files.length;
const modCount = files.reduce((n, f) => {
try { const d = typeof f.data === 'string' ? JSON.parse(f.data) : f.data; return n + (d?.mods?.length ?? 0); } catch (_) { return n; }
}, 0);
body.innerHTML = files.map(f => renderCheatFileBlock(f)).join('');
badge.textContent = `${modCount}`;
} catch (e) {
body.innerHTML = `<div class="prospero-placeholder" style="color:var(--red)">${escHtml(e.message)}</div>`;
}
}
@@ -0,0 +1,28 @@
(function initCompatibilityGuidance(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.chimeraCompatibilityGuidance = api;
})(typeof window !== 'undefined' ? window : null, function compatibilityGuidanceFactory() {
'use strict';
const STATES = new Set(['compatible', 'incompatible', 'workaround', 'conflict', 'unknown']);
const esc = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]);
const tr = (key, fallback) => typeof window !== 'undefined' && window.t?.(key) && window.t(key) !== key ? window.t(key) : fallback;
function normalize(value = {}) { const state = STATES.has(value.state) ? value.state : 'unknown'; return { state, blocking: value.blocking === true, rationale: Array.isArray(value.rationale) ? value.rationale : [], workarounds: Array.isArray(value.workarounds) ? value.workarounds : [] }; }
function markup(value) {
const guidance = normalize(value);
const labels = { compatible: 'Compatible', incompatible: 'Incompatible', workaround: 'Workaround available', conflict: 'Conflicting evidence', unknown: 'Compatibility unknown' };
const rationale = guidance.rationale.map(item => `<li><strong>${esc(item.explanation)}</strong><span>${esc(item.providerId)} / ${esc(item.revision)} / ${esc(item.confidence)} confidence</span><a href="${esc(item.sourceUrl)}" target="_blank" rel="noreferrer">${esc(tr('compatibilityGuidance.inspectSource', 'Inspect source'))}</a></li>`).join('');
const workarounds = guidance.workarounds.map(item => `<li>${esc(item.explanation)} <small>${esc(item.providerId)} / ${esc(item.revision)}</small></li>`).join('');
const detail = guidance.state === 'unknown' ? tr('compatibilityGuidance.unknownDetail', 'No applicable evidence is cached for this exact context.') : guidance.state === 'conflict' ? tr('compatibilityGuidance.conflictDetail', 'Sources disagree. Chimera does not resolve this automatically.') : guidance.blocking ? tr('compatibilityGuidance.blockingDetail', 'Blocked by critical, high-confidence, non-conflicting evidence.') : tr('compatibilityGuidance.informationalDetail', 'Guidance is informational unless the evidence meets the blocking policy.');
return `<article class="compatibility-guidance state-${guidance.state}" data-compatibility-state="${guidance.state}" role="status"><header><strong>${esc(tr(`compatibilityGuidance.state.${guidance.state}`, labels[guidance.state]))}</strong>${guidance.blocking ? `<span>${esc(tr('compatibilityGuidance.blocked', 'Blocked'))}</span>` : ''}</header><p>${esc(detail)}</p>${rationale ? `<ul class="compatibility-rationale">${rationale}</ul>` : ''}${workarounds ? `<details><summary>${esc(tr('compatibilityGuidance.workarounds', 'Safe workaround guidance'))}</summary><ul>${workarounds}</ul></details>` : ''}</article>`;
}
async function load(host, context) {
if (!host || typeof window === 'undefined') return null;
let guidance;
try { guidance = await window.pork.compatibilityGuidance(context); } catch (_) { guidance = { state: 'unknown', unknownReason: 'unavailable' }; }
host.querySelector('.compatibility-guidance')?.remove();
host.insertAdjacentHTML('beforeend', markup(guidance));
return guidance;
}
return Object.freeze({ normalize, markup, load });
});
+268
View File
@@ -0,0 +1,268 @@
// ── Connection state ──────────────────────────────────────────────────────────
function setConnected(on) {
state.connected = on;
const dot = $('conn-dot');
const label = $('conn-label');
const badge = $('dash-conn-badge');
const shellHealth = $('chimera-shell-health');
const shellHealthLabel = $('chimera-shell-health-label');
const tr = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback;
dot.className = `dot ${on ? 'green' : 'red'}`;
label.textContent = on ? tr('connection.connected', 'Connected') : tr('connection.disconnected', 'Disconnected');
badge.textContent = on ? tr('connection.online', 'Online') : tr('connection.offline', 'Offline');
badge.className = `badge ${on ? 'badge-green' : 'badge-red'}`;
if (shellHealth) {
shellHealth.classList.toggle('is-online', on);
shellHealth.classList.toggle('is-offline', !on);
}
if (shellHealthLabel) {
shellHealthLabel.textContent = on
? tr('connection.ready', 'Ready')
: tr('connection.disconnected', 'Disconnected');
shellHealthLabel.dataset.i18n = on ? 'connection.ready' : 'connection.disconnected';
}
$('btn-connect').disabled = on;
$('btn-disconnect').disabled = !on;
$('btn-scan').disabled = !on;
// Re-evaluate PS5 action buttons — only enabled when connected AND a location is stored
const deleteBtn = $('btn-manage-delete-ps5');
const backupBtn = $('btn-manage-backup-ps5');
if (deleteBtn) deleteBtn.disabled = !on || !state.currentGame?.ftp_path;
if (backupBtn) backupBtn.disabled = !on || !state.currentGame?.ftp_path;
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
let lastHomeReadiness = null;
async function loadDashboard() {
try {
const [settings, stats, catalog, ftpStatus] = await Promise.all([
window.pork.getSettings(),
window.pork.getStats(),
window.pork.gameCatalogHealth().catch(() => ({})),
window.pork.ftpStatus().catch(() => ({ connected: state.connected })),
]);
$('s-total').textContent = stats.totalGames;
$('s-installed').textContent = stats.installed;
$('s-backedup').textContent = stats.backedUp;
$('s-backups').textContent = stats.totalBackups;
if ($('d-host') && document.activeElement !== $('d-host') && !$('d-host').value) {
$('d-host').value = settings.ftpHost || '';
}
if ($('d-ports') && document.activeElement !== $('d-ports') && !$('d-ports').value) {
$('d-ports').value = (settings.ftpPorts && settings.ftpPorts.length) ? settings.ftpPorts.join(', ') : '1337, 2121, 21';
}
renderHomeReadiness({ settings, stats, catalog, connected: Boolean(ftpStatus.connected) });
} catch (_) {}
renderPinnedTiles();
}
function renderHomeReadiness({ settings = {}, stats, catalog, connected }) {
lastHomeReadiness = { settings, stats, catalog, connected };
const tr = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback;
const setIndicator = (id, stateName) => {
const indicator = $(id);
if (indicator) indicator.className = `chimera-readiness-dot ${stateName}`;
};
const complete = Number(catalog.complete || 0);
const missing = Number(catalog.missing || catalog.incomplete || 0);
const total = Number(stats.totalGames || 0);
const backedUp = Number(stats.backedUp || 0);
const host = String(settings.ftpHost || '').trim();
const hostConfigured = Boolean(host);
const consoleStatus = $('home-console-status');
const libraryStatus = $('home-library-status');
const backupStatus = $('home-backup-status');
const inspectorState = $('home-inspector-state');
const inspectorReason = $('home-inspector-reason');
const inspectorTarget = $('home-inspector-target');
const libraryTruth = $('home-truth-library');
const protectionTruth = $('home-truth-protection');
if (consoleStatus) consoleStatus.textContent = connected
? tr('home.consoleReady', 'Connected · manual operations')
: hostConfigured
? tr('home.targetConfiguredOffline', 'Configured · offline')
: tr('navigation.unavailable', 'Not configured');
if (libraryStatus) libraryStatus.textContent = total
? `${complete}/${total} ${tr('home.titlesEnriched', 'titles fully enriched')}${missing ? ` · ${missing} ${tr('home.toComplete', 'to complete')}` : ''}`
: tr('home.noGames', 'No games detected yet');
if (backupStatus) backupStatus.textContent = total
? `${backedUp}/${total} ${tr('home.gamesProtected', 'games protected')}`
: `${Number(stats.totalBackups || 0)} ${tr('home.backupFiles', 'backup files')}`;
if (inspectorState) {
inspectorState.textContent = connected
? tr('connection.ready', 'Ready')
: hostConfigured
? tr('home.targetOffline', 'Offline')
: tr('navigation.unavailable', 'Not configured');
inspectorState.className = `chimera-node-state ${connected ? 'ok' : hostConfigured ? 'attention' : 'unknown'}`;
}
if (inspectorReason) inspectorReason.textContent = connected
? tr('home.targetReasonConnected', 'A bounded FTP session with the PS5 is active.')
: hostConfigured
? tr('home.targetReasonOffline', 'The PS5 target is saved, but there is no active FTP session.')
: tr('home.targetReasonMissing', 'Chimera needs an explicit PS5 target before console features can connect.');
if (inspectorTarget) inspectorTarget.textContent = hostConfigured ? host : tr('navigation.unavailable', 'Not configured');
if (libraryTruth) libraryTruth.textContent = total
? `${complete}/${total} ${tr('home.titlesEnriched', 'titles fully enriched')}`
: tr('home.noGames', 'No games detected yet');
if (protectionTruth) protectionTruth.textContent = total
? `${backedUp}/${total} ${tr('home.gamesProtected', 'games protected')}`
: `${Number(stats.totalBackups || 0)} ${tr('home.backupFiles', 'backup files')}`;
setIndicator('home-console-indicator', connected ? 'ok' : hostConfigured ? 'attention' : 'unknown');
setIndicator('home-library-indicator', total && missing === 0 ? 'ok' : total ? 'attention' : 'unknown');
setIndicator('home-backup-indicator', total && backedUp >= total ? 'ok' : backedUp ? 'attention' : 'unknown');
const title = $('home-next-title');
const detail = $('home-next-detail');
const action = $('home-next-action');
if (!title || !detail || !action) return;
if (!hostConfigured) {
title.textContent = tr('home.next.configureTitle', 'Configure the PS5 target');
detail.textContent = tr('home.next.configureDetail', 'Choose the console target yourself. Chimera will not guess an address or start a connection automatically.');
action.textContent = tr('home.next.configureAction', 'Configure PS5 target');
action.dataset.route = 'settings/general';
action.dataset.focusTarget = 's-host';
} else if (!connected) {
title.textContent = tr('home.next.connectTitle', 'Connect safely to the PS5 first');
detail.textContent = tr('home.next.connectDetail', 'The target is configured. Review it and start the bounded FTP connection manually.');
action.textContent = tr('home.next.connectAction', 'Review connection');
action.dataset.route = 'home';
action.dataset.focusTarget = 'd-host';
} else if (!total) {
title.textContent = tr('home.next.scanTitle', 'Detect your game library');
detail.textContent = tr('home.next.scanDetail', 'The PS5 is reachable, but the Chimera catalog does not contain any games yet.');
action.textContent = tr('home.next.scanAction', 'Scan games');
action.dataset.route = 'library/games';
delete action.dataset.focusTarget;
} else if (missing > 0) {
title.textContent = `${tr('home.next.completeMetadata', 'Complete metadata for')} ${missing} ${missing === 1 ? tr('home.game', 'game') : tr('home.games', 'games')}`;
detail.textContent = tr('home.next.metadataDetail', 'Titles, cover art and version information are enriched automatically; review only persistent exceptions.');
action.textContent = tr('home.next.openLibrary', 'Open library');
action.dataset.route = 'library/games';
delete action.dataset.focusTarget;
} else if (backedUp < total) {
const remaining = total - backedUp;
title.textContent = `${remaining} ${remaining === 1 ? tr('home.game', 'game') : tr('home.games', 'games')} ${tr('home.next.notProtected', 'not protected yet')}`;
detail.textContent = tr('home.next.backupDetail', 'Review backups and create a new copy only from a confirmed source.');
action.textContent = tr('home.next.checkBackups', 'Check backups');
action.dataset.route = 'protection/backups';
delete action.dataset.focusTarget;
} else {
title.textContent = tr('home.next.readyTitle', 'Chimera is ready');
detail.textContent = tr('home.next.readyDetail', 'Console, metadata and library protection are in order. Updates remain explicit and reviewable.');
action.textContent = tr('home.next.reviewPayloads', 'Review payload updates');
action.dataset.route = 'payloads/updates';
delete action.dataset.focusTarget;
}
}
window.addEventListener('chimera:language-changed', () => {
if (lastHomeReadiness) renderHomeReadiness(lastHomeReadiness);
});
async function renderPinnedTiles() {
const grid = $('dash-pinned-grid');
const empty = $('dash-pinned-empty');
if (!grid) return;
try {
const s = await window.pork.getSettings();
const pinned = new Set(s.pinnedCards || []);
const cards = PINNABLE_CARDS.filter(c => pinned.has(c.id));
grid.innerHTML = '';
if (!cards.length) {
empty.hidden = false;
} else {
empty.hidden = true;
for (const c of cards) {
const tile = document.createElement('div');
tile.className = 'dash-pinned-tile';
tile.dataset.navPage = c.page;
if (c.type === 'widget') {
tile.classList.add('dash-pinned-tile--widget');
tile.removeAttribute('data-nav-page');
tile.innerHTML = _renderWidgetTile(c);
grid.appendChild(tile);
_initWidgetTile(c, tile);
continue; // already appended
} else {
tile.innerHTML = `
<div class="dash-pinned-tile-title">${escHtml(c.label)}</div>
<div class="dash-pinned-tile-page">${escHtml(_PIN_PAGE_LABELS[c.page] || c.page)}</div>
<div class="dash-pinned-tile-hint">${escHtml(c.hint)}</div>
<button class="btn btn-sm dash-pinned-tile-open">Open &rarr;</button>`;
}
grid.appendChild(tile);
}
}
// Sync all pin button visual states across the whole page
document.querySelectorAll('.btn-pin').forEach(btn => {
btn.classList.toggle('pinned', pinned.has(btn.dataset.pinId));
});
} catch (_) {}
}
// ── FTP Connect ───────────────────────────────────────────────────────────────
$('btn-connect').addEventListener('click', async () => {
const host = $('d-host').value.trim();
const ports = $('d-ports').value.split(',').map(p => p.trim()).filter(Boolean);
if (!ports.length) ports.push('21');
if (!host) { setStatus('Enter a host address', 'error'); return; }
// Credential fields start blank. Fall back to stored settings when blank
// so the user can click Connect without re-typing every session.
const saved = await window.pork.getSettings();
const user = $('d-user').value.trim() || saved.ftpUser || '';
const password = $('d-pass').value || saved.ftpPass || '';
$('btn-connect').disabled = true;
setStatus('Connecting…');
try {
const result = await window.pork.ftpConnect({ host, ports, user, password });
setConnected(true);
setStatus('Connected', 'ok');
$('sb-sync').textContent = `Connected on :${result?.port || ports[0]} at ${new Date().toLocaleTimeString()}`;
} catch (e) {
setConnected(false);
setStatus(`Connection failed: ${e.message}`, 'error');
$('btn-connect').disabled = false;
}
});
$('btn-disconnect').addEventListener('click', async () => {
await window.pork.ftpDisconnect();
setConnected(false);
setStatus('Disconnected');
});
// ── FTP Scan ──────────────────────────────────────────────────────────────────
$('btn-scan').addEventListener('click', async () => {
const prog = $('scan-progress');
prog.hidden = false;
prog.textContent = 'Scanning PS5 game folders…';
$('btn-scan').disabled = true;
window.pork.on('ftp:progress', ({ current, game }) => {
prog.textContent = `[${current}] Found: ${game.title || game.game_id}`;
});
try {
const { count } = await window.pork.ftpScan();
prog.textContent = `Scan complete — ${count} game(s) found.`;
$('sb-sync').textContent = `Last sync: ${new Date().toLocaleTimeString()}`;
setStatus(`Scanned ${count} games`, 'ok');
await loadDashboard();
} catch (e) {
prog.textContent = `Error: ${e.message}`;
setStatus(e.message, 'error');
} finally {
window.pork.off('ftp:progress');
$('btn-scan').disabled = false;
}
});
+317
View File
@@ -0,0 +1,317 @@
'use strict';
// ── Dashboard Interactive Mini-Widgets ────────────────────────────────────────
// Renders and drives interactive tool tiles pinned to the dashboard.
// Called from connection.js renderPinnedTiles() for cards with type:'widget'.
// Off-screen canvas used by the XAvatar widget for pixel conversion
const _dwXaCanvas = document.createElement('canvas');
let _dwXaFilename = 'avatar';
// ── Render ─────────────────────────────────────────────────────────────────────
// Returns an innerHTML string for a given pinnable card.
function _renderWidgetTile(card) {
switch (card.id) {
case 'psnotify':
return `
<div class="dash-widget-hdr">&#128226; Send Notification</div>
<input class="dash-widget-input" data-dw-key="psnotify-msg" placeholder="Message&#8230;" maxlength="120">
<input class="dash-widget-input" data-dw-key="psnotify-sub" placeholder="Sub-message (optional)&#8230;" maxlength="120">
<div class="dash-widget-row">
<button class="btn btn-sm btn-teal dash-widget-btn" data-dw-action="psnotify-send">&#128226; Send</button>
<span class="dash-widget-status" data-dw-status="psnotify"></span>
</div>
<div class="dash-widget-row" style="margin-top:4px">
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="psnotify">Open &rarr;</button>
</div>`;
case 'payload-mgr':
return `
<div class="dash-widget-hdr">&#8680; Quick Payload Send</div>
<select class="dash-widget-select" data-dw-key="payload-file">
<option value="">Loading payloads&#8230;</option>
</select>
<div class="dash-widget-row">
<span class="dash-widget-port-lbl">Port</span>
<input type="number" class="dash-widget-port-input" data-dw-key="payload-port" value="9021" min="1" max="65535">
<button class="btn btn-sm btn-teal dash-widget-btn" data-dw-action="payload-send">&#8680; Send</button>
<span class="dash-widget-status" data-dw-status="payload"></span>
</div>
<div class="dash-widget-row" style="margin-top:4px">
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="jailbreak">Open &rarr;</button>
</div>`;
case 'xavatar-convert':
return `
<div class="dash-widget-hdr">&#128100; XAvatar Quick Send</div>
<div class="dash-widget-drop" data-dw-drop="xavatar">Drop image or click to browse</div>
<div class="dash-widget-row">
<button class="btn btn-sm dash-widget-btn" data-dw-action="xavatar-browse">Browse&#8230;</button>
<button class="btn btn-sm btn-teal dash-widget-btn" data-dw-action="xavatar-send" disabled>&#8599; Send to PS5</button>
<span class="dash-widget-status" data-dw-status="xavatar"></span>
</div>
<div class="dash-widget-row" style="margin-top:4px">
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="xavatar">Open &rarr;</button>
</div>`;
case 'dash-savemgr':
return `
<div class="dash-widget-hdr">&#128190; Save Manager</div>
<div class="dash-widget-savelist" data-dw-list="savemgr">Click Reload to fetch saves.</div>
<div class="dash-widget-row">
<button class="btn btn-sm dash-widget-btn" data-dw-action="savemgr-reload">&#8635; Reload</button>
<span class="dash-widget-status" data-dw-status="savemgr"></span>
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="savemgr">Open &rarr;</button>
</div>`;
default:
// Fallback: plain nav tile for unknown widget IDs
return `
<div class="dash-pinned-tile-title">${escHtml(card.label)}</div>
<div class="dash-pinned-tile-hint">${escHtml(card.hint)}</div>
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="${escHtml(card.page)}">Open &rarr;</button>`;
}
}
// ── Post-render init ───────────────────────────────────────────────────────────
// Called immediately after the tile is appended to the DOM.
function _initWidgetTile(card, tileEl) {
switch (card.id) {
case 'payload-mgr':
_dwPopulatePayloadSelect(tileEl);
break;
case 'xavatar-convert':
_dwWireXavatarDrop(tileEl);
break;
default:
break;
}
}
// ── Payload select population ──────────────────────────────────────────────────
async function _dwPopulatePayloadSelect(tileEl) {
const sel = tileEl.querySelector('[data-dw-key="payload-file"]');
if (!sel) return;
try {
const files = await window.pork.payloadListLocal();
if (!files.length) {
sel.innerHTML = '<option value="">No local payloads</option>';
return;
}
sel.innerHTML = files.map(f =>
`<option value="${escHtml(f.local_path)}|${escHtml(f.name)}">${escHtml(f.name)}</option>`
).join('');
} catch (_) {
sel.innerHTML = '<option value="">Error loading payloads</option>';
}
}
// ── XAvatar drop-zone wiring ───────────────────────────────────────────────────
function _dwWireXavatarDrop(tileEl) {
const drop = tileEl.querySelector('[data-dw-drop="xavatar"]');
if (!drop) return;
drop.addEventListener('click', () => {
const inp = document.createElement('input');
inp.type = 'file';
inp.accept = 'image/*';
inp.onchange = e => {
const file = e.target.files[0];
if (file) _dwLoadXavatarFile(file, tileEl);
};
inp.click();
});
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('dw-drag-over'); });
drop.addEventListener('dragleave', () => drop.classList.remove('dw-drag-over'));
drop.addEventListener('drop', e => {
e.preventDefault();
drop.classList.remove('dw-drag-over');
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith('image/')) _dwLoadXavatarFile(file, tileEl);
});
}
async function _dwLoadXavatarFile(file, tileEl) {
const status = tileEl.querySelector('[data-dw-status="xavatar"]');
const sendBtn = tileEl.querySelector('[data-dw-action="xavatar-send"]');
const drop = tileEl.querySelector('[data-dw-drop="xavatar"]');
if (status) status.textContent = 'Loading…';
try {
const bmp = await createImageBitmap(file);
_dwXaCanvas.width = bmp.width;
_dwXaCanvas.height = bmp.height;
_dwXaCanvas.getContext('2d').drawImage(bmp, 0, 0);
_dwXaFilename = file.name.replace(/\.[^.]+$/, '');
if (drop) drop.textContent = file.name;
if (sendBtn) sendBtn.disabled = false;
if (status) status.textContent = 'Ready';
} catch (err) {
if (status) status.textContent = 'Load failed';
}
}
// ── Save Manager ───────────────────────────────────────────────────────────────
async function _dwSavemgrLoad(btn) {
const tile = btn.closest('.dash-pinned-tile');
const listEl = tile?.querySelector('[data-dw-list="savemgr"]');
const status = tile?.querySelector('[data-dw-status="savemgr"]');
if (!listEl) return;
btn.disabled = true;
btn.textContent = 'Loading…';
if (status) status.textContent = '';
try {
const r = await window.pork.savemgrRequest('/api/saves');
const saves = typeof r === 'string' ? JSON.parse(r) : r;
if (!saves || !saves.length) {
listEl.textContent = 'No saves found.';
if (status) status.textContent = '0 saves';
} else {
listEl.innerHTML = saves.slice(0, 8).map((s, i) =>
`<div class="dash-dw-save-row">
<span class="dash-dw-save-name" title="${escHtml(s.titleId || '')}">${escHtml(s.name || s.title || s.titleId || `Save ${i}`)}</span>
<button class="btn btn-xs dash-widget-btn" data-dw-action="savemgr-dl" data-sm-idx="${i}">&#11015; ZIP</button>
</div>`
).join('');
if (status) status.textContent = `${saves.length} save${saves.length !== 1 ? 's' : ''}`;
}
} catch (err) {
listEl.textContent = 'Error: ' + err.message;
} finally {
btn.disabled = false;
btn.textContent = '↻ Reload';
}
}
async function _dwSavemgrDl(btn) {
const idx = btn.dataset.smIdx;
const tile = btn.closest('.dash-pinned-tile');
const status = tile?.querySelector('[data-dw-status="savemgr"]');
btn.disabled = true;
btn.textContent = '…';
if (status) status.textContent = 'Mounting…';
try {
await window.pork.savemgrRequest('/api/mount?idx=' + idx);
if (status) status.textContent = 'Downloading…';
await window.pork.savemgrDownload('/api/download', `save_${idx}.zip`);
if (status) status.textContent = '✓ Saved';
setTimeout(() => { if (status) status.textContent = ''; }, 4000);
} catch (err) {
if (status) status.textContent = 'Error: ' + err.message;
} finally {
btn.disabled = false;
btn.textContent = '⬇ ZIP';
}
}
// ── Event delegation ───────────────────────────────────────────────────────────
document.addEventListener('click', async e => {
// "Open →" buttons inside widget tiles — navigate to the full page
const navBtn = e.target.closest('.dash-pinned-tile-open[data-nav-page]');
if (navBtn && navBtn.closest('.dash-pinned-tile--widget')) {
e.stopPropagation();
navigate(navBtn.dataset.navPage);
return;
}
const btn = e.target.closest('.dash-widget-btn[data-dw-action]');
if (!btn) return;
e.stopPropagation();
switch (btn.dataset.dwAction) {
case 'psnotify-send': await _dwPsnotifySend(btn); break;
case 'payload-send': await _dwPayloadSend(btn); break;
case 'xavatar-browse': _dwXavatarBrowseClick(btn); break;
case 'xavatar-send': await _dwXavatarSend(btn); break;
case 'savemgr-reload': await _dwSavemgrLoad(btn); break;
case 'savemgr-dl': await _dwSavemgrDl(btn); break;
}
});
// ── PSNotify action ────────────────────────────────────────────────────────────
async function _dwPsnotifySend(btn) {
const tile = btn.closest('.dash-pinned-tile');
const msg = tile?.querySelector('[data-dw-key="psnotify-msg"]')?.value?.trim() || '';
const sub = tile?.querySelector('[data-dw-key="psnotify-sub"]')?.value?.trim() || '';
const status = tile?.querySelector('[data-dw-status="psnotify"]');
if (!msg) { if (status) { status.textContent = 'Message required'; status.style.color = 'var(--red)'; } return; }
btn.disabled = true;
if (status) { status.textContent = 'Sending…'; status.style.color = ''; }
try {
await window.pork.psnotifySend(msg, sub);
if (status) { status.textContent = '✓ Sent'; status.style.color = 'var(--green)'; }
if (tile) {
const msgEl = tile.querySelector('[data-dw-key="psnotify-msg"]');
const subEl = tile.querySelector('[data-dw-key="psnotify-sub"]');
if (msgEl) msgEl.value = '';
if (subEl) subEl.value = '';
}
setTimeout(() => { if (status) { status.textContent = ''; status.style.color = ''; } }, 3000);
} catch (err) {
if (status) { status.textContent = 'Error: ' + err.message; status.style.color = 'var(--red)'; }
} finally {
btn.disabled = false;
}
}
// ── Payload action ─────────────────────────────────────────────────────────────
async function _dwPayloadSend(btn) {
const tile = btn.closest('.dash-pinned-tile');
const sel = tile?.querySelector('[data-dw-key="payload-file"]');
const portEl = tile?.querySelector('[data-dw-key="payload-port"]');
const status = tile?.querySelector('[data-dw-status="payload"]');
const val = sel?.value || '';
if (!val) { if (status) { status.textContent = 'Pick a payload'; status.style.color = 'var(--red)'; } return; }
const [localPath, filename] = val.split('|');
const port = parseInt(portEl?.value || '9021', 10);
btn.disabled = true;
if (status) { status.textContent = 'Sending…'; status.style.color = ''; }
try {
await window.pork.payloadTcpSend({ localPath, port, filename });
if (status) { status.textContent = `✓ ${filename} sent`; status.style.color = 'var(--green)'; }
setTimeout(() => { if (status) { status.textContent = ''; status.style.color = ''; } }, 4000);
} catch (err) {
if (status) { status.textContent = 'Error: ' + err.message; status.style.color = 'var(--red)'; }
} finally {
btn.disabled = false;
}
}
// ── XAvatar actions ────────────────────────────────────────────────────────────
function _dwXavatarBrowseClick(btn) {
const tile = btn.closest('.dash-pinned-tile');
const inp = document.createElement('input');
inp.type = 'file';
inp.accept = 'image/*';
inp.onchange = e => {
const file = e.target.files[0];
if (file) _dwLoadXavatarFile(file, tile);
};
inp.click();
}
async function _dwXavatarSend(btn) {
const tile = btn.closest('.dash-pinned-tile');
const status = tile?.querySelector('[data-dw-status="xavatar"]');
if (_dwXaCanvas.width === 0) return;
btn.disabled = true;
if (status) { status.textContent = 'Converting…'; status.style.color = ''; }
try {
const ctx = _dwXaCanvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, _dwXaCanvas.width, _dwXaCanvas.height);
const rgba = new Uint8Array(imgData.data.buffer);
const res = await window.pork.xavatarConvertCanvas(rgba, _dwXaCanvas.width, _dwXaCanvas.height, _dwXaFilename);
if (!res || !res.ok) throw new Error((res && res.error) || 'Conversion failed');
if (status) status.textContent = 'Uploading…';
const pad = n => String(n).padStart(5, '0');
const rnd = pad(Math.floor(Math.random() * 100000));
const name = `chimera${rnd}.xavatar`;
await window.pork.xavatarUploadToPs5(res.buffer?.buffer || res.buffer, name);
if (status) { status.textContent = `✓ Queued: ${name}`; status.style.color = 'var(--green)'; }
setTimeout(() => { if (status) { status.textContent = ''; status.style.color = ''; } }, 5000);
} catch (err) {
if (status) { status.textContent = 'Error: ' + err.message; status.style.color = 'var(--red)'; }
} finally {
btn.disabled = false;
}
}
+521
View File
@@ -0,0 +1,521 @@
// ── Database page ─────────────────────────────────────────────────────────────
const DB_PAGE_SIZE = 100;
const _dbPages = {
games: { offset: 0, total: 0 },
backups: { offset: 0, total: 0 },
hashes: { offset: 0, total: 0 },
};
async function loadDatabase() {
await Promise.all([loadDbTable('games'), loadDbTable('backups'), loadHashRowsTable()]);
}
async function loadHashRowsTable() {
try {
const [rows, countRows] = await Promise.all([
window.pork.dbQuery(`SELECT id, game_id, hash_type, firmware_label, file_size,
SUBSTR(file_hash,1,16) || '…' AS file_hash_short,
backup_path, verified, hashed_at
FROM game_hashes ORDER BY hashed_at DESC LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages.hashes.offset}`),
window.pork.dbQuery('SELECT COUNT(*) AS total FROM game_hashes'),
]);
_dbPages.hashes.total = Number(countRows[0]?.total || 0);
if (_dbPages.hashes.offset >= _dbPages.hashes.total && _dbPages.hashes.offset > 0) {
_dbPages.hashes.offset = Math.max(0, Math.floor(Math.max(0, _dbPages.hashes.total - 1) / DB_PAGE_SIZE) * DB_PAGE_SIZE);
return loadHashRowsTable();
}
const countEl = $('hash-rows-count');
if (countEl) countEl.textContent = dbPageLabel('hashes');
updateDbPager('hashes');
renderDbTableEl('db-hash-rows-table', rows, 'hashes');
} catch (e) {
setStatus(e.message, 'error');
}
}
async function loadDbTable(name) {
try {
const sql = name === 'games'
? `SELECT id, title, game_id, version, size, installed, backed_up, last_scanned FROM games ORDER BY title LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages[name].offset}`
: `SELECT id, game_id, backup_type, size, backup_path, created_at FROM backups ORDER BY created_at DESC LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages[name].offset}`;
const [rows, countRows] = await Promise.all([window.pork.dbQuery(sql), window.pork.dbQuery(`SELECT COUNT(*) AS total FROM ${name}`)]);
_dbPages[name].total = Number(countRows[0]?.total || 0);
if (_dbPages[name].offset >= _dbPages[name].total && _dbPages[name].offset > 0) {
_dbPages[name].offset = Math.max(0, Math.floor(Math.max(0, _dbPages[name].total - 1) / DB_PAGE_SIZE) * DB_PAGE_SIZE);
return loadDbTable(name);
}
const info = $(`db-${name}-page-info`);
if (info) info.textContent = dbPageLabel(name);
updateDbPager(name);
renderDbTableEl(`db-${name}-table`, rows, name);
} catch (e) {
setStatus(e.message, 'error');
}
}
function dbPageLabel(name) {
const page = _dbPages[name];
const first = page.total ? page.offset + 1 : 0;
const last = Math.min(page.total, page.offset + DB_PAGE_SIZE);
const label = name === 'games' ? 'games' : name === 'backups' ? 'back-ups' : 'hashes';
const separator = window.i18n?.current === 'nl' ? 'van' : 'of';
return `${first}–${last} ${separator} ${page.total} ${label}`;
}
function dbColumnLabel(column) {
return String(column || '')
.replace(/_/g, ' ')
.replace(/\b\w/g, letter => letter.toUpperCase())
.replace(/\bId\b/g, 'ID')
.replace(/\bSha 256\b/gi, 'SHA-256');
}
function dbFormatValue(column, value) {
if (value == null || value === '') return '—';
if (['size', 'file_size'].includes(column) && Number.isFinite(Number(value))) return _fmtBytes(Number(value));
if (['installed', 'backed_up', 'verified'].includes(column)) return Number(value) ? 'Yes' : 'No';
if (/(?:_at|last_scanned)$/.test(column)) {
const numeric = Number(value);
const timestamp = Number.isFinite(numeric) && numeric > 0 && numeric < 1e12 ? numeric * 1000 : value;
const date = new Date(timestamp);
if (!Number.isNaN(date.getTime())) return date.toLocaleString(window.i18n?.current || 'en');
}
return String(value);
}
function updateDbPager(name) {
document.querySelectorAll(`.db-page-btn[data-table="${name}"]`).forEach(button => {
button.disabled = button.dataset.direction === 'previous'
? _dbPages[name].offset === 0
: _dbPages[name].offset + DB_PAGE_SIZE >= _dbPages[name].total;
});
}
document.querySelectorAll('.db-page-btn').forEach(button => button.addEventListener('click', async () => {
const name = button.dataset.table;
const direction = button.dataset.direction;
if (!_dbPages[name]) return;
_dbPages[name].offset = direction === 'previous'
? Math.max(0, _dbPages[name].offset - DB_PAGE_SIZE)
: Math.min(Math.max(0, _dbPages[name].total - 1), _dbPages[name].offset + DB_PAGE_SIZE);
if (name === 'hashes') await loadHashRowsTable();
else await loadDbTable(name);
}));
function renderDbTableEl(tableId, rows, tableName) {
const table = $(tableId);
if (!table) return;
// Hide toolbar when table reloads
const toolbar = $(`toolbar-${tableName}`);
if (toolbar) toolbar.hidden = true;
if (!rows.length) {
table.innerHTML = '';
return;
}
const cols = Object.keys(rows[0]);
const hasId = cols.includes('id');
table.innerHTML = `
<thead><tr>${hasId ? '<th class="db-row-cb"><input type="checkbox" class="db-select-all" data-table="' + tableName + '"></th>' : ''}${cols.map(c => `<th>${escHtml(dbColumnLabel(c))}</th>`).join('')}</tr></thead>
<tbody>
${rows.map(r => {
const rowId = hasId ? r.id : '';
return `<tr data-row-id="${rowId}">${hasId ? `<td class="db-row-cb"><input type="checkbox" class="db-row-check" data-table="${tableName}" value="${rowId}"></td>` : ''}${cols.map(c => `<td>${escHtml(dbFormatValue(c, r[c]))}</td>`).join('')}</tr>`;
}).join('')}
</tbody>`;
if (!hasId) return;
// Wire up "select all" header checkbox
const selectAllCb = table.querySelector('.db-select-all');
if (selectAllCb) {
selectAllCb.addEventListener('change', () => {
table.querySelectorAll('.db-row-check').forEach(cb => {
cb.checked = selectAllCb.checked;
cb.closest('tr').classList.toggle('db-row-selected', selectAllCb.checked);
});
_updateRowToolbar(tableName);
});
}
// Wire up individual row checkboxes
table.querySelectorAll('.db-row-check').forEach(cb => {
cb.addEventListener('change', () => {
cb.closest('tr').classList.toggle('db-row-selected', cb.checked);
_updateRowToolbar(tableName);
});
});
}
function _getCheckedIds(tableName) {
const tableEl = tableName === 'games' ? $('db-games-table')
: tableName === 'backups' ? $('db-backups-table')
: $('db-hash-rows-table');
if (!tableEl) return [];
return [...tableEl.querySelectorAll('.db-row-check:checked')].map(cb => Number(cb.value));
}
function _updateRowToolbar(tableName) {
const ids = _getCheckedIds(tableName);
const toolbar = $(`toolbar-${tableName}`);
if (!toolbar) return;
toolbar.hidden = ids.length === 0;
const countEl = $(`toolbar-${tableName}-count`);
if (countEl) countEl.textContent = `${ids.length} selected`;
}
// DB Tabs
document.querySelectorAll('.db-tab').forEach(tab =>
tab.addEventListener('click', () => {
document.querySelectorAll('.db-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.db-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
$(`dbtab-${tab.dataset.dbtab}`).classList.add('active');
if (tab.dataset.dbtab === 'hash-rows') loadHashRowsTable();
if (tab.dataset.dbtab === 'hashes') loadHashDbContent();
})
);
// Query runner
$('btn-run-query').addEventListener('click', async () => {
const sql = $('query-input').value.trim();
if (!sql) return;
const out = $('query-results');
try {
const rows = await window.pork.dbQuery(sql);
if (!rows.length) { out.innerHTML = '<div class="empty-msg">Query returned no rows.</div>'; return; }
const cols = Object.keys(rows[0]);
out.innerHTML = `
<div class="table-wrap">
<table>
<thead><tr>${cols.map(c => `<th>${escHtml(c)}</th>`).join('')}</tr></thead>
<tbody>${rows.map(r => `<tr>${cols.map(c => `<td>${escHtml(r[c] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody>
</table>
</div>`;
} catch (e) {
out.innerHTML = `<div class="empty-msg" style="color:var(--red)">${escHtml(e.message)}</div>`;
}
});
// Exports
$('btn-export-sql').addEventListener('click', async () => {
const p = await window.pork.exportSql();
if (p) setStatus(`Exported to ${p}`, 'ok');
});
$('btn-export-games-csv').addEventListener('click', async () => {
const p = await window.pork.exportCsv('games');
if (p) setStatus(`Exported to ${p}`, 'ok');
});
$('btn-export-backups-csv').addEventListener('click', async () => {
const p = await window.pork.exportCsv('backups');
if (p) setStatus(`Exported to ${p}`, 'ok');
});
$('btn-export-hashes').addEventListener('click', async () => {
const p = await window.pork.hashExport();
if (p) setStatus(`Hash DB exported to ${p}`, 'ok');
});
// ── Hash Database tab ───────────────────────────────────────────────────────────────────────
async function loadHashDbContent() {
try {
const info = await window.pork.hashCommunityInfo();
const countEl = $('hdb-community-count');
const verEl = $('hdb-community-ver');
const srcEl = $('hdb-community-source');
const localEl = $('hdb-local-count');
if (!countEl) return; // tab panel not in DOM
countEl.textContent = `${info.count} hash${info.count !== 1 ? 'es' : ''}`;
countEl.className = info.count > 0 ? 'hash-badge hash-community' : 'hash-badge hash-none';
verEl.textContent = info.updated ? `v${info.version || 1} · ${info.updated}` : '';
srcEl.textContent = info.source === 'imported' ? '(user-imported)' : '(bundled)';
localEl.textContent = `${info.localCount} hash${info.localCount !== 1 ? 'es' : ''}`;
localEl.className = info.localCount > 0 ? 'hash-badge hash-local' : 'hash-badge hash-none';
} catch (_) {}
try {
const summaries = await window.pork.hashGameSummary();
const list = $('hdb-game-list');
if (!list) return;
if (!summaries.length) {
list.innerHTML = '<div class="hint" style="padding:16px;color:var(--text-dim)">No hashes recorded yet. Open any game, go to Hash Verification, and click \'Hash All Files\'. Or use the Hash All Games / Backporks buttons on those pages.</div>';
return;
}
summaries.sort((a, b) => {
if (a.community_mismatch !== b.community_mismatch) return a.community_mismatch ? -1 : 1;
if (a.community_matches !== b.community_matches) return b.community_matches - a.community_matches;
const typeOrder = { game: 0, backpork: 1 };
const tDiff = (typeOrder[a.hash_type] ?? 0) - (typeOrder[b.hash_type] ?? 0);
if (tDiff !== 0) return tDiff;
return a.game_id.localeCompare(b.game_id);
});
list.innerHTML = `<div class="table-wrap" style="margin-top:14px">
<table style="width:100%">
<thead><tr><th>Game ID</th><th>Type</th><th>Firmware</th><th>Files Hashed</th><th>Community Matches</th><th>Status</th></tr></thead>
<tbody>${summaries.map(s => {
const badge = s.community_mismatch
? `<span class="hash-badge hash-mismatch">⚠ Hash Mismatch</span>`
: s.community_matches > 0
? `<span class="hash-badge hash-community">✓ Verified (${s.community_matches}/${s.hash_count})</span>`
: `<span class="hash-badge hash-local">⧭ Hashed (${s.hash_count})</span>`;
const typeLabel = s.hash_type === 'backpork'
? `<span class="pill" style="background:var(--surface-2);color:var(--text-dim);font-size:10px">Backpork</span>`
: `<span class="pill" style="background:var(--surface-2);color:var(--text-dim);font-size:10px">Game</span>`;
const fwLabel = s.firmware_label ? escHtml(s.firmware_label) : '—';
return `<tr><td><code>${escHtml(s.game_id)}</code></td><td>${typeLabel}</td><td>${fwLabel}</td><td>${s.hash_count}</td><td>${s.community_matches || '—'}</td><td>${badge}</td></tr>`;
}).join('')}</tbody>
</table></div>`;
} catch (_) {}
}
const _fmtBytes = b => b < 1024 ? `${b} B` : b < 1048576 ? `${(b/1024).toFixed(1)} KB` : b < 1073741824 ? `${(b/1048576).toFixed(1)} MB` : `${(b/1073741824).toFixed(2)} GB`;
let _hashBulkRunning = false;
function _setBulkProgress({ show = true, pct = 0, status = '', fileStatus = '' } = {}) {
const wrap = $('bulk-hash-progress');
const bar = $('bulk-hash-bar');
const pctEl = $('bulk-hash-pct');
const statEl = $('hash-all-games-status');
const fileEl = $('bulk-hash-file-status');
if (!wrap) return;
wrap.style.display = show ? 'flex' : 'none';
if (bar) bar.style.width = `${pct}%`;
if (pctEl) pctEl.textContent = `${pct}%`;
if (statEl) statEl.textContent = status;
if (fileEl) fileEl.textContent = fileStatus;
}
async function _runBulkHash(source) {
if (_hashBulkRunning) { showToast('A hash operation is already running.', 'info'); return; }
_hashBulkRunning = true;
const isGames = source === 'local';
const btnEl = $(isGames ? 'btn-hash-all-games' : 'btn-hash-all-backporks');
const backporksStat = $('hash-all-backporks-status');
btnEl.disabled = true;
if (isGames) {
_setBulkProgress({ show: true, pct: 0, status: 'Preparing…', fileStatus: '' });
} else if (backporksStat) {
backporksStat.textContent = 'Preparing…';
}
try {
const { results, total, totalFilesHashed, totalBytesHashed } = await window.pork.hashBulk(source);
const ok = results.filter(r => r.ok).length;
const fail = results.filter(r => !r.ok).length;
const doneMsg = `Done ✓ — ${ok} game${ok !== 1 ? 's' : ''}, ${totalFilesHashed} file${totalFilesHashed !== 1 ? 's' : ''} (${_fmtBytes(totalBytesHashed || 0)})${fail ? `, ${fail} skipped` : ''}`;
if (isGames) {
_setBulkProgress({ show: true, pct: 100, status: doneMsg, fileStatus: '' });
// Fade the progress block away after a few seconds
setTimeout(() => _setBulkProgress({ show: false }), 6000);
} else if (backporksStat) {
backporksStat.textContent = doneMsg;
}
// Refresh hash summaries and re-render badges everywhere
const summaries = await window.pork.hashGameSummary();
_populateHashMaps(summaries);
renderGames();
showToast(`Hash complete — ${totalFilesHashed} files (${_fmtBytes(totalBytesHashed || 0)})`, 'ok');
} catch (e) {
const errMsg = 'Error: ' + e.message;
if (isGames) {
_setBulkProgress({ show: true, pct: 0, status: errMsg, fileStatus: '' });
} else if (backporksStat) {
backporksStat.textContent = errMsg;
}
showToast('Hash failed: ' + e.message, 'error');
} finally {
_hashBulkRunning = false;
btnEl.disabled = false;
}
}
window.pork.on('hash:bulk:progress', ({ phase, done, total, percent, gameId, currentFile, fileSize, filePct, totalFilesHashed, totalBytesHashed }) => {
const backporksStat = $('hash-all-backporks-status');
const isGamesPage = !!document.querySelector('#page-games.active');
const overallStatus = `Game ${done}/${total} (${percent}%) — ${gameId || ''}`
+ (totalFilesHashed ? ` | ${totalFilesHashed} file${totalFilesHashed !== 1 ? 's' : ''} hashed` : '')
+ (totalBytesHashed ? ` | ${_fmtBytes(totalBytesHashed)}` : '');
let fileStatus = '';
if (currentFile) {
const sizeSuffix = fileSize ? ` (${_fmtBytes(fileSize)})` : '';
const pctSuffix = (phase === 'file-progress' && filePct != null) ? ` — ${filePct}%` : '';
fileStatus = `⧭ ${currentFile}${sizeSuffix}${pctSuffix}`;
} else if (phase === 'backup-done') {
fileStatus = `✓ ${gameId} complete`;
}
// done = completed backups so far (0 during first game's files).
// Interpolate within the current slot: done/total gets us to the start of this slot,
// then add filePct spread across one slot width.
const barPct = (phase === 'file-progress' && filePct != null)
? Math.min(100, Math.round(done / (total || 1) * 100 + filePct / (total || 1)))
: percent;
_setBulkProgress({ show: true, pct: barPct, status: overallStatus, fileStatus });
// Mirror to backporks status if the games progress block isn't visible
if (backporksStat && !isGamesPage) {
backporksStat.textContent = overallStatus;
}
});
$('btn-hash-all-games').addEventListener('click', async () => {
const ok = await showConfirm('Hash All Game Backups?\n\nThis will SHA-256 every file in every local game backup. On large libraries this can take hours.\n\nThe app stays usable while hashing. Continue?');
if (ok) _runBulkHash('local');
});
$('btn-hash-all-backporks').addEventListener('click', async () => {
const ok = await showConfirm('Hash All Backpork Backups?\n\nThis will SHA-256 every file in every backpork backup. On large libraries this can take hours.\n\nThe app stays usable while hashing. Continue?');
if (ok) _runBulkHash('backpork');
});
$('btn-hdb-export').addEventListener('click', async () => {
const p = await window.pork.hashExport();
if (p) showToast(`Hash DB exported to ${p}`, 'ok');
});
$('btn-import-community-hashes').addEventListener('click', async () => {
const btn = $('btn-import-community-hashes');
btn.disabled = true;
try {
const result = await window.pork.hashImportCommunity();
if (!result) { btn.disabled = false; return; }
showToast(`Community hash list imported — ${result.count} hashes loaded`, 'ok');
await loadHashDbContent();
// Refresh game summaries so badges update immediately
const summaries = await window.pork.hashGameSummary();
_populateHashMaps(summaries);
renderGames();
} catch (e) {
showToast('Import failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
// ── Selective Clear dialog ──────────────────────────────────────────────────────
$('btn-clear-db').addEventListener('click', () => {
// Reset all checkboxes
document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => { cb.checked = false; });
$('clear-select-ok').disabled = true;
openAccessibleDialog($('clear-select-overlay'), document.querySelector('#clear-select-options input'));
});
// Toggle "Clear Selected" button enable/disable based on any checked
document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => {
const anyChecked = [...document.querySelectorAll('#clear-select-options input[type="checkbox"]')].some(c => c.checked);
$('clear-select-ok').disabled = !anyChecked;
});
});
// Select All button
$('clear-select-all').addEventListener('click', () => {
document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => { cb.checked = true; });
$('clear-select-ok').disabled = false;
});
// Cancel
$('clear-select-cancel').addEventListener('click', () => {
closeAccessibleDialog($('clear-select-overlay'));
});
// Clear Selected
$('clear-select-ok').addEventListener('click', async () => {
const checked = [...document.querySelectorAll('#clear-select-options input[type="checkbox"]')]
.filter(cb => cb.checked).map(cb => cb.value);
if (!checked.length) return;
const labels = checked.map(v => v.charAt(0).toUpperCase() + v.slice(1));
// Hide the selection overlay so the confirm dialog is visible on top
closeAccessibleDialog($('clear-select-overlay'));
const confirmation = await showExactConfirmation(`Permanently delete: ${labels.join(', ')}?\n\nThis cannot be undone.`, 'WIS SELECTIE');
if (!confirmation) {
// User cancelled — re-show the selection overlay with their choices preserved
openAccessibleDialog($('clear-select-overlay'), document.querySelector('#clear-select-options input:checked'));
return;
}
const opts = {};
checked.forEach(k => { opts[k] = true; });
await window.pork.dbClearSelective(opts, confirmation);
loadDatabase();
loadHashDbContent();
const summaries = await window.pork.hashGameSummary();
_populateHashMaps(summaries);
renderGames();
setStatus(`Cleared: ${labels.join(', ')}`, 'ok');
showToast(`Cleared: ${labels.join(', ')}`, 'ok');
});
// ── Row-level delete handlers ────────────────────────────────────────────────────
async function _deleteSelectedRows(tableName, dbTable) {
const ids = _getCheckedIds(tableName);
if (!ids.length) return;
const confirmation = await showExactConfirmation(`Delete ${ids.length} ${tableName} row${ids.length !== 1 ? 's' : ''}?\n\nThis cannot be undone.`, 'VERWIJDER');
if (!confirmation) return;
const { deleted } = await window.pork.dbDeleteRows(dbTable, ids, confirmation);
showToast(`Deleted ${deleted} ${tableName} row${deleted !== 1 ? 's' : ''}.`, 'ok');
// Reload the affected table
if (tableName === 'games') await loadDbTable('games');
if (tableName === 'backups') await loadDbTable('backups');
if (tableName === 'hashes') await loadHashRowsTable();
// Refresh game page data
const summaries = await window.pork.hashGameSummary();
_populateHashMaps(summaries);
renderGames();
}
function _selectAllRows(tableName) {
const tableEl = tableName === 'games' ? $('db-games-table')
: tableName === 'backups' ? $('db-backups-table')
: $('db-hash-rows-table');
tableEl.querySelectorAll('.db-row-check').forEach(cb => { cb.checked = true; cb.closest('tr').classList.add('db-row-selected'); });
const selectAll = tableEl.querySelector('.db-select-all');
if (selectAll) selectAll.checked = true;
_updateRowToolbar(tableName);
}
function _deselectAllRows(tableName) {
const tableEl = tableName === 'games' ? $('db-games-table')
: tableName === 'backups' ? $('db-backups-table')
: $('db-hash-rows-table');
tableEl.querySelectorAll('.db-row-check').forEach(cb => { cb.checked = false; cb.closest('tr').classList.remove('db-row-selected'); });
const selectAll = tableEl.querySelector('.db-select-all');
if (selectAll) selectAll.checked = false;
_updateRowToolbar(tableName);
}
// Games toolbar
$('btn-delete-games').addEventListener('click', () => _deleteSelectedRows('games', 'games'));
$('btn-select-all-games').addEventListener('click', () => _selectAllRows('games'));
$('btn-deselect-all-games').addEventListener('click', () => _deselectAllRows('games'));
// Backups toolbar
$('btn-delete-backups').addEventListener('click', () => _deleteSelectedRows('backups', 'backups'));
$('btn-select-all-backups').addEventListener('click', () => _selectAllRows('backups'));
$('btn-deselect-all-backups').addEventListener('click', () => _deselectAllRows('backups'));
// Hashes toolbar
$('btn-delete-hashes').addEventListener('click', () => _deleteSelectedRows('hashes', 'game_hashes'));
$('btn-select-all-hashes').addEventListener('click', () => _selectAllRows('hashes'));
$('btn-deselect-all-hashes').addEventListener('click', () => _deselectAllRows('hashes'));
async function _clearHashes() {
const confirmation = await showExactConfirmation('Delete all stored hashes from the database? This cannot be undone.', 'WIS HASHES');
if (!confirmation) return;
await window.pork.dbClearHashes(confirmation);
await loadHashRowsTable();
await loadHashDbContent();
const summaries = await window.pork.hashGameSummary();
_populateHashMaps(summaries);
renderGames();
showToast('All hashes cleared.', 'ok');
}
$('btn-clear-hashes').addEventListener('click', _clearHashes);
$('btn-hdb-clear-hashes').addEventListener('click', _clearHashes);
File diff suppressed because it is too large. Load diff
+213
View File
@@ -0,0 +1,213 @@
// ELF Arsenal native Chimera client. The upstream WebUI remains available in a
// separate browser tab, but is no longer embedded: its opaque iframe errors
// cannot be diagnosed and its storage assumptions conflict with sandboxing.
let _eaPath = '/';
let _eaConnected = false;
function eaFriendlyError(value) {
const message = String(value || '').trim();
if (/Configureer eerst een geldig PS5-adres/i.test(message)) return 'Configure a PS5 host in Settings first.';
if (/ELF Arsenal-payload.*gestart/i.test(message)) return 'Start the ELF Arsenal payload explicitly, then retry.';
return message || 'ELF Arsenal is unavailable on port 6969.';
}
function eaBytes(value) {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function eaJoin(base, name) {
const cleanName = String(name || '').replace(/^\/+|\/+$/g, '');
return base === '/' ? `/${cleanName}` : `${String(base).replace(/\/+$/, '')}/${cleanName}`;
}
function eaParent(value) {
const parts = String(value || '/').split('/').filter(Boolean);
parts.pop();
return parts.length ? `/${parts.join('/')}` : '/';
}
function eaSetStatus(message, type = '') {
const status = document.getElementById('ea-embed-status');
if (!status) return;
status.textContent = message;
status.className = `ea-native-status ${type}`;
}
function eaRenderList(packet) {
const target = document.getElementById('ea-native-list');
const pathLabel = document.getElementById('ea-native-path');
if (!target || !pathLabel) return;
pathLabel.innerHTML = `<button class="btn btn-xs ea-native-parent" ${_eaPath === '/' ? 'disabled' : ''} data-path="${escHtml(eaParent(_eaPath))}">← Up</button><code>${escHtml(_eaPath)}</code>`;
const entries = (packet.entries || []).slice().sort((left, right) =>
Number(Boolean(right.is_dir)) - Number(Boolean(left.is_dir)) || String(left.name).localeCompare(String(right.name)));
target.innerHTML = entries.length ? entries.map(entry => {
const remotePath = eaJoin(_eaPath, entry.name);
const isElf = !entry.is_dir && /\.elf$/i.test(entry.name);
return `<article class="ea-native-row" data-path="${escHtml(remotePath)}" data-name="${escHtml(entry.name)}" data-dir="${entry.is_dir ? 'true' : 'false'}">
<span class="ea-native-icon" aria-hidden="true">${entry.is_dir ? '📁' : isElf ? '◆' : '•'}</span>
<div><strong>${escHtml(entry.name)}</strong><span>${entry.is_dir ? 'Directory' : eaBytes(entry.size)}${entry.mode ? ` · ${escHtml(entry.mode)}` : ''}</span></div>
<div class="ecosystem-inline-actions">
${entry.is_dir ? '<button class="btn btn-sm ea-native-open">Open</button>' : `<button class="btn btn-sm ea-native-download">Download</button>${isElf ? '<button class="btn btn-sm btn-accent ea-native-launch">Launch ELF</button>' : ''}`}
</div>
</article>`;
}).join('') : '<div class="operations-empty"><strong>This directory is empty</strong><span>Choose another safe root or upload a file explicitly.</span></div>';
const lock = document.getElementById('ea-native-lock');
if (lock) {
lock.textContent = packet.safety_lock ? 'Safety lock enabled' : 'Safety lock not reported';
lock.className = `operations-status ${packet.safety_lock ? 'operations-status-compatible' : 'operations-status-unknown'}`;
}
}
async function eaLoadDirectory(path = _eaPath) {
_eaPath = String(path || '/');
const target = document.getElementById('ea-native-list');
if (target) target.innerHTML = '<p class="hint">Loading directory…</p>';
try {
const [packet, job] = await Promise.all([
window.pork.eaList(_eaPath),
window.pork.eaJobStatus().catch(() => ({ busy: false })),
]);
eaRenderList(packet);
const jobTarget = document.getElementById('ea-native-job');
if (jobTarget) jobTarget.textContent = job.busy
? `File operation active${job.progress != null ? ` · ${job.progress}%` : ''}`
: 'No active file operation';
} catch (error) {
if (target) target.innerHTML = `<div class="operations-empty"><strong>Directory unavailable</strong><span>${escHtml(error.message)}</span></div>`;
eaSetStatus(`File API error: ${error.message}`, 'error');
}
}
async function eaReload() {
eaSetStatus('Checking ELF Arsenal…');
try {
const result = await window.pork.eaEmbedUrl();
const open = document.getElementById('ea-embed-open');
_eaConnected = result.connected === true;
if (!_eaConnected) {
if (open) { open.disabled = true; delete open.dataset.url; }
eaSetStatus(eaFriendlyError(result.error), 'error');
const list = document.getElementById('ea-native-list');
if (list) list.innerHTML = '<div class="operations-empty"><strong>ELF Arsenal is offline</strong><span>Start it explicitly through Payload Manager, then retry.</span></div>';
return;
}
if (open) {
open.disabled = false;
open.dataset.url = result.directUrl || result.url;
}
eaSetStatus(result.version ? `Online · ${result.version}` : 'Online', 'ok');
document.getElementById('ea-native-version').textContent = result.version || 'Unknown';
await eaLoadDirectory(_eaPath);
} catch (error) {
_eaConnected = false;
eaSetStatus(`Connection error: ${eaFriendlyError(error.message)}`, 'error');
}
}
function buildElfArsenalEmbed() {
const root = document.getElementById('page-elf-arsenal');
if (!root) return;
root.innerHTML = `
<div class="page-header">
<div>
<h1>ELF Arsenal</h1>
<p class="page-subtitle">Native Chimera file browser and launcher for the ELF Arsenal service on your PS5.</p>
</div>
<div class="header-tools">
<span id="ea-embed-status" class="ea-native-status">Checking connection…</span>
<button id="ea-embed-start" class="btn btn-accent">Start via Payload Manager</button>
<button id="ea-embed-open" class="btn" disabled>Open original WebUI</button>
<button id="ea-embed-reload" class="btn">Refresh</button>
</div>
</div>
<div class="ecosystem-workflow-strip ea-workflow" aria-label="ELF Arsenal workflow">
<span><b>1</b><strong>Start service</strong><small>Payload Manager, explicit</small></span>
<span><b>2</b><strong>Browse</strong><small>Choose a reviewed ELF</small></span>
<span><b>3</b><strong>Inspect</strong><small>Path and operation</small></span>
<span><b>4</b><strong>Launch</strong><small>Exact confirmation</small></span>
</div>
<div class="ea-native-summary">
<div class="operations-metric"><strong id="ea-native-version">—</strong><span>Version</span></div>
<div class="operations-metric"><strong id="ea-native-job">No active job</strong><span>File operation</span></div>
<div class="operations-metric"><strong id="ea-native-lock" class="operations-status operations-status-unknown">Unknown</strong><span>Upstream safety lock</span></div>
</div>
<div class="card ea-native-card">
<div class="card-header ecosystem-card-header">
<div><h3>PS5 files</h3><p class="hint">Browse with Chimera's authenticated API. The original upstream WebUI opens separately and can no longer crash this page.</p></div>
<div class="ecosystem-inline-actions">
<button class="btn btn-sm ea-native-root" data-path="/">Root</button>
<button class="btn btn-sm ea-native-root" data-path="/data">Data</button>
<button class="btn btn-sm ea-native-root" data-path="/mnt">USB / mounts</button>
<button id="ea-native-upload" class="btn btn-sm btn-accent">Upload here</button>
</div>
</div>
<div id="ea-native-path" class="ea-native-path"></div>
<div id="ea-native-list" class="ea-native-list"><p class="hint">Waiting for ELF Arsenal…</p></div>
</div>
<p class="hint">FTP alone is not enough: ELF Arsenal must be running on port 6969. Launch and upload actions always require an explicit confirmation.</p>`;
document.getElementById('ea-embed-open').addEventListener('click', () => {
const url = document.getElementById('ea-embed-open')?.dataset.url;
if (url) window.pork.openShell(url);
});
document.getElementById('ea-embed-start').addEventListener('click', async (event) => {
const button = event.currentTarget;
button.disabled = true;
eaSetStatus('Starting ELF Arsenal explicitly through Payload Manager…', 'warning');
try {
await window.pork.eaStart();
showToast('ELF Arsenal started. Rechecking the service.', 'ok');
await eaReload();
} catch (error) {
eaSetStatus(`Start failed: ${error.message}`, 'error');
showToast(`ELF Arsenal start failed: ${error.message}`, 'error');
} finally {
button.disabled = false;
}
});
document.getElementById('ea-embed-reload').addEventListener('click', eaReload);
document.getElementById('ea-native-path').addEventListener('click', event => {
const button = event.target.closest('.ea-native-parent');
if (button) eaLoadDirectory(button.dataset.path);
});
root.querySelectorAll('.ea-native-root').forEach(button => button.addEventListener('click', () => eaLoadDirectory(button.dataset.path)));
document.getElementById('ea-native-list').addEventListener('click', async event => {
const row = event.target.closest('.ea-native-row');
if (!row) return;
const button = event.target.closest('button');
try {
if (button?.classList.contains('ea-native-open')) await eaLoadDirectory(row.dataset.path);
if (button?.classList.contains('ea-native-download')) await window.pork.eaDownload(row.dataset.path, row.dataset.name);
if (button?.classList.contains('ea-native-launch')) {
const confirmation = await showExactConfirmation(`Launch ${row.dataset.path} through ELF Arsenal?`, 'LAUNCH ELF');
if (!confirmation) return;
await window.pork.eaLaunch(row.dataset.path, [], false);
showToast(`${row.dataset.name} launch request sent.`, 'ok');
}
} catch (error) { showToast(error.message, 'error', 10000); }
});
document.getElementById('ea-native-upload').addEventListener('click', async () => {
if (!_eaConnected) return showToast('Start ELF Arsenal first.', 'error');
const confirmation = await showExactConfirmation(`Upload one selected file to ${_eaPath}?`, 'UPLOAD FILE');
if (!confirmation) return;
try {
const uploadRoot = _eaPath === '/' ? '/' : `${_eaPath.replace(/\/+$/, '')}/`;
const result = await window.pork.eaUpload(null, uploadRoot);
if (result) {
showToast(`Uploaded ${eaBytes(result.bytes)} to ${result.remotePath}.`, 'ok');
await eaLoadDirectory(_eaPath);
}
} catch (error) { showToast(error.message, 'error', 10000); }
});
eaReload();
}
async function loadElfArsenal() {
if (!document.getElementById('ea-native-list')) buildElfArsenalEmbed();
else await eaReload();
}
+63
View File
@@ -0,0 +1,63 @@
'use strict';
(function exposeEtahenLinkUx(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.ChimeraLinkUx = api;
})(typeof window === 'object' ? window : null, () => {
const STATES = new Set(['online', 'offline', 'unknown']);
const JOB_STATES = new Set(['none', 'staged', 'approval_required', 'recovery_required']);
const safeState = (value, allowed, fallback) => allowed.has(String(value || '')) ? String(value) : fallback;
const text = (value, fallback = '') => String(value || fallback).slice(0, 160);
function createModel(packet = {}) {
const runtime = packet.runtime || {};
const last = packet.lastInventory || null;
const etaHenState = safeState(runtime.state, STATES, 'unknown');
const agentState = safeState(packet.agent?.state, STATES, etaHenState === 'online' && last ? 'online' : 'unknown');
const chimeraState = last ? 'online' : 'unknown';
const jobState = safeState(packet.job?.state, JOB_STATES, 'none');
const stagedCount = Math.max(0, Number(packet.job?.stagedCount) || 0);
return Object.freeze({
etaHen: Object.freeze({ state: etaHenState, version: text(runtime.identity?.version, '—') }),
agent: Object.freeze({ state: agentState, paired: packet.agent?.paired === true }),
chimera: Object.freeze({ state: chimeraState, lastScanAt: last?.completedAt || null }),
transfer: Object.freeze({ state: jobState, stagedCount, installedCount: 0 }),
recoveryRequired: jobState === 'recovery_required',
approvalRequired: jobState === 'approval_required',
});
}
function exactConfirmation({ action, target, runtimeEpoch, localIntent, recoveryAvailable }) {
if (!/^[A-Z][A-Z0-9_]{2,31}$/.test(String(action || ''))) throw new Error('Invalid action.');
if (!/^[A-Za-z0-9_-]{8,96}$/.test(String(target || '')) || !/^[A-Za-z0-9_-]{8,96}$/.test(String(runtimeEpoch || ''))) throw new Error('Invalid confirmation identity.');
if (localIntent !== true) throw new Error('Local console intent is required.');
if (recoveryAvailable !== true) throw new Error('Verified recovery is required.');
return `CONFIRM ${action} ${target} ${runtimeEpoch}`;
}
function mount({ root, loadStatus, translate = (_key, fallback) => fallback } = {}) {
if (!root || typeof loadStatus !== 'function') return null;
const t = translate;
root.innerHTML = `<section class="card etahen-link-card" aria-labelledby="etahen-link-title">
<div class="card-header"><div><span class="etahen-link-kicker">${t('etahenLink.kicker', 'Console companion')}</span><h3 id="etahen-link-title">${t('etahenLink.title', 'Chimera Link for etaHEN')}</h3><p class="hint">${t('etahenLink.description', 'Separate health for etaHEN, the paired agent and Chimera evidence.')}</p></div><button id="etahen-link-refresh" class="btn btn-sm" type="button">${t('common.refresh', 'Refresh')}</button></div>
<div class="etahen-link-health" role="list" aria-label="${t('etahenLink.healthLabel', 'Chimera Link health')}"></div>
<div class="etahen-link-alert" role="status" aria-live="polite"></div>
</section>`;
const health = root.querySelector('.etahen-link-health'); const alert = root.querySelector('.etahen-link-alert'); const button = root.querySelector('#etahen-link-refresh');
const label = state => t(`etahenLink.state.${state}`, state === 'online' ? 'Online' : state === 'offline' ? 'Offline' : 'Unknown');
const render = packet => {
const model = createModel(packet);
health.innerHTML = [['etaHEN', model.etaHen], [t('etahenLink.agent', 'Link agent'), model.agent], ['Chimera', model.chimera]].map(([name, item]) => `<div class="etahen-link-health-item state-${item.state}" role="listitem"><span>${name}</span><strong>${label(item.state)}</strong></div>`).join('');
alert.className = `etahen-link-alert ${model.recoveryRequired ? 'is-critical' : ''}`;
alert.textContent = model.recoveryRequired ? t('etahenLink.recoveryRequired', 'Recovery required before any new action.') : model.approvalRequired ? t('etahenLink.approvalRequired', 'Approval must be confirmed locally on the console.') : model.transfer.stagedCount ? t('etahenLink.stagedOnly', 'Artifact staged — not installed.') : t('etahenLink.noPendingWork', 'No pending Link work.');
return model;
};
const refresh = async () => { button.disabled = true; try { return render(await loadStatus()); } catch (_) { return render({ runtime: { state: 'unknown' } }); } finally { button.disabled = false; } };
button.addEventListener('click', refresh); root.addEventListener('keydown', event => { if (event.key === 'Enter' && event.target === root) refresh(); });
root.tabIndex = 0; refresh();
return Object.freeze({ refresh, render });
}
return Object.freeze({ createModel, exactConfirmation, mount });
});
+115
View File
@@ -0,0 +1,115 @@
// ── FTP Browser ───────────────────────────────────────────────────────────────
let ftpBrowserResolve = null;
let ftpBrowserCwd = '/';
function openFtpBrowser(startPath = '/') {
return new Promise(resolve => {
ftpBrowserResolve = resolve;
openAccessibleDialog($('ftp-browser-overlay'), $('ftp-browser-close'));
ftpBrowserNavigate(startPath);
});
}
function closeFtpBrowser(result) {
closeAccessibleDialog($('ftp-browser-overlay'));
if (ftpBrowserResolve) { ftpBrowserResolve(result); ftpBrowserResolve = null; }
}
async function ftpBrowserNavigate(path) {
ftpBrowserCwd = path;
$('ftp-browser-cwd').textContent = path;
$('ftp-browser-up').disabled = (path === '/');
const body = $('ftp-browser-body');
body.innerHTML = '<div class="ftp-browser-loading">Loading…</div>';
try {
const entries = await window.pork.ftpListDir(path);
if (!entries.length) {
body.innerHTML = '<div class="ftp-browser-loading">Empty folder.</div>';
return;
}
body.innerHTML = entries.map(e => `
<div class="ftp-browser-entry ${e.isDir ? 'ftp-entry-dir' : 'ftp-entry-file'}"
data-name="${escHtml(e.name)}" data-isdir="${e.isDir}">
<span class="ftp-entry-icon">${e.isDir ? '📁' : '📄'}</span>
<span class="ftp-entry-name">${escHtml(e.name)}</span>
</div>
`).join('');
} catch (e) {
body.innerHTML = `<div class="ftp-browser-loading" style="color:var(--red)">Error: ${escHtml(e.message)}</div>`;
}
}
$('ftp-browser-body').addEventListener('click', e => {
const entry = e.target.closest('.ftp-entry-dir');
if (!entry) return;
const name = entry.dataset.name;
const next = (ftpBrowserCwd.endsWith('/') ? ftpBrowserCwd : ftpBrowserCwd + '/') + name;
ftpBrowserNavigate(next);
});
$('ftp-browser-up').addEventListener('click', () => {
const parts = ftpBrowserCwd.replace(/\/$/, '').split('/').filter(Boolean);
parts.pop();
ftpBrowserNavigate('/' + parts.join('/') || '/');
});
$('ftp-browser-select').addEventListener('click', () => closeFtpBrowser(ftpBrowserCwd));
$('ftp-browser-cancel').addEventListener('click', () => closeFtpBrowser(null));
$('ftp-browser-close').addEventListener('click', () => closeFtpBrowser(null));
$('ftp-browser-overlay').addEventListener('keydown', event => {
if (event.key === 'Escape') closeFtpBrowser(null);
});
$('btn-browse-ftp').addEventListener('click', async () => {
if (!state.connected) { setStatus('Connect to FTP first to browse remote paths', 'error'); return; }
const selected = await openFtpBrowser(ftpBrowserCwd || '/');
if (!selected) return;
// Auto-add immediately — no separate "+ Add" click required
if (remoteGamePaths.some(p => p.path === selected)) {
setStatus('That path is already in the list', 'ok');
return;
}
const label = $('new-remote-label').value.trim()
|| selected.replace(/\/$/, '').split('/').filter(Boolean).pop()
|| selected;
remoteGamePaths = [...remoteGamePaths, { label, path: selected }];
await saveRemoteGamePaths();
renderRemoteGamePaths();
$('new-remote-label').value = '';
$('new-remote-path').value = '';
});
// Add payload source
$('btn-add-payload-source').addEventListener('click', async () => {
const name = $('new-source-name').value.trim();
const url = $('new-source-url').value.trim();
if (!name || !url) { setStatus('Enter both a name and a source URL', 'error'); return; }
try {
await window.pork.payloadSourcesAdd(name, url);
$('new-source-name').value = '';
$('new-source-url').value = '';
await renderSettingsPayloadSources();
setStatus(`Source "${name}" added`, 'ok');
} catch (e) {
setStatus(`Failed to add source: ${e.message}`, 'error');
}
});
// Delegated clicks in settings payload source list
$('settings-payload-sources').addEventListener('click', async e => {
const row = e.target.closest('[data-source-id]');
if (!row) return;
const id = Number(row.dataset.sourceId);
if (e.target.closest('.btn-source-remove')) {
const confirmation = await showExactConfirmation('Remove this payload source? Downloaded files will not be deleted.', 'VERWIJDER');
if (!confirmation) return;
await window.pork.payloadSourcesRemove(id, confirmation);
await renderSettingsPayloadSources();
} else if (e.target.closest('.source-toggle')) {
await window.pork.payloadSourcesToggle(id);
await renderSettingsPayloadSources();
}
});
+23
View File
@@ -0,0 +1,23 @@
'use strict';
$('btn-rescan-game-sources').addEventListener('click', async () => {
const btn = $('btn-rescan-game-sources');
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = 'Scanning…';
setStatus('Scanning local game source folders…');
try {
const { total, added } = await window.pork.scanBackups();
await loadGames();
await loadDashboard();
const msg = added > 0
? `Found ${total} game(s) — ${added} new, fetching metadata…`
: `Scan complete: ${total} game(s) found, none new`;
setStatus(msg, 'ok');
} catch (e) {
setStatus(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = orig;
}
});
+481
View File
@@ -0,0 +1,481 @@
// ── Games page ────────────────────────────────────────────────────────────────
async function loadGames() {
try {
const library = await window.pork.listLibraryTitles({ limit: 1000 });
state.games = window.chimeraLibraryModel.items(library);
window.pork.shadowMountStatus?.().then(result => {
const byGame = new Map();
for (const entry of (result.model?.entries || [])) {
if (!entry.titleId) continue;
const current = byGame.get(entry.titleId) || [];
current.push(entry);
byGame.set(entry.titleId, current);
}
state.games = state.games.map(game => {
const matches = byGame.get(game.game_id) || [];
if (!matches.length) return game;
return { ...game, shadow_mount: {
state: matches.some(entry => entry.state === 'installed') ? 'installed' : matches[0].state,
sources: matches.length,
experimental: matches.some(entry => entry.format?.support === 'experimental'),
formats: [...new Set(matches.map(entry => entry.format?.id).filter(Boolean))],
} };
});
renderGames();
}).catch(() => {});
// Refresh hash summaries (fire-and-forget so badges update when ready)
window.pork.hashGameSummary().then(summaries => {
_populateHashMaps(summaries);
renderGames();
}).catch(() => {});
renderGames();
await refreshFirmwareFilters();
await refreshConvQueuePanel();
const conversionQueue = $('conv-queue-panel');
const gamesPage = $('page-games');
if (conversionQueue && gamesPage) gamesPage.append(conversionQueue);
await refreshGameCatalogHealth();
} catch (e) {
setStatus(e.message, 'error');
}
}
async function refreshGameCatalogHealth() {
if (!window.pork.gameCatalogHealth || !$('game-catalog-health')) return;
try {
const health = await window.pork.gameCatalogHealth();
$('catalog-health-complete').textContent = `${Number(health.complete || 0)} / ${Number(health.total || 0)}`;
$('catalog-health-missing').textContent = String(Number(health.missing_metadata || 0));
$('catalog-health-artwork').textContent = String(Number(health.missing_artwork || 0));
const scan = health.scan || {};
$('catalog-health-scan').textContent = scan.running ? 'Scanning…' : (scan.lastError ? 'Scan issue' : 'Automatic');
$('catalog-health-scan-detail').textContent = scan.lastError
? String(scan.lastError)
: (scan.lastCompleted
? window.tf('games.health.lastScan', 'last scan {date}', { date: new Date(scan.lastCompleted).toLocaleString(window.i18n?.current || 'en') })
: window.t('games.health.interval', 'source check every 15 min'));
} catch (_) {}
}
window.pork.on('local:scan:started', refreshGameCatalogHealth);
window.pork.on('local:scan:complete', () => refreshGameCatalogHealth());
window.pork.on('prospero:queue:done', () => refreshGameCatalogHealth());
function gamePlatform(game) {
const explicit = String(game?.platform || '').toLowerCase();
if (explicit === 'ps4' || explicit === 'ps5') return explicit;
const identity = [
game?.game_id,
game?.content_id,
game?.prospero_content_id,
].filter(Boolean).join(' ').toUpperCase();
if (/CUSA\d{5}/.test(identity)) return 'ps4';
if (/PPSA\d{5}/.test(identity)) return 'ps5';
return 'unknown';
}
function versionedArtworkUrl(url, fetchedAt) {
const source = String(url || '');
if (!source.startsWith('/pork-cache/')) return source;
const revision = Number(fetchedAt || 0);
return revision > 0 ? `${source}?v=${Math.trunc(revision)}` : source;
}
function disconnectedDriveLabels(game) {
const labels = [];
for (const copy of Array.isArray(game?.localCopies) ? game.localCopies : []) {
const device = copy.device || copy.storageDevice || copy.volume?.device || {};
const state = String(device.availabilityState || copy.volume?.availabilityState || copy.availabilityState || '').toLowerCase();
if (!['unavailable', 'disconnected', 'offline', 'missing'].includes(state)) continue;
labels.push(device.friendlyLabel || device.displayName || device.label || device.stableFingerprint || copy.volume?.stableVolumeKey || 'external drive');
}
return [...new Set(labels.map(String))];
}
function renderGamePlatformFilters() {
const counts = { all: state.games.length, ps4: 0, ps5: 0, unknown: 0 };
for (const game of state.games) counts[gamePlatform(game)] += 1;
for (const platform of Object.keys(counts)) {
const count = $(`games-platform-count-${platform}`);
if (count) count.textContent = String(counts[platform]);
}
document.querySelectorAll('[data-game-platform]').forEach(button => {
const platform = button.dataset.gamePlatform;
const active = platform === state.platformFilter;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
if (platform === 'unknown') button.hidden = counts.unknown === 0 && !active;
});
}
function getFilteredGames() {
return state.games.filter(g => {
const q = state.search.toLowerCase();
if (q && !(g.title || '').toLowerCase().includes(q) && !(g.game_id || '').toLowerCase().includes(q) &&
!(g.prospero_name || '').toLowerCase().includes(q)) return false;
if (state.platformFilter !== 'all' && gamePlatform(g) !== state.platformFilter) return false;
// Status and firmware are independent filter dimensions — compute status as a
// boolean so both can compose (previously the status branches returned early,
// silently disabling the firmware filter).
let statusOk = true;
if (state.filter === 'installed') statusOk = window.chimeraLibraryModel.isInstalled(g);
else if (state.filter === 'backed_up') statusOk = window.chimeraLibraryModel.hasLocalCopy(g);
else if (state.filter === 'not_backed') statusOk = window.chimeraLibraryModel.needsProtection(g);
if (!statusOk) return false;
if (state.firmwareFilter !== 'all') {
const labels = (g.firmware_labels || '').split(',').filter(Boolean);
if (!labels.includes(state.firmwareFilter)) return false;
}
return true;
}).sort((a, b) => {
const sortValue = game => state.sortCol === 'title'
? (game.prospero_name || game.title || game.game_id || '')
: (game[state.sortCol] ?? '');
let av = sortValue(a), bv = sortValue(b);
if (typeof av === 'string') av = av.toLowerCase();
if (typeof bv === 'string') bv = bv.toLowerCase();
if (av === bv) return 0;
return state.sortDir === 'asc' ? (av > bv ? 1 : -1) : (av < bv ? 1 : -1);
});
}
function renderGames() {
renderGamePlatformFilters();
const allGames = getFilteredGames();
const list = allGames.slice(0, state.gameRenderLimit);
const tbody = $('games-tbody');
const grid = $('games-grid');
const tableWrap = $('games-table-wrap');
const empty = $('games-empty');
const emptyDetail = $('games-empty-detail');
const emptyScan = $('btn-games-empty-scan');
const emptyReset = $('btn-games-empty-reset');
const loadMore = $('btn-games-load-more');
const resultCount = $('games-result-count');
const filtersActive = Boolean(
state.games.length && (
state.search ||
state.filter !== 'all' ||
state.platformFilter !== 'all' ||
state.firmwareFilter !== 'all'
)
);
tbody.innerHTML = '';
grid.innerHTML = '';
empty.hidden = allGames.length !== 0;
emptyDetail.textContent = filtersActive
? window.t('games.empty.filtered', 'No games match the selected platform and filters.')
: window.t('games.empty.detail', 'Connect the PS5 or scan the configured source folders.');
emptyScan.hidden = filtersActive;
emptyReset.hidden = !filtersActive;
grid.hidden = !allGames.length || state.gameView !== 'grid';
tableWrap.hidden = !allGames.length || state.gameView !== 'table';
loadMore.hidden = list.length >= allGames.length;
resultCount.textContent = allGames.length
? window.tf('games.resultCount', '{visible} of {total}', { visible: list.length, total: allGames.length })
: '';
document.querySelectorAll('[data-game-view]').forEach(button => {
const active = button.dataset.gameView === state.gameView;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
});
for (const g of list) {
const tr = document.createElement('tr');
tr.className = 'game-row';
tr.dataset.id = g.game_id;
// Compact status cell: readable tags instead of cryptic symbols
const patchesOnly = g.backup_count === 0 && g.backpork_count > 0;
const _hs = _gameHashSummary.get(g.game_id);
const hashTag = _hs
? _hs.community_mismatch
? `<span class="s-tag s-hash-fail" title="⚠ Community hash check FAILED — files may be modified or contain brick code">⚠ Hash Fail</span>`
: _hs.community_matches > 0
? `<span class="s-tag s-hash-ok" title="Community verified: ${_hs.community_matches}/${_hs.hash_count} files match known-good hashes">✓ Verified</span>`
: `<span class="s-tag s-hash-local" title="${_hs.hash_count} file(s) hashed — not yet in community list">⧭ Hashed</span>`
: '';
const fwLabels = (g.firmware_labels || '').split(',').filter(Boolean);
const patchedTag = fwLabels.length
? `<span class="s-tag s-patched" title="Backporked for: ${escHtml(fwLabels.join(', '))}">&#x1F527; ${escHtml(fwLabels.join(', '))}</span>`
: '';
const shadowTag = g.shadow_mount
? `<span class="s-tag ${g.shadow_mount.state === 'installed' ? 's-backed' : 's-warn'}" title="ShadowMount: ${escHtml(g.shadow_mount.sources)} source(s) · ${escHtml((g.shadow_mount.formats || []).join(', ') || 'unknown format')}${g.shadow_mount.experimental ? ' · experimental format' : ''}">&#9673; Mount ${escHtml(g.shadow_mount.state)}</span>`
: '';
const metadataTag = `<span class="s-tag ${g.metadata?.source === 'manual' ? 's-patched' : 's-num'}" title="Metadata: ${escHtml(g.metadata?.source || 'unknown')} · ${escHtml(window.t('games.metadataConfidence', 'confidence'))} ${escHtml(g.metadata?.confidence || 'unknown')}">Meta ${escHtml(g.metadata?.source || '?')}</span>`;
const variantTag = g.regional_variants?.length
? `<span class="s-tag s-num" title="${escHtml(g.regional_variants.map(item => `${item.titleId}${item.region ? ` (${item.region})` : ''}`).join(' · '))}">${g.regional_variants.length} ${escHtml(g.regional_variants.length === 1 ? window.t('games.region', 'region') : window.t('games.regions', 'regions'))}</span>`
: '';
const platform = gamePlatform(g);
const platformLabel = platform === 'unknown'
? window.t('games.platform.unknown', 'Unknown')
: platform.toUpperCase();
const platformTag = `<span class="s-tag s-platform-${platform}">${escHtml(platformLabel)}</span>`;
const requiredDrives = disconnectedDriveLabels(g);
const requiredDriveTag = requiredDrives.length
? `<span class="s-tag s-warn s-required-drive" title="${escHtml(window.t('games.requiredDrive.title', 'Reconnect the named drive to access this copy'))}">${escHtml(window.t('games.requiredDrive', 'Needs drive'))}: ${escHtml(requiredDrives.join(', '))}</span>`
: '';
const statusHtml = `<div class="game-status-col">
${platformTag}
${requiredDriveTag}
${window.chimeraLibraryModel.isInstalled(g) ? `<span class="s-tag s-installed" title="${escHtml(window.t('games.status.installedTitle', 'Currently installed on the PS5'))}">${escHtml(window.t('games.status.installed', 'Installed'))}</span>` : ''}
${g.backup_count > 0 ? `<span class="s-tag s-num" title="${g.backup_count} ${escHtml(window.t('games.status.localBackups', 'local backup(s)'))}">${g.backup_count} bkp</span>` : ''}
${window.chimeraLibraryModel.hasLocalCopy(g) ? `<span class="s-tag s-backed" title="${escHtml(window.t('games.status.backedUpTitle', 'Local copy available'))}">&#10003;</span>` : ''}
${patchesOnly ? `<span class="s-tag s-warn" title="${escHtml(window.t('games.status.patchesOnlyTitle', 'Only Backpork patches were found; no local game backup exists'))}">${escHtml(window.t('games.status.patchesOnly', 'Patches only'))}</span>` : ''}
${patchedTag}
${shadowTag}
${hashTag}
${metadataTag}
${variantTag}
</div>`;
const displayTitle = g.prospero_name || g.title || g.game_id;
const metadataLine = [
g.prospero_publisher,
g.prospero_region,
g.content_id || g.prospero_content_id,
].filter(Boolean).join(' · ');
const coverUrl = g.prospero_cover_url || '';
const renderedCoverUrl = versionedArtworkUrl(coverUrl, g.cover_fetched_at);
const iconFallbackAttribute = g.prospero_icon_url
? ` data-fallback-src="${escHtml(g.prospero_icon_url)}"`
: '';
const coverPlaceholder = `<div class="game-card-cover-fallback" hidden aria-hidden="true"><span>${escHtml(platformLabel)}</span><strong>${escHtml(displayTitle)}</strong><small>${escHtml(g.game_id)}</small></div>`;
const cover = renderedCoverUrl
? `<div class="game-card-cover-case has-native-cover">
<img src="${escHtml(renderedCoverUrl)}" class="game-card-cover" alt="${escHtml(displayTitle)}" loading="lazy" decoding="async" data-image-error="fallback"${iconFallbackAttribute}>
${coverPlaceholder}
</div>`
: g.prospero_icon_url
? `<img src="${escHtml(g.prospero_banner_url || g.prospero_icon_url)}" class="game-card-cover-backdrop" alt="" aria-hidden="true" loading="lazy" decoding="async">
<div class="game-card-cover-case">
<span class="game-card-cover-platform">${escHtml(platformLabel)}</span>
<img src="${escHtml(g.prospero_icon_url)}" class="game-card-cover" alt="${escHtml(displayTitle)}" loading="lazy" decoding="async" data-image-error="fallback">
${coverPlaceholder}
<div class="game-card-cover-caption"><strong>${escHtml(displayTitle)}</strong><small>${escHtml(g.game_id)}</small></div>
</div>`
: `<div class="game-card-cover-fallback is-visible" aria-label="${escHtml(window.t?.('games.coverMissing', 'Cover unavailable') || 'Cover unavailable')}"><span>${escHtml(platformLabel)}</span><strong>${escHtml(displayTitle)}</strong><small>${escHtml(g.game_id || '?')}</small></div>`;
const metadataSynced = Boolean(g.prospero_name && g.prospero_icon_url && g.prospero_cover_url);
const metadataState = metadataSynced
? window.t('games.metadata.synced', 'Metadata: synced')
: window.t('games.metadata.enriching', 'Metadata: enriching');
const card = document.createElement('article');
card.className = 'game-card';
card.dataset.id = g.game_id;
card.dataset.platform = platform;
card.style.setProperty('--card-hue', String(titleHue(g.game_id)));
card.tabIndex = 0;
card.innerHTML = `
<div class="game-card-art">${cover}
${metadataSynced ? '' : `<span class="game-card-sync pending" title="${escHtml(metadataState)}">${escHtml(metadataState)}</span>`}
<div class="game-card-actions">
${patchesOnly ? '' : `<button class="btn btn-sm btn-accent" data-action="install" data-id="${escHtml(g.game_id)}" data-i18n="games.install">Plan install</button>`}
<button class="btn btn-sm" data-action="metadata" data-id="${escHtml(g.game_id)}" data-i18n="games.metadata">Metadata</button>
</div>
</div>
<div class="game-card-body">
<div class="game-card-heading">
<strong title="${escHtml(displayTitle)}">${escHtml(displayTitle)}</strong>
</div>
<span class="game-card-meta">${escHtml([g.game_id, metadataLine].filter(Boolean).join(' · '))}</span>
${statusHtml}
</div>`;
const nativeCover = card.querySelector('.game-card-cover-case.has-native-cover .game-card-cover');
if (nativeCover) {
const applyNaturalCoverRatio = () => {
const width = Number(nativeCover.naturalWidth || 0);
const height = Number(nativeCover.naturalHeight || 0);
const ratio = height > 0 ? width / height : 0;
if (width >= 160 && height >= 240 && ratio >= 0.52 && ratio <= 0.86) {
card.style.setProperty('--game-cover-ratio', `${width} / ${height}`);
}
};
if (nativeCover.complete) applyNaturalCoverRatio();
else nativeCover.addEventListener('load', applyNaturalCoverRatio, { once: true });
}
grid.appendChild(card);
if (patchesOnly) tr.classList.add('game-row-patches-only');
tr.innerHTML = `
<td>
<div class="game-title-cell">
${renderedCoverUrl || g.prospero_icon_url
? `<img src="${escHtml(renderedCoverUrl || g.prospero_icon_url)}" class="game-row-icon${renderedCoverUrl ? ' is-portrait' : ''}" alt="${escHtml(window.t('games.coverFor', 'Cover for'))} ${escHtml(g.prospero_name || g.title || g.game_id)}" loading="lazy" decoding="async" data-image-error="fallback"${renderedCoverUrl && g.prospero_icon_url ? ` data-fallback-src="${escHtml(g.prospero_icon_url)}"` : ''}>`
: '<div class="game-row-icon game-row-icon-empty"></div>'}
${renderedCoverUrl || g.prospero_icon_url ? '<div class="game-row-icon game-row-icon-empty" hidden></div>' : ''}
<span class="game-title-copy">
<strong>${escHtml(g.prospero_name || g.title || g.game_id)}</strong>
<small>${escHtml([
g.prospero_publisher,
g.prospero_region,
g.content_id || g.prospero_content_id,
].filter(Boolean).join(' · ') || window.t('games.metadataQueued', 'Metadata is being enriched…'))}</small>
</span>
</div>
</td>
<td><code>${escHtml(g.game_id)}</code></td>
<td>${escHtml(g.version || g.prospero_version || '—')}</td>
<td>${g.backup_size ? fmt(g.backup_size) : (g.size ? fmt(g.size) : (g.prospero_size || '—'))}</td>
<td>${renderFwPills(g.firmware_labels)}</td>
<td>${statusHtml}</td>
<td class="game-actions-cell">
${patchesOnly
? `<span class="patches-only-hint" title="${escHtml(window.t('games.noBackupDetail', 'No game backup was found; only patch folders exist. Create or download a base-game backup first.'))}">&#9888; ${escHtml(window.t('games.noBackup', 'No game backup'))}</span>`
: `<button class="btn btn-sm btn-teal" data-action="install" data-id="${escHtml(g.game_id)}" title="${escHtml(window.t('games.installQueueTitle', 'Add to the PS5 installation queue'))}">
&#11014; ${escHtml(window.t('games.install', 'Plan install'))}
</button>
<button class="btn btn-sm" data-action="convert" data-id="${escHtml(g.game_id)}" title="${escHtml(window.t('games.convertTitle', 'Convert the game and optionally upload it to the PS5'))}">
&#9881; ${escHtml(window.t('games.convert', 'Convert'))}
</button>`
}
<button class="btn btn-sm" data-action="metadata" data-id="${escHtml(g.game_id)}" title="${escHtml(window.t('games.metadataEditTitle', 'Correct the title, artwork and description manually'))}">Metadata</button>
</td>`;
tbody.appendChild(tr);
}
// Click delegation: action buttons take priority; otherwise the card/row opens detail.
const handleLibraryClick = async e => {
const btn = e.target.closest('[data-action]');
if (btn) {
try {
if (btn.dataset.action === 'install') {
await openInstallModal(btn.dataset.id, 'pfs');
} else if (btn.dataset.action === 'convert') {
const s = await window.pork.getSettings();
const mode = (s.gameConversionMode === 'pfs' || !s.gameConversionMode)
? (window.pork.isWebMode ? 'ffpfsc' : 'ffpkg')
: s.gameConversionMode;
await openInstallModal(btn.dataset.id, mode);
} else if (btn.dataset.action === 'metadata') {
await editGameMetadata(btn.dataset.id);
}
} catch (error) {
setStatus(error.message, 'error');
showToast(error.message, 'error', 9000);
}
return;
}
const item = e.target.closest('[data-id].game-row, [data-id].game-card');
if (item) openModal(item.dataset.id);
};
tbody.onclick = handleLibraryClick;
grid.onclick = handleLibraryClick;
grid.onkeydown = e => {
if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('button')) {
e.preventDefault();
const card = e.target.closest('.game-card[data-id]');
if (card) openModal(card.dataset.id);
}
};
window.i18n?.apply(grid);
window.i18n?.apply(tbody);
}
async function editGameMetadata(gameId) {
const game = state.games.find(item => item.game_id === gameId);
if (!game) return;
const hasOverride = Boolean(game.metadata?.overriddenFields?.length);
if (hasOverride && await showExactConfirmation('Clear manual metadata and return to online and local metadata management?', 'CLEAR METADATA')) {
await window.pork.updateGameMetadata(gameId, {});
await loadGames();
setStatus(`Handmatige metadata voor ${gameId} gewist.`, 'ok');
return;
}
const ask = (label, value = '') => showPrompt(`${label} voor ${gameId}:`, String(value || ''));
const title = await ask('Volledige titel', game.prospero_name || game.title);
if (title == null) return;
const publisher = await ask('Uitgever', game.prospero_publisher);
if (publisher == null) return;
const region = await ask('Regio', game.prospero_region);
if (region == null) return;
const iconUrl = await ask(window.t?.('games.metadata.iconUrl', 'HTTPS image URL') || 'HTTPS image URL', game.prospero_icon_url);
if (iconUrl == null) return;
if (iconUrl && !/^(https:\/\/|\/api\/local-artwork\/)/i.test(iconUrl)) {
throw new Error(window.t?.('games.metadata.invalidImage', 'Use an HTTPS URL or a local Chimera image.') || 'Use an HTTPS URL or a local Chimera image.');
}
const coverUrl = await ask(window.t?.('games.metadata.coverUrl', 'HTTPS portrait cover URL') || 'HTTPS portrait cover URL', game.prospero_cover_url);
if (coverUrl == null) return;
if (coverUrl && !/^(https:\/\/|\/api\/local-artwork\/)/i.test(coverUrl)) {
throw new Error(window.t?.('games.metadata.invalidImage', 'Use an HTTPS URL or a local Chimera image.') || 'Use an HTTPS URL or a local Chimera image.');
}
const description = await ask(window.t?.('games.metadata.description', 'Description') || 'Description', game.prospero_description);
if (description == null) return;
await window.pork.updateGameMetadata(gameId, { title, publisher, region, iconUrl, coverUrl, description });
await loadGames();
setStatus(`Metadata voor ${gameId} handmatig vastgezet.`, 'success');
}
// Sorting
document.querySelectorAll('#games-table th[data-col]').forEach(th => {
th.addEventListener('click', () => {
if (state.sortCol === th.dataset.col) state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
else { state.sortCol = th.dataset.col; state.sortDir = 'asc'; }
localStorage.setItem('chimera.games.sortCol', state.sortCol);
localStorage.setItem('chimera.games.sortDir', state.sortDir);
renderGames();
});
});
// Filter buttons
document.querySelectorAll('.fbtn').forEach(btn =>
btn.addEventListener('click', () => {
document.querySelectorAll('.fbtn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.filter = btn.dataset.filter;
state.gameRenderLimit = 160;
localStorage.setItem('chimera.games.filter', state.filter);
renderGames();
})
);
document.querySelectorAll('[data-game-platform]').forEach(button => {
button.addEventListener('click', () => {
state.platformFilter = ['ps4', 'ps5', 'unknown'].includes(button.dataset.gamePlatform)
? button.dataset.gamePlatform
: 'all';
state.gameRenderLimit = 160;
localStorage.setItem('chimera.games.platform', state.platformFilter);
renderGames();
});
});
// Search
$('games-search').addEventListener('input', e => {
state.search = e.target.value;
state.gameRenderLimit = 160;
renderGames();
});
document.querySelectorAll('[data-game-view]').forEach(button => {
button.addEventListener('click', () => {
state.gameView = button.dataset.gameView === 'table' ? 'table' : 'grid';
localStorage.setItem('chimera.games.view', state.gameView);
renderGames();
});
});
$('btn-games-load-more')?.addEventListener('click', () => {
state.gameRenderLimit += 160;
renderGames();
});
$('btn-games-empty-scan')?.addEventListener('click', () => $('btn-rescan-game-sources')?.click());
$('btn-games-empty-reset')?.addEventListener('click', () => {
state.search = '';
state.filter = 'all';
state.platformFilter = 'all';
state.firmwareFilter = 'all';
state.gameRenderLimit = 160;
$('games-search').value = '';
localStorage.setItem('chimera.games.filter', 'all');
localStorage.setItem('chimera.games.platform', 'all');
document.querySelectorAll('.fwbtn').forEach(button => {
button.classList.toggle('active', button.dataset.fw === 'all');
});
renderGames();
});
document.querySelectorAll('.fbtn').forEach(button => {
button.classList.toggle('active', button.dataset.filter === state.filter);
});
window.addEventListener('chimera:language-changed', () => renderGames());
+164
View File
@@ -0,0 +1,164 @@
// ── Hash verification ──────────────────────────────────────────────────────────
let hashingInProgress = false;
window.pork.on('hash:progress', ({ percent }) => {
const fill = $('hash-progress-fill');
const label = $('hash-progress-label');
if (fill) fill.style.width = `${percent}%`;
if (label) label.textContent = `Hashing… ${percent}%`;
});
async function renderHashSection(game_id, backups) {
const section = $('modal-hash-section');
const badge = $('modal-hash-community-badge');
badge.hidden = true;
badge.className = 'hash-badge'; // reset class each render
// Include both local and backpork backups — all game files deserve verification.
if (!backups.length) { section.hidden = true; return; }
section.hidden = false;
const { localHashes, communityMatches, communityMismatch } = await window.pork.hashStatus(game_id);
const communitySet = new Set(communityMatches.map(h => h.file_hash));
// Badge above hash list: mismatch warning takes priority over verified check.
const allInCommunity = localHashes.length > 0 && communityMatches.length === localHashes.length;
if (communityMismatch && localHashes.length > 0) {
badge.hidden = false;
badge.className = 'hash-badge hash-mismatch';
badge.textContent = '⚠ Community Hash Mismatch — files may be modified or contain brick code';
} else if (allInCommunity) {
badge.hidden = false;
badge.className = 'hash-badge hash-community';
badge.textContent = 'Community Verified ✓';
}
// Each backup is a folder — show aggregate hash status for the whole folder.
$('modal-hash-list').innerHTML = backups.map(b => {
const folderName = b.backup_path.replace(/\\/g, '/').split('/').pop();
const hashedCount = localHashes.length;
const communityCount = communityMatches.length;
if (hashedCount === 0) {
return `
<div class="hash-entry">
<div class="hash-entry-info">
<span class="hash-filename">${escHtml(folderName)}</span>
<span class="hash-badge hash-none">Not hashed</span>
</div>
<div class="hash-entry-status">
<button class="btn btn-sm btn-teal hash-btn-add"
data-path="${escHtml(b.backup_path)}"
data-game="${escHtml(game_id)}"
data-source="${escHtml(b.source || 'local')}">Hash All Files</button>
</div>
</div>`;
}
const allCommunity = communityCount === hashedCount;
const statusBadge = allCommunity
? `<span class="hash-badge hash-community">${communityCount} file${communityCount !== 1 ? 's' : ''} community ✓</span>`
: communityMismatch
? `<span class="hash-badge hash-mismatch">⚠ Hash Mismatch — ${hashedCount} local hash${hashedCount !== 1 ? 'es' : ''} don't match community list</span>`
: `<span class="hash-badge hash-local">${hashedCount} file${hashedCount !== 1 ? 's' : ''} hashed</span>`
+ (communityCount > 0 ? ` <span class="hash-badge hash-community">${communityCount} community ✓</span>` : '');
return `
<div class="hash-entry">
<div class="hash-entry-info">
<span class="hash-filename">${escHtml(folderName)}</span>
${statusBadge}
</div>
<div class="hash-entry-status">
<button class="btn btn-sm hash-btn-reverify"
data-path="${escHtml(b.backup_path)}"
data-game="${escHtml(game_id)}"
data-source="${escHtml(b.source || 'local')}">Re-verify All</button>
</div>
</div>`;
}).join('');
}
async function runHash(backup_path, game_id, verified = 1, hash_type = 'game') {
if (hashingInProgress) return;
hashingInProgress = true;
// Disable all hash buttons while running
$('modal-hash-list').querySelectorAll('button').forEach(b => b.disabled = true);
const prog = $('modal-hash-progress');
const fill = $('hash-progress-fill');
fill.style.width = '0%';
prog.hidden = false;
try {
const { hashes } = await window.pork.hashCompute(backup_path, game_id);
let communityCount = 0;
for (const h of hashes) {
const { communityMatch } = await window.pork.hashAdd({
game_id, file_hash: h.file_hash, file_size: h.file_size, backup_path: h.backup_path, verified, hash_type,
});
if (communityMatch) communityCount++;
}
prog.hidden = true;
if (communityCount > 0) {
setStatus(`${hashes.length} file(s) hashed — ${communityCount} community match(es) ✓`, 'ok');
} else {
setStatus(`${hashes.length} file(s) hashed — will appear in the next community update`, 'ok');
}
// Re-render the hash section with fresh data
const backups = await window.pork.listBackups(game_id);
await renderHashSection(game_id, backups);
} catch (e) {
prog.hidden = true;
setStatus(`Hash failed: ${e.message}`, 'error');
// Re-enable buttons on error
$('modal-hash-list').querySelectorAll('button').forEach(b => b.disabled = false);
} finally {
hashingInProgress = false;
}
}
// Hash button delegation — attached once on the hash list container
$('modal-hash-list').addEventListener('click', async e => {
if (hashingInProgress) return;
// ── Hash & Add (not yet in DB) ─────────────────────────────────────────────
const addBtn = e.target.closest('.hash-btn-add');
if (addBtn) {
await runHash(addBtn.dataset.path, addBtn.dataset.game, 1, addBtn.dataset.source === 'backpork' ? 'backpork' : 'game');
return;
}
// ── Re-verify (already in DB) — show inline confirm first ─────────────────
const reverifyBtn = e.target.closest('.hash-btn-reverify');
if (reverifyBtn) {
const statusEl = reverifyBtn.closest('.hash-entry-status');
const path = reverifyBtn.dataset.path;
const game = reverifyBtn.dataset.game;
const source = reverifyBtn.dataset.source;
// Replace button with inline confirm
reverifyBtn.style.display = 'none';
const confirm = document.createElement('div');
confirm.className = 'hash-confirm';
confirm.innerHTML = `
<span class="hash-confirm-msg">Game works?</span>
<button class="btn btn-sm btn-teal hash-confirm-yes">Yes, verify</button>
<button class="btn btn-sm hash-confirm-no">Cancel</button>`;
statusEl.appendChild(confirm);
confirm.querySelector('.hash-confirm-yes').addEventListener('click', async () => {
confirm.remove();
await runHash(path, game, 1, source === 'backpork' ? 'backpork' : 'game');
});
confirm.querySelector('.hash-confirm-no').addEventListener('click', () => {
confirm.remove();
reverifyBtn.style.display = '';
});
return;
}
});
+199
View File
@@ -0,0 +1,199 @@
// ── Accent color picker ────────────────────────────────────────────────────────
$('s-accent-color').addEventListener('input', async e => {
const hex = e.target.value;
applyAccent(hex);
$('s-accent-hex').value = hex.toUpperCase();
await window.pork.setSettings({ accentColor: hex });
});
// Hex text field — let user type a hex value directly
$('s-accent-hex').addEventListener('input', e => {
const raw = e.target.value.trim();
const hex = raw.startsWith('#') ? raw : '#' + raw;
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
applyAccent(hex);
window.pork.setSettings({ accentColor: hex });
}
});
$('btn-reset-accent').addEventListener('click', async () => {
const def = '#CBBAFF';
applyAccent(def);
await window.pork.setSettings({ accentColor: def });
setStatus('Accent color reset to default', 'ok');
});
// Confirm dialog helper
function showConfirm(message) {
return new Promise(resolve => {
$('confirm-message').textContent = message;
openAccessibleDialog($('confirm-overlay'), $('confirm-ok'));
function cleanup(result) {
closeAccessibleDialog($('confirm-overlay'));
$('confirm-ok').removeEventListener('click', onOk);
$('confirm-cancel').removeEventListener('click', onCancel);
$('confirm-overlay').removeEventListener('keydown', onKey);
resolve(result);
}
const onOk = () => cleanup(true);
const onCancel = () => cleanup(false);
const onKey = event => { if (event.key === 'Escape') onCancel(); };
$('confirm-ok').addEventListener('click', onOk);
$('confirm-cancel').addEventListener('click', onCancel);
$('confirm-overlay').addEventListener('keydown', onKey);
});
}
// Prompt dialog helper
function showPrompt(message, defaultValue = '', options = {}) {
return new Promise(resolve => {
$('prompt-message').textContent = message;
$('prompt-input').value = defaultValue;
$('prompt-dialog').classList.toggle('prompt-dialog-danger', options.tone === 'danger');
$('prompt-ok').className = `btn ${options.tone === 'danger' ? 'btn-danger' : 'btn-accent'}`;
$('prompt-ok').textContent = options.confirmLabel || (typeof window.t === 'function' ? window.t('common.ok', 'OK') : 'OK');
$('prompt-cancel').textContent = typeof window.t === 'function' ? window.t('common.cancel', 'Cancel') : 'Cancel';
openAccessibleDialog($('prompt-overlay'), $('prompt-input'));
function cleanup(result) {
closeAccessibleDialog($('prompt-overlay'));
$('prompt-ok').removeEventListener('click', onOk);
$('prompt-cancel').removeEventListener('click', onCancel);
$('prompt-input').removeEventListener('keydown', onKey);
resolve(result);
}
function onOk() { cleanup($('prompt-input').value); }
function onCancel() { cleanup(null); }
function onKey(e) { if (e.key === 'Enter') onOk(); else if (e.key === 'Escape') onCancel(); }
$('prompt-ok').addEventListener('click', onOk);
$('prompt-cancel').addEventListener('click', onCancel);
$('prompt-input').addEventListener('keydown', onKey);
});
}
async function showExactConfirmation(message, phrase) {
const isDutch = window.i18n?.current === 'nl';
const englishPhrases = { VERWIJDER: 'DELETE', 'VERWIJDER VAN PS5': 'DELETE FROM PS5', UITSCHAKELEN: 'DISABLE', INTREKKEN: 'REVOKE', HERSTEL: 'RESTORE', 'PAS ETAHEN TOE': 'APPLY ETAHEN', 'WEK PS5': 'WAKE PS5' }; // i18n: server confirmation tokens
const visiblePhrase = isDutch ? phrase : (englishPhrases[phrase] || phrase);
const instruction = isDutch ? `Typ exact ${visiblePhrase} om door te gaan:` : `Type ${visiblePhrase} exactly to continue:`;
const entered = await showPrompt(`${message}\n\n${instruction}`, '', {
tone: 'danger',
confirmLabel: isDutch ? 'Bevestigen' : 'Confirm',
});
if (entered === visiblePhrase) return phrase;
if (entered !== null) showToast(isDutch ? 'Actie geannuleerd: de bevestiging kwam niet exact overeen.' : 'Action cancelled: the confirmation phrase did not match exactly.', 'info');
return null;
}
// Destination picker for upload and conversion
function showDestinationPicker(paths) {
return new Promise(resolve => {
const list = $('dest-picker-list');
list.innerHTML = paths.map((p, i) => `
<button class="dest-picker-option" data-idx="${i}">
<span class="dest-opt-label">${escHtml(p.label)}</span>
<span class="dest-opt-path">${escHtml(p.path)}</span>
</button>
`).join('');
openAccessibleDialog($('dest-picker-overlay'), list.querySelector('.dest-picker-option'));
let resolved = false;
function cleanup(result) {
if (resolved) return;
resolved = true;
closeAccessibleDialog($('dest-picker-overlay'));
// #dest-picker-cancel is a persistent element; remove our handler so it
// doesn't accumulate across opens when the user picks an option instead.
$('dest-picker-cancel').removeEventListener('click', onCancel);
$('dest-picker-overlay').removeEventListener('keydown', onKey);
resolve(result);
}
const onCancel = () => cleanup(null);
const onKey = event => { if (event.key === 'Escape') onCancel(); };
// Option buttons live in list.innerHTML, which is re-rendered each open, so
// their listeners die with the old DOM — no cleanup needed for those.
list.querySelectorAll('.dest-picker-option').forEach((btn, i) => {
btn.addEventListener('click', () => cleanup(paths[i]), { once: true });
});
$('dest-picker-cancel').addEventListener('click', onCancel);
$('dest-picker-overlay').addEventListener('keydown', onKey);
});
}
// Ensure a game has an ftp_path set (prompting if needed). Returns the path or null if cancelled.
async function ensureGameFtpPath(gameId) {
const g = state.currentGame?.game_id === gameId ? state.currentGame
: (await window.pork.listGames({})).find(x => x.game_id === gameId);
if (g?.ftp_path) return g.ftp_path;
// No stored path — pick one
let basePath = null;
if (remoteGamePaths.length === 0) {
// No locations configured — use default, but don't store (user hasn't set up locations)
return `/mnt/sandbox/pfsmnt/${gameId}-app0`;
} else if (remoteGamePaths.length === 1) {
basePath = remoteGamePaths[0].path;
} else {
const chosen = await showDestinationPicker(remoteGamePaths);
if (!chosen) return null;
basePath = chosen.path;
}
const ftp_path = basePath.replace(/\/?$/, '/') + gameId + '-app0';
await window.pork.updateGame({ game_id: gameId, fields: { ftp_path } });
if (state.currentGame?.game_id === gameId) {
state.currentGame = { ...state.currentGame, ftp_path };
renderManageInstallLocation(state.currentGame);
}
return ftp_path;
}
$('btn-refresh-all-meta').addEventListener('click', async () => {
const ok = await showConfirm(
'This will clear all cached Prospero metadata and re-fetch every game from scratch. ' +
'Background fetches will queue automatically. Continue?'
);
if (!ok) return;
$('btn-refresh-all-meta').disabled = true;
setStatus('Queueing metadata refresh for all games…');
try {
const { queued } = await window.pork.prosperoRefreshAll();
setStatus(`Refreshing metadata for ${queued} game(s) in background…`, 'ok');
} catch (e) {
setStatus(`Refresh failed: ${e.message}`, 'error');
} finally {
$('btn-refresh-all-meta').disabled = false;
}
});
// Firmware helpers
function renderFwPills(firmware_labels) {
if (!firmware_labels) return '<span style="color:var(--text-dim)">—</span>';
return firmware_labels.split(',').filter(Boolean)
.map(l => `<span class="fw-pill fw-pill-patched" title="Backporked for firmware ${escHtml(l)}">&#x1F527; ${escHtml(l)}</span>`).join('');
}
async function refreshFirmwareFilters() {
const labels = await window.pork.firmwareLabels();
const row = $('firmware-filter-row');
const cont = $('firmware-filter-btns');
if (!labels.length) { row.hidden = true; return; }
row.hidden = false;
cont.innerHTML = `<button class="fwbtn${state.firmwareFilter === 'all' ? ' active' : ''}" data-fw="all">All</button>` +
labels.map(l => `<button class="fwbtn${state.firmwareFilter === l ? ' active' : ''}" data-fw="${escHtml(l)}">${escHtml(l)}</button>`).join('');
cont.onclick = e => {
const btn = e.target.closest('.fwbtn');
if (!btn) return;
state.firmwareFilter = btn.dataset.fw;
cont.querySelectorAll('.fwbtn').forEach(b => b.classList.toggle('active', b.dataset.fw === state.firmwareFilter));
renderGames();
};
}
+103
View File
@@ -0,0 +1,103 @@
// ── System View hotkeys ────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
// Escape always exits in-app fullscreen, regardless of active page
if (e.key === 'Escape' && $('sv-viewer-wrap')?.classList.contains('sv-viewer-wrap--fullscreen')) {
e.preventDefault();
svToggleFullscreen();
return;
}
if (!$('page-system-view')?.classList.contains('active')) return;
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
const k = e.key.toLowerCase();
if (k === _svHotkeys.mute) { e.preventDefault(); $('btn-sv-mute').click(); }
else if (k === _svHotkeys.gif) { e.preventDefault(); $('btn-sv-record').click(); }
else if (k === _svHotkeys.video) { e.preventDefault(); $('btn-sv-vid-rec').click(); }
else if (k === _svHotkeys.fullscreen) { e.preventDefault(); svToggleFullscreen(); }
else if (k === _svHotkeys.popout) { e.preventDefault(); $('btn-sv-popout').click(); }
});
// Auto-scan toast on launch
window.pork.on('local:scan:complete', async ({ total, added }) => {
if (added > 0) {
showToast(`Auto-scan: found ${added} new game${added !== 1 ? 's' : ''} (${total} total) — fetching metadata…`, 'ok');
} else if (total > 0) {
showToast(`Auto-scan: ${total} game${total !== 1 ? 's' : ''} already tracked`, 'info');
}
// Always refresh the active page so pruned/stale game rows clear from the UI
// even when 0 games were found this scan.
const activePage = document.querySelector('.page.active')?.id;
if (activePage === 'page-dashboard') loadDashboard();
if (activePage === 'page-games') loadGames();
if (activePage === 'page-backups') loadBackups();
});
// Toast when Prospero background queue finishes
window.pork.on('prospero:queue:done', ({ fetched, failed }) => {
if (fetched > 0 && failed === 0) {
showToast(`Metadata fetched for ${fetched} game${fetched !== 1 ? 's' : ''}`, 'ok');
} else if (fetched > 0) {
showToast(`Metadata fetched for ${fetched} game${fetched !== 1 ? 's' : ''} (${failed} failed)`, 'info');
} else if (failed > 0) {
showToast(`Metadata fetch failed for ${failed} game${failed !== 1 ? 's' : ''}`, 'error', 8000);
}
});
// Live-refresh modal when background fetch completes
window.pork.on('prospero:updated', async ({ game_id, data }) => {
// Reload games list in background so future opens have fresh data
state.games = await window.pork.listGames({});
renderGames(); // Redraw table so icon/name/metadata show without requiring a page nav
// Update cheat icon map and any visible cheat card for this game
if (data.iconUrl) {
_cheatIconMap.set(game_id, data.iconUrl);
document.querySelectorAll(`.cheat-card[data-cusa="${game_id}"]`).forEach(card => {
const empty = card.querySelector('.cheat-card-icon-empty');
if (!empty) return;
const img = document.createElement('img');
img.className = 'cheat-card-icon';
img.alt = '';
img.src = data.iconUrl;
img.onerror = () => { img.style.display = 'none'; };
empty.replaceWith(img);
});
}
// If the modal is open for this game, refresh it
if (state.currentGame?.game_id === game_id) {
const updated = state.games.find(g => g.game_id === game_id);
if (updated) {
state.currentGame = updated;
$('modal-title').textContent = updated.prospero_name || updated.title || game_id;
$('modal-subtitle').textContent = `${updated.game_id} · ${updated.prospero_region || (updated.version ? 'v' + updated.version : '')}`;
// Refresh info table with new metadata
const patches = tryParseJson(updated.prospero_patches, []);
const fwVersions = [...new Set(patches.map(p => p.requiredFirmware).filter(Boolean))];
const dlc = tryParseJson(updated.prospero_dlc, []);
const infoRows = [
['Game ID', updated.game_id],
['Content ID', updated.content_id || updated.prospero_content_id || '—'],
['Version', updated.version || updated.prospero_version || '—'],
['Size', updated.backup_size ? fmt(updated.backup_size) : (updated.size ? fmt(updated.size) : (updated.prospero_size || '—'))],
['Installed', updated.installed ? 'Yes' : 'No'],
['Backed Up', updated.backed_up ? 'Yes' : 'No'],
['Prepared for firmware', updated.porked_firmware ? `${updated.porked_firmware}${updated.porked_at ? ' (' + new Date(updated.porked_at).toLocaleDateString() + ')' : ''}` : '—'],
['Publisher', updated.prospero_publisher || '—'],
['Publisher ID', updated.prospero_publisher_id || '—'],
['Region', updated.prospero_region || '—'],
['Last Updated', updated.prospero_last_updated || '—'],
['Required Firmware', fwVersions.length ? fwVersions.join(', ') : '—'],
['DLC Available', dlc.length ? `${dlc.length} item(s)` : 'None'],
['FTP Path', updated.ftp_path || '—'],
['Last Scanned', updated.last_scanned ? new Date(updated.last_scanned).toLocaleString() : '—'],
['Metadata Age', updated.prospero_fetched_at ? fmtAge(Date.now() - updated.prospero_fetched_at) : 'Never fetched'],
];
$('modal-info').innerHTML = infoRows.map(([k, v]) => `<tr><th>${escHtml(k)}</th><td>${escHtml(v)}</td></tr>`).join('');
renderProsperoData(updated);
}
}
});
+84
View File
@@ -0,0 +1,84 @@
// ── Chimera i18n engine ───────────────────────────────────────────────────────
// Loaded first so window.t() is available to all modules.
// Call window.i18n.load(code) to fetch a lang file and re-apply all translations.
window.chimeraI18nReady = new Promise(resolve => {
window.chimeraResolveI18nReady = resolve;
});
window.i18n = {
current: 'en',
strings: {},
_observer: null,
/** Translate a key. Falls back to `fallback` if provided, then the key itself. */
t(key, fallback) {
const v = this.strings[key];
if (v !== undefined) return v;
return fallback !== undefined ? fallback : key;
},
/** Walk every [data-i18n] element and set its textContent to the translated string.
* If no translation exists for a key the original text is left unchanged. */
apply(root = document) {
document.documentElement.lang = this.current || 'en';
const select = selector => [
...(root.matches?.(selector) ? [root] : []),
...(root.querySelectorAll?.(selector) || []),
];
select('[data-i18n]').forEach(el => {
const val = this.strings[el.dataset.i18n];
if (val !== undefined) el.textContent = val;
});
select('[data-i18n-placeholder]').forEach(el => {
const val = this.strings[el.dataset.i18nPlaceholder];
if (val !== undefined) el.placeholder = val;
});
select('[data-i18n-title]').forEach(el => {
const val = this.strings[el.dataset.i18nTitle];
if (val !== undefined) el.title = val;
});
select('[data-i18n-aria-label]').forEach(el => {
const val = this.strings[el.dataset.i18nAriaLabel];
if (val !== undefined) el.setAttribute('aria-label', val);
});
if (!this._observer && document.body) {
this._observer = new MutationObserver(records => {
for (const record of records) {
for (const node of record.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) this.apply(node);
}
}
});
this._observer.observe(document.body, { childList: true, subtree: true });
}
},
/** Load a language file by code (e.g. "en", "fr", "de") via IPC, then apply. */
async load(code) {
if (!code) code = 'en';
try {
const data = await window.pork.langLoad(code);
if (data && typeof data.strings === 'object') {
this.strings = data.strings;
this.current = data.meta?.code || code;
}
} catch (e) {
console.warn('[i18n] Could not load language "' + code + '":', e.message || e);
// Fall back to empty (original HTML text remains visible)
this.strings = {};
this.current = 'en';
}
this.apply();
window.dispatchEvent(new CustomEvent('chimera:language-changed', {
detail: { code: this.current },
}));
},
};
/** Shorthand global translate function */
window.t = (key, fallback) => window.i18n.t(key, fallback);
window.tf = (key, fallback, values = {}) => Object.entries(values).reduce(
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
window.i18n.t(key, fallback),
);
+1
View File
@@ -0,0 +1 @@
"use strict";!function(t,e){const a=e();"object"==typeof module&&module.exports&&(module.exports=a),t?.document&&t.addEventListener("DOMContentLoaded",()=>a.mount(t))}("undefined"!=typeof window?window:null,function(){const t=["upload","inspect","recipe","destination","finalize"];function e(t){return Array.from(t||[]).map(t=>{return{file:t,relativePath:(e=t.webkitRelativePath||t.name,String(e||"").replace(/\\/g,"/").replace(/^\/+/,"").split("/").filter(t=>t&&"."!==t&&".."!==t).join("/")),size:Number(t.size)};var e}).filter(t=>t.relativePath&&Number.isSafeInteger(t.size)&&t.size>0)}function a(t,e,a){return e?(t+(a?-1:1)+e)%e:-1}function i(t){const e=Array.isArray(t?.files)?t.files:[],a=e.reduce((t,e)=>t+Number(e.size||0),0),i=e.reduce((t,e)=>t+Number(e.offset??e.bytesReceived??0),0);return{resumable:["created","uploading"].includes(t?.state),expected:a,received:i,percent:a?Math.min(100,Math.round(i/a*100)):0}}function r(t){return 0!==t.step||t.selection?.length||t.session?t.step>=2&&"classified"!==t.classification?.status?"importWizard.reviewRequired":t.step>=3&&!t.destinationId?"importWizard.error.destination":4!==t.step||t.confirmed?null:"importWizard.error.confirm":"importWizard.error.selection"}const o=t=>String(t??"").replace(/[&<>"']/g,t=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[t])),s=t=>{const e=Number(t);if(!Number.isFinite(e))return"unknown";const a=["B","KiB","MiB","GiB","TiB"];let i=0,r=e;for(;r>=1024&&i<a.length-1;)r/=1024,i+=1;return`${r.toFixed(i?1:0)} ${a[i]}`};async function n(t){const e=await crypto.subtle.digest("SHA-256",await t.arrayBuffer());return Array.from(new Uint8Array(e),t=>t.toString(16).padStart(2,"0")).join("")}return Object.freeze({deriveResumeState:i,mount:function(d){const l=d.pork;if(d.document.getElementById("chimera-import-open"))return;const c=(t,e)=>d.i18n?.t?.(t,e)||e,p={step:0,selection:[],session:null,job:null,classification:null,order:[],patchIds:new Set,artworkIds:new Set,installNotes:"",destinations:[],destinationId:"",preview:null,uploads:[],jobs:[],busy:!1,confirmed:!1,status:"",error:""};let m=null;const u=d.document.createElement("button");u.id="chimera-import-open",u.type="button",u.className="btn btn-accent chimera-import-launcher",u.style.cssText="position:fixed;right:22px;bottom:22px;z-index:1150",u.dataset.i18n="importWizard.open",u.textContent=c("importWizard.open","Managed import"),d.document.body.appendChild(u);const f=d.document.createElement("div");f.id="import-wizard-overlay",f.className="import-wizard-overlay",f.hidden=!0,f.innerHTML='<section class="import-wizard" role="dialog" aria-modal="true" aria-labelledby="import-wizard-title" aria-describedby="import-wizard-subtitle"><header class="import-wizard-head"><div><span class="import-wizard-kicker">QUARANTINE / VERIFY / COMMIT</span><h2 id="import-wizard-title" data-i18n="importWizard.title">Managed Import</h2><p id="import-wizard-subtitle" data-i18n="importWizard.subtitle">Upload, inspect and finalize without executing package content.</p></div><button type="button" class="import-wizard-close" data-wizard-close aria-label="Close import wizard" data-i18n-aria-label="importWizard.close">x</button></header><div class="import-wizard-steps" role="list"></div><main class="import-wizard-body"></main><footer class="import-wizard-footer"><div class="import-wizard-status" role="status" aria-live="polite"></div><div class="import-wizard-footer-actions"><button type="button" class="btn" data-wizard-previous data-i18n="importWizard.previous">Previous</button><button type="button" class="btn btn-accent" data-wizard-next data-i18n="importWizard.next">Next</button></div></footer><input id="import-wizard-files" type="file" multiple hidden><input id="import-wizard-folder" type="file" webkitdirectory multiple hidden></section>',d.document.body.appendChild(f);const b=f.querySelector(".import-wizard-body"),v=f.querySelector(".import-wizard-status"),h=()=>({installNotes:p.installNotes,componentOrder:p.order,patchComponentIds:[...p.patchIds],artworkComponentIds:[...p.artworkIds]}),w=()=>p.classification?.components?.reduce((t,e)=>t+Number(e.size||0),0)||p.selection.reduce((t,e)=>t+e.size,0);function z(){const t=p.session?i(p.session):{percent:0,received:0,expected:w()};b.innerHTML=`<div class="import-wizard-drop"><p data-i18n="importWizard.choose">Choose multiple files or a complete folder. Files stay quarantined until every validation succeeds.</p><button class="import-wizard-choice" type="button" data-choose-files data-i18n="importWizard.files">Files</button><button class="import-wizard-choice" type="button" data-choose-folder data-i18n="importWizard.folder">Folder</button></div>${p.selection.length?`<div class="import-wizard-progress" aria-label="${o(c("importWizard.progress","Upload progress"))}"><span style="width:${t.percent}%"></span></div><p>${p.selection.length} files - ${s(w())}</p><button class="btn btn-accent" type="button" data-start-upload data-i18n="importWizard.startUpload">Start re Line truncated
+114
View File
@@ -0,0 +1,114 @@
// ── External links — open in default browser via shell ────────────────────────
document.addEventListener('click', e => {
const link = e.target.closest('a.external-link[data-url]');
if (link) { e.preventDefault(); window.pork.openShell(link.dataset.url); }
});
document.addEventListener('click', e => {
const button = e.target.closest('[data-payload-target]');
if (!button) return;
const target = document.getElementById(button.dataset.payloadTarget);
if (!target) return;
target.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'start',
});
target.classList.add('payload-workspace-highlight');
window.setTimeout(() => target.classList.remove('payload-workspace-highlight'), 1200);
});
// Pin buttons
document.addEventListener('click', async e => {
const btn = e.target.closest('.btn-pin');
if (!btn) return;
e.stopPropagation(); // prevent tile nav from firing if btn is inside a tile
const pinId = btn.dataset.pinId;
if (!pinId) return;
const s = await window.pork.getSettings();
const pinned = new Set(s.pinnedCards || []);
if (pinned.has(pinId)) pinned.delete(pinId);
else pinned.add(pinId);
await window.pork.setSettings({ pinnedCards: [...pinned] });
// Update pin button visuals everywhere
document.querySelectorAll(`.btn-pin[data-pin-id="${pinId}"]`).forEach(b =>
b.classList.toggle('pinned', pinned.has(pinId))
);
// If on dashboard, refresh pinned grid immediately
if (document.querySelector('#page-dashboard.active')) renderPinnedTiles();
});
// Dashboard tiles
document.addEventListener('click', e => {
const tile = e.target.closest('.dash-pinned-tile[data-nav-page]');
if (tile) navigate(tile.dataset.navPage);
});
// Title bar
$('btn-min').addEventListener('click', () => window.pork.minimize());
$('btn-max').addEventListener('click', () => window.pork.maximize());
$('btn-cls').addEventListener('click', () => window.pork.close());
$('sidebar-donate').addEventListener('click', event => {
event.preventDefault();
if (typeof window.chimeraOpenAbout === 'function') window.chimeraOpenAbout();
else window.pork.openDonate();
});
$('titlebar-x-link').addEventListener('click', e => {
e.preventDefault();
showToast('Chimera · private PS4/PS5 operations hub', 'info');
});
// Conversion jobs
window.pork.on('conv:job-update', (job) => {
if (document.querySelector('#page-games.active')) {
patchConvJobCard(job);
}
});
window.pork.on('conv:queue:paused-change', () => {
if (document.querySelector('#page-games.active')) {
refreshConvQueuePanel().catch(() => {});
}
});
// Init
(async function init() {
// Load just the startup essentials. Heavy page-specific setup is deferred
// until the user actually opens those pages.
const s = await window.pork.getSettings().catch(() => ({}));
applyDensity(s.interfaceDensity);
try {
await window.i18n.load(s.language || 'en');
} finally {
window.chimeraResolveI18nReady?.();
delete window.chimeraResolveI18nReady;
}
await loadDashboard();
// Restore FTP connection state immediately so the UI isn't blank while devices enumerate.
let connected = false;
try {
({ connected } = await window.pork.ftpStatus());
setConnected(connected);
$('sb-sync').textContent = `Last connection check · ${new Date().toLocaleTimeString()}`;
} catch (_) {}
if (!connected && s.autoConnect && s.ftpHost) {
setStatus('Auto-connecting…');
try {
const autoPorts = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts : ['1337', '2121', '21'];
await window.pork.ftpConnect({
host: s.ftpHost,
ports: autoPorts,
user: s.ftpUser || '',
password: s.ftpPass || '',
});
setConnected(true);
setStatus('Connected', 'ok');
$('sb-sync').textContent = `Last connection check · ${new Date().toLocaleTimeString()}`;
} catch (_) {
setConnected(false);
setStatus('Auto-connect failed — enter credentials and connect manually', 'error');
}
}
scheduleBackgroundWarm(1200);
})();
+77
View File
@@ -0,0 +1,77 @@
(function installDestinationSelector() {
'use strict';
let options = [];
let requiredBytes = 0;
const esc = value => String(value == null ? '' : value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
const bytes = value => value == null ? 'unknown' : value >= 1024 ** 3 ? `${(value / 1024 ** 3).toFixed(1)} GB` : value >= 1024 ** 2 ? `${(value / 1024 ** 2).toFixed(1)} MB` : `${Math.max(0, value)} B`;
const age = value => value == null ? 'unknown age' : value < 60_000 ? 'just now' : value < 3_600_000 ? `${Math.ceil(value / 60_000)} min old` : `${Math.ceil(value / 3_600_000)} h old`;
const reason = code => ({
'offline-volume': 'offline',
'install-capability-disabled': 'install disabled by adapter',
'missing-mountpoint': 'mountpoint unavailable',
'capacity-unknown': 'capacity unknown',
'insufficient-capacity': 'not enough free space',
'stale-capacity-telemetry': 'capacity telemetry stale',
'stale-capability-telemetry': 'capability telemetry stale',
'compatibility-incompatible': 'blocked by applicable compatibility evidence',
}[code] || code);
function selected() {
const select = document.getElementById('install-destination-volume');
return options.find(item => item.volumeId === select?.value) || null;
}
function expertOpen() {
return document.getElementById('install-expert-destination')?.open === true;
}
function renderStatus() {
const status = document.getElementById('install-destination-status');
const option = selected();
if (!status) return;
if (option) {
status.className = `install-destination-status ${option.eligible ? 'is-eligible' : 'is-ineligible'}`;
status.innerHTML = `<strong>${esc(option.eligible ? 'Eligible destination' : 'Destination unavailable')}</strong><span>Required ${esc(bytes(requiredBytes))} · free ${esc(bytes(option.freeBytes))} · projected ${esc(bytes(option.projectedFreeBytes))} · evidence ${esc(age(option.capacityAgeMs))}${option.reasons.length ? ` · ${esc(option.reasons.map(reason).join(', '))}` : ''}</span>`;
const raw = document.getElementById('install-ftp-path');
if (option.compatibility) status.insertAdjacentHTML('beforeend', window.chimeraCompatibilityGuidance?.markup?.(option.compatibility) || '');
if (raw && option.suggestedRemotePath) raw.value = option.suggestedRemotePath;
} else {
status.className = 'install-destination-status is-ineligible';
status.innerHTML = '<strong>No eligible semantic destination</strong><span>Refresh console storage evidence or use the Expert raw-path override.</span>';
}
if (typeof updateInstallConfirmState === 'function') updateInstallConfirmState('pfs');
}
async function load({ titleId, required = 0, legacyPath = '' } = {}) {
requiredBytes = Math.max(0, Number(required) || 0);
const packet = await window.pork.installDestinations({ titleId, requiredBytes });
options = Array.isArray(packet?.destinations) ? packet.destinations : [];
const select = document.getElementById('install-destination-volume');
const raw = document.getElementById('install-ftp-path');
if (raw) raw.value = legacyPath || '';
if (!select) return packet;
select.innerHTML = '<option value="">Select a destination volume</option>' + options.map(option => {
const suffix = option.eligible ? `${bytes(option.freeBytes)} free` : option.reasons.map(reason).join(', ');
return `<option value="${esc(option.volumeId)}"${option.eligible ? '' : ' disabled'}>${esc(option.deviceName)} · ${esc(option.volumeKey)} · ${esc(suffix)}</option>`;
}).join('');
const preferred = options.find(option => option.eligible);
select.value = preferred?.volumeId || '';
renderStatus();
return packet;
}
function selection() {
const option = selected();
if (option?.eligible) return { destinationVolumeId: option.volumeId, remotePath: option.suggestedRemotePath, expertRawPath: false };
const raw = document.getElementById('install-ftp-path')?.value?.trim() || '';
return expertOpen() && raw ? { destinationVolumeId: null, remotePath: raw, expertRawPath: true } : null;
}
document.getElementById('install-destination-volume')?.addEventListener('change', renderStatus);
document.getElementById('install-expert-destination')?.addEventListener('toggle', renderStatus);
document.getElementById('install-ftp-path')?.addEventListener('input', renderStatus);
window.chimeraInstallDestination = Object.freeze({ load, selection, isReady: () => Boolean(selection()) });
})();
+16
View File
@@ -0,0 +1,16 @@
'use strict';
(function exposeLibraryModel(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.chimeraLibraryModel = api;
})(typeof globalThis !== 'undefined' ? globalThis : window, function createLibraryModel() {
function items(response) {
if (Array.isArray(response)) return response;
return Array.isArray(response?.items) ? response.items : [];
}
function isInstalled(title) { return Number(title?.availability?.confirmedInstallCount || 0) > 0; }
function hasLocalCopy(title) { return Number(title?.protection?.localCopyCount || title?.availability?.localCopyCount || 0) > 0; }
function needsProtection(title) { return isInstalled(title) && !hasLocalCopy(title); }
return Object.freeze({ items, isInstalled, hasLocalCopy, needsProtection });
});
+391
View File
@@ -0,0 +1,391 @@
// ── Manage panel ──────────────────────────────────────────────────────────────
let uploadInProgress = false;
let uploadLocalPath = null;
window.pork.on('ftp:upload:progress', info => {
const log = $('manage-upload-log');
if (!log) return;
// Directory upload shape has filesTotal; single-file shape has bytes/size
if (info.filesTotal != null) {
const pct = info.filesTotal > 0 ? Math.round((info.filesDone / info.filesTotal) * 100) : 0;
log.textContent = `Uploading… File ${info.filesDone}/${info.filesTotal} (${pct}%)${info.file ? ' ' + info.file : ''}`;
} else {
const done = info.bytes || 0;
const total = info.size || 0;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
log.textContent = `Uploading… ${pct}% (${fmt(done)} / ${fmt(total)})`;
}
});
async function renderManagePanel(g, backups) {
// ── Backup file list (local only — backpork entries shown in Backpork Versions) ─
const list = $('manage-backup-list');
backups = backups.filter(b => b.source !== 'backpork');
$('mtab-manage-count').textContent = backups.length || '';
if (!backups.length) {
list.innerHTML = '<div class="prospero-placeholder">No backup files recorded.</div>';
} else {
list.innerHTML = backups.map(b => {
const filename = b.backup_path.replace(/\\/g, '/').split('/').pop();
return `
<div class="manage-backup-item" data-backup-id="${b.id}" data-game="${escHtml(g.game_id)}"
data-local="${escHtml(b.backup_path)}" data-folder="${escHtml(b.backup_path)}">
<div class="manage-backup-info">
<span class="manage-filename" title="${escHtml(b.backup_path)}">${escHtml(filename)}</span>
<span class="manage-size">${fmt(b.size)}</span>
<span class="pill pill-num">${escHtml(b.backup_type || 'dump')}</span>
</div>
<div class="manage-backup-actions">
<button class="btn btn-sm manage-btn-folder">Open Folder</button>
<button class="btn btn-sm btn-teal manage-btn-upload">Upload to PS5</button>
<button class="btn btn-sm btn-danger manage-btn-remove">Remove Record</button>
</div>
</div>`;
}).join('');
}
// ── Upload section state ────────────────────────────────────────────────────
uploadLocalPath = null;
$('manage-remote-path').value = '';
$('manage-remote-path').readOnly = true;
$('btn-manage-upload').disabled = true;
$('manage-upload-log').hidden = true;
// ── Backed-up / Installed status buttons ─────────────────────────────────────
const toggleBackedBtn = $('btn-manage-toggle-backed');
if (g.backed_up) {
toggleBackedBtn.textContent = 'Clear Backed Up Status';
toggleBackedBtn.className = 'btn btn-danger';
} else {
toggleBackedBtn.textContent = 'Mark as Backed Up';
toggleBackedBtn.className = 'btn btn-teal';
}
const toggleInstBtn = $('btn-manage-toggle-installed');
if (g.installed) {
toggleInstBtn.textContent = 'Mark as Not Installed';
toggleInstBtn.className = 'btn btn-danger';
} else {
toggleInstBtn.textContent = 'Mark as Installed';
toggleInstBtn.className = 'btn';
}
// ── Install location display ──────────────────────────────────────────────────
renderManageInstallLocation(g);
// ── Backpork versions section ────────────────────────────────────────────────
await renderBackporkSection(g);
}
// ── Manage: install location display ─────────────────────────────────────────
function renderManageInstallLocation(g) {
const locRow = $('manage-install-location');
const noLocRow = $('manage-no-location');
const locPath = $('manage-loc-path');
const deleteBtn = $('btn-manage-delete-ps5');
const backupBtn = $('btn-manage-backup-ps5');
if (g?.ftp_path) {
locRow.hidden = false;
noLocRow.hidden = true;
locPath.textContent = g.ftp_path;
deleteBtn.disabled = !state.connected;
backupBtn.disabled = !state.connected;
} else {
locRow.hidden = true;
noLocRow.hidden = false;
locPath.textContent = '';
deleteBtn.disabled = true;
backupBtn.disabled = true;
}
}
$('btn-manage-forget-loc').addEventListener('click', async () => {
if (!state.currentGame) return;
const gameId = state.currentGame.game_id;
await window.pork.updateGame({ game_id: gameId, fields: { ftp_path: '' } });
state.currentGame = { ...state.currentGame, ftp_path: '' };
renderManageInstallLocation(state.currentGame);
setStatus('PS5 install location forgotten', 'ok');
});
$('btn-manage-backup-ps5').addEventListener('click', async () => {
if (!state.currentGame || !state.connected) return;
const { game_id, ftp_path } = state.currentGame;
if (!ftp_path) { setStatus('No PS5 location stored — run a scan first', 'error'); return; }
const btn = $('btn-manage-backup-ps5');
btn.disabled = true;
try {
await window.pork.ftpDownloadFolder(game_id);
setStatus('Backup queued — see Transfers for progress', 'ok');
} catch (e) {
setStatus(e.message, 'error');
} finally {
btn.disabled = !state.connected || !state.currentGame?.ftp_path;
}
});
$('btn-manage-delete-ps5').addEventListener('click', async () => {
if (!state.currentGame) return;
const g = state.currentGame;
if (!g.ftp_path) { setStatus('No PS5 location stored — cannot delete', 'error'); return; }
const confirmation = await showExactConfirmation(
`Delete "${g.title || g.game_id}" from PS5?\n\nFolder: ${g.ftp_path}\n\nThis cannot be undone.`,
'VERWIJDER VAN PS5'
);
if (!confirmation) return;
const btn = $('btn-manage-delete-ps5');
btn.disabled = true;
btn.textContent = 'Deleting…';
try {
await window.pork.ftpDeleteGame(g.game_id, confirmation);
state.currentGame = { ...g, availability: { ...(g.availability || {}), state: 'absent', confirmedInstallCount: 0 }, ftp_path: '' };
state.games = await window.pork.listGames({});
renderManageInstallLocation(state.currentGame);
const toggleInstBtn = $('btn-manage-toggle-installed');
toggleInstBtn.textContent = 'Mark as Installed';
toggleInstBtn.className = 'btn';
setStatus(`${g.title || g.game_id} deleted from PS5`, 'ok');
} catch (err) {
setStatus(`Delete failed: ${err.message}`, 'error');
} finally {
btn.textContent = 'Delete from PS5';
btn.disabled = !state.connected || !state.currentGame?.ftp_path;
}
});
// ── Backpork section in Manage tab ────────────────────────────────────────────
let porkInProgress = false;
window.pork.on('backporks:pork:progress', ({ file, filesDone, filesTotal, bytesOverall }) => {
const fill = $('pork-progress-fill');
const label = $('pork-progress-label');
const pct = filesTotal > 0 ? Math.round((filesDone / filesTotal) * 100) : 0;
if (fill) fill.style.width = `${pct}%`;
if (label) label.textContent = `Uploading ${filesDone + 1}/${filesTotal}: ${escHtml(file || '')}`;
});
async function renderBackporkSection(g) {
const section = $('manage-backpork-section');
const entries = await window.pork.backporksEntries(g.game_id);
if (!entries.length) { section.hidden = true; return; }
section.hidden = false;
// Porked-to status
const statusEl = $('manage-porked-status');
if (g.porked_firmware) {
const when = g.porked_at ? ` · ${new Date(g.porked_at).toLocaleDateString()}` : '';
statusEl.innerHTML = `
<div class="manage-porked-status">
✓ Porked to firmware <strong>${escHtml(g.porked_firmware)}</strong>${escHtml(when)}
<button class="clear-pork btn btn-sm" data-game="${escHtml(g.game_id)}">Clear</button>
</div>`;
} else {
statusEl.innerHTML = '';
}
// Entry list
const listEl = $('manage-backpork-list');
listEl.innerHTML = entries.map(e => {
const gamePath = e.game_path || e.folder_path;
const displayPath = gamePath.replace(/\\/g, '/').split('/').pop();
return `
<div class="manage-backpork-entry"
data-fw="${escHtml(e.folder_name)}"
data-path="${escHtml(e.folder_path)}"
data-game="${escHtml(g.game_id)}">
<span class="manage-backpork-fw">${escHtml(e.folder_name)}</span>
<span class="manage-backpork-path" title="${escHtml(gamePath)}">${escHtml(displayPath)}</span>
<button class="btn btn-sm manage-btn-view-backpork"
data-open="${escHtml(gamePath)}">View Backpork</button>
<button class="btn btn-sm btn-teal pork-btn"
${!state.connected ? 'disabled title="Connect to PS5 first"' : ''}>
Pork to PS5
</button>
</div>`;
}).join('');
// Reset progress bar
$('manage-pork-progress').hidden = true;
$('pork-progress-fill').style.width = '0%';
}
// Clear porked status
$('manage-porked-status').addEventListener('click', async e => {
const btn = e.target.closest('.clear-pork');
if (!btn) return;
const gameId = btn.dataset.game;
await window.pork.updateGame({ game_id: gameId, fields: { porked_firmware: null, porked_at: null } });
state.games = await window.pork.listGames({});
const updated = state.games.find(x => x.game_id === gameId) || state.currentGame;
state.currentGame = { ...state.currentGame, porked_firmware: null, porked_at: null };
await renderBackporkSection(state.currentGame);
setStatus('Porked status cleared', 'ok');
});
// Backpork list click delegation
$('manage-backpork-list').addEventListener('click', async e => {
// ── View Backpork folder ─────────────────────────────────────────────────────
const viewBtn = e.target.closest('.manage-btn-view-backpork');
if (viewBtn) {
window.pork.openShell(viewBtn.dataset.open);
return;
}
// Pork to PS5
const btn = e.target.closest('.pork-btn');
if (!btn || porkInProgress) return;
const entry = btn.closest('.manage-backpork-entry');
if (!entry) return;
const gameId = entry.dataset.game;
const folderName = entry.dataset.fw;
const folderPath = entry.dataset.path;
// Ensure a destination is remembered before porking
const ftpDest = await ensureGameFtpPath(gameId);
if (!ftpDest) return; // user cancelled picker
porkInProgress = true;
btn.disabled = true;
$('manage-pork-progress').hidden = false;
$('pork-progress-fill').style.width = '0%';
$('pork-progress-label').textContent = 'Starting…';
setStatus(`Porking ${gameId} (${folderName})…`);
try {
const { remotePath } = await window.pork.backporksPork(gameId, folderName, folderPath);
$('manage-pork-progress').hidden = true;
setStatus(`Porked to PS5 at ${remotePath}`, 'ok');
// Refresh game state
state.games = await window.pork.listGames({});
const updated = state.games.find(x => x.game_id === gameId);
if (updated) {
state.currentGame = updated;
await renderBackporkSection(updated);
// Update installed toggle
const toggleInstBtn = $('btn-manage-toggle-installed');
toggleInstBtn.textContent = 'Mark as Not Installed';
toggleInstBtn.className = 'btn btn-danger';
}
} catch (err) {
$('manage-pork-progress').hidden = true;
setStatus(`Pork failed: ${err.message}`, 'error');
} finally {
porkInProgress = false;
btn.disabled = false;
}
});
// Toggle installed status
$('btn-manage-toggle-installed').addEventListener('click', async () => {
setStatus('Install status is derived from complete console scan evidence.', 'info');
});
// ── Delegation for all manage-panel interactions (attached once, never re-bound)
$('manage-backup-list').addEventListener('click', async e => {
const item = e.target.closest('.manage-backup-item');
if (!item) return;
const backupId = Number(item.dataset.backupId);
const gameId = item.dataset.game;
const local = item.dataset.local;
const folder = item.dataset.folder;
// ── Open Folder ─────────────────────────────────────────────────────────────
if (e.target.closest('.manage-btn-folder')) {
window.pork.openShell(folder);
return;
}
// ── Upload to PS5 — inline expand ───────────────────────────────────────────
if (e.target.closest('.manage-btn-upload')) {
if (uploadInProgress) return;
await openInstallModal(gameId, 'pfs');
return;
}
// ── Remove backup record ─────────────────────────────────────────────────────
if (e.target.closest('.manage-btn-remove')) {
const btn = e.target.closest('.manage-btn-remove');
const confirmation = await showExactConfirmation('Remove this backup record from Chimera?', 'VERWIJDER');
if (!confirmation) return;
btn.disabled = true;
try {
await window.pork.deleteBackup(backupId, confirmation);
setStatus('Backup record removed', 'ok');
// Refresh the modal's backup-dependent sections
const backups = await window.pork.listBackups(gameId);
const localBackups = backups.filter(b => b.source !== 'backpork');
state.games = await window.pork.listGames({});
const g = state.games.find(x => x.game_id === gameId) || state.currentGame;
state.currentGame = g;
await renderManagePanel(g, backups);
await renderHashSection(gameId, backups);
$('modal-backup-list').innerHTML = localBackups.length
? localBackups.map(b => `<li><span>${escHtml(b.backup_path)}</span><span>${fmt(b.size)}</span></li>`).join('')
: '<li style="justify-content:center">No backups recorded</li>';
} catch (err) {
setStatus(err.message, 'error');
btn.disabled = false;
}
return;
}
});
$('btn-manage-upload').addEventListener('click', async () => {
if (uploadInProgress || !uploadLocalPath) return;
const remotePath = $('manage-remote-path').value.trim();
if (!remotePath) { setStatus('Enter a remote path on the PS5', 'error'); return; }
uploadInProgress = true;
$('btn-manage-upload').disabled = true;
const log = $('manage-upload-log');
log.hidden = false;
log.textContent = 'Starting upload…';
try {
await window.pork.ftpUpload({ localPath: uploadLocalPath, remotePath });
log.textContent = 'Upload complete ✓';
setStatus('Uploaded to PS5 successfully', 'ok');
uploadLocalPath = null;
$('manage-remote-path').value = '';
$('manage-remote-path').readOnly = true;
document.querySelectorAll('.manage-item-selected').forEach(el => el.classList.remove('manage-item-selected'));
} catch (e) {
log.textContent = `Upload failed: ${e.message}`;
setStatus(e.message, 'error');
$('btn-manage-upload').disabled = false;
} finally {
uploadInProgress = false;
}
});
$('btn-manage-toggle-backed').addEventListener('click', async () => {
setStatus('Protection status is derived from registered local-copy evidence.', 'info');
});
$('btn-manage-refresh-meta').addEventListener('click', async () => {
if (!state.currentGame) return;
const { game_id } = state.currentGame;
$('btn-manage-refresh-meta').disabled = true;
setStatus('Refreshing metadata…');
try {
await window.pork.prosperoFetch(game_id);
setStatus('Metadata refreshed', 'ok');
} catch (e) {
setStatus(`Refresh failed: ${e.message}`, 'error');
} finally {
$('btn-manage-refresh-meta').disabled = false;
}
});
+820
View File
@@ -0,0 +1,820 @@
// ── Transfer Manager controls ─────────────────────────────────────────────────
// ── Media page ────────────────────────────────────────────────────────────────
// Format a Unix ms timestamp into a short human-readable string.
// Respects _timeFormat ('12h' | '24h').
let _timeFormat = '12h'; // default; overwritten by loadSettings
function _fmtDate(ms) {
if (!ms) return '';
const d = new Date(ms);
const now = new Date();
let time;
if (_timeFormat === '24h') {
const pad = n => String(n).padStart(2, '0');
time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
} else {
const h = d.getHours();
const m = d.getMinutes();
const ampm = h >= 12 ? 'PM' : 'AM';
const h12 = h % 12 || 12;
time = `${h12}:${String(m).padStart(2, '0')} ${ampm}`;
}
const isSameDay = d.toDateString() === now.toDateString();
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
const isYesterday = d.toDateString() === yesterday.toDateString();
if (isSameDay) return `Today ${time}`;
if (isYesterday) return `Yesterday ${time}`;
const mo = d.toLocaleString('default', { month: 'short' });
return `${mo} ${d.getDate()} ${time}`;
}
let _mediaItems = []; // all items returned by last scan
let _mediaFilter = 'all';
let _mediaSort = 'date-desc';
let _mediaSearch = '';
let _mediaSearchTimer = null;
const _thumbCache = new Map(); // remotePath → dataUrl (in-memory, cleared on new scan)
const _thumbFetching = new Set(); // remotePaths currently being fetched
let _thumbQueued = 0; // total thumb loads enqueued in current render cycle
let _thumbDone = 0; // completed (success + failure)
let _mediaCacheStatus = { entries: [], metrics: {} };
function _mediaText(key, fallback) {
return window.t ? window.t(key, fallback) : fallback;
}
function _renderMediaCacheStatus() {
const metrics = _mediaCacheStatus.metrics || {};
const target = $('media-cache-metrics');
if (!target) return;
target.textContent = metrics.managedCount
? `${metrics.managedCount} ${_mediaText('media.cache.assets', 'assets')} · ${fmt(metrics.managedBytes || 0)} · ${metrics.pinnedCount || 0} ${_mediaText('media.cache.pinned', 'pinned')}`
: _mediaText('media.cache.empty', 'No managed cache assets.');
}
async function _refreshMediaCacheStatus({ rerender = false } = {}) {
if (!window.pork.mediaCacheStatus) return;
_mediaCacheStatus = await window.pork.mediaCacheStatus();
_renderMediaCacheStatus();
if (rerender) renderMediaGrid();
}
async function _mediaSha256(blob) {
const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer());
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
}
function _renderImportedMedia(packet) {
const target = $('media-imported-list');
if (!target) return;
const items = packet?.items || [];
target.innerHTML = items.map(item => `<article class="media-imported-card">
${item.mime.startsWith('image/') ? `<img src="${escHtml(item.url)}" alt="">` : `<video src="${escHtml(item.url)}" controls preload="metadata"></video>`}
<div><strong>${escHtml(item.titleId)} · ${escHtml(item.kind)}</strong><span>${escHtml(item.originalName)} · ${fmt(item.bytes)} · ${escHtml(item.provenance.provider)}</span>${item.kind === 'cover' ? `<button class="btn btn-sm" type="button" data-preferred-cover="${escHtml(item.id)}" data-title-id="${escHtml(item.titleId)}">${item.preferred ? _mediaText('media.import.preferredActive', 'Preferred') : _mediaText('media.import.makePreferred', 'Make preferred')}</button>` : ''}</div>
</article>`).join('');
}
async function _loadImportedMedia() {
if (!window.pork.mediaImportsList) return;
_renderImportedMedia(await window.pork.mediaImportsList());
}
function _updateThumbStatus() {
const el = $('media-thumb-status');
if (!el) return;
const pending = _thumbQueued - _thumbDone;
if (pending <= 0 || _thumbQueued === 0) { el.hidden = true; return; }
el.hidden = false;
el.textContent = `Loading thumbnails: ${_thumbDone} / ${_thumbQueued}`;
}
/** Show or update the thumb-status bar with an arbitrary message (for clip-batch). */
function _setThumbStatusMsg(msg) {
const el = $('media-thumb-status');
if (!el) return;
if (msg == null) { el.hidden = true; return; }
el.textContent = msg;
el.hidden = false;
}
function _setCardLabel(remotePath, text) {
const lbl = document.querySelector(`.thumb-label[data-remote="${CSS.escape(remotePath)}"]`);
if (lbl) lbl.textContent = text;
}
// ── Video frame extractor removed ───────────────────────────────────────────
// Webm thumbnails are not generated — the card shows a WEBM type badge instead.
// generateVideoThumb intentionally omitted.
// ── IntersectionObserver for lazy thumbnail loading ───────────────────────────
const _thumbObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const imgEl = entry.target;
const remote = imgEl.dataset.remote;
if (!remote) continue;
_thumbObserver.unobserve(imgEl);
if (_thumbCache.has(remote)) {
_applyThumb(imgEl, _thumbCache.get(remote));
continue;
}
if (_thumbFetching.has(remote)) continue;
_thumbFetching.add(remote);
_thumbQueued++;
_updateThumbStatus();
// Videos: screenshots only now (clips use static badge)
const thumbPromise = window.pork.mediaFetchThumb(remote);
thumbPromise
.then(thumbUrl => {
_thumbCache.set(remote, thumbUrl);
document.querySelectorAll(`.media-thumb-img[data-remote="${CSS.escape(remote)}"]`).forEach(el => {
_applyThumb(el, thumbUrl);
});
})
.catch(err => {
const card = imgEl.closest('.media-card');
const wrap = imgEl.closest('.media-card-thumb');
wrap?.classList.add('thumb-error');
// Show a retry button so the user isn't left with a silent `!`
if (card && !card.querySelector('.btn-thumb-retry')) {
const retryBtn = document.createElement('button');
retryBtn.className = 'btn btn-sm btn-thumb-retry';
retryBtn.title = err?.message || 'Thumbnail failed';
retryBtn.textContent = '\u21BB Retry';
retryBtn.addEventListener('click', () => {
_thumbFetching.delete(remote);
_thumbCache.delete(remote);
_thumbQueued = Math.max(0, _thumbQueued - 1);
_thumbDone = Math.max(0, _thumbDone - 1);
retryBtn.remove();
wrap?.classList.remove('thumb-error');
// Re-add the spinner and img, then re-observe
const newSpin = document.createElement('div');
newSpin.className = 'thumb-spin';
const newLbl = document.createElement('div');
newLbl.className = 'thumb-label'; newLbl.dataset.remote = remote;
newLbl.textContent = 'Waiting…';
wrap?.prepend(newLbl);
wrap?.prepend(newSpin);
imgEl.src = ''; imgEl.classList.remove('thumb-loaded'); imgEl.classList.add('thumb-loading');
_thumbObserver.observe(imgEl);
});
card.querySelector('.media-card-body')?.prepend(retryBtn);
}
})
.finally(() => {
_thumbFetching.delete(remote);
_thumbDone++;
_updateThumbStatus();
});
}
}, { rootMargin: '200px', threshold: 0 });
function _applyThumb(imgEl, dataUrl) {
imgEl.src = dataUrl;
imgEl.classList.remove('thumb-loading');
imgEl.classList.add('thumb-loaded');
const spin = imgEl.parentElement?.querySelector('.thumb-spin');
if (spin) spin.remove();
}
async function loadMedia() {
const gameContext = consumeGameWorkspaceContext('media');
if (gameContext) {
_mediaSearch = gameContext;
if ($('media-search')) $('media-search').value = gameContext;
}
try {
const s = await window.pork.getSettings();
if (s.mediaLocalPath) $('media-save-path').value = s.mediaLocalPath;
} catch (_) {}
if (window.pork.mediaCatalog) {
try {
const catalog = await window.pork.mediaCatalog();
if (Array.isArray(catalog?.items)) {
_mediaItems = catalog.items;
const status = $('media-scan-status');
if (catalog.scannedAt) {
status.textContent = `Catalogus: ${catalog.stats?.total || catalog.items.length} items · ${_fmtDate(catalog.scannedAt)}${catalog.stale ? ' · nieuwe scan aanbevolen' : ''}`;
status.hidden = false;
}
}
} catch (_) {}
}
try { await _refreshMediaCacheStatus(); } catch (_) {}
try { await _loadImportedMedia(); } catch (_) {}
renderMediaGrid();
}
function getFilteredMedia() {
const filtered = _mediaItems.filter(item => {
if (_mediaFilter !== 'all' && item.type !== _mediaFilter) return false;
if (_mediaSearch) {
const q = _mediaSearch.toLowerCase();
return item.game_id.toLowerCase().includes(q) || item.filename.toLowerCase().includes(q);
}
return true;
});
// Sort
filtered.sort((a, b) => {
switch (_mediaSort) {
case 'date-asc': return (a.capturedAt || 0) - (b.capturedAt || 0);
case 'size-desc': return b.size - a.size;
case 'name-asc': return a.filename.localeCompare(b.filename);
default: return (b.capturedAt || 0) - (a.capturedAt || 0); // date-desc
}
});
return filtered;
}
function renderMediaGrid() {
const grid = $('media-grid');
const empty = $('media-empty');
const all = _mediaItems;
const shots = all.filter(i => i.type === 'screenshot');
const clips = all.filter(i => i.type === 'clip');
$('media-count-all').textContent = all.length || '';
$('media-count-screenshots').textContent = shots.length || '';
$('media-count-clips').textContent = clips.length || '';
// Show the "Fetch Clip Thumbnails" button whenever there are clips loaded
const clipThumbBtn = $('btn-clip-thumbs');
if (clipThumbBtn) {
const missing = clips.filter(c => !_thumbCache.has(c.remotePath)).length;
clipThumbBtn.style.display = clips.length ? '' : 'none';
clipThumbBtn.title = missing
? `Generate thumbnails for ${missing} clip${missing !== 1 ? 's' : ''} (${clips.length - missing} already cached)`
: 'All clip thumbnails are already cached';
}
const badge = $('nav-media-badge');
// The Chimera v2 navigation replaces Porkfolio's legacy media badge. The
// background warmer may render Media after that replacement, so the badge is
// deliberately optional.
if (badge) {
if (all.length) { badge.textContent = all.length; badge.hidden = false; }
else badge.hidden = true;
}
const visible = getFilteredMedia();
grid.innerHTML = '';
if (!visible.length) { empty.hidden = false; return; }
empty.hidden = true;
const iconMap = new Map((state.games || []).map(g => [g.game_id, g.prospero_icon_url || '']));
for (const item of visible) {
const card = document.createElement('div');
card.className = 'media-card';
card.dataset.remote = item.remotePath;
card.dataset.type = item.type;
const icon = iconMap.get(item.game_id) || '';
const iconHtml = icon
? `<img src="${escHtml(icon)}" class="media-card-game-icon" alt="" loading="lazy" data-image-error="hide">`
: `<div class="media-card-game-icon-empty"></div>`;
// Thumbnail area — real <img> for screenshots (lazy-loaded), styled placeholder for clips
let thumbHtml;
if (item.type === 'screenshot') {
const cached = _thumbCache.get(item.remotePath);
if (cached) {
thumbHtml = `
<div class="media-card-thumb type-screenshot media-thumb-wrap">
<img src="${escHtml(cached)}" class="media-thumb-img thumb-loaded"
data-remote="${escHtml(item.remotePath)}" alt=""/>
</div>`;
} else {
thumbHtml = `
<div class="media-card-thumb type-screenshot media-thumb-wrap">
<div class="thumb-spin"></div>
<img src="" class="media-thumb-img thumb-loading"
data-remote="${escHtml(item.remotePath)}" alt=""/>
</div>`;
}
} else {
// Video clip — static WEBM badge + play overlay; no thumbnail download.
const cachedClip = _thumbCache.get(item.remotePath);
const clipImg = cachedClip
? `<img src="${escHtml(cachedClip)}" class="media-thumb-img thumb-loaded" data-remote="${escHtml(item.remotePath)}" alt=""/>`
: `<div class="clip-type-badge">WEBM</div>`;
thumbHtml = `
<div class="media-card-thumb type-clip media-thumb-wrap">
${clipImg}
<div class="clip-play-overlay">
<svg viewBox="0 0 40 40" width="36" height="36" fill="none">
<circle cx="20" cy="20" r="18" fill="rgba(0,0,0,.45)" stroke="rgba(255,255,255,.25)" stroke-width="1"/>
<polygon points="16,13 30,20 16,27" fill="white" opacity=".9"/>
</svg>
</div>
</div>`;
}
const cachedAsset = item.cache || (_mediaCacheStatus.entries || []).find(entry => entry.origin?.locator === item.remotePath && entry.kind === 'cached');
const provenance = item.provenance || { type: 'remote', provider: 'ps5-ftp' };
const originLabel = provenance.type === 'remote'
? _mediaText('media.cache.remote', 'Remote PS5')
: provenance.type === 'imported' ? _mediaText('media.cache.imported', 'Imported original') : _mediaText('media.cache.generated', 'Generated');
card.innerHTML = `
${thumbHtml}
<div class="media-card-body">
<div class="media-card-game">
${iconHtml}
<span class="media-card-game-id">${escHtml(item.game_id)}</span>
</div>
<div class="media-card-filename" title="${escHtml(item.filename)}">${escHtml(item.filename)}</div>
<div class="media-card-meta">
<span class="media-card-size">${fmt(item.size)}</span>
${item.capturedAt ? `<span class="media-card-date" title="${new Date(item.capturedAt).toLocaleString()}">${_fmtDate(item.capturedAt)}</span>` : ''}
</div>
<div class="media-card-provenance"><span>${escHtml(originLabel)}</span><span>${escHtml(provenance.provider || '')}</span>${cachedAsset ? `<button class="btn-cache-pin" type="button" data-cache-pin="${escHtml(cachedAsset.id)}" data-pinned="${cachedAsset.pinned ? 'true' : 'false'}" aria-pressed="${cachedAsset.pinned ? 'true' : 'false'}">${cachedAsset.pinned ? _mediaText('media.cache.unpin', 'Unpin') : _mediaText('media.cache.pin', 'Pin cache')}</button>` : ''}</div>
<div class="media-card-actions">
${item.type === 'clip'
? `<button class="btn btn-sm btn-accent btn-media-play"
data-remote="${escHtml(item.remotePath)}"
data-file="${escHtml(item.filename)}">&#9654; Play</button>`
: ''}
<button class="btn btn-sm btn-media-dl"
data-remote="${escHtml(item.remotePath)}"
data-game="${escHtml(item.game_id)}"
data-file="${escHtml(item.filename)}">&#8659; Download</button>
<button class="btn btn-sm btn-media-share"
data-remote="${escHtml(item.remotePath)}"
data-game="${escHtml(item.game_id)}"
data-file="${escHtml(item.filename)}"
data-type="${escHtml(item.type)}">&#128279; Discord</button>
</div>
</div>`;
grid.appendChild(card);
// Register lazy loading — screenshots only; clips show a static WEBM badge.
if (item.type === 'screenshot' && !_thumbCache.has(item.remotePath)) {
const imgEl = card.querySelector('.media-thumb-img');
if (imgEl) _thumbObserver.observe(imgEl);
}
}
}
$('media-grid').addEventListener('click', async event => {
const button = event.target.closest('[data-cache-pin]');
if (!button || !window.pork.mediaCachePin) return;
button.disabled = true;
try {
await window.pork.mediaCachePin(button.dataset.cachePin, button.dataset.pinned !== 'true');
await _refreshMediaCacheStatus({ rerender: true });
} catch (error) {
setStatus(error.message, 'error');
button.disabled = false;
}
});
async function _clearMediaCache(options) {
if (!window.pork.mediaCacheClear) return;
const result = await window.pork.mediaCacheClear(options);
_thumbCache.clear();
await _refreshMediaCacheStatus({ rerender: true });
setStatus(`${result.removed || 0} ${_mediaText('media.cache.cleared', 'cache assets cleared')}.`, 'ok');
}
$('btn-media-cache-expired').addEventListener('click', () => _clearMediaCache({ expiredOnly: true }).catch(error => setStatus(error.message, 'error')));
$('btn-media-cache-unpinned').addEventListener('click', async () => {
const ok = await showConfirm(_mediaText('media.cache.clearConfirm', 'Clear all unpinned generated and cached media? Imported originals are never deleted.'));
if (ok) await _clearMediaCache({});
});
$('btn-media-import').addEventListener('click', async () => {
const button = $('btn-media-import');
const status = $('media-import-status');
const file = $('media-import-file').files?.[0];
const titleId = $('media-import-title-id').value.trim().toUpperCase();
const kind = $('media-import-kind').value;
if (!file || !/^(CUSA|PPSA)\d{5}$/.test(titleId)) return setStatus(_mediaText('media.import.invalid', 'Select a file and valid title ID.'), 'error');
button.disabled = true;
try {
const session = await window.pork.importUploadCreate([{ relativePath: file.name, size: file.size }]);
const staged = session.files[0];
let offset = 0;
while (offset < file.size) {
const chunk = file.slice(offset, Math.min(file.size, offset + 4 * 1024 * 1024));
const progress = await window.pork.importUploadChunk(session.id, staged.id, offset, chunk, await _mediaSha256(chunk));
offset = Number(progress.files[0].offset);
status.textContent = `${Math.round(offset / file.size * 100)}%`;
}
const crop = $('media-import-crop').value === 'center' ? { x: 0.125, y: 0, width: 0.75, height: 1 } : { x: 0, y: 0, width: 1, height: 1 };
await window.pork.mediaImportFinalize({ sessionId: session.id, fileId: staged.id, titleId, kind, crop: kind === 'cover' ? crop : null, preferred: kind === 'cover' && $('media-import-preferred').checked });
status.textContent = _mediaText('media.import.done', 'Import complete.');
$('media-import-file').value = '';
await _loadImportedMedia();
} catch (error) { status.textContent = error.message; setStatus(error.message, 'error'); }
finally { button.disabled = false; }
});
$('media-imported-list').addEventListener('click', async event => {
const button = event.target.closest('[data-preferred-cover]');
if (!button || !window.pork.mediaImportPreferred) return;
await window.pork.mediaImportPreferred(button.dataset.titleId, button.dataset.preferredCover);
await _loadImportedMedia();
});
// Clip thumbnail batch progress — update the status bar with live count
window.pork.on('media:clip-thumb:progress', ({ done, total, remote, state }) => {
const label = remote ? remote.split('/').pop() : '';
if (state === 'downloading') {
_setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total} — downloading ${label}…`);
} else if (state === 'extracting') {
_setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total} — extracting frame…`);
} else {
// 'ok' or 'failed'
if (done >= total) {
const clips = _mediaItems.filter(i => i.type === 'clip');
const missing = clips.filter(c => !_thumbCache.has(c.remotePath)).length;
_setThumbStatusMsg(`Done — fetched ${done} clip thumbnail${done !== 1 ? 's' : ''}${missing ? `, ${missing} still missing (partial download may not have contained a keyframe)` : ''}`);
setTimeout(() => _setThumbStatusMsg(null), 8000);
// Refresh button tooltip
const clipThumbBtn = $('btn-clip-thumbs');
if (clipThumbBtn) {
clipThumbBtn.disabled = false;
clipThumbBtn.textContent = '\u{1F3AC} Fetch Clip Thumbnails';
}
renderMediaGrid();
} else {
_setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total}${state === 'failed' ? ` (last failed)` : ''}`);
}
}
});
// ── Fetch Clip Thumbnails button ──────────────────────────────────────────────
$('btn-clip-thumbs').addEventListener('click', async () => {
const clips = _mediaItems.filter(i => i.type === 'clip');
const missing = clips.filter(c => !_thumbCache.has(c.remotePath));
if (missing.length === 0) {
setStatus('All clip thumbnails are already cached.', 'ok');
return;
}
// Warn if it will take a while — each clip needs a partial FTP download + ffmpeg
const warnThreshold = 5;
if (missing.length > warnThreshold) {
const ok = await showConfirm(
`Fetching thumbnails for ${missing.length} video clip${missing.length !== 1 ? 's' : ''}.\n\n` +
`This will download the first ~10 MB of each clip from your PS5 and extract a frame.\n` +
`With many files this can take several minutes.\n\nContinue?`
);
if (!ok) return;
}
const btn = $('btn-clip-thumbs');
btn.disabled = true;
btn.textContent = 'Fetching…';
_setThumbStatusMsg(`Starting thumbnail fetch for ${missing.length} clip${missing.length !== 1 ? 's' : ''}…`);
try {
await window.pork.mediaFetchClipThumbs(missing.map(c => c.remotePath));
} catch (err) {
setStatus(`Clip thumbnail fetch failed: ${err.message}`, 'error');
_setThumbStatusMsg(null);
btn.disabled = false;
btn.textContent = '\u{1F3AC} Fetch Clip Thumbnails';
}
});
// ── Scan buttons ──────────────────────────────────────────────────────────────
async function runMediaScan(force = false) {
const btn = $('btn-media-scan');
const forceBtn = $('btn-media-force-scan');
const cancelBtn = $('btn-media-cancel-scan');
const status = $('media-scan-status');
btn.disabled = true;
forceBtn.disabled = true;
cancelBtn.hidden = !window.pork.mediaScanCancel;
btn.textContent = force ? 'Volledige scan…' : 'Scanning…';
status.textContent = 'Connecting to PS5…';
status.hidden = false;
// Clear old data + caches
_mediaItems = [];
_thumbCache.clear();
_thumbFetching.clear();
_thumbQueued = 0;
_thumbDone = 0;
const tsEl = $('media-thumb-status');
if (tsEl) tsEl.hidden = true;
renderMediaGrid();
try {
const result = await window.pork.mediaScan({ force });
const packet = Array.isArray(result) ? { items: result, stats: { total: result.length }, scan: { mode: 'full' }, delta: {} } : result;
_mediaItems = Array.isArray(packet?.items) ? packet.items : [];
const mode = packet.cached ? 'cache' : packet.scan?.mode === 'incremental' ? 'incrementeel' : 'volledig';
const delta = packet.delta || {};
status.textContent = `${_mediaItems.length} items · ${mode} · +${delta.added || 0} / -${delta.removed || 0} / changed ${delta.changed || 0}${packet.scan?.reusedDirectories ? ` · ${packet.scan.reusedDirectories} folders reused` : ''}${packet.scan?.errors?.length ? ` · ${packet.scan.errors.length} FTP error(s), previous contents preserved` : ''}`;
status.style.color = '';
// Restore persisted clip thumbnails so they show immediately after scan
try {
const diskCache = await window.pork.mediaLoadClipThumbs();
for (const [rp, du] of Object.entries(diskCache || {})) _thumbCache.set(rp, du);
} catch (_) {}
renderMediaGrid();
} catch (e) {
status.textContent = `Scan failed: ${e.message}`;
status.style.cssText += ';color:var(--red)';
status.style.animationName = 'none';
} finally {
btn.disabled = false;
forceBtn.disabled = false;
cancelBtn.hidden = true;
btn.textContent = '↺ Scan via FTP';
}
}
$('btn-media-scan').addEventListener('click', () => runMediaScan(false));
$('btn-media-force-scan').addEventListener('click', () => runMediaScan(true));
$('btn-media-cancel-scan').addEventListener('click', async event => {
event.currentTarget.disabled = true;
try { await window.pork.mediaScanCancel(); $('media-scan-status').textContent = 'Scan annuleren…'; }
catch (error) { setStatus(error.message, 'error'); }
finally { event.currentTarget.disabled = false; }
});
// Per-card thumb state updates from main process
window.pork.on('media:thumb:progress', ({ remotePath, state }) => {
const labels = { downloading: 'Downloading…', decoding: 'Processing…' };
if (labels[state]) _setCardLabel(remotePath, labels[state]);
});
// Live scan progress
window.pork.on('media:scan:progress', ({ game_id, type, found, scannedDirectories, reusedDirectories }) => {
const status = $('media-scan-status');
if (!status.hidden) {
status.textContent = `Scanning ${type === 'screenshot' ? 'screenshots' : 'video clips'}… ${found} found${game_id ? ` (${game_id})` : ''}${scannedDirectories ? ` · ${scannedDirectories} folders read` : ''}${reusedDirectories ? ` · ${reusedDirectories} reused` : ''}`;
}
});
// Discord share progress — update the share button live for every pipeline stage
// Track remote paths whose discord share has fully resolved so late progress
// events (which can race against the IPC reply) don't override the final state.
const _discordCompleted = new Set();
window.pork.on('media:discord:progress', ({ remotePath, state, pct, attempt, max }) => {
if (_discordCompleted.has(remotePath)) return;
const btn = document.querySelector(`.btn-media-share[data-remote="${CSS.escape(remotePath)}"]`);
if (!btn) return;
if (state === 'downloading') {
btn.classList.remove('uploading');
btn.textContent = pct != null ? `Downloading ${pct}%` : 'Downloading…';
} else if (state === 'converting') {
btn.classList.remove('uploading');
btn.textContent = pct != null ? `Converting ${pct}%` : 'Converting…';
} else if (state === 'uploading-video') {
btn.classList.add('uploading');
btn.style.setProperty('--upload-pct', `${pct ?? 0}%`);
btn.textContent = pct != null ? `Uploading ${pct}%` : 'Uploading…';
} else if (state === 'retrying') {
btn.classList.remove('uploading');
btn.style.removeProperty('--upload-pct');
btn.textContent = `Retrying… (${attempt}/${max})`;
} else {
btn.classList.remove('uploading');
btn.style.removeProperty('--upload-pct');
const labels = { sending: 'Sending…', uploading: 'Uploading…' };
if (labels[state]) btn.textContent = labels[state];
}
});
// Discord share thumbnail — ffmpeg extracted a frame from the local mp4; cache it
// and patch the clip card immediately so the user sees the thumbnail right away.
window.pork.on('media:discord:thumb', ({ remotePath, dataUrl }) => {
if (!remotePath || !dataUrl) return;
_thumbCache.set(remotePath, dataUrl);
// Find every clip card for this path and swap the background → actual thumb
document.querySelectorAll(`.media-card[data-remote="${CSS.escape(remotePath)}"]`).forEach(card => {
const wrap = card.querySelector('.media-card-thumb');
if (!wrap) return;
// Replace the WEBM badge placeholder with a real thumbnail image
const badge = wrap.querySelector('.clip-type-badge');
if (badge) badge.remove();
let img = wrap.querySelector('.media-thumb-img');
if (!img) {
img = document.createElement('img');
img.className = 'media-thumb-img';
img.alt = '';
img.dataset.remote = remotePath;
wrap.insertBefore(img, wrap.firstChild);
}
img.src = dataUrl;
img.classList.add('thumb-loaded');
img.classList.remove('thumb-loading');
});
});
// Save path browse
$('btn-media-save-browse').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (!folder) return;
$('media-save-path').value = folder;
await window.pork.setSettings({ mediaLocalPath: folder });
});
// Open media folder in Explorer
$('btn-media-open-folder').addEventListener('click', () => {
const folder = $('media-save-path').value;
if (folder) window.pork.openShell(folder);
else setStatus('No media folder configured — select one first.', 'error');
});
// Live update when main process falls back to a default path (e.g. configured drive missing)
window.pork.on('settings:updated', (patch) => {
if (patch?.mediaLocalPath) {
const el = $('media-save-path');
if (el) el.value = patch.mediaLocalPath;
setStatus(`Media save folder updated to "${patch.mediaLocalPath}" (previous path was inaccessible).`, 'ok');
}
});
// Sort dropdown
$('media-sort').addEventListener('change', e => {
_mediaSort = e.target.value;
renderMediaGrid();
});
// Filter buttons
document.querySelectorAll('[data-mfilter]').forEach(btn =>
btn.addEventListener('click', () => {
document.querySelectorAll('[data-mfilter]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_mediaFilter = btn.dataset.mfilter;
renderMediaGrid();
})
);
// Search
$('media-search').addEventListener('input', () => {
clearTimeout(_mediaSearchTimer);
_mediaSearchTimer = setTimeout(() => {
_mediaSearch = $('media-search').value.trim();
renderMediaGrid();
}, 250);
});
// ── Lightbox ──────────────────────────────────────────────────────────────────
let _lightboxItem = null; // current { remotePath, game_id, filename }
function openLightbox(item) {
_lightboxItem = item;
const box = $('media-lightbox');
const img = $('lightbox-img');
const spin = $('lightbox-spinner');
const meta = $('lightbox-meta');
img.src = '';
img.hidden = true;
spin.hidden = false;
meta.textContent = `${item.game_id} — ${item.filename}`;
box.hidden = false;
document.body.classList.add('lightbox-open');
const cached = _thumbCache.get(item.remotePath);
if (cached) {
img.src = cached;
img.hidden = false;
spin.hidden = true;
} else {
window.pork.mediaFetchThumb(item.remotePath)
.then(dataUrl => {
_thumbCache.set(item.remotePath, dataUrl);
if (_lightboxItem?.remotePath === item.remotePath) {
img.src = dataUrl;
img.hidden = false;
spin.hidden = true;
}
})
.catch(err => {
spin.hidden = true;
meta.textContent += ` — Load failed: ${err.message}`;
});
}
}
function closeLightbox() {
$('media-lightbox').hidden = true;
document.body.classList.remove('lightbox-open');
$('lightbox-img').src = '';
_lightboxItem = null;
}
$('btn-lightbox-close').addEventListener('click', closeLightbox);
$('media-lightbox').addEventListener('click', e => {
if (e.target === $('media-lightbox') || e.target.classList.contains('media-lightbox-backdrop')) closeLightbox();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !$('media-lightbox').hidden) closeLightbox();
});
$('btn-lightbox-dl').addEventListener('click', async () => {
if (!_lightboxItem) return;
const btn = $('btn-lightbox-dl');
btn.disabled = true;
btn.textContent = 'Queued';
try {
await window.pork.mediaDownload({
remotePath: _lightboxItem.remotePath,
game_id: _lightboxItem.game_id,
filename: _lightboxItem.filename,
});
setStatus(`Queued: ${_lightboxItem.filename} — see Transfers`, 'ok');
} catch (err) {
setStatus(err.message, 'error');
} finally {
setTimeout(() => { btn.disabled = false; btn.textContent = '↓ Save to PC'; }, 2000);
}
});
// ── Grid click delegation ─────────────────────────────────────────────────────
$('media-grid').addEventListener('click', async e => {
// Share to Discord button
const shareBtn = e.target.closest('.btn-media-share');
if (shareBtn) {
shareBtn.disabled = true;
shareBtn.textContent = 'Preparing…';
try {
const res = await window.pork.mediaDiscordSend({
remotePath: shareBtn.dataset.remote,
game_id: shareBtn.dataset.game,
filename: shareBtn.dataset.file,
type: shareBtn.dataset.type,
});
_discordCompleted.add(shareBtn.dataset.remote);
shareBtn.classList.remove('uploading');
shareBtn.style.removeProperty('--upload-pct');
shareBtn.textContent = '\u2713 Sent!';
const detail = res?.url ? ` • ${res.url}` : '';
setStatus(`Shared to Discord: ${shareBtn.dataset.file}${detail}`, 'ok');
setTimeout(() => {
shareBtn.disabled = false;
shareBtn.textContent = '\uD83D\uDD17 Discord';
_discordCompleted.delete(shareBtn.dataset.remote);
}, 3000);
} catch (err) {
_discordCompleted.add(shareBtn.dataset.remote);
shareBtn.classList.remove('uploading');
shareBtn.style.removeProperty('--upload-pct');
shareBtn.disabled = false;
shareBtn.textContent = '\uD83D\uDD17 Discord';
_discordCompleted.delete(shareBtn.dataset.remote);
setStatus(`Discord share failed: ${err.message}`, 'error');
}
return;
}
// Download button
const dlBtn = e.target.closest('.btn-media-dl');
if (dlBtn) {
dlBtn.disabled = true;
dlBtn.textContent = 'Queued';
try {
await window.pork.mediaDownload({
remotePath: dlBtn.dataset.remote,
game_id: dlBtn.dataset.game,
filename: dlBtn.dataset.file,
});
setStatus(`Queued: ${dlBtn.dataset.file} — see Transfers`, 'ok');
} catch (err) {
setStatus(err.message, 'error');
}
dlBtn.disabled = false;
dlBtn.textContent = '\u21D3 Download';
return;
}
// Play button (video clips)
const playBtn = e.target.closest('.btn-media-play');
if (playBtn) {
playBtn.disabled = true;
playBtn.textContent = 'Opening\u2026';
try {
await window.pork.mediaOpen(playBtn.dataset.remote);
} catch (err) {
setStatus(err.message, 'error');
} finally {
setTimeout(() => { playBtn.disabled = false; playBtn.textContent = '\u25B6 Play'; }, 1500);
}
return;
}
// Click on a screenshot thumbnail → open lightbox
const card = e.target.closest('.media-card[data-type="screenshot"]');
if (card && !e.target.closest('button')) {
const remote = card.dataset.remote;
const mediaItem = _mediaItems.find(i => i.remotePath === remote);
if (mediaItem) openLightbox(mediaItem);
}
});
File diff suppressed because it is too large. Load diff
+110
View File
@@ -0,0 +1,110 @@
'use strict';
(function exposeNavigationContract(root, factory) {
const contract = factory();
if (typeof module === 'object' && module.exports) module.exports = contract;
else root.ChimeraNavigationContract = contract;
})(typeof globalThis !== 'undefined' ? globalThis : this, function buildNavigationContract() {
const groups = [
{
id: 'home', label: 'Home', icon: 'home', defaultRoute: 'home',
routes: [
{ id: 'home', label: 'Overview', description: 'Library, connection, activity and quick actions.', page: 'dashboard', keywords: ['dashboard', 'start'] },
],
},
{
id: 'library', label: 'Library', icon: 'library', defaultRoute: 'library/games',
routes: [
{ id: 'library/games', label: 'Games', description: 'Complete game library with metadata and cover art.', page: 'games', keywords: ['titles', 'roms', 'covers'] },
{ id: 'library/updates', label: 'Game updates', description: 'Available patches and updates for each title.', page: 'ecosystem', tab: 'game-updates', keywords: ['patches'] },
{ id: 'library/cheats', label: 'Cheats & patches', description: 'Search, review and manage cheats and patches.', page: 'ecosystem', tab: 'cheats', keywords: ['goldhen', 'patches'] },
{ id: 'library/media', label: 'Media', description: 'Screenshots and video clips from the console.', page: 'media', keywords: ['screenshots', 'video'] },
{ id: 'library/mounts', label: 'Mounts', description: 'ShadowMount configuration with restore-point protection.', page: 'operations', panel: 'shadowmount', keywords: ['shadowmount', 'folders'], expert: true },
{ id: 'library/packages', label: 'Packages', description: 'Browse package sets, components, versions, hashes and recipes.', page: 'packages', keywords: ['pkg', 'sets', 'components', 'recipes'] },
],
},
{
id: 'console', label: 'Console', icon: 'console', defaultRoute: 'console/status',
routes: [
{ id: 'console/status', label: 'Status', description: 'Configured PS5 endpoint, connection evidence and service health.', page: 'ecosystem', tab: 'overview', keywords: ['health', 'overview'] },
{ id: 'console/live', label: 'Live session', description: 'Live console information and the active session.', page: 'ecosystem', tab: 'live', keywords: ['runtime', 'temperature'], expert: true },
{ id: 'console/remote-play', label: 'Remote Play', description: 'Discover and wake the PS5, then start a local chiaki-ng session.', page: 'ecosystem', tab: 'remote-play', keywords: ['chiaki', 'stream', 'controller', 'wake'] },
{ id: 'console/system-view', label: 'System View', description: 'Capture-card video, audio and recording.', page: 'system-view', keywords: ['capture', 'stream'] },
{ id: 'console/files', label: 'Files', description: 'Safely browse files on the PS5.', page: 'ecosystem', tab: 'files', keywords: ['ftp', 'browser'], expert: true },
{ id: 'console/profile', label: 'Profile & compatibility', description: 'Firmware, HEN and loader profile with validation.', page: 'operations', panel: 'compatibility', keywords: ['firmware', 'etahen', 'kstuff'] },
{ id: 'console/etahen', label: 'etaHEN', description: 'etaHEN configuration and integration status.', page: 'ecosystem', tab: 'etahen', keywords: ['hen'] },
{ id: 'console/notifications', label: 'Notifications', description: 'Test the connection and send console notifications.', page: 'psnotify', keywords: ['notify', 'toast'] },
{ id: 'console/avatar', label: 'XAvatar', description: 'Convert, transfer and manage avatars.', page: 'xavatar', keywords: ['profile', 'image'] },
{ id: 'console/power', label: 'System actions', description: 'Confirmed power, rest-mode and disc actions.', page: 'jailbreak', focus: 'power', keywords: ['shutdown', 'reboot', 'eject'], risk: 'confirm', expert: true },
],
},
{
id: 'payloads', label: 'Payloads & Homebrew', icon: 'payload', defaultRoute: 'payloads/installed',
routes: [
{ id: 'payloads/installed', label: 'Payload Manager', description: 'PS5 manager, local payloads, catalog and sender.', page: 'jailbreak', focus: 'payloads', keywords: ['sender', 'elf', 'bin'] },
{ id: 'payloads/updates', label: 'Updates', description: 'Safely update payloads and components with version checks.', page: 'ecosystem', tab: 'updates', keywords: ['upgrade', 'watchdog'] },
{ id: 'payloads/catalog', label: 'Component catalog', description: 'Tracked PS5 projects, releases and compatibility.', page: 'ecosystem', tab: 'components', keywords: ['projects', 'releases'] },
{ id: 'payloads/homebrew', label: 'Homebrew', description: 'Homebrew library and available releases.', page: 'ecosystem', tab: 'homebrew', keywords: ['apps'] },
{ id: 'payloads/plugins', label: 'Plugins', description: 'Plugin library and source information.', page: 'ecosystem', tab: 'plugins', keywords: ['extensions'] },
{ id: 'payloads/boot', label: 'Boot & autoload', description: 'Autoload order, snapshots and USB/disc builders.', page: 'jailbreak', focus: 'boot', keywords: ['y2jb', 'bdjb', 'autoload'] },
{ id: 'payloads/elf-arsenal', label: 'ELF Arsenal', description: 'Manage ELF files and send them to the console.', page: 'elf-arsenal', keywords: ['payload', 'sender'] },
],
},
{
id: 'protection', label: 'Protection', icon: 'shield', defaultRoute: 'protection/backups',
routes: [
{ id: 'protection/backups', label: 'Backups', description: 'View and manage game backups.', page: 'backups', keywords: ['backup'] },
{ id: 'protection/trophies', label: 'Trophy evidence', description: 'Compare read-only PSN proof with the selected local profile.', page: 'trophies', keywords: ['trophy', 'profile', 'evidence', 'reconstruct'] },
{ id: 'protection/images', label: 'Backpork images', description: 'Manage, build and verify firmware images.', page: 'backporks', keywords: ['image', 'firmware'] },
{ id: 'protection/saves', label: 'Save Vault', description: 'Save snapshots, comparison and safe recovery.', page: 'ecosystem', tab: 'saves', keywords: ['saves', 'restore'] },
{ id: 'protection/save-service', label: 'GarlicSaves worker', description: 'Install and manage the optional GarlicSaves community worker.', page: 'garlic', keywords: ['garlic', 'community', 'worker'], expert: true },
{ id: 'protection/save-browser', label: 'Save browser', description: 'Advanced local save browser.', page: 'savemgr', keywords: ['local save manager'], expert: true },
{ id: 'protection/recovery', label: 'Recovery Vault', description: 'Create, export and restore recovery points.', page: 'operations', panel: 'recovery', keywords: ['snapshot', 'restore'], risk: 'confirm' },
{ id: 'protection/integrity', label: 'Integrity audit', description: 'Audit mounts, registrations and orphaned files read-only.', page: 'operations', panel: 'storage', keywords: ['storage', 'audit'] },
{ id: 'protection/audit', label: 'Audit Center', description: 'Search mutating actions, results and safety classifications.', page: 'operations', panel: 'audit', keywords: ['log', 'history', 'security'], expert: true },
{ id: 'protection/replication', label: 'Replication', description: 'Use a second backup target with SHA-256 verification.', page: 'operations', panel: 'reliability', focus: 'replication', keywords: ['3-2-1', 'copy'], expert: true },
],
},
{
id: 'tools', label: 'Tools', icon: 'tools', defaultRoute: 'tools/transfers',
routes: [
{ id: 'tools/transfers', label: 'Transfers', description: 'Queue, progress and recent FTP transfers.', page: 'transfers', keywords: ['upload', 'download'], badge: 'transfer' },
{ id: 'tools/pfs', label: 'PFS Ripper', description: 'PFS workflow and extraction tools.', page: 'pfs-ripper', keywords: ['extract'], expert: true },
{ id: 'tools/database', label: 'Database', description: 'Manage the Chimera database and hash information.', page: 'database', keywords: ['sqlite', 'hash'], expert: true },
{ id: 'tools/itemzflow', label: 'Itemzflow', description: 'Itemzflow status and integration.', page: 'ecosystem', tab: 'itemzflow', keywords: ['itemz'], expert: true },
{ id: 'tools/timeline', label: 'Timeline', description: 'Recent platform and console activity.', page: 'ecosystem', tab: 'timeline', keywords: ['events', 'log'], expert: true },
{ id: 'tools/diagnostics', label: 'Diagnostics', description: 'Read-only probes and a sanitized support bundle.', page: 'operations', panel: 'diagnostics', keywords: ['support', 'logs'], expert: true },
{ id: 'tools/package-services', label: 'Package Services', description: 'Expert DPI, Void and raw package service diagnostics.', page: 'operations', panel: 'packages', keywords: ['dpi', 'voidpkg', 'handoff'], expert: true },
{ id: 'tools/research', label: 'Research', description: 'Evaluated projects, sources and expert information.', page: 'ecosystem', tab: 'research', keywords: ['linux', 'sources'], expert: true },
],
},
{
id: 'settings', label: 'Settings', icon: 'settings', defaultRoute: 'settings/general',
routes: [
{ id: 'settings/general', label: 'General', description: 'Language, paths, console connection and platform preferences.', page: 'settings', keywords: ['configuration', 'ftp'] },
{ id: 'settings/automation', label: 'Automation', description: 'Safe maintenance tasks and execution history.', page: 'operations', panel: 'automation', keywords: ['scheduler', 'tasks'] },
{ id: 'settings/sources', label: 'Sources & trust', description: 'Upstream sources and supply-chain policy.', page: 'ecosystem', tab: 'sources', keywords: ['https', 'checksum'] },
{ id: 'settings/security', label: 'Security', description: 'Active sessions and password management.', page: 'operations', panel: 'reliability', focus: 'security', keywords: ['sessions', 'password'] },
{ id: 'settings/monitoring', label: 'Monitoring', description: 'Health metrics and Prometheus token.', page: 'operations', panel: 'reliability', focus: 'monitoring', keywords: ['metrics', 'prometheus'], expert: true },
],
},
];
const legacyAliases = {
dashboard: 'home', games: 'library/games', media: 'library/media', cheats: 'library/cheats',
backporks: 'protection/images', backups: 'protection/backups', trophies: 'protection/trophies', garlic: 'protection/save-service',
savemgr: 'protection/save-browser', jailbreak: 'payloads/installed', psnotify: 'console/notifications',
xavatar: 'console/avatar', 'system-view': 'console/system-view',
transfers: 'tools/transfers', database: 'tools/database', 'elf-arsenal': 'payloads/elf-arsenal',
'pfs-ripper': 'tools/pfs', ecosystem: 'console/status', operations: 'settings/automation', settings: 'settings/general',
};
const routes = groups.flatMap(group => group.routes.map(route => Object.freeze({ ...route, group: group.id })));
return Object.freeze({
version: 1,
defaultRoute: 'home',
groups: Object.freeze(groups.map(group => Object.freeze({ ...group, routes: Object.freeze(group.routes) }))),
routes: Object.freeze(routes),
legacyAliases: Object.freeze(legacyAliases),
});
});
File diff suppressed because it is too large. Load diff
+700
View File
@@ -0,0 +1,700 @@
'use strict';
let _operationsLoaded = false;
let _operationsStatus = null;
let _operationsBusy = new Set();
let _operationsStorageReport = null;
let _operationsStorageTopology = null;
function operationsDate(value) {
if (!value) return '—';
try { return new Date(Number(value) || value).toLocaleString(window.i18n?.current || 'en'); } catch (_) { return '—'; }
}
function operationsDuration(value) {
const ms = Number(value || 0);
if (!ms) return '—';
if (ms < 1000) return `${ms} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
}
function operationsBytes(value) {
const bytes = Number(value || 0);
if (!Number.isFinite(bytes) || bytes <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let amount = bytes;
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; }
return `${amount >= 10 || unit === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
}
function operationsRunSummary(run) {
const raw = String(run.error || run.summary || '').trim();
if (!raw) return run.status === 'completed' ? 'Completed' : '';
if (run.error) return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw;
let result;
try { result = JSON.parse(raw); } catch (_) { return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw; }
if (result == null) return 'Completed';
if (typeof result === 'string' || typeof result === 'number') return String(result);
if (run.task_id === 'library-scan') {
const total = Number(result.total ?? result.found?.length ?? result.found ?? 0);
const added = Number(result.added || 0);
return `${total} game${total === 1 ? '' : 's'} found · ${added} new`;
}
if (run.task_id === 'metadata-refresh') {
const queued = Number(result.queued ?? (typeof result === 'number' ? result : 0));
return `${queued} metadata item${queued === 1 ? '' : 's'} queued`;
}
if (run.task_id === 'payload-sync' && Array.isArray(result)) {
const failed = result.filter(item => item?.error).length;
return `${result.length} source${result.length === 1 ? '' : 's'} checked${failed ? ` · ${failed} error${failed === 1 ? '' : 's'}` : ''}`;
}
if (run.task_id === 'cheat-sync') {
const indexed = Number(result.ecosystem?.total || 0);
const cached = Number(result.legacy?.total || 0);
const failed = Number(result.legacy?.failed || 0) + Number(result.ecosystem?.errors?.length || 0);
return `${indexed} modern cheats or patches indexed · ${cached} legacy items cached${failed ? ` · ${failed} error${failed === 1 ? '' : 's'}` : ''}`;
}
if (run.task_id === 'storage-audit') {
const summary = result.summary || {};
const issues = Number(summary.missingBackups || 0) + Number(summary.partialFiles || 0) + Number(summary.orphanGameFolders || 0);
return `${Number(summary.games || 0)} games · ${Number(summary.backups || 0)} backups · ${issues} attention item${issues === 1 ? '' : 's'}`;
}
if (run.task_id === 'recovery-snapshot') {
const localFiles = Array.isArray(result.files) ? result.files.length : 0;
const remoteFiles = Array.isArray(result.remote) ? result.remote.filter(item => item.included).length : 0;
return `${localFiles} local and ${remoteFiles} PS5 files secured`;
}
const values = Object.entries(result).filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value)).slice(0, 3);
return values.length ? values.map(([key, value]) => `${key}: ${value}`).join(' · ') : 'Completed';
}
function operationsStatusBadge(status) {
const labels = { completed: 'Succeeded', failed: 'Failed', running: 'Running', compatible: 'Compatible', warning: 'Warning', unknown: 'Unknown' };
return `<span class="operations-status operations-status-${escHtml(status || 'unknown')}">${escHtml(labels[status] || status || 'Unknown')}</span>`;
}
async function operationsAction(key, button, task) {
if (_operationsBusy.has(key)) return null;
_operationsBusy.add(key);
const prior = button?.textContent;
if (button) { button.disabled = true; button.textContent = window.t('common.loading', 'Loading…'); }
try { return await task(); }
finally {
_operationsBusy.delete(key);
if (button) { button.disabled = false; button.textContent = prior; }
}
}
function renderOperationsAutomation(data) {
_operationsStatus = data;
const scheduler = $('operations-scheduler-state');
scheduler.textContent = data.running ? 'Active' : 'Stopped';
scheduler.className = `badge ${data.running ? 'badge-green' : 'badge-red'}`;
const tasks = data.tasks || [];
$('operations-task-list').innerHTML = tasks.map(task => `
<div class="operations-task" data-task-id="${escHtml(task.id)}">
<div class="operations-task-main">
<span class="operations-task-indicator ${task.running ? 'running' : task.enabled ? 'enabled' : ''}"></span>
<div><strong>${escHtml(task.label)}</strong><span>${task.running ? 'Running now' : task.enabled ? `Next: ${operationsDate(task.nextRunAt)}` : 'Disabled'}</span></div>
</div>
<label class="operations-switch"><input class="operations-task-enabled" type="checkbox" ${task.enabled ? 'checked' : ''}/><span>Active</span></label>
<label class="operations-interval"><input class="operations-task-interval" type="number" min="5" max="10080" value="${Number(task.intervalMinutes)}"/><span>min.</span></label>
<button class="btn btn-sm operations-task-save">Save</button>
<button class="btn btn-sm btn-accent operations-task-run" ${task.running ? 'disabled' : ''}>Run now</button>
</div>`).join('') || '<p class="hint">No tasks configured.</p>';
const runs = (data.recentRuns || []).slice(0, 25);
$('operations-run-list').innerHTML = runs.map(run => {
const summary = operationsRunSummary(run);
return `
<div class="operations-run">
<div><strong>${escHtml(tasks.find(task => task.id === run.task_id)?.label || run.task_id)}</strong><span>${escHtml(run.trigger || 'manual')} · ${operationsDate(run.started_at)}</span></div>
${operationsStatusBadge(run.status)}
<span>${operationsDuration(run.duration_ms)}</span>
<span class="operations-run-message" title="${escHtml(run.error || run.summary || '')}">${escHtml(summary)}</span>
</div>`;
}).join('') || '<p class="hint">No runs yet.</p>';
const failed = runs.filter(run => run.status === 'failed').length;
const health = $('operations-health');
health.textContent = failed
? window.tf('operations.status.failures', '{count} recent failure(s)', { count: failed })
: window.t('operations.status.healthy', 'Automation healthy');
health.className = `operations-health ${failed ? 'warning' : 'healthy'}`;
const badge = $('nav-operations-badge');
if (badge) { badge.hidden = failed === 0; badge.textContent = failed || ''; }
}
function renderOperationsStorage(report) {
_operationsStorageReport = report;
if (!report) {
$('operations-storage-summary').innerHTML = '';
$('operations-storage-details').innerHTML = '<p class="hint">No storage report available yet.</p>';
return;
}
const summary = report.summary || {};
const metrics = [
[window.t('operations.storage.metrics.mounts', 'Mounts'), summary.roots],
[window.t('operations.storage.metrics.games', 'Games'), summary.games],
[window.t('operations.storage.metrics.backups', 'Backups'), summary.backups],
[window.t('operations.storage.metrics.missing', 'Missing'), summary.missingBackups],
[window.t('operations.storage.metrics.duplicates', 'Duplicate games'), summary.duplicateGames],
[window.t('operations.storage.metrics.partial', 'Partial files'), summary.partialFiles],
[window.t('operations.storage.metrics.orphaned', 'Orphaned folders'), summary.orphanGameFolders],
];
$('operations-storage-summary').innerHTML = metrics.map(([label, value]) => `<div class="operations-metric"><strong>${Number(value || 0)}</strong><span>${escHtml(label)}</span></div>`).join('');
const sections = [];
if ((report.roots || []).length) sections.push(`<div class="operations-audit-section"><h4>${escHtml(window.t('operations.storage.volumes', 'Volumes'))}</h4>${report.roots.map(root => `<div class="operations-audit-row"><code>${escHtml(root.path)}</code><span>${root.error ? escHtml(root.error) : `${operationsBytes(root.availableBytes)} ${escHtml(window.t('operations.storage.freeOf', 'free of'))} ${operationsBytes(root.totalBytes)}`}</span></div>`).join('')}</div>`);
const issue = (title, items, render) => {
if (!items?.length) return;
sections.push(`<details class="operations-audit-section"><summary>${escHtml(title)} <span>${items.length}</span></summary>${items.slice(0, 100).map(render).join('')}${items.length > 100 ? `<p class="hint">${escHtml(window.t('operations.storage.first100', 'Only the first 100 items are shown.'))}</p>` : ''}</details>`);
};
issue(window.t('operations.storage.issues.missing', 'Missing backups'), report.missingBackups, item => `<div class="operations-audit-row"><strong>${escHtml(item.gameId)}</strong><code>${escHtml(item.path)}</code></div>`);
issue(window.t('operations.storage.issues.duplicates', 'Duplicate backup registrations'), report.duplicateBackups, item => `<div class="operations-audit-row"><strong>${escHtml(item.gameId)} (${item.count})</strong><span>${item.paths.map(escHtml).join('<br>')}</span></div>`);
issue(window.t('operations.storage.issues.partial', 'Partial files'), report.partialFiles, item => `<div class="operations-audit-row"><code>${escHtml(item)}</code></div>`);
issue(window.t('operations.storage.issues.orphaned', 'Orphaned game folders'), report.orphanGameFolders, item => `<div class="operations-audit-row"><strong>${escHtml(item.gameId)}</strong><code>${escHtml(item.path)}</code></div>`);
if (!report.missingBackups?.length && !report.partialFiles?.length && !report.orphanGameFolders?.length) sections.push(`<div class="operations-empty-ok">✓ ${escHtml(window.t('operations.storage.healthy', 'No missing backups, partial files or orphaned game folders found.'))}</div>`);
$('operations-storage-details').innerHTML = `<p class="hint">${escHtml(window.t('operations.storage.reportAt', 'Report from'))} ${operationsDate(report.generatedAt)}</p>${sections.join('')}`;
}
function renderStorageTopology(packet) {
const target = $('operations-storage-topology');
if (!target || !packet) return;
_operationsStorageTopology = packet;
target.innerHTML = `<p class="hint">${escHtml(window.t('operations.storage.consoleSource', 'Console source'))}: ${escHtml(packet.provenance?.console || 'unknown')} · ${escHtml(window.t('operations.storage.serverSource', 'server source'))}: ${escHtml(packet.provenance?.server || 'unknown')}</p><div class="ecosystem-update-list">${(packet.volumes || []).map(volume => `<div class="ecosystem-service-row"><div><strong>${escHtml(volume.scope === 'console' ? `PS5 ${volume.kind}` : volume.mountpoint)}</strong><span>${escHtml(volume.mountpoint || 'unknown')} · ${escHtml(volume.filesystem || 'unknown')} · ${volume.freeBytes == null ? escHtml(window.t('operations.storage.freeUnknown', 'free space unknown')) : `${operationsBytes(volume.freeBytes)} ${escHtml(window.t('operations.storage.free', 'free'))}`} · ${volume.totalBytes == null ? escHtml(window.t('operations.storage.totalUnknown', 'total unknown')) : operationsBytes(volume.totalBytes)}</span></div>${operationsStatusBadge(volume.status === 'unknown' || volume.source === 'unavailable' ? 'unknown' : volume.readOnly === true ? 'warning' : 'compatible')}</div>`).join('')}</div>`;
}
async function loadStorageTopology() {
if (!window.pork.consoleStorageTopology) return;
renderStorageTopology(await window.pork.consoleStorageTopology());
}
function renderOperationsRecovery(snapshots) {
$('operations-recovery-list').innerHTML = (snapshots || []).map(snapshot => {
const included = (snapshot.remote || []).filter(item => item.included).length;
return `<div class="operations-recovery-item" data-snapshot-id="${escHtml(snapshot.id)}">
<div class="operations-recovery-icon">↺</div>
<div class="operations-recovery-info"><strong>${escHtml(snapshot.label || 'Restore point')}</strong><span>${operationsDate(snapshot.createdAt)} · Chimera ${escHtml(snapshot.chimeraVersion || '?')} · ${included} remote files · ${snapshot.integrity === 'sha256' ? 'SHA-256' : 'legacy verification'}</span><code>${escHtml(snapshot.id)}</code></div>
<div class="operations-inline-actions"><button class="btn btn-sm operations-recovery-verify">Verify</button><button class="btn btn-sm operations-recovery-export">Download</button><button class="btn btn-sm operations-recovery-restore">Restore</button><button class="btn btn-sm btn-danger operations-recovery-delete">Delete</button></div>
</div>`;
}).join('') || '<div class="operations-empty"><strong>No restore points yet</strong><span>Create a restore point before making major changes.</span></div>';
}
function operationsAuditFilters() {
const since = $('operations-audit-since').value;
const until = $('operations-audit-until').value;
return {
level: $('operations-audit-level').value,
classification: $('operations-audit-classification').value,
result: $('operations-audit-result').value,
search: $('operations-audit-search').value.trim(),
since: since ? new Date(since).getTime() : null,
until: until ? new Date(until).getTime() : null,
limit: 200,
};
}
function renderOperationsAudit(result, health) {
const summary = result.summary || {};
$('operations-audit-summary').innerHTML = [
['Matched', result.totalMatched || 0], ['Succeeded', summary.success || 0], ['Failed', summary.failed || 0],
['Sensitive', summary.sensitive || 0], ['Destructive', summary.destructive || 0], ['Errors', summary.errors || 0],
].map(([label, value]) => `<div class="operations-metric"><strong>${Number(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
const files = health?.files || [];
$('operations-audit-health').textContent = `${files.length} log file(s) · ${operationsBytes(health?.totalBytes)} · rotation ${operationsBytes(health?.retention?.maxBytes)} × ${Number(health?.retention?.maxFiles || 0)} · secrets redacted at rest`;
$('operations-audit-list').innerHTML = (result.entries || []).map(entry => `<div class="operations-run">
<div><strong>${escHtml(entry.command || entry.event || entry.level)}</strong><span>${operationsDate(entry.time)}${entry.requestIp ? ` · ${escHtml(entry.requestIp)}` : ''}</span></div>
${operationsStatusBadge(entry.ok === true ? 'completed' : entry.ok === false ? 'failed' : 'unknown')}
<span>${escHtml(entry.classification || entry.level)}</span>
<span class="operations-run-message" title="${escHtml(entry.error || '')}">${escHtml(entry.error || (entry.durationMs == null ? entry.event : `${entry.event} · ${operationsDuration(entry.durationMs)}`))}</span>
</div>`).join('') || '<div class="operations-empty"><strong>No audit records</strong><span>No records match these filters.</span></div>';
}
async function loadOperationsAudit() {
const [result, health] = await Promise.all([window.pork.auditQuery(operationsAuditFilters()), window.pork.auditHealth()]);
renderOperationsAudit(result, health);
return result;
}
function renderOperationsCompatibility(data) {
const profile = data?.profile || {};
$('operations-profile-firmware').value = profile.firmware || '';
$('operations-profile-hen').value = profile.hen || '';
$('operations-profile-hen-version').value = profile.henVersion || '';
$('operations-profile-kstuff').value = profile.kstuffVersion || '';
$('operations-profile-loader').value = profile.loader || '';
$('operations-profile-notes').value = profile.notes || '';
$('operations-compatibility-checks').innerHTML = (data?.checks || []).map(check => `<div class="operations-check"><div><strong>${escHtml(check.label)}</strong><span>${escHtml(check.detail)}</span></div>${operationsStatusBadge(check.status)}</div>`).join('') || '<p class="hint">No checks available.</p>';
}
function renderReplicationStatus(status) {
const targetState = status.available && status.writable ? 'Available and writable' : status.error || 'Unavailable';
const free = status.storage ? `${operationsBytes(status.storage.availableBytes)} free` : 'free space unknown';
const protection = status.protection || {};
const rpo = protection.effectiveRpoSeconds == null ? 'not established' : protection.effectiveRpoSeconds < 3600
? `${Math.ceil(protection.effectiveRpoSeconds / 60)} min`
: `${Math.round(protection.effectiveRpoSeconds / 360) / 10} h`;
const rto = protection.lastRestoreDrillDurationMs == null ? 'not measured' : `${Math.max(1, Math.round(protection.lastRestoreDrillDurationMs / 1000))} s drill`;
$('operations-replication-status').innerHTML = `
<div class="operations-audit-row"><strong>${escHtml(targetState)}</strong><code>${escHtml(status.target || '/replication')}</code><span>${escHtml(free)}${status.running ? ' · replication running' : ''}</span></div>
<div class="ecosystem-metrics">
<div class="operations-metric"><strong>${protection.threeTwoOneSatisfied ? 'Met' : 'Not met'}</strong><span>3-2-1 status</span></div>
<div class="operations-metric"><strong>${escHtml(rpo)}</strong><span>Effective RPO</span></div>
<div class="operations-metric"><strong>${escHtml(rto)}</strong><span>Observed restore drill</span></div>
<div class="operations-metric"><strong>${protection.immutableObserved ? 'Yes' : 'No'}</strong><span>Immutable target</span></div>
</div>
<div class="${protection.threeTwoOneSatisfied ? 'operations-empty-ok' : 'operations-warning'}"><strong>Target contract: ${escHtml(status.targetContract?.type || 'unknown')}</strong><p>Atomic: ${status.targetContract?.atomicPublish ? 'yes' : 'unknown'} · hashes: ${status.targetContract?.hashVerified ? 'yes' : 'unknown'} · immutable: ${status.targetContract?.immutable ? 'yes' : 'no'} · off-host: ${escHtml(status.targetContract?.offHost || 'unknown')}</p>${protection.blockers?.length ? `<ul>${protection.blockers.map(item => `<li>${escHtml(item)}</li>`).join('')}</ul>` : ''}</div>`;
$('operations-replication-retention-enabled').checked = status.retention?.enabled !== false;
$('operations-replication-retention-count').value = status.retention?.maxSnapshots || 14;
$('operations-replication-retention-days').value = status.retention?.maxAgeDays || 180;
$('operations-replication-run').disabled = !status.available || !status.writable || status.running;
$('operations-replication-list').innerHTML = (status.snapshots || []).map(snapshot => snapshot.invalid
? `<div class="operations-recovery-item"><div class="operations-recovery-info"><strong>Invalid snapshot</strong><code>${escHtml(snapshot.id)}</code><span>${escHtml(snapshot.error)}</span></div></div>`
: `<div class="operations-recovery-item" data-replication-id="${escHtml(snapshot.id)}"><div class="operations-recovery-info"><strong>${escHtml(snapshot.label || 'Replication snapshot')}</strong><span>${operationsDate(snapshot.completedAt || snapshot.createdAt)} · ${Number(snapshot.files || 0)} files · ${operationsBytes(snapshot.bytes)}${snapshot.verifiedAt ? ` · verified ${operationsDate(snapshot.verifiedAt)}` : ''}${snapshot.lastRestoreDrillAt ? ` · restore drill ${operationsDate(snapshot.lastRestoreDrillAt)}` : ''}</span><code>${escHtml(snapshot.id)}</code></div><div class="operations-inline-actions"><button class="btn btn-sm operations-replication-verify">Verify SHA-256</button><button class="btn btn-sm operations-replication-drill">Local restore drill</button></div></div>`).join('') || '<p class="hint">No replication snapshots yet.</p>';
}
function renderMonitoring(value) {
const metrics = [
['Uptime', `${Math.round(Number(value.uptimeSeconds || 0) / 60)} min.`],
['Memory', operationsBytes(value.memory?.rssBytes)],
['Games', value.library?.totalGames || 0],
['Backups', value.library?.totalBackups || 0],
['FTP', value.ftpConnected ? 'Connected' : 'Offline'],
['Task failures', value.automation?.recentFailures || 0],
['Replications', value.replication?.snapshots || 0],
['API p95', `${Number(value.api?.p95DurationMs || 0)} ms`],
['Last scan', value.scans?.lastDurationMs == null ? 'not measured' : `${Number(value.scans.lastDurationMs)} ms`],
['Transfer / 24h', operationsBytes(value.transfers?.bytes)],
['Cache hit', `${Math.round(Number(value.transport?.staticCacheHitRatio || 0) * 100)}%`],
['WebSocket disconnects', Number(value.transport?.wsDisconnects || 0)],
];
$('operations-monitoring-summary').innerHTML = metrics.map(([label, result]) => `<div class="operations-metric"><strong>${escHtml(result)}</strong><span>${escHtml(label)}</span></div>`).join('');
}
function renderSessions(result) {
$('operations-session-list').innerHTML = (result.sessions || []).map(session => `<div class="operations-recovery-item" data-session-id="${escHtml(session.id)}"><div class="operations-recovery-info"><strong>${session.current ? 'This browser' : escHtml(session.userAgent || 'Unknown browser')}</strong><span>${escHtml(session.ip || 'unknown')} · last active ${operationsDate(session.lastSeenAt)} · expires ${operationsDate(session.expiresAt)}</span><code>${escHtml(session.id)}</code></div><div class="operations-inline-actions"><button class="btn btn-sm btn-danger operations-session-revoke">${session.current ? 'Sign out' : 'Revoke'}</button></div></div>`).join('') || '<p class="hint">No active sessions.</p>';
}
async function loadOperationsReliability({ focus = '' } = {}) {
if (focus === 'security') {
if (!window.pork.authSessions) {
$('operations-session-list').innerHTML = '<p class="hint">Session management is available only in server mode.</p>';
return;
}
renderSessions(await window.pork.authSessions());
return;
}
if (!window.pork.backupReplicationStatus || !window.pork.authSessions) {
$('operations-replication-status').innerHTML = '<p class="hint">Session and replication management are available only in server mode.</p>';
return;
}
const [replication, monitoring, token, sessions] = await Promise.all([
window.pork.backupReplicationStatus(), window.pork.monitoringSnapshot(), window.pork.monitoringTokenStatus(), window.pork.authSessions(),
]);
renderReplicationStatus(replication);
renderMonitoring(monitoring);
$('operations-monitoring-token-state').textContent = token.configured
? window.t('operations.monitoring.tokenActive', 'Metrics token active · endpoint /metrics')
: window.t('operations.monitoring.tokenDisabled', 'Metrics disabled until a token is created');
renderSessions(sessions);
}
async function loadOperations({ background = false } = {}) {
const [status, storage, snapshots, compatibility, topology] = await Promise.all([
window.pork.operationsStatus(),
window.pork.storageStatus().catch(() => null),
window.pork.recoveryList().catch(() => []),
window.pork.compatibilityGet().catch(() => ({ profile: {}, checks: [] })),
window.pork.consoleStorageTopology?.().catch(() => null) || Promise.resolve(null),
]);
renderOperationsAutomation(status);
renderOperationsStorage(storage);
renderOperationsRecovery(snapshots);
renderOperationsCompatibility(compatibility);
renderStorageTopology(topology);
_operationsLoaded = true;
if (!background) $('operations-health').title = window.tf('operations.status.updated', 'Updated {time}', {
time: new Date().toLocaleTimeString(window.i18n?.current || 'en'),
});
}
async function loadOperationsShadowMount() {
const result = await window.pork.shadowMountStatus();
const config = result.files?.['shadowmount-config'] || {};
const manual = result.files?.['shadowmount-manual'] || {};
$('operations-shadow-config').value = config.text || '';
$('operations-shadow-manual').value = manual.text || '';
for (const [element, file] of [[$('operations-shadow-config-state'), config], [$('operations-shadow-manual-state'), manual]]) {
element.textContent = file.ok ? 'Loaded' : 'Unavailable';
element.className = `badge ${file.ok ? 'badge-green' : 'badge-red'}`;
element.title = file.error || '';
}
renderOperationsShadowMountModel(result.model || { summary: {}, entries: [], unknownStatusLines: [] });
renderOperationsShadowPreflight(result.preflight);
}
function renderOperationsShadowPreflight(preflight) {
const target = $('operations-shadow-preflight');
if (!target) return;
if (!preflight) {
target.innerHTML = '<strong>Configuration preflight unavailable</strong><p>Load a reachable config.ini or run the check again while saving.</p>';
return;
}
const counts = preflight.counts || {};
const headline = !preflight.ok ? 'Save blocked' : preflight.requiresRiskConfirmation ? 'Explicit risk confirmation required' : 'Preflight passed';
const findings = (preflight.findings || []).map(item => `<li><strong>${escHtml(item.severity)}</strong>${item.line ? ` · line ${Number(item.line)}` : ''}${item.key ? ` · ${escHtml(item.key)}` : ''}: ${escHtml(item.message)}</li>`).join('');
target.innerHTML = `<strong>${escHtml(headline)}</strong><p>${Number(counts.invalid || 0)} invalid · ${Number(counts.critical || 0)} critical · ${Number(counts.warning || 0)} warning(s) · ${Number(counts.unknown || 0)} unknown future key(s)</p>${findings ? `<ul>${findings}</ul>` : '<p>No safety issues in the recognized configuration.</p>'}`;
}
function renderOperationsShadowMountModel(model) {
const summary = model.summary || {};
const entries = model.entries || [];
const statusBadge = $('operations-shadow-status-state');
statusBadge.textContent = summary.invalid || summary.unknownStatusLines ? 'review needed' : `${summary.installed || 0} installed`;
statusBadge.className = `badge ${summary.invalid || summary.unknownStatusLines ? 'badge-yellow' : 'badge-green'}`;
$('operations-shadow-summary').innerHTML = [
['Active sources', summary.active || 0],
['Installed', summary.installed || 0],
['Pending', summary.pending || 0],
['Deleted', summary.deleted || 0],
['Experimental', summary.experimental || 0],
['Library matches', summary.libraryMatches || 0],
].map(([label, value]) => `<div class="operations-metric"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
const stateLabel = { installed: 'Installed', pending: 'Pending', deleted: 'Deleted', invalid: 'Invalid' };
const supportLabel = { recommended: 'Recommended', compatibility: 'Compatibility', experimental: 'Experimental', normal: 'Folder', unknown: 'Unknown' };
const rows = entries.map(entry => `<article class="operations-detail-row shadowmount-entry ${entry.warnings?.length ? 'has-warning' : ''}">
<div><strong>${escHtml(entry.titleName || entry.titleId || entry.sourceName || 'Unknown source')}</strong><span>${escHtml(entry.titleId || 'no title ID')} · ${escHtml(entry.format?.label || 'Unknown')} · ${escHtml(supportLabel[entry.format?.support] || entry.format?.support || 'Unknown')}</span><code>${escHtml(entry.sourcePath)}</code>${entry.warnings?.length ? `<small>${entry.warnings.map(escHtml).join(' ')}</small>` : ''}</div>
<span class="badge ${entry.state === 'installed' ? 'badge-green' : entry.state === 'deleted' || entry.state === 'invalid' ? 'badge-red' : 'badge-yellow'}">${escHtml(stateLabel[entry.state] || entry.state)}</span>
</article>`);
if (model.unknownStatusLines?.length) rows.push(`<article class="operations-detail-row has-warning"><div><strong>${model.unknownStatusLines.length} unknown status line(s)</strong><span>Preserved because the upstream format was not recognized.</span><code>${escHtml(model.unknownStatusLines.map(item => `line ${item.line}: ${item.text}`).join('\n'))}</code></div><span class="badge badge-yellow">Raw</span></article>`);
$('operations-shadow-entries').innerHTML = rows.length ? rows.join('') : '<p class="hint">manual.lst and manual.status do not contain game sources yet.</p>';
}
async function loadOperationsPackages() {
const result = await window.pork.packagesStatus();
$('operations-package-services').innerHTML = (result.services || []).map(service => `<div class="operations-service"><span class="operations-service-dot ${service.reachable ? 'online' : ''}"></span><div><strong>${escHtml(service.label)}</strong><span>Port ${service.port}${service.error ? ` · ${escHtml(service.error)}` : ''}</span></div><b>${service.reachable ? 'Online' : 'Offline'}</b></div>`).join('');
return result;
}
function showOperationsJson(element, value) {
element.hidden = false;
element.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
function klogFilters() {
return { severity: $('operations-klog-severity').value, search: $('operations-klog-search').value.trim(), limit: 200 };
}
function renderKlogStatus(status) {
const badge = $('operations-klog-state');
badge.textContent = status.running ? status.connected ? 'Capture active' : 'Connecting…' : 'Stopped';
badge.className = `badge ${status.running ? status.connected ? 'badge-green' : 'badge-yellow' : ''}`;
$('operations-klog-start').disabled = status.running || !status.available;
$('operations-klog-stop').disabled = !status.running;
$('operations-klog-summary').innerHTML = [
['Port', status.port || 3232], ['Status', status.reason || 'unknown'], ['Lines', status.lines || 0],
['Received', operationsBytes(status.bytes)], ['Write contract', status.zeroWriteContract ? '0 bytes' : 'unknown'], ['Payload started', status.payloadLaunchAttempted ? 'yes' : 'no'],
].map(([label, value]) => `<div class="operations-metric"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
}
function renderKlogEntries(result) {
$('operations-klog-list').innerHTML = (result.entries || []).map(entry => `<div class="operations-run">
<div><strong>${escHtml(entry.severity.toUpperCase())}</strong><span>${operationsDate(entry.time)}</span></div>
${operationsStatusBadge(entry.severity === 'error' ? 'failed' : entry.severity === 'warn' ? 'warning' : 'completed')}
<span>${escHtml(String(entry.sessionId || '').slice(0, 8))}</span>
<span class="operations-run-message" title="${escHtml(entry.line)}">${escHtml(entry.line)}</span>
</div>`).join('') || '<p class="hint">No kernel log records match these filters.</p>';
}
async function loadKlog() {
const [status, result] = await Promise.all([window.pork.klogStatus(), window.pork.klogQuery(klogFilters())]);
renderKlogStatus(status);
renderKlogEntries(result);
}
document.querySelectorAll('.operations-tab').forEach(button => button.addEventListener('click', async () => {
const tab = button.dataset.operationsTab;
document.querySelectorAll('.operations-tab').forEach(item => item.classList.toggle('active', item === button));
document.querySelectorAll('.operations-panel').forEach(panel => {
const active = panel.dataset.operationsPanel === tab;
panel.classList.toggle('active', active);
panel.hidden = !active;
});
try {
if (tab === 'shadowmount') await loadOperationsShadowMount();
if (tab === 'storage') await loadStorageTopology();
if (tab === 'packages') await loadOperationsPackages();
if (tab === 'reliability') await loadOperationsReliability();
if (tab === 'audit') await loadOperationsAudit();
if (tab === 'diagnostics') await loadKlog();
} catch (error) { showToast(error.message, 'error'); }
}));
$('operations-refresh')?.addEventListener('click', event => operationsAction('refresh', event.currentTarget, () => loadOperations()).catch(error => showToast(error.message, 'error')));
$('operations-task-list')?.addEventListener('click', async event => {
const row = event.target.closest('.operations-task');
if (!row) return;
const taskId = row.dataset.taskId;
try {
if (event.target.closest('.operations-task-run')) {
await operationsAction(`run-${taskId}`, event.target.closest('button'), () => window.pork.operationsRunTask(taskId));
showToast('Task completed.', 'ok');
await loadOperations();
}
if (event.target.closest('.operations-task-save')) {
await operationsAction(`save-${taskId}`, event.target.closest('button'), () => window.pork.operationsConfigureTask(taskId, {
enabled: row.querySelector('.operations-task-enabled').checked,
intervalMinutes: Number(row.querySelector('.operations-task-interval').value),
}));
showToast('Schedule saved.', 'ok');
await loadOperations();
}
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-storage-run')?.addEventListener('click', async event => {
try {
const report = await operationsAction('storage', event.currentTarget, () => window.pork.storageAudit());
renderOperationsStorage(report);
showToast('Storage audit completed. Nothing was deleted.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-storage-topology-refresh')?.addEventListener('click', event => operationsAction('storage-topology', event.currentTarget, loadStorageTopology).catch(error => showToast(error.message, 'error')));
$('operations-recovery-create')?.addEventListener('click', async event => {
const label = await showPrompt('Name for this restore point:', 'Manual restore point');
if (label == null) return;
try {
await operationsAction('recovery-create', event.currentTarget, () => window.pork.recoveryCreate({ label, includeRemote: true }));
renderOperationsRecovery(await window.pork.recoveryList());
showToast('Restore point saved safely.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-recovery-import')?.addEventListener('click', async event => {
try {
const result = await operationsAction('recovery-import', event.currentTarget, () => window.pork.recoveryImport());
if (!result) return;
renderOperationsRecovery(await window.pork.recoveryList());
showToast(`Archive imported as ${result.id} and verified with SHA-256. Nothing was restored.`, 'ok', 9000);
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('operations-recovery-list')?.addEventListener('click', async event => {
const row = event.target.closest('.operations-recovery-item');
if (!row) return;
const id = row.dataset.snapshotId;
try {
if (event.target.closest('.operations-recovery-verify')) {
const verified = await operationsAction(`verify-${id}`, event.target.closest('button'), () => window.pork.recoveryVerify(id));
showToast(`${verified.files.length} files verified (${verified.integrity}). Nothing was restored.`, 'ok');
}
if (event.target.closest('.operations-recovery-export')) await operationsAction(`export-${id}`, event.target.closest('button'), () => window.pork.recoveryExport(id));
if (event.target.closest('.operations-recovery-restore')) {
if (!await showExactConfirmation('This restores the database, settings and available PS5 configuration. A new safety recovery point is created first.', 'RESTORE')) return;
await operationsAction(`restore-${id}`, event.target.closest('button'), () => window.pork.recoveryRestore(id, true));
showToast('Restore completed. Refresh the page if settings are not immediately visible.', 'ok', 8000);
await loadOperations();
}
if (event.target.closest('.operations-recovery-delete')) {
if (!await showExactConfirmation('Permanently delete this recovery point?', 'DELETE')) return;
await operationsAction(`delete-${id}`, event.target.closest('button'), () => window.pork.recoveryDelete(id));
renderOperationsRecovery(await window.pork.recoveryList());
}
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-replication-refresh')?.addEventListener('click', event => operationsAction('replication-refresh', event.currentTarget, loadOperationsReliability).catch(error => showToast(error.message, 'error')));
$('operations-replication-run')?.addEventListener('click', async event => {
const includeGameBackups = $('operations-replication-games').checked;
const warning = includeGameBackups ? '\n\nFull game backups are selected and may use a large amount of storage.' : '';
if (!await showConfirm(`Create a new hash-verified snapshot on the secondary backup target? Nothing is removed from the source.${warning}`)) return;
try {
const label = `Manual replication ${new Date().toLocaleString(window.i18n?.current || 'en')}`;
await operationsAction('replication-run', event.currentTarget, () => window.pork.backupReplicationRun({ includeGameBackups, label }));
await loadOperationsReliability();
showToast('Replication completed and every copied file was verified.', 'ok', 8000);
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('operations-replication-list')?.addEventListener('click', async event => {
const row = event.target.closest('[data-replication-id]');
if (!row) return;
try {
const button = event.target.closest('button');
if (button?.classList.contains('operations-replication-verify')) {
await operationsAction(`replication-verify-${row.dataset.replicationId}`, button, () => window.pork.backupReplicationVerify(row.dataset.replicationId));
showToast('Snapshot fully verified with SHA-256.', 'ok');
} else if (button?.classList.contains('operations-replication-drill')) {
if (!await showConfirm('Copy every snapshot file to a temporary local destination, verify its hash, then remove the temporary copies? The PS5 is not modified.')) return;
const result = await operationsAction(`replication-drill-${row.dataset.replicationId}`, button, () => window.pork.backupReplicationRestoreDrill(row.dataset.replicationId));
showToast(`Restore drill succeeded: ${result.files} files, PS5 unchanged.`, 'ok', 8000);
} else return;
await loadOperationsReliability();
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('operations-replication-retention')?.addEventListener('submit', async event => {
event.preventDefault();
try {
const preview = await operationsAction('replication-retention', event.submitter, () => window.pork.backupReplicationRetentionSave({ enabled: $('operations-replication-retention-enabled').checked, maxSnapshots: Number($('operations-replication-retention-count').value), maxAgeDays: Number($('operations-replication-retention-days').value) }));
showToast(`Retention saved. ${preview.removable?.length || 0} snapshot(s) will be eligible after the next successful replication.`, 'ok', 9000);
await loadOperationsReliability();
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-monitoring-refresh')?.addEventListener('click', event => operationsAction('monitoring-refresh', event.currentTarget, async () => renderMonitoring(await window.pork.monitoringSnapshot())).catch(error => showToast(error.message, 'error')));
$('operations-monitoring-token')?.addEventListener('click', async event => {
if (!await showConfirm('Create a new metrics token? An existing token becomes invalid immediately.')) return;
try {
const result = await operationsAction('metrics-token', event.currentTarget, () => window.pork.monitoringTokenRotate());
$('operations-monitoring-token-state').textContent = 'New token active. Save it now; Chimera will not show it again.';
showOperationsJson($('operations-monitoring-token-output'), `Endpoint: ${location.origin}${result.endpoint}\nAuthorization: Bearer ${result.token}\n\nThis token is shown only once.`);
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-sessions-refresh')?.addEventListener('click', event => operationsAction('sessions-refresh', event.currentTarget, async () => renderSessions(await window.pork.authSessions())).catch(error => showToast(error.message, 'error')));
$('operations-session-list')?.addEventListener('click', async event => {
const row = event.target.closest('[data-session-id]');
if (!row || !event.target.closest('.operations-session-revoke') || !await showExactConfirmation('Revoke this session immediately?', 'REVOKE')) return;
try {
const result = await window.pork.authSessionRevoke(row.dataset.sessionId);
if (result.current) { location.replace('/server-login.html'); return; }
renderSessions(await window.pork.authSessions());
showToast('Session revoked.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-password-form')?.addEventListener('submit', async event => {
event.preventDefault();
const currentPassword = $('operations-password-current').value;
const newPassword = $('operations-password-new').value;
if (newPassword !== $('operations-password-repeat').value) { showToast('The new passwords do not match.', 'error'); return; }
if (!await showConfirm('Change the password and revoke all other active sessions?')) return;
try {
const result = await operationsAction('password-change', event.submitter, () => window.pork.authPasswordChange(currentPassword, newPassword));
event.currentTarget.reset();
showToast(`Password changed; ${result.otherSessionsRevoked} other session(s) revoked.`, 'ok', 8000);
renderSessions(await window.pork.authSessions());
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-profile-save')?.addEventListener('click', async event => {
try {
const result = await operationsAction('profile', event.currentTarget, () => window.pork.compatibilitySet({
firmware: $('operations-profile-firmware').value,
hen: $('operations-profile-hen').value,
henVersion: $('operations-profile-hen-version').value,
kstuffVersion: $('operations-profile-kstuff').value,
loader: $('operations-profile-loader').value,
notes: $('operations-profile-notes').value,
}));
renderOperationsCompatibility(result);
showToast('PS5 profile saved.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-shadow-reload')?.addEventListener('click', event => operationsAction('shadow-load', event.currentTarget, loadOperationsShadowMount).catch(error => showToast(error.message, 'error')));
$('operations-shadow-propose')?.addEventListener('click', async event => {
try {
const proposal = await operationsAction('shadow-proposal', event.currentTarget, () => window.pork.shadowMountProposal());
$('operations-shadow-manual').value = proposal.proposedManual;
$('operations-shadow-preflight').innerHTML = `<strong>Library proposal · ${proposal.additions.length} addition(s)</strong><p>${proposal.unchanged.length} unchanged · ${proposal.unresolved.length} unresolved. Nothing was written to the PS5.</p>${proposal.unresolved.length ? `<ul>${proposal.unresolved.slice(0, 20).map(item => `<li>${escHtml(item.gameId)} · ${escHtml(item.reason)}</li>`).join('')}</ul>` : ''}`;
showToast(proposal.additions.length ? 'ShadowMount proposal loaded for review; save is still separate.' : 'No new PS5-visible paths were found.', proposal.unresolved.length ? 'info' : 'ok', 9000);
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('operations-shadow-save')?.addEventListener('click', async event => {
try {
const config = $('operations-shadow-config').value;
const manual = $('operations-shadow-manual').value;
const preflight = await operationsAction('shadow-preflight', event.currentTarget, () => window.pork.shadowMountPreflight(config));
renderOperationsShadowPreflight(preflight);
if (!preflight.ok) throw new Error('The configuration contains invalid values. Resolve the preflight findings before saving.');
let confirmation = null;
if (preflight.requiresRiskConfirmation) {
confirmation = await showExactConfirmation('This configuration activates critical ShadowMount options. Review the preflight carefully.', preflight.confirmationPhrase);
if (!confirmation) return;
} else if (!await showConfirm('Save the ShadowMount configuration? Chimera creates a recovery point first.')) return;
const result = await operationsAction('shadow-save', event.currentTarget, () => window.pork.shadowMountSave({ config, manual, confirmation }));
showToast(`ShadowMount saved. Safety restore point: ${result.snapshotId}`, 'ok', 8000);
await loadOperationsShadowMount();
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-shadow-logs')?.addEventListener('click', async event => {
try {
const result = await operationsAction('shadow-logs', event.currentTarget, () => window.pork.shadowMountLogs());
if (!result.ok) throw new Error(result.error);
showOperationsJson($('operations-shadow-output'), result.text || 'debug.log is empty.');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-package-probe')?.addEventListener('click', event => operationsAction('package-probe', event.currentTarget, loadOperationsPackages).catch(error => showToast(error.message, 'error')));
$('operations-package-list')?.addEventListener('click', async event => {
try { showOperationsJson($('operations-package-output'), await operationsAction('package-list', event.currentTarget, () => window.pork.packagesList())); }
catch (error) { showToast(error.message, 'error'); }
});
$('operations-package-progress')?.addEventListener('click', async event => {
try { showOperationsJson($('operations-package-output'), await operationsAction('package-progress', event.currentTarget, () => window.pork.packagesProgress())); }
catch (error) { showToast(error.message, 'error'); }
});
$('operations-package-install')?.addEventListener('click', async event => {
const url = $('operations-package-url').value.trim();
if (!url || !await showConfirm(`Send this HTTPS URL to etaHEN DPI?\n\n${url}`)) return;
try {
const result = await operationsAction('package-install', event.currentTarget, () => window.pork.packagesInstallUrl(url));
showOperationsJson($('operations-package-output'), result);
showToast('Package URL sent to etaHEN DPI.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-diagnostics-run')?.addEventListener('click', async event => {
try {
const result = await operationsAction('diagnostics', event.currentTarget, () => window.pork.diagnosticsSummary());
const services = result.packages?.services || [];
const build = result.chimera?.build || {};
$('operations-diagnostics-summary').innerHTML = [
['Chimera', result.chimera?.version || '—'],
['Build', build.id || 'development'],
['Revision', String(build.revision || 'development').slice(0, 12)],
['Built', build.builtAt ? operationsDate(build.builtAt) : '—'],
['Uptime', `${Math.round(Number(result.chimera?.uptimeSeconds || 0) / 60)} min.`],
['FTP', result.ps5?.ftpConnected ? 'Connected' : result.ps5?.ftpReconnectAvailable ? 'Reconnect available' : 'Not connected'],
['PS5 services', `${services.filter(item => item.reachable).length}/${services.length} online`],
].map(([label, value]) => `<div class="operations-metric"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
showOperationsJson($('operations-diagnostics-log'), result.recentLogs || []);
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-diagnostics-export')?.addEventListener('click', event => operationsAction('diagnostics-export', event.currentTarget, () => window.pork.diagnosticsExport()).then(() => showToast('Geschoonde supportbundel aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error')));
$('operations-audit-filter')?.addEventListener('submit', event => {
event.preventDefault();
operationsAction('audit-filter', event.submitter, loadOperationsAudit).catch(error => showToast(error.message, 'error'));
});
$('operations-audit-refresh')?.addEventListener('click', event => operationsAction('audit-refresh', event.currentTarget, loadOperationsAudit).catch(error => showToast(error.message, 'error')));
$('operations-audit-export')?.addEventListener('click', event => operationsAction('audit-export', event.currentTarget, () => window.pork.auditExport(operationsAuditFilters())).then(() => showToast('Gefilterde auditexport aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error')));
$('operations-klog-start')?.addEventListener('click', async event => {
try {
const status = await operationsAction('klog-start', event.currentTarget, () => window.pork.klogStart({ durationSeconds: Number($('operations-klog-duration').value) }));
renderKlogStatus(status);
showToast('Passive capture started; Chimera writes nothing to the PS5.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-klog-stop')?.addEventListener('click', async event => {
try {
renderKlogStatus(await operationsAction('klog-stop', event.currentTarget, () => window.pork.klogStop()));
await loadKlog();
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-klog-refresh')?.addEventListener('click', event => operationsAction('klog-refresh', event.currentTarget, loadKlog).catch(error => showToast(error.message, 'error')));
$('operations-klog-export')?.addEventListener('click', event => operationsAction('klog-export', event.currentTarget, () => window.pork.klogExport(klogFilters())).then(() => showToast('Geschoonde kernellogexport aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error')));
window.pork?.on?.('operations:task-update', () => {
if (document.querySelector('#page-operations.active')) loadOperations().catch(() => {});
});
window.pork?.on?.('klog:status', status => renderKlogStatus(status));
window.addEventListener('chimera:language-changed', () => {
if (!document.querySelector('#page-operations.active [data-operations-panel="storage"]:not([hidden])')) return;
renderOperationsStorage(_operationsStorageReport);
if (_operationsStorageTopology) renderStorageTopology(_operationsStorageTopology);
});
+29
View File
@@ -0,0 +1,29 @@
'use strict';
(function () {
let catalog = { sets: [], stats: {} };
const esc = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
const tr = (key, fallback) => window.t?.(key, fallback) || fallback;
if (!document.querySelector('link[data-package-library-style]')) {
const link = document.createElement('link');
Object.assign(link, { rel: 'stylesheet', href: 'styles/package-library.css' });
link.dataset.packageLibraryStyle = 'true';
document.head.appendChild(link);
}
const componentHtml = component => `<li><div><strong>${esc(component.type)}</strong><span>${esc(component.componentId)} ${tr(`packageLibrary.${component.required ? 'required' : 'optional'}`, component.required ? 'required' : 'optional')}</span></div><div><span>v${esc(component.version || '?')} ${esc(component.region || '')}</span><code title="SHA-256 ${esc(component.sha256)}">${esc(component.sha256)}</code></div></li>`;
const recipeHtml = item => `<li><strong>${esc(item.label || item.recipeKey)}</strong><span>v${esc(item.version)} ${esc(item.state)} ${esc(item.recipeSha256.slice(0, 12))}...</span></li>`;
function setHtml(set) {
const recipes = set.recipes.length ? set.recipes.map(recipeHtml).join('') : `<li class="package-library-empty">${esc(tr('packageLibrary.noRecipes', 'No recipes yet'))}</li>`;
return `<article class="package-library-set"><header><div><span>${esc(set.title.externalTitleId)}</span><h3>${esc(set.title.name)}</h3></div><div><strong>${esc(set.label || set.setKey)}</strong><small>${esc(set.platform)} ${esc(set.region || '')} ${esc(set.confidence)} ${esc(set.source)}</small></div></header><div class="package-library-columns"><section><h4>${esc(tr('packageLibrary.components', 'Components'))} (${set.components.length})</h4><ul class="package-component-list">${set.components.map(componentHtml).join('')}</ul></section><section><h4>${esc(tr('packageLibrary.recipes', 'Recipes'))} (${set.recipes.length})</h4><ul class="package-recipe-list">${recipes}</ul></section></div></article>`;
}
function render() {
const host = document.getElementById('package-library-content');
if (!host) return;
const query = document.getElementById('package-library-search')?.value.trim().toLowerCase();
const sets = catalog.sets.filter(set => !query || JSON.stringify(set).toLowerCase().includes(query));
host.innerHTML = sets.length ? `<div class="package-library-list">${sets.map(setHtml).join('')}</div>` : `<div class="operations-empty"><strong>${esc(tr('packageLibrary.empty', 'No package sets match'))}</strong><span>${esc(tr('packageLibrary.emptyDetail', 'Import or adjust the search. Package inventory never implies installation.'))}</span></div>`;
const stats = document.getElementById('package-library-stats');
if (stats) stats.textContent = [['titles', 'titles'], ['sets', 'sets'], ['components', 'componentsLower'], ['recipes', 'recipesLower']].map(([field, key]) => `${catalog.stats[field] || 0} ${tr(`packageLibrary.${key}`, field)}`).join(' / ');
}
window.loadPackages = async () => { catalog = await window.pork.packageCatalog(); render(); };
document.getElementById('package-library-search')?.addEventListener('input', render);
})();
+636
View File
@@ -0,0 +1,636 @@
// ── Payload Manager ───────────────────────────────────────────────────────────
function renderPs5PayloadManagerObservability(snapshot) {
const root = $('pldmgr-observability');
if (!root || !snapshot) return;
const autoload = snapshot.autoload || {};
const processes = snapshot.processes || {};
const logs = snapshot.logs || {};
const stateLabels = {
idle: window.t('payload.state.idle', 'idle'),
countdown: window.t('payload.state.countdown', 'countdown'),
running: window.t('payload.state.running', 'running'),
done: window.t('payload.state.done', 'done'),
unknown: window.t('common.unknown', 'unknown'),
};
const stateClasses = { done: 'badge-green', running: 'badge-blue', countdown: 'badge-yellow', idle: '', unknown: 'badge-red' };
const state = autoload.supported ? (autoload.state || 'unknown') : 'unknown';
const daemonItems = (processes.items || []).filter(item => item.daemon).slice(0, 40);
root.hidden = false;
$('pldmgr-autoload-metric').textContent = autoload.supported ? `${autoload.done || 0}/${autoload.total || autoload.list?.length || 0}` : 'n/b';
$('pldmgr-process-metric').textContent = processes.supported ? String(processes.total || 0) : 'n/b';
$('pldmgr-memory-metric').textContent = processes.supported ? `${Number(processes.memoryMiB || 0).toLocaleString(window.i18n?.current || 'en', { maximumFractionDigits: 1 })} MiB` : 'n/a';
$('pldmgr-log-metric').textContent = logs.supported ? String(logs.total || 0) : 'n/b';
$('pldmgr-autoload-state').textContent = stateLabels[state] || state;
$('pldmgr-autoload-state').className = `badge ${stateClasses[state] || ''}`.trim();
const detail = [];
if (autoload.supported) {
if (autoload.current) detail.push(window.tf('payload.current', 'Current: {value}', { value: autoload.current }));
if (autoload.remaining > 0) detail.push(window.tf('payload.startsIn', 'starts in {seconds}s', { seconds: autoload.remaining }));
if (autoload.list?.length) detail.push(window.tf('payload.itemsConfigured', '{count} item(s) configured', { count: autoload.list.length }));
if (autoload.scanUsbPayloads) detail.push(window.t('payload.usbScanActive', 'USB scan active'));
} else detail.push(autoload.unavailable || window.t('payload.endpointUnsupported', 'This endpoint is not supported by this Payload Manager version.'));
$('pldmgr-autoload-detail').textContent = detail.join(' · ');
$('pldmgr-process-list').innerHTML = processes.supported
? (daemonItems.length ? daemonItems.map(item => `<div class="payload-process-row"><div><strong>${escHtml(item.name)}</strong><span>PID ${item.pid}</span></div><b>${Number(item.memoryMiB || 0).toLocaleString(window.i18n?.current || 'en', { maximumFractionDigits: 1 })} MiB</b></div>`).join('') : `<p class="hint">${escHtml(window.t('payload.noDaemons', 'No separate payload daemons detected.'))}</p>`)
: `<p class="hint">${escHtml(processes.unavailable || window.t('payload.processesUnsupported', 'Processes are unavailable in this Payload Manager version.'))}</p>`;
$('pldmgr-log-output').textContent = logs.supported && logs.lines?.length
? `${logs.truncated ? `[${window.t('payload.latestLogsOnly', 'latest lines only')}]\n` : ''}${logs.lines.join('\n')}`
: (logs.unavailable || window.t('payload.noLogs', 'No log lines yet.'));
$('pldmgr-observability-note').textContent = window.tf(
'payload.snapshotNote',
'Read-only snapshot from {time}. Chimera never terminates a process automatically.',
{ time: new Date(snapshot.checkedAt || Date.now()).toLocaleTimeString(window.i18n?.current || 'en') },
);
}
function renderPs5PayloadManagerInventory(inventory) {
const list = $('pldmgr-installed-list');
if (!list) return;
const payloads = (inventory?.payloads || []).slice(0, 200);
list.innerHTML = payloads.length ? payloads.map(item => `<div class="payload-process-row"><div><strong>${escHtml(item.name || item.filename)}</strong><span>${escHtml(item.version || window.t('payload.unknownVersion', 'unknown version'))}${item.checksumVerified ? ` · ${escHtml(window.t('payload.checksumKnown', 'checksum known'))}` : ''}</span></div><button class="btn btn-sm pldmgr-safe-launch" data-payload-path="${escHtml(item.path)}">${escHtml(window.t('payload.preflightStart', 'Preflight & start'))}</button></div>`).join('') : `<p class="hint">${escHtml(window.t('payload.noneInstalled', 'No installed payloads reported.'))}</p>`;
}
async function refreshPs5PayloadManagerObservability({ quiet = false } = {}) {
if (!window.pork.pldmgrObservability) return null;
try {
const result = await window.pork.pldmgrObservability();
renderPs5PayloadManagerObservability(result);
return result;
} catch (error) {
if (!quiet) showToast(error.message, 'error');
return null;
}
}
async function connectPs5PayloadManager({ quiet = false, persistPort = false } = {}) {
const status = $('pldmgr-status');
const version = $('pldmgr-version');
const frame = $('pldmgr-frame');
if (!window.pork.pldmgrProbe || !status || !frame) return;
if (!quiet) status.textContent = window.t('payload.connectingPs5', 'Connecting to PS5…');
try {
const port = Number($('pldmgr-port')?.value || 8084);
if (persistPort) await window.pork.setSettings({ payloadManagerPort: port });
const settings = await window.pork.getSettings();
if (!settings.ftpHost) throw new Error(window.t('payload.configureIp', 'Configure the PS5 IP address first.'));
const [result] = await Promise.all([
window.pork.pldmgrProbe(),
refreshPs5PayloadManagerObservability({ quiet: true }),
]);
const versionValue = typeof result.version === 'string'
? result.version
: (result.version?.version || result.version?.menu_version || 'online');
version.textContent = String(versionValue).replace(/^"|"$/g, '') || 'online';
const payloadCount = Number(result.inventory?.total || 0);
renderPs5PayloadManagerInventory(result.inventory);
const sourceCount = Number(result.sources?.total || 0);
const repositoryCount = Number(result.repository?.total || 0);
const autoloadRaw = result.autoload?.list || result.autoload?.AUTOLOAD_LIST || '';
const autoloadCount = Array.isArray(autoloadRaw)
? autoloadRaw.length
: String(autoloadRaw || '').split(',').filter(Boolean).length;
status.textContent = window.tf(
'payload.connectedSummary',
'Connected · {payloads} installed · {autoload} autoload · {sources} sources · {available} available',
{ payloads: payloadCount, autoload: autoloadCount, sources: sourceCount, available: repositoryCount },
);
const embedUrl = await window.pork.pldmgrEmbedUrl();
frame.dataset.url = embedUrl;
if (!quiet || frame.dataset.userOpened === 'true') {
frame.src = embedUrl;
frame.hidden = false;
} else {
frame.hidden = true;
}
} catch (error) {
version.textContent = 'offline';
frame.hidden = true;
if ($('pldmgr-observability')) $('pldmgr-observability').hidden = true;
status.textContent = `${window.t('payload.unreachable', 'Unavailable')}: ${error.message}`;
}
}
$('btn-pldmgr-refresh')?.addEventListener('click', () => connectPs5PayloadManager());
$('btn-pldmgr-logs-refresh')?.addEventListener('click', () => refreshPs5PayloadManagerObservability());
$('pldmgr-installed-list')?.addEventListener('click', async event => {
const button = event.target.closest('.pldmgr-safe-launch');
if (!button) return;
const payloadPath = button.dataset.payloadPath;
button.disabled = true;
try {
const preflight = await window.pork.pldmgrLaunchPreflight(payloadPath);
let confirmConflict = false;
if (preflight.conflict) {
const conflictConfirmation = await showExactConfirmation(
`${preflight.warning}\n\nActive: ${preflight.active.map(item => `${item.name} (PID ${item.pid})`).join(', ')}\n\nA duplicate instance can make the console unstable.`,
'START PAYLOAD',
);
if (!conflictConfirmation) return;
confirmConflict = true;
} else if (!await showExactConfirmation(`Start ${preflight.selected} on the PS5?`, 'START PAYLOAD')) return;
await window.pork.pldmgrLaunch(payloadPath, confirmConflict, 'START PAYLOAD');
showToast(`${preflight.selected} was sent to Payload Manager.`, 'ok');
setTimeout(() => refreshPs5PayloadManagerObservability({ quiet: true }), 1_000);
} catch (error) { showToast(error.message, 'error'); }
finally { button.disabled = false; }
});
$('pldmgr-port')?.addEventListener('change', () => connectPs5PayloadManager({ persistPort: true }));
$('btn-pldmgr-open')?.addEventListener('click', async () => {
try {
const frame = $('pldmgr-frame');
const url = frame?.dataset.url || await window.pork.pldmgrEmbedUrl();
if (window.pork.isWebMode && String(url).startsWith('/')) {
frame.dataset.userOpened = 'true';
frame.src = url;
frame.hidden = false;
frame.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else await window.pork.openShell(url);
} catch (error) { setStatus(error.message, 'error'); }
});
setInterval(() => {
const page = $('page-jailbreak');
if (page?.classList.contains('active') && $('pldmgr-version')?.textContent !== 'offline') {
refreshPs5PayloadManagerObservability({ quiet: true });
}
}, 15_000);
async function loadPs5ServiceMatrix() {
const grid = $('service-matrix-grid');
const summary = $('service-matrix-summary');
const button = $('btn-service-matrix-refresh');
if (!grid || !window.pork.ps5ServiceMatrix) return;
if (button) button.disabled = true;
summary.textContent = 'controleren…';
try {
const result = await window.pork.ps5ServiceMatrix();
summary.textContent = `${result.online}/${result.total} online`;
grid.innerHTML = result.services.map(service => `
<div class="service-matrix-item ${service.online ? 'online' : 'offline'}">
<div class="service-matrix-head">
<span class="service-matrix-dot"></span>
<span class="service-matrix-name">${escHtml(service.name)}</span>
<span class="service-matrix-port">:${service.port}${service.online && service.latencyMs != null ? ` · ${service.latencyMs} ms` : ''}</span>
</div>
<p>${service.online ? `${escHtml(service.section)} is reachable.` : escHtml(service.hint)}</p>
</div>
`).join('');
} catch (error) {
summary.textContent = 'check failed';
grid.innerHTML = `<p class="hint">${escHtml(error.message)}</p>`;
} finally { if (button) button.disabled = false; }
}
$('btn-service-matrix-refresh')?.addEventListener('click', loadPs5ServiceMatrix);
async function renderSettingsPayloadSources() {
const sources = await window.pork.payloadSourcesList();
const cont = $('settings-payload-sources');
if (!sources.length) {
cont.innerHTML = '<p class="hint" style="margin:6px 0">No sources yet. Add one below.</p>';
return;
}
const expertMode = document.body.classList.contains('chimera-expert-mode');
const visibleSources = sources.filter(source =>
expertMode || payloadCatalogGroup({ source, files: [] }) !== 'expert'
);
const hiddenCount = sources.length - visibleSources.length;
const hiddenNotice = hiddenCount > 0
? `<p class="hint payload-expert-hidden">${escHtml(window.tf(
'payload.settings.expertHidden',
'{count} expert source(s) hidden. Enable Expert tools to manage them.',
{ count: hiddenCount },
))}</p>`
: '';
cont.innerHTML = hiddenNotice + visibleSources.map(s => `
<div class="payload-settings-source-row" data-source-id="${s.id}">
<input type="checkbox" class="source-toggle" ${s.enabled ? 'checked' : ''}/>
<span class="payload-settings-source-name">${escHtml(s.name)}</span>
<span class="payload-settings-source-url" title="${escHtml(s.github_url)}">${escHtml(s.github_url)}</span>
${s.latest_tag ? `<span class="payload-tag-chip">${escHtml(s.latest_tag)}</span>` : ''}
${s.built_in ? `<span class="payload-trust-chip payload-trust-official">${escHtml(window.t('payload.builtIn', 'built-in'))}</span>` : '<button class="btn btn-sm btn-source-remove" style="margin-left:auto">&#10005;</button>'}
</div>
`).join('');
}
window.addEventListener('chimera:expert-mode-changed', () => {
if ($('page-settings')?.classList.contains('active')) renderSettingsPayloadSources().catch(error => showToast(error.message, 'error'));
});
function payloadStatusBadge(file) {
if (file.local_hash && file.local_version && file.version && file.local_version !== file.version) {
return `<span class="badge badge-yellow">&#8595; ${escHtml(window.t('payload.status.update', 'Update available'))}</span>`;
}
if (file.local_hash && file.remote_hash) {
if (file.local_hash === file.remote_hash) return `<span class="badge badge-green">&#10003; ${escHtml(window.t('payload.status.current', 'Up to date'))}</span>`;
return `<span class="badge badge-yellow">&#8595; ${escHtml(window.t('payload.status.update', 'Update available'))}</span>`;
}
if (file.local_hash) return `<span class="badge badge-dim">${escHtml(window.t('payload.status.downloadedUnverified', 'Downloaded (no checksum)'))}</span>`;
if (file.remote_hash) return `<span class="badge badge-dim">${escHtml(window.t('payload.status.notDownloaded', 'Not downloaded'))}</span>`;
return '<span class="badge badge-dim">—</span>';
}
function payloadActionBtn(file, source_id) {
const upToDate = !!(file.local_hash && (
(file.remote_hash && file.local_hash === file.remote_hash) ||
(!file.remote_hash && file.local_version && file.version && file.local_version === file.version)
));
const hasUpdate = !!(file.local_hash && (
(file.remote_hash && file.local_hash !== file.remote_hash) ||
(file.local_version && file.version && file.local_version !== file.version)
));
const dlLabel = hasUpdate
? `&#8595;&nbsp;${escHtml(window.t('payload.action.update', 'Update'))}`
: `&#8595;&nbsp;${escHtml(window.t('payload.action.download', 'Download'))}`;
const dlCls = hasUpdate ? 'btn btn-sm btn-teal btn-payload-download' : 'btn btn-sm btn-payload-download';
const dlBtn = !upToDate
? `<button class="${dlCls}"
data-source-id="${source_id}"
data-asset-name="${escHtml(file.asset_name)}"
data-asset-url="${escHtml(file.asset_url || '')}"
data-version="${escHtml(file.version || '')}">${dlLabel}</button>`
: '';
const pushBtn = file.local_path
? `<button class="btn btn-sm btn-payload-push-remote"
data-local-path="${escHtml(file.local_path)}"
data-filename="${escHtml(file.asset_name)}"
title="Upload to PS5 via FTP transfer queue">&#8593;&nbsp;PS5</button>`
: '';
return (dlBtn || pushBtn)
? `<span style="display:inline-flex;gap:4px">${dlBtn}${pushBtn}</span>`
: '';
}
function payloadHasUpdate(file) {
return Boolean(file.local_hash && (
(file.remote_hash && file.local_hash !== file.remote_hash) ||
(file.local_version && file.version && file.local_version !== file.version)
));
}
function payloadCatalogGroup({ source, files }) {
const fingerprint = `${source.name || ''} ${source.category || ''} ${source.description || ''} ${(files || []).map(file => file.asset_name).join(' ')}`.toLowerCase();
const expertCategory = ['kernel', 'debug', 'linux'].includes(String(source.category || '').toLowerCase());
if (expertCategory || /(kernel|kstuff|kpatch|exploit|debugger|gdb|elf\s*(?:ldr|loader)|binloader|shell(?:code)?|jailbreak|linux\s+loader)/.test(fingerprint)) return 'expert';
if ((files || []).some(payloadHasUpdate)) return 'updates';
if (source.trust === 'custom' || /(experimental|prototype|research|legacy)/.test(fingerprint)) return 'experimental';
if (source.trust === 'official' && (files || []).some(file => file.remote_hash)) return 'recommended';
return 'compatible';
}
const PAYLOAD_GROUPS = [
{ id: 'updates', label: 'Updates', detail: 'Downloaded payloads with a newer or changed upstream asset.' },
{ id: 'recommended', label: 'Recommended', detail: 'Reviewed official sources with a published checksum.' },
{ id: 'compatible', label: 'Compatible', detail: 'Normal payloads that still require a manual compatibility check.' },
{ id: 'experimental', label: 'Experimental', detail: 'Custom, research or incomplete sources. Review before downloading.' },
{ id: 'expert', label: 'Expert', detail: 'Low-level payloads that can affect console stability.' },
];
function payloadSourceMarkup({ source, files, error }, forceOpen = false) {
const usableFiles = (files || []).filter(file =>
/\.(bin|elf|js)$/i.test(file.asset_name) &&
!/sha256|checksums|sha2|sums/i.test(file.asset_name)
);
const tag = source.latest_tag ? `<span class="payload-tag-chip">${escHtml(source.latest_tag)}</span>` : '';
const trust = escHtml(source.trust || 'custom');
const checked = source.last_checked
? `<span class="hint payload-checked-at">${escHtml(window.tf('payload.checkedAt', 'checked {date}', { date: new Date(source.last_checked).toLocaleString() }))}</span>`
: '';
const errRow = error ? `<p class="hint payload-source-error">${escHtml(error)}</p>` : '';
const rows = usableFiles.map(file => `
<tr>
<td class="payload-asset-name">${escHtml(file.asset_name)}</td>
<td>${payloadStatusBadge(file)}</td>
<td class="payload-hash-snippet">${file.remote_hash ? file.remote_hash.slice(0, 12) + '…' : '—'}</td>
<td class="payload-asset-meta">${escHtml(file.version || '—')} · ${file.asset_size ? fmt(file.asset_size) : escHtml(window.t('payload.unknownSize', 'unknown size'))}</td>
<td>${payloadActionBtn(file, source.id)}</td>
</tr>
`).join('');
return `
<details class="payload-check-source" ${forceOpen ? 'open' : ''}>
<summary class="payload-check-source-header">
<span class="payload-check-source-name">${escHtml(source.name)}</span>
<span class="payload-source-count">${usableFiles.length}</span>
${tag} ${checked}
<span class="payload-source-meta">
<span class="payload-trust-chip payload-trust-${trust}">${trust}</span>
<span class="payload-tag-chip">${escHtml(source.category || 'other')}</span>
</span>
</summary>
<div class="payload-source-body">
${source.description ? `<p class="payload-source-description">${escHtml(source.description)}</p>` : ''}
${errRow}
${rows ? `<div class="payload-asset-scroll"><table class="payload-asset-table"><tbody>${rows}</tbody></table></div>` : `<p class="hint">${escHtml(window.t('payload.noAssets', 'No assets found.'))}</p>`}
</div>
</details>`;
}
function renderPayloadSourceCheckList(results) {
const cont = $('payload-sources-check-list');
if (!results || !results.length) {
cont.innerHTML = `<div class="library-empty"><span class="library-empty-icon">⌁</span><strong>${escHtml(window.t('payload.empty.title', 'No payload sources match'))}</strong><span>${escHtml(window.t('payload.empty.detail', 'Adjust the filters or add a source in Settings.'))}</span></div>`;
return;
}
const queryActive = Boolean(String($('payload-catalog-search')?.value || '').trim());
const grouped = Object.groupBy
? Object.groupBy(results, payloadCatalogGroup)
: results.reduce((groups, item) => {
const id = payloadCatalogGroup(item);
(groups[id] ||= []).push(item);
return groups;
}, {});
cont.innerHTML = PAYLOAD_GROUPS.map(group => {
const items = grouped[group.id] || [];
if (!items.length) return '';
const label = window.t(`payload.groups.${group.id}.label`, group.label);
const detail = window.t(`payload.groups.${group.id}.detail`, group.detail);
const risk = group.id === 'expert' || group.id === 'experimental' ? group.id : 'standard';
return `<details class="payload-catalog-group payload-catalog-group-${risk}" ${queryActive || group.id === 'updates' || group.id === 'recommended' ? 'open' : ''}>
<summary><span><strong>${escHtml(label)}</strong><small>${escHtml(detail)}</small></span><b>${items.length}</b></summary>
<div class="payload-catalog-group-body">${items.map(item => payloadSourceMarkup(item, queryActive)).join('')}</div>
</details>`;
}).join('');
}
let _payloadCatalog = null;
function catalogResults(catalog) {
return (catalog.sources || []).map(source => ({
source,
files: (catalog.files || []).filter(file => file.source_id === source.id),
error: source.last_error || null,
}));
}
function applyPayloadCatalogFilters() {
if (!_payloadCatalog) return;
const query = String($('payload-catalog-search')?.value || '').trim().toLowerCase();
const category = String($('payload-catalog-category')?.value || 'all');
const showExpert = Boolean($('payload-show-expert')?.checked);
const results = catalogResults(_payloadCatalog)
.filter(({ source }) => category === 'all' || source.category === category)
.map(item => ({
...item,
files: item.files.filter(file => !query || `${file.asset_name} ${item.source.name} ${item.source.description || ''}`.toLowerCase().includes(query)),
}))
.filter(item => !query || item.files.length || `${item.source.name} ${item.source.description || ''}`.toLowerCase().includes(query))
.filter(item => showExpert || payloadCatalogGroup(item) !== 'expert');
renderPayloadSourceCheckList(results);
}
function renderPayloadCatalog(catalog) {
_payloadCatalog = catalog;
const stats = catalog.stats || {};
$('payload-stat-assets').textContent = String(stats.available || 0);
$('payload-stat-sources').textContent = String(stats.enabledSources || stats.sources || 0);
$('payload-stat-downloaded').textContent = String(stats.downloaded || 0);
$('payload-stat-updates').textContent = String(stats.updates || 0);
$('payload-stat-verified').textContent = String(stats.verified || 0);
const select = $('payload-catalog-category');
const selected = select.value || 'all';
const categories = [...new Set((catalog.sources || []).map(source => source.category || 'other'))].sort();
select.innerHTML = `<option value="all">${escHtml(window.t('payload.allCategories', 'All categories'))}</option>` + categories.map(value => `<option value="${escHtml(value)}">${escHtml(value)}</option>`).join('');
select.value = categories.includes(selected) ? selected : 'all';
applyPayloadCatalogFilters();
}
async function loadPayloadCatalog() {
const catalog = await window.pork.payloadCatalog();
renderPayloadCatalog(catalog);
return catalog;
}
async function renderLocalPayloads() {
let files;
try { files = await window.pork.payloadListLocal(); } catch { files = []; }
const list = $('payload-local-list');
const empty = $('payload-local-empty');
if (!files.length) {
list.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
list.innerHTML = files.map(f => {
const ext = (f.name.split('.').pop() || '').toLowerCase();
const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || '';
return `<div class="al-file-card payload-local-card">
<span class="al-ext ${extClass}">${escHtml(ext || '?')}</span>
<span class="al-file-name" title="${escHtml(f.local_path)}">${escHtml(f.name)}</span>
<span class="payload-card-size">${fmt(f.size)}</span>
${payloadStatusBadge(f)}
<div class="payload-card-actions">
<button class="btn btn-sm btn-teal btn-payload-push"
data-local-path="${escHtml(f.local_path)}"
data-filename="${escHtml(f.name)}" title="Push to PS5 via FTP transfer queue">&#8593;&nbsp;FTP</button>
<button class="btn btn-sm btn-payload-delete-local"
data-id="${f.db_id ?? ''}"
data-local-path="${escHtml(f.local_path)}" title="Delete local file">&#128465;</button>
</div>
</div>`;
}).join('');
}
async function renderPayloadSender() {
let files;
try { files = await window.pork.payloadListLocal(); } catch { files = []; }
const list = $('payload-sender-list');
const empty = $('payload-sender-empty');
if (!files.length) {
list.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
const status = $('payload-sender-status');
if (status) status.textContent = '';
list.innerHTML = files.map(f => {
const ext = (f.name.split('.').pop() || '').toLowerCase();
const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || '';
return `<div class="al-file-card payload-sender-card">
<span class="al-ext ${extClass}">${escHtml(ext || '?')}</span>
<span class="al-file-name" title="${escHtml(f.local_path)}">${escHtml(f.name)}</span>
<span class="payload-card-size">${fmt(f.size)}</span>
<button class="btn btn-sm btn-teal btn-payload-tcp-send"
data-local-path="${escHtml(f.local_path)}"
data-filename="${escHtml(f.name)}">&#x21D2; Send</button>
</div>`;
}).join('');
}
$('btn-payload-check-all').addEventListener('click', async () => {
const btn = $('btn-payload-check-all');
const status = $('payload-check-status');
btn.disabled = true;
status.textContent = 'Checking…';
try {
const results = await window.pork.payloadCheckUpdates();
await loadPayloadCatalog();
await renderLocalPayloads();
status.textContent = `Done — ${results.length} source(s) checked`;
} catch (e) {
status.textContent = `Error: ${e.message}`;
setStatus(`Payload check failed: ${e.message}`, 'error');
} finally {
btn.disabled = false;
}
});
$('payload-catalog-search')?.addEventListener('input', applyPayloadCatalogFilters);
$('payload-catalog-category')?.addEventListener('change', applyPayloadCatalogFilters);
$('payload-show-expert')?.addEventListener('change', event => {
localStorage.setItem('chimera.payload.showExpert', event.target.checked ? '1' : '0');
applyPayloadCatalogFilters();
});
$('btn-payload-update-downloaded')?.addEventListener('click', async () => {
const btn = $('btn-payload-update-downloaded');
const status = $('payload-check-status');
btn.disabled = true;
status.textContent = 'Checking and updating local payloads…';
try {
const result = await window.pork.payloadUpdateDownloaded();
await loadPayloadCatalog();
await renderLocalPayloads();
await renderPayloadSender();
status.textContent = `${result.checked} checked locally · ${result.updated} updated${result.failed ? ` · ${result.failed} failed` : ''}`;
} catch (error) {
status.textContent = `Update failed: ${error.message}`;
} finally { btn.disabled = false; }
});
$('payload-sources-check-list').addEventListener('click', async e => {
const pushBtn = e.target.closest('.btn-payload-push-remote');
if (pushBtn) {
const local_path = pushBtn.dataset.localPath;
const filename = pushBtn.dataset.filename;
const origText = pushBtn.textContent;
pushBtn.disabled = true;
pushBtn.textContent = 'Queuing…';
try {
await window.pork.payloadPush({ local_path, filename });
setStatus(`${filename} queued for FTP transfer to PS5`, 'ok');
navigate('transfers');
} catch (err) {
setStatus(`FTP push failed: ${err.message}`, 'error');
pushBtn.disabled = false;
pushBtn.textContent = origText;
}
return;
}
const dlBtn = e.target.closest('.btn-payload-download');
if (!dlBtn) return;
const source_id = Number(dlBtn.dataset.sourceId);
const asset_name = dlBtn.dataset.assetName;
const asset_url = dlBtn.dataset.assetUrl;
const version = dlBtn.dataset.version;
dlBtn.disabled = true;
dlBtn.textContent = 'Downloading…';
try {
await window.pork.payloadDownload({ source_id, asset_name, asset_url, version });
setStatus(`Downloaded ${asset_name}`, 'ok');
await loadPayloadCatalog();
await renderLocalPayloads();
await renderPayloadSender();
} catch (err) {
setStatus(`Download failed: ${err.message}`, 'error');
dlBtn.disabled = false;
dlBtn.textContent = '↓ Retry';
}
});
$('payload-local-list').addEventListener('click', async e => {
const pushBtn = e.target.closest('.btn-payload-push');
if (pushBtn) {
const local_path = pushBtn.dataset.localPath;
const filename = pushBtn.dataset.filename;
try {
await window.pork.payloadPush({ local_path, filename });
setStatus(`${filename} queued for transfer`, 'ok');
navigate('transfers');
} catch (e2) {
setStatus(`Push failed: ${e2.message}`, 'error');
}
return;
}
const delBtn = e.target.closest('.btn-payload-delete-local');
if (delBtn) {
const confirmation = await showExactConfirmation(`Delete local file "${delBtn.dataset.localPath}"?`, 'VERWIJDER');
if (!confirmation) return;
try {
const id = delBtn.dataset.id ? Number(delBtn.dataset.id) : null;
const local_path = delBtn.dataset.localPath;
await window.pork.payloadDeleteLocal({ id, local_path, confirmation });
await renderLocalPayloads();
await renderPayloadSender();
} catch (e2) {
setStatus(`Delete failed: ${e2.message}`, 'error');
}
}
});
$('payload-sender-list').addEventListener('click', async e => {
const btn = e.target.closest('.btn-payload-tcp-send');
if (!btn) return;
const local_path = btn.dataset.localPath;
const filename = btn.dataset.filename;
const port = parseInt($('payload-sender-port')?.value || '9021', 10);
const status = $('payload-sender-status');
btn.disabled = true;
btn.textContent = 'Sending\u2026';
if (status) { status.textContent = `Sending ${filename} on port ${port}\u2026`; status.style.color = ''; }
try {
await window.pork.payloadTcpSend({ localPath: local_path, port, filename });
if (status) { status.textContent = `\u2713 ${filename} sent \u2014 PS5 notified`; status.style.color = 'var(--teal)'; }
setStatus(`${filename} sent to PS5 on port ${port}`, 'ok');
} catch (e2) {
if (status) { status.textContent = `Error: ${e2.message}`; status.style.color = 'var(--red)'; }
setStatus(`TCP send failed: ${e2.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = '\u21D2 Send';
}
});
$('autoloader-file-list').addEventListener('click', async e => {
const saveBtn = e.target.closest('.btn-al-save-local');
if (saveBtn) {
e.stopPropagation(); // don't also add to sequence
const filename = saveBtn.dataset.filename;
const orig = saveBtn.textContent;
saveBtn.disabled = true;
saveBtn.textContent = '⋯';
try {
await window.pork.autoloaderSaveLocal({ filename });
setStatus(`${filename} saved to local payloads`, 'ok');
saveBtn.textContent = '✓';
await renderLocalPayloads();
await renderPayloadSender();
setTimeout(() => { saveBtn.textContent = orig; saveBtn.disabled = false; }, 2000);
} catch (e2) {
setStatus(`Save failed: ${e2.message}`, 'error');
saveBtn.textContent = orig;
saveBtn.disabled = false;
}
return;
}
const card = e.target.closest('[data-al-add]');
if (!card) return;
_alSequence.push({ type: 'payload', filename: card.dataset.alAdd });
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
});
window.pork.on('payload:download:progress', ({ asset_name, percent }) => {
const btn = document.querySelector(`.btn-payload-download[data-asset-name="${CSS.escape(asset_name)}"]`);
if (btn) btn.textContent = `${percent}%`;
});
+160
View File
@@ -0,0 +1,160 @@
// ── PFS Ripper — PS5 PFS/image library browser ───────────────────────────────
// Native port of Deckerr97's "PFS Ripper": browse a library of PS5 image files,
// show cover art (ProsperoPatches), search, view PPSA IDs + versions, export.
let _pfsInit = false;
let _pfsItems = [];
let _pfsSearch = '';
const _pfsIconCache = new Map();
function _pfsEsc(s) { return (typeof escHtml === 'function') ? escHtml(s) : String(s ?? ''); }
function _pfsToast(m, k) { if (typeof showToast === 'function') showToast(m, k); }
function _pfsFmt(n) {
if (!n) return '';
if (n < 1048576) return (n / 1024).toFixed(0) + ' KB';
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB';
return (n / 1073741824).toFixed(2) + ' GB';
}
function _pfsBuildUI() {
const root = document.getElementById('page-pfs-ripper');
if (!root) return;
root.innerHTML = `
<div class="embed-toolbar">
<div class="embed-controls">
<span class="embed-page-title">PFS Ripper</span>
<button id="pfs-add" class="btn btn-sm">+ Add Folder</button>
<button id="pfs-rescan" class="btn btn-xs">⟳ Rescan</button>
<span id="pfs-count" class="hint"></span>
</div>
<div class="embed-controls">
<input id="pfs-search" type="text" placeholder="Search title or ID…" style="width:220px" />
</div>
</div>
<div id="pfs-folders" style="display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px"></div>
<div id="pfs-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;padding:12px"></div>`;
document.getElementById('pfs-add').addEventListener('click', async () => {
await window.pork.pfsFoldersAdd();
await _pfsRenderFolders();
await _pfsScan();
});
document.getElementById('pfs-rescan').addEventListener('click', () => _pfsScan());
document.getElementById('pfs-search').addEventListener('input', e => {
_pfsSearch = e.target.value.toLowerCase();
_pfsRenderGrid();
});
}
async function _pfsRenderFolders() {
const el = document.getElementById('pfs-folders');
if (!el) return;
const folders = (await window.pork.pfsFoldersList().catch(() => [])) || [];
if (!folders.length) {
el.innerHTML = '<span class="hint">No library folders yet — click “Add Folder”.</span>';
return;
}
el.innerHTML = folders.map(f =>
`<span class="pill" style="display:inline-flex;align-items:center;gap:6px;padding:3px 8px;border:1px solid var(--border);border-radius:12px;font-size:12px">
<code style="font-size:11px">${_pfsEsc(f)}</code>
<button class="btn btn-xs btn-red pfs-folder-rm" data-folder="${_pfsEsc(f)}" title="Remove">✕</button>
</span>`).join('');
el.querySelectorAll('.pfs-folder-rm').forEach(b =>
b.addEventListener('click', async () => {
await window.pork.pfsFoldersRemove(b.dataset.folder);
await _pfsRenderFolders();
await _pfsScan();
}));
}
async function _pfsScan() {
const grid = document.getElementById('pfs-grid');
if (grid) grid.innerHTML = '<div class="embed-empty-message" style="padding:16px">Scanning…</div>';
_pfsItems = (await window.pork.pfsScan().catch(() => [])) || [];
_pfsRenderGrid();
}
function _pfsFiltered() {
if (!_pfsSearch) return _pfsItems;
return _pfsItems.filter(it =>
(it.gameId || '').toLowerCase().includes(_pfsSearch) ||
(it.file || '').toLowerCase().includes(_pfsSearch));
}
function _pfsRenderGrid() {
const grid = document.getElementById('pfs-grid');
const countEl = document.getElementById('pfs-count');
if (!grid) return;
const list = _pfsFiltered();
if (countEl) countEl.textContent = `${list.length} image${list.length !== 1 ? 's' : ''}`;
if (!list.length) {
grid.innerHTML = '<div class="embed-empty-message" style="padding:16px">No images found. Add a folder containing .ffpkg / .pkg / .exfat images.</div>';
return;
}
grid.innerHTML = list.map((it, i) => `
<div class="pfs-card" style="border:1px solid var(--border);border-radius:8px;overflow:hidden;display:flex;flex-direction:column">
<div class="pfs-art" data-gid="${_pfsEsc(it.gameId)}" style="aspect-ratio:1;background:rgba(var(--accent-rgb),.08);display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--text-dim)">
${it.gameId ? _pfsEsc(it.gameId) : '—'}
</div>
<div style="padding:8px;display:flex;flex-direction:column;gap:2px;flex:1">
<div style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${_pfsEsc(it.file)}">${_pfsEsc(it.gameId || it.file)}</div>
<div class="hint" style="font-size:11px">
<span class="pill-num" style="padding:0 4px">${_pfsEsc(it.ext)}</span>
${it.version ? ' v' + _pfsEsc(it.version) : ''} · ${_pfsFmt(it.size)}
</div>
<div style="display:flex;gap:4px;margin-top:6px;flex-wrap:wrap">
<button class="btn btn-xs pfs-export" data-i="${i}" title="Copy to a folder">Export</button>
<button class="btn btn-xs pfs-ftp" data-i="${i}" title="Send to PS5 via FTP">→ PS5</button>
<button class="btn btn-xs pfs-reveal" data-i="${i}" title="Show in Explorer">📂</button>
</div>
</div>
</div>`).join('');
// Lazy-load cover art via ProsperoPatches.
grid.querySelectorAll('.pfs-art[data-gid]').forEach(el => {
const gid = el.dataset.gid;
if (!gid) return;
_pfsLoadArt(gid, el);
});
grid.querySelectorAll('.pfs-export').forEach(b => b.addEventListener('click', () => _pfsExport(list[+b.dataset.i])));
grid.querySelectorAll('.pfs-ftp').forEach(b => b.addEventListener('click', () => _pfsFtp(list[+b.dataset.i])));
grid.querySelectorAll('.pfs-reveal').forEach(b => b.addEventListener('click', () => window.pork.pfsReveal(list[+b.dataset.i].path)));
}
async function _pfsLoadArt(gid, el) {
try {
let url = _pfsIconCache.get(gid);
if (url === undefined) {
const result = await window.pork.prosperoFetchIcon(gid).catch(() => null);
url = result?.iconUrl || '';
_pfsIconCache.set(gid, url || null);
}
if (url && el.isConnected) {
el.innerHTML = `<img src="${_pfsEsc(url)}" alt="" style="width:100%;height:100%;object-fit:cover" data-image-error="hide">`;
}
} catch (_) { /* leave the ID placeholder */ }
}
async function _pfsExport(it) {
if (!it) return;
try {
const r = await window.pork.pfsExportCopy(it.path);
if (r && r.ok) _pfsToast('Exported to ' + r.dest, 'ok');
} catch (e) { _pfsToast('Export failed: ' + e.message, 'error'); }
}
async function _pfsFtp(it) {
if (!it) return;
try {
await window.pork.pfsExportFtp(it.path);
_pfsToast('Queued upload to PS5 — see Transfers', 'ok');
} catch (e) { _pfsToast('Send failed: ' + e.message, 'error'); }
}
// Entry point invoked by the page router (state.js pageLoaders).
async function loadPfsRipper() {
if (!_pfsInit) { _pfsInit = true; _pfsBuildUI(); }
await _pfsRenderFolders();
await _pfsScan();
}
+203
View File
@@ -0,0 +1,203 @@
'use strict';
(function mountPremiumProjectIllustrations() {
const targets = Object.freeze({
'games-empty': 'library',
'backups-empty': 'protection',
'transfers-empty': 'transfer',
'media-empty': 'media',
});
const illustrations = Object.freeze({
'library': `<div class="premium-project-illustration" data-variant="library" aria-hidden="true">
<span class="premium-project-glow"></span>
<svg viewBox="0 0 320 180" focusable="false">
<path class="premium-project-path path-a" d="M31 124C76 50 122 57 159 88s75 37 130-23"/>
<path class="premium-project-path path-b" d="M37 58c42 18 65 4 96 34s70 44 151 27"/>
<g class="premium-project-source library-source"><rect x="24" y="82" width="38" height="54" rx="6"/><rect x="31" y="74" width="38" height="54" rx="6"/><path d="M39 91h22M39 100h16"/></g>
<g class="premium-project-surface library-index"><rect x="119" y="39" width="83" height="104" rx="11"/><path d="M135 63h51M135 79h34M135 105h18M160 105h26M135 119h18M160 119h26"/></g>
<g class="premium-project-node library-art"><rect x="260" y="48" width="38" height="48" rx="7"/><path d="m268 84 8-10 7 7 6-8"/><circle cx="286" cy="60" r="4"/></g>
<g class="premium-project-node library-proof"><circle cx="276" cy="126" r="15"/><path d="m269 126 5 5 9-11"/></g>
</svg>
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
</div>`,
'protection': `<div class="premium-project-illustration" data-variant="protection" aria-hidden="true">
<span class="premium-project-glow"></span>
<svg viewBox="0 0 320 180" focusable="false">
<path class="premium-project-path path-a" d="M35 95h79c21 0 27-30 49-30h27c24 0 29 31 49 31h47"/>
<path class="premium-project-path path-b" d="M65 131h67c18 0 25-21 42-21h35c17 0 23 21 43 21h34"/>
<g class="premium-project-source protection-copy"><rect x="27" y="61" width="46" height="68" rx="7"/><path d="M38 80h24M38 94h19M38 108h24"/></g>
<g class="premium-project-node protection-hash"><rect x="129" y="49" width="68" height="55" rx="10"/><path d="M142 68h42M142 80h32"/><text x="144" y="94">SHA</text></g>
<g class="premium-project-surface protection-vault"><path d="M238 55h55v77h-55z"/><circle cx="265" cy="93" r="14"/><path d="M265 84v18M256 93h18"/></g>
<g class="premium-project-shield"><path d="M183 112c0 19-12 28-25 34-13-6-25-15-25-34l25-9z"/><path d="m149 121 7 7 12-16"/></g>
</svg>
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
</div>`,
'transfer': `<div class="premium-project-illustration" data-variant="transfer" aria-hidden="true">
<span class="premium-project-glow"></span>
<svg viewBox="0 0 320 180" focusable="false">
<path class="premium-project-path path-a" d="M68 87h67c21 0 26-27 47-27h28c22 0 27 27 47 27h26"/>
<path class="premium-project-path path-b" d="M68 119h63c22 0 31 25 53 25h30c21 0 29-25 47-25h22"/>
<g class="premium-project-source transfer-console"><path d="M27 61h46l8 65H19z"/><path d="M37 78h27M42 111h17"/><circle cx="50" cy="93" r="5"/></g>
<g class="premium-project-surface transfer-router"><rect x="133" y="42" width="69" height="78" rx="11"/><path d="M146 64h43M146 79h43M146 94h31"/><circle cx="186" cy="95" r="3"/></g>
<g class="premium-project-node transfer-vault"><rect x="251" y="61" width="48" height="65" rx="7"/><path d="M260 78h30M260 91h30M260 104h21"/></g>
<g class="premium-project-progress"><path d="M126 144h82"/><path d="M126 144h55"/></g>
</svg>
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
</div>`,
'media': `<div class="premium-project-illustration" data-variant="media" aria-hidden="true">
<span class="premium-project-glow"></span>
<svg viewBox="0 0 320 180" focusable="false">
<path class="premium-project-path path-a" d="M85 90h58c21 0 24-28 46-28h23c21 0 25 28 44 28h35"/>
<path class="premium-project-path path-b" d="M84 120h57c22 0 27 22 47 22h26c20 0 25-22 44-22h33"/>
<g class="premium-project-source media-console"><rect x="22" y="48" width="70" height="84" rx="9"/><path d="m34 111 15-18 12 12 18-24"/><circle cx="71" cy="66" r="6"/></g>
<g class="premium-project-wave"><path d="M119 92h8l5-17 9 35 8-25 8 13 7-6h8"/></g>
<g class="premium-project-surface media-gallery"><rect x="225" y="43" width="67" height="47" rx="7"/><rect x="217" y="99" width="75" height="43" rx="7"/><path d="m235 80 10-11 8 8 11-14 17 17M227 132l11-12 9 8 11-13 22 17"/></g>
<g class="premium-project-node media-play"><circle cx="184" cy="95" r="19"/><path d="m180 86 12 9-12 9z"/></g>
</svg>
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
</div>`,
'trophies': `<div class="premium-project-illustration" data-variant="trophies" aria-hidden="true">
<span class="premium-project-glow"></span>
<svg viewBox="0 0 320 180" focusable="false">
<path class="premium-project-path path-a" d="M48 126c39-72 80-80 119-45s65 40 111-25"/>
<path class="premium-project-path path-b" d="M46 66c43 5 66 23 91 59s80 32 139 4"/>
<g class="premium-project-source trophy-game"><rect x="27" y="54" width="52" height="75" rx="7"/><path d="M38 75h30M38 88h20M38 108h30"/></g>
<g class="premium-project-surface trophy-cup"><path d="M139 49h46v22c0 23-9 36-23 36s-23-13-23-36z"/><path d="M139 58h-14v8c0 13 7 20 19 21M185 58h14v8c0 13-7 20-19 21M162 107v17M145 126h34"/></g>
<g class="premium-project-node trophy-locked"><circle cx="257" cy="62" r="19"/><path d="M250 62v-5a7 7 0 0 1 14 0v5M248 62h18v14h-18z"/></g>
<g class="premium-project-node trophy-earned"><circle cx="268" cy="127" r="17"/><path d="m259 127 6 6 12-14"/></g>
<g class="premium-project-progress trophy-progress"><circle cx="162" cy="84" r="56"/><path d="M162 28a56 56 0 0 1 51 79"/></g>
</svg>
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
</div>`,
});
const reducedMotion = typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-reduced-motion: reduce)')
: { matches: false, addListener() {}, removeListener() {} };
const mounted = new Set();
const visibilityObserver = 'IntersectionObserver' in window ? new IntersectionObserver(entries => {
for (const entry of entries) {
entry.target.classList.toggle('is-active', entry.isIntersecting);
syncMotion(entry.target);
}
}, { threshold: .16 }) : null;
function motionPaused() {
return reducedMotion.matches || document.visibilityState !== 'visible';
}
function syncMotion(element) {
if (!element) return;
element.classList.toggle('is-paused', motionPaused());
}
function syncAllMotion() {
for (const element of mounted) {
if (!element.isConnected) {
visibilityObserver?.unobserve(element);
mounted.delete(element);
continue;
}
syncMotion(element);
}
}
function bind(illustration) {
if (!illustration || mounted.has(illustration)) return;
syncAllMotion();
mounted.add(illustration);
visibilityObserver?.observe(illustration);
if (!visibilityObserver) illustration.classList.add('is-active');
syncMotion(illustration);
let pointerFrame = 0;
let pointerX = 0;
let pointerY = 0;
illustration.addEventListener('pointermove', event => {
if (motionPaused() || event.pointerType === 'touch') return;
pointerX = event.clientX;
pointerY = event.clientY;
if (pointerFrame) return;
pointerFrame = window.requestAnimationFrame(() => {
pointerFrame = 0;
const rect = illustration.getBoundingClientRect();
illustration.style.setProperty('--illustration-x', `${((pointerX - rect.left) / rect.width - .5) * 7}deg`);
illustration.style.setProperty('--illustration-y', `${((pointerY - rect.top) / rect.height - .5) * -5}deg`);
});
}, { passive: true });
illustration.addEventListener('pointerleave', () => {
if (pointerFrame) window.cancelAnimationFrame(pointerFrame);
pointerFrame = 0;
illustration.style.removeProperty('--illustration-x');
illustration.style.removeProperty('--illustration-y');
});
}
function bindHomeTopology() {
const home = document.getElementById('chimera-home-command-center');
if (!home) return;
home.classList.add('premium-topology-motion');
bind(home);
}
function dockImportLauncher() {
const launcher = document.getElementById('chimera-import-open');
const sidebarDonate = document.getElementById('sidebar-donate');
if (!launcher || !sidebarDonate) return false;
launcher.classList.add('chimera-import-launcher-docked');
sidebarDonate.after(launcher);
return true;
}
function ensureTrophyIllustration() {
const target = document.querySelector('.trophy-library-empty');
if (!target) return;
let illustration = target.querySelector('.premium-project-illustration');
if (!illustration) {
target.insertAdjacentHTML('afterbegin', illustrations.trophies);
illustration = target.querySelector('.premium-project-illustration');
}
bind(illustration);
}
for (const [id, variant] of Object.entries(targets)) {
const target = document.getElementById(id);
if (!target) continue;
const ensure = () => {
let illustration = target.querySelector('.premium-project-illustration');
if (!illustration) {
target.insertAdjacentHTML('afterbegin', illustrations[variant]);
illustration = target.querySelector('.premium-project-illustration');
}
bind(illustration);
};
ensure();
new MutationObserver(ensure).observe(target, { childList: true });
}
// Trophy content is rendered by a route module and the page node may be
// replaced while the feature is loading. Observe the stable content root so
// both a late render and a replacement page receive the illustration.
const trophyHost = document.getElementById('content') || document.body;
const touchesTrophyLibrary = mutation => {
const target = mutation.target?.nodeType === Node.ELEMENT_NODE ? mutation.target : mutation.target?.parentElement;
if (target?.matches?.('#page-trophies, #page-trophies *')) return true;
return [...mutation.addedNodes].some(node => node.nodeType === Node.ELEMENT_NODE && (
node.matches?.('#page-trophies, .trophy-library-empty')
|| node.querySelector?.('#page-trophies, .trophy-library-empty')
));
};
ensureTrophyIllustration();
new MutationObserver(mutations => {
if (mutations.some(touchesTrophyLibrary)) ensureTrophyIllustration();
}).observe(trophyHost, { childList: true, subtree: true });
bindHomeTopology();
if (!dockImportLauncher()) {
const importLauncherObserver = new MutationObserver(() => {
if (!dockImportLauncher()) return;
importLauncherObserver.disconnect();
});
importLauncherObserver.observe(document.body, { childList: true });
}
document.addEventListener('visibilitychange', syncAllMotion);
if (typeof reducedMotion.addEventListener === 'function') reducedMotion.addEventListener('change', syncAllMotion);
else reducedMotion.addListener(syncAllMotion);
})();
+103
View File
@@ -0,0 +1,103 @@
(function initProtectionModel(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.chimeraProtectionModel = api;
})(typeof window !== 'undefined' ? window : globalThis, function createProtectionModel() {
'use strict';
const list = value => Array.isArray(value) ? value : [];
const value = input => input == null || input === '' ? null : input;
const first = (...inputs) => inputs.map(value).find(item => item != null) ?? null;
const lower = input => String(input || '').trim().toLowerCase();
const missingStates = new Set(['missing', 'unavailable', 'disconnected', 'offline', 'not_mounted', 'absent']);
const failedIntegrityStates = new Set(['failed', 'corrupt', 'mismatch', 'invalid']);
function normalizeCopy(title, copy, index) {
const volume = copy.volume || {};
const device = copy.device || volume.device || copy.storageDevice || {};
const id = first(copy.id, copy.copyId, copy.copy_id);
const volumeId = first(copy.volumeId, copy.volume_id, volume.id);
const deviceId = first(copy.deviceId, copy.device_id, device.id, volume.deviceId, volume.device_id);
const deviceFingerprint = first(copy.deviceFingerprint, copy.stableFingerprint, device.stableFingerprint, device.stable_fingerprint);
const volumeKey = first(copy.stableVolumeKey, copy.stable_volume_key, volume.stableVolumeKey, volume.stable_volume_key);
const deviceKey = deviceId != null ? `device:${deviceId}` : deviceFingerprint ? `fingerprint:${deviceFingerprint}` : 'device:unknown';
const relativePath = first(copy.relativePath, copy.relative_path, copy.path, '');
const mountPath = first(copy.mountPath, copy.mount_path, volume.mountPath, volume.mount_path, '');
const fullPath = mountPath && relativePath
? `${String(mountPath).replace(/[\\/]+$/, '')}/${String(relativePath).replace(/^[\\/]+/, '')}`
: String(first(relativePath, mountPath, 'Unknown path'));
const availabilityState = lower(first(copy.availabilityState, copy.availability_state, volume.availabilityState, volume.availability_state, device.availabilityState, device.availability_state, 'unknown'));
const integrityState = lower(first(copy.integrityState, copy.integrity_state, 'unknown'));
const hashState = lower(first(copy.hashState, copy.hash_state, copy.inventoryHash || copy.inventory_hash ? 'recorded' : 'missing'));
const missing = missingStates.has(availabilityState);
const integrityFailed = failedIntegrityStates.has(integrityState);
return Object.freeze({
copyId: id != null ? String(id) : `unidentified-${index + 1}`,
titleId: String(first(title.game_id, title.titleId, title.title_id, title.id, 'unknown')),
titleName: String(first(title.prospero_name, title.name, title.title, title.game_id, 'Unknown title')),
region: String(first(title.prospero_region, title.region, '')),
deviceId: deviceId == null ? null : String(deviceId),
deviceKey,
deviceName: String(first(copy.deviceName, copy.displayName, device.displayName, device.display_name, deviceFingerprint, 'Unknown device')),
deviceFingerprint: deviceFingerprint == null ? null : String(deviceFingerprint),
volumeId: volumeId == null ? null : String(volumeId),
volumeKey: volumeKey == null ? null : String(volumeKey),
path: fullPath,
sizeBytes: Number(first(copy.sizeBytes, copy.size_bytes, 0)) || 0,
hash: first(copy.inventoryHash, copy.inventory_hash),
hashAlgorithm: String(first(copy.hashAlgorithm, copy.hash_algorithm, 'sha256')),
hashState,
integrityState,
lastVerifiedAt: first(copy.lastVerifiedAt, copy.last_verified_at),
lastSeenAt: first(copy.lastSeenAt, copy.last_seen_at),
availabilityState,
missing,
integrityFailed,
restoreTarget: Object.freeze({
copyId: id != null ? String(id) : `unidentified-${index + 1}`,
titleId: String(first(title.game_id, title.titleId, title.title_id, title.id, 'unknown')),
volumeId: volumeId == null ? null : String(volumeId),
deviceId: deviceId == null ? null : String(deviceId),
path: fullPath
})
});
}
function evaluate(copies, requiredDistinctDevices = 2) {
const concrete = list(copies);
const healthyCopies = concrete.filter(copy => !copy.missing && !copy.integrityFailed);
const knownDevices = new Set(healthyCopies.map(copy => copy.deviceKey).filter(key => key !== 'device:unknown'));
const unknownDeviceCopies = healthyCopies.filter(copy => copy.deviceKey === 'device:unknown').length;
const distinctDevices = knownDevices.size + (unknownDeviceCopies ? 1 : 0);
const required = Math.max(1, Number(requiredDistinctDevices) || 2);
const missingCopies = concrete.filter(copy => copy.missing).length;
const failedCopies = concrete.filter(copy => copy.integrityFailed).length;
const healthy = distinctDevices >= required;
const sameDeviceExcess = Math.max(0, healthyCopies.length - distinctDevices);
const status = healthy ? 'healthy' : healthyCopies.length ? 'needs-diversity' : concrete.length ? 'degraded' : 'unprotected';
return Object.freeze({
copyCount: concrete.length,
healthyCopyCount: healthyCopies.length,
distinctDeviceCount: distinctDevices,
requiredDistinctDevices: required,
missingCopyCount: missingCopies,
failedCopyCount: failedCopies,
sameDeviceExcess,
healthy,
status
});
}
function flatten(titles, options = {}) {
const result = [];
for (const title of list(titles)) {
const copies = list(title.localCopies).map((copy, index) => normalizeCopy(title, copy, index));
const policy = evaluate(copies, options.requiredDistinctDevices);
copies.forEach(copy => result.push(Object.freeze({ ...copy, policy })));
}
return result;
}
return Object.freeze({ normalizeCopy, evaluate, flatten });
});
+268
View File
@@ -0,0 +1,268 @@
// ── PS Notify page ────────────────────────────────────────────────────────────
let _psnotifyConfigured = false;
function applyPsNotifyAvailability(configured, detail = '') {
_psnotifyConfigured = configured;
['btn-psn-send', 'btn-psn-test', 'btn-psn-push-payload'].forEach(id => {
const button = $(id);
if (!button) return;
button.disabled = !configured;
button.title = configured ? '' : 'Configure a PS5 host in Settings before using this action.';
});
document.querySelectorAll('#page-psnotify [id^="btn-psn-exp-"]').forEach(button => {
button.disabled = !configured;
button.title = configured ? '' : 'Configure a PS5 host in Settings before using this action.';
});
document.querySelectorAll('.psnotify-compose-card, .psnotify-payload-card').forEach(card => {
card.classList.toggle('is-unavailable', !configured);
});
const status = $('psn-status');
if (!configured && status && !status.textContent) {
status.textContent = detail || 'Configure a PS5 host in Settings to enable console actions.';
}
}
function enhancePsNotifyLayout() {
const page = $('page-psnotify');
if (!page || page.dataset.enhanced) return;
page.dataset.enhanced = 'true';
const header = page.querySelector(':scope > .page-header');
header?.insertAdjacentHTML('beforeend', '<p class="page-subtitle psnotify-page-subtitle">Compose, test and review PS5 notifications from one focused workspace.</p><span id="psn-page-readiness" class="operations-status operations-status-unknown">Not checked</span>');
const cards = [...page.querySelectorAll(':scope > .card')];
const [compose, settings, payload, lab, history] = cards;
if (!compose || !settings || !payload || !lab || !history) return;
compose.classList.add('psnotify-compose-card');
settings.classList.add('psnotify-settings-card');
payload.classList.add('psnotify-payload-card');
lab.classList.add('psnotify-lab', 'collapsed');
history.classList.add('psnotify-history-card');
compose.querySelector('.card-header h3').textContent = 'Compose notification';
compose.querySelector('.hint').textContent = 'Sent to the PS5 host from your active console profile.';
compose.querySelector('.form-grid')?.insertAdjacentHTML('beforebegin', `
<div class="psnotify-templates" aria-label="Notification templates">
<button class="btn btn-xs" data-psn-template="complete">Build complete</button>
<button class="btn btn-xs" data-psn-template="backup">Backup complete</button>
<button class="btn btn-xs" data-psn-template="update">Update ready</button>
<button class="btn btn-xs" data-psn-template="warning">Attention needed</button>
</div>`);
compose.querySelector('.form-grid')?.insertAdjacentHTML('afterend', `
<div class="psnotify-preview" aria-live="polite">
<span>PS5 preview</span>
<strong id="psn-preview-message">Your notification appears here</strong>
<small id="psn-preview-submessage">Optional supporting detail</small>
</div>`);
$('psn-message')?.insertAdjacentHTML('afterend', '<span id="psn-message-count" class="psnotify-counter">0/120</span>');
$('psn-submessage')?.insertAdjacentHTML('afterend', '<span id="psn-submessage-count" class="psnotify-counter">0/120</span>');
settings.querySelector('.card-header h3').textContent = 'Service connection';
payload.querySelector('.card-header h3').textContent = 'Notification payload';
history.querySelector('.card-header h3').textContent = 'Notification history';
lab.querySelector('.card-header')?.insertAdjacentHTML('beforeend', '<button id="psn-lab-toggle" class="btn btn-sm" type="button" aria-expanded="false">Show advanced lab</button>');
const grid = document.createElement('div');
grid.className = 'psnotify-grid';
compose.insertAdjacentElement('beforebegin', grid);
grid.append(compose);
const side = document.createElement('div');
side.className = 'psnotify-side';
side.append(settings, payload);
grid.append(side);
history.insertAdjacentElement('afterend', lab);
$('psn-lab-toggle')?.addEventListener('click', event => {
const expanded = event.currentTarget.getAttribute('aria-expanded') === 'true';
event.currentTarget.setAttribute('aria-expanded', String(!expanded));
event.currentTarget.textContent = expanded ? 'Show advanced lab' : 'Hide advanced lab';
lab.classList.toggle('collapsed', expanded);
});
const templates = {
complete: ['Build complete', 'The latest Chimera task finished successfully.'],
backup: ['Backup complete', 'Your PS5 data has been verified and stored.'],
update: ['Update ready', 'A reviewed update is ready in Chimera.'],
warning: ['Attention needed', 'Open Chimera for details and the recommended next step.'],
};
compose.querySelector('.psnotify-templates')?.addEventListener('click', event => {
const button = event.target.closest('[data-psn-template]');
if (!button) return;
[$('psn-message').value, $('psn-submessage').value] = templates[button.dataset.psnTemplate];
updatePsNotifyPreview();
$('psn-message').focus();
});
}
function updatePsNotifyPreview() {
const message = $('psn-message')?.value || '';
const submessage = $('psn-submessage')?.value || '';
if ($('psn-message-count')) $('psn-message-count').textContent = `${message.length}/120`;
if ($('psn-submessage-count')) $('psn-submessage-count').textContent = `${submessage.length}/120`;
if ($('psn-preview-message')) $('psn-preview-message').textContent = message || 'Your notification appears here';
if ($('psn-preview-submessage')) $('psn-preview-submessage').textContent = submessage || 'Optional supporting detail';
}
async function loadPsNotify() {
enhancePsNotifyLayout();
// Populate settings fields from store
try {
const s = await window.pork.getSettings();
$('psn-enabled').checked = s.psnotifyEnabled !== false;
$('psn-port').value = s.psnotifyPort || 6969;
const readiness = $('psn-page-readiness');
if (readiness) {
readiness.textContent = s.ftpHost ? `Configured · ${s.ftpHost}:${s.psnotifyPort || 6969}` : 'PS5 host not configured';
readiness.className = `operations-status ${s.ftpHost ? 'operations-status-compatible' : 'operations-status-warning'}`;
}
applyPsNotifyAvailability(Boolean(s.ftpHost));
} catch (_) {
applyPsNotifyAvailability(false, 'Console configuration could not be loaded.');
}
updatePsNotifyPreview();
await renderPsnHistory();
}
$('psn-message').addEventListener('input', updatePsNotifyPreview);
$('psn-submessage').addEventListener('input', updatePsNotifyPreview);
$('psn-message').addEventListener('keydown', event => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') $('btn-psn-send').click();
});
async function renderPsnHistory() {
const hist = await window.pork.psnotifyHistory();
const list = $('psn-history');
const empty = $('psn-history-empty');
if (!hist.length) {
list.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
list.innerHTML = hist.slice().reverse().map(e => {
const cls = e.ok !== false ? 'psn-ok' : 'psn-fail';
const ts = new Date(e.ts).toLocaleTimeString();
const sub = e.subMessage ? `<span class="psn-entry-sub">\u2014 ${escHtml(e.subMessage)}</span>` : '';
return `<div class="psn-entry ${cls}">
<span class="psn-entry-ts">${ts}</span>
<span class="psn-entry-msg">${escHtml(e.message)}</span>${sub}
</div>`;
}).join('');
}
$('btn-psn-send').addEventListener('click', async () => {
if (!_psnotifyConfigured) return;
const msg = $('psn-message').value.trim();
const sub = $('psn-submessage').value.trim();
if (!msg) { $('psn-status').textContent = 'Message is required.'; return; }
const btn = $('btn-psn-send');
btn.disabled = true;
$('psn-status').textContent = 'Sending\u2026';
try {
await window.pork.psnotifySend(msg, sub);
$('psn-status').textContent = 'Sent \u2713';
$('psn-message').value = '';
$('psn-submessage').value = '';
updatePsNotifyPreview();
await renderPsnHistory();
} catch (e) {
$('psn-status').textContent = 'Error: ' + e.message;
}
btn.disabled = !_psnotifyConfigured;
});
$('btn-psn-test').addEventListener('click', async () => {
if (!_psnotifyConfigured) return;
const btn = $('btn-psn-test');
btn.disabled = true;
$('psn-status').textContent = 'Sending test\u2026';
try {
await window.pork.psnotifyTest();
$('psn-status').textContent = 'Test sent \u2713';
if ($('psn-page-readiness')) {
$('psn-page-readiness').textContent = 'Online';
$('psn-page-readiness').className = 'operations-status operations-status-compatible';
}
await renderPsnHistory();
} catch (e) {
$('psn-status').textContent = 'Error: ' + e.message;
if ($('psn-page-readiness')) {
$('psn-page-readiness').textContent = 'Offline';
$('psn-page-readiness').className = 'operations-status operations-status-warning';
}
}
btn.disabled = !_psnotifyConfigured;
});
$('btn-psn-save-settings').addEventListener('click', async () => {
const port = parseInt($('psn-port').value) || 6969;
await window.pork.setSettings({
psnotifyEnabled: $('psn-enabled').checked,
psnotifyPort: port,
});
$('psn-port').value = port;
$('psn-status').textContent = 'Settings saved \u2713';
});
$('btn-psn-refresh-history').addEventListener('click', renderPsnHistory);
// ── PS Notify colour/encoding experiments ────────────────────────────────────────
async function _sendExp(msg, sub = '') {
const el = $('psn-exp-status');
if (!_psnotifyConfigured) {
el.textContent = 'Configure a PS5 host in Settings first.';
return;
}
el.textContent = 'Sending…';
try {
await window.pork.psnotifySend(msg, sub);
el.textContent = 'Sent \u2713 — check your PS5';
await renderPsnHistory();
} catch (e) {
el.textContent = 'Error: ' + e.message;
}
}
$('btn-psn-push-payload').addEventListener('click', async () => {
if (!_psnotifyConfigured) return;
const btn = $('btn-psn-push-payload');
const st = $('psn-push-status');
btn.disabled = true;
st.textContent = 'Queuing upload\u2026';
try {
const res = await window.pork.psnotifyPushPayload();
st.textContent = `Queued \u2713 \u2014 sending to ${res.remotePath}. Reload the payload on your PS5 then emoji will work.`;
} catch (e) {
st.textContent = 'Error: ' + e.message;
}
btn.disabled = !_psnotifyConfigured;
});
// HTML markup
$('btn-psn-exp-html-red').addEventListener('click', () =>
_sendExp('<font color="red">This is red text</font>', 'HTML <font color> test'));
$('btn-psn-exp-html-multi').addEventListener('click', () =>
_sendExp('<font color="red">Red</font> <font color="green">Green</font> <font color="blue">Blue</font>', 'HTML multi-colour test'));
$('btn-psn-exp-sony-tag').addEventListener('click', () =>
_sendExp('$[color=ff0000]Red$[/color] $[color=00ff00]Green$[/color]', 'Sony $[color] tag test'));
// Emoji colour squares (4-byte UTF-8 — need TextDecoder patch in customPSNotify.js to arrive intact)
$('btn-psn-exp-sq-red').addEventListener('click', () =>
_sendExp('\uD83D\uDFE5 Red notification', 'Emoji \uD83D\uDFE5 (U+1F7E5, 4-byte UTF-8)'));
$('btn-psn-exp-sq-green').addEventListener('click', () =>
_sendExp('\uD83D\uDFE9 Green notification', 'Emoji \uD83D\uDFE9 (U+1F7E9, 4-byte UTF-8)'));
$('btn-psn-exp-sq-blue').addEventListener('click', () =>
_sendExp('\uD83D\uDFE6 Blue notification', 'Emoji \uD83D\uDFE6 (U+1F7E6, 4-byte UTF-8)'));
$('btn-psn-exp-sq-yellow').addEventListener('click', () =>
_sendExp('\uD83D\uDFE8 Yellow notification', 'Emoji \uD83D\uDFE8 (U+1F7E8, 4-byte UTF-8)'));
$('btn-psn-exp-sq-all').addEventListener('click', () =>
_sendExp('\uD83D\uDFE5\uD83D\uDFE7\uD83D\uDFE8\uD83D\uDFE9\uD83D\uDFE6\uD83D\uDFEA Colour squares test', 'All 6 colour squares'));
// ANSI escape codes
$('btn-psn-exp-ansi-red').addEventListener('click', () =>
_sendExp('\x1b[31mRed ANSI text\x1b[0m', 'ANSI \\x1b[31m colour test'));
$('btn-psn-exp-ansi-bold').addEventListener('click', () =>
_sendExp('\x1b[1mBold ANSI text\x1b[0m', 'ANSI \\x1b[1m bold test'));
+5
View File
@@ -0,0 +1,5 @@
'use strict';
if ('serviceWorker' in navigator && window.isSecureContext && window.pork?.isWebMode) {
window.addEventListener('load', () => navigator.serviceWorker.register('/service-worker.js', { scope: '/' }).catch(error => console.warn('[Chimera PWA]', error.message)));
}
+561
View File
@@ -0,0 +1,561 @@
// ── Local Save Manager (native GarlicMgr UI) ────────────────────────────────
// ── State ───────────────────────────────────────────────────────────────────
const smState = {
saves: [], selectedIdx: -1, mounted: false,
files: [], expanded: new Set(),
busy: false, tab: 'browse',
};
let _smHasSuccessfulWarm = false;
// ── Terminal log ─────────────────────────────────────────────────────────────
function smLog(msg, cls) {
const logEl = $('sm-log');
if (!logEl) return;
const t = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = 'sm-ll' + (cls ? ' sm-' + cls : '');
div.innerHTML = `<span class="sm-lt">${t}</span><span>${escHtml(msg)}</span>`;
logEl.appendChild(div);
logEl.scrollTop = logEl.scrollHeight;
}
// ── Confirmation modal (native browser dialogs are blocked) ─────────────────
function smConfirm(message) {
return new Promise(resolve => {
const bg = document.createElement('div');
bg.style.cssText = 'position:fixed;inset:0;z-index:9900;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;';
bg.setAttribute('aria-hidden', 'true');
bg.innerHTML = `<div role="dialog" aria-modal="true" aria-labelledby="smconf-message" tabindex="-1" style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px 28px;max-width:360px;width:90%;">
<p id="smconf-message" style="font-size:14px;color:var(--text);margin-bottom:20px;line-height:1.5;">${escHtml(message)}</p>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="btn btn-sm" id="smconf-skip">Skip</button>
<button class="btn btn-sm btn-accent" id="smconf-ok">Replace</button>
</div></div>`;
document.body.appendChild(bg);
const cleanup = result => {
closeAccessibleDialog(bg);
bg.remove();
resolve(result);
};
bg.querySelector('#smconf-skip').addEventListener('click', () => cleanup(false));
bg.querySelector('#smconf-ok').addEventListener('click', () => cleanup(true));
bg.addEventListener('keydown', event => { if (event.key === 'Escape') cleanup(false); });
openAccessibleDialog(bg, bg.querySelector('#smconf-skip'));
});
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function smFmtBytes(b) {
if (!b) return '0B';
if (b < 1024) return b + 'B';
if (b < 1048576) return (b / 1024).toFixed(1) + 'K';
return (b / 1048576).toFixed(1) + 'M';
}
function smSetBusy(v) {
smState.busy = v;
['btn-sm-dl-zip','btn-sm-dl-enc','btn-sm-dump-usb','btn-sm-upload','btn-sm-unmount']
.forEach(id => { const el = $(id); if (el) el.disabled = v; });
}
function smShowUsbProgress(pct) {
const bar = $('sm-usb-progress'); if (bar) bar.hidden = false;
const fill = $('sm-usb-fill'); if (fill) fill.style.width = pct + '%';
}
function smHideUsbProgress() { const bar = $('sm-usb-progress'); if (bar) bar.hidden = true; }
function smShowUploadProgress(pct, loaded, total) {
const bar = $('sm-upload-progress'); if (bar) bar.hidden = false;
const fill = $('sm-upload-fill'); if (fill) fill.style.width = pct + '%';
const txt = $('sm-upload-text'); if (txt) txt.textContent = `${smFmtBytes(loaded)} / ${smFmtBytes(total)} (${pct}%)`;
}
function smHideUploadProgress() { const bar = $('sm-upload-progress'); if (bar) bar.hidden = true; }
// ── Load / reload saves ──────────────────────────────────────────────────────
async function loadSaveMgr(options = {}) {
const background = !!options.background;
const s = await window.pork.getSettings();
const ip = s.ftpHost || '';
const port = s.savemgrPort || 8082;
$('sm-ip-display').textContent = ip || '(not set — configure FTP Settings)';
$('sm-port').value = port;
const ssp = $('s-savemgr-port'); if (ssp) ssp.value = port;
if (ip && (!background || _smHasSuccessfulWarm)) await smFetchSaves();
}
async function smFetchSaves() {
try {
const d = await window.pork.savemgrRequest('/api/saves');
smState.saves = d.saves || [];
_smHasSuccessfulWarm = true;
smRenderSaves();
smLog(`Loaded ${smState.saves.length} saves`, 'ok');
} catch (e) {
smLog('Failed to load saves: ' + e.message, 'err');
smLog('Start the session only when needed with “Start session”.');
smState.saves = [];
smRenderSaves();
}
}
function smRenderSaves() {
const el = $('sm-saves-list');
if (!smState.saves.length) {
el.innerHTML = '<div class="sm-empty-list">No saves found</div>';
return;
}
el.innerHTML = smState.saves.map((s, i) =>
`<div class="sm-save-item${i === smState.selectedIdx ? ' active' : ''}" data-idx="${i}">
<div class="sm-save-tid">${escHtml(s.title_id)}</div>
<div class="sm-save-name">${escHtml(s.save_name)}</div>
</div>`
).join('');
el.querySelectorAll('.sm-save-item').forEach(item =>
item.addEventListener('click', () => smSelectSave(parseInt(item.dataset.idx)))
);
}
async function smSelectSave(idx) {
if (smState.busy || smState.tab !== 'browse') return;
smState.selectedIdx = idx;
smRenderSaves();
await smMount(idx);
}
async function smMount(idx) {
if (smState.busy) return;
smSetBusy(true);
const s = smState.saves[idx];
smLog(`Mounting ${s.title_id}/${s.save_name}...`);
try {
const d = await window.pork.savemgrRequest(`/api/mount?idx=${idx}`);
if (d.error) { smLog('Mount failed: ' + d.error, 'err'); smSetBusy(false); return; }
smState.mounted = true;
smState.files = d.files || [];
smState.expanded = new Set(smState.files.filter(f => f.dir).map(f => f.name));
$('sm-save-title-text').textContent = d.save_title || 'Untitled';
$('sm-save-title-id').innerHTML = d.title_id ? `Title ID: <b>${escHtml(d.title_id)}</b>` : '';
$('sm-save-account-id').innerHTML = d.account_id ? `&nbsp;&nbsp;Account: <b style="font-family:monospace">${escHtml(d.account_id)}</b>` : '';
$('sm-save-ftp-row').textContent = d.mount ? `FTP: ${d.mount}` : '';
$('sm-save-header').hidden = false;
$('sm-empty-state').hidden = true;
smLoadIcon();
smRenderFileTree();
smLog(`Mounted ${[d.title_id, d.save_title, d.account_id ? '['+d.account_id+']' : ''].filter(Boolean).join(' ')}`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
async function smLoadIcon() {
try {
const dataUrl = await window.pork.savemgrIcon();
const img = $('sm-game-icon');
if (dataUrl) { img.src = dataUrl; img.hidden = false; }
else img.hidden = true;
} catch (_) { $('sm-game-icon').hidden = true; }
}
// ── File tree ────────────────────────────────────────────────────────────────
function smRenderFileTree() {
const tree = $('sm-filetree');
tree.hidden = false;
let h = '';
smState.files.forEach((f, i) => {
const parts = f.name.split('/'); parts.pop();
let visible = true, pp = '';
for (let j = 0; j < parts.length; j++) {
pp = j ? pp + '/' + parts[j] : parts[j];
if (!smState.expanded.has(pp)) { visible = false; break; }
}
if (!visible) return;
const depth = f.name.split('/').length - 1;
const name = f.name.split('/').pop();
const pad = 12 + depth * 16;
if (f.dir) {
const open = smState.expanded.has(f.name);
h += `<div class="sm-fi sm-fi-dir" style="padding-left:${pad}px" data-fidx="${i}">
<span class="sm-fi-arrow">${open ? '▾' : '▸'}</span> 📁 ${escHtml(name)}</div>`;
} else {
const fname = escHtml(f.name).replace(/'/g, "\\'");
h += `<div class="sm-fi" style="padding-left:${pad}px" data-fname="${escHtml(f.name)}">
📄 ${escHtml(name)}<span class="sm-fi-sz">${smFmtBytes(f.size)}</span></div>`;
}
});
h += '<div class="sm-tip">Tip: drag &amp; drop files here to add or replace them</div>';
tree.innerHTML = h;
tree.querySelectorAll('.sm-fi-dir').forEach(el =>
el.addEventListener('click', () => {
const name = smState.files[parseInt(el.dataset.fidx)].name;
if (smState.expanded.has(name)) smState.expanded.delete(name);
else smState.expanded.add(name);
smRenderFileTree();
})
);
tree.querySelectorAll('.sm-fi:not(.sm-fi-dir)').forEach(el =>
el.addEventListener('contextmenu', e => { e.preventDefault(); smShowCtxMenu(e, el.dataset.fname); })
);
smSetupDropZone(tree, smHandleFileDrop);
}
// ── Context menu ─────────────────────────────────────────────────────────────
document.addEventListener('click', () => { const m = $('sm-ctx-menu'); if (m) m.remove(); });
function smShowCtxMenu(e, fname) {
const old = $('sm-ctx-menu'); if (old) old.remove();
const m = document.createElement('div');
m.className = 'sm-ctx'; m.id = 'sm-ctx-menu';
m.innerHTML = `<div data-action="download">Download</div>
<div data-action="upload">Upload File</div>
<div class="sm-ctx-del" data-action="delete">Delete</div>`;
m.style.left = e.clientX + 'px'; m.style.top = e.clientY + 'px';
document.body.appendChild(m);
const r = m.getBoundingClientRect();
if (r.right > window.innerWidth) m.style.left = (window.innerWidth - r.width - 4) + 'px';
if (r.bottom > window.innerHeight) m.style.top = (window.innerHeight - r.height - 4) + 'px';
m.querySelectorAll('[data-action]').forEach(item => item.addEventListener('click', () => {
m.remove();
if (item.dataset.action === 'download') smDownloadFile(fname);
else if (item.dataset.action === 'upload') smPickUploadFile();
else if (item.dataset.action === 'delete') smDeleteFile(fname);
}));
}
// ── File operations ──────────────────────────────────────────────────────────
async function smDownloadFile(fname) {
smLog(`Downloading ${fname}...`);
try {
await window.pork.savemgrDownload(`/api/download_file?name=${encodeURIComponent(fname)}`, fname.split('/').pop());
smLog(`Downloaded ${fname.split('/').pop()}`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
}
function smPickUploadFile() {
const input = document.createElement('input');
input.type = 'file'; input.multiple = true;
input.onchange = () => { if (input.files.length) smHandleFileDrop(Array.from(input.files)); };
input.click();
}
async function smDeleteFile(fname) {
const ok = await smConfirm(`Delete ${fname}?`);
if (!ok) return;
try {
const d = await window.pork.savemgrRequest(`/api/delete_file?name=${encodeURIComponent(fname)}`);
if (d.error) throw new Error(d.error);
smLog(`Deleted ${fname}`, 'ok');
await smRefreshFiles();
} catch (e) { smLog('Error: ' + e.message, 'err'); }
}
async function smRefreshFiles() {
const d = await window.pork.savemgrRequest('/api/files');
smState.files = d.files || [];
smState.expanded = new Set(smState.files.filter(f => f.dir).map(f => f.name));
smRenderFileTree();
}
async function smHandleFileDrop(files) {
if (smState.busy || !smState.mounted) return;
smSetBusy(true);
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.name.endsWith('.zip')) {
smLog(`Extracting ${file.name} (${smFmtBytes(file.size)})...`);
const buf = await file.arrayBuffer();
const d = await window.pork.savemgrUpload('/api/upload', buf);
smLog(`Extracted ${d.files} files`, 'ok');
} else {
const ce = await window.pork.savemgrRequest(`/api/file_exists?name=${encodeURIComponent(file.name)}`);
if (ce.exists) {
const replace = await smConfirm(`${file.name} already exists. Replace it?`);
if (!replace) { smLog(`Skipped ${file.name}`); continue; }
}
smLog(`Uploading ${file.name} (${smFmtBytes(file.size)})...`);
const buf = await file.arrayBuffer();
const d = await window.pork.savemgrUpload(`/api/upload_file?name=${encodeURIComponent(file.name)}`, buf);
smHideUploadProgress();
if (d.ok) smLog(`Added ${file.name}`, 'ok');
else smLog(d.error || 'Upload failed', 'err');
}
}
await smRefreshFiles();
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
smHideUploadProgress();
}
// ── Browse actions ────────────────────────────────────────────────────────────
async function smDownloadZip() {
if (smState.busy) return; smSetBusy(true);
const s = smState.saves[smState.selectedIdx];
const filename = s ? `${s.title_id}_${s.save_name}.zip` : 'save.zip';
smLog('Downloading decrypted zip...');
try {
await window.pork.savemgrDownload('/api/download', filename);
smLog(`Downloaded ${filename}`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
async function smDownloadEncrypted() {
if (smState.busy || smState.selectedIdx < 0) return; smSetBusy(true);
const s = smState.saves[smState.selectedIdx];
smLog('Unmounting and downloading encrypted save...');
try {
await window.pork.savemgrRequest('/api/unmount');
smState.mounted = false;
await window.pork.savemgrDownload(`/api/download_raw?idx=${smState.selectedIdx}`, `${s.title_id}_${s.save_name}`);
smState.selectedIdx = -1; smState.files = []; smState.expanded = new Set();
smRenderSaves();
$('sm-save-header').hidden = true; $('sm-filetree').hidden = true; $('sm-empty-state').hidden = false;
smLog('Downloaded encrypted save', 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
async function smDumpToUsb() {
if (smState.busy || smState.selectedIdx < 0) return; smSetBusy(true);
smLog('Dumping to USB...');
window.pork.on('savemgr:usb-progress', data => { smShowUsbProgress(data.progress); });
try {
const result = await window.pork.savemgrDumpUsb(smState.selectedIdx);
window.pork.off('savemgr:usb-progress');
smHideUsbProgress();
smLog(result.path ? `Dumped to ${result.path} (${smFmtBytes(result.size)})` : 'Dump complete', 'ok');
} catch (e) { window.pork.off('savemgr:usb-progress'); smHideUsbProgress(); smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
async function smUnmount() {
if (smState.busy) return; smSetBusy(true);
try {
await window.pork.savemgrRequest('/api/unmount');
smState.mounted = false; smState.selectedIdx = -1; smState.files = []; smState.expanded = new Set();
smRenderSaves();
$('sm-save-header').hidden = true; $('sm-filetree').hidden = true; $('sm-empty-state').hidden = false;
smLog('Unmounted', 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
// ── Sub-tab switching ─────────────────────────────────────────────────────────
function smSwitchTab(tab) {
smState.tab = tab;
document.querySelectorAll('.sm-tab').forEach(btn =>
btn.classList.toggle('active', btn.dataset.smTab === tab)
);
document.querySelectorAll('.sm-pane').forEach(pane =>
pane.hidden = (pane.id !== `sm-pane-${tab}`)
);
if (tab === 'browse') smFetchSaves();
}
// ── Decrypt drop ──────────────────────────────────────────────────────────────
async function smHandleDecryptDrop(files) {
const file = files[0]; if (!file || smState.busy) return;
smSetBusy(true);
smLog(`Uploading ${file.name} (${smFmtBytes(file.size)}) for decrypt...`);
try {
const buf = await file.arrayBuffer();
const filename = file.name.replace(/\.[^.]+$/, '') + '.zip';
const r = await window.pork.savemgrDecrypt(buf, filename);
if (r.canceled) smLog('Cancelled');
else smLog(`Decrypted → ${filename} (${smFmtBytes(r.size)})`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
// ── Encrypt drop ──────────────────────────────────────────────────────────────
async function smHandleEncryptDrop(files, folderName) {
if (!files.length || smState.busy) return;
const aid = $('sm-enc-aid').value.trim();
smSetBusy(true);
let totalSize = 0; for (const f of files) totalSize += f.size;
smLog(`Creating PFS image (${smFmtBytes(totalSize)} of files)...`);
try {
const pfs = await window.pork.savemgrCreatePfs(totalSize);
if (pfs.error) throw new Error(pfs.error);
smLog('PFS image created', 'ok');
for (let i = 0; i < files.length; i++) {
const f = files[i];
const raw = f._path || f.webkitRelativePath || f.name;
const parts = raw.split('/');
const relPath = parts.length > 1 ? parts.slice(1).join('/') : parts[0];
smLog(`Uploading (${i+1}/${files.length}) ${relPath}...`);
const buf = await f.arrayBuffer();
const d = await window.pork.savemgrUpload(`/api/upload_file?name=${encodeURIComponent(relPath)}`, buf);
if (!d.ok) throw new Error(d.error || `Upload failed: ${relPath}`);
}
smLog(`All ${files.length} files uploaded`, 'ok');
const name = folderName || 'encrypted_save';
smLog('Downloading encrypted save...');
const r = await window.pork.savemgrDownloadNew(name, aid);
if (r.canceled) smLog('Cancelled');
else smLog(`Encrypted save downloaded (${smFmtBytes(r.size)})`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
// ── Resign drop ───────────────────────────────────────────────────────────────
async function smHandleResignDrop(files) {
const file = files[0]; if (!file || smState.busy) return;
const aid = $('sm-resign-aid').value.trim();
if (!aid) { smLog('Enter new Account ID first', 'err'); return; }
smSetBusy(true);
smLog(`Uploading ${file.name} for resign...`);
try {
const buf = await file.arrayBuffer();
const filename = file.name.replace(/\.[^.]+$/, '') + '_resigned';
const r = await window.pork.savemgrResign(buf, aid, filename);
if (r.canceled) smLog('Cancelled');
else smLog(`Resigned → ${filename} (${smFmtBytes(r.size)})`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
// ── Drop zone setup ───────────────────────────────────────────────────────────
function smSetupDropZone(el, handler, folderMode) {
el.ondragover = e => { e.preventDefault(); el.classList.add('sm-drag-over'); };
el.ondragleave = e => { e.preventDefault(); el.classList.remove('sm-drag-over'); };
el.ondrop = async e => {
e.preventDefault(); el.classList.remove('sm-drag-over');
if (folderMode) {
const items = e.dataTransfer.items, entries = [];
for (let i = 0; i < items.length; i++) {
const ent = items[i].webkitGetAsEntry?.(); if (ent) entries.push(ent);
}
if (entries.length && entries[0].isDirectory) {
const files = await smReadFolderEntries(entries[0], '');
handler(files, entries[0].name);
} else if (e.dataTransfer.files.length) handler(Array.from(e.dataTransfer.files));
} else {
if (e.dataTransfer.files.length) handler(Array.from(e.dataTransfer.files));
}
};
const btn = el.querySelector('.sm-browse-btn');
if (btn) btn.onclick = e => {
e.stopPropagation();
const input = document.createElement('input'); input.type = 'file';
if (folderMode) input.webkitdirectory = true;
input.onchange = () => {
if (!input.files.length) return;
if (folderMode) { const prefix = input.files[0].webkitRelativePath.split('/')[0]; handler(Array.from(input.files), prefix); }
else handler(Array.from(input.files));
};
input.click();
};
}
async function smReadFolderEntries(entry, basePath) {
return new Promise(resolve => {
if (entry.isFile) { entry.file(f => { f._path = basePath + f.name; resolve([f]); }); }
else if (entry.isDirectory) {
const reader = entry.createReader(), all = [];
const readBatch = () => reader.readEntries(entries => {
if (!entries.length) { Promise.all(all).then(arrs => resolve(arrs.flat())); return; }
for (const e of entries) all.push(smReadFolderEntries(e, basePath + entry.name + '/'));
readBatch();
});
readBatch();
} else resolve([]);
});
}
// ── Event wiring (run once on startup) ───────────────────────────────────────
(function smInitEvents() {
document.querySelectorAll('.sm-tab').forEach(btn =>
btn.addEventListener('click', () => smSwitchTab(btn.dataset.smTab))
);
$('btn-sm-start-session').addEventListener('click', async event => {
const button = event.currentTarget;
const s = await window.pork.getSettings();
const ip = s.ftpHost || '';
const port = parseInt($('sm-port').value) || 8082;
if (!ip) { showToast('PS5 IP not set. Configure FTP Settings first.', 'error'); return; }
button.disabled = true;
await window.pork.setSettings({ savemgrPort: port });
smLog('Starting Garlic Save Manager explicitly through PS5 Payload Manager…');
try {
await window.pork.savemgrStart();
smLog('Garlic Save Manager started', 'ok');
showToast('Save Manager session started.', 'ok');
await smFetchSaves();
} catch (error) {
smLog('Start failed: ' + error.message, 'err');
showToast(`Save Manager failed to start: ${error.message}`, 'error');
} finally {
button.disabled = false;
}
});
$('btn-sm-load').addEventListener('click', async () => {
const s = await window.pork.getSettings();
const ip = s.ftpHost || '';
const port = parseInt($('sm-port').value) || 8082;
if (!ip) { showToast('PS5 IP not set. Configure FTP Settings first.', 'error'); return; }
await window.pork.setSettings({ savemgrPort: port });
smFetchSaves();
});
$('btn-sm-save').addEventListener('click', async () => {
const port = parseInt($('sm-port').value) || 8082;
await window.pork.setSettings({ savemgrPort: port });
const ssp = $('s-savemgr-port'); if (ssp) ssp.value = port;
showToast('Save Manager port saved.', 'ok');
});
$('btn-sm-open-ext').addEventListener('click', async () => {
const s = await window.pork.getSettings();
const ip = s.ftpHost || ''; const port = parseInt($('sm-port').value) || 8082;
if (!ip) { showToast('PS5 IP not set.', 'error'); return; }
window.pork.openShell(`http://${ip}:${port}`);
});
$('btn-sm-dl-zip').addEventListener('click', smDownloadZip);
$('btn-sm-dl-enc').addEventListener('click', smDownloadEncrypted);
$('btn-sm-dump-usb').addEventListener('click', smDumpToUsb);
$('btn-sm-unmount').addEventListener('click', smUnmount);
$('btn-sm-upload').addEventListener('click', smPickUploadFile);
smSetupDropZone($('sm-decrypt-drop'), smHandleDecryptDrop, false);
smSetupDropZone($('sm-encrypt-drop'), (f, n) => smHandleEncryptDrop(f, n), true);
smSetupDropZone($('sm-resign-drop'), smHandleResignDrop, false);
// Drag-drop onto the right panel (file browser)
const rp = $('sm-right-pane');
rp.ondragover = e => { e.preventDefault(); if (smState.mounted) rp.classList.add('sm-drag-over'); };
rp.ondragleave = e => { e.preventDefault(); rp.classList.remove('sm-drag-over'); };
rp.ondrop = e => {
e.preventDefault(); rp.classList.remove('sm-drag-over');
if (smState.mounted && e.dataTransfer.files.length) smHandleFileDrop(Array.from(e.dataTransfer.files));
};
$('btn-sm-push-payload').addEventListener('click', async () => {
const confirmation = 'INSTALLEER GARLIC AUTOLOAD';
const entered = await showPrompt(
`Deze actie schrijft garlic-savemgr.elf en autoload.txt naar je PS5. Typ exact ${confirmation} om door te gaan:`,
''
);
if (entered !== confirmation) {
showToast('Installation cancelled: the confirmation phrase did not match exactly.', 'info');
return;
}
const btn = $('btn-sm-push-payload'), st = $('sm-push-status');
btn.disabled = true; st.textContent = 'Installing\u2026';
try {
const res = await window.pork.savemgrPushPayload({ confirmation });
st.textContent = res.appended
? 'Installed \u2713 \u2014 garlic-savemgr.elf uploaded and added to autoload.txt. Restart your PS5 to apply.'
: 'Installed \u2713 \u2014 garlic-savemgr.elf uploaded (already in autoload.txt). Restart your PS5 to apply.';
} catch (e) { st.textContent = 'Error: ' + e.message; }
btn.disabled = false;
});
const ssBtn = $('btn-s-savemgr-save');
if (ssBtn) ssBtn.addEventListener('click', async () => {
const port = parseInt($('s-savemgr-port').value) || 8082;
await window.pork.setSettings({ savemgrPort: port });
showToast('Save Manager port saved.', 'ok');
});
})();
+861
View File
@@ -0,0 +1,861 @@
function darkenHex(hex, amount = 0.18, satCap = null) {
const r = parseInt(hex.slice(1,3), 16) / 255;
const g = parseInt(hex.slice(3,5), 16) / 255;
const b = parseInt(hex.slice(5,7), 16) / 255;
const max = Math.max(r,g,b), min = Math.min(r,g,b);
let h = 0, s = 0, l = (max+min)/2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d/(2-max-min) : d/(max+min);
switch(max) {
case r: h = ((g-b)/d + (g<b?6:0))/6; break;
case g: h = ((b-r)/d + 2)/6; break;
case b: h = ((r-g)/d + 4)/6; break;
}
}
l = Math.max(0, l - amount);
if (satCap !== null) s = Math.min(s, satCap);
const hue2rgb = (p, q, t) => {
if (t<0) t+=1; if (t>1) t-=1;
if (t<1/6) return p+(q-p)*6*t;
if (t<1/2) return q;
if (t<2/3) return p+(q-p)*(2/3-t)*6;
return p;
};
const q = l < 0.5 ? l*(1+s) : l+s-l*s;
const p = 2*l - q;
const toHex = v => Math.round(hue2rgb(p,q,v)*255).toString(16).padStart(2,'0');
return `#${toHex(h+1/3)}${toHex(h)}${toHex(h-1/3)}`;
}
function applyAccent(hex) {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return;
// --accent = tint (text/borders); --accent-d = fill (buttons); --accent-rgb tracks the fill.
const fill = darkenHex(hex, 0.26, 0.78);
const r = parseInt(fill.slice(1,3), 16);
const g = parseInt(fill.slice(3,5), 16);
const b = parseInt(fill.slice(5,7), 16);
const root = document.documentElement;
root.style.setProperty('--accent', hex);
root.style.setProperty('--accent-d', fill);
root.style.setProperty('--accent-rgb', `${r}, ${g}, ${b}`);
const swatch = $('accent-swatch');
const picker = $('s-accent-color');
const hexEl = $('s-accent-hex');
if (swatch) swatch.style.background = fill;
if (picker) picker.value = hex;
if (hexEl) hexEl.value = hex.toUpperCase();
}
let settingsTimer = null;
let remoteGamePaths = [];
let localBackupPaths = [];
let _userDirty = false;
let _passDirty = false;
const settingsT = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback;
function renderConsoleProfiles(packet) {
const select = $('console-profile-active');
if (!select) return;
select.innerHTML = (packet.profiles || []).map(profile => `<option value="${escHtml(profile.id)}">${escHtml(profile.label)} · ${escHtml(profile.host || settingsT('settings.status.hostUnknown', 'host unknown'))}${profile.active ? ` · ${settingsT('settings.status.active', 'active')}` : ''}</option>`).join('');
select.value = packet.activeId || '';
const active = (packet.profiles || []).find(profile => profile.id === packet.activeId);
$('console-profile-status').textContent = active
? `${active.platform} · Remote Play ${active.secrets?.remotePlayKey ? settingsT('settings.status.linked', 'linked') : settingsT('settings.status.notLinked', 'not linked')} · Sentinel ${active.secrets?.sentinelPairingKey ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')}`
: settingsT('settings.status.noActiveProfile', 'No active profile');
}
function renderConsoleIdentity(identity) {
if (!$('settings-identity-profile')) return;
const unavailable = settingsT('settings.identity.unavailable', 'Not observed');
$('settings-identity-profile').textContent = identity?.profileLabel || unavailable;
$('settings-identity-local').textContent = identity?.local?.username || unavailable;
$('settings-identity-account').textContent = identity?.local?.accountIdMasked || unavailable;
$('settings-identity-psn').textContent = identity?.linkedPsn?.linked && identity.linkedPsn.onlineId ? identity.linkedPsn.onlineId : unavailable;
const observed = identity?.provenance?.observedAt ? new Date(identity.provenance.observedAt).toLocaleString() : settingsT('settings.identity.noTimestamp', 'no timestamp');
$('settings-identity-evidence').textContent = `${identity?.provenance?.source || 'unknown'} · ${observed}`;
}
async function loadConsoleProfiles() {
if (!window.pork.consoleProfiles || !$('console-profile-active')) return;
renderConsoleProfiles(await window.pork.consoleProfiles());
if (window.pork.consoleIdentity) renderConsoleIdentity(await window.pork.consoleIdentity());
}
function renderCompanionWorkers(workers) {
const target = $('companion-workers-list');
if (!target) return;
target.innerHTML = workers.length ? workers.map(worker => `<div class="ecosystem-service-row" data-worker-id="${escHtml(worker.id)}"><div><strong>${escHtml(worker.label)}</strong><span>${escHtml(worker.endpoint)} · ${worker.paired ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')}</span></div><div class="operations-inline-actions"><button class="btn btn-sm companion-worker-test">Capabilities</button><button class="btn btn-sm companion-worker-pair">${worker.paired ? settingsT('settings.companion.rotateKey', 'Rotate key') : 'Pairing key'}</button><button class="btn btn-sm btn-danger companion-worker-remove">${settingsT('btn.remove', 'Remove')}</button></div></div>`).join('') : `<p class="hint">${settingsT('settings.companion.empty', 'No companion workers configured.')}</p>`;
}
async function loadCompanionWorkers() {
if (window.pork.companionWorkers && $('companion-workers-list')) renderCompanionWorkers(await window.pork.companionWorkers());
}
async function loadTotpStatus() {
if (!$('totp-settings-status')) return;
if (!window.pork.totpStatus) {
$('totp-settings-status').textContent = settingsT('settings.twoFactor.unavailable', 'Unavailable in this runtime');
$('totp-settings-begin').disabled = true;
$('totp-settings-disable').hidden = true;
$('passkey-readiness').innerHTML = `<strong>${escHtml(settingsT('settings.passkeys.preparedTitle', 'Passkeys: safely prepared, not active yet'))}</strong><p>${escHtml(settingsT('settings.passkeys.runtimeUnavailable', 'This runtime does not expose the required enrollment service.'))}</p>`;
return;
}
let status;
try {
status = await Promise.race([
window.pork.totpStatus(),
new Promise((_, reject) => setTimeout(() => reject(new Error(settingsT('settings.twoFactor.timeout', 'Status check timed out'))), 3500)),
]);
} catch (error) {
$('totp-settings-status').textContent = settingsT('settings.twoFactor.unavailable', 'Temporarily unavailable');
$('totp-settings-status').title = error.message;
$('totp-settings-begin').disabled = true;
return;
}
$('totp-settings-begin').disabled = false;
$('totp-settings-status').textContent = status.enabled
? `${settingsT('settings.status.active', 'Active')} · ${status.recoveryCodesRemaining} ${settingsT('settings.twoFactor.codesRemaining', 'recovery codes remaining')}`
: status.pending ? settingsT('settings.twoFactor.pending', 'Pairing started but not confirmed') : settingsT('settings.status.inactive', 'Inactive');
$('totp-settings-begin').textContent = status.enabled ? settingsT('settings.twoFactor.repair', 'Pair authenticator again') : settingsT('settings.twoFactor.pair', 'Pair authenticator');
$('totp-settings-disable').hidden = !status.enabled;
$('passkey-readiness').innerHTML = `<strong>Passkeys: ${status.passkeys?.available ? settingsT('settings.status.available', 'available') : settingsT('settings.passkeys.prepared', 'safely prepared, not active yet')}</strong><p>${escHtml(status.passkeys?.reason || settingsT('settings.status.none', 'No status.'))}</p>`;
}
async function loadGenericNotificationStatus() {
if (!window.pork.genericNotificationStatus || !$('generic-notification-status')) return;
const status = await window.pork.genericNotificationStatus();
$('generic-notification-enabled').checked = status.enabled;
$('generic-notification-endpoint').value = '';
$('generic-notification-endpoint').placeholder = status.endpointConfigured ? `${settingsT('settings.notifications.configuredFor', 'Configured for')} ${status.endpointHost}; ${settingsT('settings.notifications.leaveBlank', 'leave blank to keep')}` : 'https://notify.example/chimera';
$('generic-notification-token').value = '';
$('generic-notification-cooldown').value = status.cooldownMinutes || 60;
$('generic-notification-rules').innerHTML = (status.availableRules || []).map(rule => `<label class="ecosystem-check"><input type="checkbox" data-notification-rule="${escHtml(rule)}" ${status.rules?.includes(rule) ? 'checked' : ''}> ${escHtml(settingsT(`settings.notifications.rule.${rule}`, rule.replace(/-/g, ' ')))}</label>`).join('');
$('generic-notification-status').textContent = `${status.format} · endpoint ${status.endpointConfigured ? status.endpointHost : settingsT('settings.status.notConfigured', 'not configured')} · bearer ${status.bearerConfigured ? settingsT('settings.status.encrypted', 'configured and encrypted') : settingsT('settings.status.notConfigured', 'not configured')}`;
}
function renderConsoleComparison(packet) {
const target = $('console-profiles-comparison');
target.hidden = false;
target.innerHTML = `<div class="ecosystem-update-list">${(packet.profiles || []).map(profile => `<div class="ecosystem-service-row"><div><strong>${profile.active ? '● ' : ''}${escHtml(profile.label)}</strong><span>${escHtml(profile.host)} · firmware ${escHtml(profile.firmware)} · ${escHtml(profile.hen)} ${escHtml(profile.henVersion)}</span></div><span class="hint">Remote Play ${profile.pairing?.remotePlay ? settingsT('settings.status.linked', 'linked') : settingsT('settings.status.notLinked', 'not linked')} · Sentinel ${profile.pairing?.sentinel ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')} · ${settingsT('settings.status.storage', 'storage')} ${escHtml(profile.storage?.status || 'unknown')}</span></div>`).join('')}</div>`;
}
$('console-profile-activate')?.addEventListener('click', async event => {
try {
const result = await window.pork.consoleProfileActivate($('console-profile-active').value);
renderConsoleProfiles(result);
await loadSettings();
showToast('Console profile activated; no automatic connection was started.', 'ok', 7000);
} catch (error) { showToast(error.message, 'error'); }
});
$('console-profile-new')?.addEventListener('submit', async event => {
event.preventDefault();
try {
const result = await window.pork.consoleProfileSave({ label: $('console-profile-new-label').value.trim(), host: $('console-profile-new-host').value.trim(), makeActive: false });
renderConsoleProfiles(result);
event.target.reset();
showToast(settingsT('settings.consoleProfiles.added', 'Console profile added without starting a connection.'), 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('console-profiles-compare')?.addEventListener('click', async event => {
try { renderConsoleComparison(await window.pork.consoleProfilesCompare()); }
catch (error) { showToast(error.message, 'error'); }
});
$('companion-worker-form')?.addEventListener('submit', async event => {
event.preventDefault();
try {
renderCompanionWorkers(await window.pork.companionWorkerSave({ id: $('companion-worker-id').value.trim(), label: $('companion-worker-label').value.trim(), endpoint: $('companion-worker-endpoint').value.trim() }));
event.target.reset(); showToast('Companion worker saved; no job was started.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('companion-workers-list')?.addEventListener('click', async event => {
const row = event.target.closest('[data-worker-id]'); if (!row) return; const id = row.dataset.workerId;
try {
if (event.target.closest('.companion-worker-test')) {
const status = await window.pork.companionWorkerCapabilities(id);
showToast(`Worker verified: ${status.capabilities.join(', ') || 'no supported capabilities'}; shell disabled.`, 'ok', 9000);
}
if (event.target.closest('.companion-worker-pair')) {
const workers = await window.pork.companionWorkers(); const rotate = workers.find(worker => worker.id === id)?.paired === true;
const phrase = `PAIR WORKER ${id}`; const confirmation = await showExactConfirmation(`${rotate ? 'Rotate' : 'Create'} the pairing key for this worker?`, phrase); if (!confirmation) return;
const result = await window.pork.companionWorkerPair({ id, rotate, confirmation });
if (result.pairingKey) { const output = $('companion-worker-pairing-output'); output.hidden = false; output.textContent = `Worker: ${id}\nPairing key (eenmalig zichtbaar): ${result.pairingKey}\n\nImporteer lokaal in Chimera Worker en wis deze weergave.`; }
await loadCompanionWorkers();
}
if (event.target.closest('.companion-worker-remove') && await showExactConfirmation(`Remove ${id} and erase its pairing key?`, 'DELETE')) renderCompanionWorkers(await window.pork.companionWorkerRemove(id));
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('totp-settings-begin')?.addEventListener('click', async event => {
if (!await showConfirm('Pair a new authenticator? TOTP becomes active only after you confirm a valid code.')) return;
try {
const enrollment = await window.pork.totpBegin(); const output = $('totp-settings-output'); output.hidden = false; output.textContent = `One-time TOTP secret: ${enrollment.secret}\n\nAuthenticator URI:\n${enrollment.uri}`;
const code = await showPrompt('Add the secret or URI to your authenticator, then enter the current 6-digit code:'); if (code == null) return;
const confirmed = await window.pork.totpConfirm(code); output.textContent = `TOTP is active. Save these recovery codes now; they will not be shown again:\n\n${confirmed.recoveryCodes.join('\n')}`;
await loadTotpStatus(); showToast('Two-factor authentication is active.', 'ok', 8000);
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('totp-settings-disable')?.addEventListener('click', async () => {
const code = await showPrompt('Enter a valid authenticator or recovery code to disable TOTP:'); if (code == null) return;
if (!await showExactConfirmation('Disable two-factor authentication?', 'UITSCHAKELEN')) return;
try { await window.pork.totpDisable(code); $('totp-settings-output').hidden = true; await loadTotpStatus(); showToast('Two-factor authentication disabled.', 'ok'); }
catch (error) { showToast(error.message, 'error'); }
});
$('generic-notification-form')?.addEventListener('submit', async event => {
event.preventDefault();
try {
const token = $('generic-notification-token').value;
const endpoint = $('generic-notification-endpoint').value.trim();
const rules = [...document.querySelectorAll('[data-notification-rule]:checked')].map(input => input.dataset.notificationRule);
await window.pork.genericNotificationSave({ enabled: $('generic-notification-enabled').checked, rules, cooldownMinutes: Number($('generic-notification-cooldown').value), ...(endpoint ? { endpoint } : {}), ...(token ? { bearerToken: token } : {}) });
await loadGenericNotificationStatus(); showToast('Generic notification settings saved.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('generic-notification-test')?.addEventListener('click', async event => {
try { await window.pork.genericNotificationTest(); showToast('Test notification delivered.', 'ok'); }
catch (error) { showToast(error.message, 'error'); }
});
$('settings-automation-analyze')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try {
const [sources, protection] = await Promise.all([window.pork.automationSourcesAnalyze(), window.pork.automationProtectionStatus()]);
$('settings-automation-analysis').innerHTML = `
<div class="ecosystem-metrics">
<div class="operations-metric"><strong>${sources.roots.length}</strong><span>roots inspected</span></div>
<div class="operations-metric"><strong>${sources.recommendations.length}</strong><span>high-confidence suggestions</span></div>
<div class="operations-metric"><strong>${protection.saveVault.eligible ? 'Ready' : 'Setup'}</strong><span>Save Vault</span></div>
</div>
${(sources.roots || []).map(item => `<div class="ecosystem-service-row"><div><strong>${escHtml(item.root)}</strong><span>${escHtml(item.classification)} · ${escHtml(item.confidence)} confidence · ${item.writable ? 'writable' : 'read-only/unavailable'}</span></div><span class="badge">${escHtml(item.exists ? 'inspected' : item.error || 'missing')}</span></div>`).join('')}
<p class="hint">No configuration changes were applied. Enable Save Vault, replication and restore drills from Settings → Automation after reviewing their targets.</p>`;
} catch (error) { showToast(error.message, 'error'); }
finally { button.disabled = false; }
});
function scheduleSettingsSave() {
clearTimeout(settingsTimer);
settingsTimer = setTimeout(commitSettingsSave, 600);
}
let _webhookDirty = false;
$('s-discord-webhook')?.addEventListener('input', () => { _webhookDirty = true; });
async function commitSettingsSave() {
const payload = {
ftpHost: $('s-host').value.trim(),
ftpPorts: $('s-ports').value.split(',').map(p => p.trim()).filter(Boolean),
backupPaths: localBackupPaths,
backupPath: localBackupPaths[0] || '',
payloadLocalPath: $('s-payload-local').value.trim(),
payloadRemotePath:$('s-payload-remote').value.trim(),
cheatsRemotePath: $('s-cheats-remote').value.trim(),
mediaPsNotifyEnabled: $('s-media-discord-notify').checked,
autoConnect: $('s-auto-connect').checked,
timeFormat: $('s-time-format').value,
hashConcurrency: Number($('s-hash-concurrency')?.value) || 8,
};
if (_userDirty) payload.ftpUser = $('s-user').value.trim();
if (_passDirty) payload.ftpPass = $('s-pass').value;
if (_webhookDirty) payload.discordWebhookUrl = $('s-discord-webhook').value.trim();
await window.pork.setSettings(payload);
_passDirty = false;
_webhookDirty = false;
$('d-host').value = $('s-host').value;
$('d-ports').value = $('s-ports').value;
setStatus('Settings saved', 'ok');
}
async function loadSettings(options = {}) {
const background = !!options.background;
const s = await window.pork.getSettings();
$('s-host').value = s.ftpHost || '';
$('s-ports').value = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts.join(', ') : '1337, 2121, 21';
localBackupPaths = s.backupPaths || (s.backupPath ? [s.backupPath] : []);
renderLocalBackupPaths();
$('s-payload-local').value = s.payloadLocalPath || '';
$('s-payload-remote').value = s.payloadRemotePath || '/data/payloads/';
$('s-cheats-remote').value = s.cheatsRemotePath || '/data/etaHEN/cheats/';
$('s-discord-webhook').value = '';
$('s-discord-webhook').placeholder = s.discordWebhookConfigured ? 'Stored securely — enter to replace' : 'https://discord.com/api/webhooks/...';
$('s-media-discord-notify').checked = s.mediaPsNotifyEnabled !== false; // default true
$('media-discord-psn-detail').hidden = s.mediaPsNotifyEnabled === false;
$('s-media-psn-download').checked = s.mediaPsNotifyDiscordDownload !== false;
$('s-media-psn-convert').checked = s.mediaPsNotifyDiscordConvert !== false;
$('s-media-psn-upload').checked = s.mediaPsNotifyDiscordUpload !== false;
$('s-media-psn-done').checked = s.mediaPsNotifyDiscordDone !== false;
$('s-media-psn-error').checked = s.mediaPsNotifyDiscordError !== false;
$('s-auto-connect').checked = s.autoConnect === true;
_timeFormat = s.timeFormat || '12h';
$('s-time-format').value = _timeFormat;
if ($('s-interface-density')) $('s-interface-density').value = applyDensity(s.interfaceDensity);
if (!background) {
$('d-host').value = s.ftpHost || '';
$('d-ports').value = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts.join(', ') : '1337, 2121, 21';
}
applyAccent(s.accentColor || '#CBBAFF');
remoteGamePaths = s.remoteGamePaths || [];
renderRemoteGamePaths();
await renderSettingsPayloadSources();
if (s.svHotkeys) _svHotkeys = { ..._svHotkeys, ...s.svHotkeys };
const hkMap = { 'hk-mute': 'mute', 'hk-gif': 'gif', 'hk-video': 'video', 'hk-fullscreen': 'fullscreen', 'hk-popout': 'popout' };
Object.entries(hkMap).forEach(([id, action]) => {
const el = $(id); if (el) el.value = _svHotkeys[action] || '';
});
let convMode = s.gameConversionMode || 'pfs';
if (window.pork.isWebMode) {
for (const unavailable of ['ffpkg', 'exfat']) {
const radio = document.querySelector(`input[name="conv-mode"][value="${unavailable}"]`);
if (radio) radio.disabled = true;
radio?.closest('.radio-card')?.setAttribute('title', settingsT('settings.windowsModeUnavailable', 'This Windows mode is unavailable in the Unraid container. Use FFPFSC.'));
const queueButton = document.querySelector(`.conv-mode-btn[data-conv-mode="${unavailable}"]`);
if (queueButton) queueButton.disabled = true;
}
if (!['pfs', 'ffpfsc'].includes(convMode)) {
convMode = 'ffpfsc';
await window.pork.setSettings({ gameConversionMode: convMode });
}
}
const modeRadio = document.querySelector(`input[name="conv-mode"][value="${convMode}"]`);
if (modeRadio) modeRadio.checked = true;
applyConvModeUI(convMode);
$('s-conv-ufs2-path').value = s.ufs2ToolPath || '';
$('s-conv-exfat-path').value = s.osfmountPath || s.exfatToolPath || '';
$('s-conv-output-dir').value = s.convOutputDir || '';
$('s-conv-temp-dir').value = s.convTempDir || '';
$('s-conv-ftp-upload').checked = !!s.convFtpUpload;
$('s-conv-delete-after').checked = !!s.convDeleteAfter;
$('s-ps4-package-builder').value = s.ps4PackageBuilderPath || '';
$('s-ps4-package-output').value = s.ps4PackageOutputPath || '';
window.pork.ps4PackageBuilderStatus().then(status => {
$('s-ps4-package-status').textContent = status.available ? settingsT('ps4Packages.settings.ready', 'Ready') : settingsT('ps4Packages.settings.blocked', 'Not configured');
$('s-ps4-package-status').className = `badge ${status.available ? 'badge-green' : 'badge-orange'}`;
}).catch(error => { $('s-ps4-package-status').textContent = error.message; });
$('s-ps5-native-package-builder').value = s.ps5NativePackageBuilderPath || '';
$('s-ps5-native-package-output').value = s.ps5NativePackageOutputPath || '';
window.pork.ps5NativePackageBuilderStatus().then(status => {
$('s-ps5-native-package-status').textContent = status.available ? settingsT('ps5NativePackages.settings.ready', 'Pinned bridge ready') : settingsT('ps5NativePackages.settings.blocked', 'Research bridge not configured');
$('s-ps5-native-package-status').className = `badge ${status.available ? 'badge-green' : 'badge-orange'}`;
}).catch(error => { $('s-ps5-native-package-status').textContent = error.message; });
$('conv-delete-after-row').hidden = false;
$('s-conv-delete-after').disabled = !s.convFtpUpload;
$('conv-delete-after-row').style.opacity = s.convFtpUpload ? '' : '0.45';
$('s-conv-psn-enabled').checked = s.convPsNotifyEnabled !== false;
$('conv-psn-detail').hidden = !s.convPsNotifyEnabled;
$('s-conv-psn-queued').checked = s.convPsNotifyOnGameQueued !== false;
$('s-conv-psn-batch').checked = s.convPsNotifyOnBatchQueued !== false;
$('s-conv-psn-copy').checked = s.convPsNotifyOnCopyStart !== false;
$('s-conv-psn-convert').checked = s.convPsNotifyOnConvertStart!== false;
$('s-conv-psn-done').checked = s.convPsNotifyOnJobDone !== false;
await refreshConvToolStatus();
if ($('s-hash-concurrency')) $('s-hash-concurrency').value = s.hashConcurrency || 8;
if ($('s-webui-port')) $('s-webui-port').value = s.webUiPort || 6967;
if (window.pork.isWebMode) {
$('s-webui-enabled').disabled = true;
$('s-webui-port').disabled = true;
$('btn-webui-apply').disabled = true;
$('s-webui-enabled').closest('.toggle-row')?.setAttribute('title', settingsT('settings.serverManagedByUnraid', 'This web server is managed as an Unraid container.'));
}
await refreshWebuiStatus();
const _langCode = s.language || 'en';
try {
await refreshLangDropdown(_langCode);
} catch (_) {}
await window.i18n.load(_langCode);
await Promise.all([
loadConsoleProfiles(),
loadCompanionWorkers(),
loadTotpStatus(),
loadGenericNotificationStatus(),
]);
}
['s-host', 's-ports', 's-hash-concurrency'].forEach(id => {
const el = $(id);
if (el) el.addEventListener('input', scheduleSettingsSave);
});
async function refreshLangDropdown(selectedCode) {
const langs = await window.pork.langList();
const langSel = $('s-language');
langSel.innerHTML = langs.map(l => {
const label = l.source === 'imported' ? `${escHtml(l.name)} ✦` : escHtml(l.name);
return `<option value="${escHtml(l.code)}"${l.code === selectedCode ? ' selected' : ''}>${label}</option>`;
}).join('');
}
$('s-language').addEventListener('change', async function() {
const code = this.value;
await window.pork.setSettings({ language: code });
await window.i18n.load(code);
await Promise.all([
loadConsoleProfiles(),
loadCompanionWorkers(),
loadTotpStatus(),
loadGenericNotificationStatus(),
]);
setStatus(window.t('status.languageChanged', 'Language changed'), 'ok');
});
$('s-interface-density')?.addEventListener('change', async function() {
const interfaceDensity = applyDensity(this.value);
await window.pork.setSettings({ interfaceDensity });
setStatus(window.t('settings.density.saved', 'Interface density updated.'), 'ok');
});
$('btn-import-lang').addEventListener('click', async () => {
const btn = $('btn-import-lang');
btn.disabled = true;
try {
const res = await window.pork.langImport();
if (res.canceled) return;
const currentCode = $('s-language').value;
await refreshLangDropdown(currentCode);
setStatus(`${window.t('lang.importDone', 'Language imported')}: ${res.name}`, 'ok');
if (res.code === currentCode) await window.i18n.load(res.code);
} catch (e) {
setStatus(`${window.t('lang.importError', 'Import failed')}: ${e.message}`, 'error');
} finally {
btn.disabled = false;
}
});
$('btn-export-lang').addEventListener('click', async () => {
const btn = $('btn-export-lang');
btn.disabled = true;
try {
const res = await window.pork.langExportBase();
if (!res.canceled) setStatus(window.t('lang.exportDone', 'English base file exported.'), 'ok');
} catch (e) {
setStatus(`${window.t('lang.exportError', 'Export failed')}: ${e.message}`, 'error');
} finally {
btn.disabled = false;
}
});
$('d-host').addEventListener('input', () => {
$('s-host').value = $('d-host').value;
scheduleSettingsSave();
});
$('d-ports').addEventListener('input', () => {
$('s-ports').value = $('d-ports').value;
scheduleSettingsSave();
});
$('s-user').addEventListener('input', () => { _userDirty = true; scheduleSettingsSave(); });
$('s-pass').addEventListener('input', () => { _passDirty = true; scheduleSettingsSave(); });
function renderLocalBackupPaths() {
const cont = $('settings-backup-paths-list');
if (!cont) return;
if (!localBackupPaths.length) {
cont.innerHTML = '<p class="hint" style="margin:6px 0">No folders added yet. Click &ldquo;+ Add Folder&hellip;&rdquo; to get started.</p>';
return;
}
cont.innerHTML = localBackupPaths.map((p, i) => `
<div class="remote-path-row" data-backup-idx="${i}">
<span class="remote-path-value" title="${escHtml(p)}" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p)}</span>
<button class="btn btn-sm btn-backup-path-remove" style="margin-left:auto;flex-shrink:0">&#10005;</button>
</div>
`).join('');
}
async function saveLocalBackupPaths() {
await window.pork.setSettings({
backupPaths: localBackupPaths,
backupPath: localBackupPaths[0] || '',
});
setStatus('Settings saved', 'ok');
}
$('settings-backup-paths-list').addEventListener('click', async e => {
const row = e.target.closest('[data-backup-idx]');
if (!row || !e.target.closest('.btn-backup-path-remove')) return;
const idx = Number(row.dataset.backupIdx);
const ok = await showConfirm(`Remove "${escHtml(localBackupPaths[idx])}" from game source folders?`);
if (!ok) return;
localBackupPaths = localBackupPaths.filter((_, i) => i !== idx);
await saveLocalBackupPaths();
renderLocalBackupPaths();
});
$('btn-add-backup-path').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (!folder) return;
if (localBackupPaths.includes(folder)) { setStatus('That folder is already in the list', 'ok'); return; }
localBackupPaths = [...localBackupPaths, folder];
await saveLocalBackupPaths();
renderLocalBackupPaths();
});
$('btn-browse-payload-local').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (!folder) return;
$('s-payload-local').value = folder;
await commitSettingsSave();
});
$('s-payload-remote').addEventListener('input', scheduleSettingsSave);
$('s-cheats-remote').addEventListener('input', scheduleSettingsSave);
$('s-discord-webhook').addEventListener('input', scheduleSettingsSave);
$('btn-browse-cheats-remote').addEventListener('click', async () => {
const cur = $('s-cheats-remote').value.trim() || '/';
const p = await openFtpBrowser(cur);
if (p) { $('s-cheats-remote').value = p; scheduleSettingsSave(); }
});
$('s-auto-connect').addEventListener('change', commitSettingsSave);
$('s-time-format').addEventListener('change', e => {
_timeFormat = e.target.value;
commitSettingsSave();
renderMediaGrid();
});
$('s-media-discord-notify').addEventListener('change', function() {
$('media-discord-psn-detail').hidden = !this.checked;
commitSettingsSave();
});
[
['s-media-psn-download', 'mediaPsNotifyDiscordDownload'],
['s-media-psn-convert', 'mediaPsNotifyDiscordConvert'],
['s-media-psn-upload', 'mediaPsNotifyDiscordUpload'],
['s-media-psn-done', 'mediaPsNotifyDiscordDone'],
['s-media-psn-error', 'mediaPsNotifyDiscordError'],
].forEach(([id, key]) => {
$(id).addEventListener('change', function() {
window.pork.setSettings({ [key]: this.checked });
});
});
function applyConvModeUI(mode) {
const isPfs = mode === 'pfs';
const isFfpkg = mode === 'ffpkg';
const isExfat = mode === 'exfat';
const isConv = !isPfs;
$('conv-ffpkg-fields').hidden = !isFfpkg;
$('conv-exfat-fields').hidden = !isExfat;
$('conv-shared-fields').hidden = !isConv;
$('conv-notify-fields').hidden = !isConv;
const badge = $('conv-queue-mode-badge');
if (badge) badge.textContent = isPfs ? 'Raw Dumps' : isFfpkg ? 'FFPKG' : isExfat ? 'ExFAT' : 'FFPFSC';
}
async function refreshConvToolStatus() {
const mode = document.querySelector('input[name="conv-mode"]:checked')?.value || 'pfs';
if (mode === 'ffpkg') {
try {
const info = await window.pork.convToolInfo();
const el = $('s-conv-ufs2-status');
if (!el) return;
if (info.toolAvailable) {
el.textContent = '✓ UFS2Tool.exe found';
el.style.color = 'var(--green)';
} else {
el.textContent = info.toolPath ? '✗ File not found at configured path' : '✗ Not configured';
el.style.color = 'var(--red)';
}
} catch (_) {}
}
if (mode === 'exfat') {
try {
const info = await window.pork.convExfatToolInfo();
const el = $('s-conv-exfat-status');
if (!el) return;
if (info.toolAvailable) {
el.textContent = `✓ OSFMount found — native exFAT (${info.osfmountPath || info.toolPath})`;
el.style.color = 'var(--green)';
} else {
el.textContent = '✗ OSFMount not found — install OSFMount or pick osfmount.com';
el.style.color = 'var(--red)';
}
} catch (_) {}
}
}
document.querySelectorAll('input[name="conv-mode"]').forEach(radio => {
radio.addEventListener('change', async () => {
const mode = radio.value;
applyConvModeUI(mode);
await window.pork.setSettings({ gameConversionMode: mode });
await refreshConvToolStatus();
setStatus('Conversion mode saved', 'ok');
});
});
document.querySelectorAll('input[name="ufs2-method"]').forEach(radio => {
radio.addEventListener('change', async () => {
await window.pork.setSettings({ ufs2Method: radio.value });
setStatus('UFS2 method saved', 'ok');
});
});
$('btn-conv-ufs2-pick').addEventListener('click', async () => {
const info = await window.pork.convToolPick();
if (!info) return;
$('s-conv-ufs2-path').value = info.toolPath || '';
const el = $('s-conv-ufs2-status');
if (el) {
el.textContent = info.toolAvailable ? '✓ UFS2Tool.exe found' : '✗ File not found';
el.style.color = info.toolAvailable ? 'var(--green)' : 'var(--red)';
}
setStatus('UFS2Tool path saved', 'ok');
});
$('btn-conv-exfat-pick').addEventListener('click', async () => {
const info = await window.pork.convExfatToolPick();
if (!info) return;
$('s-conv-exfat-path').value = info.toolPath || '';
await refreshConvToolStatus();
setStatus('ExFAT tool path saved', 'ok');
});
$('btn-conv-output-pick').addEventListener('click', async () => {
const p = await window.pork.convPickOutputDir();
if (!p) return;
$('s-conv-output-dir').value = p;
setStatus('Output directory saved', 'ok');
});
$('btn-conv-temp-pick').addEventListener('click', async () => {
const p = await window.pork.convPickTempDir();
if (!p) return;
$('s-conv-temp-dir').value = p;
setStatus('Temp directory saved', 'ok');
});
$('s-conv-ftp-upload').addEventListener('change', async function() {
const ftpEnabled = this.checked;
$('s-conv-delete-after').disabled = !ftpEnabled;
$('conv-delete-after-row').style.opacity = ftpEnabled ? '' : '0.45';
if (!ftpEnabled) {
$('s-conv-delete-after').checked = false;
await window.pork.setSettings({ convFtpUpload: false, convDeleteAfter: false });
} else {
await window.pork.setSettings({ convFtpUpload: true });
}
setStatus('Settings saved', 'ok');
});
$('s-conv-delete-after').addEventListener('change', async function() {
await window.pork.setSettings({ convDeleteAfter: this.checked });
setStatus('Settings saved', 'ok');
});
$('btn-ps4-package-builder').addEventListener('click', async () => {
const folder = await window.pork.selectFolder(); if (!folder) return;
await window.pork.setSettings({ ps4PackageBuilderPath: folder });
$('s-ps4-package-builder').value = folder;
const status = await window.pork.ps4PackageBuilderStatus();
$('s-ps4-package-status').textContent = status.available ? settingsT('ps4Packages.settings.ready', 'Ready') : settingsT('ps4Packages.settings.blocked', 'Not configured');
});
$('btn-ps4-package-output').addEventListener('click', async () => {
const folder = await window.pork.selectFolder(); if (!folder) return;
await window.pork.setSettings({ ps4PackageOutputPath: folder });
$('s-ps4-package-output').value = folder;
showToast(settingsT('ps4Packages.settings.restart', 'Restart Chimera to activate the new package vault.'), 'info');
});
$('btn-ps5-native-package-builder').addEventListener('click', async () => {
const folder = await window.pork.selectFolder(); if (!folder) return;
await window.pork.setSettings({ ps5NativePackageBuilderPath: folder });
$('s-ps5-native-package-builder').value = folder;
const status = await window.pork.ps5NativePackageBuilderStatus();
$('s-ps5-native-package-status').textContent = status.available ? settingsT('ps5NativePackages.settings.ready', 'Pinned bridge ready') : settingsT('ps5NativePackages.settings.blocked', 'Research bridge not configured');
});
$('btn-ps5-native-package-output').addEventListener('click', async () => {
const folder = await window.pork.selectFolder(); if (!folder) return;
await window.pork.setSettings({ ps5NativePackageOutputPath: folder });
$('s-ps5-native-package-output').value = folder;
showToast(settingsT('ps5NativePackages.settings.restart', 'Restart Chimera to activate the native package vault.'), 'info');
});
$('s-conv-psn-enabled').addEventListener('change', async function() {
$('conv-psn-detail').hidden = !this.checked;
await window.pork.setSettings({ convPsNotifyEnabled: this.checked });
setStatus('Settings saved', 'ok');
});
[
['s-conv-psn-queued', 'convPsNotifyOnGameQueued'],
['s-conv-psn-batch', 'convPsNotifyOnBatchQueued'],
['s-conv-psn-copy', 'convPsNotifyOnCopyStart'],
['s-conv-psn-convert', 'convPsNotifyOnConvertStart'],
['s-conv-psn-done', 'convPsNotifyOnJobDone'],
].forEach(([id, key]) => {
$(id).addEventListener('change', function() {
window.pork.setSettings({ [key]: this.checked });
});
});
$('settings-ftp-presets').addEventListener('click', e => {
const btn = e.target.closest('[data-add-port]');
if (!btn) return;
const port = btn.dataset.addPort;
const current = $('s-ports').value.split(',').map(p => p.trim()).filter(Boolean);
if (!current.includes(port)) {
current.push(port);
$('s-ports').value = current.join(', ');
commitSettingsSave();
}
});
{
const hkMap = { 'hk-mute': 'mute', 'hk-gif': 'gif', 'hk-video': 'video', 'hk-fullscreen': 'fullscreen', 'hk-popout': 'popout' };
Object.entries(hkMap).forEach(([id, action]) => {
const input = $(id);
if (!input) return;
input.addEventListener('focus', () => {
input.dataset.prev = input.value;
input.value = '';
input.placeholder = 'press a key…';
input.classList.add('capturing');
});
input.addEventListener('blur', () => {
if (!input.value) input.value = input.dataset.prev || '';
input.placeholder = '—';
input.classList.remove('capturing');
});
input.addEventListener('keydown', async e => {
e.preventDefault();
if (e.key === 'Escape') { input.value = input.dataset.prev || ''; input.blur(); return; }
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
input.value = key;
_svHotkeys[action] = key;
input.blur();
await window.pork.setSettings({ svHotkeys: { ..._svHotkeys } });
setStatus('Hotkey saved', 'ok');
});
});
}
function renderRemoteGamePaths() {
const cont = $('settings-remote-paths-list');
if (!remoteGamePaths.length) {
cont.innerHTML = '<p class="hint" style="margin:6px 0">No paths configured. Scan will use standard PS5 paths.</p>';
return;
}
cont.innerHTML = remoteGamePaths.map((p, i) => `
<div class="remote-path-row" data-idx="${i}">
<span class="remote-path-label">${escHtml(p.label)}</span>
<span class="remote-path-value" title="${escHtml(p.path)}">${escHtml(p.path)}</span>
<button class="btn btn-sm btn-remote-path-remove" style="margin-left:auto">&#10005;</button>
</div>
`).join('');
}
async function saveRemoteGamePaths() {
await window.pork.setSettings({ remoteGamePaths });
setStatus('Settings saved', 'ok');
}
$('btn-add-remote-path').addEventListener('click', async () => {
const label = $('new-remote-label').value.trim();
const path = $('new-remote-path').value.trim();
if (!path) { setStatus('Enter a remote path', 'error'); return; }
remoteGamePaths = [...remoteGamePaths, { label: label || path, path }];
await saveRemoteGamePaths();
renderRemoteGamePaths();
$('new-remote-label').value = '';
$('new-remote-path').value = '';
});
$('settings-remote-paths-list').addEventListener('click', async e => {
const row = e.target.closest('[data-idx]');
if (!row || !e.target.closest('.btn-remote-path-remove')) return;
const idx = Number(row.dataset.idx);
const ok = await showConfirm(`Remove "${escHtml(remoteGamePaths[idx]?.label)}" from remote game paths?`);
if (!ok) return;
remoteGamePaths = remoteGamePaths.filter((_, i) => i !== idx);
await saveRemoteGamePaths();
renderRemoteGamePaths();
});
async function refreshWebuiStatus() {
try {
const status = await window.pork.webuiStatus();
const badge = $('webui-status-badge');
const urlRow = $('webui-url-row');
const urlEl = $('webui-url');
const toggle = $('s-webui-enabled');
if (badge) badge.hidden = !status.running;
if (toggle) toggle.checked = status.running;
if (urlRow && urlEl) {
if (status.running) {
const port = status.port;
const hosts = Array.isArray(status.hosts) && status.hosts.length ? status.hosts : ['localhost'];
const primary = (window.pork.isWebMode && location.hostname)
? location.hostname
: hosts[0];
const primaryUrl = status.publicOrigin || (window.pork.isWebMode ? location.origin : `http://${primary}:${port}`);
urlEl.textContent = primaryUrl;
urlRow.hidden = false;
const openBtn = $('btn-webui-open');
if (openBtn) openBtn.onclick = () => window.pork.openShell ? window.pork.openShell(primaryUrl) : window.open(primaryUrl, '_blank');
} else {
urlRow.hidden = true;
}
}
} catch (_) {}
}
$('s-webui-enabled').addEventListener('change', async function () {
const btn = $('btn-webui-apply');
if (btn) btn.disabled = true;
try {
if (this.checked) {
const port = Number($('s-webui-port').value) || 6967;
await window.pork.setSettings({ webUiEnabled: true, webUiPort: port });
const res = await window.pork.webuiStart();
if (!res.ok) { setStatus(`Web UI failed: ${res.error}`, 'error'); this.checked = false; }
else setStatus('Web UI server started', 'ok');
} else {
await window.pork.setSettings({ webUiEnabled: false });
const res = await window.pork.webuiStop();
if (res.ok) setStatus('Web UI server stopped', 'ok');
}
} catch (e) {
setStatus(`Web UI error: ${e.message}`, 'error');
} finally {
if (btn) btn.disabled = false;
await refreshWebuiStatus();
}
});
$('btn-webui-apply').addEventListener('click', async () => {
const port = Number($('s-webui-port').value) || 6967;
await window.pork.setSettings({ webUiPort: port });
if ($('s-webui-enabled').checked) {
await window.pork.webuiStop();
const res = await window.pork.webuiStart();
if (res.ok) setStatus(`Web UI restarted on port ${port}`, 'ok');
else setStatus(`Web UI restart failed: ${res.error}`, 'error');
} else {
setStatus('Port saved', 'ok');
}
await refreshWebuiStatus();
});
+485
View File
@@ -0,0 +1,485 @@
'use strict';
// State
const state = {
connected: false,
games: [],
backups: [],
sortCol: localStorage.getItem('chimera.games.sortCol') || 'title',
sortDir: localStorage.getItem('chimera.games.sortDir') || 'asc',
filter: localStorage.getItem('chimera.games.filter') || 'all',
platformFilter: ['all', 'ps4', 'ps5', 'unknown'].includes(localStorage.getItem('chimera.games.platform'))
? localStorage.getItem('chimera.games.platform')
: 'all',
search: '',
firmwareFilter: 'all',
gameView: localStorage.getItem('chimera.games.view') || 'grid',
gameRenderLimit: 160,
currentGame: null,
};
// Shared hash summary maps — populated by loadGames() and loadBackporks().
// _gameHashSummary: game_id → summary for hash_type='game'
// _backporkHashSummary: 'game_id|firmware_label' → summary for hash_type='backpork'
const _gameHashSummary = new Map();
const _backporkHashSummary = new Map();
const _pageLoadTimestamps = new Map();
const _pageLoadModes = new Map();
const _pageLoadInFlight = new Map();
let _pageWarmTimer = null;
let _pageWarmRunning = false;
// Feature code is route-loaded. Background refreshes only revisit modules that a user
// already opened; they never download the entire application after startup.
const _PAGE_WARM_ORDER = [];
const _PAGE_STALE_MS = {
dashboard: 15_000,
transfers: 5_000,
'system-view': 20_000,
media: 30_000,
default: 45_000,
};
function _populateHashMaps(summaries) {
_gameHashSummary.clear();
_backporkHashSummary.clear();
for (const s of summaries) {
if (s.hash_type === 'backpork') {
_backporkHashSummary.set(s.game_id + '|' + (s.firmware_label || ''), s);
} else {
_gameHashSummary.set(s.game_id, s);
}
}
}
// System View state
let _svStream = null;
let _svMuted = false;
let _svAudioCtx = null; // AudioContext for low-latency capture-card audio
let _svAudioGain = null; // GainNode — controls mute without stopping the stream
let _svHotkeys = { mute: 'm', gif: 'g', video: 'v', fullscreen: 'f', popout: 'p' };
let _svRecording = false;
let _svRecFrames = []; // Uint8ClampedArray[] — one per captured frame
let _svRecInterval = null;
let _svRecTimer = null;
let _svRecStart = 0;
let _svRecCanvas = null; // lazy-created hidden 640×360 canvas
let _svRecCtx = null;
const GIF_W = 640, GIF_H = 360;
// Video recording state
let _svVidRecorder = null;
let _svVidWritable = null;
let _svVidChunks = null;
let _svVidTimer = null;
let _svVidStart = 0;
let _svVidPaused = false;
let _svVidBytes = 0;
let _svVidMimeType = '';
let _svVidExt = '';
// Helpers
const $ = id => document.getElementById(id);
const _dialogReturnFocus = new WeakMap();
function dialogFocusable(container) {
if (!container) return [];
return [...container.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')]
.filter(element => !element.hidden && element.getAttribute('aria-hidden') !== 'true' && element.getClientRects().length);
}
function openAccessibleDialog(overlay, initialFocus = null) {
if (!overlay) return;
_dialogReturnFocus.set(overlay, document.activeElement instanceof HTMLElement ? document.activeElement : null);
overlay.hidden = false;
overlay.setAttribute('aria-hidden', 'false');
document.body.classList.add('chimera-dialog-open');
requestAnimationFrame(() => (initialFocus || dialogFocusable(overlay)[0] || overlay.querySelector('[role="dialog"]'))?.focus?.());
}
function closeAccessibleDialog(overlay) {
if (!overlay) return;
overlay.hidden = true;
overlay.setAttribute('aria-hidden', 'true');
const returnFocus = _dialogReturnFocus.get(overlay);
_dialogReturnFocus.delete(overlay);
if (![...document.querySelectorAll('[role="dialog"][aria-modal="true"]')]
.some(dialog => !dialog.closest('[hidden]') && dialog.getClientRects().length)) {
document.body.classList.remove('chimera-dialog-open');
}
if (returnFocus?.isConnected) requestAnimationFrame(() => returnFocus.focus());
}
document.addEventListener('keydown', event => {
const tab = event.target.closest?.('[role="tab"]');
if (tab && ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
const tabs = [...tab.closest('[role="tablist"]')?.querySelectorAll('[role="tab"]') || []].filter(item => !item.disabled && !item.hidden);
if (tabs.length) {
event.preventDefault();
const current = Math.max(0, tabs.indexOf(tab));
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
tabs[next].focus();
tabs[next].click();
return;
}
}
if (event.key !== 'Tab') return;
const dialogs = [...document.querySelectorAll('[role="dialog"][aria-modal="true"]')]
.filter(dialog => !dialog.closest('[hidden]') && dialog.getClientRects().length);
const dialog = dialogs.at(-1);
if (!dialog) return;
const focusable = dialogFocusable(dialog);
if (!focusable.length) { event.preventDefault(); dialog.focus(); return; }
const first = focusable[0];
const last = focusable.at(-1);
if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
});
document.addEventListener('error', event => {
const image = event.target;
if (!(image instanceof HTMLImageElement) || !image.dataset.imageError) return;
const fallbackSrc = image.dataset.fallbackSrc;
if (fallbackSrc && image.dataset.fallbackAttempted !== 'true' && image.currentSrc !== fallbackSrc) {
image.dataset.fallbackAttempted = 'true';
image.classList.add('is-fallback-artwork');
image.src = fallbackSrc;
return;
}
image.hidden = true;
if (image.dataset.imageError === 'fallback' && image.nextElementSibling) image.nextElementSibling.hidden = false;
}, true);
function setGameWorkspaceContext(gameId, target) {
const normalized = String(gameId || '').trim().toUpperCase();
if (!/^(?:CUSA|PPSA)\d{5}$/.test(normalized)) return false;
sessionStorage.setItem('chimera.gameWorkspace', JSON.stringify({ gameId: normalized, target: String(target || ''), at: Date.now() }));
return true;
}
function consumeGameWorkspaceContext(target) {
try {
const raw = sessionStorage.getItem('chimera.gameWorkspace');
const context = raw ? JSON.parse(raw) : null;
if (!context || context.target !== target || Date.now() - Number(context.at || 0) > 5 * 60 * 1000) return null;
sessionStorage.removeItem('chimera.gameWorkspace');
return /^(?:CUSA|PPSA)\d{5}$/.test(String(context.gameId || '')) ? context.gameId : null;
} catch (_) {
sessionStorage.removeItem('chimera.gameWorkspace');
return null;
}
}
function _isNarrowLayout() {
return window.matchMedia('(max-width: 900px)').matches;
}
function setMobileNavOpen(open) {
document.body.classList.toggle('mobile-nav-open', !!open && _isNarrowLayout());
const isOpen = document.body.classList.contains('mobile-nav-open');
const toggle = $('mobile-nav-toggle');
if (toggle) {
toggle.setAttribute('aria-expanded', String(isOpen));
toggle.setAttribute('aria-label', isOpen ? 'Close navigation' : 'Open navigation');
toggle.title = isOpen ? 'Close navigation' : 'Open navigation';
toggle.textContent = isOpen ? '×' : '☰';
}
const companionMore = $('chimera-companion-more');
if (companionMore) companionMore.setAttribute('aria-expanded', String(isOpen));
const scrim = $('sidebar-scrim');
if (scrim) scrim.hidden = !isOpen;
const companionDock = $('chimera-companion-dock');
if (companionDock) {
companionDock.toggleAttribute('inert', isOpen);
if (isOpen) companionDock.setAttribute('aria-hidden', 'true');
else companionDock.removeAttribute('aria-hidden');
}
if (isOpen) {
requestAnimationFrame(() => document.querySelector('.chimera-route-link.active, .chimera-domain-home.active')?.scrollIntoView({ block: 'nearest' }));
}
}
function closeMobileNav() {
setMobileNavOpen(false);
}
function applyDensity(value) {
const density = value === 'compact' ? 'compact' : 'comfortable';
document.body.dataset.density = density;
return density;
}
function _pageLoader(page, background = false) {
const loaders = {
dashboard: () => loadDashboard(),
games: () => loadGames(),
packages: () => loadPackages(),
backporks: () => loadBackporks(),
backups: () => loadBackups(),
database: () => loadDatabase(),
settings: () => loadSettings({ background }),
jailbreak: () => loadJailbreak(),
psnotify: () => loadPsNotify(),
xavatar: () => loadXavatar(),
'system-view': () => loadSystemView(background ? { passive: true } : {}),
transfers: () => loadTransfers(),
media: () => loadMedia(),
cheats: () => loadCheats({ background }),
garlic: () => loadGarlic(),
'elf-arsenal': () => (typeof loadElfArsenal === 'function' ? loadElfArsenal({ background }) : null),
'pfs-ripper': () => (typeof loadPfsRipper === 'function' ? loadPfsRipper({ background }) : null),
savemgr: () => loadSaveMgr({ background }),
ecosystem: () => (typeof loadEcosystem === 'function' ? loadEcosystem({ background }) : null),
operations: () => (typeof loadOperations === 'function' ? loadOperations({ background }) : null),
};
return loaders[page] || null;
}
function _pageStaleMs(page) {
return _PAGE_STALE_MS[page] || _PAGE_STALE_MS.default;
}
function pageIsStale(page) {
const last = _pageLoadTimestamps.get(page) || 0;
return (Date.now() - last) > _pageStaleMs(page);
}
function ensurePageReady(page, { background = false, force = false } = {}) {
const needsActiveHydrate = !background && _pageLoadModes.get(page) === 'background';
if (!force && !needsActiveHydrate && !pageIsStale(page)) return _pageLoadInFlight.get(page) || Promise.resolve();
if (_pageLoadInFlight.has(page)) return _pageLoadInFlight.get(page);
const run = Promise.resolve()
.then(() => window.ChimeraModuleLoader?.ensureForPage(page))
.then(() => {
const loader = _pageLoader(page, background);
return loader ? loader() : null;
})
.then(() => {
_pageLoadTimestamps.set(page, Date.now());
const priorMode = _pageLoadModes.get(page);
_pageLoadModes.set(page, background && priorMode === 'active' ? 'active' : (background ? 'background' : 'active'));
})
.catch(err => {
if (background) {
const method = isExpectedUnavailable(err) ? 'info' : 'warn';
console[method](`[page-load] ${page} was not preloaded: ${err.message}`);
return;
}
throw err;
})
.finally(() => {
if (_pageLoadInFlight.get(page) === run) _pageLoadInFlight.delete(page);
});
_pageLoadInFlight.set(page, run);
return run;
}
function scheduleBackgroundWarm(delay = 600) {
if (_pageWarmTimer || _pageWarmRunning) return;
_pageWarmTimer = setTimeout(() => {
_pageWarmTimer = null;
startBackgroundWarm().catch(() => {});
}, delay);
}
async function startBackgroundWarm() {
if (_pageWarmRunning) return;
_pageWarmRunning = true;
try {
for (const page of _PAGE_WARM_ORDER) {
const active = document.querySelector('.page.active')?.id?.replace('page-', '');
if (page === active) continue;
await ensurePageReady(page, { background: true });
await new Promise(resolve => setTimeout(resolve, 250));
}
} finally {
_pageWarmRunning = false;
scheduleBackgroundWarm(_PAGE_STALE_MS.default);
}
}
function fmt(bytes) {
const n = Number(bytes);
if (!n || isNaN(n)) return '—';
const gb = n / 1073741824;
return gb >= 1 ? `${gb.toFixed(2)} GB` : `${(n / 1048576).toFixed(1)} MB`;
}
function yesNo(v) {
return v ? '<span class="pill pill-yes">Yes</span>' : '<span class="pill pill-no">No</span>';
}
function escHtml(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Pinnable cards registry
const PINNABLE_CARDS = [
{ id: 'payload-update', label: 'Payload Update Checker', page: 'jailbreak', hint: 'Check sources for payload updates and push to PS5.' },
{ id: 'payload-mgr', label: 'Payload Sender', page: 'jailbreak', type: 'widget', hint: 'Quick-send a payload to PS5 via TCP.' },
{ id: 'psnotify', label: 'Send Notification', page: 'psnotify', type: 'widget', hint: 'Send a PS5 notification from the dashboard.' },
{ id: 'xavatar-convert', label: 'XAvatar Quick Send', page: 'xavatar', type: 'widget', hint: 'Convert an image and send to PS5 as .xavatar.' },
{ id: 'xavatar-library', label: 'XAvatar Library', page: 'xavatar', hint: 'Manage .xavatar files on your PS5.' },
{ id: 'transfers', label: 'Transfer Manager', page: 'transfers', hint: 'Monitor active and queued FTP transfers.' },
{ id: 'games', label: 'Games Library', page: 'games', hint: 'Browse and manage your PS5 game collection.' },
{ id: 'cheats', label: 'Cheats', page: 'cheats', hint: 'Browse and install game cheats.' },
{ id: 'media', label: 'Media', page: 'media', hint: 'Browse screenshots and video clips from PS5.' },
{ id: 'system-view', label: 'System View', page: 'system-view', hint: 'View your PS5 via capture card stream.' },
{ id: 'backups', label: 'Backups', page: 'backups', hint: 'Browse and manage game backup files.' },
// Other page widgets
{ id: 'dash-savemgr', label: 'Save Manager', page: 'savemgr', type: 'widget', hint: 'Browse and download PS5 save files.' },
];
const _PIN_PAGE_LABELS = {
jailbreak: 'Payload Workspace', psnotify: 'Notifications', xavatar: 'XAvatar',
transfers: 'Transfers', games: 'Games', cheats: 'Cheats & patches', media: 'Media',
'system-view': 'System View', backups: 'Backups', savemgr: 'Local Save Manager',
ecosystem: 'Console Status', operations: 'Operations',
};
function setStatus(msg, type = '') {
const el = $('sb-msg');
el.textContent = msg;
el.style.color = type === 'error' ? 'var(--red)' : type === 'ok' ? 'var(--green)' : 'var(--yellow)';
if (msg) setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 5000);
}
function titleHue(id){let h=0;for(const c of String(id||''))h=(h*31+c.charCodeAt(0))%360;return h;}
// Server messages are Dutch; translated at display time via serverError.* keys.
function friendlyMessage(text) {
const message = String(text ?? '').trim();
if (!/^en(?:-|$)/i.test(document.documentElement.lang || 'en')) return message;
const mapped = window.i18n?.strings?.['serverError.' + message];
if (mapped) return mapped;
return message
.replace(/niet bereikbaar/gi, 'unreachable')
.replace(/niet beschikbaar/gi, 'unavailable')
.replace(/niet geconfigureerd/gi, 'not configured')
.replace(/ontbreekt/gi, 'is missing');
}
function showToast(msg, type = 'info', duration = 5000) {
const icons = { ok: '✓', error: '✕', info: '●' };
const safeType = Object.hasOwn(icons, type) ? type : 'info';
const message = friendlyMessage(msg);
if (!message) return null;
const container = $('toast-container');
const key = `${safeType}:${message.replace(/\s+/g, ' ').toLocaleLowerCase()}`;
const existing = [...container.querySelectorAll('.toast')].find(item => item.dataset.toastKey === key);
if (existing) {
const count = Number(existing.dataset.toastCount || 1) + 1;
existing.dataset.toastCount = String(count);
let badge = existing.querySelector('.toast-count');
if (!badge) {
badge = document.createElement('span');
badge.className = 'toast-count';
existing.querySelector('.toast-body')?.after(badge);
}
badge.textContent = `×${count}`;
clearTimeout(existing._dismissTimer);
existing._dismissTimer = setTimeout(() => existing._dismiss?.(), duration);
return existing;
}
const toast = document.createElement('div');
toast.className = `toast toast-${safeType}`;
toast.dataset.toastKey = key;
toast.dataset.toastCount = '1';
toast.setAttribute('role', safeType === 'error' ? 'alert' : 'status');
const closeLabel = window.t?.('notifications.close', 'Close notification') || 'Close notification';
const icon = document.createElement('span');
icon.className = 'toast-icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = icons[safeType];
const body = document.createElement('span');
body.className = 'toast-body';
body.textContent = message;
const close = document.createElement('button');
close.className = 'toast-close';
close.type = 'button';
close.setAttribute('aria-label', closeLabel);
close.textContent = '×';
toast.append(icon, body, close);
while (container.children.length >= 4) container.firstElementChild?.remove();
container.appendChild(toast);
const dismiss = () => {
if (!toast.isConnected || toast.classList.contains('toast-out')) return;
toast.classList.add('toast-out');
toast.addEventListener('animationend', () => toast.remove(), { once: true });
setTimeout(() => toast.remove(), 350);
};
toast._dismiss = dismiss;
close.addEventListener('click', dismiss);
toast._dismissTimer = setTimeout(dismiss, duration);
toast.addEventListener('mouseenter', () => clearTimeout(toast._dismissTimer));
toast.addEventListener('mouseleave', () => { toast._dismissTimer = setTimeout(dismiss, 2000); });
return toast;
}
// ── Navigation ────────────────────────────────────────────────────────────────
function navigate(page) {
document.querySelectorAll('.nav-link').forEach(a => a.classList.toggle('active', a.dataset.page === page));
document.querySelectorAll('.page').forEach(p => p.classList.toggle('active', p.id === `page-${page}`));
// Embed pages need #content to be overflow:hidden so the iframe can fill available height
const isEmbed = page === 'savemgr';
document.getElementById('content').classList.toggle('embed-page-active', isEmbed);
ensurePageReady(page, { force: pageIsStale(page) });
scheduleBackgroundWarm(800);
closeMobileNav();
syncPinStates();
}
$('mobile-nav-toggle')?.addEventListener('click', () => {
setMobileNavOpen(!document.body.classList.contains('mobile-nav-open'));
});
$('sidebar-scrim')?.addEventListener('click', closeMobileNav);
window.addEventListener('resize', () => {
if (!_isNarrowLayout()) closeMobileNav();
});
async function syncPinStates() {
try {
const s = await window.pork.getSettings();
const pinned = new Set(s.pinnedCards || []);
document.querySelectorAll('.btn-pin').forEach(btn =>
btn.classList.toggle('pinned', pinned.has(btn.dataset.pinId))
);
} catch (_) {}
}
async function loadJailbreak() {
// Reset UI
$('payload-check-status').textContent = '';
$('payload-sources-check-list').innerHTML = '';
if ($('payload-show-expert')) {
$('payload-show-expert').checked = localStorage.getItem('chimera.payload.showExpert') === '1';
}
try { await loadPayloadCatalog(); } catch (error) {
$('payload-sources-check-list').innerHTML = `<p class="hint">${escHtml(window.t('payload.catalogLoadFailed', 'Catalog could not be loaded'))}: ${escHtml(error.message)}</p>`;
}
await renderLocalPayloads();
await renderPayloadSender();
await renderAutoloaderSnapshots();
try {
const settings = await window.pork.getSettings();
if ($('pldmgr-port')) $('pldmgr-port').value = settings.payloadManagerPort || 8084;
if (!settings.ftpHost) {
if ($('service-matrix-summary')) $('service-matrix-summary').textContent = window.t('services.ps5NotConfigured', 'PS5 not configured');
if ($('service-matrix-grid')) $('service-matrix-grid').innerHTML = `<p class="hint">${escHtml(window.t('services.configureProfile', 'Configure a console profile before checking services.'))}</p>`;
if ($('pldmgr-version')) $('pldmgr-version').textContent = 'offline';
if ($('pldmgr-status')) $('pldmgr-status').textContent = window.t('services.ps5NotConfigured', 'PS5 not configured');
return;
}
await loadPs5ServiceMatrix();
await connectPs5PayloadManager({ quiet: true });
} catch (_) {}
}
function isExpectedUnavailable(error) {
if (error?.expected || ['NOT_CONFIGURED', 'UNAVAILABLE', 'UPSTREAM_UNAVAILABLE'].includes(error?.code)) return true;
return /niet (?:bereikbaar|verbonden|beschikbaar|actief|geconfigureerd|gevonden)|maak eerst verbinding|not (?:reachable|connected|available|active|configured|found|mounted)|connect (?:to )?(?:the )?PS5|offline|ECONNREFUSED|timed? out|reageerde niet op tijd|HTTP (?:403|429|5\d\d)|rate.?limit|ontbreekt/i.test(String(error?.message || ''));
}
+76
View File
@@ -0,0 +1,76 @@
// System State Controls
let pendingSystemAction = null;
const systemStateLabels = {
shutdown: 'Shutdown', reboot: 'Reboot', restmode: 'Enter Rest Mode', eject: 'Eject Disc',
};
function systemStateStatus(text, error = false) {
const el = $('system-state-status');
el.textContent = text;
el.style.color = error ? 'var(--red)' : '';
}
async function refreshSystemStateStatus() {
systemStateStatus('Checking service...');
const result = await window.pork.systemStateStatus();
systemStateStatus(result.connected ? `Service ready: ${result.response || 'port 9112'}` : (result.error || result.response || 'Service unavailable.'), !result.connected);
}
$('btn-system-state-status').addEventListener('click', () => refreshSystemStateStatus().catch(error => systemStateStatus(error.message, true)));
$('btn-system-state-deploy').addEventListener('click', async () => {
const button = $('btn-system-state-deploy');
button.disabled = true;
systemStateStatus('Sending payload to loader port 9021...');
try {
const result = await window.pork.systemStateDeploy();
systemStateStatus(`${result.message} (${result.bytes} bytes)`);
} catch (error) {
systemStateStatus(error.message, true);
} finally {
button.disabled = false;
}
});
document.querySelectorAll('.system-state-action').forEach((button) => button.addEventListener('click', () => {
pendingSystemAction = button.dataset.systemAction;
const command = pendingSystemAction === 'restmode' ? 'RESTMODE' : pendingSystemAction.toUpperCase();
$('system-state-confirm-title').textContent = `Confirm: ${systemStateLabels[pendingSystemAction]}`;
const shutdownWarning = pendingSystemAction === 'shutdown'
? ' The upstream SystemStateManager project still marks shutdown as in testing; use it only if you accept that risk.'
: '';
$('system-state-confirm-copy').textContent = `This sends ${command} to SystemStateManager on your PS5. Type "CONFIRM ${command}" exactly to enable the action.${shutdownWarning}`;
$('system-state-confirm-input').value = '';
$('system-state-confirm').hidden = false;
$('system-state-confirm-input').focus();
}));
$('system-state-confirm-input').addEventListener('input', () => {
if (!pendingSystemAction) return;
const command = pendingSystemAction === 'restmode' ? 'RESTMODE' : pendingSystemAction.toUpperCase();
$('btn-system-state-confirm').disabled = $('system-state-confirm-input').value !== `CONFIRM ${command}`;
});
$('btn-system-state-cancel').addEventListener('click', () => {
pendingSystemAction = null;
$('system-state-confirm').hidden = true;
});
$('btn-system-state-confirm').addEventListener('click', async () => {
if (!pendingSystemAction) return;
const action = pendingSystemAction;
const command = action === 'restmode' ? 'RESTMODE' : action.toUpperCase();
const button = $('btn-system-state-confirm');
button.disabled = true;
systemStateStatus(`Sending ${command}...`);
try {
const result = await window.pork.systemStateCommand({ action, confirmation: `CONFIRM ${command}` });
systemStateStatus(`${systemStateLabels[action]} command accepted: ${result.response || 'OK'}`);
$('system-state-confirm').hidden = true;
pendingSystemAction = null;
} catch (error) {
systemStateStatus(error.message, true);
button.disabled = false;
}
});
+767
View File
@@ -0,0 +1,767 @@
// ── System View ───────────────────────────────────────────────────────────────
let _svGifWorker = null;
let _svGifJobId = 0;
let _svGifSavePath = '';
let _svVidWriteQueue = Promise.resolve();
const SV_GIF_MAX_WIDTH = 480;
const SV_GIF_MAX_COLORS = 128;
const SV_GIF_MAX_FPS = 8;
function updateSvSourceUi() {
$('page-system-view')?.classList.toggle('sv-has-source', Boolean($('sv-video-device')?.value));
}
async function loadSystemView(options = {}) {
const passive = !!options.passive;
if (window.pork.isWebMode) {
if (!passive) await _loadSystemViewWeb();
return;
}
$('sv-status-codec').textContent = svDetectCodec().label;
if (!passive) await enumerateSvDevices();
let autoLoad = false;
// Restore all persisted system-view settings
try {
const s = await window.pork.getSettings();
if (s.svResolution) $('sv-resolution').value = s.svResolution;
if (s.svGifFps) $('sv-gif-fps').value = s.svGifFps;
if (s.svVidQuality) $('sv-vid-quality').value = s.svVidQuality;
if (s.svHotkeys) _svHotkeys = { ..._svHotkeys, ...s.svHotkeys };
autoLoad = !!s.svAutoLoad;
$('sv-auto-load').checked = autoLoad;
// Restore saved device selections only if nothing is currently selected
if (!passive) {
const videoSel = $('sv-video-device');
const audioSel = $('sv-audio-device');
if (!videoSel.value && s.svVideoDevice &&
[...videoSel.options].some(o => o.value === s.svVideoDevice)) {
videoSel.value = s.svVideoDevice;
}
if (!audioSel.value && s.svAudioDevice &&
[...audioSel.options].some(o => o.value === s.svAudioDevice)) {
audioSel.value = s.svAudioDevice;
}
}
} catch (_) {}
if (!$('sv-gif-fps').value) $('sv-gif-fps').value = '8';
if (!$('sv-vid-quality').value) $('sv-vid-quality').value = '8000000';
// Re-attach existing stream if still active (user navigated away and back)
if (_svStream && !_svStream.active) _svStream = null;
if (_svStream) {
// Re-attach video-only (AudioContext keeps running independently)
$('sv-video').srcObject = new MediaStream(_svStream.getVideoTracks());
$('sv-video').muted = true;
$('sv-no-signal').classList.add('hidden');
$('btn-sv-mute').disabled = false;
$('btn-sv-popout').disabled = false;
$('btn-sv-record').disabled = false;
$('btn-sv-vid-rec').disabled = false;
$('sv-ctx-popout').disabled = false;
updateSvMuteBtn();
svSetLive($('sv-video-device').selectedOptions[0]?.text || '');
} else if (!passive && autoLoad && $('sv-video-device').value) {
// Auto-start stream only when the user explicitly enabled it
await startSvStream();
}
updateSvSourceUi();
}
async function enumerateSvDevices() {
let devices;
try {
if (!navigator.mediaDevices?.enumerateDevices) throw new Error('MediaDevices is unavailable in this browser.');
// Device discovery must never trigger a camera permission prompt merely by
// opening the page. Permission is requested only when the user explicitly
// starts a selected source.
devices = await Promise.race([
navigator.mediaDevices.enumerateDevices(),
new Promise(resolve => setTimeout(() => resolve([]), 1800)),
]);
} catch (e) {
console.error('enumerateDevices failed:', e);
$('sv-no-signal-msg').textContent = 'Capture devices could not be listed. Check the secure browser context and permissions, then retry.';
$('sv-no-signal').classList.remove('hidden');
return;
}
const prevVideo = $('sv-video-device').value;
const prevAudio = $('sv-audio-device').value;
// Video
const videoSel = $('sv-video-device');
while (videoSel.options.length > 1) videoSel.remove(1);
devices.filter(d => d.kind === 'videoinput').forEach(d => {
videoSel.add(new Option(d.label || `Camera ${d.deviceId.slice(0,8)}`, d.deviceId));
});
// Audio
const audioSel = $('sv-audio-device');
while (audioSel.options.length > 1) audioSel.remove(1);
devices.filter(d => d.kind === 'audioinput').forEach(d => {
audioSel.add(new Option(d.label || `Mic ${d.deviceId.slice(0,8)}`, d.deviceId));
});
if (!devices.some(device => device.kind === 'videoinput')) {
$('sv-no-signal-msg').textContent = 'No capture source detected. Connect a capture device, then refresh the device list.';
$('sv-no-signal').classList.remove('hidden');
}
// Restore previous selection if device still present
if (prevVideo && [...videoSel.options].some(o => o.value === prevVideo)) videoSel.value = prevVideo;
if (prevAudio && [...audioSel.options].some(o => o.value === prevAudio)) audioSel.value = prevAudio;
updateSvSourceUi();
}
function _svAudioStop() {
if (_svAudioCtx) { _svAudioCtx.close().catch(() => {}); _svAudioCtx = null; }
_svAudioGain = null;
}
// ── System View — stream relay (web UI forwarding) ────────────────────────────
let _relayWs = null;
let _relayRecorder = null;
let _relayRequested = false;
let _webViewerWs = null; // browser-side viewer WS
const _RELAY_CODECS = [
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8',
'video/webm',
];
async function startSvRelay() {
if (_relayWs || !_svStream || !_svStream.active) return;
try {
const status = await window.pork.webuiStatus();
if (!status.running) return;
const mimeType = _RELAY_CODECS.find(m => MediaRecorder.isTypeSupported(m)) || 'video/webm';
_relayWs = new WebSocket(`ws://localhost:${status.port}/sv-stream?role=encoder`);
_relayWs.onopen = () => {
_relayWs.send(JSON.stringify({ type: 'meta', mimeType }));
_relayRecorder = new MediaRecorder(_svStream, {
mimeType,
videoBitsPerSecond: 4_000_000,
});
_relayRecorder.ondataavailable = async (e) => {
if (e.data.size === 0 || !_relayWs || _relayWs.readyState !== WebSocket.OPEN) return;
try {
const buf = await e.data.arrayBuffer();
if (_relayWs && _relayWs.readyState === WebSocket.OPEN) _relayWs.send(buf);
} catch (_) {}
};
_relayRecorder.start(500);
};
_relayWs.onclose = () => {
if (_relayRecorder && _relayRecorder.state !== 'inactive') {
try { _relayRecorder.stop(); } catch (_) {}
}
_relayRecorder = null;
_relayWs = null;
};
_relayWs.onerror = () => { try { _relayWs.close(); } catch (_) {} };
} catch (e) {
console.error('[SvRelay] Failed to start:', e);
}
}
function stopSvRelay() {
if (_relayRecorder && _relayRecorder.state !== 'inactive') {
try { _relayRecorder.stop(); } catch (_) {}
}
_relayRecorder = null;
if (_relayWs) { try { _relayWs.close(); } catch (_) {} _relayWs = null; }
}
window.pork.on('sv:relay:request', ({ active }) => {
_relayRequested = active;
if (active && _svStream && _svStream.active) {
startSvRelay().catch(() => {});
} else if (!active) {
stopSvRelay();
}
});
// ── System View — web-mode viewer ─────────────────────────────────────────────
async function _loadSystemViewWeb() {
// In server mode the capture card belongs to the browser machine, so use
// MediaDevices there directly instead of waiting for an Electron relay.
const deviceBar = document.querySelector('.sv-device-bar');
if (deviceBar) deviceBar.hidden = false;
['btn-sv-refresh-devices', 'btn-sv-mute', 'btn-sv-record', 'sv-gif-fps',
'btn-sv-vid-rec', 'sv-vid-quality',
].forEach(id => { const el = $(id); if (el) el.hidden = false; });
$('btn-sv-popout').hidden = true;
$('sv-status-codec').textContent = typeof MediaRecorder !== 'undefined' ? svDetectCodec().label : 'Unavailable';
if (!window.isSecureContext || !navigator.mediaDevices?.enumerateDevices) {
$('sv-no-signal-msg').textContent = window.t('systemView.httpsRequired.detail', 'Capture needs HTTPS or localhost, plus camera and microphone access.');
$('sv-no-signal').classList.remove('hidden');
svSetIdle(window.t('systemView.httpsRequired', 'HTTPS required for capture'));
return;
}
await enumerateSvDevices();
try {
const settings = await window.pork.getSettings();
if (settings.svResolution) $('sv-resolution').value = settings.svResolution;
if (settings.svGifFps) $('sv-gif-fps').value = settings.svGifFps;
if (settings.svVidQuality) $('sv-vid-quality').value = settings.svVidQuality;
if (settings.svVideoDevice && [...$('sv-video-device').options].some(option => option.value === settings.svVideoDevice)) $('sv-video-device').value = settings.svVideoDevice;
if (settings.svAudioDevice && [...$('sv-audio-device').options].some(option => option.value === settings.svAudioDevice)) $('sv-audio-device').value = settings.svAudioDevice;
$('sv-auto-load').checked = settings.svAutoLoad === true;
if (settings.svAutoLoad && $('sv-video-device').value) await startSvStream();
updateSvSourceUi();
} catch (_) {}
}
async function startSvStream() {
if (_svRecording) stopSvRecording();
stopSvRelay();
if (_svStream) { _svStream.getTracks().forEach(t => t.stop()); _svStream = null; }
_svAudioStop();
$('sv-video').srcObject = null;
const videoId = $('sv-video-device').value;
updateSvSourceUi();
if (!videoId) {
$('sv-no-signal-msg').textContent = 'Select a video source above to begin';
$('sv-no-signal').classList.remove('hidden');
$('btn-sv-mute').disabled = true;
$('btn-sv-popout').disabled = true;
$('btn-sv-record').disabled = true;
$('btn-sv-vid-rec').disabled = true;
$('sv-ctx-popout').disabled = true;
svSetIdle('No device selected');
return;
}
$('sv-no-signal-msg').textContent = 'Starting stream…';
const audioId = $('sv-audio-device').value;
const res = $('sv-resolution').value; // "1920x1080" or ""
const [rW, rH] = res ? res.split('x').map(Number) : [];
const _buildConstraints = (exactRes) => {
const vc = { deviceId: { exact: videoId } };
if (rW) {
vc.width = exactRes ? { exact: rW } : { ideal: rW };
vc.height = exactRes ? { exact: rH } : { ideal: rH };
}
return {
video: vc,
audio: audioId ? {
deviceId: { exact: audioId },
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
channelCount: 2,
} : false,
};
};
try {
// First try exact resolution; fall back to ideal if the device can't match
try {
_svStream = await navigator.mediaDevices.getUserMedia(_buildConstraints(true));
} catch (exactErr) {
if (rW && exactErr.name === 'OverconstrainedError') {
console.warn(`[SysView] Exact ${rW}×${rH} not supported, falling back to ideal`);
_svStream = await navigator.mediaDevices.getUserMedia(_buildConstraints(false));
} else {
throw exactErr;
}
}
// Feed only video tracks to the video element. Chromium syncs audio
// to the video element's clock, which introduces ~150-300 ms of buffering.
// Routing audio separately via AudioContext (latencyHint:'interactive')
// drops that to ~20-40 ms and eliminates the choppiness.
$('sv-video').srcObject = new MediaStream(_svStream.getVideoTracks());
$('sv-video').muted = true; // always; audio is handled by AudioContext below
const audioTracks = _svStream.getAudioTracks();
if (audioId && audioTracks.length) {
_svAudioCtx = new AudioContext({ latencyHint: 'interactive' });
_svAudioGain = _svAudioCtx.createGain();
_svAudioGain.gain.value = _svMuted ? 0 : 1;
_svAudioCtx.createMediaStreamSource(new MediaStream(audioTracks))
.connect(_svAudioGain)
.connect(_svAudioCtx.destination);
}
$('sv-no-signal').classList.add('hidden');
$('btn-sv-mute').disabled = false;
$('btn-sv-popout').disabled = false;
$('btn-sv-record').disabled = false;
$('btn-sv-vid-rec').disabled = false;
$('sv-ctx-popout').disabled = false;
updateSvMuteBtn();
svSetLive($('sv-video-device').selectedOptions[0]?.text || videoId);
if (_relayRequested) startSvRelay().catch(() => {});
} catch (e) {
$('sv-no-signal-msg').textContent = `Error: ${e.message}`;
$('sv-no-signal').classList.remove('hidden');
$('btn-sv-mute').disabled = true;
$('btn-sv-popout').disabled = true;
$('btn-sv-record').disabled = true;
$('btn-sv-vid-rec').disabled = true;
$('sv-ctx-popout').disabled = true;
svSetIdle(`Error: ${e.message}`);
}
}
function updateSvMuteBtn() {
$('btn-sv-mute').textContent = _svMuted ? '🔇 Unmute' : '🔊 Mute';
}
// ── System View — status bar ───────────────────────────────────────────────────
function svSetLive(label) {
$('sv-status-dot').className = 'sv-status-dot live';
$('sv-status-text').textContent = `Live — ${label}`;
}
function svSetIdle(msg = 'No device selected') {
$('sv-status-dot').className = 'sv-status-dot';
$('sv-status-text').textContent = msg;
$('sv-status-res').textContent = '';
}
function svSetSaving() {
$('sv-status-dot').className = 'sv-status-dot saving';
$('sv-status-text').textContent = 'Saving video…';
}
function svSetGifSaving() {
$('sv-status-dot').className = 'sv-status-dot saving';
$('sv-status-text').textContent = 'Saving GIF…';
}
$('sv-video').addEventListener('loadedmetadata', () => {
const { videoWidth: w, videoHeight: h } = $('sv-video');
if (w && h) $('sv-status-res').textContent = `${w}×${h}`;
});
// ── System View — codec detection ─────────────────────────────────────────────
function svDetectCodec() {
const candidates = [
{ mime: 'video/mp4;codecs="avc1.640032,mp4a.40.2"', ext: 'mp4', label: 'H264/AAC · MP4' },
{ mime: 'video/mp4;codecs="avc1.42E01E,mp4a.40.2"', ext: 'mp4', label: 'H264/AAC · MP4' },
{ mime: 'video/mp4;codecs=avc1,mp4a.40.2', ext: 'mp4', label: 'H264/AAC · MP4' },
{ mime: 'video/mp4', ext: 'mp4', label: 'MP4' },
{ mime: 'video/webm;codecs="vp9,opus"', ext: 'webm', label: 'VP9/Opus · WebM' },
{ mime: 'video/webm;codecs="vp8,opus"', ext: 'webm', label: 'VP8/Opus · WebM' },
{ mime: 'video/webm', ext: 'webm', label: 'WebM' },
];
return candidates.find(c => MediaRecorder.isTypeSupported(c.mime))
|| { mime: '', ext: 'webm', label: 'WebM (default)' };
}
// ── System View — video recording ─────────────────────────────────────────────
function svFmtBytes(n) {
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function svFmtTime(ms) {
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function svUpdateVidStatus() {
const elapsed = _svVidPaused
? _svVidStart
: Date.now() - _svVidStart;
$('sv-vid-rec-time').textContent = svFmtTime(elapsed);
$('sv-vid-rec-size').textContent = _svVidBytes > 0 ? ` · ${svFmtBytes(_svVidBytes)}` : '';
}
async function startSvVidRecording() {
if (!_svStream) return;
const codec = svDetectCodec();
_svVidMimeType = codec.mime;
_svVidExt = codec.ext;
$('sv-status-codec').textContent = codec.label;
const bitrate = Math.max(2_500_000, Math.min(12_000_000, parseInt($('sv-vid-quality').value) || 8_000_000));
const name = `chimera-${new Date().toISOString().slice(0,19).replace(/[T:]/g,'-')}.${_svVidExt}`;
_svVidChunks = [];
if (!window.pork.isWebMode) {
try {
const picked = await window.pork.systemViewPickSave({
defaultPath: name,
filters: [{ name: `${_svVidExt.toUpperCase()} Video`, extensions: [_svVidExt] }],
});
if (picked?.canceled || !picked?.filePath) return;
_svVidWritable = picked.filePath;
_svVidWriteQueue = Promise.resolve();
await window.pork.systemViewWriteFile({ filePath: _svVidWritable, buffer: new Uint8Array(0).buffer });
} catch (e) {
setStatus(`Video save dialog failed: ${e.message}`, 'error');
return;
}
} else {
_svVidWritable = name;
}
_svVidBytes = 0; _svVidPaused = false; _svVidStart = Date.now();
const recOpts = { videoBitsPerSecond: bitrate, audioBitsPerSecond: 256_000 };
if (_svVidMimeType) recOpts.mimeType = _svVidMimeType;
_svVidRecorder = new MediaRecorder(_svStream, recOpts);
_svVidRecorder.ondataavailable = async (e) => {
if (!e.data || e.data.size === 0) return;
_svVidBytes += e.data.size;
if (window.pork.isWebMode) {
_svVidChunks.push(e.data);
} else if (_svVidWritable) {
const filePath = _svVidWritable;
_svVidWriteQueue = _svVidWriteQueue.then(async () => {
const buffer = await e.data.arrayBuffer();
await window.pork.systemViewAppendFile({ filePath, buffer });
}).catch(err => {
console.error('[system-view] video append failed', err);
});
}
};
_svVidRecorder.onstop = async () => {
clearInterval(_svVidTimer);
svSetSaving();
await _svVidWriteQueue.catch(() => {});
if (window.pork.isWebMode && _svVidChunks?.length) {
const url = URL.createObjectURL(new Blob(_svVidChunks, { type: _svVidMimeType || 'video/webm' }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = _svVidWritable || name;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 30000);
}
_svVidChunks = null;
_svVidWritable = null;
_svVidRecorder = null;
svSetVidIdle();
if (_svStream) svSetLive($('sv-video-device').selectedOptions[0]?.text || '');
};
_svVidRecorder.start(500);
$('btn-sv-vid-rec').hidden = true;
$('btn-sv-vid-pause').hidden = false;
$('btn-sv-vid-stop').hidden = false;
$('sv-vid-rec-status').hidden = false;
$('sv-vid-rec-label').textContent = 'REC';
$('sv-vid-rec-status').className = 'sv-vid-rec-status';
$('btn-sv-record').disabled = true; // no GIF while video recording
_svVidTimer = setInterval(svUpdateVidStatus, 500);
}
function pauseSvVidRecording() {
if (!_svVidRecorder) return;
if (_svVidPaused) {
_svVidRecorder.resume();
_svVidStart = Date.now() - _svVidStart;
_svVidPaused = false;
$('btn-sv-vid-pause').textContent = '⏸ Pause';
$('sv-vid-rec-label').textContent = 'REC';
$('sv-vid-rec-status').className = 'sv-vid-rec-status';
} else {
_svVidRecorder.pause();
_svVidStart = Date.now() - _svVidStart;
_svVidPaused = true;
$('btn-sv-vid-pause').textContent = '▶ Resume';
$('sv-vid-rec-label').textContent = 'PAUSED';
$('sv-vid-rec-status').className = 'sv-vid-rec-status paused';
}
}
function stopSvVidRecording() {
if (!_svVidRecorder) return;
if (_svVidPaused) { _svVidRecorder.resume(); _svVidPaused = false; }
_svVidRecorder.stop();
}
function svSetVidIdle() {
$('btn-sv-vid-rec').hidden = false;
$('btn-sv-vid-pause').hidden = true;
$('btn-sv-vid-stop').hidden = true;
$('sv-vid-rec-status').hidden = true;
$('btn-sv-vid-pause').textContent = '⏸ Pause';
$('sv-vid-rec-label').textContent = 'REC';
$('sv-vid-rec-status').className = 'sv-vid-rec-status';
$('btn-sv-record').disabled = !_svStream;
_svVidPaused = false;
}
// ── System View — recording helpers ───────────────────────────────────────────
function getSvRecCtx() {
const vid = $('sv-video');
const srcW = vid.videoWidth || GIF_W;
const srcH = vid.videoHeight || GIF_H;
const scale = srcW > SV_GIF_MAX_WIDTH ? (SV_GIF_MAX_WIDTH / srcW) : 1;
const w = Math.max(2, Math.round(srcW * scale));
const h = Math.max(2, Math.round(srcH * scale));
if (!_svRecCanvas || _svRecCanvas.width !== w || _svRecCanvas.height !== h) {
_svRecCanvas = document.createElement('canvas');
_svRecCanvas.width = w; _svRecCanvas.height = h;
_svRecCtx = _svRecCanvas.getContext('2d');
}
return _svRecCtx;
}
function getSvGifWorker() {
if (_svGifWorker) return _svGifWorker;
const workerSource = `
importScripts(${JSON.stringify(new URL('gif-encoder.js', location.href).href)});
self.onmessage = e => {
const { id, width, height, fps, maxColors, frames } = e.data || {};
try {
const enc = new self.GifEncoder(width, height, { fps, maxColors });
for (const frame of frames || []) enc.addFrame(new Uint8ClampedArray(frame));
const bytes = enc.encode();
self.postMessage({ id, ok: true, bytes }, [bytes.buffer]);
} catch (error) {
self.postMessage({ id, ok: false, error: error?.message || String(error) });
}
};
`;
const workerUrl = URL.createObjectURL(new Blob([workerSource], { type: 'application/javascript' }));
_svGifWorker = new Worker(workerUrl);
URL.revokeObjectURL(workerUrl);
return _svGifWorker;
}
function encodeGifOffThread({ width, height, fps, frames, maxColors }) {
return new Promise((resolve, reject) => {
const worker = getSvGifWorker();
const id = ++_svGifJobId;
const onMessage = (event) => {
const msg = event.data || {};
if (msg.id !== id) return;
worker.removeEventListener('message', onMessage);
if (!msg.ok) reject(new Error(msg.error || 'GIF encode failed'));
else resolve(new Uint8Array(msg.bytes));
};
worker.addEventListener('message', onMessage);
worker.postMessage({
id,
width,
height,
fps,
maxColors,
frames: frames.map(f => f.buffer),
}, frames.map(f => f.buffer));
});
}
async function startSvRecording() {
if (_svRecording || !_svStream) return;
const fps = Math.min(SV_GIF_MAX_FPS, parseInt($('sv-gif-fps').value) || 10);
const defaultName = `chimera-${Date.now()}.gif`;
if (window.pork.isWebMode) {
_svGifSavePath = defaultName;
} else {
const picked = await window.pork.systemViewPickSave({
defaultPath: defaultName,
filters: [{ name: 'GIF Image', extensions: ['gif'] }],
});
if (picked?.canceled || !picked?.filePath) return;
_svGifSavePath = picked.filePath;
}
_svRecording = true; _svRecFrames = []; _svRecStart = Date.now();
$('btn-sv-record').textContent = '⏹ Stop & Save';
$('sv-rec-status').hidden = false;
const ctx = getSvRecCtx();
const vid = $('sv-video');
const gifW = _svRecCanvas.width, gifH = _svRecCanvas.height;
_svRecInterval = setInterval(() => {
ctx.drawImage(vid, 0, 0, gifW, gifH);
_svRecFrames.push(new Uint8ClampedArray(ctx.getImageData(0, 0, gifW, gifH).data));
}, Math.round(1000 / fps));
_svRecTimer = setInterval(() => {
const s = Math.floor((Date.now() - _svRecStart) / 1000);
$('sv-rec-time').textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}, 500);
}
async function stopSvRecording() {
if (!_svRecording) return;
clearInterval(_svRecInterval); clearInterval(_svRecTimer);
_svRecording = false;
$('btn-sv-record').textContent = '⏺ Record GIF';
$('sv-rec-status').hidden = true;
$('sv-rec-time').textContent = '0:00';
if (!_svRecFrames.length) return;
const fps = Math.min(SV_GIF_MAX_FPS, parseInt($('sv-gif-fps').value) || 10);
const gifW = _svRecCanvas?.width || GIF_W;
const gifH = _svRecCanvas?.height || GIF_H;
const frames = _svRecFrames;
const filePath = _svGifSavePath;
_svRecFrames = [];
_svGifSavePath = '';
svSetGifSaving();
try {
const bytes = await encodeGifOffThread({
width: gifW,
height: gifH,
fps,
frames,
maxColors: SV_GIF_MAX_COLORS,
});
if (window.pork.isWebMode) {
const url = URL.createObjectURL(new Blob([bytes], { type: 'image/gif' }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filePath || `chimera-${Date.now()}.gif`;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 30000);
} else {
await window.pork.systemViewWriteFile({ filePath, buffer: bytes.buffer });
}
} catch (e) {
console.error('[system-view] gif save failed', e);
setStatus(`GIF save failed: ${e.message}`, 'error');
}
if (_svStream) svSetLive($('sv-video-device').selectedOptions[0]?.text || '');
}
// System View — event handlers (attached once at startup)
$('sv-video-device').addEventListener('change', () => {
window.pork.setSettings({ svVideoDevice: $('sv-video-device').value });
updateSvSourceUi();
startSvStream();
});
$('sv-audio-device').addEventListener('change', () => {
window.pork.setSettings({ svAudioDevice: $('sv-audio-device').value });
startSvStream();
});
$('btn-sv-refresh-devices').addEventListener('click', () => enumerateSvDevices());
$('sv-resolution').addEventListener('change', () => {
window.pork.setSettings({ svResolution: $('sv-resolution').value });
if ($('sv-video-device').value) startSvStream();
});
$('sv-gif-fps').addEventListener('change', () =>
window.pork.setSettings({ svGifFps: $('sv-gif-fps').value }));
$('sv-vid-quality').addEventListener('change', () =>
window.pork.setSettings({ svVidQuality: $('sv-vid-quality').value }));
$('sv-auto-load').addEventListener('change', () => {
window.pork.setSettings({ svAutoLoad: $('sv-auto-load').checked });
});
$('btn-sv-mute').addEventListener('click', () => {
_svMuted = !_svMuted;
if (_svAudioGain) _svAudioGain.gain.value = _svMuted ? 0 : 1;
updateSvMuteBtn();
});
// In-app fullscreen: CSS-based fixed overlay — requestFullscreen() is
// unreliable in Electron's frameless window and silently fails.
function svToggleFullscreen() {
const wrap = $('sv-viewer-wrap');
const entering = !wrap.classList.contains('sv-viewer-wrap--fullscreen');
wrap.classList.toggle('sv-viewer-wrap--fullscreen', entering);
$('btn-sv-fullscreen').title = entering ? 'Exit Fullscreen (Esc)' : 'Fullscreen (or double-click)';
$('btn-sv-fullscreen').innerHTML = entering ? '&#x2715;' : '&#x26F6;';
}
$('btn-sv-fullscreen').addEventListener('click', svToggleFullscreen);
$('sv-viewer-wrap').addEventListener('dblclick', svToggleFullscreen);
$('btn-sv-record').addEventListener('click', () => {
(_svRecording ? stopSvRecording() : startSvRecording()).catch(err => {
setStatus(err.message, 'error');
});
});
$('btn-sv-vid-rec').addEventListener('click', startSvVidRecording);
$('btn-sv-vid-pause').addEventListener('click', pauseSvVidRecording);
$('btn-sv-vid-stop').addEventListener('click', stopSvVidRecording);
// Right-click context menu on video area
const _svCtxMenu = $('sv-ctx-menu');
$('sv-viewer-wrap').addEventListener('contextmenu', e => {
e.preventDefault();
const x = Math.min(e.clientX, window.innerWidth - 164);
const y = Math.min(e.clientY, window.innerHeight - 80);
_svCtxMenu.style.left = `${x}px`;
_svCtxMenu.style.top = `${y}px`;
_svCtxMenu.hidden = false;
});
document.addEventListener('click', () => { _svCtxMenu.hidden = true; });
$('sv-ctx-fs').addEventListener('click', svToggleFullscreen);
$('sv-ctx-popout').addEventListener('click', () => $('btn-sv-popout').click());
if (navigator.mediaDevices) {
navigator.mediaDevices.addEventListener('devicechange', () => enumerateSvDevices().catch(() => {}));
}
$('btn-sv-popout').addEventListener('click', async () => {
const videoId = $('sv-video-device').value;
const audioId = $('sv-audio-device').value;
const resolution = $('sv-resolution').value;
if (_svRecording) stopSvRecording();
stopSvRelay();
// Stop the main stream — the pop-out will start its own
if (_svStream) { _svStream.getTracks().forEach(t => t.stop()); _svStream = null; }
_svAudioStop();
$('sv-video').srcObject = null;
$('sv-no-signal').classList.remove('hidden');
$('sv-no-signal-msg').textContent = 'Stream moved to pop-out window';
$('btn-sv-mute').disabled = true;
$('btn-sv-popout').disabled = true;
$('btn-sv-record').disabled = true;
await window.pork.openSystemViewPopout(videoId, audioId, resolution);
});
// When pop-out closes, auto-restart stream in main window if device still selected
window.pork.on('system-view:popout-closed', () => {
if ($('page-system-view')?.classList.contains('active') && $('sv-video-device').value) {
startSvStream();
}
});
// ── System View mini-cards (Transfers / Jailbreak) ────────────────────────────
function svMiniGetStatus() {
const sel = $('sv-video-device');
const opt = sel?.selectedOptions?.[0];
return (opt && opt.value) ? `Capture device: ${opt.text}` : 'No capture device selected.';
}
['transfers', 'jailbreak'].forEach(pageId => {
$(`sv-mini-${pageId}-hdr`).addEventListener('click', () => {
const card = $(`sv-mini-${pageId}`);
card.classList.toggle('open');
if (card.classList.contains('open')) {
$(`sv-mini-${pageId}-status`).textContent = svMiniGetStatus();
}
});
$(`btn-sv-mini-${pageId}-nav`).addEventListener('click', () => navigate('system-view'));
$(`btn-sv-mini-${pageId}-popout`).addEventListener('click', async () => {
const videoId = $('sv-video-device').value;
const audioId = $('sv-audio-device').value;
const resolution = $('sv-resolution').value;
try {
await window.pork.openSystemViewPopout(videoId, audioId, resolution);
} catch (e) {
setStatus(e.message, 'error');
}
});
});
document.querySelectorAll('.nav-link').forEach(a =>
a.addEventListener('click', e => { e.preventDefault(); navigate(a.dataset.page); })
);
+219
View File
@@ -0,0 +1,219 @@
(function initTitleWorkspace(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.chimeraTitleWorkspace = api;
})(typeof window !== 'undefined' ? window : globalThis, function createTitleWorkspace() {
'use strict';
const array = value => Array.isArray(value) ? value : [];
const text = value => value == null || value === '' ? null : String(value);
const first = (...values) => values.map(text).find(Boolean) || null;
const lower = value => String(value || '').trim().toLowerCase();
const missingStates = new Set(['missing', 'unavailable', 'disconnected', 'offline', 'not_mounted']);
function deviceState(item) {
return first(
item && item.deviceAvailabilityState,
item && item.availabilityState,
item && item.device && item.device.availabilityState,
item && item.storageDevice && item.storageDevice.availabilityState,
item && item.volume && item.volume.device && item.volume.device.availabilityState
);
}
function locationLabel(item, fallback) {
return first(
item && item.volume && item.volume.label,
item && item.volume && item.volume.name,
item && item.volumeLabel,
item && item.device && item.device.label,
item && item.storageDevice && item.storageDevice.label,
item && item.console && item.console.name,
item && item.consoleName,
item && item.path,
fallback
);
}
function isConfirmedInstall(install) {
const state = lower(first(install && install.presenceState, install && install.state, install && install.installState));
return state === 'present' || state === 'installed' || state === 'confirmed';
}
function isMissing(item) {
return missingStates.has(lower(deviceState(item)));
}
function collectActivity(title, locations) {
const entries = [];
const add = (kind, value, label) => {
if (!value) return;
const timestamp = new Date(value);
if (Number.isNaN(timestamp.getTime())) return;
entries.push({ kind, label, timestamp: timestamp.toISOString() });
};
add('title', title.updatedAt || title.updated_at, 'Title metadata updated');
add('title', title.createdAt || title.created_at, 'Title first recorded');
locations.forEach(location => {
add(location.kind, location.raw.lastSeenAt || location.raw.last_seen_at, `${location.label} last seen`);
add(location.kind, location.raw.verifiedAt || location.raw.verified_at, `${location.label} verified`);
add(location.kind, location.raw.detectedAt || location.raw.detected_at, `${location.label} detected`);
});
array(title.media).forEach(item => add('media', item.importedAt || item.provenance?.importedAt, `Imported ${item.kind || 'media'}: ${item.originalName || item.name || 'asset'}`));
return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}
function project(title = {}) {
const localCopies = array(title.localCopies);
const consoleInstalls = array(title.consoleInstalls);
const packages = array(title.packages);
const saves = array(title.saves);
const trophies = array(title.trophies);
const media = array(title.media);
const locations = [
...localCopies.map((raw, index) => ({
kind: 'local-copy',
label: locationLabel(raw, `Local copy ${index + 1}`),
state: deviceState(raw) || 'unknown',
version: first(raw.version && raw.version.value, raw.version, raw.appVersion),
format: first(raw.format, raw.copyType, raw.type),
path: first(raw.path, raw.relativePath),
missing: isMissing(raw),
raw
})),
...consoleInstalls.map((raw, index) => ({
kind: 'console-install',
label: locationLabel(raw, `Console install ${index + 1}`),
state: first(raw.presenceState, raw.state, raw.installState, 'unknown'),
version: first(raw.version && raw.version.value, raw.version, raw.appVersion),
format: first(raw.format, raw.packageType),
path: first(raw.path, raw.installPath),
missing: isMissing(raw),
confirmed: isConfirmedInstall(raw),
raw
}))
];
const missingDrive = Number(title.availability && title.availability.missingDeviceCount || 0) > 0 || locations.some(location => location.missing);
const installed = consoleInstalls.some(isConfirmedInstall) || Number(title.availability && title.availability.confirmedInstallCount || 0) > 0;
const protectionState = lower(first(title.protection && title.protection.state, title.protection && title.protection.status));
const protectedCopy = ['protected', 'healthy', 'verified', 'complete'].includes(protectionState)
|| Number(title.protection && title.protection.protectedCopyCount || 0) > 0;
const badges = [];
if (installed) badges.push({ code: 'installed', tone: 'positive' });
if (localCopies.length) badges.push({ code: 'local-copy', tone: 'info' });
if (missingDrive) badges.push({ code: 'missing-drive', tone: 'warning' });
if (protectedCopy) badges.push({ code: 'protected', tone: 'positive' });
if ((installed || localCopies.length) && !protectedCopy) badges.push({ code: 'needs-protection', tone: 'danger' });
if (!locations.length) badges.push({ code: 'metadata-only', tone: 'neutral' });
return Object.freeze({
title,
badges,
locations,
localCopies,
consoleInstalls,
packages,
saves,
trophies,
media,
missingDrive,
installed,
protectedCopy,
activity: collectActivity(title, locations)
});
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function tr(key, fallback) {
if (typeof window === 'undefined' || typeof window.t !== 'function') return fallback;
const translated = window.t(key);
return translated && translated !== key ? translated : fallback;
}
function setCount(id, count) {
const node = document.getElementById(id);
if (!node) return;
node.textContent = count ? String(count) : '';
node.hidden = !count;
}
function emptyState(key, fallback, action) {
return `<div class="title-workspace-empty"><p>${escapeHtml(tr(key, fallback))}</p>${action || ''}</div>`;
}
function renderLocation(location) {
const kind = location.kind === 'console-install'
? tr('library.workspace.consoleInstall', 'Console install')
: tr('library.workspace.localCopy', 'Local copy');
const details = [location.version && `v${location.version}`, location.format, location.path].filter(Boolean);
return `<article class="title-location-card${location.missing ? ' is-missing' : ''}">
<div><span class="title-location-kind">${escapeHtml(kind)}</span><h4>${escapeHtml(location.label)}</h4></div>
<span class="title-location-state">${escapeHtml(location.missing ? tr('library.workspace.driveMissing', 'Drive unavailable') : location.state)}</span>
${details.length ? `<p>${details.map(escapeHtml).join(' · ')}</p>` : ''}
</article>`;
}
function renderList(id, items, emptyKey, emptyFallback, mapper, action) {
const node = document.getElementById(id);
if (!node) return;
node.innerHTML = items.length ? items.map(mapper).join('') : emptyState(emptyKey, emptyFallback, action);
}
function render(title) {
if (typeof document === 'undefined') return project(title);
const workspace = project(title);
const labels = {
installed: tr('library.badge.installed', 'Installed'),
'local-copy': tr('library.badge.localCopy', 'Local copy'),
'missing-drive': tr('library.badge.missingDrive', 'Drive unavailable'),
protected: tr('library.badge.protected', 'Protected'),
'needs-protection': tr('library.badge.needsProtection', 'Needs protection'),
'metadata-only': tr('library.badge.metadataOnly', 'Metadata only')
};
const badges = document.getElementById('modal-derived-badges');
if (badges) badges.innerHTML = workspace.badges.map(badge => `<span class="title-badge tone-${badge.tone}">${escapeHtml(labels[badge.code])}</span>`).join('');
renderList('modal-copies-content', workspace.locations, 'library.workspace.noCopies', 'No local copies or confirmed console installations recorded.', renderLocation);
renderList('modal-packages-content', workspace.packages, 'library.workspace.noPackages', 'No package evidence linked to this title.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.name, item.label, item.packageId, 'Package'))}</strong><span>${escapeHtml(first(item.state, item.status, item.type, 'recorded'))}</span></article>`);
window.chimeraRecipeEditor?.mount?.({ title, workspace });
renderList('modal-saves-content', workspace.saves, 'library.workspace.noSaves', 'No save evidence linked to this title.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.name, item.accountLabel, 'Save'))}</strong><span>${escapeHtml(first(item.state, item.status, 'recorded'))}</span></article>`, `<button class="btn" type="button" data-route="protection/saves">${escapeHtml(tr('library.workspace.openSaves', 'Open Save Vault'))}</button>`);
renderList('modal-trophies-content', workspace.trophies, 'library.workspace.noTrophies', 'No verified read-only trophy evidence imported.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.name, item.label, 'Trophy evidence'))}</strong><span>${escapeHtml(first(item.state, item.status, 'read-only'))}</span></article>`);
renderList('modal-media-content', workspace.media, 'library.workspace.noMedia', 'No media assets linked to this title.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.originalName, item.name, item.label, item.kind, 'Media'))}</strong><span>${escapeHtml(first(item.provenance?.provider, item.state, item.status, 'available'))}${item.preferred ? ` · ${escapeHtml(tr('media.import.preferredActive', 'Preferred'))}` : ''}</span></article>`, `<button class="btn" type="button" data-route="library/media">${escapeHtml(tr('library.workspace.openMedia', 'Open Media Library'))}</button>`);
renderList('modal-activity-content', workspace.activity, 'library.workspace.noActivity', 'No timestamped evidence recorded yet.', item => `<article class="title-evidence-row"><strong>${escapeHtml(item.label)}</strong><time datetime="${escapeHtml(item.timestamp)}">${escapeHtml(new Date(item.timestamp).toLocaleString())}</time></article>`);
const compatibility = document.getElementById('modal-compatibility-content');
if (compatibility) {
const versions = workspace.locations.map(item => item.version).filter(Boolean);
const formats = workspace.locations.map(item => item.format).filter(Boolean);
compatibility.innerHTML = `<dl class="title-fact-grid">
<div><dt>${escapeHtml(tr('library.workspace.availability', 'Availability'))}</dt><dd>${escapeHtml(workspace.missingDrive ? tr('library.workspace.partialAvailability', 'Partial - drive unavailable') : workspace.locations.length ? tr('library.workspace.availableEvidence', 'Evidence available') : tr('library.workspace.unknown', 'Unknown'))}</dd></div>
<div><dt>${escapeHtml(tr('library.workspace.versions', 'Observed versions'))}</dt><dd>${escapeHtml([...new Set(versions)].join(', ') || tr('library.workspace.unknown', 'Unknown'))}</dd></div>
<div><dt>${escapeHtml(tr('library.workspace.formats', 'Observed formats'))}</dt><dd>${escapeHtml([...new Set(formats)].join(', ') || tr('library.workspace.unknown', 'Unknown'))}</dd></div>
<div><dt>${escapeHtml(tr('library.workspace.protection', 'Protection'))}</dt><dd>${escapeHtml(workspace.protectedCopy ? tr('library.workspace.protected', 'Protected copy recorded') : tr('library.workspace.unprotected', 'No protected copy recorded'))}</dd></div>
</dl>`;
window.chimeraCompatibilityGuidance?.load?.(compatibility, {
titleId: first(title.externalTitleId, title.game_id, title.gameId),
version: versions[0] || '', firmware: first(title.consoleFirmware, title.firmware),
loader: first(title.loader, title.hen), format: formats[0] || ''
});
}
setCount('mtab-copies-count', workspace.locations.length);
setCount('mtab-packages-count', workspace.packages.length);
setCount('mtab-saves-count', workspace.saves.length);
setCount('mtab-trophies-count', workspace.trophies.length);
setCount('mtab-media-count', workspace.media.length);
setCount('mtab-activity-count', workspace.activity.length);
return workspace;
}
return Object.freeze({ project, render });
});
+216
View File
@@ -0,0 +1,216 @@
// ── Payload Manager Settings modal ────────────────────────────────────────────
$('btn-payload-settings').addEventListener('click', async () => {
const s = await window.pork.getSettings();
$('pm-local-path').value = s.payloadLocalPath || '';
$('pm-remote-path').value = s.payloadRemotePath || '';
openAccessibleDialog($('payload-settings-overlay'), $('pm-remote-path'));
});
$('btn-pm-browse-local').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (folder) $('pm-local-path').value = folder;
});
$('btn-pm-settings-save').addEventListener('click', async () => {
await window.pork.setSettings({
payloadLocalPath: $('pm-local-path').value.trim(),
payloadRemotePath: $('pm-remote-path').value.trim(),
});
closeAccessibleDialog($('payload-settings-overlay'));
});
['payload-settings-close', 'btn-pm-settings-cancel'].forEach(id =>
$(id).addEventListener('click', () => closeAccessibleDialog($('payload-settings-overlay')))
);
$('payload-settings-overlay').addEventListener('click', event => {
if (event.target === $('payload-settings-overlay')) closeAccessibleDialog($('payload-settings-overlay'));
});
$('payload-settings-overlay').addEventListener('keydown', event => {
if (event.key === 'Escape') closeAccessibleDialog($('payload-settings-overlay'));
});
// ── Transfer Manager ──────────────────────────────────────────────────────────
function fmtSpeed(bps) {
if (!bps || bps < 0) return '—';
if (bps < 1024) return `${Math.round(bps)} B/s`;
if (bps < 1048576) return `${(bps / 1024).toFixed(1)} KB/s`;
return `${(bps / 1048576).toFixed(1)} MB/s`;
}
let _transferState = { jobs: [], paused: false, maxConcurrent: 1, activeCount: 0, queuedCount: 0, interruptedCount: 0, policy: {} };
function renderTransferPage(st) {
_transferState = st;
// Stats bar
$('tstat-active').textContent = st.activeCount;
$('tstat-queued').textContent = st.queuedCount;
$('tstat-interrupted').textContent = st.interruptedCount || 0;
$('tstat-concurrent').textContent = st.maxConcurrent;
const slider = $('transfer-concurrent-slider');
if (slider && parseInt(slider.value) !== st.maxConcurrent) slider.value = st.maxConcurrent;
if ($('transfer-window-start') && document.activeElement !== $('transfer-window-start')) $('transfer-window-start').value = st.policy?.windowStart || '';
if ($('transfer-window-end') && document.activeElement !== $('transfer-window-end')) $('transfer-window-end').value = st.policy?.windowEnd || '';
if ($('transfer-policy-state')) $('transfer-policy-state').textContent = st.policy?.windowStart && st.policy?.windowEnd
? `${st.policy.windowStart}–${st.policy.windowEnd} · ${window.t('transfers.policy.bandwidthUnsupported', 'bandwidth limiting is not supported by the current FTP layer')}`
: `${window.t('transfers.policy.noWindow', 'No time window')} · ${window.t('transfers.policy.bandwidthUnsupported', 'bandwidth limiting is not supported by the current FTP layer')}`;
// Total speed from all active jobs
const totalSpeed = st.jobs
.filter(j => j.status === 'active')
.reduce((sum, j) => sum + (j.progress?.speedBps || 0), 0);
$('tstat-speed').textContent = fmtSpeed(totalSpeed);
// Pause/resume button
const pauseBtn = $('btn-transfer-pause');
if (pauseBtn) {
pauseBtn.textContent = st.paused
? `▶ ${window.t('transfers.resumeAll', 'Resume All')}`
: `⏸ ${window.t('transfers.pauseAll', 'Pause All')}`;
pauseBtn.disabled = !st.paused && !st.jobs.some(job => ['queued', 'active', 'retrying'].includes(job.status));
}
const clearBtn = $('btn-transfer-clear');
if (clearBtn) clearBtn.disabled = !st.jobs.some(job => ['done', 'error', 'cancelled'].includes(job.status));
// Job list
const list = $('transfers-list');
const empty = $('transfers-empty');
const visibleJobs = st.jobs.filter(j => j.status !== 'cancelled');
if (!visibleJobs.length) {
list.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
list.innerHTML = visibleJobs.map(job => {
const statusText = {
queued: 'Queued',
active: 'Transferring…',
done: 'Done',
error: 'Error',
cancelled: 'Cancelled',
interrupted: window.t('transfers.status.interrupted', 'Interrupted after restart'),
retrying: 'Retrying…',
}[job.status] || job.status;
const typeLabel = { upload: 'UPLOAD', download: 'DOWNLOAD', pork: 'PORK' }[job.type] || (job.type || '').toUpperCase();
const typeClass = { upload: 'type-upload', download: 'type-download', pork: 'type-pork' }[job.type] || '';
const pct = job.progress?.percent || 0;
const speed = job.status === 'active' ? fmtSpeed(job.progress?.speedBps) : '';
const file = job.progress?.file || '';
const filesDone = job.progress?.filesDone || 0;
const filesTotal = job.progress?.filesTotal || 0;
const transferred = job.progress?.transferred || 0;
const total = job.progress?.total || 0;
const showCancel = job.cancellable !== false && ['queued', 'active', 'retrying'].includes(job.status);
const showRetry = ['error', 'interrupted'].includes(job.status) && job.retryable;
const showBar = job.status === 'active' || job.status === 'done';
const currentBytes = job.progress?.currentBytes || 0;
const currentFileSize = job.progress?.currentFileSize || 0;
// File counter or single-file byte progress
const sizeStr = filesTotal > 1
? `File ${filesDone}/${filesTotal}`
: (total > 0 ? `${fmt(transferred)} / ${fmt(total)}` : '');
// Current-file byte progress for directory uploads (shows activity on large files)
const fileByteStr = (filesTotal > 1 && currentFileSize > 0 && job.status === 'active')
? `${fmt(currentBytes)} / ${fmt(currentFileSize)}`
: '';
const pctLabel = (showBar && job.status === 'active') ? `${pct}%` : (job.status === 'done' ? '100%' : '');
return `
<div class="transfer-item transfer-item-${job.status}">
<div class="transfer-item-header">
<span class="transfer-label" title="${escHtml(job.localPath || job.remotePath || '')}">${escHtml(job.label)}</span>
<span class="transfer-type-badge ${typeClass}">${typeLabel}</span>
<span class="transfer-status-text">${statusText}</span>
${showRetry ? `<button class="btn btn-sm btn-retry-transfer" data-job-id="${escHtml(job.id)}">Retry</button>` : ''}
${showCancel ? `<button class="btn-cancel-transfer" data-job-id="${job.id}" title="Cancel">×</button>` : ''}
</div>
${showBar ? `
<div class="transfer-progress-wrap">
<div class="transfer-progress-bar">
<div class="transfer-progress-fill" style="width:${pct}%"></div>
</div>
${pctLabel ? `<span class="transfer-pct-label">${pctLabel}</span>` : ''}
</div>` : ''}
<div class="transfer-meta">
${speed ? `<span class="transfer-speed">${speed}</span>` : ''}
${file ? `<span class="transfer-file">${escHtml(file)}</span>` : ''}
${fileByteStr ? `<span class="transfer-sizes">${fileByteStr}</span>` : sizeStr ? `<span class="transfer-sizes">${sizeStr}</span>` : ''}
${job.error ? `<span class="transfer-error">${escHtml(job.error)}</span>` : ''}
${job.cancellable === false && ['queued', 'active', 'retrying'].includes(job.status) ? '<span class="transfer-error">Upstream offers no safe cancellation; only task status is tracked.</span>' : ''}
</div>
</div>`;
}).join('');
}
function updateNavTransferBadge(st) {
const badge = $('nav-transfer-badge');
if (!badge) return;
const n = (st.activeCount || 0) + (st.queuedCount || 0);
badge.hidden = n === 0;
badge.textContent = n;
}
async function loadTransfers() {
const st = await window.pork.transferState();
renderTransferPage(st);
updateNavTransferBadge(st);
}
$('btn-transfer-pause').addEventListener('click', async () => {
if (_transferState.paused) await window.pork.transferResume();
else await window.pork.transferPause();
});
$('btn-transfer-clear').addEventListener('click', () => window.pork.transferClearDone());
$('transfer-concurrent-slider').addEventListener('input', async event => {
const n = parseInt(event.target.value);
$('tstat-concurrent').textContent = n;
await window.pork.transferSetConcurrent(n);
});
$('transfers-list').addEventListener('click', event => {
const retry = event.target.closest('.btn-retry-transfer');
if (retry) {
window.pork.transferRetry?.(retry.dataset.jobId).catch(error => showToast(error.message, 'error'));
return;
}
const cancel = event.target.closest('.btn-cancel-transfer');
if (cancel?.dataset.jobId) window.pork.transferCancel(cancel.dataset.jobId);
});
$('btn-transfer-policy-save').addEventListener('click', async () => {
try {
await window.pork.transferSetPolicy?.({
windowStart: $('transfer-window-start').value || null,
windowEnd: $('transfer-window-end').value || null,
});
showToast(window.t('transfers.policy.saved', 'Transfer policy saved.'), 'ok');
} catch (error) {
showToast(error.message, 'error');
}
});
window.pork.on('transfer:update', state => {
renderTransferPage(state);
updateNavTransferBadge(state);
});
window.addEventListener('chimera:language-changed', () => {
if (document.querySelector('#page-transfers.active')) renderTransferPage(_transferState);
});
+707
View File
@@ -0,0 +1,707 @@
// ── PS Avatar ─────────────────────────────────────────────────────────────────
let _xavatarClient = null;
let _xavatarLastResult = null; // { buffer: ArrayBuffer, filename: string }
const _xavatarThumbCache = new Map(); // remotePath → blob URL
let _ps5ProfileInventory = null;
let _ps5ProfileSelectedUid = null;
let _ps5ProfileBound = false;
function ps5ProfileText(key, fallback) {
return typeof window.t === 'function' ? window.t(`ps5Profile.${key}`, fallback) : fallback;
}
function ps5ProfileSetAction(message, state = '') {
const target = $('ps5-profile-action-state');
if (!target) return;
target.textContent = message || '';
target.className = `ps5-profile-action-state${state ? ` is-${state}` : ''}`;
}
function ps5ProfileSelectedUser() {
return _ps5ProfileInventory?.users?.find(user => user.uid === _ps5ProfileSelectedUid) || null;
}
async function loadPs5ProfileRuntime() {
const card = document.querySelector('.ps5-profile-runtime');
const title = $('ps5-profile-runtime-title');
const state = $('ps5-profile-runtime-state');
const detail = $('ps5-profile-runtime-detail');
const button = $('btn-ps5-profile-start-payload');
if (!card || !state || !detail || !button || !window.pork?.ps5ProfileRuntimePreflight) return null;
card.classList.remove('is-ready');
button.disabled = true;
state.textContent = ps5ProfileText('runtimeCheckingShort', 'Checking…');
try {
const result = await window.pork.ps5ProfileRuntimePreflight();
if (title) title.textContent = `PS5Upload ${result.version || '5.4.19'}`;
const artifactsReady = result.engine?.verified && result.engine?.available && result.payload?.verified && result.console?.configured;
if (result.ready) {
card.classList.add('is-ready');
state.textContent = ps5ProfileText('runtimeReady', 'Ready to start');
detail.textContent = ps5ProfileText('runtimeReadyDetail', 'Exact engine and ELF hashes verified; the PS5 loader is reachable on port 9021.');
button.disabled = false;
} else if (artifactsReady && result.loader?.reason === 'unreachable') {
state.textContent = ps5ProfileText('runtimeLoaderClosed', 'Loader waiting');
detail.textContent = ps5ProfileText('runtimeLoaderHelp', 'Engine and ELF are verified. Start the jailbreak ELF loader on the PS5, then refresh this check.');
} else {
state.textContent = ps5ProfileText('runtimeBlocked', 'Preflight blocked');
detail.textContent = ps5ProfileText('runtimeBlockedDetail', 'A pinned artifact, configured PS5 host or local engine check failed.');
}
return result;
} catch (error) {
state.textContent = ps5ProfileText('runtimeBlocked', 'Preflight blocked');
detail.textContent = error.message;
return null;
}
}
function renderPs5ProfileOperations(operations = []) {
const target = $('ps5-profile-operations');
if (!target) return;
if (!operations.length) {
target.innerHTML = `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('noActivity', 'No prepared profile changes yet.'))}</div>`;
return;
}
target.innerHTML = operations.slice().reverse().slice(0, 8).map(operation => {
const canRollback = ['verified', 'verification_failed', 'failed', 'interrupted'].includes(operation.status);
const label = operation.kind === 'rename' ? ps5ProfileText('renameActivity', 'Display name') : ps5ProfileText('avatarActivity', 'Profile image');
const detail = operation.kind === 'rename' ? `${operation.snapshot?.username || ''} → ${operation.desired?.username || ''}` : operation.uidHex;
return `<article class="ps5-profile-operation" data-profile-operation="${escHtml(operation.id)}" data-profile-uid="${escHtml(operation.uidHex)}">
<div><strong>${escHtml(label)} <span class="ps5-profile-operation-status">${escHtml(operation.status)}</span></strong><small>${escHtml(detail)} · ${escHtml(new Date(operation.updatedAt || operation.createdAt).toLocaleString())}</small></div>
${canRollback ? `<button type="button" class="btn btn-sm ps5-profile-rollback">${escHtml(ps5ProfileText('rollback', 'Restore previous state'))}</button>` : ''}
</article>`;
}).join('');
}
function selectPs5LocalProfile(uid) {
_ps5ProfileSelectedUid = Number(uid);
const user = ps5ProfileSelectedUser();
document.querySelectorAll('.ps5-profile-user').forEach(card => card.classList.toggle('is-selected', Number(card.dataset.profileUid) === _ps5ProfileSelectedUid));
const name = $('ps5-profile-name');
const rename = $('btn-ps5-profile-rename');
const applyAvatar = $('btn-ps5-profile-apply-avatar');
if (!user) return;
$('ps5-profile-selected-title').textContent = user.username;
$('ps5-profile-selected-uid').textContent = user.uidHex;
$('ps5-profile-avatar-initial').textContent = [...user.username][0]?.toUpperCase() || '?';
name.value = user.username;
name.disabled = false;
rename.disabled = false;
applyAvatar.disabled = !_xavatarClient?._masterCanvas;
ps5ProfileSetAction(user.avatar?.available
? ps5ProfileText('avatarObserved', 'Current profile image evidence was read successfully.')
: ps5ProfileText('avatarDefault', 'This user currently has no recoverable custom image. Applying is blocked until exact recovery is possible.'));
const preview = $('ps5-profile-avatar-preview');
const shell = preview?.closest('.ps5-profile-avatar-shell');
if (preview && shell && !_xavatarClient?._masterCanvas) {
shell.classList.remove('has-preview');
window.pork.ps5ProfileCurrentAvatar({ uid: user.uid }).then(result => {
if (_ps5ProfileSelectedUid !== user.uid || !result?.dataUrl || _xavatarClient?._masterCanvas) return;
const image = new Image();
image.onload = () => { const context = preview.getContext('2d'); context.clearRect(0, 0, preview.width, preview.height); context.drawImage(image, 0, 0, preview.width, preview.height); shell.classList.add('has-preview'); };
image.src = result.dataUrl;
}).catch(() => {});
}
}
async function loadPs5LocalProfiles() {
const provider = $('ps5-profile-provider-state');
const usersTarget = $('ps5-profile-users');
if (!provider || !usersTarget || !window.pork?.ps5ProfileInspect) return;
if (!_ps5ProfileBound) bindPs5ProfileWorkspace();
loadPs5ProfileRuntime();
provider.className = 'ps5-profile-provider-state';
provider.textContent = ps5ProfileText('checking', 'Checking the guarded profile bridge…');
usersTarget.innerHTML = `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('loading', 'Reading local users…'))}</div>`;
try {
const [inventory, operations] = await Promise.all([window.pork.ps5ProfileInspect(), window.pork.ps5ProfileListOperations()]);
_ps5ProfileInventory = inventory;
renderPs5ProfileOperations(operations);
if (!inventory.available) {
provider.classList.add('is-blocked');
provider.textContent = ps5ProfileText('bridgeBlocked', 'Profile bridge unavailable · configure PS5 host and pinned PS5Upload 5.4.19');
usersTarget.innerHTML = `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('bridgeHelp', 'Start the pinned loopback bridge and configure the PS5 host. No console change is possible while this gate is closed.'))}</div>`;
return;
}
provider.classList.add('is-ready');
provider.textContent = `${ps5ProfileText('bridgeReady', 'Verified bridge')} · ${inventory.provider?.version || '5.4.19'}`;
usersTarget.innerHTML = inventory.users.length ? inventory.users.map(user => `<button type="button" class="ps5-profile-user" data-profile-uid="${user.uid}">
<span class="ps5-profile-user-avatar">${escHtml([...user.username][0]?.toUpperCase() || '?')}</span><span><strong>${escHtml(user.username)}</strong><small>${escHtml(user.uidHex)}</small></span>${user.foreground ? `<em>${escHtml(ps5ProfileText('active', 'Active'))}</em>` : ''}
</button>`).join('') : `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('noUsers', 'No local users were reported.'))}</div>`;
const preferred = inventory.users.find(user => user.uid === _ps5ProfileSelectedUid) || inventory.users.find(user => user.foreground) || inventory.users[0];
if (preferred) selectPs5LocalProfile(preferred.uid);
} catch (error) {
provider.classList.add('is-blocked');
provider.textContent = ps5ProfileText('bridgeError', 'Profile bridge check failed');
usersTarget.innerHTML = `<div class="ps5-profile-empty">${escHtml(error.message)}</div>`;
}
}
function bindPs5ProfileWorkspace() {
if (_ps5ProfileBound) return;
_ps5ProfileBound = true;
$('btn-ps5-profile-refresh')?.addEventListener('click', loadPs5LocalProfiles);
$('btn-ps5-profile-start-payload')?.addEventListener('click', async () => {
const button = $('btn-ps5-profile-start-payload');
const confirmation = await showExactConfirmation(
ps5ProfileText('startPayloadConfirm', 'Send only the verified PS5Upload 5.4.19 ELF to the configured PS5 loader? This payload remains active until rest mode or reboot.'),
'START PS5 PROFILE PAYLOAD',
);
if (!confirmation) return;
button.disabled = true;
ps5ProfileSetAction(ps5ProfileText('startingPayload', 'Sending the verified payload and checking the management service…'));
try {
const result = await window.pork.ps5ProfileStartPayload({ confirmation });
if (!result.managementReady) throw new Error(ps5ProfileText('payloadNotReady', 'The ELF was sent, but the PS5 management service did not become ready. Check the jailbreak and firmware compatibility.'));
ps5ProfileSetAction(ps5ProfileText('payloadReady', 'Verified payload active. Reading local profiles…'), 'ok');
await loadPs5LocalProfiles();
} catch (error) {
ps5ProfileSetAction(error.message, 'error');
await loadPs5ProfileRuntime();
}
});
$('ps5-profile-users')?.addEventListener('click', event => {
const card = event.target.closest('.ps5-profile-user');
if (card) selectPs5LocalProfile(card.dataset.profileUid);
});
$('btn-ps5-profile-rename')?.addEventListener('click', async () => {
const user = ps5ProfileSelectedUser();
if (!user) return;
ps5ProfileSetAction(ps5ProfileText('preparing', 'Creating recovery snapshot and checking fresh evidence…'));
try {
const operation = await window.pork.ps5ProfilePrepareRename({ uid: user.uid, name: $('ps5-profile-name').value });
const confirmation = await showExactConfirmation(ps5ProfileText('renameConfirm', 'Apply this local display name after the recovery snapshot and fresh-evidence check?'), operation.confirmation);
if (!confirmation) return void loadPs5LocalProfiles();
await window.pork.ps5ProfileApply({ id: operation.id, confirmation });
ps5ProfileSetAction(ps5ProfileText('verifiedRestart', 'Verified on the console. Restart the PS5 to show the change everywhere.'), 'ok');
await loadPs5LocalProfiles();
} catch (error) { ps5ProfileSetAction(error.message, 'error'); }
});
$('btn-ps5-profile-apply-avatar')?.addEventListener('click', async () => {
const user = ps5ProfileSelectedUser();
if (!user || !_xavatarClient?._masterCanvas) return;
ps5ProfileSetAction(ps5ProfileText('converting', 'Building the canonical profile image set…'));
try {
const converted = await _xavatarClient.convert();
const operation = await window.pork.ps5ProfilePrepareAvatar({ uid: user.uid, archive: converted.buffer });
const confirmation = await showExactConfirmation(ps5ProfileText('avatarConfirm', 'Apply this reviewed image after preserving the current profile image?'), operation.confirmation);
if (!confirmation) return void loadPs5LocalProfiles();
await window.pork.ps5ProfileApply({ id: operation.id, confirmation });
ps5ProfileSetAction(ps5ProfileText('verifiedRestart', 'Verified on the console. Restart the PS5 to show the change everywhere.'), 'ok');
await loadPs5LocalProfiles();
} catch (error) { ps5ProfileSetAction(error.message, 'error'); }
});
$('ps5-profile-operations')?.addEventListener('click', async event => {
const button = event.target.closest('.ps5-profile-rollback');
const row = button?.closest('.ps5-profile-operation');
if (!row) return;
const phrase = `ROLLBACK PS5 PROFILE ${row.dataset.profileUid}`;
const confirmation = await showExactConfirmation(ps5ProfileText('rollbackConfirm', 'Restore the exact local profile state captured before this operation?'), phrase);
if (!confirmation) return;
button.disabled = true;
try {
await window.pork.ps5ProfileRollback({ id: row.dataset.profileOperation, confirmation });
ps5ProfileSetAction(ps5ProfileText('rollbackVerified', 'Previous local profile state restored and verified.'), 'ok');
await loadPs5LocalProfiles();
} catch (error) { ps5ProfileSetAction(error.message, 'error'); button.disabled = false; }
});
}
async function loadXavatarCompanion() {
const status = $('xavatar-companion-status');
const button = $('btn-xavatar-install-companion');
if (!status || !button || !window.pork.xavatarCompanion) return;
try {
const companion = await window.pork.xavatarCompanion();
button.disabled = !companion.installerReady;
button.dataset.ready = companion.installerReady ? 'true' : 'false';
status.textContent = companion.installerReady
? `${companion.titleId} · v${companion.version} · ${(companion.size / 1024 / 1024).toFixed(1)} MB · etaHEN DPI ready`
: `${companion.titleId} · v${companion.version} · ${companion.installerError}`;
status.title = `Expected SHA-256: ${companion.sha256}`;
} catch (error) {
button.disabled = true;
status.textContent = `Installer check failed: ${error.message}`;
}
if (button.dataset.bound) return;
button.dataset.bound = 'true';
button.addEventListener('click', async () => {
const confirmation = await showExactConfirmation(
"Install Lapy's Avatar Changer through etaHEN Direct Package Installer? The PS5 downloads the fixed reviewed package URL; Chimera does not launch the app.",
'INSTALL XAVATAR',
);
if (!confirmation) return;
button.disabled = true;
status.textContent = 'Submitting package to etaHEN DPI…';
try {
await window.pork.xavatarInstallCompanion(confirmation);
status.textContent = 'Installation submitted to etaHEN DPI. Follow the package progress on your PS5.';
showToast('Avatar Changer installation submitted.', 'ok', 9000);
} catch (error) {
status.textContent = `Installation failed: ${error.message}`;
showToast(error.message, 'error', 10000);
}
await loadXavatarCompanion();
});
}
function loadXavatar() {
// Refresh the library on every navigate; only initialise the converter once.
loadXavatarLibrary();
loadXavatarCompanion();
loadPs5LocalProfiles();
if (_xavatarClient) return;
if (!window.XavatarClient) {
$('xavatar-status').textContent = 'XavatarClient not available — ensure the module script loaded.';
return;
}
// Build the API object — prefer the dedicated xavatarAPI bridge; fall back to
// the pork bridge channel so conversion still works even if the separate
// xavatarElectronModule preload failed to expose window.xavatarAPI.
let _xavApi = window.xavatarAPI;
if (!_xavApi) {
console.info('[Chimera] Dedicated XAvatar bridge unavailable; using the supported web compatibility path.');
_xavApi = {
async convertFromCanvas(canvas, opts = {}) {
const ctx = canvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const rgba = new Uint8Array(imgData.data.buffer);
const res = await window.pork.xavatarConvertCanvas(
rgba, canvas.width, canvas.height, opts.filename || 'avatar'
);
if (!res || !res.ok) throw new Error((res && res.error) || 'Conversion failed');
return { buffer: res.buffer && res.buffer.buffer ? res.buffer.buffer : res.buffer, filename: res.filename };
}
};
}
_xavatarClient = new window.XavatarClient({ api: _xavApi });
const dropZone = $('xavatar-drop-zone');
const fileInput = $('xavatar-file-input');
const preview = $('xavatar-preview');
const status = $('xavatar-status');
const convertBtn = $('btn-xavatar-convert');
const sendBtn = $('btn-xavatar-send-ps5');
const convProgress = $('xavatar-conv-progress');
const convBar = $('xavatar-conv-bar');
function _xavatarSetProgress(pct, label) {
if (!convProgress) return;
convProgress.hidden = pct == null;
if (pct != null) {
convBar.style.width = `${pct}%`;
convBar.textContent = label || '';
}
}
_xavatarClient.on('image-ready', (canvas) => {
const ctx = preview.getContext('2d');
preview.width = canvas.width;
preview.height = canvas.height;
ctx.drawImage(canvas, 0, 0);
preview.hidden = false;
convertBtn.disabled = false;
sendBtn.disabled = false;
_xavatarLastResult = null; // new image — invalidate previous conversion
_xavatarSetProgress(null);
status.textContent = 'Image loaded — convert & download, or send directly to PS5';
dropZone.classList.add('xavatar-drop-zone--has-image');
const profilePreview = $('ps5-profile-avatar-preview');
const profileShell = profilePreview?.closest('.ps5-profile-avatar-shell');
if (profilePreview && profileShell) {
const profileContext = profilePreview.getContext('2d');
profileContext.clearRect(0, 0, profilePreview.width, profilePreview.height);
profileContext.drawImage(canvas, 0, 0, profilePreview.width, profilePreview.height);
profileShell.classList.add('has-preview');
$('btn-ps5-profile-apply-avatar').disabled = !ps5ProfileSelectedUser();
}
});
_xavatarClient.on('load-error', (err) => {
status.textContent = 'Failed to load image: ' + err.message;
});
_xavatarClient.on('convert-start', () => {
convertBtn.disabled = true;
convertBtn.textContent = 'Converting…';
status.textContent = 'Converting…';
});
_xavatarClient.on('convert-error', (err) => {
convertBtn.disabled = false;
convertBtn.textContent = 'Convert & Download';
_xavatarSetProgress(null);
status.textContent = 'Error: ' + err.message;
});
_xavatarClient.wireFileInput(fileInput);
_xavatarClient.wireDropZone(dropZone);
_xavatarClient.wirePaste(document);
$('btn-xavatar-browse').addEventListener('click', () => fileInput.click());
dropZone.addEventListener('click', (e) => {
if (e.target !== $('btn-xavatar-browse')) fileInput.click();
});
dropZone.addEventListener('dragover', () => dropZone.classList.add('xavatar-drop-zone--over'));
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('xavatar-drop-zone--over'));
dropZone.addEventListener('drop', () => dropZone.classList.remove('xavatar-drop-zone--over'));
convertBtn.addEventListener('click', async () => {
status.textContent = '';
_xavatarSetProgress(null);
try {
const result = await _xavatarClient.convert();
_xavatarLastResult = result;
convertBtn.disabled = false;
convertBtn.textContent = 'Convert & Download';
const saved = await window.pork.xavatarSaveFile(result.buffer, result.filename);
status.textContent = saved?.saved ? `Saved: ${result.filename}` : 'Converted — not saved.';
} catch (err) {
convertBtn.disabled = false;
convertBtn.textContent = 'Convert & Download';
if (!$('xavatar-status').textContent) {
status.textContent = 'Error: ' + err.message;
}
}
});
sendBtn.addEventListener('click', async () => {
if (!_xavatarClient?._masterCanvas) return;
convertBtn.disabled = true;
sendBtn.disabled = true;
sendBtn.textContent = 'Working…';
status.textContent = '';
try {
// ── Step 1: Convert ──────────────────────────────────────────────────
_xavatarSetProgress(10, 'Converting…');
status.textContent = 'Converting image to .xavatar…';
const result = await _xavatarClient.convert();
_xavatarLastResult = result;
// ── Step 2: Random filename ──────────────────────────────────────────
const pad = n => String(n).padStart(5, '0');
const rnd = pad(Math.floor(Math.random() * 100000));
const rndName = `chimera${rnd}.xavatar`;
_xavatarSetProgress(55, 'Uploading…');
status.textContent = `Queuing upload → /data/AVATARS/${rndName}…`;
// ── Step 3: Upload ──────────────────────────────────────────────────
await window.pork.xavatarUploadToPs5(result.buffer, rndName);
_xavatarSetProgress(100, 'Queued ✓');
status.textContent = `Queued → /data/AVATARS/${rndName}`;
sendBtn.textContent = '↗ Send to PS5';
sendBtn.disabled = false;
convertBtn.disabled = false;
convertBtn.textContent = 'Convert & Download';
if ($('xavatar-notify-toggle')?.checked) {
window.pork.psnotifySend('XAvatar Queued', rndName).catch(() => {});
}
setTimeout(() => _xavatarSetProgress(null), 3000);
} catch (e) {
_xavatarSetProgress(null);
convertBtn.disabled = false;
sendBtn.disabled = false;
sendBtn.textContent = '↗ Send to PS5';
convertBtn.textContent = 'Convert & Download';
status.textContent = 'Error: ' + e.message;
}
});
$('btn-xavatar-refresh-lib').addEventListener('click', loadXavatarLibrary);
}
function _xavatarFmtSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
async function loadXavatarLibrary() {
const listEl = $('xavatar-library-list');
const emptyEl = $('xavatar-library-empty');
if (!listEl) return;
listEl.innerHTML = '<div class="jb-list-placeholder" style="padding:18px 14px">Loading…</div>';
emptyEl.hidden = true;
try {
const ftpStatus = await window.pork.ftpStatus().catch(() => ({ connected: false }));
if (!ftpStatus?.connected) {
listEl.innerHTML = '';
emptyEl.hidden = false;
emptyEl.textContent = 'Connect to PS5 FTP to browse /data/AVATARS.';
return;
}
const files = await window.pork.ftpListDir('/data/AVATARS');
const xavs = files.filter(f => !f.isDir);
if (!xavs.length) {
listEl.innerHTML = '';
emptyEl.hidden = false;
return;
}
emptyEl.hidden = true;
listEl.innerHTML = '<div class="xavatar-lib-grid">' +
xavs.map(f => {
const rpath = `/data/AVATARS/${escHtml(f.name)}`;
return `<div class="xavatar-lib-card" data-xa-card-path="${rpath}" data-xa-card-name="${escHtml(f.name)}">
<div class="xavatar-thumb-wrap">
<img class="xavatar-thumb" data-xa-path="${rpath}" src="" alt="${escHtml(f.name)}">
<div class="xavatar-thumb-placeholder">&#128100;</div>
</div>
<div class="xavatar-lib-card-info">
<span class="xavatar-lib-card-name" title="${escHtml(f.name)}">${escHtml(f.name)}</span>
<span class="xavatar-lib-card-size">${_xavatarFmtSize(f.size)}</span>
</div>
<div class="xavatar-lib-card-actions">
<button class="btn btn-xs" title="Download .xavatar" data-xa="download" data-xa-path="${rpath}" data-xa-name="${escHtml(f.name)}">&#8659;</button>
<button class="btn btn-xs btn-danger" title="Delete from PS5" data-xa="delete" data-xa-path="${rpath}" data-xa-name="${escHtml(f.name)}">&#10005;</button>
</div>
</div>`;
}).join('') + '</div>';
_setupXavatarThumbObserver(listEl);
} catch (err) {
listEl.innerHTML = '';
emptyEl.hidden = false;
emptyEl.textContent = 'Failed to load library: ' + err.message;
}
}
/** Attach IntersectionObserver to lazily load thumbnails as cards scroll into view */
function _setupXavatarThumbObserver(container) {
const imgs = container.querySelectorAll('img.xavatar-thumb');
if (!imgs.length) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
observer.unobserve(img);
_loadXavatarThumb(img, img.dataset.xaPath);
}
});
}, { rootMargin: '120px', threshold: 0.01 });
imgs.forEach(img => observer.observe(img));
}
/** Fetch + extract avatar.png from a .xavatar on FTP, cache as blob URL */
async function _getXavatarThumbUrl(rpath) {
let url = _xavatarThumbCache.get(rpath);
if (!url) {
const xavBuf = await window.pork.ftpDownloadFile(rpath);
const pngBuf = await window.pork.xavatarExtractPng(xavBuf);
const blob = new Blob([pngBuf], { type: 'image/png' });
url = URL.createObjectURL(blob);
_xavatarThumbCache.set(rpath, url);
}
return url;
}
/** Load one thumbnail into an img element */
async function _loadXavatarThumb(img, rpath) {
const card = img.closest?.('.xavatar-lib-card');
try {
const url = await _getXavatarThumbUrl(rpath);
img.src = url;
img.onload = () => card?.classList.add('xavatar-thumb-loaded');
img.onerror = () => card?.classList.add('xavatar-thumb-error');
} catch (_) {
card?.classList.add('xavatar-thumb-error');
}
}
// ── XAvatar full-size modal ───────────────────────────────────────────────────
let _xavatarModalPath = null;
function _xavatarOpenModal(rpath, name) {
_xavatarModalPath = rpath;
const modal = $('xavatar-thumb-modal');
const img = $('xavatar-modal-img');
const namEl = $('xavatar-modal-name');
img.src = '';
img.alt = 'Loading…';
namEl.textContent = name;
openAccessibleDialog(modal, $('btn-xavatar-modal-close'));
// reset modal send button
const sendBtn = $('btn-xavatar-modal-send');
if (sendBtn) { sendBtn.disabled = false; sendBtn.textContent = '↗ Send to PS5'; }
_getXavatarThumbUrl(rpath)
.then(url => { if (_xavatarModalPath === rpath) { img.src = url; img.alt = name; } })
.catch(() => { if (_xavatarModalPath === rpath) img.alt = 'Preview unavailable'; });
}
// Close modal on backdrop click or close button
document.addEventListener('click', e => {
const modal = $('xavatar-thumb-modal');
if (!modal || modal.hidden) return;
if (e.target === modal || e.target.id === 'btn-xavatar-modal-close') {
closeAccessibleDialog(modal);
_xavatarModalPath = null;
}
});
document.addEventListener('keydown', event => {
const modal = $('xavatar-thumb-modal');
if (event.key === 'Escape' && modal && !modal.hidden) {
closeAccessibleDialog(modal);
_xavatarModalPath = null;
}
});
// ── XAvatar: format picker dialog ──────────────────────────────────────────
function _xavatarPickFormat() {
return new Promise(resolve => {
const dlg = document.createElement('dialog');
dlg.style.cssText = [
'border:none',
'border-radius:10px',
'padding:22px 26px 18px',
'background:var(--bg2,#1e1e2e)',
'color:var(--fg,#cdd6f4)',
'box-shadow:0 8px 32px rgba(0,0,0,.65)',
'text-align:center',
'min-width:260px',
'position:fixed',
'top:50%',
'left:50%',
'transform:translate(-50%,-50%)',
'margin:0',
].join(';');
dlg.innerHTML = [
'<p id="_xa-fmt-title" style="margin:0 0 16px;font-size:14px;font-weight:600;">Choose download format</p>',
'<div style="display:flex;gap:10px;justify-content:center;">',
' <button class="btn btn-teal" id="_xa-fmt-xav" style="min-width:96px;">.xavatar</button>',
' <button class="btn" id="_xa-fmt-png" style="min-width:96px;">.png</button>',
' <button class="btn" id="_xa-fmt-cancel" style="min-width:74px;opacity:.7;">Cancel</button>',
'</div>',
].join('');
dlg.setAttribute('aria-labelledby', '_xa-fmt-title');
document.body.appendChild(dlg);
dlg.showModal();
const done = (val) => { dlg.removeEventListener('close', onClose); dlg.close(); dlg.remove(); resolve(val); };
const onClose = () => { dlg.remove(); resolve(null); };
dlg.addEventListener('close', onClose);
dlg.querySelector('#_xa-fmt-xav').addEventListener('click', () => done('xavatar'));
dlg.querySelector('#_xa-fmt-png').addEventListener('click', () => done('png'));
dlg.querySelector('#_xa-fmt-cancel').addEventListener('click', () => done(null));
});
}
// Modal action buttons
function _xavatarBindModalButtons() {
const modalDownload = $('btn-xavatar-modal-download');
const modalToPng = $('btn-xavatar-modal-topng');
const modalSend = $('btn-xavatar-modal-send');
if (!modalDownload) return;
modalDownload.addEventListener('click', async () => {
if (!_xavatarModalPath) return;
const fmt = await _xavatarPickFormat();
if (!fmt) return;
const name = $('xavatar-modal-name')?.textContent || 'avatar.xavatar';
modalDownload.disabled = true;
try {
const buf = await window.pork.ftpDownloadFile(_xavatarModalPath);
if (fmt === 'png') {
const pngName = name.replace(/\.xavatar$/i, '') + '.png';
const pngBuf = await window.pork.xavatarExtractPng(buf);
await window.pork.xavatarSaveFile(pngBuf, pngName);
} else {
await window.pork.xavatarSaveFile(buf, name);
}
} catch (err) { showToast('Download failed: ' + err.message, 'error'); }
modalDownload.disabled = false;
});
modalToPng.addEventListener('click', async () => {
if (!_xavatarModalPath) return;
const name = $('xavatar-modal-name')?.textContent || 'avatar';
const pngName = name.replace(/\.xavatar$/i, '') + '.png';
modalToPng.disabled = true;
try {
const xavBuf = await window.pork.ftpDownloadFile(_xavatarModalPath);
const pngBuf = await window.pork.xavatarExtractPng(xavBuf);
await window.pork.xavatarSaveFile(pngBuf, pngName);
} catch (err) { showToast('PNG save failed: ' + err.message, 'error'); }
modalToPng.disabled = false;
});
modalSend.addEventListener('click', async () => {
if (!_xavatarModalPath) return;
const name = $('xavatar-modal-name')?.textContent || 'avatar.xavatar';
modalSend.disabled = true;
modalSend.textContent = 'Sending…';
try {
const buf = await window.pork.ftpDownloadFile(_xavatarModalPath);
await window.pork.xavatarUploadToPs5(buf, name);
modalSend.textContent = '↗ Send to PS5';
modalSend.disabled = false;
showToast(`Queued: /data/AVATARS/${name}`, 'success');
} catch (err) {
showToast('Upload failed: ' + err.message, 'error');
modalSend.textContent = '↗ Send to PS5';
modalSend.disabled = false;
}
});
}
// Bind now if the DOM is already parsed, otherwise wait — this module may be
// loaded after DOMContentLoaded has already fired.
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _xavatarBindModalButtons);
else _xavatarBindModalButtons();
// ── XAvatar: card thumbnail click → open modal ────────────────────────────────
document.addEventListener('click', e => {
const card = e.target.closest('.xavatar-lib-card');
if (card && !e.target.closest('[data-xa]')) {
_xavatarOpenModal(card.dataset.xaCardPath, card.dataset.xaCardName);
}
});
// ── XAvatar: library card action buttons (↓ download / ✕ delete) ─────────────
document.addEventListener('click', async e => {
const btn = e.target.closest('[data-xa]');
if (!btn) return;
const action = btn.dataset.xa;
const rpath = btn.dataset.xaPath;
const name = btn.dataset.xaName;
btn.disabled = true;
try {
if (action === 'download') {
const fmt = await _xavatarPickFormat();
if (!fmt) { btn.disabled = false; return; }
const buf = await window.pork.ftpDownloadFile(rpath);
if (fmt === 'png') {
const pngName = name.replace(/\.xavatar$/i, '') + '.png';
const pngBuf = await window.pork.xavatarExtractPng(buf);
await window.pork.xavatarSaveFile(pngBuf, pngName);
} else {
await window.pork.xavatarSaveFile(buf, name);
}
} else if (action === 'delete') {
// Native browser dialogs are blocked in this app; use the in-app modal.
const confirmation = await showExactConfirmation(`Delete /data/AVATARS/${name} from your PS5?`, 'VERWIJDER VAN PS5');
if (!confirmation) { btn.disabled = false; return; }
await window.pork.ftpDeleteFile(rpath, confirmation);
btn.closest('.xavatar-lib-card')?.remove();
_xavatarThumbCache.delete(rpath);
const grid = $('xavatar-library-list')?.querySelector('.xavatar-lib-grid');
if (grid && !grid.children.length) $('xavatar-library-empty').hidden = false;
}
} catch (err) {
showToast('XAvatar: ' + err.message, 'error');
}
btn.disabled = false;
});
+191
View File
@@ -0,0 +1,191 @@
// ── Y2JB Update Generator ───────────────────────────────────────────────────
const y2jbGen = { entries: [], payloads: [], catalog: [] };
const y2jbGenBytes = value => {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
function y2jbGenRender() {
const list = $('y2jbgen-list');
if (!list) return;
if (!y2jbGen.entries.length) {
list.innerHTML = '<p class="hint autoloader-empty-hint">No entries yet. Add payload files, then order payloads, delays, and messages.</p>';
return;
}
list.innerHTML = y2jbGen.entries.map((entry, index) => {
const value = escHtml(entry.value || '');
const title = entry.type === 'payload' ? 'Payload' : entry.type === 'delay' ? 'Delay (ms)' : 'Message';
const placeholder = entry.type === 'payload' ? 'payload.elf' : entry.type === 'delay' ? '1000' : 'Shown during autoload';
return `<div class="autoloader-row" data-y2jb-index="${index}">
<span class="autoload-row-num">${index + 1}</span>
<select class="y2jbgen-type" data-y2jb-type="${index}" aria-label="Y2JB entry type">
<option value="payload" ${entry.type === 'payload' ? 'selected' : ''}>Payload</option>
<option value="delay" ${entry.type === 'delay' ? 'selected' : ''}>Delay</option>
<option value="message" ${entry.type === 'message' ? 'selected' : ''}>Message</option>
</select>
<input class="y2jbgen-value" data-y2jb-value="${index}" value="${value}" placeholder="${placeholder}" aria-label="${title}">
<button class="btn btn-xs" data-y2jb-up="${index}" title="Move up">&#8593;</button>
<button class="btn btn-xs" data-y2jb-down="${index}" title="Move down">&#8595;</button>
<button class="btn btn-xs" data-y2jb-remove="${index}" title="Remove">&#10005;</button>
</div>`;
}).join('');
}
function y2jbGenSetStatus(text, type = '') {
const status = $('y2jbgen-status');
status.textContent = text;
status.className = `hint ${type}`;
}
async function y2jbGenLoadCatalog() {
const select = $('y2jbgen-catalog');
if (!select || !window.pork.y2jbGenCatalog) return;
select.disabled = true;
select.innerHTML = '<option>Loading local payloads…</option>';
try {
const packet = await window.pork.y2jbGenCatalog();
y2jbGen.catalog = packet.items || [];
select.innerHTML = y2jbGen.catalog.length
? y2jbGen.catalog.map(item => `<option value="${escHtml(item.id)}">${escHtml(item.name)}${item.version ? ` · ${escHtml(item.version)}` : ''} · ${y2jbGenBytes(item.size)}</option>`).join('')
: '<option disabled>No downloaded payloads yet — use the Online Payload Catalog first.</option>';
y2jbGenSetStatus(y2jbGen.catalog.length
? `${y2jbGen.catalog.length} local catalog payload${y2jbGen.catalog.length === 1 ? '' : 's'} available.`
: 'The local Payload Catalog is empty.');
} catch (error) {
select.innerHTML = '<option disabled>Catalog unavailable</option>';
y2jbGenSetStatus(`Catalog error: ${error.message}`, 'error');
}
select.disabled = false;
}
$('btn-y2jbgen-refresh-catalog').addEventListener('click', y2jbGenLoadCatalog);
$('btn-y2jb-discover')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try {
const release = await window.pork.y2jbDiscover(true);
$('y2jb-upstream-status').innerHTML = `<strong>${escHtml(release.version || 'Latest official Y2JB release')}</strong><span>${escHtml(release.source)} · ${y2jbGenBytes(release.size || 0)} · ${release.digest ? 'published SHA-256 available' : 'ZIP validation; no published SHA-256'}</span>`;
} catch (error) {
$('y2jb-upstream-status').innerHTML = `<strong>Release discovery unavailable</strong><span>${escHtml(error.message)}</span>`;
} finally { button.disabled = false; }
});
$('btn-y2jb-stage')?.addEventListener('click', async event => {
if (!await showConfirm('Download the discovered official Y2JB ZIP, validate it and copy it to the configured PS5 update folder? The update is not executed.')) return;
const button = event.currentTarget;
button.disabled = true;
try {
const result = await window.pork.y2jbApply();
$('y2jb-upstream-status').innerHTML = `<strong>Verified update staged</strong><span>${escHtml(result.version || 'Y2JB')} · ${escHtml(result.remotePath)} · SHA-256 ${escHtml(result.sha256.slice(0, 16))}… · not executed</span>`;
} catch (error) {
$('y2jb-upstream-status').innerHTML = `<strong>Staging failed</strong><span>${escHtml(error.message)}</span>`;
} finally { button.disabled = false; }
});
$('btn-y2jbgen-add-catalog').addEventListener('click', () => {
const selected = [...$('y2jbgen-catalog').selectedOptions];
let added = 0;
for (const option of selected) {
const payload = y2jbGen.catalog.find(item => item.id === option.value);
if (!payload || y2jbGen.entries.some(entry => entry.type === 'payload' && entry.value === payload.name)) continue;
y2jbGen.entries.push({ type: 'payload', value: payload.name, catalogId: payload.id });
added += 1;
}
y2jbGenRender();
y2jbGenSetStatus(added ? `Added ${added} catalog payload${added === 1 ? '' : 's'} ✓` : 'No new catalog payloads selected.');
});
$('btn-y2jbgen-add-payloads').addEventListener('click', async () => {
const btn = $('btn-y2jbgen-add-payloads');
btn.disabled = true;
y2jbGenSetStatus('Choosing payload files…');
try {
const chosen = await window.pork.y2jbGenSelectPayloads();
let added = 0;
for (const payload of chosen || []) {
if (y2jbGen.payloads.some((item) => item.name === payload.name)
|| y2jbGen.entries.some(entry => entry.type === 'payload' && entry.value === payload.name)) continue;
y2jbGen.payloads.push(payload);
y2jbGen.entries.push({ type: 'payload', value: payload.name });
added += 1;
}
y2jbGenRender();
y2jbGenSetStatus(added ? `Added ${added} payload file${added === 1 ? '' : 's'} ✓` : 'No new payload files selected.');
} catch (error) {
y2jbGenSetStatus(`Error: ${error.message}`, 'error');
}
btn.disabled = false;
});
$('btn-y2jbgen-add-delay').addEventListener('click', () => {
y2jbGen.entries.push({ type: 'delay', value: '1000' });
y2jbGenRender();
});
$('btn-y2jbgen-add-message').addEventListener('click', () => {
y2jbGen.entries.push({ type: 'message', value: 'Starting next payload' });
y2jbGenRender();
});
$('y2jbgen-list').addEventListener('input', (event) => {
const index = Number(event.target.dataset.y2jbValue);
if (Number.isInteger(index) && y2jbGen.entries[index]) y2jbGen.entries[index].value = event.target.value;
});
$('y2jbgen-list').addEventListener('change', (event) => {
const index = Number(event.target.dataset.y2jbType);
if (Number.isInteger(index) && y2jbGen.entries[index]) {
y2jbGen.entries[index].type = event.target.value;
if (event.target.value === 'delay' && !/^\d+$/.test(y2jbGen.entries[index].value)) y2jbGen.entries[index].value = '1000';
y2jbGenRender();
}
});
$('y2jbgen-list').addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button) return;
const up = Number(button.dataset.y2jbUp);
const down = Number(button.dataset.y2jbDown);
const remove = Number(button.dataset.y2jbRemove);
if (Number.isInteger(up) && y2jbGen.entries[up] && up > 0) {
[y2jbGen.entries[up - 1], y2jbGen.entries[up]] = [y2jbGen.entries[up], y2jbGen.entries[up - 1]];
} else if (Number.isInteger(down) && y2jbGen.entries[down] && down < y2jbGen.entries.length - 1) {
[y2jbGen.entries[down + 1], y2jbGen.entries[down]] = [y2jbGen.entries[down], y2jbGen.entries[down + 1]];
} else if (Number.isInteger(remove) && y2jbGen.entries[remove]) {
y2jbGen.entries.splice(remove, 1);
} else {
return;
}
y2jbGenRender();
});
$('btn-y2jbgen-build').addEventListener('click', async () => {
const btn = $('btn-y2jbgen-build');
btn.disabled = true;
y2jbGenSetStatus('Validating and building ZIP…');
try {
const referenced = new Set(y2jbGen.entries.filter(entry => entry.type === 'payload').map(entry => entry.value));
const catalogPayloadIds = y2jbGen.entries
.filter(entry => entry.type === 'payload' && entry.catalogId && referenced.has(entry.value))
.map(entry => entry.catalogId);
const result = await window.pork.y2jbGenBuild({
entries: y2jbGen.entries.map(({ catalogId, ...entry }) => entry),
payloads: y2jbGen.payloads.filter(payload => referenced.has(payload.name)),
catalogPayloadIds,
});
if (result?.canceled) {
y2jbGenSetStatus('Save cancelled.');
} else {
y2jbGenSetStatus(`Built ✓ ${result.outputZip} (${result.entryCount} entries)`);
}
} catch (error) {
y2jbGenSetStatus(`Error: ${error.message}`, 'error');
}
btn.disabled = false;
});
y2jbGenLoadCatalog();