Files
chimera-Public/renderer/modules/savemgr.js
T

562 lines
26 KiB
JavaScript

// ── Local Save Manager (native GarlicMgr UI) ────────────────────────────────
// ── State ───────────────────────────────────────────────────────────────────
const smState = {
saves: [], selectedIdx: -1, mounted: false,
files: [], expanded: new Set(),
busy: false, tab: 'browse',
};
let _smHasSuccessfulWarm = false;
// ── Terminal log ─────────────────────────────────────────────────────────────
function smLog(msg, cls) {
const logEl = $('sm-log');
if (!logEl) return;
const t = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = 'sm-ll' + (cls ? ' sm-' + cls : '');
div.innerHTML = `<span class="sm-lt">${t}</span><span>${escHtml(msg)}</span>`;
logEl.appendChild(div);
logEl.scrollTop = logEl.scrollHeight;
}
// ── Confirmation modal (native browser dialogs are blocked) ─────────────────
function smConfirm(message) {
return new Promise(resolve => {
const bg = document.createElement('div');
bg.style.cssText = 'position:fixed;inset:0;z-index:9900;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;';
bg.setAttribute('aria-hidden', 'true');
bg.innerHTML = `<div role="dialog" aria-modal="true" aria-labelledby="smconf-message" tabindex="-1" style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px 28px;max-width:360px;width:90%;">
<p id="smconf-message" style="font-size:14px;color:var(--text);margin-bottom:20px;line-height:1.5;">${escHtml(message)}</p>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="btn btn-sm" id="smconf-skip">Skip</button>
<button class="btn btn-sm btn-accent" id="smconf-ok">Replace</button>
</div></div>`;
document.body.appendChild(bg);
const cleanup = result => {
closeAccessibleDialog(bg);
bg.remove();
resolve(result);
};
bg.querySelector('#smconf-skip').addEventListener('click', () => cleanup(false));
bg.querySelector('#smconf-ok').addEventListener('click', () => cleanup(true));
bg.addEventListener('keydown', event => { if (event.key === 'Escape') cleanup(false); });
openAccessibleDialog(bg, bg.querySelector('#smconf-skip'));
});
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function smFmtBytes(b) {
if (!b) return '0B';
if (b < 1024) return b + 'B';
if (b < 1048576) return (b / 1024).toFixed(1) + 'K';
return (b / 1048576).toFixed(1) + 'M';
}
function smSetBusy(v) {
smState.busy = v;
['btn-sm-dl-zip','btn-sm-dl-enc','btn-sm-dump-usb','btn-sm-upload','btn-sm-unmount']
.forEach(id => { const el = $(id); if (el) el.disabled = v; });
}
function smShowUsbProgress(pct) {
const bar = $('sm-usb-progress'); if (bar) bar.hidden = false;
const fill = $('sm-usb-fill'); if (fill) fill.style.width = pct + '%';
}
function smHideUsbProgress() { const bar = $('sm-usb-progress'); if (bar) bar.hidden = true; }
function smShowUploadProgress(pct, loaded, total) {
const bar = $('sm-upload-progress'); if (bar) bar.hidden = false;
const fill = $('sm-upload-fill'); if (fill) fill.style.width = pct + '%';
const txt = $('sm-upload-text'); if (txt) txt.textContent = `${smFmtBytes(loaded)} / ${smFmtBytes(total)} (${pct}%)`;
}
function smHideUploadProgress() { const bar = $('sm-upload-progress'); if (bar) bar.hidden = true; }
// ── Load / reload saves ──────────────────────────────────────────────────────
async function loadSaveMgr(options = {}) {
const background = !!options.background;
const s = await window.pork.getSettings();
const ip = s.ftpHost || '';
const port = s.savemgrPort || 8082;
$('sm-ip-display').textContent = ip || '(not set — configure FTP Settings)';
$('sm-port').value = port;
const ssp = $('s-savemgr-port'); if (ssp) ssp.value = port;
if (ip && (!background || _smHasSuccessfulWarm)) await smFetchSaves();
}
async function smFetchSaves() {
try {
const d = await window.pork.savemgrRequest('/api/saves');
smState.saves = d.saves || [];
_smHasSuccessfulWarm = true;
smRenderSaves();
smLog(`Loaded ${smState.saves.length} saves`, 'ok');
} catch (e) {
smLog('Failed to load saves: ' + e.message, 'err');
smLog('Start the session only when needed with “Start session”.');
smState.saves = [];
smRenderSaves();
}
}
function smRenderSaves() {
const el = $('sm-saves-list');
if (!smState.saves.length) {
el.innerHTML = '<div class="sm-empty-list">No saves found</div>';
return;
}
el.innerHTML = smState.saves.map((s, i) =>
`<div class="sm-save-item${i === smState.selectedIdx ? ' active' : ''}" data-idx="${i}">
<div class="sm-save-tid">${escHtml(s.title_id)}</div>
<div class="sm-save-name">${escHtml(s.save_name)}</div>
</div>`
).join('');
el.querySelectorAll('.sm-save-item').forEach(item =>
item.addEventListener('click', () => smSelectSave(parseInt(item.dataset.idx)))
);
}
async function smSelectSave(idx) {
if (smState.busy || smState.tab !== 'browse') return;
smState.selectedIdx = idx;
smRenderSaves();
await smMount(idx);
}
async function smMount(idx) {
if (smState.busy) return;
smSetBusy(true);
const s = smState.saves[idx];
smLog(`Mounting ${s.title_id}/${s.save_name}...`);
try {
const d = await window.pork.savemgrRequest(`/api/mount?idx=${idx}`);
if (d.error) { smLog('Mount failed: ' + d.error, 'err'); smSetBusy(false); return; }
smState.mounted = true;
smState.files = d.files || [];
smState.expanded = new Set(smState.files.filter(f => f.dir).map(f => f.name));
$('sm-save-title-text').textContent = d.save_title || 'Untitled';
$('sm-save-title-id').innerHTML = d.title_id ? `Title ID: <b>${escHtml(d.title_id)}</b>` : '';
$('sm-save-account-id').innerHTML = d.account_id ? `&nbsp;&nbsp;Account: <b style="font-family:monospace">${escHtml(d.account_id)}</b>` : '';
$('sm-save-ftp-row').textContent = d.mount ? `FTP: ${d.mount}` : '';
$('sm-save-header').hidden = false;
$('sm-empty-state').hidden = true;
smLoadIcon();
smRenderFileTree();
smLog(`Mounted ${[d.title_id, d.save_title, d.account_id ? '['+d.account_id+']' : ''].filter(Boolean).join(' ')}`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
async function smLoadIcon() {
try {
const dataUrl = await window.pork.savemgrIcon();
const img = $('sm-game-icon');
if (dataUrl) { img.src = dataUrl; img.hidden = false; }
else img.hidden = true;
} catch (_) { $('sm-game-icon').hidden = true; }
}
// ── File tree ────────────────────────────────────────────────────────────────
function smRenderFileTree() {
const tree = $('sm-filetree');
tree.hidden = false;
let h = '';
smState.files.forEach((f, i) => {
const parts = f.name.split('/'); parts.pop();
let visible = true, pp = '';
for (let j = 0; j < parts.length; j++) {
pp = j ? pp + '/' + parts[j] : parts[j];
if (!smState.expanded.has(pp)) { visible = false; break; }
}
if (!visible) return;
const depth = f.name.split('/').length - 1;
const name = f.name.split('/').pop();
const pad = 12 + depth * 16;
if (f.dir) {
const open = smState.expanded.has(f.name);
h += `<div class="sm-fi sm-fi-dir" style="padding-left:${pad}px" data-fidx="${i}">
<span class="sm-fi-arrow">${open ? '▾' : '▸'}</span> 📁 ${escHtml(name)}</div>`;
} else {
const fname = escHtml(f.name).replace(/'/g, "\\'");
h += `<div class="sm-fi" style="padding-left:${pad}px" data-fname="${escHtml(f.name)}">
📄 ${escHtml(name)}<span class="sm-fi-sz">${smFmtBytes(f.size)}</span></div>`;
}
});
h += '<div class="sm-tip">Tip: drag &amp; drop files here to add or replace them</div>';
tree.innerHTML = h;
tree.querySelectorAll('.sm-fi-dir').forEach(el =>
el.addEventListener('click', () => {
const name = smState.files[parseInt(el.dataset.fidx)].name;
if (smState.expanded.has(name)) smState.expanded.delete(name);
else smState.expanded.add(name);
smRenderFileTree();
})
);
tree.querySelectorAll('.sm-fi:not(.sm-fi-dir)').forEach(el =>
el.addEventListener('contextmenu', e => { e.preventDefault(); smShowCtxMenu(e, el.dataset.fname); })
);
smSetupDropZone(tree, smHandleFileDrop);
}
// ── Context menu ─────────────────────────────────────────────────────────────
document.addEventListener('click', () => { const m = $('sm-ctx-menu'); if (m) m.remove(); });
function smShowCtxMenu(e, fname) {
const old = $('sm-ctx-menu'); if (old) old.remove();
const m = document.createElement('div');
m.className = 'sm-ctx'; m.id = 'sm-ctx-menu';
m.innerHTML = `<div data-action="download">Download</div>
<div data-action="upload">Upload File</div>
<div class="sm-ctx-del" data-action="delete">Delete</div>`;
m.style.left = e.clientX + 'px'; m.style.top = e.clientY + 'px';
document.body.appendChild(m);
const r = m.getBoundingClientRect();
if (r.right > window.innerWidth) m.style.left = (window.innerWidth - r.width - 4) + 'px';
if (r.bottom > window.innerHeight) m.style.top = (window.innerHeight - r.height - 4) + 'px';
m.querySelectorAll('[data-action]').forEach(item => item.addEventListener('click', () => {
m.remove();
if (item.dataset.action === 'download') smDownloadFile(fname);
else if (item.dataset.action === 'upload') smPickUploadFile();
else if (item.dataset.action === 'delete') smDeleteFile(fname);
}));
}
// ── File operations ──────────────────────────────────────────────────────────
async function smDownloadFile(fname) {
smLog(`Downloading ${fname}...`);
try {
await window.pork.savemgrDownload(`/api/download_file?name=${encodeURIComponent(fname)}`, fname.split('/').pop());
smLog(`Downloaded ${fname.split('/').pop()}`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
}
function smPickUploadFile() {
const input = document.createElement('input');
input.type = 'file'; input.multiple = true;
input.onchange = () => { if (input.files.length) smHandleFileDrop(Array.from(input.files)); };
input.click();
}
async function smDeleteFile(fname) {
const ok = await smConfirm(`Delete ${fname}?`);
if (!ok) return;
try {
const d = await window.pork.savemgrRequest(`/api/delete_file?name=${encodeURIComponent(fname)}`);
if (d.error) throw new Error(d.error);
smLog(`Deleted ${fname}`, 'ok');
await smRefreshFiles();
} catch (e) { smLog('Error: ' + e.message, 'err'); }
}
async function smRefreshFiles() {
const d = await window.pork.savemgrRequest('/api/files');
smState.files = d.files || [];
smState.expanded = new Set(smState.files.filter(f => f.dir).map(f => f.name));
smRenderFileTree();
}
async function smHandleFileDrop(files) {
if (smState.busy || !smState.mounted) return;
smSetBusy(true);
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.name.endsWith('.zip')) {
smLog(`Extracting ${file.name} (${smFmtBytes(file.size)})...`);
const buf = await file.arrayBuffer();
const d = await window.pork.savemgrUpload('/api/upload', buf);
smLog(`Extracted ${d.files} files`, 'ok');
} else {
const ce = await window.pork.savemgrRequest(`/api/file_exists?name=${encodeURIComponent(file.name)}`);
if (ce.exists) {
const replace = await smConfirm(`${file.name} already exists. Replace it?`);
if (!replace) { smLog(`Skipped ${file.name}`); continue; }
}
smLog(`Uploading ${file.name} (${smFmtBytes(file.size)})...`);
const buf = await file.arrayBuffer();
const d = await window.pork.savemgrUpload(`/api/upload_file?name=${encodeURIComponent(file.name)}`, buf);
smHideUploadProgress();
if (d.ok) smLog(`Added ${file.name}`, 'ok');
else smLog(d.error || 'Upload failed', 'err');
}
}
await smRefreshFiles();
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
smHideUploadProgress();
}
// ── Browse actions ────────────────────────────────────────────────────────────
async function smDownloadZip() {
if (smState.busy) return; smSetBusy(true);
const s = smState.saves[smState.selectedIdx];
const filename = s ? `${s.title_id}_${s.save_name}.zip` : 'save.zip';
smLog('Downloading decrypted zip...');
try {
await window.pork.savemgrDownload('/api/download', filename);
smLog(`Downloaded ${filename}`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
async function smDownloadEncrypted() {
if (smState.busy || smState.selectedIdx < 0) return; smSetBusy(true);
const s = smState.saves[smState.selectedIdx];
smLog('Unmounting and downloading encrypted save...');
try {
await window.pork.savemgrRequest('/api/unmount');
smState.mounted = false;
await window.pork.savemgrDownload(`/api/download_raw?idx=${smState.selectedIdx}`, `${s.title_id}_${s.save_name}`);
smState.selectedIdx = -1; smState.files = []; smState.expanded = new Set();
smRenderSaves();
$('sm-save-header').hidden = true; $('sm-filetree').hidden = true; $('sm-empty-state').hidden = false;
smLog('Downloaded encrypted save', 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
async function smDumpToUsb() {
if (smState.busy || smState.selectedIdx < 0) return; smSetBusy(true);
smLog('Dumping to USB...');
window.pork.on('savemgr:usb-progress', data => { smShowUsbProgress(data.progress); });
try {
const result = await window.pork.savemgrDumpUsb(smState.selectedIdx);
window.pork.off('savemgr:usb-progress');
smHideUsbProgress();
smLog(result.path ? `Dumped to ${result.path} (${smFmtBytes(result.size)})` : 'Dump complete', 'ok');
} catch (e) { window.pork.off('savemgr:usb-progress'); smHideUsbProgress(); smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
async function smUnmount() {
if (smState.busy) return; smSetBusy(true);
try {
await window.pork.savemgrRequest('/api/unmount');
smState.mounted = false; smState.selectedIdx = -1; smState.files = []; smState.expanded = new Set();
smRenderSaves();
$('sm-save-header').hidden = true; $('sm-filetree').hidden = true; $('sm-empty-state').hidden = false;
smLog('Unmounted', 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
// ── Sub-tab switching ─────────────────────────────────────────────────────────
function smSwitchTab(tab) {
smState.tab = tab;
document.querySelectorAll('.sm-tab').forEach(btn =>
btn.classList.toggle('active', btn.dataset.smTab === tab)
);
document.querySelectorAll('.sm-pane').forEach(pane =>
pane.hidden = (pane.id !== `sm-pane-${tab}`)
);
if (tab === 'browse') smFetchSaves();
}
// ── Decrypt drop ──────────────────────────────────────────────────────────────
async function smHandleDecryptDrop(files) {
const file = files[0]; if (!file || smState.busy) return;
smSetBusy(true);
smLog(`Uploading ${file.name} (${smFmtBytes(file.size)}) for decrypt...`);
try {
const buf = await file.arrayBuffer();
const filename = file.name.replace(/\.[^.]+$/, '') + '.zip';
const r = await window.pork.savemgrDecrypt(buf, filename);
if (r.canceled) smLog('Cancelled');
else smLog(`Decrypted → ${filename} (${smFmtBytes(r.size)})`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
// ── Encrypt drop ──────────────────────────────────────────────────────────────
async function smHandleEncryptDrop(files, folderName) {
if (!files.length || smState.busy) return;
const aid = $('sm-enc-aid').value.trim();
smSetBusy(true);
let totalSize = 0; for (const f of files) totalSize += f.size;
smLog(`Creating PFS image (${smFmtBytes(totalSize)} of files)...`);
try {
const pfs = await window.pork.savemgrCreatePfs(totalSize);
if (pfs.error) throw new Error(pfs.error);
smLog('PFS image created', 'ok');
for (let i = 0; i < files.length; i++) {
const f = files[i];
const raw = f._path || f.webkitRelativePath || f.name;
const parts = raw.split('/');
const relPath = parts.length > 1 ? parts.slice(1).join('/') : parts[0];
smLog(`Uploading (${i+1}/${files.length}) ${relPath}...`);
const buf = await f.arrayBuffer();
const d = await window.pork.savemgrUpload(`/api/upload_file?name=${encodeURIComponent(relPath)}`, buf);
if (!d.ok) throw new Error(d.error || `Upload failed: ${relPath}`);
}
smLog(`All ${files.length} files uploaded`, 'ok');
const name = folderName || 'encrypted_save';
smLog('Downloading encrypted save...');
const r = await window.pork.savemgrDownloadNew(name, aid);
if (r.canceled) smLog('Cancelled');
else smLog(`Encrypted save downloaded (${smFmtBytes(r.size)})`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
// ── Resign drop ───────────────────────────────────────────────────────────────
async function smHandleResignDrop(files) {
const file = files[0]; if (!file || smState.busy) return;
const aid = $('sm-resign-aid').value.trim();
if (!aid) { smLog('Enter new Account ID first', 'err'); return; }
smSetBusy(true);
smLog(`Uploading ${file.name} for resign...`);
try {
const buf = await file.arrayBuffer();
const filename = file.name.replace(/\.[^.]+$/, '') + '_resigned';
const r = await window.pork.savemgrResign(buf, aid, filename);
if (r.canceled) smLog('Cancelled');
else smLog(`Resigned → ${filename} (${smFmtBytes(r.size)})`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
// ── Drop zone setup ───────────────────────────────────────────────────────────
function smSetupDropZone(el, handler, folderMode) {
el.ondragover = e => { e.preventDefault(); el.classList.add('sm-drag-over'); };
el.ondragleave = e => { e.preventDefault(); el.classList.remove('sm-drag-over'); };
el.ondrop = async e => {
e.preventDefault(); el.classList.remove('sm-drag-over');
if (folderMode) {
const items = e.dataTransfer.items, entries = [];
for (let i = 0; i < items.length; i++) {
const ent = items[i].webkitGetAsEntry?.(); if (ent) entries.push(ent);
}
if (entries.length && entries[0].isDirectory) {
const files = await smReadFolderEntries(entries[0], '');
handler(files, entries[0].name);
} else if (e.dataTransfer.files.length) handler(Array.from(e.dataTransfer.files));
} else {
if (e.dataTransfer.files.length) handler(Array.from(e.dataTransfer.files));
}
};
const btn = el.querySelector('.sm-browse-btn');
if (btn) btn.onclick = e => {
e.stopPropagation();
const input = document.createElement('input'); input.type = 'file';
if (folderMode) input.webkitdirectory = true;
input.onchange = () => {
if (!input.files.length) return;
if (folderMode) { const prefix = input.files[0].webkitRelativePath.split('/')[0]; handler(Array.from(input.files), prefix); }
else handler(Array.from(input.files));
};
input.click();
};
}
async function smReadFolderEntries(entry, basePath) {
return new Promise(resolve => {
if (entry.isFile) { entry.file(f => { f._path = basePath + f.name; resolve([f]); }); }
else if (entry.isDirectory) {
const reader = entry.createReader(), all = [];
const readBatch = () => reader.readEntries(entries => {
if (!entries.length) { Promise.all(all).then(arrs => resolve(arrs.flat())); return; }
for (const e of entries) all.push(smReadFolderEntries(e, basePath + entry.name + '/'));
readBatch();
});
readBatch();
} else resolve([]);
});
}
// ── Event wiring (run once on startup) ───────────────────────────────────────
(function smInitEvents() {
document.querySelectorAll('.sm-tab').forEach(btn =>
btn.addEventListener('click', () => smSwitchTab(btn.dataset.smTab))
);
$('btn-sm-start-session').addEventListener('click', async event => {
const button = event.currentTarget;
const s = await window.pork.getSettings();
const ip = s.ftpHost || '';
const port = parseInt($('sm-port').value) || 8082;
if (!ip) { showToast('PS5 IP not set. Configure FTP Settings first.', 'error'); return; }
button.disabled = true;
await window.pork.setSettings({ savemgrPort: port });
smLog('Starting Garlic Save Manager explicitly through PS5 Payload Manager…');
try {
await window.pork.savemgrStart();
smLog('Garlic Save Manager started', 'ok');
showToast('Save Manager session started.', 'ok');
await smFetchSaves();
} catch (error) {
smLog('Start failed: ' + error.message, 'err');
showToast(`Save Manager failed to start: ${error.message}`, 'error');
} finally {
button.disabled = false;
}
});
$('btn-sm-load').addEventListener('click', async () => {
const s = await window.pork.getSettings();
const ip = s.ftpHost || '';
const port = parseInt($('sm-port').value) || 8082;
if (!ip) { showToast('PS5 IP not set. Configure FTP Settings first.', 'error'); return; }
await window.pork.setSettings({ savemgrPort: port });
smFetchSaves();
});
$('btn-sm-save').addEventListener('click', async () => {
const port = parseInt($('sm-port').value) || 8082;
await window.pork.setSettings({ savemgrPort: port });
const ssp = $('s-savemgr-port'); if (ssp) ssp.value = port;
showToast('Save Manager port saved.', 'ok');
});
$('btn-sm-open-ext').addEventListener('click', async () => {
const s = await window.pork.getSettings();
const ip = s.ftpHost || ''; const port = parseInt($('sm-port').value) || 8082;
if (!ip) { showToast('PS5 IP not set.', 'error'); return; }
window.pork.openShell(`http://${ip}:${port}`);
});
$('btn-sm-dl-zip').addEventListener('click', smDownloadZip);
$('btn-sm-dl-enc').addEventListener('click', smDownloadEncrypted);
$('btn-sm-dump-usb').addEventListener('click', smDumpToUsb);
$('btn-sm-unmount').addEventListener('click', smUnmount);
$('btn-sm-upload').addEventListener('click', smPickUploadFile);
smSetupDropZone($('sm-decrypt-drop'), smHandleDecryptDrop, false);
smSetupDropZone($('sm-encrypt-drop'), (f, n) => smHandleEncryptDrop(f, n), true);
smSetupDropZone($('sm-resign-drop'), smHandleResignDrop, false);
// Drag-drop onto the right panel (file browser)
const rp = $('sm-right-pane');
rp.ondragover = e => { e.preventDefault(); if (smState.mounted) rp.classList.add('sm-drag-over'); };
rp.ondragleave = e => { e.preventDefault(); rp.classList.remove('sm-drag-over'); };
rp.ondrop = e => {
e.preventDefault(); rp.classList.remove('sm-drag-over');
if (smState.mounted && e.dataTransfer.files.length) smHandleFileDrop(Array.from(e.dataTransfer.files));
};
$('btn-sm-push-payload').addEventListener('click', async () => {
const confirmation = 'INSTALLEER GARLIC AUTOLOAD';
const entered = await showPrompt(
`Deze actie schrijft garlic-savemgr.elf en autoload.txt naar je PS5. Typ exact ${confirmation} om door te gaan:`,
''
);
if (entered !== confirmation) {
showToast('Installation cancelled: the confirmation phrase did not match exactly.', 'info');
return;
}
const btn = $('btn-sm-push-payload'), st = $('sm-push-status');
btn.disabled = true; st.textContent = 'Installing\u2026';
try {
const res = await window.pork.savemgrPushPayload({ confirmation });
st.textContent = res.appended
? 'Installed \u2713 \u2014 garlic-savemgr.elf uploaded and added to autoload.txt. Restart your PS5 to apply.'
: 'Installed \u2713 \u2014 garlic-savemgr.elf uploaded (already in autoload.txt). Restart your PS5 to apply.';
} catch (e) { st.textContent = 'Error: ' + e.message; }
btn.disabled = false;
});
const ssBtn = $('btn-s-savemgr-save');
if (ssBtn) ssBtn.addEventListener('click', async () => {
const port = parseInt($('s-savemgr-port').value) || 8082;
await window.pork.setSettings({ savemgrPort: port });
showToast('Save Manager port saved.', 'ok');
});
})();