Initial curated chimera source from a8ae5b1db07fb49926c94585e2718e5130330974
This commit is contained in:
commit
c737a37f71
531 files changed
+119270
No files matched your search
@@ -0,0 +1,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">✕</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">⋮⋮</span>
|
||||
<span class="al-delay-clock">⏱</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">⋮⋮</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">+⏱</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">📄</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">⇣</button>
|
||||
<span class="al-file-add">+</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)}">↑ Restore</button>
|
||||
<button class="btn btn-sm btn-snap-export" data-snap-id="${escHtml(s.id)}" title="Export snapshot to .porksnap file">↘ Export</button>
|
||||
<button class="btn btn-sm btn-snap-delete" data-snap-id="${escHtml(s.id)}" title="Delete snapshot">✕</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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── 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 ? '✓' : '+';
|
||||
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;
|
||||
@@ -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}">↻ 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">
|
||||
🔧 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 });
|
||||
});
|
||||
@@ -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 →</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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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">📢 Send Notification</div>
|
||||
<input class="dash-widget-input" data-dw-key="psnotify-msg" placeholder="Message…" maxlength="120">
|
||||
<input class="dash-widget-input" data-dw-key="psnotify-sub" placeholder="Sub-message (optional)…" maxlength="120">
|
||||
<div class="dash-widget-row">
|
||||
<button class="btn btn-sm btn-teal dash-widget-btn" data-dw-action="psnotify-send">📢 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 →</button>
|
||||
</div>`;
|
||||
|
||||
case 'payload-mgr':
|
||||
return `
|
||||
<div class="dash-widget-hdr">⇨ Quick Payload Send</div>
|
||||
<select class="dash-widget-select" data-dw-key="payload-file">
|
||||
<option value="">Loading payloads…</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">⇨ 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 →</button>
|
||||
</div>`;
|
||||
|
||||
case 'xavatar-convert':
|
||||
return `
|
||||
<div class="dash-widget-hdr">👤 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…</button>
|
||||
<button class="btn btn-sm btn-teal dash-widget-btn" data-dw-action="xavatar-send" disabled>↗ 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 →</button>
|
||||
</div>`;
|
||||
|
||||
case 'dash-savemgr':
|
||||
return `
|
||||
<div class="dash-widget-hdr">💾 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">↻ 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 →</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 →</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}">⬇ 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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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(', '))}">🔧 ${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' : ''}">◉ 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'))}">✓</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.'))}">⚠ ${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'))}">
|
||||
⬆ ${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'))}">
|
||||
⚙ ${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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)}">🔧 ${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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -0,0 +1,77 @@
|
||||
(function installDestinationSelector() {
|
||||
'use strict';
|
||||
|
||||
let options = [];
|
||||
let requiredBytes = 0;
|
||||
|
||||
const esc = value => String(value == null ? '' : value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
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()) });
|
||||
})();
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)}">▶ 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)}">⇓ 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)}">🔗 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
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
'use strict';
|
||||
(function () {
|
||||
let catalog = { sets: [], stats: {} };
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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);
|
||||
})();
|
||||
@@ -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">✕</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">↓ ${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">✓ ${escHtml(window.t('payload.status.current', 'Up to date'))}</span>`;
|
||||
return `<span class="badge badge-yellow">↓ ${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
|
||||
? `↓ ${escHtml(window.t('payload.action.update', 'Update'))}`
|
||||
: `↓ ${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">↑ 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">↑ 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">🗑</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)}">⇒ 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}%`;
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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 ? ` 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 & 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');
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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 “+ Add Folder…” 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">✕</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">✕</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();
|
||||
});
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// 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 || ''));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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 ? '✕' : '⛶';
|
||||
}
|
||||
|
||||
$('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); })
|
||||
);
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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">👤</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)}">⇓</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)}">✕</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;
|
||||
});
|
||||
|
||||
@@ -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">↑</button>
|
||||
<button class="btn btn-xs" data-y2jb-down="${index}" title="Move down">↓</button>
|
||||
<button class="btn btn-xs" data-y2jb-remove="${index}" title="Remove">✕</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();
|
||||
Reference in new issue
Block a user