637 lines
31 KiB
JavaScript
637 lines
31 KiB
JavaScript
// ── 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}%`;
|
|
});
|
|
|