Files

862 lines
42 KiB
JavaScript

function darkenHex(hex, amount = 0.18, satCap = null) {
const r = parseInt(hex.slice(1,3), 16) / 255;
const g = parseInt(hex.slice(3,5), 16) / 255;
const b = parseInt(hex.slice(5,7), 16) / 255;
const max = Math.max(r,g,b), min = Math.min(r,g,b);
let h = 0, s = 0, l = (max+min)/2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d/(2-max-min) : d/(max+min);
switch(max) {
case r: h = ((g-b)/d + (g<b?6:0))/6; break;
case g: h = ((b-r)/d + 2)/6; break;
case b: h = ((r-g)/d + 4)/6; break;
}
}
l = Math.max(0, l - amount);
if (satCap !== null) s = Math.min(s, satCap);
const hue2rgb = (p, q, t) => {
if (t<0) t+=1; if (t>1) t-=1;
if (t<1/6) return p+(q-p)*6*t;
if (t<1/2) return q;
if (t<2/3) return p+(q-p)*(2/3-t)*6;
return p;
};
const q = l < 0.5 ? l*(1+s) : l+s-l*s;
const p = 2*l - q;
const toHex = v => Math.round(hue2rgb(p,q,v)*255).toString(16).padStart(2,'0');
return `#${toHex(h+1/3)}${toHex(h)}${toHex(h-1/3)}`;
}
function applyAccent(hex) {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return;
// --accent = tint (text/borders); --accent-d = fill (buttons); --accent-rgb tracks the fill.
const fill = darkenHex(hex, 0.26, 0.78);
const r = parseInt(fill.slice(1,3), 16);
const g = parseInt(fill.slice(3,5), 16);
const b = parseInt(fill.slice(5,7), 16);
const root = document.documentElement;
root.style.setProperty('--accent', hex);
root.style.setProperty('--accent-d', fill);
root.style.setProperty('--accent-rgb', `${r}, ${g}, ${b}`);
const swatch = $('accent-swatch');
const picker = $('s-accent-color');
const hexEl = $('s-accent-hex');
if (swatch) swatch.style.background = fill;
if (picker) picker.value = hex;
if (hexEl) hexEl.value = hex.toUpperCase();
}
let settingsTimer = null;
let remoteGamePaths = [];
let localBackupPaths = [];
let _userDirty = false;
let _passDirty = false;
const settingsT = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback;
function renderConsoleProfiles(packet) {
const select = $('console-profile-active');
if (!select) return;
select.innerHTML = (packet.profiles || []).map(profile => `<option value="${escHtml(profile.id)}">${escHtml(profile.label)} · ${escHtml(profile.host || settingsT('settings.status.hostUnknown', 'host unknown'))}${profile.active ? ` · ${settingsT('settings.status.active', 'active')}` : ''}</option>`).join('');
select.value = packet.activeId || '';
const active = (packet.profiles || []).find(profile => profile.id === packet.activeId);
$('console-profile-status').textContent = active
? `${active.platform} · Remote Play ${active.secrets?.remotePlayKey ? settingsT('settings.status.linked', 'linked') : settingsT('settings.status.notLinked', 'not linked')} · Sentinel ${active.secrets?.sentinelPairingKey ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')}`
: settingsT('settings.status.noActiveProfile', 'No active profile');
}
function renderConsoleIdentity(identity) {
if (!$('settings-identity-profile')) return;
const unavailable = settingsT('settings.identity.unavailable', 'Not observed');
$('settings-identity-profile').textContent = identity?.profileLabel || unavailable;
$('settings-identity-local').textContent = identity?.local?.username || unavailable;
$('settings-identity-account').textContent = identity?.local?.accountIdMasked || unavailable;
$('settings-identity-psn').textContent = identity?.linkedPsn?.linked && identity.linkedPsn.onlineId ? identity.linkedPsn.onlineId : unavailable;
const observed = identity?.provenance?.observedAt ? new Date(identity.provenance.observedAt).toLocaleString() : settingsT('settings.identity.noTimestamp', 'no timestamp');
$('settings-identity-evidence').textContent = `${identity?.provenance?.source || 'unknown'} · ${observed}`;
}
async function loadConsoleProfiles() {
if (!window.pork.consoleProfiles || !$('console-profile-active')) return;
renderConsoleProfiles(await window.pork.consoleProfiles());
if (window.pork.consoleIdentity) renderConsoleIdentity(await window.pork.consoleIdentity());
}
function renderCompanionWorkers(workers) {
const target = $('companion-workers-list');
if (!target) return;
target.innerHTML = workers.length ? workers.map(worker => `<div class="ecosystem-service-row" data-worker-id="${escHtml(worker.id)}"><div><strong>${escHtml(worker.label)}</strong><span>${escHtml(worker.endpoint)} · ${worker.paired ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')}</span></div><div class="operations-inline-actions"><button class="btn btn-sm companion-worker-test">Capabilities</button><button class="btn btn-sm companion-worker-pair">${worker.paired ? settingsT('settings.companion.rotateKey', 'Rotate key') : 'Pairing key'}</button><button class="btn btn-sm btn-danger companion-worker-remove">${settingsT('btn.remove', 'Remove')}</button></div></div>`).join('') : `<p class="hint">${settingsT('settings.companion.empty', 'No companion workers configured.')}</p>`;
}
async function loadCompanionWorkers() {
if (window.pork.companionWorkers && $('companion-workers-list')) renderCompanionWorkers(await window.pork.companionWorkers());
}
async function loadTotpStatus() {
if (!$('totp-settings-status')) return;
if (!window.pork.totpStatus) {
$('totp-settings-status').textContent = settingsT('settings.twoFactor.unavailable', 'Unavailable in this runtime');
$('totp-settings-begin').disabled = true;
$('totp-settings-disable').hidden = true;
$('passkey-readiness').innerHTML = `<strong>${escHtml(settingsT('settings.passkeys.preparedTitle', 'Passkeys: safely prepared, not active yet'))}</strong><p>${escHtml(settingsT('settings.passkeys.runtimeUnavailable', 'This runtime does not expose the required enrollment service.'))}</p>`;
return;
}
let status;
try {
status = await Promise.race([
window.pork.totpStatus(),
new Promise((_, reject) => setTimeout(() => reject(new Error(settingsT('settings.twoFactor.timeout', 'Status check timed out'))), 3500)),
]);
} catch (error) {
$('totp-settings-status').textContent = settingsT('settings.twoFactor.unavailable', 'Temporarily unavailable');
$('totp-settings-status').title = error.message;
$('totp-settings-begin').disabled = true;
return;
}
$('totp-settings-begin').disabled = false;
$('totp-settings-status').textContent = status.enabled
? `${settingsT('settings.status.active', 'Active')} · ${status.recoveryCodesRemaining} ${settingsT('settings.twoFactor.codesRemaining', 'recovery codes remaining')}`
: status.pending ? settingsT('settings.twoFactor.pending', 'Pairing started but not confirmed') : settingsT('settings.status.inactive', 'Inactive');
$('totp-settings-begin').textContent = status.enabled ? settingsT('settings.twoFactor.repair', 'Pair authenticator again') : settingsT('settings.twoFactor.pair', 'Pair authenticator');
$('totp-settings-disable').hidden = !status.enabled;
$('passkey-readiness').innerHTML = `<strong>Passkeys: ${status.passkeys?.available ? settingsT('settings.status.available', 'available') : settingsT('settings.passkeys.prepared', 'safely prepared, not active yet')}</strong><p>${escHtml(status.passkeys?.reason || settingsT('settings.status.none', 'No status.'))}</p>`;
}
async function loadGenericNotificationStatus() {
if (!window.pork.genericNotificationStatus || !$('generic-notification-status')) return;
const status = await window.pork.genericNotificationStatus();
$('generic-notification-enabled').checked = status.enabled;
$('generic-notification-endpoint').value = '';
$('generic-notification-endpoint').placeholder = status.endpointConfigured ? `${settingsT('settings.notifications.configuredFor', 'Configured for')} ${status.endpointHost}; ${settingsT('settings.notifications.leaveBlank', 'leave blank to keep')}` : 'https://notify.example/chimera';
$('generic-notification-token').value = '';
$('generic-notification-cooldown').value = status.cooldownMinutes || 60;
$('generic-notification-rules').innerHTML = (status.availableRules || []).map(rule => `<label class="ecosystem-check"><input type="checkbox" data-notification-rule="${escHtml(rule)}" ${status.rules?.includes(rule) ? 'checked' : ''}> ${escHtml(settingsT(`settings.notifications.rule.${rule}`, rule.replace(/-/g, ' ')))}</label>`).join('');
$('generic-notification-status').textContent = `${status.format} · endpoint ${status.endpointConfigured ? status.endpointHost : settingsT('settings.status.notConfigured', 'not configured')} · bearer ${status.bearerConfigured ? settingsT('settings.status.encrypted', 'configured and encrypted') : settingsT('settings.status.notConfigured', 'not configured')}`;
}
function renderConsoleComparison(packet) {
const target = $('console-profiles-comparison');
target.hidden = false;
target.innerHTML = `<div class="ecosystem-update-list">${(packet.profiles || []).map(profile => `<div class="ecosystem-service-row"><div><strong>${profile.active ? '● ' : ''}${escHtml(profile.label)}</strong><span>${escHtml(profile.host)} · firmware ${escHtml(profile.firmware)} · ${escHtml(profile.hen)} ${escHtml(profile.henVersion)}</span></div><span class="hint">Remote Play ${profile.pairing?.remotePlay ? settingsT('settings.status.linked', 'linked') : settingsT('settings.status.notLinked', 'not linked')} · Sentinel ${profile.pairing?.sentinel ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')} · ${settingsT('settings.status.storage', 'storage')} ${escHtml(profile.storage?.status || 'unknown')}</span></div>`).join('')}</div>`;
}
$('console-profile-activate')?.addEventListener('click', async event => {
try {
const result = await window.pork.consoleProfileActivate($('console-profile-active').value);
renderConsoleProfiles(result);
await loadSettings();
showToast('Console profile activated; no automatic connection was started.', 'ok', 7000);
} catch (error) { showToast(error.message, 'error'); }
});
$('console-profile-new')?.addEventListener('submit', async event => {
event.preventDefault();
try {
const result = await window.pork.consoleProfileSave({ label: $('console-profile-new-label').value.trim(), host: $('console-profile-new-host').value.trim(), makeActive: false });
renderConsoleProfiles(result);
event.target.reset();
showToast(settingsT('settings.consoleProfiles.added', 'Console profile added without starting a connection.'), 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('console-profiles-compare')?.addEventListener('click', async event => {
try { renderConsoleComparison(await window.pork.consoleProfilesCompare()); }
catch (error) { showToast(error.message, 'error'); }
});
$('companion-worker-form')?.addEventListener('submit', async event => {
event.preventDefault();
try {
renderCompanionWorkers(await window.pork.companionWorkerSave({ id: $('companion-worker-id').value.trim(), label: $('companion-worker-label').value.trim(), endpoint: $('companion-worker-endpoint').value.trim() }));
event.target.reset(); showToast('Companion worker saved; no job was started.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('companion-workers-list')?.addEventListener('click', async event => {
const row = event.target.closest('[data-worker-id]'); if (!row) return; const id = row.dataset.workerId;
try {
if (event.target.closest('.companion-worker-test')) {
const status = await window.pork.companionWorkerCapabilities(id);
showToast(`Worker verified: ${status.capabilities.join(', ') || 'no supported capabilities'}; shell disabled.`, 'ok', 9000);
}
if (event.target.closest('.companion-worker-pair')) {
const workers = await window.pork.companionWorkers(); const rotate = workers.find(worker => worker.id === id)?.paired === true;
const phrase = `PAIR WORKER ${id}`; const confirmation = await showExactConfirmation(`${rotate ? 'Rotate' : 'Create'} the pairing key for this worker?`, phrase); if (!confirmation) return;
const result = await window.pork.companionWorkerPair({ id, rotate, confirmation });
if (result.pairingKey) { const output = $('companion-worker-pairing-output'); output.hidden = false; output.textContent = `Worker: ${id}\nPairing key (eenmalig zichtbaar): ${result.pairingKey}\n\nImporteer lokaal in Chimera Worker en wis deze weergave.`; }
await loadCompanionWorkers();
}
if (event.target.closest('.companion-worker-remove') && await showExactConfirmation(`Remove ${id} and erase its pairing key?`, 'DELETE')) renderCompanionWorkers(await window.pork.companionWorkerRemove(id));
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('totp-settings-begin')?.addEventListener('click', async event => {
if (!await showConfirm('Pair a new authenticator? TOTP becomes active only after you confirm a valid code.')) return;
try {
const enrollment = await window.pork.totpBegin(); const output = $('totp-settings-output'); output.hidden = false; output.textContent = `One-time TOTP secret: ${enrollment.secret}\n\nAuthenticator URI:\n${enrollment.uri}`;
const code = await showPrompt('Add the secret or URI to your authenticator, then enter the current 6-digit code:'); if (code == null) return;
const confirmed = await window.pork.totpConfirm(code); output.textContent = `TOTP is active. Save these recovery codes now; they will not be shown again:\n\n${confirmed.recoveryCodes.join('\n')}`;
await loadTotpStatus(); showToast('Two-factor authentication is active.', 'ok', 8000);
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('totp-settings-disable')?.addEventListener('click', async () => {
const code = await showPrompt('Enter a valid authenticator or recovery code to disable TOTP:'); if (code == null) return;
if (!await showExactConfirmation('Disable two-factor authentication?', 'UITSCHAKELEN')) return;
try { await window.pork.totpDisable(code); $('totp-settings-output').hidden = true; await loadTotpStatus(); showToast('Two-factor authentication disabled.', 'ok'); }
catch (error) { showToast(error.message, 'error'); }
});
$('generic-notification-form')?.addEventListener('submit', async event => {
event.preventDefault();
try {
const token = $('generic-notification-token').value;
const endpoint = $('generic-notification-endpoint').value.trim();
const rules = [...document.querySelectorAll('[data-notification-rule]:checked')].map(input => input.dataset.notificationRule);
await window.pork.genericNotificationSave({ enabled: $('generic-notification-enabled').checked, rules, cooldownMinutes: Number($('generic-notification-cooldown').value), ...(endpoint ? { endpoint } : {}), ...(token ? { bearerToken: token } : {}) });
await loadGenericNotificationStatus(); showToast('Generic notification settings saved.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('generic-notification-test')?.addEventListener('click', async event => {
try { await window.pork.genericNotificationTest(); showToast('Test notification delivered.', 'ok'); }
catch (error) { showToast(error.message, 'error'); }
});
$('settings-automation-analyze')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try {
const [sources, protection] = await Promise.all([window.pork.automationSourcesAnalyze(), window.pork.automationProtectionStatus()]);
$('settings-automation-analysis').innerHTML = `
<div class="ecosystem-metrics">
<div class="operations-metric"><strong>${sources.roots.length}</strong><span>roots inspected</span></div>
<div class="operations-metric"><strong>${sources.recommendations.length}</strong><span>high-confidence suggestions</span></div>
<div class="operations-metric"><strong>${protection.saveVault.eligible ? 'Ready' : 'Setup'}</strong><span>Save Vault</span></div>
</div>
${(sources.roots || []).map(item => `<div class="ecosystem-service-row"><div><strong>${escHtml(item.root)}</strong><span>${escHtml(item.classification)} · ${escHtml(item.confidence)} confidence · ${item.writable ? 'writable' : 'read-only/unavailable'}</span></div><span class="badge">${escHtml(item.exists ? 'inspected' : item.error || 'missing')}</span></div>`).join('')}
<p class="hint">No configuration changes were applied. Enable Save Vault, replication and restore drills from Settings → Automation after reviewing their targets.</p>`;
} catch (error) { showToast(error.message, 'error'); }
finally { button.disabled = false; }
});
function scheduleSettingsSave() {
clearTimeout(settingsTimer);
settingsTimer = setTimeout(commitSettingsSave, 600);
}
let _webhookDirty = false;
$('s-discord-webhook')?.addEventListener('input', () => { _webhookDirty = true; });
async function commitSettingsSave() {
const payload = {
ftpHost: $('s-host').value.trim(),
ftpPorts: $('s-ports').value.split(',').map(p => p.trim()).filter(Boolean),
backupPaths: localBackupPaths,
backupPath: localBackupPaths[0] || '',
payloadLocalPath: $('s-payload-local').value.trim(),
payloadRemotePath:$('s-payload-remote').value.trim(),
cheatsRemotePath: $('s-cheats-remote').value.trim(),
mediaPsNotifyEnabled: $('s-media-discord-notify').checked,
autoConnect: $('s-auto-connect').checked,
timeFormat: $('s-time-format').value,
hashConcurrency: Number($('s-hash-concurrency')?.value) || 8,
};
if (_userDirty) payload.ftpUser = $('s-user').value.trim();
if (_passDirty) payload.ftpPass = $('s-pass').value;
if (_webhookDirty) payload.discordWebhookUrl = $('s-discord-webhook').value.trim();
await window.pork.setSettings(payload);
_passDirty = false;
_webhookDirty = false;
$('d-host').value = $('s-host').value;
$('d-ports').value = $('s-ports').value;
setStatus('Settings saved', 'ok');
}
async function loadSettings(options = {}) {
const background = !!options.background;
const s = await window.pork.getSettings();
$('s-host').value = s.ftpHost || '';
$('s-ports').value = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts.join(', ') : '1337, 2121, 21';
localBackupPaths = s.backupPaths || (s.backupPath ? [s.backupPath] : []);
renderLocalBackupPaths();
$('s-payload-local').value = s.payloadLocalPath || '';
$('s-payload-remote').value = s.payloadRemotePath || '/data/payloads/';
$('s-cheats-remote').value = s.cheatsRemotePath || '/data/etaHEN/cheats/';
$('s-discord-webhook').value = '';
$('s-discord-webhook').placeholder = s.discordWebhookConfigured ? 'Stored securely — enter to replace' : 'https://discord.com/api/webhooks/...';
$('s-media-discord-notify').checked = s.mediaPsNotifyEnabled !== false; // default true
$('media-discord-psn-detail').hidden = s.mediaPsNotifyEnabled === false;
$('s-media-psn-download').checked = s.mediaPsNotifyDiscordDownload !== false;
$('s-media-psn-convert').checked = s.mediaPsNotifyDiscordConvert !== false;
$('s-media-psn-upload').checked = s.mediaPsNotifyDiscordUpload !== false;
$('s-media-psn-done').checked = s.mediaPsNotifyDiscordDone !== false;
$('s-media-psn-error').checked = s.mediaPsNotifyDiscordError !== false;
$('s-auto-connect').checked = s.autoConnect === true;
_timeFormat = s.timeFormat || '12h';
$('s-time-format').value = _timeFormat;
if ($('s-interface-density')) $('s-interface-density').value = applyDensity(s.interfaceDensity);
if (!background) {
$('d-host').value = s.ftpHost || '';
$('d-ports').value = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts.join(', ') : '1337, 2121, 21';
}
applyAccent(s.accentColor || '#CBBAFF');
remoteGamePaths = s.remoteGamePaths || [];
renderRemoteGamePaths();
await renderSettingsPayloadSources();
if (s.svHotkeys) _svHotkeys = { ..._svHotkeys, ...s.svHotkeys };
const hkMap = { 'hk-mute': 'mute', 'hk-gif': 'gif', 'hk-video': 'video', 'hk-fullscreen': 'fullscreen', 'hk-popout': 'popout' };
Object.entries(hkMap).forEach(([id, action]) => {
const el = $(id); if (el) el.value = _svHotkeys[action] || '';
});
let convMode = s.gameConversionMode || 'pfs';
if (window.pork.isWebMode) {
for (const unavailable of ['ffpkg', 'exfat']) {
const radio = document.querySelector(`input[name="conv-mode"][value="${unavailable}"]`);
if (radio) radio.disabled = true;
radio?.closest('.radio-card')?.setAttribute('title', settingsT('settings.windowsModeUnavailable', 'This Windows mode is unavailable in the Unraid container. Use FFPFSC.'));
const queueButton = document.querySelector(`.conv-mode-btn[data-conv-mode="${unavailable}"]`);
if (queueButton) queueButton.disabled = true;
}
if (!['pfs', 'ffpfsc'].includes(convMode)) {
convMode = 'ffpfsc';
await window.pork.setSettings({ gameConversionMode: convMode });
}
}
const modeRadio = document.querySelector(`input[name="conv-mode"][value="${convMode}"]`);
if (modeRadio) modeRadio.checked = true;
applyConvModeUI(convMode);
$('s-conv-ufs2-path').value = s.ufs2ToolPath || '';
$('s-conv-exfat-path').value = s.osfmountPath || s.exfatToolPath || '';
$('s-conv-output-dir').value = s.convOutputDir || '';
$('s-conv-temp-dir').value = s.convTempDir || '';
$('s-conv-ftp-upload').checked = !!s.convFtpUpload;
$('s-conv-delete-after').checked = !!s.convDeleteAfter;
$('s-ps4-package-builder').value = s.ps4PackageBuilderPath || '';
$('s-ps4-package-output').value = s.ps4PackageOutputPath || '';
window.pork.ps4PackageBuilderStatus().then(status => {
$('s-ps4-package-status').textContent = status.available ? settingsT('ps4Packages.settings.ready', 'Ready') : settingsT('ps4Packages.settings.blocked', 'Not configured');
$('s-ps4-package-status').className = `badge ${status.available ? 'badge-green' : 'badge-orange'}`;
}).catch(error => { $('s-ps4-package-status').textContent = error.message; });
$('s-ps5-native-package-builder').value = s.ps5NativePackageBuilderPath || '';
$('s-ps5-native-package-output').value = s.ps5NativePackageOutputPath || '';
window.pork.ps5NativePackageBuilderStatus().then(status => {
$('s-ps5-native-package-status').textContent = status.available ? settingsT('ps5NativePackages.settings.ready', 'Pinned bridge ready') : settingsT('ps5NativePackages.settings.blocked', 'Research bridge not configured');
$('s-ps5-native-package-status').className = `badge ${status.available ? 'badge-green' : 'badge-orange'}`;
}).catch(error => { $('s-ps5-native-package-status').textContent = error.message; });
$('conv-delete-after-row').hidden = false;
$('s-conv-delete-after').disabled = !s.convFtpUpload;
$('conv-delete-after-row').style.opacity = s.convFtpUpload ? '' : '0.45';
$('s-conv-psn-enabled').checked = s.convPsNotifyEnabled !== false;
$('conv-psn-detail').hidden = !s.convPsNotifyEnabled;
$('s-conv-psn-queued').checked = s.convPsNotifyOnGameQueued !== false;
$('s-conv-psn-batch').checked = s.convPsNotifyOnBatchQueued !== false;
$('s-conv-psn-copy').checked = s.convPsNotifyOnCopyStart !== false;
$('s-conv-psn-convert').checked = s.convPsNotifyOnConvertStart!== false;
$('s-conv-psn-done').checked = s.convPsNotifyOnJobDone !== false;
await refreshConvToolStatus();
if ($('s-hash-concurrency')) $('s-hash-concurrency').value = s.hashConcurrency || 8;
if ($('s-webui-port')) $('s-webui-port').value = s.webUiPort || 6967;
if (window.pork.isWebMode) {
$('s-webui-enabled').disabled = true;
$('s-webui-port').disabled = true;
$('btn-webui-apply').disabled = true;
$('s-webui-enabled').closest('.toggle-row')?.setAttribute('title', settingsT('settings.serverManagedByUnraid', 'This web server is managed as an Unraid container.'));
}
await refreshWebuiStatus();
const _langCode = s.language || 'en';
try {
await refreshLangDropdown(_langCode);
} catch (_) {}
await window.i18n.load(_langCode);
await Promise.all([
loadConsoleProfiles(),
loadCompanionWorkers(),
loadTotpStatus(),
loadGenericNotificationStatus(),
]);
}
['s-host', 's-ports', 's-hash-concurrency'].forEach(id => {
const el = $(id);
if (el) el.addEventListener('input', scheduleSettingsSave);
});
async function refreshLangDropdown(selectedCode) {
const langs = await window.pork.langList();
const langSel = $('s-language');
langSel.innerHTML = langs.map(l => {
const label = l.source === 'imported' ? `${escHtml(l.name)} ✦` : escHtml(l.name);
return `<option value="${escHtml(l.code)}"${l.code === selectedCode ? ' selected' : ''}>${label}</option>`;
}).join('');
}
$('s-language').addEventListener('change', async function() {
const code = this.value;
await window.pork.setSettings({ language: code });
await window.i18n.load(code);
await Promise.all([
loadConsoleProfiles(),
loadCompanionWorkers(),
loadTotpStatus(),
loadGenericNotificationStatus(),
]);
setStatus(window.t('status.languageChanged', 'Language changed'), 'ok');
});
$('s-interface-density')?.addEventListener('change', async function() {
const interfaceDensity = applyDensity(this.value);
await window.pork.setSettings({ interfaceDensity });
setStatus(window.t('settings.density.saved', 'Interface density updated.'), 'ok');
});
$('btn-import-lang').addEventListener('click', async () => {
const btn = $('btn-import-lang');
btn.disabled = true;
try {
const res = await window.pork.langImport();
if (res.canceled) return;
const currentCode = $('s-language').value;
await refreshLangDropdown(currentCode);
setStatus(`${window.t('lang.importDone', 'Language imported')}: ${res.name}`, 'ok');
if (res.code === currentCode) await window.i18n.load(res.code);
} catch (e) {
setStatus(`${window.t('lang.importError', 'Import failed')}: ${e.message}`, 'error');
} finally {
btn.disabled = false;
}
});
$('btn-export-lang').addEventListener('click', async () => {
const btn = $('btn-export-lang');
btn.disabled = true;
try {
const res = await window.pork.langExportBase();
if (!res.canceled) setStatus(window.t('lang.exportDone', 'English base file exported.'), 'ok');
} catch (e) {
setStatus(`${window.t('lang.exportError', 'Export failed')}: ${e.message}`, 'error');
} finally {
btn.disabled = false;
}
});
$('d-host').addEventListener('input', () => {
$('s-host').value = $('d-host').value;
scheduleSettingsSave();
});
$('d-ports').addEventListener('input', () => {
$('s-ports').value = $('d-ports').value;
scheduleSettingsSave();
});
$('s-user').addEventListener('input', () => { _userDirty = true; scheduleSettingsSave(); });
$('s-pass').addEventListener('input', () => { _passDirty = true; scheduleSettingsSave(); });
function renderLocalBackupPaths() {
const cont = $('settings-backup-paths-list');
if (!cont) return;
if (!localBackupPaths.length) {
cont.innerHTML = '<p class="hint" style="margin:6px 0">No folders added yet. Click &ldquo;+ Add Folder&hellip;&rdquo; to get started.</p>';
return;
}
cont.innerHTML = localBackupPaths.map((p, i) => `
<div class="remote-path-row" data-backup-idx="${i}">
<span class="remote-path-value" title="${escHtml(p)}" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p)}</span>
<button class="btn btn-sm btn-backup-path-remove" style="margin-left:auto;flex-shrink:0">&#10005;</button>
</div>
`).join('');
}
async function saveLocalBackupPaths() {
await window.pork.setSettings({
backupPaths: localBackupPaths,
backupPath: localBackupPaths[0] || '',
});
setStatus('Settings saved', 'ok');
}
$('settings-backup-paths-list').addEventListener('click', async e => {
const row = e.target.closest('[data-backup-idx]');
if (!row || !e.target.closest('.btn-backup-path-remove')) return;
const idx = Number(row.dataset.backupIdx);
const ok = await showConfirm(`Remove "${escHtml(localBackupPaths[idx])}" from game source folders?`);
if (!ok) return;
localBackupPaths = localBackupPaths.filter((_, i) => i !== idx);
await saveLocalBackupPaths();
renderLocalBackupPaths();
});
$('btn-add-backup-path').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (!folder) return;
if (localBackupPaths.includes(folder)) { setStatus('That folder is already in the list', 'ok'); return; }
localBackupPaths = [...localBackupPaths, folder];
await saveLocalBackupPaths();
renderLocalBackupPaths();
});
$('btn-browse-payload-local').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (!folder) return;
$('s-payload-local').value = folder;
await commitSettingsSave();
});
$('s-payload-remote').addEventListener('input', scheduleSettingsSave);
$('s-cheats-remote').addEventListener('input', scheduleSettingsSave);
$('s-discord-webhook').addEventListener('input', scheduleSettingsSave);
$('btn-browse-cheats-remote').addEventListener('click', async () => {
const cur = $('s-cheats-remote').value.trim() || '/';
const p = await openFtpBrowser(cur);
if (p) { $('s-cheats-remote').value = p; scheduleSettingsSave(); }
});
$('s-auto-connect').addEventListener('change', commitSettingsSave);
$('s-time-format').addEventListener('change', e => {
_timeFormat = e.target.value;
commitSettingsSave();
renderMediaGrid();
});
$('s-media-discord-notify').addEventListener('change', function() {
$('media-discord-psn-detail').hidden = !this.checked;
commitSettingsSave();
});
[
['s-media-psn-download', 'mediaPsNotifyDiscordDownload'],
['s-media-psn-convert', 'mediaPsNotifyDiscordConvert'],
['s-media-psn-upload', 'mediaPsNotifyDiscordUpload'],
['s-media-psn-done', 'mediaPsNotifyDiscordDone'],
['s-media-psn-error', 'mediaPsNotifyDiscordError'],
].forEach(([id, key]) => {
$(id).addEventListener('change', function() {
window.pork.setSettings({ [key]: this.checked });
});
});
function applyConvModeUI(mode) {
const isPfs = mode === 'pfs';
const isFfpkg = mode === 'ffpkg';
const isExfat = mode === 'exfat';
const isConv = !isPfs;
$('conv-ffpkg-fields').hidden = !isFfpkg;
$('conv-exfat-fields').hidden = !isExfat;
$('conv-shared-fields').hidden = !isConv;
$('conv-notify-fields').hidden = !isConv;
const badge = $('conv-queue-mode-badge');
if (badge) badge.textContent = isPfs ? 'Raw Dumps' : isFfpkg ? 'FFPKG' : isExfat ? 'ExFAT' : 'FFPFSC';
}
async function refreshConvToolStatus() {
const mode = document.querySelector('input[name="conv-mode"]:checked')?.value || 'pfs';
if (mode === 'ffpkg') {
try {
const info = await window.pork.convToolInfo();
const el = $('s-conv-ufs2-status');
if (!el) return;
if (info.toolAvailable) {
el.textContent = '✓ UFS2Tool.exe found';
el.style.color = 'var(--green)';
} else {
el.textContent = info.toolPath ? '✗ File not found at configured path' : '✗ Not configured';
el.style.color = 'var(--red)';
}
} catch (_) {}
}
if (mode === 'exfat') {
try {
const info = await window.pork.convExfatToolInfo();
const el = $('s-conv-exfat-status');
if (!el) return;
if (info.toolAvailable) {
el.textContent = `✓ OSFMount found — native exFAT (${info.osfmountPath || info.toolPath})`;
el.style.color = 'var(--green)';
} else {
el.textContent = '✗ OSFMount not found — install OSFMount or pick osfmount.com';
el.style.color = 'var(--red)';
}
} catch (_) {}
}
}
document.querySelectorAll('input[name="conv-mode"]').forEach(radio => {
radio.addEventListener('change', async () => {
const mode = radio.value;
applyConvModeUI(mode);
await window.pork.setSettings({ gameConversionMode: mode });
await refreshConvToolStatus();
setStatus('Conversion mode saved', 'ok');
});
});
document.querySelectorAll('input[name="ufs2-method"]').forEach(radio => {
radio.addEventListener('change', async () => {
await window.pork.setSettings({ ufs2Method: radio.value });
setStatus('UFS2 method saved', 'ok');
});
});
$('btn-conv-ufs2-pick').addEventListener('click', async () => {
const info = await window.pork.convToolPick();
if (!info) return;
$('s-conv-ufs2-path').value = info.toolPath || '';
const el = $('s-conv-ufs2-status');
if (el) {
el.textContent = info.toolAvailable ? '✓ UFS2Tool.exe found' : '✗ File not found';
el.style.color = info.toolAvailable ? 'var(--green)' : 'var(--red)';
}
setStatus('UFS2Tool path saved', 'ok');
});
$('btn-conv-exfat-pick').addEventListener('click', async () => {
const info = await window.pork.convExfatToolPick();
if (!info) return;
$('s-conv-exfat-path').value = info.toolPath || '';
await refreshConvToolStatus();
setStatus('ExFAT tool path saved', 'ok');
});
$('btn-conv-output-pick').addEventListener('click', async () => {
const p = await window.pork.convPickOutputDir();
if (!p) return;
$('s-conv-output-dir').value = p;
setStatus('Output directory saved', 'ok');
});
$('btn-conv-temp-pick').addEventListener('click', async () => {
const p = await window.pork.convPickTempDir();
if (!p) return;
$('s-conv-temp-dir').value = p;
setStatus('Temp directory saved', 'ok');
});
$('s-conv-ftp-upload').addEventListener('change', async function() {
const ftpEnabled = this.checked;
$('s-conv-delete-after').disabled = !ftpEnabled;
$('conv-delete-after-row').style.opacity = ftpEnabled ? '' : '0.45';
if (!ftpEnabled) {
$('s-conv-delete-after').checked = false;
await window.pork.setSettings({ convFtpUpload: false, convDeleteAfter: false });
} else {
await window.pork.setSettings({ convFtpUpload: true });
}
setStatus('Settings saved', 'ok');
});
$('s-conv-delete-after').addEventListener('change', async function() {
await window.pork.setSettings({ convDeleteAfter: this.checked });
setStatus('Settings saved', 'ok');
});
$('btn-ps4-package-builder').addEventListener('click', async () => {
const folder = await window.pork.selectFolder(); if (!folder) return;
await window.pork.setSettings({ ps4PackageBuilderPath: folder });
$('s-ps4-package-builder').value = folder;
const status = await window.pork.ps4PackageBuilderStatus();
$('s-ps4-package-status').textContent = status.available ? settingsT('ps4Packages.settings.ready', 'Ready') : settingsT('ps4Packages.settings.blocked', 'Not configured');
});
$('btn-ps4-package-output').addEventListener('click', async () => {
const folder = await window.pork.selectFolder(); if (!folder) return;
await window.pork.setSettings({ ps4PackageOutputPath: folder });
$('s-ps4-package-output').value = folder;
showToast(settingsT('ps4Packages.settings.restart', 'Restart Chimera to activate the new package vault.'), 'info');
});
$('btn-ps5-native-package-builder').addEventListener('click', async () => {
const folder = await window.pork.selectFolder(); if (!folder) return;
await window.pork.setSettings({ ps5NativePackageBuilderPath: folder });
$('s-ps5-native-package-builder').value = folder;
const status = await window.pork.ps5NativePackageBuilderStatus();
$('s-ps5-native-package-status').textContent = status.available ? settingsT('ps5NativePackages.settings.ready', 'Pinned bridge ready') : settingsT('ps5NativePackages.settings.blocked', 'Research bridge not configured');
});
$('btn-ps5-native-package-output').addEventListener('click', async () => {
const folder = await window.pork.selectFolder(); if (!folder) return;
await window.pork.setSettings({ ps5NativePackageOutputPath: folder });
$('s-ps5-native-package-output').value = folder;
showToast(settingsT('ps5NativePackages.settings.restart', 'Restart Chimera to activate the native package vault.'), 'info');
});
$('s-conv-psn-enabled').addEventListener('change', async function() {
$('conv-psn-detail').hidden = !this.checked;
await window.pork.setSettings({ convPsNotifyEnabled: this.checked });
setStatus('Settings saved', 'ok');
});
[
['s-conv-psn-queued', 'convPsNotifyOnGameQueued'],
['s-conv-psn-batch', 'convPsNotifyOnBatchQueued'],
['s-conv-psn-copy', 'convPsNotifyOnCopyStart'],
['s-conv-psn-convert', 'convPsNotifyOnConvertStart'],
['s-conv-psn-done', 'convPsNotifyOnJobDone'],
].forEach(([id, key]) => {
$(id).addEventListener('change', function() {
window.pork.setSettings({ [key]: this.checked });
});
});
$('settings-ftp-presets').addEventListener('click', e => {
const btn = e.target.closest('[data-add-port]');
if (!btn) return;
const port = btn.dataset.addPort;
const current = $('s-ports').value.split(',').map(p => p.trim()).filter(Boolean);
if (!current.includes(port)) {
current.push(port);
$('s-ports').value = current.join(', ');
commitSettingsSave();
}
});
{
const hkMap = { 'hk-mute': 'mute', 'hk-gif': 'gif', 'hk-video': 'video', 'hk-fullscreen': 'fullscreen', 'hk-popout': 'popout' };
Object.entries(hkMap).forEach(([id, action]) => {
const input = $(id);
if (!input) return;
input.addEventListener('focus', () => {
input.dataset.prev = input.value;
input.value = '';
input.placeholder = 'press a key…';
input.classList.add('capturing');
});
input.addEventListener('blur', () => {
if (!input.value) input.value = input.dataset.prev || '';
input.placeholder = '—';
input.classList.remove('capturing');
});
input.addEventListener('keydown', async e => {
e.preventDefault();
if (e.key === 'Escape') { input.value = input.dataset.prev || ''; input.blur(); return; }
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
input.value = key;
_svHotkeys[action] = key;
input.blur();
await window.pork.setSettings({ svHotkeys: { ..._svHotkeys } });
setStatus('Hotkey saved', 'ok');
});
});
}
function renderRemoteGamePaths() {
const cont = $('settings-remote-paths-list');
if (!remoteGamePaths.length) {
cont.innerHTML = '<p class="hint" style="margin:6px 0">No paths configured. Scan will use standard PS5 paths.</p>';
return;
}
cont.innerHTML = remoteGamePaths.map((p, i) => `
<div class="remote-path-row" data-idx="${i}">
<span class="remote-path-label">${escHtml(p.label)}</span>
<span class="remote-path-value" title="${escHtml(p.path)}">${escHtml(p.path)}</span>
<button class="btn btn-sm btn-remote-path-remove" style="margin-left:auto">&#10005;</button>
</div>
`).join('');
}
async function saveRemoteGamePaths() {
await window.pork.setSettings({ remoteGamePaths });
setStatus('Settings saved', 'ok');
}
$('btn-add-remote-path').addEventListener('click', async () => {
const label = $('new-remote-label').value.trim();
const path = $('new-remote-path').value.trim();
if (!path) { setStatus('Enter a remote path', 'error'); return; }
remoteGamePaths = [...remoteGamePaths, { label: label || path, path }];
await saveRemoteGamePaths();
renderRemoteGamePaths();
$('new-remote-label').value = '';
$('new-remote-path').value = '';
});
$('settings-remote-paths-list').addEventListener('click', async e => {
const row = e.target.closest('[data-idx]');
if (!row || !e.target.closest('.btn-remote-path-remove')) return;
const idx = Number(row.dataset.idx);
const ok = await showConfirm(`Remove "${escHtml(remoteGamePaths[idx]?.label)}" from remote game paths?`);
if (!ok) return;
remoteGamePaths = remoteGamePaths.filter((_, i) => i !== idx);
await saveRemoteGamePaths();
renderRemoteGamePaths();
});
async function refreshWebuiStatus() {
try {
const status = await window.pork.webuiStatus();
const badge = $('webui-status-badge');
const urlRow = $('webui-url-row');
const urlEl = $('webui-url');
const toggle = $('s-webui-enabled');
if (badge) badge.hidden = !status.running;
if (toggle) toggle.checked = status.running;
if (urlRow && urlEl) {
if (status.running) {
const port = status.port;
const hosts = Array.isArray(status.hosts) && status.hosts.length ? status.hosts : ['localhost'];
const primary = (window.pork.isWebMode && location.hostname)
? location.hostname
: hosts[0];
const primaryUrl = status.publicOrigin || (window.pork.isWebMode ? location.origin : `http://${primary}:${port}`);
urlEl.textContent = primaryUrl;
urlRow.hidden = false;
const openBtn = $('btn-webui-open');
if (openBtn) openBtn.onclick = () => window.pork.openShell ? window.pork.openShell(primaryUrl) : window.open(primaryUrl, '_blank');
} else {
urlRow.hidden = true;
}
}
} catch (_) {}
}
$('s-webui-enabled').addEventListener('change', async function () {
const btn = $('btn-webui-apply');
if (btn) btn.disabled = true;
try {
if (this.checked) {
const port = Number($('s-webui-port').value) || 6967;
await window.pork.setSettings({ webUiEnabled: true, webUiPort: port });
const res = await window.pork.webuiStart();
if (!res.ok) { setStatus(`Web UI failed: ${res.error}`, 'error'); this.checked = false; }
else setStatus('Web UI server started', 'ok');
} else {
await window.pork.setSettings({ webUiEnabled: false });
const res = await window.pork.webuiStop();
if (res.ok) setStatus('Web UI server stopped', 'ok');
}
} catch (e) {
setStatus(`Web UI error: ${e.message}`, 'error');
} finally {
if (btn) btn.disabled = false;
await refreshWebuiStatus();
}
});
$('btn-webui-apply').addEventListener('click', async () => {
const port = Number($('s-webui-port').value) || 6967;
await window.pork.setSettings({ webUiPort: port });
if ($('s-webui-enabled').checked) {
await window.pork.webuiStop();
const res = await window.pork.webuiStart();
if (res.ok) setStatus(`Web UI restarted on port ${port}`, 'ok');
else setStatus(`Web UI restart failed: ${res.error}`, 'error');
} else {
setStatus('Port saved', 'ok');
}
await refreshWebuiStatus();
});