Initial curated chimera source from a8ae5b1db07fb49926c94585e2718e5130330974

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:38:08 +02:00
commit c737a37f71
531 files changed
+119270

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
<title id="title">Chimera crest</title>
<defs>
<linearGradient id="left" x1="7" y1="8" x2="34" y2="39" gradientUnits="userSpaceOnUse">
<stop stop-color="#9b7bf2"/>
<stop offset="1" stop-color="#7651e6"/>
</linearGradient>
<linearGradient id="right" x1="57" y1="8" x2="31" y2="39" gradientUnits="userSpaceOnUse">
<stop stop-color="#6ee8f7"/>
<stop offset="1" stop-color="#22c7e8"/>
</linearGradient>
<linearGradient id="base" x1="32" y1="27" x2="32" y2="59" gradientUnits="userSpaceOnUse">
<stop stop-color="#7651e6"/>
<stop offset="1" stop-color="#2ed39a"/>
</linearGradient>
</defs>
<path fill="url(#left)" d="M5 13 18 7l14 20-8 9L5 13Z"/>
<path fill="url(#right)" d="m59 13-13-6-14 20 8 9 19-23Z"/>
<path fill="url(#base)" d="m24 36 8-9 8 9-8 22-8-22Z"/>
<path fill="#0d0a11" d="m32 27-5.2 7.1L32 42l5.2-7.9L32 27Z"/>
</svg>

After

Width:  |  Height:  |  Size: 997 B

+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+85
View File
@@ -0,0 +1,85 @@
'use strict';
(function bootstrapChimera() {
const loaded = new Map();
const featureModules = Object.freeze({
games: ['modules/games.js', 'modules/game-source-scan.js', 'modules/ftpbrowser.js', 'modules/backpork-gen.js', 'modules/hash.js', 'modules/manage.js', 'modules/compatibility-guidance.js', 'modules/title-workspace.js', '../src/renderer/recipe-editor.js', 'modules/install-destination.js', 'modules/cheats.js', 'modules/modal.js'],
packages: ['modules/packages.js'],
backporks: ['modules/backporks.js', 'modules/backpork-gen.js', 'modules/ftpbrowser.js'],
backups: ['modules/protection-model.js', 'modules/backups.js', 'modules/ftpbrowser.js'],
trophies: ['../src/renderer/trophies.js'],
database: ['modules/database.js'],
settings: ['modules/payload.js', 'modules/settings.js'],
jailbreak: ['modules/payload.js', 'modules/y2jb-generator.js', 'modules/bdjb-generator.js', 'modules/system-state.js', 'modules/autoloader.js'],
psnotify: ['modules/psnotify.js'],
xavatar: [{ src: '../src/xavatarElectronModule/renderer/xavatarRenderer.js', type: 'module' }, 'modules/xavatar.js'],
'system-view': ['gif-encoder.js', 'modules/system-view.js', 'modules/hotkeys.js'],
transfers: ['modules/transfers.js'],
media: ['modules/media.js'],
cheats: ['modules/cheats.js'],
garlic: ['modules/cheats.js'],
'elf-arsenal': ['modules/elf-arsenal.js'],
'pfs-ripper': ['modules/pfs-ripper.js'],
savemgr: ['modules/savemgr.js'],
ecosystem: ['modules/etahen-link-ux.js', 'modules/ecosystem.js'],
operations: ['modules/operations.js'],
});
const coreModules = [
'pork-web-shim.js',
'pork-evidence-bridge.js',
'modules/i18n.js',
'modules/state.js',
'modules/library-model.js',
'modules/helpers.js',
'modules/connection.js',
'modules/navigation-routes.js',
'modules/navigation.js',
'modules/premium-illustrations.js?v=sol-ultra-1',
'modules/dashboard-widgets.js',
'modules/init.js',
'modules/pwa.js',
];
function descriptor(value) {
return typeof value === 'string' ? { src: value, type: 'text/javascript' } : value;
}
function load(value) {
const item = descriptor(value);
if (loaded.has(item.src)) return loaded.get(item.src);
const promise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = item.src;
script.type = item.type || 'text/javascript';
script.dataset.chimeraModule = item.src;
script.addEventListener('load', () => resolve(item.src), { once: true });
script.addEventListener('error', () => {
loaded.delete(item.src);
reject(new Error(`Could not load ${item.src}.`));
}, { once: true });
document.head.appendChild(script);
});
loaded.set(item.src, promise);
return promise;
}
async function loadSequence(items) {
for (const item of items) await load(item);
}
window.ChimeraModuleLoader = Object.freeze({
ensureForPage: page => loadSequence(featureModules[page] || []),
isLoaded: src => loaded.has(src),
loadedModules: () => [...loaded.keys()],
});
loadSequence(coreModules).catch(error => {
console.error('[bootstrap]', error);
const status = document.getElementById('sb-msg');
if (status) {
status.textContent = `Chimera could not start: ${error.message}`;
status.style.color = 'var(--red)';
}
document.body.dataset.bootstrapFailed = 'true';
});
})();
+252
View File
@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<meta name="color-scheme" content="dark"/>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:;"/>
<title>Support Chimera</title>
<style>@font-face{font-family:Inter;src:url('assets/fonts/inter-latin.woff2') format('woff2');font-style:normal;font-weight:100 900;font-display:swap}@font-face{font-family:'Space Grotesk';src:url('assets/fonts/space-grotesk-latin.woff2') format('woff2');font-style:normal;font-weight:300 700;font-display:swap}:root{--bg:#0d0a11;--surface:#1d1822;--surface2:#231d29;--surface3:#2c2533;--border:#342b3d;--accent:#CBBAFF;--accent-d:#7651e6;--text:#f4eefb;--text-dim:#c6bbd2;--text-muted:#978ca3;--green:#2ed39a}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; min-height: 100%; overflow-x: hidden; overflow-y: auto;
background: #0d0a11;
font-family: Inter,'Segoe UI',system-ui,sans-serif;
user-select: none; -webkit-app-region: drag;
color: var(--text);
}
body {
display: flex; flex-direction: column;
align-items: center; justify-content: flex-start;
padding: 28px 28px 20px;
gap: 0;
}
/* ── Icon ── */
.icon {
width: 64px; height: 64px; border-radius: 16px;
filter: drop-shadow(0 0 18px rgba(187,134,252,0.45));
animation: pop .35s cubic-bezier(.34,1.56,.64,1) both;
margin-bottom: 14px;
}
@keyframes pop {
from { opacity: 0; transform: scale(.7); }
to { opacity: 1; transform: scale(1); }
}
/* ── Heading ── */
h1 {
font-size: 17px; font-weight: 700;
color: var(--text); letter-spacing: .03em;
text-align: center; margin-bottom: 6px;
}
.subtitle {
font-size: 11px; color: var(--text-muted);
letter-spacing: .05em; text-transform: uppercase;
text-align: center; margin-bottom: 16px;
}
/* ── Body text ── */
.body-text {
font-size: 12px; color: var(--text-dim); text-align: center;
line-height: 1.6; margin-bottom: 18px;
max-width: 320px;
}
.body-text a {
color: #CBBAFF; text-decoration: none;
-webkit-app-region: no-drag; cursor: pointer;
}
.body-text a:hover { text-decoration: underline; }
/* ── Divider ── */
.divider {
width: 100%; height: 1px; background: var(--surface3);
margin-bottom: 16px; flex-shrink: 0;
}
/* ── QR + BTC row ── */
.btc-row {
display: flex; align-items: flex-start; gap: 14px;
margin-bottom: 16px; width: 100%;
}
.qr-wrap {
flex-shrink: 0; width: 80px; height: 80px;
border-radius: 8px; overflow: hidden; background: var(--surface2);
border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
}
.qr-wrap img { width: 80px; height: 80px; display: block; }
.qr-placeholder {
font-size: 9px; color: #555; text-align: center; padding: 6px;
line-height: 1.4;
}
.btc-info { flex: 1; display: flex; flex-direction: column; gap: 5px; }
.btc-label {
font-size: 10px; color: var(--text-muted); text-transform: uppercase;
letter-spacing: .06em; font-weight: 700;
display: flex; align-items: center; gap: 6px;
}
.btc-label svg { flex-shrink: 0; }
.btc-addr {
font-size: 10px; color: #CBBAFF; font-family: 'Courier New', monospace;
word-break: break-all; line-height: 1.4;
background: var(--surface2); border: 1px solid var(--border);
border-radius: 6px; padding: 6px 8px;
-webkit-app-region: no-drag; cursor: text; user-select: text;
}
.copy-btn {
display: inline-flex; align-items: center; gap: 5px;
background: var(--surface2); border: 1px solid var(--border); border-radius: 6px;
padding: 4px 10px; font-size: 10px; color: var(--text-dim); cursor: pointer;
transition: background .15s, color .15s; -webkit-app-region: no-drag;
align-self: flex-start;
}
.copy-btn:hover { background: var(--surface3); color: var(--text); }
.copy-btn.copied { color: var(--green); border-color: var(--green); }
/* ── Action buttons ── */
.actions {
display: flex; flex-direction: column; gap: 8px;
width: 100%;
}
.btn {
display: flex; align-items: center; justify-content: center; gap: 7px;
padding: 9px 0; border-radius: 8px; font-size: 12px; font-weight: 600;
cursor: pointer; border: none; transition: opacity .15s, filter .15s;
-webkit-app-region: no-drag; width: 100%;
}
.btn:hover { filter: brightness(1.12); }
.btn:active { filter: brightness(.9); }
.btn-revolut { background: #222; border: 1px solid #444; color: var(--text); }
.btn-x { background: #1a1a1a; border: 1px solid var(--border); color: var(--text-dim); }
.btn-close { background: transparent; border: 1px solid var(--border); color: #555;
font-size: 11px; padding: 7px 0; margin-top: 2px; }
.btn-close:hover { color: var(--text-dim); border-color: #555; }
a:focus-visible, button:focus-visible, .btc-addr:focus-visible {
outline: 2px solid #CBBAFF; outline-offset: 3px;
}
@media (max-height: 620px) { body { padding-top: 18px; padding-bottom: 18px; } .icon { width: 52px; height: 52px; margin-bottom: 10px; } }
@media (prefers-reduced-motion: reduce) { .icon { animation: none; } .btn, .copy-btn { transition: none; } }
/* ── Revolut branding ── */
.revolut-logo {
font-size: 11px; font-weight: 900; letter-spacing: -.5px;
}
</style>
</head>
<body>
<img class="icon" src="__CHIMERA_ICON_SRC__" alt="Chimera"/>
<h1>Chimera</h1>
<p class="subtitle">Personal PS4/PS5 platform &middot; based on Porkfolio 1.0.9</p>
<p class="body-text">
Please consider
<a id="x-link" href="#">following on X</a>
for more projects, or donating to support future development and get rid of this message.
</p>
<div class="divider"></div>
<!-- Bitcoin -->
<div class="btc-row">
<div class="qr-wrap">
<!-- QR SVG injected by script -->
</div>
<div class="btc-info">
<div class="btc-label">
<!-- Bitcoin "₿" icon -->
<svg width="11" height="11" viewBox="0 0 24 24" fill="#F7931A"><path d="M23.638 14.904c-1.602 6.425-8.113 10.343-14.542 8.74C2.67 22.044-1.244 15.525.362 9.105 1.96 2.68 8.475-1.243 14.9.358c6.43 1.605 10.342 8.115 8.738 14.546zM17.239 10.65c.277-1.85-.977-2.845-2.641-3.508l.54-2.164-1.317-.33-.526 2.108c-.347-.086-.704-.167-1.058-.247l.53-2.12-1.317-.33-.54 2.162c-.287-.066-.569-.13-.843-.2l.002-.006-1.817-.454-.35 1.404s.977.224.956.237c.533.133.63.486.613.766l-.615 2.464c.037.01.085.022.137.043l-.139-.035-.862 3.455c-.065.162-.231.405-.605.313.014.02-.957-.239-.957-.239l-.654 1.503 1.714.428c.319.08.63.163.937.242l-.547 2.19 1.316.33.54-2.167c.36.098.71.188 1.05.272l-.538 2.154 1.317.33.547-2.186c2.257.427 3.954.255 4.668-1.787.576-1.643-.029-2.591-1.217-3.21.865-.199 1.516-.768 1.69-1.942zm-3.025 4.242c-.41 1.643-3.182.755-4.08.531l.728-2.917c.898.224 3.782.668 3.352 2.386zm.41-4.264c-.374 1.496-2.686.736-3.438.55l.66-2.647c.752.187 3.169.537 2.778 2.097z"/></svg>
Bitcoin
</div>
<div class="btc-addr" id="btc-addr" title="Click to select all" tabindex="0" role="textbox" aria-label="Bitcoin address" aria-readonly="true"></div>
<button class="copy-btn" id="copy-btn">
<svg width="11" height="11" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="6" y="6" width="10" height="12" rx="2"/><path d="M4 14H3a2 2 0 01-2-2V3a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
Copy Address
</button>
</div>
</div>
<div class="actions">
<button class="btn btn-revolut" id="revolut-btn">
<svg width="16" height="16" viewBox="0 0 92 92" fill="none"><rect width="92" height="92" rx="46" fill="#191C1F"/><path d="M28 20h22c10 0 18 8 18 17.5S60 55 50 55H42l12 17H37L24 54.5a2 2 0 01.4-2.8l7.6-5.7H50c5 0 8-3.6 8-8.5S55 29 50 29H42v43H28V20z" fill="#fff"/></svg>
<span>Donate via Revolut</span>
</button>
<button class="btn btn-x" id="x-btn">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.747l7.73-8.835L2.012 2.25H8.08l4.253 5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
Follow on X for more tools
</button>
<button class="btn btn-close" id="close-btn">Close Chimera</button>
</div>
<script>
// ── Determine exit mode from URL (safe — no window.donate needed) ──────────
const _mode = new URLSearchParams(location.search).get('mode');
// ── Wire up ALL buttons FIRST so they always work ──────────────────────────
// These run synchronously before any async/IPC calls that could fail.
document.getElementById('close-btn').addEventListener('click', () => {
if (_mode === 'exit') window.donate.exitApp();
else window.donate.dismiss();
});
document.getElementById('x-link').addEventListener('click', (e) => {
e.preventDefault();
window.donate.openX();
});
document.getElementById('x-btn').addEventListener('click', () => window.donate.openX());
document.getElementById('revolut-btn').addEventListener('click', () => window.donate.openRevolut());
document.getElementById('copy-btn').addEventListener('click', () => {
const addr = document.getElementById('btc-addr').textContent;
navigator.clipboard.writeText(addr).then(() => {
const btn = document.getElementById('copy-btn');
btn.classList.add('copied');
btn.innerHTML = `<svg width="11" height="11" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="3,10 8,15 17,6"/></svg> Copied!`;
setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = `<svg width="11" height="11" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="6" y="6" width="10" height="12" rx="2"/><path d="M4 14H3a2 2 0 01-2-2V3a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> Copy Address`;
}, 2000);
});
});
document.getElementById('btc-addr').addEventListener('click', function () {
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(this);
sel.removeAllRanges();
sel.addRange(range);
});
// ── Populate dynamic content after listeners are attached ──────────────────
(async () => {
try {
// Bitcoin address text
const addr = window.donate.btcAddress();
document.getElementById('btc-addr').textContent = addr;
} catch (_) {}
try {
// QR code — returned as { ok, svg } from main process
const result = await window.donate.genQR();
const qrWrap = document.querySelector('.qr-wrap');
if (result && result.ok && result.svg) {
// Inject SVG directly so colours render correctly
qrWrap.innerHTML = result.svg;
const svgEl = qrWrap.querySelector('svg');
if (svgEl) {
svgEl.style.width = '80px';
svgEl.style.height = '80px';
}
} else {
qrWrap.innerHTML = '<div class="qr-placeholder">QR unavailable</div>';
}
} catch (_) {
document.querySelector('.qr-wrap').innerHTML = '<div class="qr-placeholder">QR unavailable</div>';
}
})();
</script>
</body>
</html>
+338
View File
@@ -0,0 +1,338 @@
'use strict';
/**
* gif-encoder.js — self-contained animated GIF89a encoder
* No dependencies. Exposes global GifEncoder via IIFE.
*
* Usage:
* const enc = new GifEncoder(640, 360, { fps: 10 });
* enc.addFrame(canvas.getContext('2d').getImageData(0,0,w,h).data);
* const bytes = enc.encode(); // Uint8Array — complete GIF89a file
* const url = URL.createObjectURL(new Blob([bytes], { type: 'image/gif' }));
*/
(function (root) {
// ── Utilities ──────────────────────────────────────────────────────────────────
function u16le(n) { return [n & 0xFF, (n >> 8) & 0xFF]; }
function ascii(s) {
const a = new Array(s.length);
for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i);
return a;
}
// ── Median-cut colour quantisation ────────────────────────────────────────────
// Returns up to maxColors {r,g,b} objects representative of the palette.
function buildPalette(frames, maxColors) {
// Sample every 4th pixel from every 2nd frame for better colour coverage
const fStep = Math.max(1, frames.length >> 1);
const seen = new Set();
for (let fi = 0; fi < frames.length; fi += fStep) {
const d = frames[fi];
for (let i = 0; i < d.length; i += 16) { // every 4th pixel (×4 RGBA)
seen.add((d[i] << 16) | (d[i + 1] << 8) | d[i + 2]);
}
}
let colors = [...seen].map(c => ({ r: (c >> 16) & 0xFF, g: (c >> 8) & 0xFF, b: c & 0xFF }));
if (colors.length <= maxColors) return colors;
// Median cut
function splitBucket(bucket) {
let rMn=255,rMx=0,gMn=255,gMx=0,bMn=255,bMx=0;
for (const c of bucket) {
if (c.r < rMn) rMn=c.r; if (c.r > rMx) rMx=c.r;
if (c.g < gMn) gMn=c.g; if (c.g > gMx) gMx=c.g;
if (c.b < bMn) bMn=c.b; if (c.b > bMx) bMx=c.b;
}
const rR=rMx-rMn, gR=gMx-gMn, bR=bMx-bMn;
const axis = rR>=gR && rR>=bR ? 'r' : gR>=bR ? 'g' : 'b';
bucket.sort((a, b) => a[axis] - b[axis]);
const mid = bucket.length >> 1;
return [bucket.slice(0, mid), bucket.slice(mid)];
}
function representative(bucket) {
const n = bucket.length;
let r=0,g=0,b=0;
for (const c of bucket) { r+=c.r; g+=c.g; b+=c.b; }
return { r: (r/n+0.5)|0, g: (g/n+0.5)|0, b: (b/n+0.5)|0 };
}
let buckets = [colors];
while (buckets.length < maxColors) {
let li = 0;
for (let i = 1; i < buckets.length; i++)
if (buckets[i].length > buckets[li].length) li = i;
if (buckets[li].length <= 1) break;
const [a, b] = splitBucket(buckets[li]);
buckets.splice(li, 1, a, b);
}
return buckets.map(representative);
}
// ── 5-bit-per-channel colour lookup table (32³ = 32,768 entries) ─────────────
// 8× more accurate than the 4-bit (16³) LUT — max quantisation error per
// channel drops from ±8 to ±4 before the nearest-palette search.
function buildLUT(palette) {
const SZ = 32;
const lut = new Uint8Array(SZ * SZ * SZ);
for (let ri = 0; ri < SZ; ri++) {
for (let gi = 0; gi < SZ; gi++) {
for (let bi = 0; bi < SZ; bi++) {
const R = Math.round(ri * 255 / 31);
const G = Math.round(gi * 255 / 31);
const B = Math.round(bi * 255 / 31);
let best = 0, bestD = 1 << 30;
for (let pi = 0; pi < palette.length; pi++) {
const p = palette[pi];
const dr = R - p.r, dg = G - p.g, db = B - p.b;
const d = dr*dr + dg*dg + db*db;
if (d < bestD) { bestD = d; best = pi; }
}
lut[ri * SZ * SZ + gi * SZ + bi] = best;
}
}
}
return lut;
}
// ── Floyd-Steinberg dithering quantisation ────────────────────────────────────
// Eliminates colour banding by distributing per-pixel quantisation error to
// neighbouring pixels: right 7/16, below-left 3/16, below 5/16, below-right 1/16.
function quantizeFS(rgba, w, h, palette, lut) {
const SZ = 32;
const n = w * h;
// Float32 working buffers accumulate error without integer clamping
const R = new Float32Array(n);
const G = new Float32Array(n);
const B = new Float32Array(n);
for (let i = 0; i < n; i++) {
R[i] = rgba[i * 4];
G[i] = rgba[i * 4 + 1];
B[i] = rgba[i * 4 + 2];
}
const indices = new Uint8Array(n);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
const r = Math.max(0, Math.min(255, R[i] + 0.5)) | 0;
const g = Math.max(0, Math.min(255, G[i] + 0.5)) | 0;
const b = Math.max(0, Math.min(255, B[i] + 0.5)) | 0;
// Nearest palette entry via 5-bit LUT (O(1) lookup)
const idx = lut[(r >> 3) * SZ * SZ + (g >> 3) * SZ + (b >> 3)];
indices[i] = idx;
// Quantisation error
const p = palette[idx];
const er = r - p.r, eg = g - p.g, eb = b - p.b;
// Distribute error to neighbours
if (x + 1 < w) {
R[i + 1] += er * 0.4375; // 7/16
G[i + 1] += eg * 0.4375;
B[i + 1] += eb * 0.4375;
}
if (y + 1 < h) {
if (x > 0) {
R[i + w - 1] += er * 0.1875; // 3/16
G[i + w - 1] += eg * 0.1875;
B[i + w - 1] += eb * 0.1875;
}
R[i + w] += er * 0.3125; // 5/16
G[i + w] += eg * 0.3125;
B[i + w] += eb * 0.3125;
if (x + 1 < w) {
R[i + w + 1] += er * 0.0625; // 1/16
G[i + w + 1] += eg * 0.0625;
B[i + w + 1] += eb * 0.0625;
}
}
}
}
return indices;
}
// ── GIF LZW encoder ───────────────────────────────────────────────────────────
// Returns an Array of bytes (including sub-block framing and block terminator).
function lzwEncode(pixels, minCodeSize) {
const clearCode = 1 << minCodeSize;
const eoiCode = clearCode + 1;
// Bit-writer
const out = [];
let buf = 0, bufBits = 0;
function write(code, bits) {
buf |= (code << bufBits);
bufBits += bits;
while (bufBits >= 8) { out.push(buf & 0xFF); buf >>>= 8; bufBits -= 8; }
}
let codeSize = minCodeSize + 1;
let nextCode = eoiCode + 1;
// Code table: maps (prefixCode << 8) | pixel → new code
const table = new Map();
write(clearCode, codeSize);
if (!pixels.length) {
write(eoiCode, codeSize);
if (bufBits > 0) out.push(buf & 0xFF);
return packSubBlocks(out);
}
let prefix = pixels[0];
for (let i = 1; i < pixels.length; i++) {
const pix = pixels[i];
const key = (prefix << 8) | pix;
const hit = table.get(key);
if (hit !== undefined) {
prefix = hit;
} else {
write(prefix, codeSize);
if (nextCode < 4096) {
table.set(key, nextCode);
if (nextCode === (1 << codeSize) && codeSize < 12) codeSize++;
nextCode++;
} else {
// Table full — emit clear code and reset
write(clearCode, codeSize);
table.clear();
nextCode = eoiCode + 1;
codeSize = minCodeSize + 1;
}
prefix = pix;
}
}
write(prefix, codeSize);
write(eoiCode, codeSize);
if (bufBits > 0) out.push(buf & 0xFF);
return packSubBlocks(out);
}
// Pack raw bytes into GIF sub-blocks (≤255 bytes each), terminated with 0x00.
function packSubBlocks(bytes) {
const result = [];
for (let i = 0; i < bytes.length; i += 255) {
const end = Math.min(i + 255, bytes.length);
result.push(end - i);
for (let j = i; j < end; j++) result.push(bytes[j]);
}
result.push(0); // block terminator
return result;
}
// ── GifEncoder class ──────────────────────────────────────────────────────────
class GifEncoder {
/**
* @param {number} width
* @param {number} height
* @param {{ fps?: number, maxColors?: number }} [opts]
*/
constructor(width, height, { fps = 10, maxColors = 256 } = {}) {
this.w = width;
this.h = height;
// GIF delay is in units of 1/100 s; minimum 2 (= 50 fps max)
this.delay = Math.max(2, Math.round(100 / fps));
this.maxColors = Math.max(2, Math.min(256, maxColors | 0 || 256));
this._frames = [];
}
/** @param {Uint8ClampedArray} data — RGBA pixel data (width × height × 4 bytes) */
addFrame(data) {
this._frames.push(data instanceof Uint8ClampedArray ? data : new Uint8ClampedArray(data.buffer ?? data));
}
get frameCount() { return this._frames.length; }
/**
* Encode all added frames into an animated GIF.
* @returns {Uint8Array}
*/
encode() {
const { w, h, delay, _frames: frames } = this;
if (!frames.length) return new Uint8Array(0);
// ── Build global colour palette (up to 256 colours) ────────────────────────
let palette = buildPalette(frames, this.maxColors);
// Must be exactly a power-of-2 size between 2 and 256
const palPow = Math.max(1, Math.ceil(Math.log2(Math.max(2, palette.length))));
const palSize = 1 << palPow;
while (palette.length < palSize) palette.push({ r: 0, g: 0, b: 0 });
const minCodeSize = Math.max(2, palPow);
const lut = buildLUT(palette);
// ── Assemble GIF binary ────────────────────────────────────────────────────
const out = [];
function push(...items) {
for (const item of items) {
if (Array.isArray(item)) { for (const b of item) out.push(b); }
else out.push(item);
}
}
// Header
push(ascii('GIF89a'));
// Logical Screen Descriptor
push(
u16le(w), u16le(h),
0x80 | (palPow - 1), // Global CT flag set; CT size = palPow−1
0, // Background colour index
0, // Pixel aspect ratio
);
// Global Colour Table (3 bytes per entry: R, G, B)
for (const c of palette) push(c.r, c.g, c.b);
// Application Extension — Netscape 2.0 (loop forever)
push(
0x21, 0xFF, 0x0B,
ascii('NETSCAPE2.0'),
0x03, 0x01, 0x00, 0x00, // loop count = 0 → infinite
0x00, // sub-block terminator
);
// ── Frames ─────────────────────────────────────────────────────────────────
for (const rgba of frames) {
// Floyd-Steinberg dithered quantisation for each frame
const indices = quantizeFS(rgba, w, h, palette, lut);
// Graphic Control Extension
push(
0x21, 0xF9, 0x04,
0x00, // packed: disposal = 0, no user input, no transparent
u16le(delay), // delay in 1/100 s units
0x00, // transparent colour index (unused)
0x00, // block terminator
);
// Image Descriptor
push(
0x2C,
u16le(0), u16le(0), // left, top
u16le(w), u16le(h),
0x00, // packed: no local CT, not interlaced
);
// Image Data
push(minCodeSize);
push(lzwEncode(indices, minCodeSize));
}
// Trailer
push(0x3B);
return new Uint8Array(out);
}
}
// Export
root.GifEncoder = GifEncoder;
}(typeof window !== 'undefined' ? window : global));
+2607
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -0,0 +1,15 @@
{
"id": "/",
"name": "Chimera",
"short_name": "Chimera",
"description": "Private PS4/PS5 library and console-management platform.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#15111b",
"theme_color": "#15111b",
"orientation": "any",
"icons": [
{ "src": "/build/icon.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
+476
View File
@@ -0,0 +1,476 @@
// ── PS5 Autoloader ─────────────────────────────────────────────────────────────
let _alSequence = []; // { type: 'payload'|'delay', filename?: string, ms?: number }
let _alHeader = ''; // comment block preserved from original file
const _AL_DEFAULT_HEADER =
`#
# ps5_autoloader - autoload.txt
# Edited with Chimera
# -----------------------------------------------------------------------------------------
# Usage:
# - Put one filename per line (e.g., payload.elf or script.js).
# - Supported payload types: .elf, .bin, .js
# - Lines starting with '!' are sleep commands (example: !1000 sleeps for 1000 ms).
# -----------------------------------------------------------------------------------------
`;
function _alParse(content) {
if (!content) return { header: _AL_DEFAULT_HEADER, entries: [] };
const lines = content.split(/\r?\n/);
const headerLines = [];
const entries = [];
let headerDone = false;
for (const line of lines) {
const t = line.trim();
if (!headerDone) {
if (t.startsWith('#') || t === '') { headerLines.push(line); continue; }
headerDone = true;
}
if (t === '' || t.startsWith('#')) continue;
if (t.startsWith('!')) {
const ms = parseInt(t.slice(1), 10);
if (!isNaN(ms)) entries.push({ type: 'delay', ms });
} else {
entries.push({ type: 'payload', filename: t });
}
}
const header = headerLines.join('\n').trimEnd() + '\n\n';
return { header: header || _AL_DEFAULT_HEADER, entries };
}
function _alSerialize(header, entries) {
const lines = [header.trimEnd(), ''];
for (const e of entries)
lines.push(e.type === 'delay' ? `!${e.ms}` : e.filename);
return lines.join('\n') + '\n';
}
let _alDragIdx = -1;
function _alSecsFmt(ms) {
const s = ms / 1000;
return s === Math.floor(s) ? s.toFixed(1) + 's' : s.toFixed(2) + 's';
}
function _alBuildRow(entry, i) {
const del = `<button class="al-del" data-al="remove" data-idx="${i}" title="Remove">&#10005;</button>`;
if (entry.type === 'delay') {
const capped = Math.min(entry.ms, 10000);
const presets = [250, 500, 1000, 2000, 3000, 5000].map(p => {
const lbl = p < 1000 ? `${p}ms` : `${p / 1000}s`;
const active = entry.ms === p ? ' al-preset-active' : '';
return `<button class="al-preset${active}" data-al="preset" data-idx="${i}" data-ms="${p}">${lbl}</button>`;
}).join('');
return `<div class="al-row al-delay" draggable="true" data-idx="${i}">
<span class="al-grip" title="Drag to reorder">&#8942;&#8942;</span>
<span class="al-delay-clock">&#9201;</span>
<div class="al-delay-body">
<div class="al-delay-top">
<span class="al-delay-lbl">Wait</span>
<input class="al-delay-num" type="number" min="0" max="60000" step="50"
value="${entry.ms}" data-al="delay-num" data-idx="${i}">
<span class="al-delay-ms">ms</span>
<span class="al-delay-secs" data-idx="${i}">(${_alSecsFmt(entry.ms)})</span>
<div class="al-presets">${presets}</div>
</div>
<input class="al-slider" type="range" min="0" max="10000" step="50"
value="${capped}" data-al="delay-slider" data-idx="${i}">
</div>
${del}
</div>`;
}
const ext = (entry.filename.split('.').pop() || '').toLowerCase();
const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || '';
return `<div class="al-row al-payload" draggable="true" data-idx="${i}">
<span class="al-grip" title="Drag to reorder">&#8942;&#8942;</span>
<span class="al-ext ${extClass}">${escHtml(ext || '?')}</span>
<span class="al-name" title="${escHtml(entry.filename)}">${escHtml(entry.filename)}</span>
<button class="al-ins-delay" data-al="ins-delay" data-idx="${i}" title="Insert delay after this payload">&#43;&#9201;</button>
${del}
</div>`;
}
function _alRenderSequence() {
const seq = $('autoloader-sequence');
if (!seq) return;
if (!_alSequence.length) {
seq.innerHTML = `<div class="al-empty">
<div class="al-empty-icon">&#128196;</div>
<div>No entries yet.</div>
<div class="al-empty-hint">Click a file on the left to add it to the sequence.</div>
</div>`;
return;
}
seq.innerHTML = _alSequence.map(_alBuildRow).join('');
seq.querySelectorAll('.al-row[draggable]').forEach(row => {
row.addEventListener('dragstart', e => {
_alDragIdx = parseInt(row.dataset.idx, 10);
row.classList.add('al-dragging');
e.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragend', () => {
row.classList.remove('al-dragging');
seq.querySelectorAll('.al-drag-over').forEach(r => r.classList.remove('al-drag-over'));
});
row.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
seq.querySelectorAll('.al-drag-over').forEach(r => r.classList.remove('al-drag-over'));
if (parseInt(row.dataset.idx, 10) !== _alDragIdx) row.classList.add('al-drag-over');
});
row.addEventListener('drop', e => {
e.preventDefault();
let toIdx = parseInt(row.dataset.idx, 10);
if (_alDragIdx === -1 || _alDragIdx === toIdx) return;
const [item] = _alSequence.splice(_alDragIdx, 1);
if (_alDragIdx < toIdx) toIdx--;
_alSequence.splice(toIdx, 0, item);
_alDragIdx = -1;
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
});
});
}
function _alRenderFiles(files) {
const list = $('autoloader-file-list');
if (!list) return;
const payloads = files.filter(f => !f.isDir && /\.(elf|bin|js)$/i.test(f.name));
if (!payloads.length) {
list.innerHTML = '<p class="hint autoloader-empty-hint">No .elf / .bin / .js files found in /data/ps5_autoloader.</p>';
return;
}
list.innerHTML = payloads.map(f => {
const ext = (f.name.split('.').pop() || '').toLowerCase();
const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || '';
return `<div class="al-file-card" data-al-add="${escHtml(f.name)}" title="Click to add to sequence">
<span class="al-ext ${extClass}">${escHtml(ext)}</span>
<span class="al-file-name">${escHtml(f.name)}</span>
<button class="btn-al-save-local" data-filename="${escHtml(f.name)}" title="Save to local payloads folder">&#8675;</button>
<span class="al-file-add">&#43;</span>
</div>`;
}).join('');
}
async function loadAutoloader() {
const btn = $('btn-autoloader-refresh');
const status = $('autoloader-status');
btn.disabled = true;
status.textContent = 'Loading…';
try {
const files = await window.pork.autoloaderList();
const content = await window.pork.autoloaderRead();
const { header, entries } = _alParse(content);
_alHeader = header;
_alSequence = entries;
_alRenderFiles(files);
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
status.textContent = content
? `Loaded \u2014 ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}`
: 'autoload.txt not found \u2014 sequence is empty';
} catch (e) {
status.textContent = `Error: ${e.message}`;
} finally {
btn.disabled = false;
}
}
async function saveAutoloader() {
const btn = $('btn-autoloader-save');
const status = $('autoloader-status');
btn.disabled = true;
status.textContent = 'Saving\u2026';
try {
const content = _alSerialize(_alHeader, _alSequence);
await window.pork.autoloaderSave(content);
status.textContent = 'Saved to PS5 \u2713';
} catch (e) {
status.textContent = `Error: ${e.message}`;
btn.disabled = false;
}
}
$('btn-autoloader-refresh').addEventListener('click', loadAutoloader);
$('btn-autoloader-save').addEventListener('click', saveAutoloader);
const _persistButton = $('btn-pldmgr-persist-repair');
if (_persistButton && window.pork.payloadManagerAutoloadRepairPlan) {
$('pldmgr-persistence-panel').hidden = false;
_persistButton.addEventListener('click', async () => {
const status = $('pldmgr-persistence-status');
_persistButton.disabled = true;
status.textContent = window.t('payload.persistence.preflight');
try {
const plan = await window.pork.payloadManagerAutoloadRepairPlan();
if (plan.blocked) {
throw new Error(window.tf('payload.persistence.blocked', 'Other boot entries found: {entries}. Chimera will not remove them.', { entries: plan.unsupportedEntries.join(', ') }));
}
const confirmation = await showExactConfirmation(
window.t('payload.persistence.confirm'), plan.confirmation
);
if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled'));
status.textContent = window.t('payload.persistence.applying');
const result = await window.pork.payloadManagerAutoloadRepairApply({ planDigest: plan.planDigest, confirmation });
status.textContent = window.tf('payload.persistence.success', 'Persistent v0.5.1 verified. Snapshot: {snapshot}. Reboot check required.', { snapshot: result.snapshotId });
await loadAutoloader();
} catch (error) {
status.textContent = window.tf('payload.persistence.error', 'Persistence repair failed: {message}', { message: error.message });
} finally {
_persistButton.disabled = false;
}
});
}
const _rollbackButton = $('btn-pldmgr-persist-rollback');
if (_rollbackButton && window.pork.payloadManagerAutoloadRollbackPlan) {
_rollbackButton.addEventListener('click', async () => {
const status = $('pldmgr-persistence-status');
_rollbackButton.disabled = true;
status.textContent = window.t('payload.persistence.rollbackPreflight');
try {
const plan = await window.pork.payloadManagerAutoloadRollbackPlan();
if (plan.alreadyRestored) return void (status.textContent = window.t('payload.persistence.cancelled'));
const confirmation = await showExactConfirmation(window.t('payload.persistence.rollbackConfirm'), plan.confirmation);
if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled'));
status.textContent = window.t('payload.persistence.rollbackApplying');
const result = await window.pork.payloadManagerAutoloadRollbackApply({ planDigest: plan.planDigest, confirmation });
status.textContent = window.tf('payload.persistence.rollbackSuccess', 'Original boot state restored. Safety snapshot: {snapshot}. Reboot required.', { snapshot: result.safetySnapshotId });
await loadAutoloader();
} catch (error) {
status.textContent = window.tf('payload.persistence.error', 'Persistence repair failed: {message}', { message: error.message });
} finally { _rollbackButton.disabled = false; }
});
}
function _etaHenPersistenceAction({ buttonId, plan, apply, preflight, confirm, success, snapshot = 'snapshotId' }) {
const button = $(buttonId);
if (!button || !window.pork[plan]) return;
button.addEventListener('click', async () => {
const status = $('etahen-persistence-status');
button.disabled = true;
status.textContent = window.t(preflight);
try {
const packet = await window.pork[plan]();
const entries = [...(packet.unexpectedFiles || []), ...(packet.unsupportedEntries || [])];
if (packet.blocked) throw new Error(window.tf('payload.etahenPersistence.blocked', 'Other boot content found: {entries}.', { entries: entries.join(', ') }));
if (packet.alreadyRestored) return void (status.textContent = window.t('payload.persistence.cancelled'));
const confirmation = await showExactConfirmation(window.t(confirm), packet.confirmation);
if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled'));
const result = await window.pork[apply]({ planDigest: packet.planDigest, confirmation });
status.textContent = window.tf(success, 'etaHEN 2.6B boot state updated. Snapshot: {snapshot}. Reboot required.', { snapshot: result[snapshot] });
await loadAutoloader();
} catch (error) { status.textContent = window.tf('payload.etahenPersistence.error', 'etaHEN persistence failed: {message}', { message: error.message }); }
finally { button.disabled = false; }
});
}
_etaHenPersistenceAction({ buttonId: 'btn-etahen-persist', plan: 'etaHenAutoloadPersistencePlan', apply: 'etaHenAutoloadPersistenceApply', preflight: 'payload.etahenPersistence.preflight', confirm: 'payload.etahenPersistence.confirm', success: 'payload.etahenPersistence.success' });
_etaHenPersistenceAction({ buttonId: 'btn-etahen-persist-rollback', plan: 'etaHenAutoloadPersistenceRollbackPlan', apply: 'etaHenAutoloadPersistenceRollback', preflight: 'payload.etahenPersistence.rollbackPreflight', confirm: 'payload.etahenPersistence.rollbackConfirm', success: 'payload.etahenPersistence.rollbackSuccess', snapshot: 'safetySnapshotId' });
function _fixedPayloadStart({ buttonId, api, exact, confirmKey, startingKey, startedKey }) {
const button = $(buttonId);
if (!button || !window.pork[api]) return;
button.addEventListener('click', async () => {
const status = $('etahen-persistence-status');
const confirmation = await showExactConfirmation(window.t(confirmKey), exact);
if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled'));
button.disabled = true;
status.textContent = window.t(startingKey);
try {
const result = await window.pork[api]({ confirmation });
status.textContent = window.tf(startedKey, '', { bytes: result.bytes });
} catch (error) { status.textContent = window.tf('payload.etahenPersistence.error', '', { message: error.message }); }
finally { button.disabled = false; }
});
}
_fixedPayloadStart({ buttonId: 'btn-etahen-direct-start', api: 'etaHenDirectStart', exact: 'START DIRECT ETAHEN 2.6B', confirmKey: 'payload.etahenPersistence.startConfirm', startingKey: 'payload.etahenPersistence.starting', startedKey: 'payload.etahenPersistence.started' });
_fixedPayloadStart({ buttonId: 'btn-pldmgr-verified-start', api: 'payloadManagerVerifiedStart', exact: 'START VERIFIED PS5 PAYLOAD MANAGER 0.5.1', confirmKey: 'payload.persistence.startConfirm', startingKey: 'payload.persistence.starting', startedKey: 'payload.persistence.started' });
$('btn-autoloader-add-delay').addEventListener('click', () => {
_alSequence.push({ type: 'delay', ms: 1000 });
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
});
$('autoloader-sequence').addEventListener('click', e => {
const btn = e.target.closest('[data-al]');
if (!btn) return;
const action = btn.dataset.al;
const idx = parseInt(btn.dataset.idx, 10);
if (action === 'remove') {
_alSequence.splice(idx, 1);
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
} else if (action === 'preset') {
const ms = parseInt(btn.dataset.ms, 10);
_alSequence[idx].ms = ms;
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
} else if (action === 'ins-delay') {
_alSequence.splice(idx + 1, 0, { type: 'delay', ms: 1000 });
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
}
});
$('autoloader-sequence').addEventListener('input', e => {
const el = e.target;
const al = el.dataset.al;
if (!al) return;
const idx = parseInt(el.dataset.idx, 10);
if (al === 'delay-num' || al === 'delay-slider') {
const ms = Math.max(0, parseInt(el.value, 10) || 0);
_alSequence[idx].ms = ms;
const row = el.closest('.al-row');
const num = row.querySelector('[data-al="delay-num"]');
const slider = row.querySelector('[data-al="delay-slider"]');
const secs = row.querySelector('.al-delay-secs');
if (num) num.value = ms;
if (slider) slider.value = Math.min(ms, 10000);
if (secs) secs.textContent = `(${_alSecsFmt(ms)})`;
row.querySelectorAll('.al-preset').forEach(p =>
p.classList.toggle('al-preset-active', parseInt(p.dataset.ms, 10) === ms)
);
$('btn-autoloader-save').disabled = false;
}
});
// ── Autoloader Snapshots ─────────────────────────────────────────────────────────────────────────────
async function renderAutoloaderSnapshots() {
const list = $('snapshot-list');
if (!list) return;
list.innerHTML = '<p class="hint autoloader-empty-hint">Loading…</p>';
try {
const snaps = await window.pork.snapshotList();
if (!snaps.length) {
list.innerHTML = '<p class="hint autoloader-empty-hint">No snapshots yet. Take one above.</p>';
return;
}
list.innerHTML = snaps.map(s => {
const d = new Date(s.createdAt);
const dateStr = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const fc = (s.files || []).length;
const alStr = s.hasAutoload ? ' + autoload.txt' : '';
return `<div class="snapshot-card">
<div class="snapshot-card-info">
<span class="snapshot-card-label">${escHtml(s.label || 'Snapshot')}</span>
<span class="snapshot-card-meta">${dateStr} \u2014 ${fc} payload${fc !== 1 ? 's' : ''}${alStr}</span>
</div>
<div class="snapshot-card-actions">
<button class="btn btn-teal btn-sm btn-snap-restore" data-snap-id="${escHtml(s.id)}">&uarr; Restore</button>
<button class="btn btn-sm btn-snap-export" data-snap-id="${escHtml(s.id)}" title="Export snapshot to .porksnap file">&#8600; Export</button>
<button class="btn btn-sm btn-snap-delete" data-snap-id="${escHtml(s.id)}" title="Delete snapshot">&#10005;</button>
</div>
</div>`;
}).join('');
list.querySelectorAll('.btn-snap-restore').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.snapId;
const confirmation = await showExactConfirmation(
'Restore this snapshot to your PS5?\n\nAll payload files and autoload.txt will be uploaded back to /data/ps5_autoloader, overwriting whatever is currently there.',
'RESTORE AUTOLOADER'
);
if (!confirmation) return;
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '⋯ Uploading…';
try {
const res = await window.pork.snapshotRestore(id, confirmation);
btn.textContent = '✓ Restored';
const errNote = res.errors?.length ? ` (${res.errors.length} file${res.errors.length !== 1 ? 's' : ''} failed)` : '';
showToast(`Snapshot restored ✓ — ${res.fileCount} file${res.fileCount !== 1 ? 's' : ''} uploaded to PS5${errNote}`, res.errors?.length ? 'info' : 'ok');
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 3000);
} catch (er) {
btn.textContent = orig;
btn.disabled = false;
showToast('Restore failed: ' + er.message, 'error');
}
});
});
list.querySelectorAll('.btn-snap-export').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.snapId;
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '⋯';
try {
const res = await window.pork.snapshotExport(id);
if (res?.canceled) { btn.textContent = orig; btn.disabled = false; return; }
btn.textContent = '✓ Exported';
showToast('Snapshot exported ✓', 'ok');
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 2500);
} catch (er) {
btn.textContent = orig;
btn.disabled = false;
showToast('Export failed: ' + er.message, 'error');
}
});
});
list.querySelectorAll('.btn-snap-delete').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.snapId;
const confirmation = await showExactConfirmation('Delete this snapshot? This cannot be undone.', 'VERWIJDER');
if (!confirmation) return;
try {
await window.pork.snapshotDelete(id, confirmation);
await renderAutoloaderSnapshots();
} catch (er) {
showToast('Delete failed: ' + er.message, 'error');
}
});
});
} catch (e) {
list.innerHTML = `<p class="hint autoloader-empty-hint" style="color:var(--red)">Error: ${escHtml(e.message)}</p>`;
}
}
$('btn-snapshot-import').addEventListener('click', async () => {
const btn = $('btn-snapshot-import');
const status = $('snapshot-take-status');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '⋯ Importing…';
status.textContent = '';
try {
let res = await window.pork.snapshotImport();
if (res?.canceled) { btn.textContent = orig; btn.disabled = false; return; }
if (res?.needsLabel) {
const label = await showPrompt('Enter a label for this snapshot:', 'Imported ZIP');
if (label === null) { btn.textContent = orig; btn.disabled = false; return; } // user cancelled
res = await window.pork.snapshotImport({ label: label.trim() || 'Imported ZIP', filePath: res.filePath });
if (res?.canceled) { btn.textContent = orig; btn.disabled = false; return; }
}
status.textContent = `Imported ✓ — "${res.meta.label}"`;
await renderAutoloaderSnapshots();
} catch (e) {
status.textContent = 'Import failed: ' + e.message;
} finally {
btn.textContent = orig;
btn.disabled = false;
}
});
$('btn-snapshot-take').addEventListener('click', async () => {
const btn = $('btn-snapshot-take');
const status = $('snapshot-take-status');
const label = $('snapshot-label').value.trim() || 'Snapshot';
btn.disabled = true;
status.textContent = 'Downloading from PS5\u2026';
try {
const { meta } = await window.pork.snapshotTake(label);
const fc = meta.files.length;
const alStr = meta.hasAutoload ? ' + autoload.txt' : '';
status.textContent = `Saved \u2713 \u2014 ${fc} payload${fc !== 1 ? 's' : ''}${alStr}`;
$('snapshot-label').value = '';
await renderAutoloaderSnapshots();
} catch (e) {
status.textContent = 'Error: ' + e.message;
} finally {
btn.disabled = false;
}
});
+824
View File
@@ -0,0 +1,824 @@
// ── Auto-Backpork Generator — Renderer Module ─────────────────────────────────
// Note: $ is declared in state.js and shared across all non-module scripts.
let _sdkPairs = [];
let _gameList = []; // current candidate list shown in picker
let _selectedGames = new Set(); // game_ids explicitly selected
let _running = false;
let _bpgenProgressState = { startedAt: 0, currentGameIndex: 0, totalGames: 0 };
let _knownFirmwareLabels = [];
let _bpgenSettingsHydrated = false;
// ── Fakelib Manager state ─────────────────────────────────────────────────────
let _flBaseDir = '';
let _flPairs = []; // Array<{ pair, dir, libCount, hasLibs }>
let _flSelectedPair = null; // which pair the user wants to import for
let _flExtracting = false;
function getSdkPairValue() {
const raw = $('bpgen-sdk-pair')?.value;
if (!raw) return null;
if (raw === 'all') return 'all';
const num = Number(raw);
return Number.isFinite(num) ? num : null;
}
function getActiveFakelibDir() {
const sdkPair = getSdkPairValue();
if (!sdkPair || sdkPair === 'all') return '';
const pair = _flPairs.find(p => p.pair === sdkPair);
return pair?.hasLibs ? pair.dir : '';
}
function getPairStatus(pairNumber) {
return _flPairs.find(p => p.pair === pairNumber) || null;
}
function getPreferredFirmwareLabel(pairNumber) {
return `${pairNumber}.xx`;
}
async function saveBpgenSettings(patch = {}) {
const payload = {
bpgenOutputDir: $('bpgen-output-dir')?.value.trim() || '',
bpgenFakelibBaseDir: $('bpgen-fl-basedir')?.value.trim() || '',
bpgenFakelibDir: $('bpgen-fakelib-dir')?.value.trim() || '',
bpgenSdkPair: $('bpgen-sdk-pair')?.value || '',
bpgenFirmwareLabel: $('bpgen-fw-label')?.value.trim() || '',
...patch,
};
try {
await window.pork.setSettings(payload);
} catch (e) {
console.error('[bpgen] save settings error', e);
}
}
function restoreBpgenSettings(settings = {}) {
if (!settings || typeof settings !== 'object') return;
if (settings.bpgenOutputDir) $('bpgen-output-dir').value = settings.bpgenOutputDir;
if (settings.bpgenFakelibBaseDir) {
_flBaseDir = settings.bpgenFakelibBaseDir;
$('bpgen-fl-basedir').value = settings.bpgenFakelibBaseDir;
}
if (settings.bpgenFakelibDir) {
const input = $('bpgen-fakelib-dir');
input.value = settings.bpgenFakelibDir;
input.dataset.autoDir = '';
}
if (settings.bpgenSdkPair && [...$('bpgen-sdk-pair').options].some(o => o.value === String(settings.bpgenSdkPair))) {
$('bpgen-sdk-pair').value = String(settings.bpgenSdkPair);
}
if (settings.bpgenFirmwareLabel) {
const input = $('bpgen-fw-label');
input.value = settings.bpgenFirmwareLabel;
input.dataset.autoValue = settings.bpgenFirmwareLabel;
}
}
function syncFirmwareLabel(force = false) {
const input = $('bpgen-fw-label');
const sdkPair = getSdkPairValue();
if (!input || !sdkPair) return;
if (sdkPair === 'all') {
input.dataset.autoValue = 'All';
if (force || !input.value.trim() || input.value === input.dataset.autoValue) input.value = 'All';
return;
}
const autoLabel = getPreferredFirmwareLabel(sdkPair);
const current = input.value.trim();
const shouldReplace = force || !current || current === input.dataset.autoValue;
input.dataset.autoValue = autoLabel;
if (shouldReplace) {
input.value = autoLabel;
if (_bpgenSettingsHydrated) saveBpgenSettings({ bpgenFirmwareLabel: autoLabel });
}
}
function autoSelectSdkPairFromFakelibs(force = false) {
const available = _flPairs.filter(p => p.hasLibs).sort((a, b) => a.pair - b.pair);
if (!available.length) return;
const currentPair = getSdkPairValue();
if (currentPair === 'all') return;
const currentReady = available.some(p => p.pair === currentPair);
if (!force && currentReady) return;
$('bpgen-sdk-pair').value = String(available[0].pair);
if (_bpgenSettingsHydrated) saveBpgenSettings({ bpgenSdkPair: String(available[0].pair) });
syncFakelibInput(true);
syncFirmwareLabel(true);
}
function syncFakelibInput(force = false) {
const input = $('bpgen-fakelib-dir');
if (!input) return;
const current = input.value.trim();
const autoDir = getActiveFakelibDir();
const shouldReplace = force || !current || current === input.dataset.autoDir;
input.dataset.autoDir = autoDir || '';
if (shouldReplace) {
input.value = autoDir || '';
if (_bpgenSettingsHydrated) saveBpgenSettings({ bpgenFakelibDir: autoDir || '' });
}
}
function resetBpgenActionButtons() {
[
'bpgen-btn-output',
'bpgen-btn-fakelib',
'bpgen-btn-run',
'bpgen-btn-cancel',
'bpgen-select-all',
'bpgen-select-none',
'bpgen-fl-btn-base',
'bpgen-fl-btn-pick-pup',
'bpgen-fl-btn-extract',
].forEach(id => {
const el = $(id);
if (!el || !el.parentNode) return;
const clean = el.cloneNode(true);
clean.removeAttribute('onclick');
clean.dataset.boundInBackporks = '';
el.parentNode.replaceChild(clean, el);
});
}
// ── Init ─────────────────────────────────────────────────────────────────────
async function initBackporkGen() {
try {
const [sdkPairs, knownFirmwareLabels, savedSettings] = await Promise.all([
window.pork.bpgenSdkPairs(),
window.pork.firmwareLabels().catch(() => []),
window.pork.getSettings().catch(() => ({})),
]);
_sdkPairs = sdkPairs;
_knownFirmwareLabels = knownFirmwareLabels;
renderSdkPairDropdown();
restoreBpgenSettings(savedSettings);
_bpgenSettingsHydrated = true;
syncFakelibInput(true);
syncFirmwareLabel(true);
} catch (e) {
console.error('[bpgen] init error', e);
}
// Selection mode toggle
$('bpgen-mode').addEventListener('change', () => updateGamePickerVisibility());
$('bpgen-sdk-pair').addEventListener('change', () => {
saveBpgenSettings({ bpgenSdkPair: $('bpgen-sdk-pair').value });
syncFakelibInput();
syncFirmwareLabel();
});
$('bpgen-output-dir').addEventListener('change', () => saveBpgenSettings());
$('bpgen-fakelib-dir').addEventListener('change', () => saveBpgenSettings());
$('bpgen-fl-basedir').addEventListener('change', () => saveBpgenSettings());
// Firmware filter change → refresh game list
$('bpgen-fw-filter').addEventListener('change', () => refreshGameList());
// Buttons
$('bpgen-btn-output').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const p = await window.pork.selectFolder();
if (p) {
$('bpgen-output-dir').value = p;
saveBpgenSettings({ bpgenOutputDir: p });
}
} catch (err) {
bpgenSetStatus(`Output folder browse failed: ${err.message}`, 'error');
}
});
$('bpgen-btn-fakelib').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const p = await window.pork.selectFolder();
if (p) {
const input = $('bpgen-fakelib-dir');
input.value = p;
input.dataset.autoDir = '';
saveBpgenSettings({ bpgenFakelibDir: p });
}
} catch (err) {
bpgenSetStatus(`Fakelib folder browse failed: ${err.message}`, 'error');
}
});
$('bpgen-btn-run').addEventListener('click', () => runBackporkGen());
$('bpgen-btn-cancel').addEventListener('click', () => {
window.pork.bpgenCancel();
appendLog('Cancelling…');
});
// Select all / none in game picker
$('bpgen-select-all').addEventListener('click', () => toggleAllGames(true));
$('bpgen-select-none').addEventListener('click', () => toggleAllGames(false));
// Listen for progress events
window.pork.on('bpgen:progress', handleProgress);
// ── Fakelib Manager ────────────────────────────────────────────────────────
initFakelibManager();
resetBpgenActionButtons();
}
// ── SDK Pair Dropdown ─────────────────────────────────────────────────────────
function renderSdkPairDropdown() {
const sel = $('bpgen-sdk-pair');
const current = sel.value;
sel.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = 'all';
allOpt.textContent = 'All SDK Targets';
sel.appendChild(allOpt);
for (const p of _sdkPairs) {
const opt = document.createElement('option');
opt.value = p.pair;
const pairStatus = getPairStatus(p.pair);
const suffix = pairStatus?.hasLibs ? ` [libs ready: ${pairStatus.libCount}]` : '';
opt.textContent = `SDK Pair ${p.pair} — PS5: ${p.ps5Ver} / PS4: ${p.ps4Ver}${suffix}`;
sel.appendChild(opt);
}
if (current && [...sel.options].some(o => o.value === current)) sel.value = current;
}
// ── Game Picker ───────────────────────────────────────────────────────────────
function updateGamePickerVisibility() {
const mode = $('bpgen-mode').value;
$('bpgen-game-picker').hidden = (mode !== 'specific');
$('bpgen-fw-filter-row').hidden = (mode !== 'fw-missing');
if (mode === 'specific') refreshGameList();
}
async function refreshGameList() {
const mode = $('bpgen-mode').value;
const fw = $('bpgen-fw-filter').value.trim();
try {
if (mode === 'specific' || mode === 'fw-missing') {
$('bpgen-game-list').innerHTML = '<div class="bpgen-loading">Loading…</div>';
const games = mode === 'specific'
? await window.pork.bpgenListAll()
: await window.pork.bpgenListMissing(fw);
_gameList = games;
renderGamePicker(games);
}
} catch (e) {
console.error('[bpgen] list error', e);
}
}
function renderGamePicker(games) {
const el = $('bpgen-game-list');
if (!games.length) {
el.innerHTML = '<div style="color:var(--text-dim);font-size:12px;padding:6px">No games found</div>';
return;
}
el.innerHTML = games.map(g => {
const title = escHtml(g.prospero_name || g.title || g.game_id);
const checked = _selectedGames.has(g.game_id) ? 'checked' : '';
return `<label class="bpgen-game-row">
<input type="checkbox" data-game-id="${escHtml(g.game_id)}" ${checked}/>
<span class="bpgen-game-title">${title}</span>
<span class="bpgen-game-id">${escHtml(g.game_id)}</span>
</label>`;
}).join('');
el.querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', e => {
const id = e.target.dataset.gameId;
if (e.target.checked) _selectedGames.add(id);
else _selectedGames.delete(id);
});
});
}
function toggleAllGames(select) {
_selectedGames = select ? new Set(_gameList.map(g => g.game_id)) : new Set();
$('bpgen-game-list').querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.checked = select;
});
}
// ── Run ───────────────────────────────────────────────────────────────────────
async function runBackporkGen() {
const mode = $('bpgen-mode').value;
const sdkPair = getSdkPairValue();
const outputDir = $('bpgen-output-dir').value.trim();
const firmwareLabel = $('bpgen-fw-label').value.trim();
const fakelibDir = $('bpgen-fakelib-dir').value.trim() || null;
const overwrite = $('bpgen-overwrite').checked;
if (!outputDir) { bpgenSetStatus('Select an output folder first.', 'error'); return; }
if (sdkPair !== 'all' && !firmwareLabel) { bpgenSetStatus('Enter a firmware label (e.g. 4.xx).', 'error'); return; }
if (mode === 'fw-missing' && !$('bpgen-fw-filter').value.trim()) {
bpgenSetStatus('Enter the firmware label to check for missing backporks.', 'error');
return;
}
let gameIds = [];
let filterFw = '';
if (mode === 'specific') {
gameIds = [..._selectedGames];
if (!gameIds.length) { bpgenSetStatus('Select at least one game.', 'error'); return; }
} else if (mode === 'fw-missing') {
filterFw = $('bpgen-fw-filter').value.trim();
}
// mode === 'all-missing' → gameIds empty, filterFw empty → default all-missing logic
_running = true;
$('bpgen-btn-run').disabled = true;
$('bpgen-btn-cancel').disabled = false;
$('bpgen-log').innerHTML = '';
$('bpgen-progress-wrap').hidden = false;
_bpgenProgressState = { startedAt: Date.now(), currentGameIndex: 0, totalGames: 0 };
setProgress(0, 'Starting…');
try {
const result = await window.pork.bpgenRun({
gameIds, sdkPair, outputDir, firmwareLabel, fakelibDir, filterFw, overwrite,
});
bpgenSetStatus(`Done — ${result.ok} file(s) processed, ${result.failed} failed`, result.failed ? 'warn' : 'ok');
} catch (e) {
bpgenSetStatus(`Error: ${e.message}`, 'error');
} finally {
_running = false;
$('bpgen-btn-run').disabled = false;
$('bpgen-btn-cancel').disabled = true;
}
}
// ── Progress handling ─────────────────────────────────────────────────────────
function handleProgress({ type, ...data }) {
switch (type) {
case 'start': {
_bpgenProgressState.totalRuns = data.total || 0;
_bpgenProgressState.totalGames = data.games || 0;
_bpgenProgressState.totalPairs = data.pairs || 1;
_bpgenProgressState.currentGameIndex = 0;
const pairsLabel = data.pairs > 1 ? ` × ${data.pairs} SDK pairs = ${data.total} total operations` : '';
setProgress(0, `Processing ${data.games} game(s)${pairsLabel}…`);
appendLog(`Starting: ${data.games} game(s)${pairsLabel}`);
if (data.noLocalFolder > 0) {
appendLog(` ⚠ ${data.noLocalFolder} game(s) skipped — no local folder recorded in DB`);
}
break;
}
case 'run-config':
appendLog(`Target firmware: ${data.firmwareLabel}`);
appendLog(`SDK pair: ${data.sdkPair}`);
appendLog(`Output: ${data.outputDir}`);
appendLog(`Fakelib: ${data.fakelibDir || 'none'}`);
appendLog(`Overwrite existing output: ${data.overwrite ? 'yes' : 'no'}`);
break;
case 'pair-start': {
const pct = Math.round(((data.pairIndex - 1) / (data.totalPairs || 1)) * 100);
const pairLabel = `pair ${data.pairIndex}/${data.totalPairs}`;
const gamesNote = data.gamesInPair === 0 ? ' — all games already done, skipping' : ` — ${data.gamesInPair} game(s) to process`;
// Update progress bar with pair info so the counter always advances visibly
_bpgenProgressState.currentGameLabel = `[${pairLabel}] SDK ${data.sdkPair} (FW ${data.firmwareLabel})`;
setProgress(pct, `${_bpgenProgressState.currentGameLabel} · ${formatElapsed()}`);
appendLog(`\u27A4 ${pairLabel}: SDK ${data.sdkPair}${gamesNote}`);
break;
}
case 'game-start': {
_bpgenProgressState.currentGameIndex = data.index || 0;
// Use per-pair counters when available (all-pairs mode), else fall back to overall index
const gi = data.pairGameIndex || data.index;
const gt = data.pairTotal || _bpgenProgressState.totalGames;
const pct = Math.round(((data.index - 1) / (data.total || 1)) * 100);
// Always show pair counter so single-pair runs show "pair 1/1" and all-pairs show "pair 3/10"
const pairLabel = data.totalPairs >= 1 ? ` · pair ${data.pairIndex}/${data.totalPairs}` : '';
// Store sticky label so file-progress never overwrites it with a file path
_bpgenProgressState.currentGameLabel = `[${gi}/${gt}${pairLabel}] ${data.title || data.gameId}`;
setProgress(pct, `${_bpgenProgressState.currentGameLabel} · ${formatElapsed()}`);
appendLog(`\u25B6 [${gi}/${gt}${pairLabel}] ${data.gameId} — ${data.title || ''} (SDK ${data.sdkPair}, FW ${data.firmwareLabel})`);
break;
}
case 'file-progress':
handleFileProgress(data);
break;
case 'game-done':
appendLog(` \u2713 OK (${data.ok} files processed)`);
break;
case 'game-skip':
appendLog(` \u23ED Skipped: ${data.reason}`);
break;
case 'game-error':
appendLog(` \u2717 Error: ${data.msg}`, 'error');
break;
case 'done':
setProgress(100, `Complete — ${data.ok} files OK, ${data.failed} failed`);
appendLog('');
appendLog(`Complete: total=${data.total} ok=${data.ok} failed=${data.failed} skipped=${data.skipped}`);
appendLog(`Elapsed: ${formatElapsed()}`);
// Trigger a backpork rescan so the UI updates
window.pork.backporksScanAll().catch(() => {});
break;
case 'cancelled':
setProgress(0, 'Cancelled');
appendLog(`Cancelled after ${data.processed} game(s)`);
break;
}
}
function handleFileProgress(data) {
if (typeof data.fileIndex === 'number' && typeof data.totalFiles === 'number' && _bpgenProgressState.totalRuns > 0) {
const currentGameZeroBased = Math.max(0, (_bpgenProgressState.currentGameIndex || 1) - 1);
const perGameFraction = data.totalFiles > 0 ? (data.fileIndex / data.totalFiles) : 0;
const pct = Math.round(((currentGameZeroBased + perGameFraction) / _bpgenProgressState.totalRuns) * 100);
// Always show game name + pair info — never the file path
const label = _bpgenProgressState.currentGameLabel || `[${_bpgenProgressState.currentGameIndex}/${_bpgenProgressState.totalGames}]`;
setProgress(Math.min(99, Math.max(0, pct)), `${label} · ${formatElapsed()}`);
}
switch (data.step) {
case 'scan':
appendLog(` ⟳ ${data.msg}`);
break;
case 'inventory':
appendLog(` Found ${data.executableFiles} ELF/SELF file(s) to process`);
break;
case 'copy':
appendLog(` → Copy ${data.file}`);
break;
case 'process':
appendLog(` • ${data.file}`);
break;
case 'decrypt':
case 'patch':
case 'sign':
appendLog(` ${data.step}: ${data.file} — ${data.msg}`);
break;
case 'skip':
appendLog(` ↷ ${data.file}: ${data.msg}`);
break;
case 'warn':
appendLog(` ! ${data.file || 'step'}: ${data.msg}`);
break;
case 'error':
appendLog(` \u2717 ${data.file}: ${data.msg}`, 'error');
break;
case 'libc':
appendLog(` libc: ${data.msg}${typeof data.totalFiles === 'number' ? ` (${data.totalFiles} file(s))` : ''}`);
break;
case 'libc-progress':
appendLog(` libc [${data.fileIndex}/${data.totalFiles}]: ${data.file} — ${data.msg}`);
break;
case 'fakelib':
appendLog(` fakelib: ${data.msg}`);
break;
case 'fakelib-progress':
appendLog(` fakelib [${data.fileIndex}/${data.totalFiles}]: ${data.file}`);
break;
}
}
// ── UI helpers ────────────────────────────────────────────────────────────────
function bpgenSetStatus(msg, type = '') {
const el = $('bpgen-status');
el.textContent = msg;
el.className = 'hint bpgen-status ' + (type === 'error' ? 'status-error' : type === 'ok' ? 'status-ok' : type === 'warn' ? 'status-warn' : '');
}
function setProgress(pct, label) {
$('bpgen-progress-bar').style.width = `${pct}%`;
$('bpgen-progress-label').textContent = label;
}
function appendLog(line, type = '') {
const el = $('bpgen-log');
const row = document.createElement('div');
row.className = 'bpgen-log-row' + (type === 'error' ? ' bpgen-log-error' : '');
row.textContent = line;
el.appendChild(row);
while (el.childNodes.length > 800) el.removeChild(el.firstChild);
el.scrollTop = el.scrollHeight;
}
function escHtml(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Fakelib Manager ───────────────────────────────────────────────────────────
function initFakelibManager() {
// Base dir picker
$('bpgen-fl-btn-base').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const dir = await window.pork.selectFolder();
if (!dir) return;
const result = await window.pork.bpgenFakelibSetBasedir(dir);
if (!result) return;
_flBaseDir = result.baseDir;
_flPairs = result.pairs;
$('bpgen-fl-basedir').value = _flBaseDir;
saveBpgenSettings({ bpgenFakelibBaseDir: _flBaseDir });
renderSdkPairDropdown();
renderFlGrid();
updateFlSummaryBadge();
autoSelectSdkPairFromFakelibs(true);
syncFirmwareLabel(true);
syncFakelibInput(true);
} catch (err) {
flSetStatus(`Base directory browse failed: ${err.message}`, 'error');
}
});
// Pick PUP file
$('bpgen-fl-btn-pick-pup').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const p = await window.pork.bpgenFakelibPickPup();
if (p) $('bpgen-fl-pup-path').value = p;
} catch (err) {
flSetStatus(`PUP browse failed: ${err.message}`, 'error');
}
});
// Extract button
$('bpgen-fl-btn-extract').addEventListener('click', () => runFlExtract());
// Progress events from main process
window.pork.on('bpgen:fakelib-progress', handleFlProgress);
// Load saved state
window.pork.bpgenFakelibScan().then(({ baseDir, pairs }) => {
if (!baseDir) return;
_flBaseDir = baseDir;
_flPairs = pairs;
$('bpgen-fl-basedir').value = _flBaseDir;
renderSdkPairDropdown();
renderFlGrid();
updateFlSummaryBadge();
autoSelectSdkPairFromFakelibs();
syncFakelibInput(true);
syncFirmwareLabel(true);
}).catch(() => {});
}
function renderFlGrid() {
const grid = $('bpgen-fl-grid');
if (!_flBaseDir || !_flPairs.length) {
_flSelectedPair = null;
grid.hidden = true;
$('bpgen-fl-import-row').hidden = true;
renderSdkPairDropdown();
return;
}
grid.hidden = false;
grid.innerHTML = _flPairs.map(p => {
const cls = p.hasLibs ? 'bpgen-fl-pair-ok' : 'bpgen-fl-pair-missing';
const icon = p.hasLibs ? '&#10003;' : '&#43;';
const label = p.hasLibs ? `${p.libCount} lib${p.libCount !== 1 ? 's' : ''}` : 'No libs';
return `<button class="bpgen-fl-pair ${cls}" data-pair="${p.pair}" title="${escHtml(p.dir)}">
<span class="bpgen-fl-pair-num">${p.pair}</span>
<span class="bpgen-fl-pair-icon">${icon}</span>
<span class="bpgen-fl-pair-label">${label}</span>
</button>`;
}).join('');
grid.querySelectorAll('.bpgen-fl-pair').forEach(btn => {
btn.addEventListener('click', () => {
_flSelectedPair = Number(btn.dataset.pair);
grid.querySelectorAll('.bpgen-fl-pair').forEach(b => b.classList.remove('bpgen-fl-pair-active'));
btn.classList.add('bpgen-fl-pair-active');
$('bpgen-fl-import-pair-label').textContent = _flSelectedPair;
$('bpgen-fl-import-row').hidden = false;
$('bpgen-sdk-pair').value = String(_flSelectedPair);
syncFakelibInput(true);
syncFirmwareLabel(true);
});
});
}
function updateFlSummaryBadge() {
const badge = $('bpgen-fl-summary-status');
if (!_flPairs.length) { badge.textContent = ''; return; }
const ok = _flPairs.filter(p => p.hasLibs).length;
badge.textContent = `${ok}/10 pairs ready`;
badge.className = 'bpgen-fl-badge ' + (ok > 0 ? 'bpgen-fl-badge-ok' : 'bpgen-fl-badge-warn');
}
async function runFlExtract() {
if (_flExtracting) return;
const pupPath = $('bpgen-fl-pup-path').value.trim();
if (!pupPath) { flSetStatus('Select a .PUP.dec file first.'); return; }
if (!_flSelectedPair) { flSetStatus('Select an SDK pair above first.'); return; }
if (!_flBaseDir) { flSetStatus('Set a Fakelib Base Directory first.'); return; }
_flExtracting = true;
$('bpgen-fl-btn-extract').disabled = true;
$('bpgen-fl-progress-wrap').hidden = false;
flSetProgress(0, 'Starting…');
try {
const result = await window.pork.bpgenFakelibExtract({
pupPath,
sdkPair: _flSelectedPair,
});
flSetStatus(`Done — ${result.libCount} libraries extracted to pair ${_flSelectedPair}`, 'ok');
// Refresh grid
const scan = await window.pork.bpgenFakelibScan();
_flPairs = scan.pairs;
renderSdkPairDropdown();
renderFlGrid();
updateFlSummaryBadge();
autoSelectSdkPairFromFakelibs(true);
syncFakelibInput(true);
syncFirmwareLabel(true);
// Re-select same pair to keep import row visible
const btn = $('bpgen-fl-grid')?.querySelector(`[data-pair="${_flSelectedPair}"]`);
if (btn) { btn.classList.add('bpgen-fl-pair-active'); $('bpgen-fl-import-row').hidden = false; }
} catch (e) {
flSetProgress(0, '');
flSetStatus(`Error: ${e.message}`, 'error');
} finally {
_flExtracting = false;
$('bpgen-fl-btn-extract').disabled = false;
}
}
function handleFlProgress({ type, ...data }) {
switch (type) {
case 'start':
flSetProgress(0, `Preparing extraction for pair ${data.sdkPair}…`);
break;
case 'tool-progress':
flSetProgress(data.pct, data.msg);
break;
case 'tool-manual':
flSetStatus(`Manual download required — ${data.msg}`, 'error');
break;
case 'extract-progress':
flSetProgress(data.pct, data.msg);
break;
case 'done':
flSetProgress(100, `Complete — ${data.libCount} libraries in pair ${data.sdkPair}`);
break;
case 'error':
flSetProgress(0, '');
flSetStatus(`Error: ${data.msg}`, 'error');
break;
}
}
function flSetProgress(pct, label) {
$('bpgen-fl-progress-bar').style.width = `${pct}%`;
$('bpgen-fl-progress-label').textContent = label;
}
function flSetStatus(msg, type = '') {
// Reuse bpgen-status element for simplicity, prefixed with [FL]
const el = $('bpgen-status');
el.textContent = msg ? `[Fakelib] ${msg}` : '';
el.className = 'hint bpgen-status ' +
(type === 'error' ? 'status-error' : type === 'ok' ? 'status-ok' : type === 'warn' ? 'status-warn' : '');
}
function formatElapsed() {
if (!_bpgenProgressState.startedAt) return '0s';
const totalSeconds = Math.max(0, Math.floor((Date.now() - _bpgenProgressState.startedAt) / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
}
async function browseBpgenOutputFolder() {
try {
const p = await window.pork.selectFolder();
if (p) {
$('bpgen-output-dir').value = p;
saveBpgenSettings({ bpgenOutputDir: p });
}
} catch (err) {
bpgenSetStatus(`Output folder browse failed: ${err.message}`, 'error');
}
}
async function browseBpgenFakelibFolder() {
try {
const p = await window.pork.selectFolder();
if (p) {
const input = $('bpgen-fakelib-dir');
input.value = p;
input.dataset.autoDir = '';
saveBpgenSettings({ bpgenFakelibDir: p });
}
} catch (err) {
bpgenSetStatus(`Fakelib folder browse failed: ${err.message}`, 'error');
}
}
async function browseBpgenFakelibBaseDir() {
try {
const dir = await window.pork.selectFolder();
if (!dir) return;
const result = await window.pork.bpgenFakelibSetBasedir(dir);
if (!result) return;
_flBaseDir = result.baseDir;
_flPairs = result.pairs;
$('bpgen-fl-basedir').value = _flBaseDir;
saveBpgenSettings({ bpgenFakelibBaseDir: _flBaseDir });
renderSdkPairDropdown();
renderFlGrid();
updateFlSummaryBadge();
autoSelectSdkPairFromFakelibs(true);
syncFakelibInput(true);
syncFirmwareLabel(true);
} catch (err) {
flSetStatus(`Base directory browse failed: ${err.message}`, 'error');
}
}
async function browseBpgenPupFile() {
try {
const p = await window.pork.bpgenFakelibPickPup();
if (p) $('bpgen-fl-pup-path').value = p;
} catch (err) {
flSetStatus(`PUP browse failed: ${err.message}`, 'error');
}
}
document.addEventListener('click', e => {
const btn = e.target.closest('#bpgen-btn-output, #bpgen-btn-fakelib, #bpgen-btn-run, #bpgen-btn-cancel, #bpgen-select-all, #bpgen-select-none, #bpgen-fl-btn-base, #bpgen-fl-btn-pick-pup, #bpgen-fl-btn-extract');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
switch (btn.id) {
case 'bpgen-btn-output':
browseBpgenOutputFolder();
break;
case 'bpgen-btn-fakelib':
browseBpgenFakelibFolder();
break;
case 'bpgen-btn-run':
runBackporkGen();
break;
case 'bpgen-btn-cancel':
window.pork.bpgenCancel();
appendLog('Cancelling…');
break;
case 'bpgen-select-all':
toggleAllGames(true);
break;
case 'bpgen-select-none':
toggleAllGames(false);
break;
case 'bpgen-fl-btn-base':
browseBpgenFakelibBaseDir();
break;
case 'bpgen-fl-btn-pick-pup':
browseBpgenPupFile();
break;
case 'bpgen-fl-btn-extract':
runFlExtract();
break;
}
}, true);
window._bpgenButtonClick = action => {
switch (action) {
case 'output':
browseBpgenOutputFolder();
break;
case 'fakelib':
browseBpgenFakelibFolder();
break;
case 'baseDir':
browseBpgenFakelibBaseDir();
break;
case 'pup':
browseBpgenPupFile();
break;
case 'extract':
runFlExtract();
break;
case 'run':
runBackporkGen();
break;
case 'cancel':
window.pork.bpgenCancel();
appendLog('Cancelling…');
break;
}
return false;
};
// Export for use by main renderer
window._bpgenInit = initBackporkGen;
+624
View File
@@ -0,0 +1,624 @@
// ── Backporks page ─────────────────────────────────────────────────────────────
let _bpgenPageInitStarted = false;
let _bpgenPageInitPromise = null;
let _bpApplyUpdater = null;
window.pork.on('transfer:update', st => { _bpApplyUpdater?.(st); });
function renderBpgenSdkOptions(pairs, currentValue = '') {
const sdkSelect = $('bpgen-sdk-pair');
if (!sdkSelect) return;
sdkSelect.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = 'all';
allOpt.textContent = 'All SDK Targets';
sdkSelect.appendChild(allOpt);
for (const pair of pairs || []) {
const opt = document.createElement('option');
opt.value = String(pair.pair);
opt.textContent = `SDK Pair ${pair.pair} - PS5: ${pair.ps5Ver} / PS4: ${pair.ps4Ver}`;
sdkSelect.appendChild(opt);
}
if (currentValue && [...sdkSelect.options].some(o => o.value === String(currentValue))) {
sdkSelect.value = String(currentValue);
}
}
function pickReadyBpgenPair(pairs, preferredPair = '') {
const ready = (pairs || []).filter(p => p.hasLibs).sort((a, b) => a.pair - b.pair);
if (!ready.length) return null;
return ready.find(p => String(p.pair) === String(preferredPair)) || ready[0];
}
async function hydrateBpgenCard() {
const sdkSelect = $('bpgen-sdk-pair');
if (!sdkSelect) return;
const [settings, sdkPairs, scan] = await Promise.all([
window.pork.getSettings().catch(() => ({})),
window.pork.bpgenSdkPairs().catch(() => []),
window.pork.bpgenFakelibScan().catch(() => ({ baseDir: '', pairs: [] })),
]);
const savedPair = settings.bpgenSdkPair || '';
renderBpgenSdkOptions(sdkPairs, savedPair);
$('bpgen-output-dir').value = settings.bpgenOutputDir || '';
$('bpgen-fl-basedir').value = scan.baseDir || settings.bpgenFakelibBaseDir || '';
$('bpgen-fakelib-dir').value = settings.bpgenFakelibDir || '';
$('bpgen-fw-label').value = settings.bpgenFirmwareLabel || '';
const activePair = pickReadyBpgenPair(scan.pairs, savedPair);
if (savedPair === 'all') {
sdkSelect.value = 'all';
$('bpgen-fw-label').value = 'All';
} else if (savedPair && [...sdkSelect.options].some(o => o.value === String(savedPair))) {
sdkSelect.value = String(savedPair);
} else if (activePair) {
// Prefer a ready fakelib pair for the initial UI state, but do not
// overwrite persisted settings just because the page was opened.
sdkSelect.value = String(activePair.pair);
if (!$('bpgen-fw-label').value.trim()) $('bpgen-fw-label').value = `${activePair.pair}.xx`;
if (!$('bpgen-fakelib-dir').value.trim()) $('bpgen-fakelib-dir').value = activePair.dir || '';
}
}
function ensureBpgenPageInit() {
if (_bpgenPageInitPromise) return _bpgenPageInitPromise;
_bpgenPageInitPromise = hydrateBpgenCard().catch(err => {
console.error('[backporks] bpgen hydrate failed', err);
});
return _bpgenPageInitPromise;
}
async function loadBackporks() {
ensureBpgenPageInit();
const folders = await window.pork.backporksList();
try {
const summaries = await window.pork.hashGameSummary();
_populateHashMaps(summaries);
} catch (_) {}
const list = $('backpork-list');
const empty = $('backpork-empty');
list.innerHTML = '<div class="backpork-game-row" style="color:var(--text-dim);font-size:12px">Loading firmware folders…</div>';
empty.style.display = folders.length ? 'none' : 'block';
if (!folders.length) { list.innerHTML = ''; return; }
const gamesByFolder = await Promise.all(
folders.map(async f => ({ folder: f, games: await window.pork.backporksGames(f.name) }))
);
list.innerHTML = '';
for (const { folder: f, games } of gamesByFolder) {
const card = document.createElement('div');
card.className = 'backpork-card';
const gamesHtml = games.length ? games.map(g => {
const title = g.prospero_name || g.title || g.game_id;
const porked = g.porked_firmware === f.name;
const _bhs = _backporkHashSummary.get(g.game_id + '|' + f.name) || _gameHashSummary.get(g.game_id);
const hashPill = _bhs
? _bhs.community_mismatch
? `<span class="backpork-porked-pill hash-pill-fail" title="⚠ Community hash check FAILED — may contain modified or malicious files">⚠ Hash Fail</span>`
: _bhs.community_matches > 0
? `<span class="backpork-porked-pill hash-pill-ok" title="Community verified: ${_bhs.community_matches}/${_bhs.hash_count} files">✓ Verified</span>`
: `<span class="backpork-porked-pill hash-pill-local" title="${_bhs.hash_count} file(s) hashed">⧭ Hashed</span>`
: '';
return `
<div class="backpork-game-row">
${g.prospero_icon_url
? `<img class="backpork-game-icon" src="${escHtml(g.prospero_icon_url)}" alt="" loading="lazy" data-image-error="hide"/>`
: '<div class="backpork-game-icon"></div>'}
<div class="backpork-game-info">
<div class="backpork-game-title" title="${escHtml(title)}">${escHtml(title)}</div>
<div class="backpork-game-id">${escHtml(g.game_id)}</div>
</div>
${porked ? `<span class="backpork-porked-pill">✓ Porked</span>` : ''}
${hashPill}
<div class="backpork-game-actions">
<button class="btn btn-sm" data-bp-view="${escHtml(g.game_id)}">View</button>
<button class="btn btn-sm btn-teal"
data-bp-pork="${escHtml(g.game_id)}"
data-bp-fw="${escHtml(f.name)}"
data-bp-path="${escHtml(f.path)}"
${!state.connected ? 'disabled title="Connect to PS5 first"' : ''}>
Pork ▶
</button>
</div>
</div>`;
}).join('') : '<div class="backpork-game-row" style="color:var(--text-dim);font-size:12px">No games detected — click Rescan</div>';
card.innerHTML = `
<div class="backpork-card-header">
<div class="backpork-badge">${escHtml(f.name)}</div>
<div class="backpork-info">
<div class="backpork-path" title="${escHtml(f.path)}">${escHtml(f.path)}</div>
<div class="backpork-count"><strong>${games.length}</strong> game(s) detected</div>
</div>
<div class="backpork-actions">
<button class="btn btn-sm btn-teal" data-action="scan" data-id="${f.id}">&#8635; Rescan</button>
<button class="btn btn-sm btn-teal"
data-action="pork-all"
data-id="${f.id}"
data-fw="${escHtml(f.name)}"
data-path="${escHtml(f.path)}"
${!state.connected ? 'disabled title="Connect to PS5 first"' : ''}
title="Queue all ${games.length} game(s) from ${escHtml(f.name)} for PS5 install">
&#x1F527; Apply All (${games.length})
</button>
<button class="btn btn-sm btn-danger" data-action="remove" data-id="${f.id}">Remove</button>
</div>
</div>
<div class="backpork-games">${gamesHtml}</div>`;
list.appendChild(card);
}
list.onclick = async e => {
const btn = e.target.closest('[data-action]');
if (btn) {
const id = Number(btn.dataset.id);
if (btn.dataset.action === 'scan') {
btn.disabled = true; btn.textContent = 'Scanning…';
try {
const { count } = await window.pork.backporksScan(id);
setStatus(`Rescan complete — ${count} game(s) found`, 'ok');
await loadBackporks();
await refreshFirmwareFilters();
} catch (err) { setStatus(err.message, 'error'); }
}
if (btn.dataset.action === 'remove') {
const fwName = btn.closest('.backpork-card')?.querySelector('.backpork-badge')?.textContent || String(id);
const confirmation = await showExactConfirmation(`Remove firmware folder "${fwName}" from Chimera?`, 'VERWIJDER');
if (!confirmation) return;
await window.pork.backporksRemove(id, confirmation);
setStatus('Folder removed', 'ok');
await loadBackporks();
await refreshFirmwareFilters();
}
if (btn.dataset.action === 'pork-all') {
const folderName = btn.dataset.fw;
const folderPath = btn.dataset.path;
const folderEntry = gamesByFolder.find(({ folder }) => folder.name === folderName);
const folderGames = folderEntry?.games || [];
if (!folderGames.length) { setStatus('No games in this firmware folder', 'warn'); return; }
if (!await showConfirm(`Queue all ${folderGames.length} game(s) from ${folderName} for PS5 install?\n\nProgress will be shown below in real-time.`)) return;
// ── Progress panel helpers ────────────────────────────────────────────
const panel = $('pork-all-progress-panel');
const queueFill = $('pork-all-queue-fill');
const queueLbl = $('pork-all-queue-label');
const queueCnt = $('pork-all-queue-count');
const gameLbl = $('pork-all-current-game');
const fileFill = $('pork-all-file-fill');
const fileLbl = $('pork-all-file-label');
const logEl = $('pork-all-log');
panel.style.display = '';
logEl.textContent = '';
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const ts = () => new Date().toLocaleTimeString();
function appendLog(line, color) {
const span = document.createElement('span');
if (color) span.style.color = color;
span.textContent = `[${ts()}] ${line}\n`;
logEl.appendChild(span);
logEl.scrollTop = logEl.scrollHeight;
}
function fmtBytes(b) {
if (b >= 1073741824) return (b / 1073741824).toFixed(2) + ' GB';
if (b >= 1048576) return (b / 1048576).toFixed(1) + ' MB';
if (b >= 1024) return (b / 1024).toFixed(0) + ' KB';
return b + ' B';
}
function fmtSpeed(bps) {
return fmtBytes(bps) + '/s';
}
// ── Phase 1: Resolve FTP paths (before registering listener) ────────────
btn.disabled = true;
btn.textContent = 'Resolving paths…';
appendLog(`Starting bulk apply — ${folderGames.length} game(s) from ${folderName}`);
queueLbl.textContent = `Resolving FTP paths… (0 / ${folderGames.length})`;
const readyGames = []; // { game, ftpDest }
let skipped = 0, failed = 0;
let pathsDone = 0;
for (const game of folderGames) {
try {
const ftpDest = await ensureGameFtpPath(game.game_id);
pathsDone++;
if (!ftpDest) {
skipped++;
appendLog(` SKIP ${game.game_id} — no FTP path (cancelled)`, '#f59e0b');
} else {
appendLog(` PATH ${game.game_id} → ${ftpDest}`);
readyGames.push({ game, ftpDest });
}
queueLbl.textContent = `Resolving FTP paths… (${pathsDone} / ${folderGames.length})`;
} catch (err) {
pathsDone++;
failed++;
appendLog(` ERROR ${game.game_id}: ${err.message}`, '#f87171');
console.error('[pork-all]', game.game_id, err.message);
}
}
if (!readyGames.length) {
const parts = [`Nothing queued from ${folderName}`];
if (skipped) parts.push(`${skipped} skipped`);
if (failed) parts.push(`${failed} failed`);
appendLog(`\n${parts.join(' — ')}`, '#f59e0b');
setStatus(parts.join(' — '), 'warn');
panel.style.display = 'none';
btn.disabled = false;
btn.textContent = `\u{1F527} Apply All (${folderGames.length})`;
return;
}
// ── Phase 2: Register listener BEFORE queuing so we catch all events ──
const batchIds = new Set();
const gameById = {};
const lastState = {};
let doneCount = 0;
let lastLoggedFile = {};
let batchDone = false;
const updateProgress = (transferState) => {
if (batchDone) return;
const batchJobs2 = (transferState.jobs || []).filter(j => batchIds.has(j.id));
let nowDone = 0;
let activeJob = null;
for (const j of batchJobs2) {
const game = gameById[j.id];
const prev = lastState[j.id];
if (j.status === 'active') activeJob = j;
if (j.status !== prev) {
lastState[j.id] = j.status;
if (j.status === 'active') {
appendLog(`\n▶ START #${j.id} ${game?.game_id || j.gameId} (${j.label})`);
} else if (j.status === 'done') {
nowDone++;
appendLog(`✓ DONE #${j.id} ${game?.game_id || j.gameId}`, '#4ade80');
} else if (j.status === 'error') {
nowDone++;
appendLog(`✗ ERROR #${j.id} ${game?.game_id || j.gameId}: ${j.error || 'unknown error'}`, '#f87171');
} else if (j.status === 'cancelled') {
nowDone++;
appendLog(`⊘ CANCEL #${j.id} ${game?.game_id || j.gameId}`, '#f59e0b');
}
} else if (j.status === 'done' || j.status === 'error' || j.status === 'cancelled') {
nowDone++;
}
if (j.status === 'active' && j.progress?.file && j.progress.file !== lastLoggedFile[j.id]) {
lastLoggedFile[j.id] = j.progress.file;
const fileShort = j.progress.file.replace(/\\/g, '/').split('/').pop();
const fileInfo = j.progress.filesTotal
? ` FILE [${j.progress.filesDone + 1}/${j.progress.filesTotal}] ${fileShort}`
: ` FILE ${fileShort}`;
appendLog(fileInfo);
}
}
doneCount = nowDone;
// Overall queue bar — use batchIds.size (grows as jobs are queued)
const batchTotal = batchIds.size;
const qPct = batchTotal > 0 ? Math.round((doneCount / batchTotal) * 100) : 0;
if (!batchDone) {
// Only update bar/label while we're still monitoring
queueFill.style.width = qPct + '%';
queueLbl.textContent = `Applying backporks to PS5 — ${doneCount} / ${batchTotal} complete`;
queueCnt.textContent = `${qPct}%`;
btn.textContent = `Applying… (${doneCount}/${batchTotal})`;
}
if (activeJob) {
const g = gameById[activeJob.id];
const p = activeJob.progress || {};
const pct = p.percent || 0;
fileFill.style.width = pct + '%';
const fileShort = (p.file || '').replace(/\\/g, '/').split('/').pop();
const speedStr = p.speedBps ? ` @ ${fmtSpeed(p.speedBps)}` : '';
const sizeStr = p.total ? ` — ${fmtBytes(p.transferred)} / ${fmtBytes(p.total)}` : '';
const filesStr = p.filesTotal ? ` [file ${p.filesDone + 1}/${p.filesTotal}]` : '';
gameLbl.textContent = `${g?.game_id || activeJob.gameId}${filesStr}: ${fileShort || 'preparing…'}`;
fileLbl.textContent = `${pct.toFixed(1)}%${sizeStr}${speedStr}`;
} else if (doneCount < batchTotal) {
gameLbl.textContent = 'Waiting for next job to start…';
fileLbl.textContent = '';
fileFill.style.width = '0%';
}
// All batch jobs finished (or no more in queue)
if (batchIds.size > 0 && doneCount >= batchIds.size) {
batchDone = true;
const errCount = batchJobs2.filter(j => j.status === 'error').length;
const cancelCount = batchJobs2.filter(j => j.status === 'cancelled').length;
const parts = [`Applied ${batchIds.size - errCount - cancelCount} of ${batchIds.size}`];
if (skipped) parts.push(`${skipped} skipped`);
if (failed) parts.push(`${failed} path errors`);
if (errCount) parts.push(`${errCount} transfer errors`);
appendLog(`\n━━━ All done — ${parts.join(', ')} ━━━`, '#4ade80');
queueFill.style.width = '100%';
queueLbl.textContent = `Bulk apply complete — ${parts.join(', ')}`;
queueCnt.textContent = '100%';
gameLbl.textContent = '';
fileLbl.textContent = '';
fileFill.style.width = '0%';
btn.disabled = false;
btn.textContent = `\u{1F527} Apply All (${folderGames.length})`;
setStatus(parts.join(' — '), errCount || failed ? 'warn' : 'ok');
loadBackporks().catch(() => {});
_bpApplyUpdater = null; // stop forwarding transfer:update to this run
}
};
_bpApplyUpdater = updateProgress;
// ── Phase 3: Queue jobs now that listener is active ───────────────────
appendLog(`\nQueuing ${readyGames.length} job(s)…`);
queueLbl.textContent = `Queuing ${readyGames.length} job(s)…`;
btn.textContent = `Queuing… (0/${readyGames.length})`;
let queuedCount = 0;
for (const { game } of readyGames) {
try {
const result = await window.pork.backporksPork(game.game_id, folderName, folderPath);
batchIds.add(result.jobId);
gameById[result.jobId] = game;
queuedCount++;
appendLog(` QUEUE #${result.jobId} ${game.game_id}`);
btn.textContent = `Queuing… (${queuedCount}/${readyGames.length})`;
} catch (err) {
failed++;
appendLog(` QUEUE-ERR ${game.game_id}: ${err.message}`, '#f87171');
console.error('[pork-all queue]', game.game_id, err.message);
}
}
if (!batchIds.size) {
batchDone = true;
_bpApplyUpdater = null; // nothing queued — stop forwarding
appendLog('\nNothing was queued — all queue attempts failed', '#f59e0b');
setStatus('Queue failed — check Transfers tab', 'error');
panel.style.display = 'none';
btn.disabled = false;
btn.textContent = `\u{1F527} Apply All (${folderGames.length})`;
return;
}
appendLog(`\nAll ${batchIds.size} job(s) queued — monitoring transfers…\n`);
queueLbl.textContent = `Applying backporks to PS5 — 0 / ${batchIds.size} complete`;
queueFill.style.width = '0%';
btn.textContent = `Applying… (0/${batchIds.size})`;
return;
}
return;
}
const viewBtn = e.target.closest('[data-bp-view]');
if (viewBtn) { openModal(viewBtn.dataset.bpView); return; }
const porkBtn = e.target.closest('[data-bp-pork]');
if (porkBtn && !porkBtn.disabled) {
const gameId = porkBtn.dataset.bpPork;
const folderName = porkBtn.dataset.bpFw;
const folderPath = porkBtn.dataset.bpPath;
const ftpDest = await ensureGameFtpPath(gameId);
if (!ftpDest) return; // user cancelled picker
porkBtn.disabled = true;
porkBtn.textContent = 'Queuing…';
setStatus(`Queuing ${gameId} (${folderName}) for install…`);
try {
await window.pork.backporksPork(gameId, folderName, folderPath);
setStatus(`${gameId} queued — see Transfers tab for progress`, 'ok');
await loadBackporks();
} catch (err) {
setStatus(`Pork failed: ${err.message}`, 'error');
porkBtn.disabled = false;
porkBtn.textContent = 'Pork ▶';
}
}
};
}
window.pork.on('backporks:scan:complete', async ({ folders, count, uniqueGames }) => {
if (count > 0) {
const folderLabel = `${folders} firmware folder${folders !== 1 ? 's' : ''}`;
const buildLabel = `${count} build${count !== 1 ? 's' : ''}`;
const uniqueLabel = Number.isFinite(uniqueGames) && uniqueGames > 0
? ` across ${uniqueGames} unique game${uniqueGames !== 1 ? 's' : ''}`
: '';
showToast(`Backporks: ${buildLabel} in ${folderLabel}${uniqueLabel}`, 'info');
}
if (document.querySelector('.page.active')?.id === 'page-backporks') await loadBackporks();
await refreshFirmwareFilters();
});
$('btn-create-fw-folder').addEventListener('click', () => {
$('new-fw-form').hidden = !$('new-fw-form').hidden;
if (!$('new-fw-form').hidden) $('new-fw-name').focus();
});
$('btn-new-fw-cancel').addEventListener('click', () => {
$('new-fw-form').hidden = true;
$('new-fw-name').value = '';
$('new-fw-parent').value = '';
});
$('btn-new-fw-browse').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (folder) $('new-fw-parent').value = folder;
});
$('btn-new-fw-create').addEventListener('click', async () => {
const name = $('new-fw-name').value.trim();
const parentPath = $('new-fw-parent').value.trim();
if (!name) { setStatus('Enter a firmware version (e.g. 11.50.0)', 'error'); return; }
if (!parentPath) { setStatus('Select a parent folder', 'error'); return; }
if (!/^\d+\.\d+\.\d+$/.test(name)) { setStatus('Version must follow x.x.x format (e.g. 11.50.0)', 'error'); return; }
try {
$('btn-new-fw-create').disabled = true;
const { path: created } = await window.pork.backporksCreateFolder(name, parentPath);
showToast(`Created firmware folder ${name}`, 'ok');
$('new-fw-form').hidden = true;
$('new-fw-name').value = '';
$('new-fw-parent').value = '';
await loadBackporks();
await refreshFirmwareFilters();
} catch (err) {
setStatus(err.message, 'error');
} finally {
$('btn-new-fw-create').disabled = false;
}
});
$('btn-add-backpork').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (!folder) return;
$('btn-add-backpork').disabled = true;
setStatus('Scanning firmware folders…');
try {
const { folders, count } = await window.pork.backporksAddRoot(folder);
setStatus(`Found ${folders} firmware folder(s) — ${count} game(s) detected`, 'ok');
showToast(`Registered ${folders} firmware folder(s)`, 'ok');
await loadBackporks();
await refreshFirmwareFilters();
} catch (err) {
setStatus(err.message, 'error');
} finally {
$('btn-add-backpork').disabled = false;
}
});
// Auto-Backporks card browse actions are wired here as a fallback because this
// page module is known-good: the main "Select Backpork Folder" button on the
// same page already uses the same picker bridge successfully.
function applyBpgenAutoConfigFromPairs(pairs) {
if (!Array.isArray(pairs) || !pairs.length) return;
const ready = pairs.filter(p => p.hasLibs).sort((a, b) => a.pair - b.pair);
if (!ready.length) return;
const pair = ready[0].pair;
const sdkSelect = $('bpgen-sdk-pair');
const fwInput = $('bpgen-fw-label');
const fakelibInput = $('bpgen-fakelib-dir');
if (sdkSelect && [...sdkSelect.options].some(o => Number(o.value) === pair)) {
sdkSelect.value = String(pair);
}
if (fwInput) fwInput.value = `${pair}.xx`;
if (fakelibInput && !fakelibInput.value.trim()) {
fakelibInput.value = ready[0].dir || '';
}
window.pork.setSettings({
bpgenSdkPair: String(pair),
bpgenFirmwareLabel: `${pair}.xx`,
bpgenFakelibDir: fakelibInput?.value.trim() || '',
}).catch(() => {});
}
if (!_bpgenPageInitStarted) {
_bpgenPageInitStarted = true;
$('bpgen-sdk-pair')?.addEventListener('change', async () => {
const pair = $('bpgen-sdk-pair').value;
const baseDir = $('bpgen-fl-basedir').value.trim().replace(/[\\\/]+$/, '');
if (pair === 'all') {
$('bpgen-fw-label').value = 'All';
if (baseDir) $('bpgen-fakelib-dir').value = '';
} else {
$('bpgen-fw-label').value = pair ? `${pair}.xx` : '';
if (baseDir && pair) $('bpgen-fakelib-dir').value = `${baseDir}\\${pair}`;
}
await window.pork.setSettings({
bpgenSdkPair: pair,
bpgenFirmwareLabel: $('bpgen-fw-label').value.trim(),
bpgenFakelibDir: $('bpgen-fakelib-dir').value.trim(),
}).catch(() => {});
});
}
if (!$('bpgen-btn-output')?.dataset.boundInBackporks) {
$('bpgen-btn-output').dataset.boundInBackporks = '1';
$('bpgen-btn-output').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const folder = await window.pork.selectFolder();
if (folder) {
$('bpgen-output-dir').value = folder;
await window.pork.setSettings({ bpgenOutputDir: folder });
}
} catch (err) {
setStatus(`Output folder browse failed: ${err.message}`, 'error');
}
});
}
if (!$('bpgen-btn-fakelib')?.dataset.boundInBackporks) {
$('bpgen-btn-fakelib').dataset.boundInBackporks = '1';
$('bpgen-btn-fakelib').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const folder = await window.pork.selectFolder();
if (folder) {
$('bpgen-fakelib-dir').value = folder;
const save = { bpgenFakelibDir: folder };
const pairMatch = String(folder).match(/[\\\/](\d+)\s*$/);
if (pairMatch) {
const pair = Number(pairMatch[1]);
if (Number.isFinite(pair)) {
if ([...$('bpgen-sdk-pair').options].some(o => Number(o.value) === pair)) {
$('bpgen-sdk-pair').value = String(pair);
}
$('bpgen-fw-label').value = `${pair}.xx`;
save.bpgenSdkPair = String(pair);
save.bpgenFirmwareLabel = `${pair}.xx`;
}
}
await window.pork.setSettings(save);
}
} catch (err) {
setStatus(`Fakelib folder browse failed: ${err.message}`, 'error');
}
});
}
if (!$('bpgen-fl-btn-base')?.dataset.boundInBackporks) {
$('bpgen-fl-btn-base').dataset.boundInBackporks = '1';
$('bpgen-fl-btn-base').addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
try {
const folder = await window.pork.selectFolder();
if (!folder) return;
$('bpgen-fl-basedir').value = folder;
await window.pork.setSettings({ bpgenFakelibBaseDir: folder });
if (window.pork.bpgenFakelibSetBasedir) {
const result = await window.pork.bpgenFakelibSetBasedir(folder);
applyBpgenAutoConfigFromPairs(result?.pairs || []);
}
} catch (err) {
setStatus(`Fakelib base dir browse failed: ${err.message}`, 'error');
}
});
}
+161
View File
@@ -0,0 +1,161 @@
// ── Backups page ──────────────────────────────────────────────────────────────
function protectionText(key, fallback) {
if (typeof window.t !== 'function') return fallback;
const translated = window.t(key);
return translated && translated !== key ? translated : fallback;
}
async function loadBackups() {
try {
const gameContext = consumeGameWorkspaceContext('backups');
if (gameContext && $('backups-search')) $('backups-search').value = gameContext;
const titles = [];
let offset = 0;
let total = 0;
do {
const packet = await window.pork.listLibraryTitles({ limit: 200, offset });
const page = window.chimeraLibraryModel.items(packet);
titles.push(...page);
total = Number(packet && packet.total || page.length);
offset += page.length;
if (!page.length) break;
} while (offset < total);
state.backups = window.chimeraProtectionModel.flatten(titles);
populateBackupsRegionFilter();
renderBackups();
} catch (e) {
setStatus(e.message, 'error');
}
}
function populateBackupsRegionFilter() {
const sel = $('backups-region-filter');
const cur = sel.value;
const regions = [...new Set(
state.backups.map(copy => copy.region || '').filter(Boolean).sort()
)];
sel.innerHTML = '<option value="">All Regions</option>'
+ regions.map(r => `<option value="${escHtml(r)}"${r === cur ? ' selected' : ''}>${escHtml(r)}</option>`).join('');
}
function renderBackups() {
const search = ($('backups-search')?.value || '').toLowerCase();
const region = $('backups-region-filter')?.value || '';
const list = $('backups-list');
const empty = $('backups-empty');
const filtered = state.backups.filter(copy => {
if (region && copy.region !== region) return false;
if (search) {
const haystack = [copy.titleName, copy.titleId, copy.deviceName, copy.path, copy.hash].filter(Boolean).join(' ').toLowerCase();
if (!haystack.includes(search)) return false;
}
return true;
});
empty.hidden = filtered.length > 0;
list.innerHTML = filtered.map(copy => {
const verified = copy.lastVerifiedAt ? new Date(copy.lastVerifiedAt).toLocaleString(window.i18n?.current || 'en') : protectionText('protection.copy.never', 'Never');
const hash = copy.hash ? `${copy.hashAlgorithm.toUpperCase()} ${copy.hash}` : `${protectionText('protection.copy.notHashed', 'Not hashed')} (${copy.hashState})`;
const integrity = copy.missing
? protectionText('protection.copy.missing', 'Missing copy')
: copy.integrityFailed
? `${protectionText('protection.copy.failed', 'Failed')} (${copy.integrityState})`
: copy.integrityState === 'unknown' ? protectionText('protection.copy.notVerified', 'Not verified') : copy.integrityState;
const policyClass = copy.policy.healthy ? 'is-healthy' : copy.policy.status === 'needs-diversity' ? 'needs-diversity' : 'is-degraded';
const policyLabel = copy.policy.healthy
? `${copy.policy.distinctDeviceCount}/${copy.policy.requiredDistinctDevices} ${protectionText('protection.policy.devicesProtected', 'devices protected')}`
: `${copy.policy.distinctDeviceCount}/${copy.policy.requiredDistinctDevices} ${protectionText('protection.policy.distinctDevices', 'distinct devices')}`;
return `<article class="backup-card protection-copy-card${copy.missing ? ' is-missing' : ''}${copy.integrityFailed ? ' has-integrity-failure' : ''}" data-copy-id="${escHtml(copy.copyId)}">
<div class="protection-copy-title">
<div class="backup-card-icon backup-card-icon-empty"></div>
<div class="protection-copy-title-copy"><strong>${escHtml(copy.titleName)}</strong><code>${escHtml(copy.titleId)} · copy ${escHtml(copy.copyId)}</code></div>
</div>
<div class="protection-copy-facts">
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.device', 'Device'))}</span><button class="protection-device-label" type="button" data-label-device-id="${escHtml(copy.deviceId || '')}" data-device-label="${escHtml(copy.deviceName)}" title="${escHtml(protectionText('protection.copy.renameDevice', 'Rename this drive'))}">${escHtml(copy.deviceName)}</button></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.path', 'Path'))}</span><code title="${escHtml(copy.path)}">${escHtml(copy.path)}</code></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.hash', 'Hash'))}</span><code title="${escHtml(hash)}">${escHtml(hash)}</code></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.lastVerified', 'Last verified'))}</span><strong>${escHtml(verified)}</strong></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.integrity', 'Integrity'))}</span><strong>${escHtml(integrity)}</strong></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.size', 'Size'))}</span><strong>${escHtml(copy.sizeBytes ? fmt(copy.sizeBytes) : protectionText('protection.copy.unknown', 'Unknown'))}</strong></div>
</div>
<div class="protection-copy-actions">
<span class="protection-copy-policy ${policyClass}">${escHtml(policyLabel)}</span>
<button class="btn btn-sm btn-accent" type="button" data-restore-copy-id="${escHtml(copy.copyId)}"${copy.missing || copy.integrityFailed ? ' disabled' : ''}>${escHtml(protectionText('protection.copy.prepareRestore', 'Prepare restore'))}</button>
<button class="btn btn-sm backup-card-view" type="button" data-view-game="${escHtml(copy.titleId)}">${escHtml(protectionText('protection.copy.titleDetails', 'Title details'))}</button>
</div>
</article>`;
}).join('');
list.onclick = async e => {
const labelButton = e.target.closest('[data-label-device-id]');
if (labelButton) {
const label = await showPrompt(protectionText('protection.copy.renamePrompt', 'Friendly name for this drive:'), labelButton.dataset.deviceLabel || '');
if (label == null) return;
await window.pork.labelStorageDevice({ deviceId: Number(labelButton.dataset.labelDeviceId), label });
await loadBackups();
setStatus(protectionText('protection.copy.renamed', 'Drive label saved.'), 'ok');
return;
}
const restore = e.target.closest('[data-restore-copy-id]');
if (restore) {
const copy = state.backups.find(item => item.copyId === restore.dataset.restoreCopyId);
if (!copy) return;
state.protectionRestoreTarget = copy.restoreTarget;
list.querySelectorAll('[data-restore-copy-id]').forEach(button => {
button.setAttribute('aria-pressed', String(button === restore));
if (!button.disabled) button.textContent = button === restore
? protectionText('protection.copy.restoreSelected', 'Restore source selected')
: protectionText('protection.copy.prepareRestore', 'Prepare restore');
});
setStatus(`${protectionText('protection.copy.restoreSelected', 'Restore source selected')}: ${copy.copyId} · ${copy.deviceName}. ${protectionText('protection.copy.noMutation', 'No restore has started.')}`, 'ok');
return;
}
const details = e.target.closest('[data-view-game]');
if (details) openModal(details.dataset.viewGame);
};
}
$('backups-search')?.addEventListener('input', renderBackups);
$('backups-region-filter')?.addEventListener('change', renderBackups);
$('btn-scan-local').addEventListener('click', async () => {
$('btn-scan-local').disabled = true;
setStatus('Scanning local game source folders…');
try {
const { total, added } = await window.pork.scanBackups();
await loadBackups();
await loadDashboard();
const msg = added > 0
? `Found ${total} game(s) — ${added} new, fetching metadata…`
: `Scan complete: ${total} game(s) found, none new`;
setStatus(msg, 'ok');
} catch (e) {
setStatus(e.message, 'error');
} finally {
$('btn-scan-local').disabled = false;
}
});
$('btn-rescan-all-backporks').addEventListener('click', async () => {
const btn = $('btn-rescan-all-backporks');
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = 'Scanning…';
setStatus('Rescanning all backpork folders…');
try {
const { folders, count, uniqueGames } = await window.pork.backporksScanAll();
await loadBackporks();
await refreshFirmwareFilters();
const uniqueLabel = Number.isFinite(uniqueGames) && uniqueGames > 0
? ` across ${uniqueGames} unique game(s)`
: '';
setStatus(`Rescan complete — ${count} build(s) in ${folders} firmware folder(s)${uniqueLabel}`, 'ok');
} catch (e) {
setStatus(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = orig;
}
});
+60
View File
@@ -0,0 +1,60 @@
// BDJB Genny
const bdjbPayloads = [];
const bdjbEntries = [];
function renderBdjbEntries() {
const list = $('bdjbgen-list');
if (!bdjbEntries.length) { list.innerHTML = '<p class="hint autoloader-empty-hint">No entries yet. Add payload files, then adjust their autoload order and delays.</p>'; return; }
list.innerHTML = bdjbEntries.map((entry, index) => `<div class="autoloader-row"><span>${entry.type === 'delay' ? `Delay: ${entry.value} ms` : `Payload: ${escHtml(entry.value)}`}</span><span><button class="btn btn-sm" data-bdjb-up="${index}" ${index === 0 ? 'disabled' : ''}>Up</button><button class="btn btn-sm" data-bdjb-down="${index}" ${index === bdjbEntries.length - 1 ? 'disabled' : ''}>Down</button><button class="btn btn-sm btn-red" data-bdjb-remove="${index}">Remove</button></span></div>`).join('');
}
function bdjbLog(message) {
const log = $('bdjbgen-log');
log.textContent += `${message}\n`;
log.scrollTop = log.scrollHeight;
}
$('btn-bdjbgen-add-payloads').addEventListener('click', async () => {
const added = await window.pork.bdjbGenSelectPayloads();
for (const payload of added) {
if (bdjbPayloads.some((existing) => existing.name === payload.name)) { bdjbLog(`Skipped duplicate filename: ${payload.name}`); continue; }
bdjbPayloads.push(payload); bdjbEntries.push({ type: 'payload', value: payload.name });
}
renderBdjbEntries();
});
$('btn-bdjbgen-add-delay').addEventListener('click', async () => {
const value = await showPrompt('Delay in milliseconds:', '1000');
if (value === null || value === '') return;
bdjbEntries.push({ type: 'delay', value: String(value).trim() }); renderBdjbEntries();
});
$('bdjbgen-list').addEventListener('click', (event) => {
const button = event.target.closest('button'); if (!button) return;
const remove = button.dataset.bdjbRemove, up = button.dataset.bdjbUp, down = button.dataset.bdjbDown;
if (remove !== undefined) bdjbEntries.splice(Number(remove), 1);
else if (up !== undefined) { const i = Number(up); [bdjbEntries[i - 1], bdjbEntries[i]] = [bdjbEntries[i], bdjbEntries[i - 1]]; }
else if (down !== undefined) { const i = Number(down); [bdjbEntries[i + 1], bdjbEntries[i]] = [bdjbEntries[i], bdjbEntries[i + 1]]; }
renderBdjbEntries();
});
window.pork.on('bdjbgen:progress', ({ stage, message, percent }) => {
$('bdjbgen-progress').value = percent || 0;
$('bdjbgen-status').textContent = `${stage}: ${message}`;
bdjbLog(`[${stage}] ${message}`);
});
$('btn-bdjbgen-build').addEventListener('click', async () => {
const button = $('btn-bdjbgen-build');
button.disabled = true; $('bdjbgen-log').textContent = ''; $('bdjbgen-progress').value = 0;
try {
const result = await window.pork.bdjbGenBuild({ entries: bdjbEntries, payloads: bdjbPayloads, discTitle: $('bdjbgen-title').value });
if (result.canceled) { $('bdjbgen-status').textContent = 'Build canceled.'; return; }
$('bdjbgen-status').textContent = `ISO ready: ${result.outputIso} (${result.bytes} bytes)`;
bdjbLog(`Validated ISO with ${result.payloadCount} payload(s).`);
} catch (error) {
$('bdjbgen-status').textContent = `Build failed: ${error.message}`;
bdjbLog(`ERROR: ${error.message}`);
} finally { button.disabled = false; }
});
+496
View File
@@ -0,0 +1,496 @@
// ── Cheats page ───────────────────────────────────────────────────────────────
let _cheatsDownloading = false;
function fmtEta(sec) {
if (!sec || sec <= 0) return '—';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return m > 0 ? `${m}m ${s}s` : `${s}s`;
}
let _cheatsSearchTimer = null;
// Map of cusa_id → prospero_icon_url, built once per loadCheats() call
let _cheatIconMap = new Map();
const _cheatIconAttempted = new Set();
async function refreshCheatStats() {
try {
const stats = await window.pork.cheatsStats();
$('cheats-stat-files').textContent = `${stats.files ?? 0} files cached`;
$('cheats-stat-games').textContent = `covering ${stats.games ?? 0} games`;
const badge = $('nav-cheats-badge');
if (stats.files > 0) {
badge.textContent = stats.files;
badge.hidden = false;
} else {
badge.hidden = true;
}
} catch (_) {}
}
async function loadCheats({ background = false } = {}) {
// Hide the progress panel unless a download is actively running
if (!_cheatsDownloading) {
$('cheats-dl-panel').hidden = true;
$('btn-cheats-download-all').disabled = false;
}
await refreshCheatStats();
// Background warming keeps the badge current without building thousands of
// hidden cards or starting artwork requests for a page the user did not open.
if (background) return;
// Build icon map from games — reuse state.games if already loaded, otherwise fetch
try {
const games = state.games.length ? state.games : await window.pork.listGames({});
_cheatIconMap = new Map(games.map(g => [g.game_id, g.prospero_icon_url || '']));
} catch (_) {}
// Render with current search
const q = $('cheats-search').value.trim();
await renderCheatResults(q);
// Unmatched section (only shown when not searching)
if (!q) await renderUnmatchedCheats();
else $('cheats-unmatched-section').hidden = true;
}
// ── GarlicSaves ──────────────────────────────────────────────────────────────
let _garlicBusy = false;
let _garlicHooked = false;
async function loadGarlic() {
if (!_garlicHooked) {
_garlicHooked = true;
$('btn-garlic-site').addEventListener('click', e => {
e.preventDefault();
window.pork.openShell('https://garlicsaves.com');
});
$('garlic-contribute-link').addEventListener('click', e => {
e.preventDefault();
window.pork.openShell('https://garlicsaves.com/contribute');
});
// Start Working — show confirm modal
$('btn-garlic-start').addEventListener('click', () => {
if (_garlicBusy) return;
openAccessibleDialog($('garlic-confirm-overlay'), $('btn-garlic-confirm-cancel'));
});
// Modal: Cancel
$('btn-garlic-confirm-cancel').addEventListener('click', () => {
closeAccessibleDialog($('garlic-confirm-overlay'));
});
// Modal: Confirm — run the worker
$('btn-garlic-confirm-ok').addEventListener('click', async () => {
closeAccessibleDialog($('garlic-confirm-overlay'));
if (_garlicBusy) return;
_garlicBusy = true;
$('btn-garlic-start').disabled = true;
$('garlic-progress-area').hidden = false;
$('garlic-progress-text').textContent = 'Starting…';
$('garlic-progress-bar').style.width = '0%';
try {
await window.pork.garlicStart();
$('garlic-status-badge').hidden = false;
showToast('GarlicSaves worker started! Restart PS5 to stop.', 'success');
} catch (e) {
$('garlic-progress-text').textContent = 'Error: ' + e.message;
showToast('Failed to start worker: ' + e.message, 'error');
} finally {
_garlicBusy = false;
$('btn-garlic-start').disabled = false;
}
});
// Live progress events
window.pork.on('garlic:progress', ({ msg, percent }) => {
const txt = $('garlic-progress-text');
const bar = $('garlic-progress-bar');
if (txt) txt.textContent = msg;
if (bar && percent != null) bar.style.width = Math.min(100, percent) + '%';
});
}
}
function _garlicShowProgress(show) {
$('garlic-progress-area').hidden = !show;
if (show) {
$('garlic-progress-text').textContent = 'Starting…';
$('garlic-progress-bar').style.width = '0%';
}
}
let _lazyIconCancel = false;
// Fetch icons from prosperopatches.com for cheat entries not already in the icon map.
// Throttled at one request per 500 ms, capped at 100 entries, cancellable.
async function lazyFetchCheatIcons(cusaIds) {
_lazyIconCancel = true; // cancel any prior run
await new Promise(r => setTimeout(r, 20)); // let prior loop see the flag
_lazyIconCancel = false;
const toFetch = [...new Set(cusaIds)]
.filter(id => /^(?:CUSA|PPSA)\d{5}$/i.test(String(id || '')) && !_cheatIconAttempted.has(id))
.slice(0, 40);
for (let i = 0; i < toFetch.length; i++) {
if (_lazyIconCancel) break;
const id = toFetch[i];
_cheatIconAttempted.add(id);
try {
const { iconUrl } = await window.pork.prosperoFetchIcon(id);
if (_lazyIconCancel) break;
if (iconUrl) {
_cheatIconMap.set(id, iconUrl);
// Update every visible card for this game
document.querySelectorAll(`.cheat-card[data-cusa="${id}"]`).forEach(card => {
const empty = card.querySelector('.cheat-card-icon-empty');
if (!empty) return;
const img = document.createElement('img');
img.className = 'cheat-card-icon';
img.alt = '';
img.src = iconUrl;
img.onerror = () => { img.style.display = 'none'; };
empty.replaceWith(img);
});
}
} catch (_) {}
if (!_lazyIconCancel && i < toFetch.length - 1) await new Promise(r => setTimeout(r, 500));
}
}
async function renderCheatResults(query) {
const list = $('cheats-list');
const empty = $('cheats-empty');
list.innerHTML = '<div class="empty-msg">Loading…</div>';
empty.hidden = true;
try {
// Both cheatsAll() and cheatsSearch() return flat rows: { cusa_id, title, file_count }
const rows = query
? await window.pork.cheatsSearch(query)
: await window.pork.cheatsAll();
list.innerHTML = '';
if (!rows || rows.length === 0) {
empty.hidden = false;
return;
}
for (const row of rows) {
const card = document.createElement('div');
card.className = 'cheat-card';
card.dataset.cusa = row.cusa_id;
const fc = row.file_count ?? 1;
const iconUrl = _cheatIconMap.get(row.cusa_id) || '';
const iconHtml = iconUrl
? `<img src="${escHtml(iconUrl)}" class="cheat-card-icon" alt="" loading="lazy" data-image-error="hide">`
: `<div class="cheat-card-icon cheat-card-icon-empty"></div>`;
card.innerHTML = `
<div class="cheat-card-header">
${iconHtml}
<div class="cheat-card-info">
<div class="cheat-card-title-row">
<span class="cheat-card-id">${escHtml(row.cusa_id)}</span>
<span class="cheat-card-title">${escHtml(row.title || row.cusa_id)}</span>
</div>
<span class="cheat-card-meta">${fc} version${fc !== 1 ? 's' : ''} cached</span>
</div>
</div>`;
list.appendChild(card);
}
// Background-fetch icons for entries that have no icon in the map
const missing = rows.filter(r => r.cusa_id && !_cheatIconMap.get(r.cusa_id)).map(r => r.cusa_id);
if (missing.length > 0) lazyFetchCheatIcons(missing);
} catch (e) {
list.innerHTML = `<div class="empty-msg" style="color:var(--red)">${escHtml(e.message)}</div>`;
}
}
// Renders per-version file block inside modal cheat panel (has full data field)
function renderCheatFileBlock(f) {
let parsed;
try { parsed = typeof f.data === 'string' ? JSON.parse(f.data) : f.data; } catch (_) { parsed = null; }
const mods = parsed?.mods ?? [];
const modsHtml = mods.map(m => `
<div class="cheat-mod-row">
<span class="cheat-mod-name">${escHtml(m.name || '?')}</span>
<span class="cheat-mod-type">${escHtml(m.type || '')}</span>
</div>`).join('');
return `
<div class="cheat-file-block">
<div class="cheat-file-ver">v${escHtml(f.version || '?')} <span style="opacity:.55;font-size:.8em">${escHtml(f.filename || '')}</span></div>
<div class="cheat-mod-list">${modsHtml || '<span style="opacity:.45">No mods parsed</span>'}</div>
</div>`;
}
async function renderUnmatchedCheats() {
const section = $('cheats-unmatched-section');
const list = $('cheats-unmatched-list');
const badge = $('cheats-unmatched-count');
try {
const rows = await window.pork.cheatsUnmatched();
if (!rows || rows.length === 0) {
section.hidden = true;
return;
}
section.hidden = false;
badge.textContent = rows.length;
list.innerHTML = rows.map(r => `
<div class="cheat-unmatched-row" data-filename="${escHtml(r.filename)}">
<div class="cheat-unmatched-info">
<span class="cheat-unmatched-title">${escHtml(r.title || r.filename)}</span>
<span class="cheat-unmatched-file">${escHtml(r.filename)}</span>
</div>
<div class="cheat-unmatched-assign">
<input type="text" class="unmatched-cusa-input" placeholder="CUSA00000" maxlength="9" spellcheck="false" aria-label="Assign game ID"/>
<button class="btn btn-sm btn-accent btn-assign-cusa">Assign</button>
</div>
</div>`).join('');
} catch (e) {
section.hidden = false;
list.innerHTML = `<div class="empty-msg" style="color:var(--red)">${escHtml(e.message)}</div>`;
}
}
// Assign CUSA ID delegation
$('cheats-unmatched-list').addEventListener('click', async e => {
const btn = e.target.closest('.btn-assign-cusa');
if (!btn) return;
const row = btn.closest('.cheat-unmatched-row');
const filename = row?.dataset.filename;
const input = row?.querySelector('.unmatched-cusa-input');
const cusaId = input?.value.trim().toUpperCase();
if (!filename || !cusaId || !/^[A-Z]{4}\d{5}$/.test(cusaId)) {
input?.focus();
input?.classList.add('input-error');
setTimeout(() => input?.classList.remove('input-error'), 1200);
return;
}
btn.disabled = true;
btn.textContent = '…';
try {
await window.pork.cheatsAssign(filename, cusaId);
row.style.opacity = '0';
row.style.transition = 'opacity .3s';
setTimeout(() => renderUnmatchedCheats(), 320);
// Refresh stats / matched list
loadCheats();
} catch (err) {
btn.disabled = false;
btn.textContent = 'Assign';
setStatus(`Assign failed: ${err.message}`, 'error');
}
});
// Search input (debounced)
$('cheats-search').addEventListener('input', () => {
clearTimeout(_cheatsSearchTimer);
_cheatsSearchTimer = setTimeout(() => {
const q = $('cheats-search').value.trim();
renderCheatResults(q);
if (!q) renderUnmatchedCheats();
else $('cheats-unmatched-section').hidden = true;
}, 350);
});
// Download-all
$('btn-cheats-download-all').addEventListener('click', async () => {
_cheatsDownloading = true;
$('cheats-dl-panel').hidden = false;
$('cheats-dl-title').textContent = 'Fetching cheat index…';
$('cheats-dl-bar').style.width = '0%';
$('cheats-dl-count').textContent = '0 / 0';
$('cheats-dl-pct').textContent = '0%';
$('cheats-dl-saved').textContent = 'Saved: 0';
$('cheats-dl-failed').textContent = 'Failed: 0';
$('cheats-dl-eta').textContent = 'ETA: —';
$('cheats-dl-current').textContent = '';
$('btn-cheats-download-all').disabled = true;
try {
await window.pork.cheatsDownloadAll();
} catch (e) {
_cheatsDownloading = false;
$('cheats-dl-title').textContent = `Error: ${e.message}`;
$('btn-cheats-download-all').disabled = false;
}
});
// Cancel download
$('btn-cheats-dl-cancel').addEventListener('click', async () => {
await window.pork.cheatsCancel();
$('cheats-dl-title').textContent = 'Cancelling…';
});
// Progress IPC events
window.pork.on('cheats:dl:start', ({ total }) => {
$('cheats-dl-title').textContent = `Downloading cheat database…`;
$('cheats-dl-count').textContent = `0 / ${total}`;
$('cheats-dl-bar').style.width = '0%';
});
window.pork.on('cheats:dl:progress', ({ done, total, saved, failed, etaSec, current, pct }) => {
$('cheats-dl-bar').style.width = `${pct}%`;
$('cheats-dl-count').textContent = `${done} / ${total}`;
$('cheats-dl-pct').textContent = `${pct}%`;
$('cheats-dl-saved').textContent = `Saved: ${saved}`;
$('cheats-dl-failed').textContent = `Failed: ${failed}`;
$('cheats-dl-eta').textContent = `ETA: ${fmtEta(etaSec)}`;
$('cheats-dl-current').textContent = current || '';
});
window.pork.on('cheats:dl:done', ({ saved, failed, cancelled }) => {
_cheatsDownloading = false;
$('cheats-dl-bar').style.width = cancelled ? $('cheats-dl-bar').style.width : '100%';
$('cheats-dl-title').textContent = cancelled
? `Download cancelled — ${saved} saved, ${failed} failed`
: `Done — ${saved} saved, ${failed} failed`;
$('cheats-dl-pct').textContent = cancelled ? '' : '100%';
$('cheats-dl-eta').textContent = '';
$('cheats-dl-current').textContent = '';
$('btn-cheats-download-all').disabled = false;
// Scheduled server syncs also emit this event. Keep hidden-page work cheap;
// the full list and its artwork hydrate only when Cheats is actually open.
const active = $('page-cheats').classList.contains('active');
loadCheats({ background: !active });
});
// ── Cheats FTP install ─────────────────────────────────────────────────────────
$('btn-cheats-ftp-install').addEventListener('click', async () => {
// Reset panel to instruction state
$('cheats-ftp-progress-wrap').hidden = true;
$('cheats-ftp-bar').style.width = '0%';
$('cheats-ftp-phase').textContent = 'Preparing…';
$('cheats-ftp-count').textContent = '';
$('cheats-ftp-pct').textContent = '0%';
$('cheats-ftp-current').textContent = '';
$('cheats-ftp-title').textContent = 'Install Cheats via FTP';
$('btn-cheats-ftp-go').disabled = false;
$('btn-cheats-ftp-go').textContent = '↑ Install Now';
// Populate path from settings
const s = await window.pork.getSettings();
const configuredPath = (s.cheatsRemotePath || '').trim();
$('cheats-ftp-path').value = configuredPath || '/data/etaHEN/cheats/';
$('cheats-ftp-no-path-warn').hidden = !!configuredPath;
$('cheats-ftp-panel').hidden = false;
});
$('btn-cheats-ftp-browse').addEventListener('click', async () => {
const cur = $('cheats-ftp-path').value.trim() || '/';
const p = await openFtpBrowser(cur);
if (p) $('cheats-ftp-path').value = p;
});
$('btn-cheats-ftp-close').addEventListener('click', () => {
$('cheats-ftp-panel').hidden = true;
});
$('btn-cheats-ftp-go').addEventListener('click', async () => {
$('btn-cheats-ftp-go').disabled = true;
$('btn-cheats-ftp-go').textContent = 'Installing…';
$('cheats-ftp-progress-wrap').hidden = false;
try {
const remotePath = $('cheats-ftp-path').value.trim() || '/data/etaHEN/cheats/';
await window.pork.cheatsFtpInstall(remotePath);
} catch (e) {
$('cheats-ftp-title').textContent = `Error: ${e.message}`;
$('btn-cheats-ftp-go').disabled = false;
$('btn-cheats-ftp-go').textContent = '↑ Retry';
}
});
window.pork.on('cheats:ftp:start', ({ total }) => {
$('cheats-ftp-count').textContent = `0 / ${total}`;
});
window.pork.on('cheats:ftp:progress', ({ phase, done, total, pct, current }) => {
$('cheats-ftp-bar').style.width = `${pct}%`;
$('cheats-ftp-pct').textContent = `${pct}%`;
$('cheats-ftp-count').textContent = `${done} / ${total}`;
$('cheats-ftp-phase').textContent = phase === 'write' ? 'Writing files…' : 'Uploading to PS5…';
$('cheats-ftp-current').textContent = current || '';
});
window.pork.on('cheats:ftp:done', ({ count }) => {
$('cheats-ftp-bar').style.width = '100%';
$('cheats-ftp-pct').textContent = '100%';
$('cheats-ftp-phase').textContent = `Done — ${count} cheats installed`;
$('cheats-ftp-current').textContent = '';
$('cheats-ftp-title').textContent = 'Install complete ✓';
$('btn-cheats-ftp-go').disabled = false;
$('btn-cheats-ftp-go').textContent = '↑ Install Again';
});
// ── Cheats modal panel ─────────────────────────────────────────────────────────
async function renderCheatsPanel(game) {
const body = $('modal-cheats-body');
const badge = $('mtab-cheats-count');
body.innerHTML = '<div class="prospero-placeholder">Loading cheats…</div>';
badge.textContent = '';
try {
const cusaId = game.game_id;
const files = await window.pork.cheatsForGame(cusaId);
if (!files || files.length === 0) {
// Nothing cached — offer to fetch on-demand
body.innerHTML = `
<div class="modal-cheat-fetch">
<p>No cached cheats found for <code>${escHtml(cusaId)}</code>.</p>
<button id="btn-modal-fetch-cheats" class="btn btn-accent">Fetch from Repository</button>
</div>`;
$('btn-modal-fetch-cheats').addEventListener('click', async () => {
const fetchBtn = $('btn-modal-fetch-cheats');
fetchBtn.disabled = true;
fetchBtn.textContent = 'Fetching…';
try {
const index = await window.pork.cheatsFetchIndex();
const matching = index.filter(e => e.cusaId === cusaId);
if (matching.length === 0) {
body.innerHTML = `<div class="prospero-placeholder">No cheats found in repository for ${escHtml(cusaId)}.</div>`;
return;
}
// Download the cheat database (cached, with progress on the Cheats page),
// then re-render this game's cheats from the freshly populated cache.
fetchBtn.textContent = `Downloading ${matching.length} file(s)…`;
await window.pork.cheatsDownloadAll();
const cached = await window.pork.cheatsForGame(cusaId);
if (cached && cached.length) {
badge.textContent = cached.length;
body.innerHTML = cached.map(f => renderCheatFileBlock(f)).join('');
} else {
body.innerHTML = `<div class="prospero-placeholder">Downloaded, but no cheats cached for ${escHtml(cusaId)}.</div>`;
}
} catch (e) {
body.innerHTML = `<div class="prospero-placeholder" style="color:var(--red)">Fetch failed: ${escHtml(e.message)}</div>`;
}
});
return;
}
badge.textContent = files.length;
const modCount = files.reduce((n, f) => {
try { const d = typeof f.data === 'string' ? JSON.parse(f.data) : f.data; return n + (d?.mods?.length ?? 0); } catch (_) { return n; }
}, 0);
body.innerHTML = files.map(f => renderCheatFileBlock(f)).join('');
badge.textContent = `${modCount}`;
} catch (e) {
body.innerHTML = `<div class="prospero-placeholder" style="color:var(--red)">${escHtml(e.message)}</div>`;
}
}
@@ -0,0 +1,28 @@
(function initCompatibilityGuidance(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.chimeraCompatibilityGuidance = api;
})(typeof window !== 'undefined' ? window : null, function compatibilityGuidanceFactory() {
'use strict';
const STATES = new Set(['compatible', 'incompatible', 'workaround', 'conflict', 'unknown']);
const esc = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]);
const tr = (key, fallback) => typeof window !== 'undefined' && window.t?.(key) && window.t(key) !== key ? window.t(key) : fallback;
function normalize(value = {}) { const state = STATES.has(value.state) ? value.state : 'unknown'; return { state, blocking: value.blocking === true, rationale: Array.isArray(value.rationale) ? value.rationale : [], workarounds: Array.isArray(value.workarounds) ? value.workarounds : [] }; }
function markup(value) {
const guidance = normalize(value);
const labels = { compatible: 'Compatible', incompatible: 'Incompatible', workaround: 'Workaround available', conflict: 'Conflicting evidence', unknown: 'Compatibility unknown' };
const rationale = guidance.rationale.map(item => `<li><strong>${esc(item.explanation)}</strong><span>${esc(item.providerId)} / ${esc(item.revision)} / ${esc(item.confidence)} confidence</span><a href="${esc(item.sourceUrl)}" target="_blank" rel="noreferrer">${esc(tr('compatibilityGuidance.inspectSource', 'Inspect source'))}</a></li>`).join('');
const workarounds = guidance.workarounds.map(item => `<li>${esc(item.explanation)} <small>${esc(item.providerId)} / ${esc(item.revision)}</small></li>`).join('');
const detail = guidance.state === 'unknown' ? tr('compatibilityGuidance.unknownDetail', 'No applicable evidence is cached for this exact context.') : guidance.state === 'conflict' ? tr('compatibilityGuidance.conflictDetail', 'Sources disagree. Chimera does not resolve this automatically.') : guidance.blocking ? tr('compatibilityGuidance.blockingDetail', 'Blocked by critical, high-confidence, non-conflicting evidence.') : tr('compatibilityGuidance.informationalDetail', 'Guidance is informational unless the evidence meets the blocking policy.');
return `<article class="compatibility-guidance state-${guidance.state}" data-compatibility-state="${guidance.state}" role="status"><header><strong>${esc(tr(`compatibilityGuidance.state.${guidance.state}`, labels[guidance.state]))}</strong>${guidance.blocking ? `<span>${esc(tr('compatibilityGuidance.blocked', 'Blocked'))}</span>` : ''}</header><p>${esc(detail)}</p>${rationale ? `<ul class="compatibility-rationale">${rationale}</ul>` : ''}${workarounds ? `<details><summary>${esc(tr('compatibilityGuidance.workarounds', 'Safe workaround guidance'))}</summary><ul>${workarounds}</ul></details>` : ''}</article>`;
}
async function load(host, context) {
if (!host || typeof window === 'undefined') return null;
let guidance;
try { guidance = await window.pork.compatibilityGuidance(context); } catch (_) { guidance = { state: 'unknown', unknownReason: 'unavailable' }; }
host.querySelector('.compatibility-guidance')?.remove();
host.insertAdjacentHTML('beforeend', markup(guidance));
return guidance;
}
return Object.freeze({ normalize, markup, load });
});
+268
View File
@@ -0,0 +1,268 @@
// ── Connection state ──────────────────────────────────────────────────────────
function setConnected(on) {
state.connected = on;
const dot = $('conn-dot');
const label = $('conn-label');
const badge = $('dash-conn-badge');
const shellHealth = $('chimera-shell-health');
const shellHealthLabel = $('chimera-shell-health-label');
const tr = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback;
dot.className = `dot ${on ? 'green' : 'red'}`;
label.textContent = on ? tr('connection.connected', 'Connected') : tr('connection.disconnected', 'Disconnected');
badge.textContent = on ? tr('connection.online', 'Online') : tr('connection.offline', 'Offline');
badge.className = `badge ${on ? 'badge-green' : 'badge-red'}`;
if (shellHealth) {
shellHealth.classList.toggle('is-online', on);
shellHealth.classList.toggle('is-offline', !on);
}
if (shellHealthLabel) {
shellHealthLabel.textContent = on
? tr('connection.ready', 'Ready')
: tr('connection.disconnected', 'Disconnected');
shellHealthLabel.dataset.i18n = on ? 'connection.ready' : 'connection.disconnected';
}
$('btn-connect').disabled = on;
$('btn-disconnect').disabled = !on;
$('btn-scan').disabled = !on;
// Re-evaluate PS5 action buttons — only enabled when connected AND a location is stored
const deleteBtn = $('btn-manage-delete-ps5');
const backupBtn = $('btn-manage-backup-ps5');
if (deleteBtn) deleteBtn.disabled = !on || !state.currentGame?.ftp_path;
if (backupBtn) backupBtn.disabled = !on || !state.currentGame?.ftp_path;
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
let lastHomeReadiness = null;
async function loadDashboard() {
try {
const [settings, stats, catalog, ftpStatus] = await Promise.all([
window.pork.getSettings(),
window.pork.getStats(),
window.pork.gameCatalogHealth().catch(() => ({})),
window.pork.ftpStatus().catch(() => ({ connected: state.connected })),
]);
$('s-total').textContent = stats.totalGames;
$('s-installed').textContent = stats.installed;
$('s-backedup').textContent = stats.backedUp;
$('s-backups').textContent = stats.totalBackups;
if ($('d-host') && document.activeElement !== $('d-host') && !$('d-host').value) {
$('d-host').value = settings.ftpHost || '';
}
if ($('d-ports') && document.activeElement !== $('d-ports') && !$('d-ports').value) {
$('d-ports').value = (settings.ftpPorts && settings.ftpPorts.length) ? settings.ftpPorts.join(', ') : '1337, 2121, 21';
}
renderHomeReadiness({ settings, stats, catalog, connected: Boolean(ftpStatus.connected) });
} catch (_) {}
renderPinnedTiles();
}
function renderHomeReadiness({ settings = {}, stats, catalog, connected }) {
lastHomeReadiness = { settings, stats, catalog, connected };
const tr = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback;
const setIndicator = (id, stateName) => {
const indicator = $(id);
if (indicator) indicator.className = `chimera-readiness-dot ${stateName}`;
};
const complete = Number(catalog.complete || 0);
const missing = Number(catalog.missing || catalog.incomplete || 0);
const total = Number(stats.totalGames || 0);
const backedUp = Number(stats.backedUp || 0);
const host = String(settings.ftpHost || '').trim();
const hostConfigured = Boolean(host);
const consoleStatus = $('home-console-status');
const libraryStatus = $('home-library-status');
const backupStatus = $('home-backup-status');
const inspectorState = $('home-inspector-state');
const inspectorReason = $('home-inspector-reason');
const inspectorTarget = $('home-inspector-target');
const libraryTruth = $('home-truth-library');
const protectionTruth = $('home-truth-protection');
if (consoleStatus) consoleStatus.textContent = connected
? tr('home.consoleReady', 'Connected · manual operations')
: hostConfigured
? tr('home.targetConfiguredOffline', 'Configured · offline')
: tr('navigation.unavailable', 'Not configured');
if (libraryStatus) libraryStatus.textContent = total
? `${complete}/${total} ${tr('home.titlesEnriched', 'titles fully enriched')}${missing ? ` · ${missing} ${tr('home.toComplete', 'to complete')}` : ''}`
: tr('home.noGames', 'No games detected yet');
if (backupStatus) backupStatus.textContent = total
? `${backedUp}/${total} ${tr('home.gamesProtected', 'games protected')}`
: `${Number(stats.totalBackups || 0)} ${tr('home.backupFiles', 'backup files')}`;
if (inspectorState) {
inspectorState.textContent = connected
? tr('connection.ready', 'Ready')
: hostConfigured
? tr('home.targetOffline', 'Offline')
: tr('navigation.unavailable', 'Not configured');
inspectorState.className = `chimera-node-state ${connected ? 'ok' : hostConfigured ? 'attention' : 'unknown'}`;
}
if (inspectorReason) inspectorReason.textContent = connected
? tr('home.targetReasonConnected', 'A bounded FTP session with the PS5 is active.')
: hostConfigured
? tr('home.targetReasonOffline', 'The PS5 target is saved, but there is no active FTP session.')
: tr('home.targetReasonMissing', 'Chimera needs an explicit PS5 target before console features can connect.');
if (inspectorTarget) inspectorTarget.textContent = hostConfigured ? host : tr('navigation.unavailable', 'Not configured');
if (libraryTruth) libraryTruth.textContent = total
? `${complete}/${total} ${tr('home.titlesEnriched', 'titles fully enriched')}`
: tr('home.noGames', 'No games detected yet');
if (protectionTruth) protectionTruth.textContent = total
? `${backedUp}/${total} ${tr('home.gamesProtected', 'games protected')}`
: `${Number(stats.totalBackups || 0)} ${tr('home.backupFiles', 'backup files')}`;
setIndicator('home-console-indicator', connected ? 'ok' : hostConfigured ? 'attention' : 'unknown');
setIndicator('home-library-indicator', total && missing === 0 ? 'ok' : total ? 'attention' : 'unknown');
setIndicator('home-backup-indicator', total && backedUp >= total ? 'ok' : backedUp ? 'attention' : 'unknown');
const title = $('home-next-title');
const detail = $('home-next-detail');
const action = $('home-next-action');
if (!title || !detail || !action) return;
if (!hostConfigured) {
title.textContent = tr('home.next.configureTitle', 'Configure the PS5 target');
detail.textContent = tr('home.next.configureDetail', 'Choose the console target yourself. Chimera will not guess an address or start a connection automatically.');
action.textContent = tr('home.next.configureAction', 'Configure PS5 target');
action.dataset.route = 'settings/general';
action.dataset.focusTarget = 's-host';
} else if (!connected) {
title.textContent = tr('home.next.connectTitle', 'Connect safely to the PS5 first');
detail.textContent = tr('home.next.connectDetail', 'The target is configured. Review it and start the bounded FTP connection manually.');
action.textContent = tr('home.next.connectAction', 'Review connection');
action.dataset.route = 'home';
action.dataset.focusTarget = 'd-host';
} else if (!total) {
title.textContent = tr('home.next.scanTitle', 'Detect your game library');
detail.textContent = tr('home.next.scanDetail', 'The PS5 is reachable, but the Chimera catalog does not contain any games yet.');
action.textContent = tr('home.next.scanAction', 'Scan games');
action.dataset.route = 'library/games';
delete action.dataset.focusTarget;
} else if (missing > 0) {
title.textContent = `${tr('home.next.completeMetadata', 'Complete metadata for')} ${missing} ${missing === 1 ? tr('home.game', 'game') : tr('home.games', 'games')}`;
detail.textContent = tr('home.next.metadataDetail', 'Titles, cover art and version information are enriched automatically; review only persistent exceptions.');
action.textContent = tr('home.next.openLibrary', 'Open library');
action.dataset.route = 'library/games';
delete action.dataset.focusTarget;
} else if (backedUp < total) {
const remaining = total - backedUp;
title.textContent = `${remaining} ${remaining === 1 ? tr('home.game', 'game') : tr('home.games', 'games')} ${tr('home.next.notProtected', 'not protected yet')}`;
detail.textContent = tr('home.next.backupDetail', 'Review backups and create a new copy only from a confirmed source.');
action.textContent = tr('home.next.checkBackups', 'Check backups');
action.dataset.route = 'protection/backups';
delete action.dataset.focusTarget;
} else {
title.textContent = tr('home.next.readyTitle', 'Chimera is ready');
detail.textContent = tr('home.next.readyDetail', 'Console, metadata and library protection are in order. Updates remain explicit and reviewable.');
action.textContent = tr('home.next.reviewPayloads', 'Review payload updates');
action.dataset.route = 'payloads/updates';
delete action.dataset.focusTarget;
}
}
window.addEventListener('chimera:language-changed', () => {
if (lastHomeReadiness) renderHomeReadiness(lastHomeReadiness);
});
async function renderPinnedTiles() {
const grid = $('dash-pinned-grid');
const empty = $('dash-pinned-empty');
if (!grid) return;
try {
const s = await window.pork.getSettings();
const pinned = new Set(s.pinnedCards || []);
const cards = PINNABLE_CARDS.filter(c => pinned.has(c.id));
grid.innerHTML = '';
if (!cards.length) {
empty.hidden = false;
} else {
empty.hidden = true;
for (const c of cards) {
const tile = document.createElement('div');
tile.className = 'dash-pinned-tile';
tile.dataset.navPage = c.page;
if (c.type === 'widget') {
tile.classList.add('dash-pinned-tile--widget');
tile.removeAttribute('data-nav-page');
tile.innerHTML = _renderWidgetTile(c);
grid.appendChild(tile);
_initWidgetTile(c, tile);
continue; // already appended
} else {
tile.innerHTML = `
<div class="dash-pinned-tile-title">${escHtml(c.label)}</div>
<div class="dash-pinned-tile-page">${escHtml(_PIN_PAGE_LABELS[c.page] || c.page)}</div>
<div class="dash-pinned-tile-hint">${escHtml(c.hint)}</div>
<button class="btn btn-sm dash-pinned-tile-open">Open &rarr;</button>`;
}
grid.appendChild(tile);
}
}
// Sync all pin button visual states across the whole page
document.querySelectorAll('.btn-pin').forEach(btn => {
btn.classList.toggle('pinned', pinned.has(btn.dataset.pinId));
});
} catch (_) {}
}
// ── FTP Connect ───────────────────────────────────────────────────────────────
$('btn-connect').addEventListener('click', async () => {
const host = $('d-host').value.trim();
const ports = $('d-ports').value.split(',').map(p => p.trim()).filter(Boolean);
if (!ports.length) ports.push('21');
if (!host) { setStatus('Enter a host address', 'error'); return; }
// Credential fields start blank. Fall back to stored settings when blank
// so the user can click Connect without re-typing every session.
const saved = await window.pork.getSettings();
const user = $('d-user').value.trim() || saved.ftpUser || '';
const password = $('d-pass').value || saved.ftpPass || '';
$('btn-connect').disabled = true;
setStatus('Connecting…');
try {
const result = await window.pork.ftpConnect({ host, ports, user, password });
setConnected(true);
setStatus('Connected', 'ok');
$('sb-sync').textContent = `Connected on :${result?.port || ports[0]} at ${new Date().toLocaleTimeString()}`;
} catch (e) {
setConnected(false);
setStatus(`Connection failed: ${e.message}`, 'error');
$('btn-connect').disabled = false;
}
});
$('btn-disconnect').addEventListener('click', async () => {
await window.pork.ftpDisconnect();
setConnected(false);
setStatus('Disconnected');
});
// ── FTP Scan ──────────────────────────────────────────────────────────────────
$('btn-scan').addEventListener('click', async () => {
const prog = $('scan-progress');
prog.hidden = false;
prog.textContent = 'Scanning PS5 game folders…';
$('btn-scan').disabled = true;
window.pork.on('ftp:progress', ({ current, game }) => {
prog.textContent = `[${current}] Found: ${game.title || game.game_id}`;
});
try {
const { count } = await window.pork.ftpScan();
prog.textContent = `Scan complete — ${count} game(s) found.`;
$('sb-sync').textContent = `Last sync: ${new Date().toLocaleTimeString()}`;
setStatus(`Scanned ${count} games`, 'ok');
await loadDashboard();
} catch (e) {
prog.textContent = `Error: ${e.message}`;
setStatus(e.message, 'error');
} finally {
window.pork.off('ftp:progress');
$('btn-scan').disabled = false;
}
});
+317
View File
@@ -0,0 +1,317 @@
'use strict';
// ── Dashboard Interactive Mini-Widgets ────────────────────────────────────────
// Renders and drives interactive tool tiles pinned to the dashboard.
// Called from connection.js renderPinnedTiles() for cards with type:'widget'.
// Off-screen canvas used by the XAvatar widget for pixel conversion
const _dwXaCanvas = document.createElement('canvas');
let _dwXaFilename = 'avatar';
// ── Render ─────────────────────────────────────────────────────────────────────
// Returns an innerHTML string for a given pinnable card.
function _renderWidgetTile(card) {
switch (card.id) {
case 'psnotify':
return `
<div class="dash-widget-hdr">&#128226; Send Notification</div>
<input class="dash-widget-input" data-dw-key="psnotify-msg" placeholder="Message&#8230;" maxlength="120">
<input class="dash-widget-input" data-dw-key="psnotify-sub" placeholder="Sub-message (optional)&#8230;" maxlength="120">
<div class="dash-widget-row">
<button class="btn btn-sm btn-teal dash-widget-btn" data-dw-action="psnotify-send">&#128226; Send</button>
<span class="dash-widget-status" data-dw-status="psnotify"></span>
</div>
<div class="dash-widget-row" style="margin-top:4px">
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="psnotify">Open &rarr;</button>
</div>`;
case 'payload-mgr':
return `
<div class="dash-widget-hdr">&#8680; Quick Payload Send</div>
<select class="dash-widget-select" data-dw-key="payload-file">
<option value="">Loading payloads&#8230;</option>
</select>
<div class="dash-widget-row">
<span class="dash-widget-port-lbl">Port</span>
<input type="number" class="dash-widget-port-input" data-dw-key="payload-port" value="9021" min="1" max="65535">
<button class="btn btn-sm btn-teal dash-widget-btn" data-dw-action="payload-send">&#8680; Send</button>
<span class="dash-widget-status" data-dw-status="payload"></span>
</div>
<div class="dash-widget-row" style="margin-top:4px">
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="jailbreak">Open &rarr;</button>
</div>`;
case 'xavatar-convert':
return `
<div class="dash-widget-hdr">&#128100; XAvatar Quick Send</div>
<div class="dash-widget-drop" data-dw-drop="xavatar">Drop image or click to browse</div>
<div class="dash-widget-row">
<button class="btn btn-sm dash-widget-btn" data-dw-action="xavatar-browse">Browse&#8230;</button>
<button class="btn btn-sm btn-teal dash-widget-btn" data-dw-action="xavatar-send" disabled>&#8599; Send to PS5</button>
<span class="dash-widget-status" data-dw-status="xavatar"></span>
</div>
<div class="dash-widget-row" style="margin-top:4px">
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="xavatar">Open &rarr;</button>
</div>`;
case 'dash-savemgr':
return `
<div class="dash-widget-hdr">&#128190; Save Manager</div>
<div class="dash-widget-savelist" data-dw-list="savemgr">Click Reload to fetch saves.</div>
<div class="dash-widget-row">
<button class="btn btn-sm dash-widget-btn" data-dw-action="savemgr-reload">&#8635; Reload</button>
<span class="dash-widget-status" data-dw-status="savemgr"></span>
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="savemgr">Open &rarr;</button>
</div>`;
default:
// Fallback: plain nav tile for unknown widget IDs
return `
<div class="dash-pinned-tile-title">${escHtml(card.label)}</div>
<div class="dash-pinned-tile-hint">${escHtml(card.hint)}</div>
<button class="btn btn-sm dash-pinned-tile-open" data-nav-page="${escHtml(card.page)}">Open &rarr;</button>`;
}
}
// ── Post-render init ───────────────────────────────────────────────────────────
// Called immediately after the tile is appended to the DOM.
function _initWidgetTile(card, tileEl) {
switch (card.id) {
case 'payload-mgr':
_dwPopulatePayloadSelect(tileEl);
break;
case 'xavatar-convert':
_dwWireXavatarDrop(tileEl);
break;
default:
break;
}
}
// ── Payload select population ──────────────────────────────────────────────────
async function _dwPopulatePayloadSelect(tileEl) {
const sel = tileEl.querySelector('[data-dw-key="payload-file"]');
if (!sel) return;
try {
const files = await window.pork.payloadListLocal();
if (!files.length) {
sel.innerHTML = '<option value="">No local payloads</option>';
return;
}
sel.innerHTML = files.map(f =>
`<option value="${escHtml(f.local_path)}|${escHtml(f.name)}">${escHtml(f.name)}</option>`
).join('');
} catch (_) {
sel.innerHTML = '<option value="">Error loading payloads</option>';
}
}
// ── XAvatar drop-zone wiring ───────────────────────────────────────────────────
function _dwWireXavatarDrop(tileEl) {
const drop = tileEl.querySelector('[data-dw-drop="xavatar"]');
if (!drop) return;
drop.addEventListener('click', () => {
const inp = document.createElement('input');
inp.type = 'file';
inp.accept = 'image/*';
inp.onchange = e => {
const file = e.target.files[0];
if (file) _dwLoadXavatarFile(file, tileEl);
};
inp.click();
});
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('dw-drag-over'); });
drop.addEventListener('dragleave', () => drop.classList.remove('dw-drag-over'));
drop.addEventListener('drop', e => {
e.preventDefault();
drop.classList.remove('dw-drag-over');
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith('image/')) _dwLoadXavatarFile(file, tileEl);
});
}
async function _dwLoadXavatarFile(file, tileEl) {
const status = tileEl.querySelector('[data-dw-status="xavatar"]');
const sendBtn = tileEl.querySelector('[data-dw-action="xavatar-send"]');
const drop = tileEl.querySelector('[data-dw-drop="xavatar"]');
if (status) status.textContent = 'Loading…';
try {
const bmp = await createImageBitmap(file);
_dwXaCanvas.width = bmp.width;
_dwXaCanvas.height = bmp.height;
_dwXaCanvas.getContext('2d').drawImage(bmp, 0, 0);
_dwXaFilename = file.name.replace(/\.[^.]+$/, '');
if (drop) drop.textContent = file.name;
if (sendBtn) sendBtn.disabled = false;
if (status) status.textContent = 'Ready';
} catch (err) {
if (status) status.textContent = 'Load failed';
}
}
// ── Save Manager ───────────────────────────────────────────────────────────────
async function _dwSavemgrLoad(btn) {
const tile = btn.closest('.dash-pinned-tile');
const listEl = tile?.querySelector('[data-dw-list="savemgr"]');
const status = tile?.querySelector('[data-dw-status="savemgr"]');
if (!listEl) return;
btn.disabled = true;
btn.textContent = 'Loading…';
if (status) status.textContent = '';
try {
const r = await window.pork.savemgrRequest('/api/saves');
const saves = typeof r === 'string' ? JSON.parse(r) : r;
if (!saves || !saves.length) {
listEl.textContent = 'No saves found.';
if (status) status.textContent = '0 saves';
} else {
listEl.innerHTML = saves.slice(0, 8).map((s, i) =>
`<div class="dash-dw-save-row">
<span class="dash-dw-save-name" title="${escHtml(s.titleId || '')}">${escHtml(s.name || s.title || s.titleId || `Save ${i}`)}</span>
<button class="btn btn-xs dash-widget-btn" data-dw-action="savemgr-dl" data-sm-idx="${i}">&#11015; ZIP</button>
</div>`
).join('');
if (status) status.textContent = `${saves.length} save${saves.length !== 1 ? 's' : ''}`;
}
} catch (err) {
listEl.textContent = 'Error: ' + err.message;
} finally {
btn.disabled = false;
btn.textContent = '↻ Reload';
}
}
async function _dwSavemgrDl(btn) {
const idx = btn.dataset.smIdx;
const tile = btn.closest('.dash-pinned-tile');
const status = tile?.querySelector('[data-dw-status="savemgr"]');
btn.disabled = true;
btn.textContent = '…';
if (status) status.textContent = 'Mounting…';
try {
await window.pork.savemgrRequest('/api/mount?idx=' + idx);
if (status) status.textContent = 'Downloading…';
await window.pork.savemgrDownload('/api/download', `save_${idx}.zip`);
if (status) status.textContent = '✓ Saved';
setTimeout(() => { if (status) status.textContent = ''; }, 4000);
} catch (err) {
if (status) status.textContent = 'Error: ' + err.message;
} finally {
btn.disabled = false;
btn.textContent = '⬇ ZIP';
}
}
// ── Event delegation ───────────────────────────────────────────────────────────
document.addEventListener('click', async e => {
// "Open →" buttons inside widget tiles — navigate to the full page
const navBtn = e.target.closest('.dash-pinned-tile-open[data-nav-page]');
if (navBtn && navBtn.closest('.dash-pinned-tile--widget')) {
e.stopPropagation();
navigate(navBtn.dataset.navPage);
return;
}
const btn = e.target.closest('.dash-widget-btn[data-dw-action]');
if (!btn) return;
e.stopPropagation();
switch (btn.dataset.dwAction) {
case 'psnotify-send': await _dwPsnotifySend(btn); break;
case 'payload-send': await _dwPayloadSend(btn); break;
case 'xavatar-browse': _dwXavatarBrowseClick(btn); break;
case 'xavatar-send': await _dwXavatarSend(btn); break;
case 'savemgr-reload': await _dwSavemgrLoad(btn); break;
case 'savemgr-dl': await _dwSavemgrDl(btn); break;
}
});
// ── PSNotify action ────────────────────────────────────────────────────────────
async function _dwPsnotifySend(btn) {
const tile = btn.closest('.dash-pinned-tile');
const msg = tile?.querySelector('[data-dw-key="psnotify-msg"]')?.value?.trim() || '';
const sub = tile?.querySelector('[data-dw-key="psnotify-sub"]')?.value?.trim() || '';
const status = tile?.querySelector('[data-dw-status="psnotify"]');
if (!msg) { if (status) { status.textContent = 'Message required'; status.style.color = 'var(--red)'; } return; }
btn.disabled = true;
if (status) { status.textContent = 'Sending…'; status.style.color = ''; }
try {
await window.pork.psnotifySend(msg, sub);
if (status) { status.textContent = '✓ Sent'; status.style.color = 'var(--green)'; }
if (tile) {
const msgEl = tile.querySelector('[data-dw-key="psnotify-msg"]');
const subEl = tile.querySelector('[data-dw-key="psnotify-sub"]');
if (msgEl) msgEl.value = '';
if (subEl) subEl.value = '';
}
setTimeout(() => { if (status) { status.textContent = ''; status.style.color = ''; } }, 3000);
} catch (err) {
if (status) { status.textContent = 'Error: ' + err.message; status.style.color = 'var(--red)'; }
} finally {
btn.disabled = false;
}
}
// ── Payload action ─────────────────────────────────────────────────────────────
async function _dwPayloadSend(btn) {
const tile = btn.closest('.dash-pinned-tile');
const sel = tile?.querySelector('[data-dw-key="payload-file"]');
const portEl = tile?.querySelector('[data-dw-key="payload-port"]');
const status = tile?.querySelector('[data-dw-status="payload"]');
const val = sel?.value || '';
if (!val) { if (status) { status.textContent = 'Pick a payload'; status.style.color = 'var(--red)'; } return; }
const [localPath, filename] = val.split('|');
const port = parseInt(portEl?.value || '9021', 10);
btn.disabled = true;
if (status) { status.textContent = 'Sending…'; status.style.color = ''; }
try {
await window.pork.payloadTcpSend({ localPath, port, filename });
if (status) { status.textContent = `✓ ${filename} sent`; status.style.color = 'var(--green)'; }
setTimeout(() => { if (status) { status.textContent = ''; status.style.color = ''; } }, 4000);
} catch (err) {
if (status) { status.textContent = 'Error: ' + err.message; status.style.color = 'var(--red)'; }
} finally {
btn.disabled = false;
}
}
// ── XAvatar actions ────────────────────────────────────────────────────────────
function _dwXavatarBrowseClick(btn) {
const tile = btn.closest('.dash-pinned-tile');
const inp = document.createElement('input');
inp.type = 'file';
inp.accept = 'image/*';
inp.onchange = e => {
const file = e.target.files[0];
if (file) _dwLoadXavatarFile(file, tile);
};
inp.click();
}
async function _dwXavatarSend(btn) {
const tile = btn.closest('.dash-pinned-tile');
const status = tile?.querySelector('[data-dw-status="xavatar"]');
if (_dwXaCanvas.width === 0) return;
btn.disabled = true;
if (status) { status.textContent = 'Converting…'; status.style.color = ''; }
try {
const ctx = _dwXaCanvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, _dwXaCanvas.width, _dwXaCanvas.height);
const rgba = new Uint8Array(imgData.data.buffer);
const res = await window.pork.xavatarConvertCanvas(rgba, _dwXaCanvas.width, _dwXaCanvas.height, _dwXaFilename);
if (!res || !res.ok) throw new Error((res && res.error) || 'Conversion failed');
if (status) status.textContent = 'Uploading…';
const pad = n => String(n).padStart(5, '0');
const rnd = pad(Math.floor(Math.random() * 100000));
const name = `chimera${rnd}.xavatar`;
await window.pork.xavatarUploadToPs5(res.buffer?.buffer || res.buffer, name);
if (status) { status.textContent = `✓ Queued: ${name}`; status.style.color = 'var(--green)'; }
setTimeout(() => { if (status) { status.textContent = ''; status.style.color = ''; } }, 5000);
} catch (err) {
if (status) { status.textContent = 'Error: ' + err.message; status.style.color = 'var(--red)'; }
} finally {
btn.disabled = false;
}
}
+521
View File
@@ -0,0 +1,521 @@
// ── Database page ─────────────────────────────────────────────────────────────
const DB_PAGE_SIZE = 100;
const _dbPages = {
games: { offset: 0, total: 0 },
backups: { offset: 0, total: 0 },
hashes: { offset: 0, total: 0 },
};
async function loadDatabase() {
await Promise.all([loadDbTable('games'), loadDbTable('backups'), loadHashRowsTable()]);
}
async function loadHashRowsTable() {
try {
const [rows, countRows] = await Promise.all([
window.pork.dbQuery(`SELECT id, game_id, hash_type, firmware_label, file_size,
SUBSTR(file_hash,1,16) || '…' AS file_hash_short,
backup_path, verified, hashed_at
FROM game_hashes ORDER BY hashed_at DESC LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages.hashes.offset}`),
window.pork.dbQuery('SELECT COUNT(*) AS total FROM game_hashes'),
]);
_dbPages.hashes.total = Number(countRows[0]?.total || 0);
if (_dbPages.hashes.offset >= _dbPages.hashes.total && _dbPages.hashes.offset > 0) {
_dbPages.hashes.offset = Math.max(0, Math.floor(Math.max(0, _dbPages.hashes.total - 1) / DB_PAGE_SIZE) * DB_PAGE_SIZE);
return loadHashRowsTable();
}
const countEl = $('hash-rows-count');
if (countEl) countEl.textContent = dbPageLabel('hashes');
updateDbPager('hashes');
renderDbTableEl('db-hash-rows-table', rows, 'hashes');
} catch (e) {
setStatus(e.message, 'error');
}
}
async function loadDbTable(name) {
try {
const sql = name === 'games'
? `SELECT id, title, game_id, version, size, installed, backed_up, last_scanned FROM games ORDER BY title LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages[name].offset}`
: `SELECT id, game_id, backup_type, size, backup_path, created_at FROM backups ORDER BY created_at DESC LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages[name].offset}`;
const [rows, countRows] = await Promise.all([window.pork.dbQuery(sql), window.pork.dbQuery(`SELECT COUNT(*) AS total FROM ${name}`)]);
_dbPages[name].total = Number(countRows[0]?.total || 0);
if (_dbPages[name].offset >= _dbPages[name].total && _dbPages[name].offset > 0) {
_dbPages[name].offset = Math.max(0, Math.floor(Math.max(0, _dbPages[name].total - 1) / DB_PAGE_SIZE) * DB_PAGE_SIZE);
return loadDbTable(name);
}
const info = $(`db-${name}-page-info`);
if (info) info.textContent = dbPageLabel(name);
updateDbPager(name);
renderDbTableEl(`db-${name}-table`, rows, name);
} catch (e) {
setStatus(e.message, 'error');
}
}
function dbPageLabel(name) {
const page = _dbPages[name];
const first = page.total ? page.offset + 1 : 0;
const last = Math.min(page.total, page.offset + DB_PAGE_SIZE);
const label = name === 'games' ? 'games' : name === 'backups' ? 'back-ups' : 'hashes';
const separator = window.i18n?.current === 'nl' ? 'van' : 'of';
return `${first}–${last} ${separator} ${page.total} ${label}`;
}
function dbColumnLabel(column) {
return String(column || '')
.replace(/_/g, ' ')
.replace(/\b\w/g, letter => letter.toUpperCase())
.replace(/\bId\b/g, 'ID')
.replace(/\bSha 256\b/gi, 'SHA-256');
}
function dbFormatValue(column, value) {
if (value == null || value === '') return '—';
if (['size', 'file_size'].includes(column) && Number.isFinite(Number(value))) return _fmtBytes(Number(value));
if (['installed', 'backed_up', 'verified'].includes(column)) return Number(value) ? 'Yes' : 'No';
if (/(?:_at|last_scanned)$/.test(column)) {
const numeric = Number(value);
const timestamp = Number.isFinite(numeric) && numeric > 0 && numeric < 1e12 ? numeric * 1000 : value;
const date = new Date(timestamp);
if (!Number.isNaN(date.getTime())) return date.toLocaleString(window.i18n?.current || 'en');
}
return String(value);
}
function updateDbPager(name) {
document.querySelectorAll(`.db-page-btn[data-table="${name}"]`).forEach(button => {
button.disabled = button.dataset.direction === 'previous'
? _dbPages[name].offset === 0
: _dbPages[name].offset + DB_PAGE_SIZE >= _dbPages[name].total;
});
}
document.querySelectorAll('.db-page-btn').forEach(button => button.addEventListener('click', async () => {
const name = button.dataset.table;
const direction = button.dataset.direction;
if (!_dbPages[name]) return;
_dbPages[name].offset = direction === 'previous'
? Math.max(0, _dbPages[name].offset - DB_PAGE_SIZE)
: Math.min(Math.max(0, _dbPages[name].total - 1), _dbPages[name].offset + DB_PAGE_SIZE);
if (name === 'hashes') await loadHashRowsTable();
else await loadDbTable(name);
}));
function renderDbTableEl(tableId, rows, tableName) {
const table = $(tableId);
if (!table) return;
// Hide toolbar when table reloads
const toolbar = $(`toolbar-${tableName}`);
if (toolbar) toolbar.hidden = true;
if (!rows.length) {
table.innerHTML = '';
return;
}
const cols = Object.keys(rows[0]);
const hasId = cols.includes('id');
table.innerHTML = `
<thead><tr>${hasId ? '<th class="db-row-cb"><input type="checkbox" class="db-select-all" data-table="' + tableName + '"></th>' : ''}${cols.map(c => `<th>${escHtml(dbColumnLabel(c))}</th>`).join('')}</tr></thead>
<tbody>
${rows.map(r => {
const rowId = hasId ? r.id : '';
return `<tr data-row-id="${rowId}">${hasId ? `<td class="db-row-cb"><input type="checkbox" class="db-row-check" data-table="${tableName}" value="${rowId}"></td>` : ''}${cols.map(c => `<td>${escHtml(dbFormatValue(c, r[c]))}</td>`).join('')}</tr>`;
}).join('')}
</tbody>`;
if (!hasId) return;
// Wire up "select all" header checkbox
const selectAllCb = table.querySelector('.db-select-all');
if (selectAllCb) {
selectAllCb.addEventListener('change', () => {
table.querySelectorAll('.db-row-check').forEach(cb => {
cb.checked = selectAllCb.checked;
cb.closest('tr').classList.toggle('db-row-selected', selectAllCb.checked);
});
_updateRowToolbar(tableName);
});
}
// Wire up individual row checkboxes
table.querySelectorAll('.db-row-check').forEach(cb => {
cb.addEventListener('change', () => {
cb.closest('tr').classList.toggle('db-row-selected', cb.checked);
_updateRowToolbar(tableName);
});
});
}
function _getCheckedIds(tableName) {
const tableEl = tableName === 'games' ? $('db-games-table')
: tableName === 'backups' ? $('db-backups-table')
: $('db-hash-rows-table');
if (!tableEl) return [];
return [...tableEl.querySelectorAll('.db-row-check:checked')].map(cb => Number(cb.value));
}
function _updateRowToolbar(tableName) {
const ids = _getCheckedIds(tableName);
const toolbar = $(`toolbar-${tableName}`);
if (!toolbar) return;
toolbar.hidden = ids.length === 0;
const countEl = $(`toolbar-${tableName}-count`);
if (countEl) countEl.textContent = `${ids.length} selected`;
}
// DB Tabs
document.querySelectorAll('.db-tab').forEach(tab =>
tab.addEventListener('click', () => {
document.querySelectorAll('.db-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.db-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
$(`dbtab-${tab.dataset.dbtab}`).classList.add('active');
if (tab.dataset.dbtab === 'hash-rows') loadHashRowsTable();
if (tab.dataset.dbtab === 'hashes') loadHashDbContent();
})
);
// Query runner
$('btn-run-query').addEventListener('click', async () => {
const sql = $('query-input').value.trim();
if (!sql) return;
const out = $('query-results');
try {
const rows = await window.pork.dbQuery(sql);
if (!rows.length) { out.innerHTML = '<div class="empty-msg">Query returned no rows.</div>'; return; }
const cols = Object.keys(rows[0]);
out.innerHTML = `
<div class="table-wrap">
<table>
<thead><tr>${cols.map(c => `<th>${escHtml(c)}</th>`).join('')}</tr></thead>
<tbody>${rows.map(r => `<tr>${cols.map(c => `<td>${escHtml(r[c] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody>
</table>
</div>`;
} catch (e) {
out.innerHTML = `<div class="empty-msg" style="color:var(--red)">${escHtml(e.message)}</div>`;
}
});
// Exports
$('btn-export-sql').addEventListener('click', async () => {
const p = await window.pork.exportSql();
if (p) setStatus(`Exported to ${p}`, 'ok');
});
$('btn-export-games-csv').addEventListener('click', async () => {
const p = await window.pork.exportCsv('games');
if (p) setStatus(`Exported to ${p}`, 'ok');
});
$('btn-export-backups-csv').addEventListener('click', async () => {
const p = await window.pork.exportCsv('backups');
if (p) setStatus(`Exported to ${p}`, 'ok');
});
$('btn-export-hashes').addEventListener('click', async () => {
const p = await window.pork.hashExport();
if (p) setStatus(`Hash DB exported to ${p}`, 'ok');
});
// ── Hash Database tab ───────────────────────────────────────────────────────────────────────
async function loadHashDbContent() {
try {
const info = await window.pork.hashCommunityInfo();
const countEl = $('hdb-community-count');
const verEl = $('hdb-community-ver');
const srcEl = $('hdb-community-source');
const localEl = $('hdb-local-count');
if (!countEl) return; // tab panel not in DOM
countEl.textContent = `${info.count} hash${info.count !== 1 ? 'es' : ''}`;
countEl.className = info.count > 0 ? 'hash-badge hash-community' : 'hash-badge hash-none';
verEl.textContent = info.updated ? `v${info.version || 1} · ${info.updated}` : '';
srcEl.textContent = info.source === 'imported' ? '(user-imported)' : '(bundled)';
localEl.textContent = `${info.localCount} hash${info.localCount !== 1 ? 'es' : ''}`;
localEl.className = info.localCount > 0 ? 'hash-badge hash-local' : 'hash-badge hash-none';
} catch (_) {}
try {
const summaries = await window.pork.hashGameSummary();
const list = $('hdb-game-list');
if (!list) return;
if (!summaries.length) {
list.innerHTML = '<div class="hint" style="padding:16px;color:var(--text-dim)">No hashes recorded yet. Open any game, go to Hash Verification, and click \'Hash All Files\'. Or use the Hash All Games / Backporks buttons on those pages.</div>';
return;
}
summaries.sort((a, b) => {
if (a.community_mismatch !== b.community_mismatch) return a.community_mismatch ? -1 : 1;
if (a.community_matches !== b.community_matches) return b.community_matches - a.community_matches;
const typeOrder = { game: 0, backpork: 1 };
const tDiff = (typeOrder[a.hash_type] ?? 0) - (typeOrder[b.hash_type] ?? 0);
if (tDiff !== 0) return tDiff;
return a.game_id.localeCompare(b.game_id);
});
list.innerHTML = `<div class="table-wrap" style="margin-top:14px">
<table style="width:100%">
<thead><tr><th>Game ID</th><th>Type</th><th>Firmware</th><th>Files Hashed</th><th>Community Matches</th><th>Status</th></tr></thead>
<tbody>${summaries.map(s => {
const badge = s.community_mismatch
? `<span class="hash-badge hash-mismatch">⚠ Hash Mismatch</span>`
: s.community_matches > 0
? `<span class="hash-badge hash-community">✓ Verified (${s.community_matches}/${s.hash_count})</span>`
: `<span class="hash-badge hash-local">⧭ Hashed (${s.hash_count})</span>`;
const typeLabel = s.hash_type === 'backpork'
? `<span class="pill" style="background:var(--surface-2);color:var(--text-dim);font-size:10px">Backpork</span>`
: `<span class="pill" style="background:var(--surface-2);color:var(--text-dim);font-size:10px">Game</span>`;
const fwLabel = s.firmware_label ? escHtml(s.firmware_label) : '—';
return `<tr><td><code>${escHtml(s.game_id)}</code></td><td>${typeLabel}</td><td>${fwLabel}</td><td>${s.hash_count}</td><td>${s.community_matches || '—'}</td><td>${badge}</td></tr>`;
}).join('')}</tbody>
</table></div>`;
} catch (_) {}
}
const _fmtBytes = b => b < 1024 ? `${b} B` : b < 1048576 ? `${(b/1024).toFixed(1)} KB` : b < 1073741824 ? `${(b/1048576).toFixed(1)} MB` : `${(b/1073741824).toFixed(2)} GB`;
let _hashBulkRunning = false;
function _setBulkProgress({ show = true, pct = 0, status = '', fileStatus = '' } = {}) {
const wrap = $('bulk-hash-progress');
const bar = $('bulk-hash-bar');
const pctEl = $('bulk-hash-pct');
const statEl = $('hash-all-games-status');
const fileEl = $('bulk-hash-file-status');
if (!wrap) return;
wrap.style.display = show ? 'flex' : 'none';
if (bar) bar.style.width = `${pct}%`;
if (pctEl) pctEl.textContent = `${pct}%`;
if (statEl) statEl.textContent = status;
if (fileEl) fileEl.textContent = fileStatus;
}
async function _runBulkHash(source) {
if (_hashBulkRunning) { showToast('A hash operation is already running.', 'info'); return; }
_hashBulkRunning = true;
const isGames = source === 'local';
const btnEl = $(isGames ? 'btn-hash-all-games' : 'btn-hash-all-backporks');
const backporksStat = $('hash-all-backporks-status');
btnEl.disabled = true;
if (isGames) {
_setBulkProgress({ show: true, pct: 0, status: 'Preparing…', fileStatus: '' });
} else if (backporksStat) {
backporksStat.textContent = 'Preparing…';
}
try {
const { results, total, totalFilesHashed, totalBytesHashed } = await window.pork.hashBulk(source);
const ok = results.filter(r => r.ok).length;
const fail = results.filter(r => !r.ok).length;
const doneMsg = `Done ✓ — ${ok} game${ok !== 1 ? 's' : ''}, ${totalFilesHashed} file${totalFilesHashed !== 1 ? 's' : ''} (${_fmtBytes(totalBytesHashed || 0)})${fail ? `, ${fail} skipped` : ''}`;
if (isGames) {
_setBulkProgress({ show: true, pct: 100, status: doneMsg, fileStatus: '' });
// Fade the progress block away after a few seconds
setTimeout(() => _setBulkProgress({ show: false }), 6000);
} else if (backporksStat) {
backporksStat.textContent = doneMsg;
}
// Refresh hash summaries and re-render badges everywhere
const summaries = await window.pork.hashGameSummary();
_populateHashMaps(summaries);
renderGames();
showToast(`Hash complete — ${totalFilesHashed} files (${_fmtBytes(totalBytesHashed || 0)})`, 'ok');
} catch (e) {
const errMsg = 'Error: ' + e.message;
if (isGames) {
_setBulkProgress({ show: true, pct: 0, status: errMsg, fileStatus: '' });
} else if (backporksStat) {
backporksStat.textContent = errMsg;
}
showToast('Hash failed: ' + e.message, 'error');
} finally {
_hashBulkRunning = false;
btnEl.disabled = false;
}
}
window.pork.on('hash:bulk:progress', ({ phase, done, total, percent, gameId, currentFile, fileSize, filePct, totalFilesHashed, totalBytesHashed }) => {
const backporksStat = $('hash-all-backporks-status');
const isGamesPage = !!document.querySelector('#page-games.active');
const overallStatus = `Game ${done}/${total} (${percent}%) — ${gameId || ''}`
+ (totalFilesHashed ? ` | ${totalFilesHashed} file${totalFilesHashed !== 1 ? 's' : ''} hashed` : '')
+ (totalBytesHashed ? ` | ${_fmtBytes(totalBytesHashed)}` : '');
let fileStatus = '';
if (currentFile) {
const sizeSuffix = fileSize ? ` (${_fmtBytes(fileSize)})` : '';
const pctSuffix = (phase === 'file-progress' && filePct != null) ? ` — ${filePct}%` : '';
fileStatus = `⧭ ${currentFile}${sizeSuffix}${pctSuffix}`;
} else if (phase === 'backup-done') {
fileStatus = `✓ ${gameId} complete`;
}
// done = completed backups so far (0 during first game's files).
// Interpolate within the current slot: done/total gets us to the start of this slot,
// then add filePct spread across one slot width.
const barPct = (phase === 'file-progress' && filePct != null)
? Math.min(100, Math.round(done / (total || 1) * 100 + filePct / (total || 1)))
: percent;
_setBulkProgress({ show: true, pct: barPct, status: overallStatus, fileStatus });
// Mirror to backporks status if the games progress block isn't visible
if (backporksStat && !isGamesPage) {
backporksStat.textContent = overallStatus;
}
});
$('btn-hash-all-games').addEventListener('click', async () => {
const ok = await showConfirm('Hash All Game Backups?\n\nThis will SHA-256 every file in every local game backup. On large libraries this can take hours.\n\nThe app stays usable while hashing. Continue?');
if (ok) _runBulkHash('local');
});
$('btn-hash-all-backporks').addEventListener('click', async () => {
const ok = await showConfirm('Hash All Backpork Backups?\n\nThis will SHA-256 every file in every backpork backup. On large libraries this can take hours.\n\nThe app stays usable while hashing. Continue?');
if (ok) _runBulkHash('backpork');
});
$('btn-hdb-export').addEventListener('click', async () => {
const p = await window.pork.hashExport();
if (p) showToast(`Hash DB exported to ${p}`, 'ok');
});
$('btn-import-community-hashes').addEventListener('click', async () => {
const btn = $('btn-import-community-hashes');
btn.disabled = true;
try {
const result = await window.pork.hashImportCommunity();
if (!result) { btn.disabled = false; return; }
showToast(`Community hash list imported — ${result.count} hashes loaded`, 'ok');
await loadHashDbContent();
// Refresh game summaries so badges update immediately
const summaries = await window.pork.hashGameSummary();
_populateHashMaps(summaries);
renderGames();
} catch (e) {
showToast('Import failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
});
// ── Selective Clear dialog ──────────────────────────────────────────────────────
$('btn-clear-db').addEventListener('click', () => {
// Reset all checkboxes
document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => { cb.checked = false; });
$('clear-select-ok').disabled = true;
openAccessibleDialog($('clear-select-overlay'), document.querySelector('#clear-select-options input'));
});
// Toggle "Clear Selected" button enable/disable based on any checked
document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => {
const anyChecked = [...document.querySelectorAll('#clear-select-options input[type="checkbox"]')].some(c => c.checked);
$('clear-select-ok').disabled = !anyChecked;
});
});
// Select All button
$('clear-select-all').addEventListener('click', () => {
document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => { cb.checked = true; });
$('clear-select-ok').disabled = false;
});
// Cancel
$('clear-select-cancel').addEventListener('click', () => {
closeAccessibleDialog($('clear-select-overlay'));
});
// Clear Selected
$('clear-select-ok').addEventListener('click', async () => {
const checked = [...document.querySelectorAll('#clear-select-options input[type="checkbox"]')]
.filter(cb => cb.checked).map(cb => cb.value);
if (!checked.length) return;
const labels = checked.map(v => v.charAt(0).toUpperCase() + v.slice(1));
// Hide the selection overlay so the confirm dialog is visible on top
closeAccessibleDialog($('clear-select-overlay'));
const confirmation = await showExactConfirmation(`Permanently delete: ${labels.join(', ')}?\n\nThis cannot be undone.`, 'WIS SELECTIE');
if (!confirmation) {
// User cancelled — re-show the selection overlay with their choices preserved
openAccessibleDialog($('clear-select-overlay'), document.querySelector('#clear-select-options input:checked'));
return;
}
const opts = {};
checked.forEach(k => { opts[k] = true; });
await window.pork.dbClearSelective(opts, confirmation);
loadDatabase();
loadHashDbContent();
const summaries = await window.pork.hashGameSummary();
_populateHashMaps(summaries);
renderGames();
setStatus(`Cleared: ${labels.join(', ')}`, 'ok');
showToast(`Cleared: ${labels.join(', ')}`, 'ok');
});
// ── Row-level delete handlers ────────────────────────────────────────────────────
async function _deleteSelectedRows(tableName, dbTable) {
const ids = _getCheckedIds(tableName);
if (!ids.length) return;
const confirmation = await showExactConfirmation(`Delete ${ids.length} ${tableName} row${ids.length !== 1 ? 's' : ''}?\n\nThis cannot be undone.`, 'VERWIJDER');
if (!confirmation) return;
const { deleted } = await window.pork.dbDeleteRows(dbTable, ids, confirmation);
showToast(`Deleted ${deleted} ${tableName} row${deleted !== 1 ? 's' : ''}.`, 'ok');
// Reload the affected table
if (tableName === 'games') await loadDbTable('games');
if (tableName === 'backups') await loadDbTable('backups');
if (tableName === 'hashes') await loadHashRowsTable();
// Refresh game page data
const summaries = await window.pork.hashGameSummary();
_populateHashMaps(summaries);
renderGames();
}
function _selectAllRows(tableName) {
const tableEl = tableName === 'games' ? $('db-games-table')
: tableName === 'backups' ? $('db-backups-table')
: $('db-hash-rows-table');
tableEl.querySelectorAll('.db-row-check').forEach(cb => { cb.checked = true; cb.closest('tr').classList.add('db-row-selected'); });
const selectAll = tableEl.querySelector('.db-select-all');
if (selectAll) selectAll.checked = true;
_updateRowToolbar(tableName);
}
function _deselectAllRows(tableName) {
const tableEl = tableName === 'games' ? $('db-games-table')
: tableName === 'backups' ? $('db-backups-table')
: $('db-hash-rows-table');
tableEl.querySelectorAll('.db-row-check').forEach(cb => { cb.checked = false; cb.closest('tr').classList.remove('db-row-selected'); });
const selectAll = tableEl.querySelector('.db-select-all');
if (selectAll) selectAll.checked = false;
_updateRowToolbar(tableName);
}
// Games toolbar
$('btn-delete-games').addEventListener('click', () => _deleteSelectedRows('games', 'games'));
$('btn-select-all-games').addEventListener('click', () => _selectAllRows('games'));
$('btn-deselect-all-games').addEventListener('click', () => _deselectAllRows('games'));
// Backups toolbar
$('btn-delete-backups').addEventListener('click', () => _deleteSelectedRows('backups', 'backups'));
$('btn-select-all-backups').addEventListener('click', () => _selectAllRows('backups'));
$('btn-deselect-all-backups').addEventListener('click', () => _deselectAllRows('backups'));
// Hashes toolbar
$('btn-delete-hashes').addEventListener('click', () => _deleteSelectedRows('hashes', 'game_hashes'));
$('btn-select-all-hashes').addEventListener('click', () => _selectAllRows('hashes'));
$('btn-deselect-all-hashes').addEventListener('click', () => _deselectAllRows('hashes'));
async function _clearHashes() {
const confirmation = await showExactConfirmation('Delete all stored hashes from the database? This cannot be undone.', 'WIS HASHES');
if (!confirmation) return;
await window.pork.dbClearHashes(confirmation);
await loadHashRowsTable();
await loadHashDbContent();
const summaries = await window.pork.hashGameSummary();
_populateHashMaps(summaries);
renderGames();
showToast('All hashes cleared.', 'ok');
}
$('btn-clear-hashes').addEventListener('click', _clearHashes);
$('btn-hdb-clear-hashes').addEventListener('click', _clearHashes);
File diff suppressed because it is too large. Load diff
+213
View File
@@ -0,0 +1,213 @@
// ELF Arsenal native Chimera client. The upstream WebUI remains available in a
// separate browser tab, but is no longer embedded: its opaque iframe errors
// cannot be diagnosed and its storage assumptions conflict with sandboxing.
let _eaPath = '/';
let _eaConnected = false;
function eaFriendlyError(value) {
const message = String(value || '').trim();
if (/Configureer eerst een geldig PS5-adres/i.test(message)) return 'Configure a PS5 host in Settings first.';
if (/ELF Arsenal-payload.*gestart/i.test(message)) return 'Start the ELF Arsenal payload explicitly, then retry.';
return message || 'ELF Arsenal is unavailable on port 6969.';
}
function eaBytes(value) {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function eaJoin(base, name) {
const cleanName = String(name || '').replace(/^\/+|\/+$/g, '');
return base === '/' ? `/${cleanName}` : `${String(base).replace(/\/+$/, '')}/${cleanName}`;
}
function eaParent(value) {
const parts = String(value || '/').split('/').filter(Boolean);
parts.pop();
return parts.length ? `/${parts.join('/')}` : '/';
}
function eaSetStatus(message, type = '') {
const status = document.getElementById('ea-embed-status');
if (!status) return;
status.textContent = message;
status.className = `ea-native-status ${type}`;
}
function eaRenderList(packet) {
const target = document.getElementById('ea-native-list');
const pathLabel = document.getElementById('ea-native-path');
if (!target || !pathLabel) return;
pathLabel.innerHTML = `<button class="btn btn-xs ea-native-parent" ${_eaPath === '/' ? 'disabled' : ''} data-path="${escHtml(eaParent(_eaPath))}">← Up</button><code>${escHtml(_eaPath)}</code>`;
const entries = (packet.entries || []).slice().sort((left, right) =>
Number(Boolean(right.is_dir)) - Number(Boolean(left.is_dir)) || String(left.name).localeCompare(String(right.name)));
target.innerHTML = entries.length ? entries.map(entry => {
const remotePath = eaJoin(_eaPath, entry.name);
const isElf = !entry.is_dir && /\.elf$/i.test(entry.name);
return `<article class="ea-native-row" data-path="${escHtml(remotePath)}" data-name="${escHtml(entry.name)}" data-dir="${entry.is_dir ? 'true' : 'false'}">
<span class="ea-native-icon" aria-hidden="true">${entry.is_dir ? '📁' : isElf ? '◆' : '•'}</span>
<div><strong>${escHtml(entry.name)}</strong><span>${entry.is_dir ? 'Directory' : eaBytes(entry.size)}${entry.mode ? ` · ${escHtml(entry.mode)}` : ''}</span></div>
<div class="ecosystem-inline-actions">
${entry.is_dir ? '<button class="btn btn-sm ea-native-open">Open</button>' : `<button class="btn btn-sm ea-native-download">Download</button>${isElf ? '<button class="btn btn-sm btn-accent ea-native-launch">Launch ELF</button>' : ''}`}
</div>
</article>`;
}).join('') : '<div class="operations-empty"><strong>This directory is empty</strong><span>Choose another safe root or upload a file explicitly.</span></div>';
const lock = document.getElementById('ea-native-lock');
if (lock) {
lock.textContent = packet.safety_lock ? 'Safety lock enabled' : 'Safety lock not reported';
lock.className = `operations-status ${packet.safety_lock ? 'operations-status-compatible' : 'operations-status-unknown'}`;
}
}
async function eaLoadDirectory(path = _eaPath) {
_eaPath = String(path || '/');
const target = document.getElementById('ea-native-list');
if (target) target.innerHTML = '<p class="hint">Loading directory…</p>';
try {
const [packet, job] = await Promise.all([
window.pork.eaList(_eaPath),
window.pork.eaJobStatus().catch(() => ({ busy: false })),
]);
eaRenderList(packet);
const jobTarget = document.getElementById('ea-native-job');
if (jobTarget) jobTarget.textContent = job.busy
? `File operation active${job.progress != null ? ` · ${job.progress}%` : ''}`
: 'No active file operation';
} catch (error) {
if (target) target.innerHTML = `<div class="operations-empty"><strong>Directory unavailable</strong><span>${escHtml(error.message)}</span></div>`;
eaSetStatus(`File API error: ${error.message}`, 'error');
}
}
async function eaReload() {
eaSetStatus('Checking ELF Arsenal…');
try {
const result = await window.pork.eaEmbedUrl();
const open = document.getElementById('ea-embed-open');
_eaConnected = result.connected === true;
if (!_eaConnected) {
if (open) { open.disabled = true; delete open.dataset.url; }
eaSetStatus(eaFriendlyError(result.error), 'error');
const list = document.getElementById('ea-native-list');
if (list) list.innerHTML = '<div class="operations-empty"><strong>ELF Arsenal is offline</strong><span>Start it explicitly through Payload Manager, then retry.</span></div>';
return;
}
if (open) {
open.disabled = false;
open.dataset.url = result.directUrl || result.url;
}
eaSetStatus(result.version ? `Online · ${result.version}` : 'Online', 'ok');
document.getElementById('ea-native-version').textContent = result.version || 'Unknown';
await eaLoadDirectory(_eaPath);
} catch (error) {
_eaConnected = false;
eaSetStatus(`Connection error: ${eaFriendlyError(error.message)}`, 'error');
}
}
function buildElfArsenalEmbed() {
const root = document.getElementById('page-elf-arsenal');
if (!root) return;
root.innerHTML = `
<div class="page-header">
<div>
<h1>ELF Arsenal</h1>
<p class="page-subtitle">Native Chimera file browser and launcher for the ELF Arsenal service on your PS5.</p>
</div>
<div class="header-tools">
<span id="ea-embed-status" class="ea-native-status">Checking connection…</span>
<button id="ea-embed-start" class="btn btn-accent">Start via Payload Manager</button>
<button id="ea-embed-open" class="btn" disabled>Open original WebUI</button>
<button id="ea-embed-reload" class="btn">Refresh</button>
</div>
</div>
<div class="ecosystem-workflow-strip ea-workflow" aria-label="ELF Arsenal workflow">
<span><b>1</b><strong>Start service</strong><small>Payload Manager, explicit</small></span>
<span><b>2</b><strong>Browse</strong><small>Choose a reviewed ELF</small></span>
<span><b>3</b><strong>Inspect</strong><small>Path and operation</small></span>
<span><b>4</b><strong>Launch</strong><small>Exact confirmation</small></span>
</div>
<div class="ea-native-summary">
<div class="operations-metric"><strong id="ea-native-version">—</strong><span>Version</span></div>
<div class="operations-metric"><strong id="ea-native-job">No active job</strong><span>File operation</span></div>
<div class="operations-metric"><strong id="ea-native-lock" class="operations-status operations-status-unknown">Unknown</strong><span>Upstream safety lock</span></div>
</div>
<div class="card ea-native-card">
<div class="card-header ecosystem-card-header">
<div><h3>PS5 files</h3><p class="hint">Browse with Chimera's authenticated API. The original upstream WebUI opens separately and can no longer crash this page.</p></div>
<div class="ecosystem-inline-actions">
<button class="btn btn-sm ea-native-root" data-path="/">Root</button>
<button class="btn btn-sm ea-native-root" data-path="/data">Data</button>
<button class="btn btn-sm ea-native-root" data-path="/mnt">USB / mounts</button>
<button id="ea-native-upload" class="btn btn-sm btn-accent">Upload here</button>
</div>
</div>
<div id="ea-native-path" class="ea-native-path"></div>
<div id="ea-native-list" class="ea-native-list"><p class="hint">Waiting for ELF Arsenal…</p></div>
</div>
<p class="hint">FTP alone is not enough: ELF Arsenal must be running on port 6969. Launch and upload actions always require an explicit confirmation.</p>`;
document.getElementById('ea-embed-open').addEventListener('click', () => {
const url = document.getElementById('ea-embed-open')?.dataset.url;
if (url) window.pork.openShell(url);
});
document.getElementById('ea-embed-start').addEventListener('click', async (event) => {
const button = event.currentTarget;
button.disabled = true;
eaSetStatus('Starting ELF Arsenal explicitly through Payload Manager…', 'warning');
try {
await window.pork.eaStart();
showToast('ELF Arsenal started. Rechecking the service.', 'ok');
await eaReload();
} catch (error) {
eaSetStatus(`Start failed: ${error.message}`, 'error');
showToast(`ELF Arsenal start failed: ${error.message}`, 'error');
} finally {
button.disabled = false;
}
});
document.getElementById('ea-embed-reload').addEventListener('click', eaReload);
document.getElementById('ea-native-path').addEventListener('click', event => {
const button = event.target.closest('.ea-native-parent');
if (button) eaLoadDirectory(button.dataset.path);
});
root.querySelectorAll('.ea-native-root').forEach(button => button.addEventListener('click', () => eaLoadDirectory(button.dataset.path)));
document.getElementById('ea-native-list').addEventListener('click', async event => {
const row = event.target.closest('.ea-native-row');
if (!row) return;
const button = event.target.closest('button');
try {
if (button?.classList.contains('ea-native-open')) await eaLoadDirectory(row.dataset.path);
if (button?.classList.contains('ea-native-download')) await window.pork.eaDownload(row.dataset.path, row.dataset.name);
if (button?.classList.contains('ea-native-launch')) {
const confirmation = await showExactConfirmation(`Launch ${row.dataset.path} through ELF Arsenal?`, 'LAUNCH ELF');
if (!confirmation) return;
await window.pork.eaLaunch(row.dataset.path, [], false);
showToast(`${row.dataset.name} launch request sent.`, 'ok');
}
} catch (error) { showToast(error.message, 'error', 10000); }
});
document.getElementById('ea-native-upload').addEventListener('click', async () => {
if (!_eaConnected) return showToast('Start ELF Arsenal first.', 'error');
const confirmation = await showExactConfirmation(`Upload one selected file to ${_eaPath}?`, 'UPLOAD FILE');
if (!confirmation) return;
try {
const uploadRoot = _eaPath === '/' ? '/' : `${_eaPath.replace(/\/+$/, '')}/`;
const result = await window.pork.eaUpload(null, uploadRoot);
if (result) {
showToast(`Uploaded ${eaBytes(result.bytes)} to ${result.remotePath}.`, 'ok');
await eaLoadDirectory(_eaPath);
}
} catch (error) { showToast(error.message, 'error', 10000); }
});
eaReload();
}
async function loadElfArsenal() {
if (!document.getElementById('ea-native-list')) buildElfArsenalEmbed();
else await eaReload();
}
+63
View File
@@ -0,0 +1,63 @@
'use strict';
(function exposeEtahenLinkUx(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.ChimeraLinkUx = api;
})(typeof window === 'object' ? window : null, () => {
const STATES = new Set(['online', 'offline', 'unknown']);
const JOB_STATES = new Set(['none', 'staged', 'approval_required', 'recovery_required']);
const safeState = (value, allowed, fallback) => allowed.has(String(value || '')) ? String(value) : fallback;
const text = (value, fallback = '') => String(value || fallback).slice(0, 160);
function createModel(packet = {}) {
const runtime = packet.runtime || {};
const last = packet.lastInventory || null;
const etaHenState = safeState(runtime.state, STATES, 'unknown');
const agentState = safeState(packet.agent?.state, STATES, etaHenState === 'online' && last ? 'online' : 'unknown');
const chimeraState = last ? 'online' : 'unknown';
const jobState = safeState(packet.job?.state, JOB_STATES, 'none');
const stagedCount = Math.max(0, Number(packet.job?.stagedCount) || 0);
return Object.freeze({
etaHen: Object.freeze({ state: etaHenState, version: text(runtime.identity?.version, '—') }),
agent: Object.freeze({ state: agentState, paired: packet.agent?.paired === true }),
chimera: Object.freeze({ state: chimeraState, lastScanAt: last?.completedAt || null }),
transfer: Object.freeze({ state: jobState, stagedCount, installedCount: 0 }),
recoveryRequired: jobState === 'recovery_required',
approvalRequired: jobState === 'approval_required',
});
}
function exactConfirmation({ action, target, runtimeEpoch, localIntent, recoveryAvailable }) {
if (!/^[A-Z][A-Z0-9_]{2,31}$/.test(String(action || ''))) throw new Error('Invalid action.');
if (!/^[A-Za-z0-9_-]{8,96}$/.test(String(target || '')) || !/^[A-Za-z0-9_-]{8,96}$/.test(String(runtimeEpoch || ''))) throw new Error('Invalid confirmation identity.');
if (localIntent !== true) throw new Error('Local console intent is required.');
if (recoveryAvailable !== true) throw new Error('Verified recovery is required.');
return `CONFIRM ${action} ${target} ${runtimeEpoch}`;
}
function mount({ root, loadStatus, translate = (_key, fallback) => fallback } = {}) {
if (!root || typeof loadStatus !== 'function') return null;
const t = translate;
root.innerHTML = `<section class="card etahen-link-card" aria-labelledby="etahen-link-title">
<div class="card-header"><div><span class="etahen-link-kicker">${t('etahenLink.kicker', 'Console companion')}</span><h3 id="etahen-link-title">${t('etahenLink.title', 'Chimera Link for etaHEN')}</h3><p class="hint">${t('etahenLink.description', 'Separate health for etaHEN, the paired agent and Chimera evidence.')}</p></div><button id="etahen-link-refresh" class="btn btn-sm" type="button">${t('common.refresh', 'Refresh')}</button></div>
<div class="etahen-link-health" role="list" aria-label="${t('etahenLink.healthLabel', 'Chimera Link health')}"></div>
<div class="etahen-link-alert" role="status" aria-live="polite"></div>
</section>`;
const health = root.querySelector('.etahen-link-health'); const alert = root.querySelector('.etahen-link-alert'); const button = root.querySelector('#etahen-link-refresh');
const label = state => t(`etahenLink.state.${state}`, state === 'online' ? 'Online' : state === 'offline' ? 'Offline' : 'Unknown');
const render = packet => {
const model = createModel(packet);
health.innerHTML = [['etaHEN', model.etaHen], [t('etahenLink.agent', 'Link agent'), model.agent], ['Chimera', model.chimera]].map(([name, item]) => `<div class="etahen-link-health-item state-${item.state}" role="listitem"><span>${name}</span><strong>${label(item.state)}</strong></div>`).join('');
alert.className = `etahen-link-alert ${model.recoveryRequired ? 'is-critical' : ''}`;
alert.textContent = model.recoveryRequired ? t('etahenLink.recoveryRequired', 'Recovery required before any new action.') : model.approvalRequired ? t('etahenLink.approvalRequired', 'Approval must be confirmed locally on the console.') : model.transfer.stagedCount ? t('etahenLink.stagedOnly', 'Artifact staged — not installed.') : t('etahenLink.noPendingWork', 'No pending Link work.');
return model;
};
const refresh = async () => { button.disabled = true; try { return render(await loadStatus()); } catch (_) { return render({ runtime: { state: 'unknown' } }); } finally { button.disabled = false; } };
button.addEventListener('click', refresh); root.addEventListener('keydown', event => { if (event.key === 'Enter' && event.target === root) refresh(); });
root.tabIndex = 0; refresh();
return Object.freeze({ refresh, render });
}
return Object.freeze({ createModel, exactConfirmation, mount });
});
+115
View File
@@ -0,0 +1,115 @@
// ── FTP Browser ───────────────────────────────────────────────────────────────
let ftpBrowserResolve = null;
let ftpBrowserCwd = '/';
function openFtpBrowser(startPath = '/') {
return new Promise(resolve => {
ftpBrowserResolve = resolve;
openAccessibleDialog($('ftp-browser-overlay'), $('ftp-browser-close'));
ftpBrowserNavigate(startPath);
});
}
function closeFtpBrowser(result) {
closeAccessibleDialog($('ftp-browser-overlay'));
if (ftpBrowserResolve) { ftpBrowserResolve(result); ftpBrowserResolve = null; }
}
async function ftpBrowserNavigate(path) {
ftpBrowserCwd = path;
$('ftp-browser-cwd').textContent = path;
$('ftp-browser-up').disabled = (path === '/');
const body = $('ftp-browser-body');
body.innerHTML = '<div class="ftp-browser-loading">Loading…</div>';
try {
const entries = await window.pork.ftpListDir(path);
if (!entries.length) {
body.innerHTML = '<div class="ftp-browser-loading">Empty folder.</div>';
return;
}
body.innerHTML = entries.map(e => `
<div class="ftp-browser-entry ${e.isDir ? 'ftp-entry-dir' : 'ftp-entry-file'}"
data-name="${escHtml(e.name)}" data-isdir="${e.isDir}">
<span class="ftp-entry-icon">${e.isDir ? '📁' : '📄'}</span>
<span class="ftp-entry-name">${escHtml(e.name)}</span>
</div>
`).join('');
} catch (e) {
body.innerHTML = `<div class="ftp-browser-loading" style="color:var(--red)">Error: ${escHtml(e.message)}</div>`;
}
}
$('ftp-browser-body').addEventListener('click', e => {
const entry = e.target.closest('.ftp-entry-dir');
if (!entry) return;
const name = entry.dataset.name;
const next = (ftpBrowserCwd.endsWith('/') ? ftpBrowserCwd : ftpBrowserCwd + '/') + name;
ftpBrowserNavigate(next);
});
$('ftp-browser-up').addEventListener('click', () => {
const parts = ftpBrowserCwd.replace(/\/$/, '').split('/').filter(Boolean);
parts.pop();
ftpBrowserNavigate('/' + parts.join('/') || '/');
});
$('ftp-browser-select').addEventListener('click', () => closeFtpBrowser(ftpBrowserCwd));
$('ftp-browser-cancel').addEventListener('click', () => closeFtpBrowser(null));
$('ftp-browser-close').addEventListener('click', () => closeFtpBrowser(null));
$('ftp-browser-overlay').addEventListener('keydown', event => {
if (event.key === 'Escape') closeFtpBrowser(null);
});
$('btn-browse-ftp').addEventListener('click', async () => {
if (!state.connected) { setStatus('Connect to FTP first to browse remote paths', 'error'); return; }
const selected = await openFtpBrowser(ftpBrowserCwd || '/');
if (!selected) return;
// Auto-add immediately — no separate "+ Add" click required
if (remoteGamePaths.some(p => p.path === selected)) {
setStatus('That path is already in the list', 'ok');
return;
}
const label = $('new-remote-label').value.trim()
|| selected.replace(/\/$/, '').split('/').filter(Boolean).pop()
|| selected;
remoteGamePaths = [...remoteGamePaths, { label, path: selected }];
await saveRemoteGamePaths();
renderRemoteGamePaths();
$('new-remote-label').value = '';
$('new-remote-path').value = '';
});
// Add payload source
$('btn-add-payload-source').addEventListener('click', async () => {
const name = $('new-source-name').value.trim();
const url = $('new-source-url').value.trim();
if (!name || !url) { setStatus('Enter both a name and a source URL', 'error'); return; }
try {
await window.pork.payloadSourcesAdd(name, url);
$('new-source-name').value = '';
$('new-source-url').value = '';
await renderSettingsPayloadSources();
setStatus(`Source "${name}" added`, 'ok');
} catch (e) {
setStatus(`Failed to add source: ${e.message}`, 'error');
}
});
// Delegated clicks in settings payload source list
$('settings-payload-sources').addEventListener('click', async e => {
const row = e.target.closest('[data-source-id]');
if (!row) return;
const id = Number(row.dataset.sourceId);
if (e.target.closest('.btn-source-remove')) {
const confirmation = await showExactConfirmation('Remove this payload source? Downloaded files will not be deleted.', 'VERWIJDER');
if (!confirmation) return;
await window.pork.payloadSourcesRemove(id, confirmation);
await renderSettingsPayloadSources();
} else if (e.target.closest('.source-toggle')) {
await window.pork.payloadSourcesToggle(id);
await renderSettingsPayloadSources();
}
});
+23
View File
@@ -0,0 +1,23 @@
'use strict';
$('btn-rescan-game-sources').addEventListener('click', async () => {
const btn = $('btn-rescan-game-sources');
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = 'Scanning…';
setStatus('Scanning local game source folders…');
try {
const { total, added } = await window.pork.scanBackups();
await loadGames();
await loadDashboard();
const msg = added > 0
? `Found ${total} game(s) — ${added} new, fetching metadata…`
: `Scan complete: ${total} game(s) found, none new`;
setStatus(msg, 'ok');
} catch (e) {
setStatus(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = orig;
}
});
+481
View File
@@ -0,0 +1,481 @@
// ── Games page ────────────────────────────────────────────────────────────────
async function loadGames() {
try {
const library = await window.pork.listLibraryTitles({ limit: 1000 });
state.games = window.chimeraLibraryModel.items(library);
window.pork.shadowMountStatus?.().then(result => {
const byGame = new Map();
for (const entry of (result.model?.entries || [])) {
if (!entry.titleId) continue;
const current = byGame.get(entry.titleId) || [];
current.push(entry);
byGame.set(entry.titleId, current);
}
state.games = state.games.map(game => {
const matches = byGame.get(game.game_id) || [];
if (!matches.length) return game;
return { ...game, shadow_mount: {
state: matches.some(entry => entry.state === 'installed') ? 'installed' : matches[0].state,
sources: matches.length,
experimental: matches.some(entry => entry.format?.support === 'experimental'),
formats: [...new Set(matches.map(entry => entry.format?.id).filter(Boolean))],
} };
});
renderGames();
}).catch(() => {});
// Refresh hash summaries (fire-and-forget so badges update when ready)
window.pork.hashGameSummary().then(summaries => {
_populateHashMaps(summaries);
renderGames();
}).catch(() => {});
renderGames();
await refreshFirmwareFilters();
await refreshConvQueuePanel();
const conversionQueue = $('conv-queue-panel');
const gamesPage = $('page-games');
if (conversionQueue && gamesPage) gamesPage.append(conversionQueue);
await refreshGameCatalogHealth();
} catch (e) {
setStatus(e.message, 'error');
}
}
async function refreshGameCatalogHealth() {
if (!window.pork.gameCatalogHealth || !$('game-catalog-health')) return;
try {
const health = await window.pork.gameCatalogHealth();
$('catalog-health-complete').textContent = `${Number(health.complete || 0)} / ${Number(health.total || 0)}`;
$('catalog-health-missing').textContent = String(Number(health.missing_metadata || 0));
$('catalog-health-artwork').textContent = String(Number(health.missing_artwork || 0));
const scan = health.scan || {};
$('catalog-health-scan').textContent = scan.running ? 'Scanning…' : (scan.lastError ? 'Scan issue' : 'Automatic');
$('catalog-health-scan-detail').textContent = scan.lastError
? String(scan.lastError)
: (scan.lastCompleted
? window.tf('games.health.lastScan', 'last scan {date}', { date: new Date(scan.lastCompleted).toLocaleString(window.i18n?.current || 'en') })
: window.t('games.health.interval', 'source check every 15 min'));
} catch (_) {}
}
window.pork.on('local:scan:started', refreshGameCatalogHealth);
window.pork.on('local:scan:complete', () => refreshGameCatalogHealth());
window.pork.on('prospero:queue:done', () => refreshGameCatalogHealth());
function gamePlatform(game) {
const explicit = String(game?.platform || '').toLowerCase();
if (explicit === 'ps4' || explicit === 'ps5') return explicit;
const identity = [
game?.game_id,
game?.content_id,
game?.prospero_content_id,
].filter(Boolean).join(' ').toUpperCase();
if (/CUSA\d{5}/.test(identity)) return 'ps4';
if (/PPSA\d{5}/.test(identity)) return 'ps5';
return 'unknown';
}
function versionedArtworkUrl(url, fetchedAt) {
const source = String(url || '');
if (!source.startsWith('/pork-cache/')) return source;
const revision = Number(fetchedAt || 0);
return revision > 0 ? `${source}?v=${Math.trunc(revision)}` : source;
}
function disconnectedDriveLabels(game) {
const labels = [];
for (const copy of Array.isArray(game?.localCopies) ? game.localCopies : []) {
const device = copy.device || copy.storageDevice || copy.volume?.device || {};
const state = String(device.availabilityState || copy.volume?.availabilityState || copy.availabilityState || '').toLowerCase();
if (!['unavailable', 'disconnected', 'offline', 'missing'].includes(state)) continue;
labels.push(device.friendlyLabel || device.displayName || device.label || device.stableFingerprint || copy.volume?.stableVolumeKey || 'external drive');
}
return [...new Set(labels.map(String))];
}
function renderGamePlatformFilters() {
const counts = { all: state.games.length, ps4: 0, ps5: 0, unknown: 0 };
for (const game of state.games) counts[gamePlatform(game)] += 1;
for (const platform of Object.keys(counts)) {
const count = $(`games-platform-count-${platform}`);
if (count) count.textContent = String(counts[platform]);
}
document.querySelectorAll('[data-game-platform]').forEach(button => {
const platform = button.dataset.gamePlatform;
const active = platform === state.platformFilter;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
if (platform === 'unknown') button.hidden = counts.unknown === 0 && !active;
});
}
function getFilteredGames() {
return state.games.filter(g => {
const q = state.search.toLowerCase();
if (q && !(g.title || '').toLowerCase().includes(q) && !(g.game_id || '').toLowerCase().includes(q) &&
!(g.prospero_name || '').toLowerCase().includes(q)) return false;
if (state.platformFilter !== 'all' && gamePlatform(g) !== state.platformFilter) return false;
// Status and firmware are independent filter dimensions — compute status as a
// boolean so both can compose (previously the status branches returned early,
// silently disabling the firmware filter).
let statusOk = true;
if (state.filter === 'installed') statusOk = window.chimeraLibraryModel.isInstalled(g);
else if (state.filter === 'backed_up') statusOk = window.chimeraLibraryModel.hasLocalCopy(g);
else if (state.filter === 'not_backed') statusOk = window.chimeraLibraryModel.needsProtection(g);
if (!statusOk) return false;
if (state.firmwareFilter !== 'all') {
const labels = (g.firmware_labels || '').split(',').filter(Boolean);
if (!labels.includes(state.firmwareFilter)) return false;
}
return true;
}).sort((a, b) => {
const sortValue = game => state.sortCol === 'title'
? (game.prospero_name || game.title || game.game_id || '')
: (game[state.sortCol] ?? '');
let av = sortValue(a), bv = sortValue(b);
if (typeof av === 'string') av = av.toLowerCase();
if (typeof bv === 'string') bv = bv.toLowerCase();
if (av === bv) return 0;
return state.sortDir === 'asc' ? (av > bv ? 1 : -1) : (av < bv ? 1 : -1);
});
}
function renderGames() {
renderGamePlatformFilters();
const allGames = getFilteredGames();
const list = allGames.slice(0, state.gameRenderLimit);
const tbody = $('games-tbody');
const grid = $('games-grid');
const tableWrap = $('games-table-wrap');
const empty = $('games-empty');
const emptyDetail = $('games-empty-detail');
const emptyScan = $('btn-games-empty-scan');
const emptyReset = $('btn-games-empty-reset');
const loadMore = $('btn-games-load-more');
const resultCount = $('games-result-count');
const filtersActive = Boolean(
state.games.length && (
state.search ||
state.filter !== 'all' ||
state.platformFilter !== 'all' ||
state.firmwareFilter !== 'all'
)
);
tbody.innerHTML = '';
grid.innerHTML = '';
empty.hidden = allGames.length !== 0;
emptyDetail.textContent = filtersActive
? window.t('games.empty.filtered', 'No games match the selected platform and filters.')
: window.t('games.empty.detail', 'Connect the PS5 or scan the configured source folders.');
emptyScan.hidden = filtersActive;
emptyReset.hidden = !filtersActive;
grid.hidden = !allGames.length || state.gameView !== 'grid';
tableWrap.hidden = !allGames.length || state.gameView !== 'table';
loadMore.hidden = list.length >= allGames.length;
resultCount.textContent = allGames.length
? window.tf('games.resultCount', '{visible} of {total}', { visible: list.length, total: allGames.length })
: '';
document.querySelectorAll('[data-game-view]').forEach(button => {
const active = button.dataset.gameView === state.gameView;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
});
for (const g of list) {
const tr = document.createElement('tr');
tr.className = 'game-row';
tr.dataset.id = g.game_id;
// Compact status cell: readable tags instead of cryptic symbols
const patchesOnly = g.backup_count === 0 && g.backpork_count > 0;
const _hs = _gameHashSummary.get(g.game_id);
const hashTag = _hs
? _hs.community_mismatch
? `<span class="s-tag s-hash-fail" title="⚠ Community hash check FAILED — files may be modified or contain brick code">⚠ Hash Fail</span>`
: _hs.community_matches > 0
? `<span class="s-tag s-hash-ok" title="Community verified: ${_hs.community_matches}/${_hs.hash_count} files match known-good hashes">✓ Verified</span>`
: `<span class="s-tag s-hash-local" title="${_hs.hash_count} file(s) hashed — not yet in community list">⧭ Hashed</span>`
: '';
const fwLabels = (g.firmware_labels || '').split(',').filter(Boolean);
const patchedTag = fwLabels.length
? `<span class="s-tag s-patched" title="Backporked for: ${escHtml(fwLabels.join(', '))}">&#x1F527; ${escHtml(fwLabels.join(', '))}</span>`
: '';
const shadowTag = g.shadow_mount
? `<span class="s-tag ${g.shadow_mount.state === 'installed' ? 's-backed' : 's-warn'}" title="ShadowMount: ${escHtml(g.shadow_mount.sources)} source(s) · ${escHtml((g.shadow_mount.formats || []).join(', ') || 'unknown format')}${g.shadow_mount.experimental ? ' · experimental format' : ''}">&#9673; Mount ${escHtml(g.shadow_mount.state)}</span>`
: '';
const metadataTag = `<span class="s-tag ${g.metadata?.source === 'manual' ? 's-patched' : 's-num'}" title="Metadata: ${escHtml(g.metadata?.source || 'unknown')} · ${escHtml(window.t('games.metadataConfidence', 'confidence'))} ${escHtml(g.metadata?.confidence || 'unknown')}">Meta ${escHtml(g.metadata?.source || '?')}</span>`;
const variantTag = g.regional_variants?.length
? `<span class="s-tag s-num" title="${escHtml(g.regional_variants.map(item => `${item.titleId}${item.region ? ` (${item.region})` : ''}`).join(' · '))}">${g.regional_variants.length} ${escHtml(g.regional_variants.length === 1 ? window.t('games.region', 'region') : window.t('games.regions', 'regions'))}</span>`
: '';
const platform = gamePlatform(g);
const platformLabel = platform === 'unknown'
? window.t('games.platform.unknown', 'Unknown')
: platform.toUpperCase();
const platformTag = `<span class="s-tag s-platform-${platform}">${escHtml(platformLabel)}</span>`;
const requiredDrives = disconnectedDriveLabels(g);
const requiredDriveTag = requiredDrives.length
? `<span class="s-tag s-warn s-required-drive" title="${escHtml(window.t('games.requiredDrive.title', 'Reconnect the named drive to access this copy'))}">${escHtml(window.t('games.requiredDrive', 'Needs drive'))}: ${escHtml(requiredDrives.join(', '))}</span>`
: '';
const statusHtml = `<div class="game-status-col">
${platformTag}
${requiredDriveTag}
${window.chimeraLibraryModel.isInstalled(g) ? `<span class="s-tag s-installed" title="${escHtml(window.t('games.status.installedTitle', 'Currently installed on the PS5'))}">${escHtml(window.t('games.status.installed', 'Installed'))}</span>` : ''}
${g.backup_count > 0 ? `<span class="s-tag s-num" title="${g.backup_count} ${escHtml(window.t('games.status.localBackups', 'local backup(s)'))}">${g.backup_count} bkp</span>` : ''}
${window.chimeraLibraryModel.hasLocalCopy(g) ? `<span class="s-tag s-backed" title="${escHtml(window.t('games.status.backedUpTitle', 'Local copy available'))}">&#10003;</span>` : ''}
${patchesOnly ? `<span class="s-tag s-warn" title="${escHtml(window.t('games.status.patchesOnlyTitle', 'Only Backpork patches were found; no local game backup exists'))}">${escHtml(window.t('games.status.patchesOnly', 'Patches only'))}</span>` : ''}
${patchedTag}
${shadowTag}
${hashTag}
${metadataTag}
${variantTag}
</div>`;
const displayTitle = g.prospero_name || g.title || g.game_id;
const metadataLine = [
g.prospero_publisher,
g.prospero_region,
g.content_id || g.prospero_content_id,
].filter(Boolean).join(' · ');
const coverUrl = g.prospero_cover_url || '';
const renderedCoverUrl = versionedArtworkUrl(coverUrl, g.cover_fetched_at);
const iconFallbackAttribute = g.prospero_icon_url
? ` data-fallback-src="${escHtml(g.prospero_icon_url)}"`
: '';
const coverPlaceholder = `<div class="game-card-cover-fallback" hidden aria-hidden="true"><span>${escHtml(platformLabel)}</span><strong>${escHtml(displayTitle)}</strong><small>${escHtml(g.game_id)}</small></div>`;
const cover = renderedCoverUrl
? `<div class="game-card-cover-case has-native-cover">
<img src="${escHtml(renderedCoverUrl)}" class="game-card-cover" alt="${escHtml(displayTitle)}" loading="lazy" decoding="async" data-image-error="fallback"${iconFallbackAttribute}>
${coverPlaceholder}
</div>`
: g.prospero_icon_url
? `<img src="${escHtml(g.prospero_banner_url || g.prospero_icon_url)}" class="game-card-cover-backdrop" alt="" aria-hidden="true" loading="lazy" decoding="async">
<div class="game-card-cover-case">
<span class="game-card-cover-platform">${escHtml(platformLabel)}</span>
<img src="${escHtml(g.prospero_icon_url)}" class="game-card-cover" alt="${escHtml(displayTitle)}" loading="lazy" decoding="async" data-image-error="fallback">
${coverPlaceholder}
<div class="game-card-cover-caption"><strong>${escHtml(displayTitle)}</strong><small>${escHtml(g.game_id)}</small></div>
</div>`
: `<div class="game-card-cover-fallback is-visible" aria-label="${escHtml(window.t?.('games.coverMissing', 'Cover unavailable') || 'Cover unavailable')}"><span>${escHtml(platformLabel)}</span><strong>${escHtml(displayTitle)}</strong><small>${escHtml(g.game_id || '?')}</small></div>`;
const metadataSynced = Boolean(g.prospero_name && g.prospero_icon_url && g.prospero_cover_url);
const metadataState = metadataSynced
? window.t('games.metadata.synced', 'Metadata: synced')
: window.t('games.metadata.enriching', 'Metadata: enriching');
const card = document.createElement('article');
card.className = 'game-card';
card.dataset.id = g.game_id;
card.dataset.platform = platform;
card.style.setProperty('--card-hue', String(titleHue(g.game_id)));
card.tabIndex = 0;
card.innerHTML = `
<div class="game-card-art">${cover}
${metadataSynced ? '' : `<span class="game-card-sync pending" title="${escHtml(metadataState)}">${escHtml(metadataState)}</span>`}
<div class="game-card-actions">
${patchesOnly ? '' : `<button class="btn btn-sm btn-accent" data-action="install" data-id="${escHtml(g.game_id)}" data-i18n="games.install">Plan install</button>`}
<button class="btn btn-sm" data-action="metadata" data-id="${escHtml(g.game_id)}" data-i18n="games.metadata">Metadata</button>
</div>
</div>
<div class="game-card-body">
<div class="game-card-heading">
<strong title="${escHtml(displayTitle)}">${escHtml(displayTitle)}</strong>
</div>
<span class="game-card-meta">${escHtml([g.game_id, metadataLine].filter(Boolean).join(' · '))}</span>
${statusHtml}
</div>`;
const nativeCover = card.querySelector('.game-card-cover-case.has-native-cover .game-card-cover');
if (nativeCover) {
const applyNaturalCoverRatio = () => {
const width = Number(nativeCover.naturalWidth || 0);
const height = Number(nativeCover.naturalHeight || 0);
const ratio = height > 0 ? width / height : 0;
if (width >= 160 && height >= 240 && ratio >= 0.52 && ratio <= 0.86) {
card.style.setProperty('--game-cover-ratio', `${width} / ${height}`);
}
};
if (nativeCover.complete) applyNaturalCoverRatio();
else nativeCover.addEventListener('load', applyNaturalCoverRatio, { once: true });
}
grid.appendChild(card);
if (patchesOnly) tr.classList.add('game-row-patches-only');
tr.innerHTML = `
<td>
<div class="game-title-cell">
${renderedCoverUrl || g.prospero_icon_url
? `<img src="${escHtml(renderedCoverUrl || g.prospero_icon_url)}" class="game-row-icon${renderedCoverUrl ? ' is-portrait' : ''}" alt="${escHtml(window.t('games.coverFor', 'Cover for'))} ${escHtml(g.prospero_name || g.title || g.game_id)}" loading="lazy" decoding="async" data-image-error="fallback"${renderedCoverUrl && g.prospero_icon_url ? ` data-fallback-src="${escHtml(g.prospero_icon_url)}"` : ''}>`
: '<div class="game-row-icon game-row-icon-empty"></div>'}
${renderedCoverUrl || g.prospero_icon_url ? '<div class="game-row-icon game-row-icon-empty" hidden></div>' : ''}
<span class="game-title-copy">
<strong>${escHtml(g.prospero_name || g.title || g.game_id)}</strong>
<small>${escHtml([
g.prospero_publisher,
g.prospero_region,
g.content_id || g.prospero_content_id,
].filter(Boolean).join(' · ') || window.t('games.metadataQueued', 'Metadata is being enriched…'))}</small>
</span>
</div>
</td>
<td><code>${escHtml(g.game_id)}</code></td>
<td>${escHtml(g.version || g.prospero_version || '—')}</td>
<td>${g.backup_size ? fmt(g.backup_size) : (g.size ? fmt(g.size) : (g.prospero_size || '—'))}</td>
<td>${renderFwPills(g.firmware_labels)}</td>
<td>${statusHtml}</td>
<td class="game-actions-cell">
${patchesOnly
? `<span class="patches-only-hint" title="${escHtml(window.t('games.noBackupDetail', 'No game backup was found; only patch folders exist. Create or download a base-game backup first.'))}">&#9888; ${escHtml(window.t('games.noBackup', 'No game backup'))}</span>`
: `<button class="btn btn-sm btn-teal" data-action="install" data-id="${escHtml(g.game_id)}" title="${escHtml(window.t('games.installQueueTitle', 'Add to the PS5 installation queue'))}">
&#11014; ${escHtml(window.t('games.install', 'Plan install'))}
</button>
<button class="btn btn-sm" data-action="convert" data-id="${escHtml(g.game_id)}" title="${escHtml(window.t('games.convertTitle', 'Convert the game and optionally upload it to the PS5'))}">
&#9881; ${escHtml(window.t('games.convert', 'Convert'))}
</button>`
}
<button class="btn btn-sm" data-action="metadata" data-id="${escHtml(g.game_id)}" title="${escHtml(window.t('games.metadataEditTitle', 'Correct the title, artwork and description manually'))}">Metadata</button>
</td>`;
tbody.appendChild(tr);
}
// Click delegation: action buttons take priority; otherwise the card/row opens detail.
const handleLibraryClick = async e => {
const btn = e.target.closest('[data-action]');
if (btn) {
try {
if (btn.dataset.action === 'install') {
await openInstallModal(btn.dataset.id, 'pfs');
} else if (btn.dataset.action === 'convert') {
const s = await window.pork.getSettings();
const mode = (s.gameConversionMode === 'pfs' || !s.gameConversionMode)
? (window.pork.isWebMode ? 'ffpfsc' : 'ffpkg')
: s.gameConversionMode;
await openInstallModal(btn.dataset.id, mode);
} else if (btn.dataset.action === 'metadata') {
await editGameMetadata(btn.dataset.id);
}
} catch (error) {
setStatus(error.message, 'error');
showToast(error.message, 'error', 9000);
}
return;
}
const item = e.target.closest('[data-id].game-row, [data-id].game-card');
if (item) openModal(item.dataset.id);
};
tbody.onclick = handleLibraryClick;
grid.onclick = handleLibraryClick;
grid.onkeydown = e => {
if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('button')) {
e.preventDefault();
const card = e.target.closest('.game-card[data-id]');
if (card) openModal(card.dataset.id);
}
};
window.i18n?.apply(grid);
window.i18n?.apply(tbody);
}
async function editGameMetadata(gameId) {
const game = state.games.find(item => item.game_id === gameId);
if (!game) return;
const hasOverride = Boolean(game.metadata?.overriddenFields?.length);
if (hasOverride && await showExactConfirmation('Clear manual metadata and return to online and local metadata management?', 'CLEAR METADATA')) {
await window.pork.updateGameMetadata(gameId, {});
await loadGames();
setStatus(`Handmatige metadata voor ${gameId} gewist.`, 'ok');
return;
}
const ask = (label, value = '') => showPrompt(`${label} voor ${gameId}:`, String(value || ''));
const title = await ask('Volledige titel', game.prospero_name || game.title);
if (title == null) return;
const publisher = await ask('Uitgever', game.prospero_publisher);
if (publisher == null) return;
const region = await ask('Regio', game.prospero_region);
if (region == null) return;
const iconUrl = await ask(window.t?.('games.metadata.iconUrl', 'HTTPS image URL') || 'HTTPS image URL', game.prospero_icon_url);
if (iconUrl == null) return;
if (iconUrl && !/^(https:\/\/|\/api\/local-artwork\/)/i.test(iconUrl)) {
throw new Error(window.t?.('games.metadata.invalidImage', 'Use an HTTPS URL or a local Chimera image.') || 'Use an HTTPS URL or a local Chimera image.');
}
const coverUrl = await ask(window.t?.('games.metadata.coverUrl', 'HTTPS portrait cover URL') || 'HTTPS portrait cover URL', game.prospero_cover_url);
if (coverUrl == null) return;
if (coverUrl && !/^(https:\/\/|\/api\/local-artwork\/)/i.test(coverUrl)) {
throw new Error(window.t?.('games.metadata.invalidImage', 'Use an HTTPS URL or a local Chimera image.') || 'Use an HTTPS URL or a local Chimera image.');
}
const description = await ask(window.t?.('games.metadata.description', 'Description') || 'Description', game.prospero_description);
if (description == null) return;
await window.pork.updateGameMetadata(gameId, { title, publisher, region, iconUrl, coverUrl, description });
await loadGames();
setStatus(`Metadata voor ${gameId} handmatig vastgezet.`, 'success');
}
// Sorting
document.querySelectorAll('#games-table th[data-col]').forEach(th => {
th.addEventListener('click', () => {
if (state.sortCol === th.dataset.col) state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
else { state.sortCol = th.dataset.col; state.sortDir = 'asc'; }
localStorage.setItem('chimera.games.sortCol', state.sortCol);
localStorage.setItem('chimera.games.sortDir', state.sortDir);
renderGames();
});
});
// Filter buttons
document.querySelectorAll('.fbtn').forEach(btn =>
btn.addEventListener('click', () => {
document.querySelectorAll('.fbtn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.filter = btn.dataset.filter;
state.gameRenderLimit = 160;
localStorage.setItem('chimera.games.filter', state.filter);
renderGames();
})
);
document.querySelectorAll('[data-game-platform]').forEach(button => {
button.addEventListener('click', () => {
state.platformFilter = ['ps4', 'ps5', 'unknown'].includes(button.dataset.gamePlatform)
? button.dataset.gamePlatform
: 'all';
state.gameRenderLimit = 160;
localStorage.setItem('chimera.games.platform', state.platformFilter);
renderGames();
});
});
// Search
$('games-search').addEventListener('input', e => {
state.search = e.target.value;
state.gameRenderLimit = 160;
renderGames();
});
document.querySelectorAll('[data-game-view]').forEach(button => {
button.addEventListener('click', () => {
state.gameView = button.dataset.gameView === 'table' ? 'table' : 'grid';
localStorage.setItem('chimera.games.view', state.gameView);
renderGames();
});
});
$('btn-games-load-more')?.addEventListener('click', () => {
state.gameRenderLimit += 160;
renderGames();
});
$('btn-games-empty-scan')?.addEventListener('click', () => $('btn-rescan-game-sources')?.click());
$('btn-games-empty-reset')?.addEventListener('click', () => {
state.search = '';
state.filter = 'all';
state.platformFilter = 'all';
state.firmwareFilter = 'all';
state.gameRenderLimit = 160;
$('games-search').value = '';
localStorage.setItem('chimera.games.filter', 'all');
localStorage.setItem('chimera.games.platform', 'all');
document.querySelectorAll('.fwbtn').forEach(button => {
button.classList.toggle('active', button.dataset.fw === 'all');
});
renderGames();
});
document.querySelectorAll('.fbtn').forEach(button => {
button.classList.toggle('active', button.dataset.filter === state.filter);
});
window.addEventListener('chimera:language-changed', () => renderGames());
+164
View File
@@ -0,0 +1,164 @@
// ── Hash verification ──────────────────────────────────────────────────────────
let hashingInProgress = false;
window.pork.on('hash:progress', ({ percent }) => {
const fill = $('hash-progress-fill');
const label = $('hash-progress-label');
if (fill) fill.style.width = `${percent}%`;
if (label) label.textContent = `Hashing… ${percent}%`;
});
async function renderHashSection(game_id, backups) {
const section = $('modal-hash-section');
const badge = $('modal-hash-community-badge');
badge.hidden = true;
badge.className = 'hash-badge'; // reset class each render
// Include both local and backpork backups — all game files deserve verification.
if (!backups.length) { section.hidden = true; return; }
section.hidden = false;
const { localHashes, communityMatches, communityMismatch } = await window.pork.hashStatus(game_id);
const communitySet = new Set(communityMatches.map(h => h.file_hash));
// Badge above hash list: mismatch warning takes priority over verified check.
const allInCommunity = localHashes.length > 0 && communityMatches.length === localHashes.length;
if (communityMismatch && localHashes.length > 0) {
badge.hidden = false;
badge.className = 'hash-badge hash-mismatch';
badge.textContent = '⚠ Community Hash Mismatch — files may be modified or contain brick code';
} else if (allInCommunity) {
badge.hidden = false;
badge.className = 'hash-badge hash-community';
badge.textContent = 'Community Verified ✓';
}
// Each backup is a folder — show aggregate hash status for the whole folder.
$('modal-hash-list').innerHTML = backups.map(b => {
const folderName = b.backup_path.replace(/\\/g, '/').split('/').pop();
const hashedCount = localHashes.length;
const communityCount = communityMatches.length;
if (hashedCount === 0) {
return `
<div class="hash-entry">
<div class="hash-entry-info">
<span class="hash-filename">${escHtml(folderName)}</span>
<span class="hash-badge hash-none">Not hashed</span>
</div>
<div class="hash-entry-status">
<button class="btn btn-sm btn-teal hash-btn-add"
data-path="${escHtml(b.backup_path)}"
data-game="${escHtml(game_id)}"
data-source="${escHtml(b.source || 'local')}">Hash All Files</button>
</div>
</div>`;
}
const allCommunity = communityCount === hashedCount;
const statusBadge = allCommunity
? `<span class="hash-badge hash-community">${communityCount} file${communityCount !== 1 ? 's' : ''} community ✓</span>`
: communityMismatch
? `<span class="hash-badge hash-mismatch">⚠ Hash Mismatch — ${hashedCount} local hash${hashedCount !== 1 ? 'es' : ''} don't match community list</span>`
: `<span class="hash-badge hash-local">${hashedCount} file${hashedCount !== 1 ? 's' : ''} hashed</span>`
+ (communityCount > 0 ? ` <span class="hash-badge hash-community">${communityCount} community ✓</span>` : '');
return `
<div class="hash-entry">
<div class="hash-entry-info">
<span class="hash-filename">${escHtml(folderName)}</span>
${statusBadge}
</div>
<div class="hash-entry-status">
<button class="btn btn-sm hash-btn-reverify"
data-path="${escHtml(b.backup_path)}"
data-game="${escHtml(game_id)}"
data-source="${escHtml(b.source || 'local')}">Re-verify All</button>
</div>
</div>`;
}).join('');
}
async function runHash(backup_path, game_id, verified = 1, hash_type = 'game') {
if (hashingInProgress) return;
hashingInProgress = true;
// Disable all hash buttons while running
$('modal-hash-list').querySelectorAll('button').forEach(b => b.disabled = true);
const prog = $('modal-hash-progress');
const fill = $('hash-progress-fill');
fill.style.width = '0%';
prog.hidden = false;
try {
const { hashes } = await window.pork.hashCompute(backup_path, game_id);
let communityCount = 0;
for (const h of hashes) {
const { communityMatch } = await window.pork.hashAdd({
game_id, file_hash: h.file_hash, file_size: h.file_size, backup_path: h.backup_path, verified, hash_type,
});
if (communityMatch) communityCount++;
}
prog.hidden = true;
if (communityCount > 0) {
setStatus(`${hashes.length} file(s) hashed — ${communityCount} community match(es) ✓`, 'ok');
} else {
setStatus(`${hashes.length} file(s) hashed — will appear in the next community update`, 'ok');
}
// Re-render the hash section with fresh data
const backups = await window.pork.listBackups(game_id);
await renderHashSection(game_id, backups);
} catch (e) {
prog.hidden = true;
setStatus(`Hash failed: ${e.message}`, 'error');
// Re-enable buttons on error
$('modal-hash-list').querySelectorAll('button').forEach(b => b.disabled = false);
} finally {
hashingInProgress = false;
}
}
// Hash button delegation — attached once on the hash list container
$('modal-hash-list').addEventListener('click', async e => {
if (hashingInProgress) return;
// ── Hash & Add (not yet in DB) ─────────────────────────────────────────────
const addBtn = e.target.closest('.hash-btn-add');
if (addBtn) {
await runHash(addBtn.dataset.path, addBtn.dataset.game, 1, addBtn.dataset.source === 'backpork' ? 'backpork' : 'game');
return;
}
// ── Re-verify (already in DB) — show inline confirm first ─────────────────
const reverifyBtn = e.target.closest('.hash-btn-reverify');
if (reverifyBtn) {
const statusEl = reverifyBtn.closest('.hash-entry-status');
const path = reverifyBtn.dataset.path;
const game = reverifyBtn.dataset.game;
const source = reverifyBtn.dataset.source;
// Replace button with inline confirm
reverifyBtn.style.display = 'none';
const confirm = document.createElement('div');
confirm.className = 'hash-confirm';
confirm.innerHTML = `
<span class="hash-confirm-msg">Game works?</span>
<button class="btn btn-sm btn-teal hash-confirm-yes">Yes, verify</button>
<button class="btn btn-sm hash-confirm-no">Cancel</button>`;
statusEl.appendChild(confirm);
confirm.querySelector('.hash-confirm-yes').addEventListener('click', async () => {
confirm.remove();
await runHash(path, game, 1, source === 'backpork' ? 'backpork' : 'game');
});
confirm.querySelector('.hash-confirm-no').addEventListener('click', () => {
confirm.remove();
reverifyBtn.style.display = '';
});
return;
}
});
+199
View File
@@ -0,0 +1,199 @@
// ── Accent color picker ────────────────────────────────────────────────────────
$('s-accent-color').addEventListener('input', async e => {
const hex = e.target.value;
applyAccent(hex);
$('s-accent-hex').value = hex.toUpperCase();
await window.pork.setSettings({ accentColor: hex });
});
// Hex text field — let user type a hex value directly
$('s-accent-hex').addEventListener('input', e => {
const raw = e.target.value.trim();
const hex = raw.startsWith('#') ? raw : '#' + raw;
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
applyAccent(hex);
window.pork.setSettings({ accentColor: hex });
}
});
$('btn-reset-accent').addEventListener('click', async () => {
const def = '#CBBAFF';
applyAccent(def);
await window.pork.setSettings({ accentColor: def });
setStatus('Accent color reset to default', 'ok');
});
// Confirm dialog helper
function showConfirm(message) {
return new Promise(resolve => {
$('confirm-message').textContent = message;
openAccessibleDialog($('confirm-overlay'), $('confirm-ok'));
function cleanup(result) {
closeAccessibleDialog($('confirm-overlay'));
$('confirm-ok').removeEventListener('click', onOk);
$('confirm-cancel').removeEventListener('click', onCancel);
$('confirm-overlay').removeEventListener('keydown', onKey);
resolve(result);
}
const onOk = () => cleanup(true);
const onCancel = () => cleanup(false);
const onKey = event => { if (event.key === 'Escape') onCancel(); };
$('confirm-ok').addEventListener('click', onOk);
$('confirm-cancel').addEventListener('click', onCancel);
$('confirm-overlay').addEventListener('keydown', onKey);
});
}
// Prompt dialog helper
function showPrompt(message, defaultValue = '', options = {}) {
return new Promise(resolve => {
$('prompt-message').textContent = message;
$('prompt-input').value = defaultValue;
$('prompt-dialog').classList.toggle('prompt-dialog-danger', options.tone === 'danger');
$('prompt-ok').className = `btn ${options.tone === 'danger' ? 'btn-danger' : 'btn-accent'}`;
$('prompt-ok').textContent = options.confirmLabel || (typeof window.t === 'function' ? window.t('common.ok', 'OK') : 'OK');
$('prompt-cancel').textContent = typeof window.t === 'function' ? window.t('common.cancel', 'Cancel') : 'Cancel';
openAccessibleDialog($('prompt-overlay'), $('prompt-input'));
function cleanup(result) {
closeAccessibleDialog($('prompt-overlay'));
$('prompt-ok').removeEventListener('click', onOk);
$('prompt-cancel').removeEventListener('click', onCancel);
$('prompt-input').removeEventListener('keydown', onKey);
resolve(result);
}
function onOk() { cleanup($('prompt-input').value); }
function onCancel() { cleanup(null); }
function onKey(e) { if (e.key === 'Enter') onOk(); else if (e.key === 'Escape') onCancel(); }
$('prompt-ok').addEventListener('click', onOk);
$('prompt-cancel').addEventListener('click', onCancel);
$('prompt-input').addEventListener('keydown', onKey);
});
}
async function showExactConfirmation(message, phrase) {
const isDutch = window.i18n?.current === 'nl';
const englishPhrases = { VERWIJDER: 'DELETE', 'VERWIJDER VAN PS5': 'DELETE FROM PS5', UITSCHAKELEN: 'DISABLE', INTREKKEN: 'REVOKE', HERSTEL: 'RESTORE', 'PAS ETAHEN TOE': 'APPLY ETAHEN', 'WEK PS5': 'WAKE PS5' }; // i18n: server confirmation tokens
const visiblePhrase = isDutch ? phrase : (englishPhrases[phrase] || phrase);
const instruction = isDutch ? `Typ exact ${visiblePhrase} om door te gaan:` : `Type ${visiblePhrase} exactly to continue:`;
const entered = await showPrompt(`${message}\n\n${instruction}`, '', {
tone: 'danger',
confirmLabel: isDutch ? 'Bevestigen' : 'Confirm',
});
if (entered === visiblePhrase) return phrase;
if (entered !== null) showToast(isDutch ? 'Actie geannuleerd: de bevestiging kwam niet exact overeen.' : 'Action cancelled: the confirmation phrase did not match exactly.', 'info');
return null;
}
// Destination picker for upload and conversion
function showDestinationPicker(paths) {
return new Promise(resolve => {
const list = $('dest-picker-list');
list.innerHTML = paths.map((p, i) => `
<button class="dest-picker-option" data-idx="${i}">
<span class="dest-opt-label">${escHtml(p.label)}</span>
<span class="dest-opt-path">${escHtml(p.path)}</span>
</button>
`).join('');
openAccessibleDialog($('dest-picker-overlay'), list.querySelector('.dest-picker-option'));
let resolved = false;
function cleanup(result) {
if (resolved) return;
resolved = true;
closeAccessibleDialog($('dest-picker-overlay'));
// #dest-picker-cancel is a persistent element; remove our handler so it
// doesn't accumulate across opens when the user picks an option instead.
$('dest-picker-cancel').removeEventListener('click', onCancel);
$('dest-picker-overlay').removeEventListener('keydown', onKey);
resolve(result);
}
const onCancel = () => cleanup(null);
const onKey = event => { if (event.key === 'Escape') onCancel(); };
// Option buttons live in list.innerHTML, which is re-rendered each open, so
// their listeners die with the old DOM — no cleanup needed for those.
list.querySelectorAll('.dest-picker-option').forEach((btn, i) => {
btn.addEventListener('click', () => cleanup(paths[i]), { once: true });
});
$('dest-picker-cancel').addEventListener('click', onCancel);
$('dest-picker-overlay').addEventListener('keydown', onKey);
});
}
// Ensure a game has an ftp_path set (prompting if needed). Returns the path or null if cancelled.
async function ensureGameFtpPath(gameId) {
const g = state.currentGame?.game_id === gameId ? state.currentGame
: (await window.pork.listGames({})).find(x => x.game_id === gameId);
if (g?.ftp_path) return g.ftp_path;
// No stored path — pick one
let basePath = null;
if (remoteGamePaths.length === 0) {
// No locations configured — use default, but don't store (user hasn't set up locations)
return `/mnt/sandbox/pfsmnt/${gameId}-app0`;
} else if (remoteGamePaths.length === 1) {
basePath = remoteGamePaths[0].path;
} else {
const chosen = await showDestinationPicker(remoteGamePaths);
if (!chosen) return null;
basePath = chosen.path;
}
const ftp_path = basePath.replace(/\/?$/, '/') + gameId + '-app0';
await window.pork.updateGame({ game_id: gameId, fields: { ftp_path } });
if (state.currentGame?.game_id === gameId) {
state.currentGame = { ...state.currentGame, ftp_path };
renderManageInstallLocation(state.currentGame);
}
return ftp_path;
}
$('btn-refresh-all-meta').addEventListener('click', async () => {
const ok = await showConfirm(
'This will clear all cached Prospero metadata and re-fetch every game from scratch. ' +
'Background fetches will queue automatically. Continue?'
);
if (!ok) return;
$('btn-refresh-all-meta').disabled = true;
setStatus('Queueing metadata refresh for all games…');
try {
const { queued } = await window.pork.prosperoRefreshAll();
setStatus(`Refreshing metadata for ${queued} game(s) in background…`, 'ok');
} catch (e) {
setStatus(`Refresh failed: ${e.message}`, 'error');
} finally {
$('btn-refresh-all-meta').disabled = false;
}
});
// Firmware helpers
function renderFwPills(firmware_labels) {
if (!firmware_labels) return '<span style="color:var(--text-dim)">—</span>';
return firmware_labels.split(',').filter(Boolean)
.map(l => `<span class="fw-pill fw-pill-patched" title="Backporked for firmware ${escHtml(l)}">&#x1F527; ${escHtml(l)}</span>`).join('');
}
async function refreshFirmwareFilters() {
const labels = await window.pork.firmwareLabels();
const row = $('firmware-filter-row');
const cont = $('firmware-filter-btns');
if (!labels.length) { row.hidden = true; return; }
row.hidden = false;
cont.innerHTML = `<button class="fwbtn${state.firmwareFilter === 'all' ? ' active' : ''}" data-fw="all">All</button>` +
labels.map(l => `<button class="fwbtn${state.firmwareFilter === l ? ' active' : ''}" data-fw="${escHtml(l)}">${escHtml(l)}</button>`).join('');
cont.onclick = e => {
const btn = e.target.closest('.fwbtn');
if (!btn) return;
state.firmwareFilter = btn.dataset.fw;
cont.querySelectorAll('.fwbtn').forEach(b => b.classList.toggle('active', b.dataset.fw === state.firmwareFilter));
renderGames();
};
}
+103
View File
@@ -0,0 +1,103 @@
// ── System View hotkeys ────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
// Escape always exits in-app fullscreen, regardless of active page
if (e.key === 'Escape' && $('sv-viewer-wrap')?.classList.contains('sv-viewer-wrap--fullscreen')) {
e.preventDefault();
svToggleFullscreen();
return;
}
if (!$('page-system-view')?.classList.contains('active')) return;
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
const k = e.key.toLowerCase();
if (k === _svHotkeys.mute) { e.preventDefault(); $('btn-sv-mute').click(); }
else if (k === _svHotkeys.gif) { e.preventDefault(); $('btn-sv-record').click(); }
else if (k === _svHotkeys.video) { e.preventDefault(); $('btn-sv-vid-rec').click(); }
else if (k === _svHotkeys.fullscreen) { e.preventDefault(); svToggleFullscreen(); }
else if (k === _svHotkeys.popout) { e.preventDefault(); $('btn-sv-popout').click(); }
});
// Auto-scan toast on launch
window.pork.on('local:scan:complete', async ({ total, added }) => {
if (added > 0) {
showToast(`Auto-scan: found ${added} new game${added !== 1 ? 's' : ''} (${total} total) — fetching metadata…`, 'ok');
} else if (total > 0) {
showToast(`Auto-scan: ${total} game${total !== 1 ? 's' : ''} already tracked`, 'info');
}
// Always refresh the active page so pruned/stale game rows clear from the UI
// even when 0 games were found this scan.
const activePage = document.querySelector('.page.active')?.id;
if (activePage === 'page-dashboard') loadDashboard();
if (activePage === 'page-games') loadGames();
if (activePage === 'page-backups') loadBackups();
});
// Toast when Prospero background queue finishes
window.pork.on('prospero:queue:done', ({ fetched, failed }) => {
if (fetched > 0 && failed === 0) {
showToast(`Metadata fetched for ${fetched} game${fetched !== 1 ? 's' : ''}`, 'ok');
} else if (fetched > 0) {
showToast(`Metadata fetched for ${fetched} game${fetched !== 1 ? 's' : ''} (${failed} failed)`, 'info');
} else if (failed > 0) {
showToast(`Metadata fetch failed for ${failed} game${failed !== 1 ? 's' : ''}`, 'error', 8000);
}
});
// Live-refresh modal when background fetch completes
window.pork.on('prospero:updated', async ({ game_id, data }) => {
// Reload games list in background so future opens have fresh data
state.games = await window.pork.listGames({});
renderGames(); // Redraw table so icon/name/metadata show without requiring a page nav
// Update cheat icon map and any visible cheat card for this game
if (data.iconUrl) {
_cheatIconMap.set(game_id, data.iconUrl);
document.querySelectorAll(`.cheat-card[data-cusa="${game_id}"]`).forEach(card => {
const empty = card.querySelector('.cheat-card-icon-empty');
if (!empty) return;
const img = document.createElement('img');
img.className = 'cheat-card-icon';
img.alt = '';
img.src = data.iconUrl;
img.onerror = () => { img.style.display = 'none'; };
empty.replaceWith(img);
});
}
// If the modal is open for this game, refresh it
if (state.currentGame?.game_id === game_id) {
const updated = state.games.find(g => g.game_id === game_id);
if (updated) {
state.currentGame = updated;
$('modal-title').textContent = updated.prospero_name || updated.title || game_id;
$('modal-subtitle').textContent = `${updated.game_id} · ${updated.prospero_region || (updated.version ? 'v' + updated.version : '')}`;
// Refresh info table with new metadata
const patches = tryParseJson(updated.prospero_patches, []);
const fwVersions = [...new Set(patches.map(p => p.requiredFirmware).filter(Boolean))];
const dlc = tryParseJson(updated.prospero_dlc, []);
const infoRows = [
['Game ID', updated.game_id],
['Content ID', updated.content_id || updated.prospero_content_id || '—'],
['Version', updated.version || updated.prospero_version || '—'],
['Size', updated.backup_size ? fmt(updated.backup_size) : (updated.size ? fmt(updated.size) : (updated.prospero_size || '—'))],
['Installed', updated.installed ? 'Yes' : 'No'],
['Backed Up', updated.backed_up ? 'Yes' : 'No'],
['Prepared for firmware', updated.porked_firmware ? `${updated.porked_firmware}${updated.porked_at ? ' (' + new Date(updated.porked_at).toLocaleDateString() + ')' : ''}` : '—'],
['Publisher', updated.prospero_publisher || '—'],
['Publisher ID', updated.prospero_publisher_id || '—'],
['Region', updated.prospero_region || '—'],
['Last Updated', updated.prospero_last_updated || '—'],
['Required Firmware', fwVersions.length ? fwVersions.join(', ') : '—'],
['DLC Available', dlc.length ? `${dlc.length} item(s)` : 'None'],
['FTP Path', updated.ftp_path || '—'],
['Last Scanned', updated.last_scanned ? new Date(updated.last_scanned).toLocaleString() : '—'],
['Metadata Age', updated.prospero_fetched_at ? fmtAge(Date.now() - updated.prospero_fetched_at) : 'Never fetched'],
];
$('modal-info').innerHTML = infoRows.map(([k, v]) => `<tr><th>${escHtml(k)}</th><td>${escHtml(v)}</td></tr>`).join('');
renderProsperoData(updated);
}
}
});
+84
View File
@@ -0,0 +1,84 @@
// ── Chimera i18n engine ───────────────────────────────────────────────────────
// Loaded first so window.t() is available to all modules.
// Call window.i18n.load(code) to fetch a lang file and re-apply all translations.
window.chimeraI18nReady = new Promise(resolve => {
window.chimeraResolveI18nReady = resolve;
});
window.i18n = {
current: 'en',
strings: {},
_observer: null,
/** Translate a key. Falls back to `fallback` if provided, then the key itself. */
t(key, fallback) {
const v = this.strings[key];
if (v !== undefined) return v;
return fallback !== undefined ? fallback : key;
},
/** Walk every [data-i18n] element and set its textContent to the translated string.
* If no translation exists for a key the original text is left unchanged. */
apply(root = document) {
document.documentElement.lang = this.current || 'en';
const select = selector => [
...(root.matches?.(selector) ? [root] : []),
...(root.querySelectorAll?.(selector) || []),
];
select('[data-i18n]').forEach(el => {
const val = this.strings[el.dataset.i18n];
if (val !== undefined) el.textContent = val;
});
select('[data-i18n-placeholder]').forEach(el => {
const val = this.strings[el.dataset.i18nPlaceholder];
if (val !== undefined) el.placeholder = val;
});
select('[data-i18n-title]').forEach(el => {
const val = this.strings[el.dataset.i18nTitle];
if (val !== undefined) el.title = val;
});
select('[data-i18n-aria-label]').forEach(el => {
const val = this.strings[el.dataset.i18nAriaLabel];
if (val !== undefined) el.setAttribute('aria-label', val);
});
if (!this._observer && document.body) {
this._observer = new MutationObserver(records => {
for (const record of records) {
for (const node of record.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) this.apply(node);
}
}
});
this._observer.observe(document.body, { childList: true, subtree: true });
}
},
/** Load a language file by code (e.g. "en", "fr", "de") via IPC, then apply. */
async load(code) {
if (!code) code = 'en';
try {
const data = await window.pork.langLoad(code);
if (data && typeof data.strings === 'object') {
this.strings = data.strings;
this.current = data.meta?.code || code;
}
} catch (e) {
console.warn('[i18n] Could not load language "' + code + '":', e.message || e);
// Fall back to empty (original HTML text remains visible)
this.strings = {};
this.current = 'en';
}
this.apply();
window.dispatchEvent(new CustomEvent('chimera:language-changed', {
detail: { code: this.current },
}));
},
};
/** Shorthand global translate function */
window.t = (key, fallback) => window.i18n.t(key, fallback);
window.tf = (key, fallback, values = {}) => Object.entries(values).reduce(
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
window.i18n.t(key, fallback),
);
+1
View File
@@ -0,0 +1 @@
"use strict";!function(t,e){const a=e();"object"==typeof module&&module.exports&&(module.exports=a),t?.document&&t.addEventListener("DOMContentLoaded",()=>a.mount(t))}("undefined"!=typeof window?window:null,function(){const t=["upload","inspect","recipe","destination","finalize"];function e(t){return Array.from(t||[]).map(t=>{return{file:t,relativePath:(e=t.webkitRelativePath||t.name,String(e||"").replace(/\\/g,"/").replace(/^\/+/,"").split("/").filter(t=>t&&"."!==t&&".."!==t).join("/")),size:Number(t.size)};var e}).filter(t=>t.relativePath&&Number.isSafeInteger(t.size)&&t.size>0)}function a(t,e,a){return e?(t+(a?-1:1)+e)%e:-1}function i(t){const e=Array.isArray(t?.files)?t.files:[],a=e.reduce((t,e)=>t+Number(e.size||0),0),i=e.reduce((t,e)=>t+Number(e.offset??e.bytesReceived??0),0);return{resumable:["created","uploading"].includes(t?.state),expected:a,received:i,percent:a?Math.min(100,Math.round(i/a*100)):0}}function r(t){return 0!==t.step||t.selection?.length||t.session?t.step>=2&&"classified"!==t.classification?.status?"importWizard.reviewRequired":t.step>=3&&!t.destinationId?"importWizard.error.destination":4!==t.step||t.confirmed?null:"importWizard.error.confirm":"importWizard.error.selection"}const o=t=>String(t??"").replace(/[&<>"']/g,t=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[t])),s=t=>{const e=Number(t);if(!Number.isFinite(e))return"unknown";const a=["B","KiB","MiB","GiB","TiB"];let i=0,r=e;for(;r>=1024&&i<a.length-1;)r/=1024,i+=1;return`${r.toFixed(i?1:0)} ${a[i]}`};async function n(t){const e=await crypto.subtle.digest("SHA-256",await t.arrayBuffer());return Array.from(new Uint8Array(e),t=>t.toString(16).padStart(2,"0")).join("")}return Object.freeze({deriveResumeState:i,mount:function(d){const l=d.pork;if(d.document.getElementById("chimera-import-open"))return;const c=(t,e)=>d.i18n?.t?.(t,e)||e,p={step:0,selection:[],session:null,job:null,classification:null,order:[],patchIds:new Set,artworkIds:new Set,installNotes:"",destinations:[],destinationId:"",preview:null,uploads:[],jobs:[],busy:!1,confirmed:!1,status:"",error:""};let m=null;const u=d.document.createElement("button");u.id="chimera-import-open",u.type="button",u.className="btn btn-accent chimera-import-launcher",u.style.cssText="position:fixed;right:22px;bottom:22px;z-index:1150",u.dataset.i18n="importWizard.open",u.textContent=c("importWizard.open","Managed import"),d.document.body.appendChild(u);const f=d.document.createElement("div");f.id="import-wizard-overlay",f.className="import-wizard-overlay",f.hidden=!0,f.innerHTML='<section class="import-wizard" role="dialog" aria-modal="true" aria-labelledby="import-wizard-title" aria-describedby="import-wizard-subtitle"><header class="import-wizard-head"><div><span class="import-wizard-kicker">QUARANTINE / VERIFY / COMMIT</span><h2 id="import-wizard-title" data-i18n="importWizard.title">Managed Import</h2><p id="import-wizard-subtitle" data-i18n="importWizard.subtitle">Upload, inspect and finalize without executing package content.</p></div><button type="button" class="import-wizard-close" data-wizard-close aria-label="Close import wizard" data-i18n-aria-label="importWizard.close">x</button></header><div class="import-wizard-steps" role="list"></div><main class="import-wizard-body"></main><footer class="import-wizard-footer"><div class="import-wizard-status" role="status" aria-live="polite"></div><div class="import-wizard-footer-actions"><button type="button" class="btn" data-wizard-previous data-i18n="importWizard.previous">Previous</button><button type="button" class="btn btn-accent" data-wizard-next data-i18n="importWizard.next">Next</button></div></footer><input id="import-wizard-files" type="file" multiple hidden><input id="import-wizard-folder" type="file" webkitdirectory multiple hidden></section>',d.document.body.appendChild(f);const b=f.querySelector(".import-wizard-body"),v=f.querySelector(".import-wizard-status"),h=()=>({installNotes:p.installNotes,componentOrder:p.order,patchComponentIds:[...p.patchIds],artworkComponentIds:[...p.artworkIds]}),w=()=>p.classification?.components?.reduce((t,e)=>t+Number(e.size||0),0)||p.selection.reduce((t,e)=>t+e.size,0);function z(){const t=p.session?i(p.session):{percent:0,received:0,expected:w()};b.innerHTML=`<div class="import-wizard-drop"><p data-i18n="importWizard.choose">Choose multiple files or a complete folder. Files stay quarantined until every validation succeeds.</p><button class="import-wizard-choice" type="button" data-choose-files data-i18n="importWizard.files">Files</button><button class="import-wizard-choice" type="button" data-choose-folder data-i18n="importWizard.folder">Folder</button></div>${p.selection.length?`<div class="import-wizard-progress" aria-label="${o(c("importWizard.progress","Upload progress"))}"><span style="width:${t.percent}%"></span></div><p>${p.selection.length} files - ${s(w())}</p><button class="btn btn-accent" type="button" data-start-upload data-i18n="importWizard.startUpload">Start re Line truncated
+114
View File
@@ -0,0 +1,114 @@
// ── External links — open in default browser via shell ────────────────────────
document.addEventListener('click', e => {
const link = e.target.closest('a.external-link[data-url]');
if (link) { e.preventDefault(); window.pork.openShell(link.dataset.url); }
});
document.addEventListener('click', e => {
const button = e.target.closest('[data-payload-target]');
if (!button) return;
const target = document.getElementById(button.dataset.payloadTarget);
if (!target) return;
target.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'start',
});
target.classList.add('payload-workspace-highlight');
window.setTimeout(() => target.classList.remove('payload-workspace-highlight'), 1200);
});
// Pin buttons
document.addEventListener('click', async e => {
const btn = e.target.closest('.btn-pin');
if (!btn) return;
e.stopPropagation(); // prevent tile nav from firing if btn is inside a tile
const pinId = btn.dataset.pinId;
if (!pinId) return;
const s = await window.pork.getSettings();
const pinned = new Set(s.pinnedCards || []);
if (pinned.has(pinId)) pinned.delete(pinId);
else pinned.add(pinId);
await window.pork.setSettings({ pinnedCards: [...pinned] });
// Update pin button visuals everywhere
document.querySelectorAll(`.btn-pin[data-pin-id="${pinId}"]`).forEach(b =>
b.classList.toggle('pinned', pinned.has(pinId))
);
// If on dashboard, refresh pinned grid immediately
if (document.querySelector('#page-dashboard.active')) renderPinnedTiles();
});
// Dashboard tiles
document.addEventListener('click', e => {
const tile = e.target.closest('.dash-pinned-tile[data-nav-page]');
if (tile) navigate(tile.dataset.navPage);
});
// Title bar
$('btn-min').addEventListener('click', () => window.pork.minimize());
$('btn-max').addEventListener('click', () => window.pork.maximize());
$('btn-cls').addEventListener('click', () => window.pork.close());
$('sidebar-donate').addEventListener('click', event => {
event.preventDefault();
if (typeof window.chimeraOpenAbout === 'function') window.chimeraOpenAbout();
else window.pork.openDonate();
});
$('titlebar-x-link').addEventListener('click', e => {
e.preventDefault();
showToast('Chimera · private PS4/PS5 operations hub', 'info');
});
// Conversion jobs
window.pork.on('conv:job-update', (job) => {
if (document.querySelector('#page-games.active')) {
patchConvJobCard(job);
}
});
window.pork.on('conv:queue:paused-change', () => {
if (document.querySelector('#page-games.active')) {
refreshConvQueuePanel().catch(() => {});
}
});
// Init
(async function init() {
// Load just the startup essentials. Heavy page-specific setup is deferred
// until the user actually opens those pages.
const s = await window.pork.getSettings().catch(() => ({}));
applyDensity(s.interfaceDensity);
try {
await window.i18n.load(s.language || 'en');
} finally {
window.chimeraResolveI18nReady?.();
delete window.chimeraResolveI18nReady;
}
await loadDashboard();
// Restore FTP connection state immediately so the UI isn't blank while devices enumerate.
let connected = false;
try {
({ connected } = await window.pork.ftpStatus());
setConnected(connected);
$('sb-sync').textContent = `Last connection check · ${new Date().toLocaleTimeString()}`;
} catch (_) {}
if (!connected && s.autoConnect && s.ftpHost) {
setStatus('Auto-connecting…');
try {
const autoPorts = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts : ['1337', '2121', '21'];
await window.pork.ftpConnect({
host: s.ftpHost,
ports: autoPorts,
user: s.ftpUser || '',
password: s.ftpPass || '',
});
setConnected(true);
setStatus('Connected', 'ok');
$('sb-sync').textContent = `Last connection check · ${new Date().toLocaleTimeString()}`;
} catch (_) {
setConnected(false);
setStatus('Auto-connect failed — enter credentials and connect manually', 'error');
}
}
scheduleBackgroundWarm(1200);
})();
+77
View File
@@ -0,0 +1,77 @@
(function installDestinationSelector() {
'use strict';
let options = [];
let requiredBytes = 0;
const esc = value => String(value == null ? '' : value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
const bytes = value => value == null ? 'unknown' : value >= 1024 ** 3 ? `${(value / 1024 ** 3).toFixed(1)} GB` : value >= 1024 ** 2 ? `${(value / 1024 ** 2).toFixed(1)} MB` : `${Math.max(0, value)} B`;
const age = value => value == null ? 'unknown age' : value < 60_000 ? 'just now' : value < 3_600_000 ? `${Math.ceil(value / 60_000)} min old` : `${Math.ceil(value / 3_600_000)} h old`;
const reason = code => ({
'offline-volume': 'offline',
'install-capability-disabled': 'install disabled by adapter',
'missing-mountpoint': 'mountpoint unavailable',
'capacity-unknown': 'capacity unknown',
'insufficient-capacity': 'not enough free space',
'stale-capacity-telemetry': 'capacity telemetry stale',
'stale-capability-telemetry': 'capability telemetry stale',
'compatibility-incompatible': 'blocked by applicable compatibility evidence',
}[code] || code);
function selected() {
const select = document.getElementById('install-destination-volume');
return options.find(item => item.volumeId === select?.value) || null;
}
function expertOpen() {
return document.getElementById('install-expert-destination')?.open === true;
}
function renderStatus() {
const status = document.getElementById('install-destination-status');
const option = selected();
if (!status) return;
if (option) {
status.className = `install-destination-status ${option.eligible ? 'is-eligible' : 'is-ineligible'}`;
status.innerHTML = `<strong>${esc(option.eligible ? 'Eligible destination' : 'Destination unavailable')}</strong><span>Required ${esc(bytes(requiredBytes))} · free ${esc(bytes(option.freeBytes))} · projected ${esc(bytes(option.projectedFreeBytes))} · evidence ${esc(age(option.capacityAgeMs))}${option.reasons.length ? ` · ${esc(option.reasons.map(reason).join(', '))}` : ''}</span>`;
const raw = document.getElementById('install-ftp-path');
if (option.compatibility) status.insertAdjacentHTML('beforeend', window.chimeraCompatibilityGuidance?.markup?.(option.compatibility) || '');
if (raw && option.suggestedRemotePath) raw.value = option.suggestedRemotePath;
} else {
status.className = 'install-destination-status is-ineligible';
status.innerHTML = '<strong>No eligible semantic destination</strong><span>Refresh console storage evidence or use the Expert raw-path override.</span>';
}
if (typeof updateInstallConfirmState === 'function') updateInstallConfirmState('pfs');
}
async function load({ titleId, required = 0, legacyPath = '' } = {}) {
requiredBytes = Math.max(0, Number(required) || 0);
const packet = await window.pork.installDestinations({ titleId, requiredBytes });
options = Array.isArray(packet?.destinations) ? packet.destinations : [];
const select = document.getElementById('install-destination-volume');
const raw = document.getElementById('install-ftp-path');
if (raw) raw.value = legacyPath || '';
if (!select) return packet;
select.innerHTML = '<option value="">Select a destination volume</option>' + options.map(option => {
const suffix = option.eligible ? `${bytes(option.freeBytes)} free` : option.reasons.map(reason).join(', ');
return `<option value="${esc(option.volumeId)}"${option.eligible ? '' : ' disabled'}>${esc(option.deviceName)} · ${esc(option.volumeKey)} · ${esc(suffix)}</option>`;
}).join('');
const preferred = options.find(option => option.eligible);
select.value = preferred?.volumeId || '';
renderStatus();
return packet;
}
function selection() {
const option = selected();
if (option?.eligible) return { destinationVolumeId: option.volumeId, remotePath: option.suggestedRemotePath, expertRawPath: false };
const raw = document.getElementById('install-ftp-path')?.value?.trim() || '';
return expertOpen() && raw ? { destinationVolumeId: null, remotePath: raw, expertRawPath: true } : null;
}
document.getElementById('install-destination-volume')?.addEventListener('change', renderStatus);
document.getElementById('install-expert-destination')?.addEventListener('toggle', renderStatus);
document.getElementById('install-ftp-path')?.addEventListener('input', renderStatus);
window.chimeraInstallDestination = Object.freeze({ load, selection, isReady: () => Boolean(selection()) });
})();
+16
View File
@@ -0,0 +1,16 @@
'use strict';
(function exposeLibraryModel(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.chimeraLibraryModel = api;
})(typeof globalThis !== 'undefined' ? globalThis : window, function createLibraryModel() {
function items(response) {
if (Array.isArray(response)) return response;
return Array.isArray(response?.items) ? response.items : [];
}
function isInstalled(title) { return Number(title?.availability?.confirmedInstallCount || 0) > 0; }
function hasLocalCopy(title) { return Number(title?.protection?.localCopyCount || title?.availability?.localCopyCount || 0) > 0; }
function needsProtection(title) { return isInstalled(title) && !hasLocalCopy(title); }
return Object.freeze({ items, isInstalled, hasLocalCopy, needsProtection });
});
+391
View File
@@ -0,0 +1,391 @@
// ── Manage panel ──────────────────────────────────────────────────────────────
let uploadInProgress = false;
let uploadLocalPath = null;
window.pork.on('ftp:upload:progress', info => {
const log = $('manage-upload-log');
if (!log) return;
// Directory upload shape has filesTotal; single-file shape has bytes/size
if (info.filesTotal != null) {
const pct = info.filesTotal > 0 ? Math.round((info.filesDone / info.filesTotal) * 100) : 0;
log.textContent = `Uploading… File ${info.filesDone}/${info.filesTotal} (${pct}%)${info.file ? ' ' + info.file : ''}`;
} else {
const done = info.bytes || 0;
const total = info.size || 0;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
log.textContent = `Uploading… ${pct}% (${fmt(done)} / ${fmt(total)})`;
}
});
async function renderManagePanel(g, backups) {
// ── Backup file list (local only — backpork entries shown in Backpork Versions) ─
const list = $('manage-backup-list');
backups = backups.filter(b => b.source !== 'backpork');
$('mtab-manage-count').textContent = backups.length || '';
if (!backups.length) {
list.innerHTML = '<div class="prospero-placeholder">No backup files recorded.</div>';
} else {
list.innerHTML = backups.map(b => {
const filename = b.backup_path.replace(/\\/g, '/').split('/').pop();
return `
<div class="manage-backup-item" data-backup-id="${b.id}" data-game="${escHtml(g.game_id)}"
data-local="${escHtml(b.backup_path)}" data-folder="${escHtml(b.backup_path)}">
<div class="manage-backup-info">
<span class="manage-filename" title="${escHtml(b.backup_path)}">${escHtml(filename)}</span>
<span class="manage-size">${fmt(b.size)}</span>
<span class="pill pill-num">${escHtml(b.backup_type || 'dump')}</span>
</div>
<div class="manage-backup-actions">
<button class="btn btn-sm manage-btn-folder">Open Folder</button>
<button class="btn btn-sm btn-teal manage-btn-upload">Upload to PS5</button>
<button class="btn btn-sm btn-danger manage-btn-remove">Remove Record</button>
</div>
</div>`;
}).join('');
}
// ── Upload section state ────────────────────────────────────────────────────
uploadLocalPath = null;
$('manage-remote-path').value = '';
$('manage-remote-path').readOnly = true;
$('btn-manage-upload').disabled = true;
$('manage-upload-log').hidden = true;
// ── Backed-up / Installed status buttons ─────────────────────────────────────
const toggleBackedBtn = $('btn-manage-toggle-backed');
if (g.backed_up) {
toggleBackedBtn.textContent = 'Clear Backed Up Status';
toggleBackedBtn.className = 'btn btn-danger';
} else {
toggleBackedBtn.textContent = 'Mark as Backed Up';
toggleBackedBtn.className = 'btn btn-teal';
}
const toggleInstBtn = $('btn-manage-toggle-installed');
if (g.installed) {
toggleInstBtn.textContent = 'Mark as Not Installed';
toggleInstBtn.className = 'btn btn-danger';
} else {
toggleInstBtn.textContent = 'Mark as Installed';
toggleInstBtn.className = 'btn';
}
// ── Install location display ──────────────────────────────────────────────────
renderManageInstallLocation(g);
// ── Backpork versions section ────────────────────────────────────────────────
await renderBackporkSection(g);
}
// ── Manage: install location display ─────────────────────────────────────────
function renderManageInstallLocation(g) {
const locRow = $('manage-install-location');
const noLocRow = $('manage-no-location');
const locPath = $('manage-loc-path');
const deleteBtn = $('btn-manage-delete-ps5');
const backupBtn = $('btn-manage-backup-ps5');
if (g?.ftp_path) {
locRow.hidden = false;
noLocRow.hidden = true;
locPath.textContent = g.ftp_path;
deleteBtn.disabled = !state.connected;
backupBtn.disabled = !state.connected;
} else {
locRow.hidden = true;
noLocRow.hidden = false;
locPath.textContent = '';
deleteBtn.disabled = true;
backupBtn.disabled = true;
}
}
$('btn-manage-forget-loc').addEventListener('click', async () => {
if (!state.currentGame) return;
const gameId = state.currentGame.game_id;
await window.pork.updateGame({ game_id: gameId, fields: { ftp_path: '' } });
state.currentGame = { ...state.currentGame, ftp_path: '' };
renderManageInstallLocation(state.currentGame);
setStatus('PS5 install location forgotten', 'ok');
});
$('btn-manage-backup-ps5').addEventListener('click', async () => {
if (!state.currentGame || !state.connected) return;
const { game_id, ftp_path } = state.currentGame;
if (!ftp_path) { setStatus('No PS5 location stored — run a scan first', 'error'); return; }
const btn = $('btn-manage-backup-ps5');
btn.disabled = true;
try {
await window.pork.ftpDownloadFolder(game_id);
setStatus('Backup queued — see Transfers for progress', 'ok');
} catch (e) {
setStatus(e.message, 'error');
} finally {
btn.disabled = !state.connected || !state.currentGame?.ftp_path;
}
});
$('btn-manage-delete-ps5').addEventListener('click', async () => {
if (!state.currentGame) return;
const g = state.currentGame;
if (!g.ftp_path) { setStatus('No PS5 location stored — cannot delete', 'error'); return; }
const confirmation = await showExactConfirmation(
`Delete "${g.title || g.game_id}" from PS5?\n\nFolder: ${g.ftp_path}\n\nThis cannot be undone.`,
'VERWIJDER VAN PS5'
);
if (!confirmation) return;
const btn = $('btn-manage-delete-ps5');
btn.disabled = true;
btn.textContent = 'Deleting…';
try {
await window.pork.ftpDeleteGame(g.game_id, confirmation);
state.currentGame = { ...g, availability: { ...(g.availability || {}), state: 'absent', confirmedInstallCount: 0 }, ftp_path: '' };
state.games = await window.pork.listGames({});
renderManageInstallLocation(state.currentGame);
const toggleInstBtn = $('btn-manage-toggle-installed');
toggleInstBtn.textContent = 'Mark as Installed';
toggleInstBtn.className = 'btn';
setStatus(`${g.title || g.game_id} deleted from PS5`, 'ok');
} catch (err) {
setStatus(`Delete failed: ${err.message}`, 'error');
} finally {
btn.textContent = 'Delete from PS5';
btn.disabled = !state.connected || !state.currentGame?.ftp_path;
}
});
// ── Backpork section in Manage tab ────────────────────────────────────────────
let porkInProgress = false;
window.pork.on('backporks:pork:progress', ({ file, filesDone, filesTotal, bytesOverall }) => {
const fill = $('pork-progress-fill');
const label = $('pork-progress-label');
const pct = filesTotal > 0 ? Math.round((filesDone / filesTotal) * 100) : 0;
if (fill) fill.style.width = `${pct}%`;
if (label) label.textContent = `Uploading ${filesDone + 1}/${filesTotal}: ${escHtml(file || '')}`;
});
async function renderBackporkSection(g) {
const section = $('manage-backpork-section');
const entries = await window.pork.backporksEntries(g.game_id);
if (!entries.length) { section.hidden = true; return; }
section.hidden = false;
// Porked-to status
const statusEl = $('manage-porked-status');
if (g.porked_firmware) {
const when = g.porked_at ? ` · ${new Date(g.porked_at).toLocaleDateString()}` : '';
statusEl.innerHTML = `
<div class="manage-porked-status">
✓ Porked to firmware <strong>${escHtml(g.porked_firmware)}</strong>${escHtml(when)}
<button class="clear-pork btn btn-sm" data-game="${escHtml(g.game_id)}">Clear</button>
</div>`;
} else {
statusEl.innerHTML = '';
}
// Entry list
const listEl = $('manage-backpork-list');
listEl.innerHTML = entries.map(e => {
const gamePath = e.game_path || e.folder_path;
const displayPath = gamePath.replace(/\\/g, '/').split('/').pop();
return `
<div class="manage-backpork-entry"
data-fw="${escHtml(e.folder_name)}"
data-path="${escHtml(e.folder_path)}"
data-game="${escHtml(g.game_id)}">
<span class="manage-backpork-fw">${escHtml(e.folder_name)}</span>
<span class="manage-backpork-path" title="${escHtml(gamePath)}">${escHtml(displayPath)}</span>
<button class="btn btn-sm manage-btn-view-backpork"
data-open="${escHtml(gamePath)}">View Backpork</button>
<button class="btn btn-sm btn-teal pork-btn"
${!state.connected ? 'disabled title="Connect to PS5 first"' : ''}>
Pork to PS5
</button>
</div>`;
}).join('');
// Reset progress bar
$('manage-pork-progress').hidden = true;
$('pork-progress-fill').style.width = '0%';
}
// Clear porked status
$('manage-porked-status').addEventListener('click', async e => {
const btn = e.target.closest('.clear-pork');
if (!btn) return;
const gameId = btn.dataset.game;
await window.pork.updateGame({ game_id: gameId, fields: { porked_firmware: null, porked_at: null } });
state.games = await window.pork.listGames({});
const updated = state.games.find(x => x.game_id === gameId) || state.currentGame;
state.currentGame = { ...state.currentGame, porked_firmware: null, porked_at: null };
await renderBackporkSection(state.currentGame);
setStatus('Porked status cleared', 'ok');
});
// Backpork list click delegation
$('manage-backpork-list').addEventListener('click', async e => {
// ── View Backpork folder ─────────────────────────────────────────────────────
const viewBtn = e.target.closest('.manage-btn-view-backpork');
if (viewBtn) {
window.pork.openShell(viewBtn.dataset.open);
return;
}
// Pork to PS5
const btn = e.target.closest('.pork-btn');
if (!btn || porkInProgress) return;
const entry = btn.closest('.manage-backpork-entry');
if (!entry) return;
const gameId = entry.dataset.game;
const folderName = entry.dataset.fw;
const folderPath = entry.dataset.path;
// Ensure a destination is remembered before porking
const ftpDest = await ensureGameFtpPath(gameId);
if (!ftpDest) return; // user cancelled picker
porkInProgress = true;
btn.disabled = true;
$('manage-pork-progress').hidden = false;
$('pork-progress-fill').style.width = '0%';
$('pork-progress-label').textContent = 'Starting…';
setStatus(`Porking ${gameId} (${folderName})…`);
try {
const { remotePath } = await window.pork.backporksPork(gameId, folderName, folderPath);
$('manage-pork-progress').hidden = true;
setStatus(`Porked to PS5 at ${remotePath}`, 'ok');
// Refresh game state
state.games = await window.pork.listGames({});
const updated = state.games.find(x => x.game_id === gameId);
if (updated) {
state.currentGame = updated;
await renderBackporkSection(updated);
// Update installed toggle
const toggleInstBtn = $('btn-manage-toggle-installed');
toggleInstBtn.textContent = 'Mark as Not Installed';
toggleInstBtn.className = 'btn btn-danger';
}
} catch (err) {
$('manage-pork-progress').hidden = true;
setStatus(`Pork failed: ${err.message}`, 'error');
} finally {
porkInProgress = false;
btn.disabled = false;
}
});
// Toggle installed status
$('btn-manage-toggle-installed').addEventListener('click', async () => {
setStatus('Install status is derived from complete console scan evidence.', 'info');
});
// ── Delegation for all manage-panel interactions (attached once, never re-bound)
$('manage-backup-list').addEventListener('click', async e => {
const item = e.target.closest('.manage-backup-item');
if (!item) return;
const backupId = Number(item.dataset.backupId);
const gameId = item.dataset.game;
const local = item.dataset.local;
const folder = item.dataset.folder;
// ── Open Folder ─────────────────────────────────────────────────────────────
if (e.target.closest('.manage-btn-folder')) {
window.pork.openShell(folder);
return;
}
// ── Upload to PS5 — inline expand ───────────────────────────────────────────
if (e.target.closest('.manage-btn-upload')) {
if (uploadInProgress) return;
await openInstallModal(gameId, 'pfs');
return;
}
// ── Remove backup record ─────────────────────────────────────────────────────
if (e.target.closest('.manage-btn-remove')) {
const btn = e.target.closest('.manage-btn-remove');
const confirmation = await showExactConfirmation('Remove this backup record from Chimera?', 'VERWIJDER');
if (!confirmation) return;
btn.disabled = true;
try {
await window.pork.deleteBackup(backupId, confirmation);
setStatus('Backup record removed', 'ok');
// Refresh the modal's backup-dependent sections
const backups = await window.pork.listBackups(gameId);
const localBackups = backups.filter(b => b.source !== 'backpork');
state.games = await window.pork.listGames({});
const g = state.games.find(x => x.game_id === gameId) || state.currentGame;
state.currentGame = g;
await renderManagePanel(g, backups);
await renderHashSection(gameId, backups);
$('modal-backup-list').innerHTML = localBackups.length
? localBackups.map(b => `<li><span>${escHtml(b.backup_path)}</span><span>${fmt(b.size)}</span></li>`).join('')
: '<li style="justify-content:center">No backups recorded</li>';
} catch (err) {
setStatus(err.message, 'error');
btn.disabled = false;
}
return;
}
});
$('btn-manage-upload').addEventListener('click', async () => {
if (uploadInProgress || !uploadLocalPath) return;
const remotePath = $('manage-remote-path').value.trim();
if (!remotePath) { setStatus('Enter a remote path on the PS5', 'error'); return; }
uploadInProgress = true;
$('btn-manage-upload').disabled = true;
const log = $('manage-upload-log');
log.hidden = false;
log.textContent = 'Starting upload…';
try {
await window.pork.ftpUpload({ localPath: uploadLocalPath, remotePath });
log.textContent = 'Upload complete ✓';
setStatus('Uploaded to PS5 successfully', 'ok');
uploadLocalPath = null;
$('manage-remote-path').value = '';
$('manage-remote-path').readOnly = true;
document.querySelectorAll('.manage-item-selected').forEach(el => el.classList.remove('manage-item-selected'));
} catch (e) {
log.textContent = `Upload failed: ${e.message}`;
setStatus(e.message, 'error');
$('btn-manage-upload').disabled = false;
} finally {
uploadInProgress = false;
}
});
$('btn-manage-toggle-backed').addEventListener('click', async () => {
setStatus('Protection status is derived from registered local-copy evidence.', 'info');
});
$('btn-manage-refresh-meta').addEventListener('click', async () => {
if (!state.currentGame) return;
const { game_id } = state.currentGame;
$('btn-manage-refresh-meta').disabled = true;
setStatus('Refreshing metadata…');
try {
await window.pork.prosperoFetch(game_id);
setStatus('Metadata refreshed', 'ok');
} catch (e) {
setStatus(`Refresh failed: ${e.message}`, 'error');
} finally {
$('btn-manage-refresh-meta').disabled = false;
}
});
+820
View File
@@ -0,0 +1,820 @@
// ── Transfer Manager controls ─────────────────────────────────────────────────
// ── Media page ────────────────────────────────────────────────────────────────
// Format a Unix ms timestamp into a short human-readable string.
// Respects _timeFormat ('12h' | '24h').
let _timeFormat = '12h'; // default; overwritten by loadSettings
function _fmtDate(ms) {
if (!ms) return '';
const d = new Date(ms);
const now = new Date();
let time;
if (_timeFormat === '24h') {
const pad = n => String(n).padStart(2, '0');
time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
} else {
const h = d.getHours();
const m = d.getMinutes();
const ampm = h >= 12 ? 'PM' : 'AM';
const h12 = h % 12 || 12;
time = `${h12}:${String(m).padStart(2, '0')} ${ampm}`;
}
const isSameDay = d.toDateString() === now.toDateString();
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
const isYesterday = d.toDateString() === yesterday.toDateString();
if (isSameDay) return `Today ${time}`;
if (isYesterday) return `Yesterday ${time}`;
const mo = d.toLocaleString('default', { month: 'short' });
return `${mo} ${d.getDate()} ${time}`;
}
let _mediaItems = []; // all items returned by last scan
let _mediaFilter = 'all';
let _mediaSort = 'date-desc';
let _mediaSearch = '';
let _mediaSearchTimer = null;
const _thumbCache = new Map(); // remotePath → dataUrl (in-memory, cleared on new scan)
const _thumbFetching = new Set(); // remotePaths currently being fetched
let _thumbQueued = 0; // total thumb loads enqueued in current render cycle
let _thumbDone = 0; // completed (success + failure)
let _mediaCacheStatus = { entries: [], metrics: {} };
function _mediaText(key, fallback) {
return window.t ? window.t(key, fallback) : fallback;
}
function _renderMediaCacheStatus() {
const metrics = _mediaCacheStatus.metrics || {};
const target = $('media-cache-metrics');
if (!target) return;
target.textContent = metrics.managedCount
? `${metrics.managedCount} ${_mediaText('media.cache.assets', 'assets')} · ${fmt(metrics.managedBytes || 0)} · ${metrics.pinnedCount || 0} ${_mediaText('media.cache.pinned', 'pinned')}`
: _mediaText('media.cache.empty', 'No managed cache assets.');
}
async function _refreshMediaCacheStatus({ rerender = false } = {}) {
if (!window.pork.mediaCacheStatus) return;
_mediaCacheStatus = await window.pork.mediaCacheStatus();
_renderMediaCacheStatus();
if (rerender) renderMediaGrid();
}
async function _mediaSha256(blob) {
const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer());
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
}
function _renderImportedMedia(packet) {
const target = $('media-imported-list');
if (!target) return;
const items = packet?.items || [];
target.innerHTML = items.map(item => `<article class="media-imported-card">
${item.mime.startsWith('image/') ? `<img src="${escHtml(item.url)}" alt="">` : `<video src="${escHtml(item.url)}" controls preload="metadata"></video>`}
<div><strong>${escHtml(item.titleId)} · ${escHtml(item.kind)}</strong><span>${escHtml(item.originalName)} · ${fmt(item.bytes)} · ${escHtml(item.provenance.provider)}</span>${item.kind === 'cover' ? `<button class="btn btn-sm" type="button" data-preferred-cover="${escHtml(item.id)}" data-title-id="${escHtml(item.titleId)}">${item.preferred ? _mediaText('media.import.preferredActive', 'Preferred') : _mediaText('media.import.makePreferred', 'Make preferred')}</button>` : ''}</div>
</article>`).join('');
}
async function _loadImportedMedia() {
if (!window.pork.mediaImportsList) return;
_renderImportedMedia(await window.pork.mediaImportsList());
}
function _updateThumbStatus() {
const el = $('media-thumb-status');
if (!el) return;
const pending = _thumbQueued - _thumbDone;
if (pending <= 0 || _thumbQueued === 0) { el.hidden = true; return; }
el.hidden = false;
el.textContent = `Loading thumbnails: ${_thumbDone} / ${_thumbQueued}`;
}
/** Show or update the thumb-status bar with an arbitrary message (for clip-batch). */
function _setThumbStatusMsg(msg) {
const el = $('media-thumb-status');
if (!el) return;
if (msg == null) { el.hidden = true; return; }
el.textContent = msg;
el.hidden = false;
}
function _setCardLabel(remotePath, text) {
const lbl = document.querySelector(`.thumb-label[data-remote="${CSS.escape(remotePath)}"]`);
if (lbl) lbl.textContent = text;
}
// ── Video frame extractor removed ───────────────────────────────────────────
// Webm thumbnails are not generated — the card shows a WEBM type badge instead.
// generateVideoThumb intentionally omitted.
// ── IntersectionObserver for lazy thumbnail loading ───────────────────────────
const _thumbObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const imgEl = entry.target;
const remote = imgEl.dataset.remote;
if (!remote) continue;
_thumbObserver.unobserve(imgEl);
if (_thumbCache.has(remote)) {
_applyThumb(imgEl, _thumbCache.get(remote));
continue;
}
if (_thumbFetching.has(remote)) continue;
_thumbFetching.add(remote);
_thumbQueued++;
_updateThumbStatus();
// Videos: screenshots only now (clips use static badge)
const thumbPromise = window.pork.mediaFetchThumb(remote);
thumbPromise
.then(thumbUrl => {
_thumbCache.set(remote, thumbUrl);
document.querySelectorAll(`.media-thumb-img[data-remote="${CSS.escape(remote)}"]`).forEach(el => {
_applyThumb(el, thumbUrl);
});
})
.catch(err => {
const card = imgEl.closest('.media-card');
const wrap = imgEl.closest('.media-card-thumb');
wrap?.classList.add('thumb-error');
// Show a retry button so the user isn't left with a silent `!`
if (card && !card.querySelector('.btn-thumb-retry')) {
const retryBtn = document.createElement('button');
retryBtn.className = 'btn btn-sm btn-thumb-retry';
retryBtn.title = err?.message || 'Thumbnail failed';
retryBtn.textContent = '\u21BB Retry';
retryBtn.addEventListener('click', () => {
_thumbFetching.delete(remote);
_thumbCache.delete(remote);
_thumbQueued = Math.max(0, _thumbQueued - 1);
_thumbDone = Math.max(0, _thumbDone - 1);
retryBtn.remove();
wrap?.classList.remove('thumb-error');
// Re-add the spinner and img, then re-observe
const newSpin = document.createElement('div');
newSpin.className = 'thumb-spin';
const newLbl = document.createElement('div');
newLbl.className = 'thumb-label'; newLbl.dataset.remote = remote;
newLbl.textContent = 'Waiting…';
wrap?.prepend(newLbl);
wrap?.prepend(newSpin);
imgEl.src = ''; imgEl.classList.remove('thumb-loaded'); imgEl.classList.add('thumb-loading');
_thumbObserver.observe(imgEl);
});
card.querySelector('.media-card-body')?.prepend(retryBtn);
}
})
.finally(() => {
_thumbFetching.delete(remote);
_thumbDone++;
_updateThumbStatus();
});
}
}, { rootMargin: '200px', threshold: 0 });
function _applyThumb(imgEl, dataUrl) {
imgEl.src = dataUrl;
imgEl.classList.remove('thumb-loading');
imgEl.classList.add('thumb-loaded');
const spin = imgEl.parentElement?.querySelector('.thumb-spin');
if (spin) spin.remove();
}
async function loadMedia() {
const gameContext = consumeGameWorkspaceContext('media');
if (gameContext) {
_mediaSearch = gameContext;
if ($('media-search')) $('media-search').value = gameContext;
}
try {
const s = await window.pork.getSettings();
if (s.mediaLocalPath) $('media-save-path').value = s.mediaLocalPath;
} catch (_) {}
if (window.pork.mediaCatalog) {
try {
const catalog = await window.pork.mediaCatalog();
if (Array.isArray(catalog?.items)) {
_mediaItems = catalog.items;
const status = $('media-scan-status');
if (catalog.scannedAt) {
status.textContent = `Catalogus: ${catalog.stats?.total || catalog.items.length} items · ${_fmtDate(catalog.scannedAt)}${catalog.stale ? ' · nieuwe scan aanbevolen' : ''}`;
status.hidden = false;
}
}
} catch (_) {}
}
try { await _refreshMediaCacheStatus(); } catch (_) {}
try { await _loadImportedMedia(); } catch (_) {}
renderMediaGrid();
}
function getFilteredMedia() {
const filtered = _mediaItems.filter(item => {
if (_mediaFilter !== 'all' && item.type !== _mediaFilter) return false;
if (_mediaSearch) {
const q = _mediaSearch.toLowerCase();
return item.game_id.toLowerCase().includes(q) || item.filename.toLowerCase().includes(q);
}
return true;
});
// Sort
filtered.sort((a, b) => {
switch (_mediaSort) {
case 'date-asc': return (a.capturedAt || 0) - (b.capturedAt || 0);
case 'size-desc': return b.size - a.size;
case 'name-asc': return a.filename.localeCompare(b.filename);
default: return (b.capturedAt || 0) - (a.capturedAt || 0); // date-desc
}
});
return filtered;
}
function renderMediaGrid() {
const grid = $('media-grid');
const empty = $('media-empty');
const all = _mediaItems;
const shots = all.filter(i => i.type === 'screenshot');
const clips = all.filter(i => i.type === 'clip');
$('media-count-all').textContent = all.length || '';
$('media-count-screenshots').textContent = shots.length || '';
$('media-count-clips').textContent = clips.length || '';
// Show the "Fetch Clip Thumbnails" button whenever there are clips loaded
const clipThumbBtn = $('btn-clip-thumbs');
if (clipThumbBtn) {
const missing = clips.filter(c => !_thumbCache.has(c.remotePath)).length;
clipThumbBtn.style.display = clips.length ? '' : 'none';
clipThumbBtn.title = missing
? `Generate thumbnails for ${missing} clip${missing !== 1 ? 's' : ''} (${clips.length - missing} already cached)`
: 'All clip thumbnails are already cached';
}
const badge = $('nav-media-badge');
// The Chimera v2 navigation replaces Porkfolio's legacy media badge. The
// background warmer may render Media after that replacement, so the badge is
// deliberately optional.
if (badge) {
if (all.length) { badge.textContent = all.length; badge.hidden = false; }
else badge.hidden = true;
}
const visible = getFilteredMedia();
grid.innerHTML = '';
if (!visible.length) { empty.hidden = false; return; }
empty.hidden = true;
const iconMap = new Map((state.games || []).map(g => [g.game_id, g.prospero_icon_url || '']));
for (const item of visible) {
const card = document.createElement('div');
card.className = 'media-card';
card.dataset.remote = item.remotePath;
card.dataset.type = item.type;
const icon = iconMap.get(item.game_id) || '';
const iconHtml = icon
? `<img src="${escHtml(icon)}" class="media-card-game-icon" alt="" loading="lazy" data-image-error="hide">`
: `<div class="media-card-game-icon-empty"></div>`;
// Thumbnail area — real <img> for screenshots (lazy-loaded), styled placeholder for clips
let thumbHtml;
if (item.type === 'screenshot') {
const cached = _thumbCache.get(item.remotePath);
if (cached) {
thumbHtml = `
<div class="media-card-thumb type-screenshot media-thumb-wrap">
<img src="${escHtml(cached)}" class="media-thumb-img thumb-loaded"
data-remote="${escHtml(item.remotePath)}" alt=""/>
</div>`;
} else {
thumbHtml = `
<div class="media-card-thumb type-screenshot media-thumb-wrap">
<div class="thumb-spin"></div>
<img src="" class="media-thumb-img thumb-loading"
data-remote="${escHtml(item.remotePath)}" alt=""/>
</div>`;
}
} else {
// Video clip — static WEBM badge + play overlay; no thumbnail download.
const cachedClip = _thumbCache.get(item.remotePath);
const clipImg = cachedClip
? `<img src="${escHtml(cachedClip)}" class="media-thumb-img thumb-loaded" data-remote="${escHtml(item.remotePath)}" alt=""/>`
: `<div class="clip-type-badge">WEBM</div>`;
thumbHtml = `
<div class="media-card-thumb type-clip media-thumb-wrap">
${clipImg}
<div class="clip-play-overlay">
<svg viewBox="0 0 40 40" width="36" height="36" fill="none">
<circle cx="20" cy="20" r="18" fill="rgba(0,0,0,.45)" stroke="rgba(255,255,255,.25)" stroke-width="1"/>
<polygon points="16,13 30,20 16,27" fill="white" opacity=".9"/>
</svg>
</div>
</div>`;
}
const cachedAsset = item.cache || (_mediaCacheStatus.entries || []).find(entry => entry.origin?.locator === item.remotePath && entry.kind === 'cached');
const provenance = item.provenance || { type: 'remote', provider: 'ps5-ftp' };
const originLabel = provenance.type === 'remote'
? _mediaText('media.cache.remote', 'Remote PS5')
: provenance.type === 'imported' ? _mediaText('media.cache.imported', 'Imported original') : _mediaText('media.cache.generated', 'Generated');
card.innerHTML = `
${thumbHtml}
<div class="media-card-body">
<div class="media-card-game">
${iconHtml}
<span class="media-card-game-id">${escHtml(item.game_id)}</span>
</div>
<div class="media-card-filename" title="${escHtml(item.filename)}">${escHtml(item.filename)}</div>
<div class="media-card-meta">
<span class="media-card-size">${fmt(item.size)}</span>
${item.capturedAt ? `<span class="media-card-date" title="${new Date(item.capturedAt).toLocaleString()}">${_fmtDate(item.capturedAt)}</span>` : ''}
</div>
<div class="media-card-provenance"><span>${escHtml(originLabel)}</span><span>${escHtml(provenance.provider || '')}</span>${cachedAsset ? `<button class="btn-cache-pin" type="button" data-cache-pin="${escHtml(cachedAsset.id)}" data-pinned="${cachedAsset.pinned ? 'true' : 'false'}" aria-pressed="${cachedAsset.pinned ? 'true' : 'false'}">${cachedAsset.pinned ? _mediaText('media.cache.unpin', 'Unpin') : _mediaText('media.cache.pin', 'Pin cache')}</button>` : ''}</div>
<div class="media-card-actions">
${item.type === 'clip'
? `<button class="btn btn-sm btn-accent btn-media-play"
data-remote="${escHtml(item.remotePath)}"
data-file="${escHtml(item.filename)}">&#9654; Play</button>`
: ''}
<button class="btn btn-sm btn-media-dl"
data-remote="${escHtml(item.remotePath)}"
data-game="${escHtml(item.game_id)}"
data-file="${escHtml(item.filename)}">&#8659; Download</button>
<button class="btn btn-sm btn-media-share"
data-remote="${escHtml(item.remotePath)}"
data-game="${escHtml(item.game_id)}"
data-file="${escHtml(item.filename)}"
data-type="${escHtml(item.type)}">&#128279; Discord</button>
</div>
</div>`;
grid.appendChild(card);
// Register lazy loading — screenshots only; clips show a static WEBM badge.
if (item.type === 'screenshot' && !_thumbCache.has(item.remotePath)) {
const imgEl = card.querySelector('.media-thumb-img');
if (imgEl) _thumbObserver.observe(imgEl);
}
}
}
$('media-grid').addEventListener('click', async event => {
const button = event.target.closest('[data-cache-pin]');
if (!button || !window.pork.mediaCachePin) return;
button.disabled = true;
try {
await window.pork.mediaCachePin(button.dataset.cachePin, button.dataset.pinned !== 'true');
await _refreshMediaCacheStatus({ rerender: true });
} catch (error) {
setStatus(error.message, 'error');
button.disabled = false;
}
});
async function _clearMediaCache(options) {
if (!window.pork.mediaCacheClear) return;
const result = await window.pork.mediaCacheClear(options);
_thumbCache.clear();
await _refreshMediaCacheStatus({ rerender: true });
setStatus(`${result.removed || 0} ${_mediaText('media.cache.cleared', 'cache assets cleared')}.`, 'ok');
}
$('btn-media-cache-expired').addEventListener('click', () => _clearMediaCache({ expiredOnly: true }).catch(error => setStatus(error.message, 'error')));
$('btn-media-cache-unpinned').addEventListener('click', async () => {
const ok = await showConfirm(_mediaText('media.cache.clearConfirm', 'Clear all unpinned generated and cached media? Imported originals are never deleted.'));
if (ok) await _clearMediaCache({});
});
$('btn-media-import').addEventListener('click', async () => {
const button = $('btn-media-import');
const status = $('media-import-status');
const file = $('media-import-file').files?.[0];
const titleId = $('media-import-title-id').value.trim().toUpperCase();
const kind = $('media-import-kind').value;
if (!file || !/^(CUSA|PPSA)\d{5}$/.test(titleId)) return setStatus(_mediaText('media.import.invalid', 'Select a file and valid title ID.'), 'error');
button.disabled = true;
try {
const session = await window.pork.importUploadCreate([{ relativePath: file.name, size: file.size }]);
const staged = session.files[0];
let offset = 0;
while (offset < file.size) {
const chunk = file.slice(offset, Math.min(file.size, offset + 4 * 1024 * 1024));
const progress = await window.pork.importUploadChunk(session.id, staged.id, offset, chunk, await _mediaSha256(chunk));
offset = Number(progress.files[0].offset);
status.textContent = `${Math.round(offset / file.size * 100)}%`;
}
const crop = $('media-import-crop').value === 'center' ? { x: 0.125, y: 0, width: 0.75, height: 1 } : { x: 0, y: 0, width: 1, height: 1 };
await window.pork.mediaImportFinalize({ sessionId: session.id, fileId: staged.id, titleId, kind, crop: kind === 'cover' ? crop : null, preferred: kind === 'cover' && $('media-import-preferred').checked });
status.textContent = _mediaText('media.import.done', 'Import complete.');
$('media-import-file').value = '';
await _loadImportedMedia();
} catch (error) { status.textContent = error.message; setStatus(error.message, 'error'); }
finally { button.disabled = false; }
});
$('media-imported-list').addEventListener('click', async event => {
const button = event.target.closest('[data-preferred-cover]');
if (!button || !window.pork.mediaImportPreferred) return;
await window.pork.mediaImportPreferred(button.dataset.titleId, button.dataset.preferredCover);
await _loadImportedMedia();
});
// Clip thumbnail batch progress — update the status bar with live count
window.pork.on('media:clip-thumb:progress', ({ done, total, remote, state }) => {
const label = remote ? remote.split('/').pop() : '';
if (state === 'downloading') {
_setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total} — downloading ${label}…`);
} else if (state === 'extracting') {
_setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total} — extracting frame…`);
} else {
// 'ok' or 'failed'
if (done >= total) {
const clips = _mediaItems.filter(i => i.type === 'clip');
const missing = clips.filter(c => !_thumbCache.has(c.remotePath)).length;
_setThumbStatusMsg(`Done — fetched ${done} clip thumbnail${done !== 1 ? 's' : ''}${missing ? `, ${missing} still missing (partial download may not have contained a keyframe)` : ''}`);
setTimeout(() => _setThumbStatusMsg(null), 8000);
// Refresh button tooltip
const clipThumbBtn = $('btn-clip-thumbs');
if (clipThumbBtn) {
clipThumbBtn.disabled = false;
clipThumbBtn.textContent = '\u{1F3AC} Fetch Clip Thumbnails';
}
renderMediaGrid();
} else {
_setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total}${state === 'failed' ? ` (last failed)` : ''}`);
}
}
});
// ── Fetch Clip Thumbnails button ──────────────────────────────────────────────
$('btn-clip-thumbs').addEventListener('click', async () => {
const clips = _mediaItems.filter(i => i.type === 'clip');
const missing = clips.filter(c => !_thumbCache.has(c.remotePath));
if (missing.length === 0) {
setStatus('All clip thumbnails are already cached.', 'ok');
return;
}
// Warn if it will take a while — each clip needs a partial FTP download + ffmpeg
const warnThreshold = 5;
if (missing.length > warnThreshold) {
const ok = await showConfirm(
`Fetching thumbnails for ${missing.length} video clip${missing.length !== 1 ? 's' : ''}.\n\n` +
`This will download the first ~10 MB of each clip from your PS5 and extract a frame.\n` +
`With many files this can take several minutes.\n\nContinue?`
);
if (!ok) return;
}
const btn = $('btn-clip-thumbs');
btn.disabled = true;
btn.textContent = 'Fetching…';
_setThumbStatusMsg(`Starting thumbnail fetch for ${missing.length} clip${missing.length !== 1 ? 's' : ''}…`);
try {
await window.pork.mediaFetchClipThumbs(missing.map(c => c.remotePath));
} catch (err) {
setStatus(`Clip thumbnail fetch failed: ${err.message}`, 'error');
_setThumbStatusMsg(null);
btn.disabled = false;
btn.textContent = '\u{1F3AC} Fetch Clip Thumbnails';
}
});
// ── Scan buttons ──────────────────────────────────────────────────────────────
async function runMediaScan(force = false) {
const btn = $('btn-media-scan');
const forceBtn = $('btn-media-force-scan');
const cancelBtn = $('btn-media-cancel-scan');
const status = $('media-scan-status');
btn.disabled = true;
forceBtn.disabled = true;
cancelBtn.hidden = !window.pork.mediaScanCancel;
btn.textContent = force ? 'Volledige scan…' : 'Scanning…';
status.textContent = 'Connecting to PS5…';
status.hidden = false;
// Clear old data + caches
_mediaItems = [];
_thumbCache.clear();
_thumbFetching.clear();
_thumbQueued = 0;
_thumbDone = 0;
const tsEl = $('media-thumb-status');
if (tsEl) tsEl.hidden = true;
renderMediaGrid();
try {
const result = await window.pork.mediaScan({ force });
const packet = Array.isArray(result) ? { items: result, stats: { total: result.length }, scan: { mode: 'full' }, delta: {} } : result;
_mediaItems = Array.isArray(packet?.items) ? packet.items : [];
const mode = packet.cached ? 'cache' : packet.scan?.mode === 'incremental' ? 'incrementeel' : 'volledig';
const delta = packet.delta || {};
status.textContent = `${_mediaItems.length} items · ${mode} · +${delta.added || 0} / -${delta.removed || 0} / changed ${delta.changed || 0}${packet.scan?.reusedDirectories ? ` · ${packet.scan.reusedDirectories} folders reused` : ''}${packet.scan?.errors?.length ? ` · ${packet.scan.errors.length} FTP error(s), previous contents preserved` : ''}`;
status.style.color = '';
// Restore persisted clip thumbnails so they show immediately after scan
try {
const diskCache = await window.pork.mediaLoadClipThumbs();
for (const [rp, du] of Object.entries(diskCache || {})) _thumbCache.set(rp, du);
} catch (_) {}
renderMediaGrid();
} catch (e) {
status.textContent = `Scan failed: ${e.message}`;
status.style.cssText += ';color:var(--red)';
status.style.animationName = 'none';
} finally {
btn.disabled = false;
forceBtn.disabled = false;
cancelBtn.hidden = true;
btn.textContent = '↺ Scan via FTP';
}
}
$('btn-media-scan').addEventListener('click', () => runMediaScan(false));
$('btn-media-force-scan').addEventListener('click', () => runMediaScan(true));
$('btn-media-cancel-scan').addEventListener('click', async event => {
event.currentTarget.disabled = true;
try { await window.pork.mediaScanCancel(); $('media-scan-status').textContent = 'Scan annuleren…'; }
catch (error) { setStatus(error.message, 'error'); }
finally { event.currentTarget.disabled = false; }
});
// Per-card thumb state updates from main process
window.pork.on('media:thumb:progress', ({ remotePath, state }) => {
const labels = { downloading: 'Downloading…', decoding: 'Processing…' };
if (labels[state]) _setCardLabel(remotePath, labels[state]);
});
// Live scan progress
window.pork.on('media:scan:progress', ({ game_id, type, found, scannedDirectories, reusedDirectories }) => {
const status = $('media-scan-status');
if (!status.hidden) {
status.textContent = `Scanning ${type === 'screenshot' ? 'screenshots' : 'video clips'}… ${found} found${game_id ? ` (${game_id})` : ''}${scannedDirectories ? ` · ${scannedDirectories} folders read` : ''}${reusedDirectories ? ` · ${reusedDirectories} reused` : ''}`;
}
});
// Discord share progress — update the share button live for every pipeline stage
// Track remote paths whose discord share has fully resolved so late progress
// events (which can race against the IPC reply) don't override the final state.
const _discordCompleted = new Set();
window.pork.on('media:discord:progress', ({ remotePath, state, pct, attempt, max }) => {
if (_discordCompleted.has(remotePath)) return;
const btn = document.querySelector(`.btn-media-share[data-remote="${CSS.escape(remotePath)}"]`);
if (!btn) return;
if (state === 'downloading') {
btn.classList.remove('uploading');
btn.textContent = pct != null ? `Downloading ${pct}%` : 'Downloading…';
} else if (state === 'converting') {
btn.classList.remove('uploading');
btn.textContent = pct != null ? `Converting ${pct}%` : 'Converting…';
} else if (state === 'uploading-video') {
btn.classList.add('uploading');
btn.style.setProperty('--upload-pct', `${pct ?? 0}%`);
btn.textContent = pct != null ? `Uploading ${pct}%` : 'Uploading…';
} else if (state === 'retrying') {
btn.classList.remove('uploading');
btn.style.removeProperty('--upload-pct');
btn.textContent = `Retrying… (${attempt}/${max})`;
} else {
btn.classList.remove('uploading');
btn.style.removeProperty('--upload-pct');
const labels = { sending: 'Sending…', uploading: 'Uploading…' };
if (labels[state]) btn.textContent = labels[state];
}
});
// Discord share thumbnail — ffmpeg extracted a frame from the local mp4; cache it
// and patch the clip card immediately so the user sees the thumbnail right away.
window.pork.on('media:discord:thumb', ({ remotePath, dataUrl }) => {
if (!remotePath || !dataUrl) return;
_thumbCache.set(remotePath, dataUrl);
// Find every clip card for this path and swap the background → actual thumb
document.querySelectorAll(`.media-card[data-remote="${CSS.escape(remotePath)}"]`).forEach(card => {
const wrap = card.querySelector('.media-card-thumb');
if (!wrap) return;
// Replace the WEBM badge placeholder with a real thumbnail image
const badge = wrap.querySelector('.clip-type-badge');
if (badge) badge.remove();
let img = wrap.querySelector('.media-thumb-img');
if (!img) {
img = document.createElement('img');
img.className = 'media-thumb-img';
img.alt = '';
img.dataset.remote = remotePath;
wrap.insertBefore(img, wrap.firstChild);
}
img.src = dataUrl;
img.classList.add('thumb-loaded');
img.classList.remove('thumb-loading');
});
});
// Save path browse
$('btn-media-save-browse').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (!folder) return;
$('media-save-path').value = folder;
await window.pork.setSettings({ mediaLocalPath: folder });
});
// Open media folder in Explorer
$('btn-media-open-folder').addEventListener('click', () => {
const folder = $('media-save-path').value;
if (folder) window.pork.openShell(folder);
else setStatus('No media folder configured — select one first.', 'error');
});
// Live update when main process falls back to a default path (e.g. configured drive missing)
window.pork.on('settings:updated', (patch) => {
if (patch?.mediaLocalPath) {
const el = $('media-save-path');
if (el) el.value = patch.mediaLocalPath;
setStatus(`Media save folder updated to "${patch.mediaLocalPath}" (previous path was inaccessible).`, 'ok');
}
});
// Sort dropdown
$('media-sort').addEventListener('change', e => {
_mediaSort = e.target.value;
renderMediaGrid();
});
// Filter buttons
document.querySelectorAll('[data-mfilter]').forEach(btn =>
btn.addEventListener('click', () => {
document.querySelectorAll('[data-mfilter]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_mediaFilter = btn.dataset.mfilter;
renderMediaGrid();
})
);
// Search
$('media-search').addEventListener('input', () => {
clearTimeout(_mediaSearchTimer);
_mediaSearchTimer = setTimeout(() => {
_mediaSearch = $('media-search').value.trim();
renderMediaGrid();
}, 250);
});
// ── Lightbox ──────────────────────────────────────────────────────────────────
let _lightboxItem = null; // current { remotePath, game_id, filename }
function openLightbox(item) {
_lightboxItem = item;
const box = $('media-lightbox');
const img = $('lightbox-img');
const spin = $('lightbox-spinner');
const meta = $('lightbox-meta');
img.src = '';
img.hidden = true;
spin.hidden = false;
meta.textContent = `${item.game_id} — ${item.filename}`;
box.hidden = false;
document.body.classList.add('lightbox-open');
const cached = _thumbCache.get(item.remotePath);
if (cached) {
img.src = cached;
img.hidden = false;
spin.hidden = true;
} else {
window.pork.mediaFetchThumb(item.remotePath)
.then(dataUrl => {
_thumbCache.set(item.remotePath, dataUrl);
if (_lightboxItem?.remotePath === item.remotePath) {
img.src = dataUrl;
img.hidden = false;
spin.hidden = true;
}
})
.catch(err => {
spin.hidden = true;
meta.textContent += ` — Load failed: ${err.message}`;
});
}
}
function closeLightbox() {
$('media-lightbox').hidden = true;
document.body.classList.remove('lightbox-open');
$('lightbox-img').src = '';
_lightboxItem = null;
}
$('btn-lightbox-close').addEventListener('click', closeLightbox);
$('media-lightbox').addEventListener('click', e => {
if (e.target === $('media-lightbox') || e.target.classList.contains('media-lightbox-backdrop')) closeLightbox();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !$('media-lightbox').hidden) closeLightbox();
});
$('btn-lightbox-dl').addEventListener('click', async () => {
if (!_lightboxItem) return;
const btn = $('btn-lightbox-dl');
btn.disabled = true;
btn.textContent = 'Queued';
try {
await window.pork.mediaDownload({
remotePath: _lightboxItem.remotePath,
game_id: _lightboxItem.game_id,
filename: _lightboxItem.filename,
});
setStatus(`Queued: ${_lightboxItem.filename} — see Transfers`, 'ok');
} catch (err) {
setStatus(err.message, 'error');
} finally {
setTimeout(() => { btn.disabled = false; btn.textContent = '↓ Save to PC'; }, 2000);
}
});
// ── Grid click delegation ─────────────────────────────────────────────────────
$('media-grid').addEventListener('click', async e => {
// Share to Discord button
const shareBtn = e.target.closest('.btn-media-share');
if (shareBtn) {
shareBtn.disabled = true;
shareBtn.textContent = 'Preparing…';
try {
const res = await window.pork.mediaDiscordSend({
remotePath: shareBtn.dataset.remote,
game_id: shareBtn.dataset.game,
filename: shareBtn.dataset.file,
type: shareBtn.dataset.type,
});
_discordCompleted.add(shareBtn.dataset.remote);
shareBtn.classList.remove('uploading');
shareBtn.style.removeProperty('--upload-pct');
shareBtn.textContent = '\u2713 Sent!';
const detail = res?.url ? ` • ${res.url}` : '';
setStatus(`Shared to Discord: ${shareBtn.dataset.file}${detail}`, 'ok');
setTimeout(() => {
shareBtn.disabled = false;
shareBtn.textContent = '\uD83D\uDD17 Discord';
_discordCompleted.delete(shareBtn.dataset.remote);
}, 3000);
} catch (err) {
_discordCompleted.add(shareBtn.dataset.remote);
shareBtn.classList.remove('uploading');
shareBtn.style.removeProperty('--upload-pct');
shareBtn.disabled = false;
shareBtn.textContent = '\uD83D\uDD17 Discord';
_discordCompleted.delete(shareBtn.dataset.remote);
setStatus(`Discord share failed: ${err.message}`, 'error');
}
return;
}
// Download button
const dlBtn = e.target.closest('.btn-media-dl');
if (dlBtn) {
dlBtn.disabled = true;
dlBtn.textContent = 'Queued';
try {
await window.pork.mediaDownload({
remotePath: dlBtn.dataset.remote,
game_id: dlBtn.dataset.game,
filename: dlBtn.dataset.file,
});
setStatus(`Queued: ${dlBtn.dataset.file} — see Transfers`, 'ok');
} catch (err) {
setStatus(err.message, 'error');
}
dlBtn.disabled = false;
dlBtn.textContent = '\u21D3 Download';
return;
}
// Play button (video clips)
const playBtn = e.target.closest('.btn-media-play');
if (playBtn) {
playBtn.disabled = true;
playBtn.textContent = 'Opening\u2026';
try {
await window.pork.mediaOpen(playBtn.dataset.remote);
} catch (err) {
setStatus(err.message, 'error');
} finally {
setTimeout(() => { playBtn.disabled = false; playBtn.textContent = '\u25B6 Play'; }, 1500);
}
return;
}
// Click on a screenshot thumbnail → open lightbox
const card = e.target.closest('.media-card[data-type="screenshot"]');
if (card && !e.target.closest('button')) {
const remote = card.dataset.remote;
const mediaItem = _mediaItems.find(i => i.remotePath === remote);
if (mediaItem) openLightbox(mediaItem);
}
});
File diff suppressed because it is too large. Load diff
+110
View File
@@ -0,0 +1,110 @@
'use strict';
(function exposeNavigationContract(root, factory) {
const contract = factory();
if (typeof module === 'object' && module.exports) module.exports = contract;
else root.ChimeraNavigationContract = contract;
})(typeof globalThis !== 'undefined' ? globalThis : this, function buildNavigationContract() {
const groups = [
{
id: 'home', label: 'Home', icon: 'home', defaultRoute: 'home',
routes: [
{ id: 'home', label: 'Overview', description: 'Library, connection, activity and quick actions.', page: 'dashboard', keywords: ['dashboard', 'start'] },
],
},
{
id: 'library', label: 'Library', icon: 'library', defaultRoute: 'library/games',
routes: [
{ id: 'library/games', label: 'Games', description: 'Complete game library with metadata and cover art.', page: 'games', keywords: ['titles', 'roms', 'covers'] },
{ id: 'library/updates', label: 'Game updates', description: 'Available patches and updates for each title.', page: 'ecosystem', tab: 'game-updates', keywords: ['patches'] },
{ id: 'library/cheats', label: 'Cheats & patches', description: 'Search, review and manage cheats and patches.', page: 'ecosystem', tab: 'cheats', keywords: ['goldhen', 'patches'] },
{ id: 'library/media', label: 'Media', description: 'Screenshots and video clips from the console.', page: 'media', keywords: ['screenshots', 'video'] },
{ id: 'library/mounts', label: 'Mounts', description: 'ShadowMount configuration with restore-point protection.', page: 'operations', panel: 'shadowmount', keywords: ['shadowmount', 'folders'], expert: true },
{ id: 'library/packages', label: 'Packages', description: 'Browse package sets, components, versions, hashes and recipes.', page: 'packages', keywords: ['pkg', 'sets', 'components', 'recipes'] },
],
},
{
id: 'console', label: 'Console', icon: 'console', defaultRoute: 'console/status',
routes: [
{ id: 'console/status', label: 'Status', description: 'Configured PS5 endpoint, connection evidence and service health.', page: 'ecosystem', tab: 'overview', keywords: ['health', 'overview'] },
{ id: 'console/live', label: 'Live session', description: 'Live console information and the active session.', page: 'ecosystem', tab: 'live', keywords: ['runtime', 'temperature'], expert: true },
{ id: 'console/remote-play', label: 'Remote Play', description: 'Discover and wake the PS5, then start a local chiaki-ng session.', page: 'ecosystem', tab: 'remote-play', keywords: ['chiaki', 'stream', 'controller', 'wake'] },
{ id: 'console/system-view', label: 'System View', description: 'Capture-card video, audio and recording.', page: 'system-view', keywords: ['capture', 'stream'] },
{ id: 'console/files', label: 'Files', description: 'Safely browse files on the PS5.', page: 'ecosystem', tab: 'files', keywords: ['ftp', 'browser'], expert: true },
{ id: 'console/profile', label: 'Profile & compatibility', description: 'Firmware, HEN and loader profile with validation.', page: 'operations', panel: 'compatibility', keywords: ['firmware', 'etahen', 'kstuff'] },
{ id: 'console/etahen', label: 'etaHEN', description: 'etaHEN configuration and integration status.', page: 'ecosystem', tab: 'etahen', keywords: ['hen'] },
{ id: 'console/notifications', label: 'Notifications', description: 'Test the connection and send console notifications.', page: 'psnotify', keywords: ['notify', 'toast'] },
{ id: 'console/avatar', label: 'XAvatar', description: 'Convert, transfer and manage avatars.', page: 'xavatar', keywords: ['profile', 'image'] },
{ id: 'console/power', label: 'System actions', description: 'Confirmed power, rest-mode and disc actions.', page: 'jailbreak', focus: 'power', keywords: ['shutdown', 'reboot', 'eject'], risk: 'confirm', expert: true },
],
},
{
id: 'payloads', label: 'Payloads & Homebrew', icon: 'payload', defaultRoute: 'payloads/installed',
routes: [
{ id: 'payloads/installed', label: 'Payload Manager', description: 'PS5 manager, local payloads, catalog and sender.', page: 'jailbreak', focus: 'payloads', keywords: ['sender', 'elf', 'bin'] },
{ id: 'payloads/updates', label: 'Updates', description: 'Safely update payloads and components with version checks.', page: 'ecosystem', tab: 'updates', keywords: ['upgrade', 'watchdog'] },
{ id: 'payloads/catalog', label: 'Component catalog', description: 'Tracked PS5 projects, releases and compatibility.', page: 'ecosystem', tab: 'components', keywords: ['projects', 'releases'] },
{ id: 'payloads/homebrew', label: 'Homebrew', description: 'Homebrew library and available releases.', page: 'ecosystem', tab: 'homebrew', keywords: ['apps'] },
{ id: 'payloads/plugins', label: 'Plugins', description: 'Plugin library and source information.', page: 'ecosystem', tab: 'plugins', keywords: ['extensions'] },
{ id: 'payloads/boot', label: 'Boot & autoload', description: 'Autoload order, snapshots and USB/disc builders.', page: 'jailbreak', focus: 'boot', keywords: ['y2jb', 'bdjb', 'autoload'] },
{ id: 'payloads/elf-arsenal', label: 'ELF Arsenal', description: 'Manage ELF files and send them to the console.', page: 'elf-arsenal', keywords: ['payload', 'sender'] },
],
},
{
id: 'protection', label: 'Protection', icon: 'shield', defaultRoute: 'protection/backups',
routes: [
{ id: 'protection/backups', label: 'Backups', description: 'View and manage game backups.', page: 'backups', keywords: ['backup'] },
{ id: 'protection/trophies', label: 'Trophy evidence', description: 'Compare read-only PSN proof with the selected local profile.', page: 'trophies', keywords: ['trophy', 'profile', 'evidence', 'reconstruct'] },
{ id: 'protection/images', label: 'Backpork images', description: 'Manage, build and verify firmware images.', page: 'backporks', keywords: ['image', 'firmware'] },
{ id: 'protection/saves', label: 'Save Vault', description: 'Save snapshots, comparison and safe recovery.', page: 'ecosystem', tab: 'saves', keywords: ['saves', 'restore'] },
{ id: 'protection/save-service', label: 'GarlicSaves worker', description: 'Install and manage the optional GarlicSaves community worker.', page: 'garlic', keywords: ['garlic', 'community', 'worker'], expert: true },
{ id: 'protection/save-browser', label: 'Save browser', description: 'Advanced local save browser.', page: 'savemgr', keywords: ['local save manager'], expert: true },
{ id: 'protection/recovery', label: 'Recovery Vault', description: 'Create, export and restore recovery points.', page: 'operations', panel: 'recovery', keywords: ['snapshot', 'restore'], risk: 'confirm' },
{ id: 'protection/integrity', label: 'Integrity audit', description: 'Audit mounts, registrations and orphaned files read-only.', page: 'operations', panel: 'storage', keywords: ['storage', 'audit'] },
{ id: 'protection/audit', label: 'Audit Center', description: 'Search mutating actions, results and safety classifications.', page: 'operations', panel: 'audit', keywords: ['log', 'history', 'security'], expert: true },
{ id: 'protection/replication', label: 'Replication', description: 'Use a second backup target with SHA-256 verification.', page: 'operations', panel: 'reliability', focus: 'replication', keywords: ['3-2-1', 'copy'], expert: true },
],
},
{
id: 'tools', label: 'Tools', icon: 'tools', defaultRoute: 'tools/transfers',
routes: [
{ id: 'tools/transfers', label: 'Transfers', description: 'Queue, progress and recent FTP transfers.', page: 'transfers', keywords: ['upload', 'download'], badge: 'transfer' },
{ id: 'tools/pfs', label: 'PFS Ripper', description: 'PFS workflow and extraction tools.', page: 'pfs-ripper', keywords: ['extract'], expert: true },
{ id: 'tools/database', label: 'Database', description: 'Manage the Chimera database and hash information.', page: 'database', keywords: ['sqlite', 'hash'], expert: true },
{ id: 'tools/itemzflow', label: 'Itemzflow', description: 'Itemzflow status and integration.', page: 'ecosystem', tab: 'itemzflow', keywords: ['itemz'], expert: true },
{ id: 'tools/timeline', label: 'Timeline', description: 'Recent platform and console activity.', page: 'ecosystem', tab: 'timeline', keywords: ['events', 'log'], expert: true },
{ id: 'tools/diagnostics', label: 'Diagnostics', description: 'Read-only probes and a sanitized support bundle.', page: 'operations', panel: 'diagnostics', keywords: ['support', 'logs'], expert: true },
{ id: 'tools/package-services', label: 'Package Services', description: 'Expert DPI, Void and raw package service diagnostics.', page: 'operations', panel: 'packages', keywords: ['dpi', 'voidpkg', 'handoff'], expert: true },
{ id: 'tools/research', label: 'Research', description: 'Evaluated projects, sources and expert information.', page: 'ecosystem', tab: 'research', keywords: ['linux', 'sources'], expert: true },
],
},
{
id: 'settings', label: 'Settings', icon: 'settings', defaultRoute: 'settings/general',
routes: [
{ id: 'settings/general', label: 'General', description: 'Language, paths, console connection and platform preferences.', page: 'settings', keywords: ['configuration', 'ftp'] },
{ id: 'settings/automation', label: 'Automation', description: 'Safe maintenance tasks and execution history.', page: 'operations', panel: 'automation', keywords: ['scheduler', 'tasks'] },
{ id: 'settings/sources', label: 'Sources & trust', description: 'Upstream sources and supply-chain policy.', page: 'ecosystem', tab: 'sources', keywords: ['https', 'checksum'] },
{ id: 'settings/security', label: 'Security', description: 'Active sessions and password management.', page: 'operations', panel: 'reliability', focus: 'security', keywords: ['sessions', 'password'] },
{ id: 'settings/monitoring', label: 'Monitoring', description: 'Health metrics and Prometheus token.', page: 'operations', panel: 'reliability', focus: 'monitoring', keywords: ['metrics', 'prometheus'], expert: true },
],
},
];
const legacyAliases = {
dashboard: 'home', games: 'library/games', media: 'library/media', cheats: 'library/cheats',
backporks: 'protection/images', backups: 'protection/backups', trophies: 'protection/trophies', garlic: 'protection/save-service',
savemgr: 'protection/save-browser', jailbreak: 'payloads/installed', psnotify: 'console/notifications',
xavatar: 'console/avatar', 'system-view': 'console/system-view',
transfers: 'tools/transfers', database: 'tools/database', 'elf-arsenal': 'payloads/elf-arsenal',
'pfs-ripper': 'tools/pfs', ecosystem: 'console/status', operations: 'settings/automation', settings: 'settings/general',
};
const routes = groups.flatMap(group => group.routes.map(route => Object.freeze({ ...route, group: group.id })));
return Object.freeze({
version: 1,
defaultRoute: 'home',
groups: Object.freeze(groups.map(group => Object.freeze({ ...group, routes: Object.freeze(group.routes) }))),
routes: Object.freeze(routes),
legacyAliases: Object.freeze(legacyAliases),
});
});
File diff suppressed because it is too large. Load diff
+700
View File
@@ -0,0 +1,700 @@
'use strict';
let _operationsLoaded = false;
let _operationsStatus = null;
let _operationsBusy = new Set();
let _operationsStorageReport = null;
let _operationsStorageTopology = null;
function operationsDate(value) {
if (!value) return '—';
try { return new Date(Number(value) || value).toLocaleString(window.i18n?.current || 'en'); } catch (_) { return '—'; }
}
function operationsDuration(value) {
const ms = Number(value || 0);
if (!ms) return '—';
if (ms < 1000) return `${ms} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
}
function operationsBytes(value) {
const bytes = Number(value || 0);
if (!Number.isFinite(bytes) || bytes <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let amount = bytes;
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; }
return `${amount >= 10 || unit === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
}
function operationsRunSummary(run) {
const raw = String(run.error || run.summary || '').trim();
if (!raw) return run.status === 'completed' ? 'Completed' : '';
if (run.error) return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw;
let result;
try { result = JSON.parse(raw); } catch (_) { return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw; }
if (result == null) return 'Completed';
if (typeof result === 'string' || typeof result === 'number') return String(result);
if (run.task_id === 'library-scan') {
const total = Number(result.total ?? result.found?.length ?? result.found ?? 0);
const added = Number(result.added || 0);
return `${total} game${total === 1 ? '' : 's'} found · ${added} new`;
}
if (run.task_id === 'metadata-refresh') {
const queued = Number(result.queued ?? (typeof result === 'number' ? result : 0));
return `${queued} metadata item${queued === 1 ? '' : 's'} queued`;
}
if (run.task_id === 'payload-sync' && Array.isArray(result)) {
const failed = result.filter(item => item?.error).length;
return `${result.length} source${result.length === 1 ? '' : 's'} checked${failed ? ` · ${failed} error${failed === 1 ? '' : 's'}` : ''}`;
}
if (run.task_id === 'cheat-sync') {
const indexed = Number(result.ecosystem?.total || 0);
const cached = Number(result.legacy?.total || 0);
const failed = Number(result.legacy?.failed || 0) + Number(result.ecosystem?.errors?.length || 0);
return `${indexed} modern cheats or patches indexed · ${cached} legacy items cached${failed ? ` · ${failed} error${failed === 1 ? '' : 's'}` : ''}`;
}
if (run.task_id === 'storage-audit') {
const summary = result.summary || {};
const issues = Number(summary.missingBackups || 0) + Number(summary.partialFiles || 0) + Number(summary.orphanGameFolders || 0);
return `${Number(summary.games || 0)} games · ${Number(summary.backups || 0)} backups · ${issues} attention item${issues === 1 ? '' : 's'}`;
}
if (run.task_id === 'recovery-snapshot') {
const localFiles = Array.isArray(result.files) ? result.files.length : 0;
const remoteFiles = Array.isArray(result.remote) ? result.remote.filter(item => item.included).length : 0;
return `${localFiles} local and ${remoteFiles} PS5 files secured`;
}
const values = Object.entries(result).filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value)).slice(0, 3);
return values.length ? values.map(([key, value]) => `${key}: ${value}`).join(' · ') : 'Completed';
}
function operationsStatusBadge(status) {
const labels = { completed: 'Succeeded', failed: 'Failed', running: 'Running', compatible: 'Compatible', warning: 'Warning', unknown: 'Unknown' };
return `<span class="operations-status operations-status-${escHtml(status || 'unknown')}">${escHtml(labels[status] || status || 'Unknown')}</span>`;
}
async function operationsAction(key, button, task) {
if (_operationsBusy.has(key)) return null;
_operationsBusy.add(key);
const prior = button?.textContent;
if (button) { button.disabled = true; button.textContent = window.t('common.loading', 'Loading…'); }
try { return await task(); }
finally {
_operationsBusy.delete(key);
if (button) { button.disabled = false; button.textContent = prior; }
}
}
function renderOperationsAutomation(data) {
_operationsStatus = data;
const scheduler = $('operations-scheduler-state');
scheduler.textContent = data.running ? 'Active' : 'Stopped';
scheduler.className = `badge ${data.running ? 'badge-green' : 'badge-red'}`;
const tasks = data.tasks || [];
$('operations-task-list').innerHTML = tasks.map(task => `
<div class="operations-task" data-task-id="${escHtml(task.id)}">
<div class="operations-task-main">
<span class="operations-task-indicator ${task.running ? 'running' : task.enabled ? 'enabled' : ''}"></span>
<div><strong>${escHtml(task.label)}</strong><span>${task.running ? 'Running now' : task.enabled ? `Next: ${operationsDate(task.nextRunAt)}` : 'Disabled'}</span></div>
</div>
<label class="operations-switch"><input class="operations-task-enabled" type="checkbox" ${task.enabled ? 'checked' : ''}/><span>Active</span></label>
<label class="operations-interval"><input class="operations-task-interval" type="number" min="5" max="10080" value="${Number(task.intervalMinutes)}"/><span>min.</span></label>
<button class="btn btn-sm operations-task-save">Save</button>
<button class="btn btn-sm btn-accent operations-task-run" ${task.running ? 'disabled' : ''}>Run now</button>
</div>`).join('') || '<p class="hint">No tasks configured.</p>';
const runs = (data.recentRuns || []).slice(0, 25);
$('operations-run-list').innerHTML = runs.map(run => {
const summary = operationsRunSummary(run);
return `
<div class="operations-run">
<div><strong>${escHtml(tasks.find(task => task.id === run.task_id)?.label || run.task_id)}</strong><span>${escHtml(run.trigger || 'manual')} · ${operationsDate(run.started_at)}</span></div>
${operationsStatusBadge(run.status)}
<span>${operationsDuration(run.duration_ms)}</span>
<span class="operations-run-message" title="${escHtml(run.error || run.summary || '')}">${escHtml(summary)}</span>
</div>`;
}).join('') || '<p class="hint">No runs yet.</p>';
const failed = runs.filter(run => run.status === 'failed').length;
const health = $('operations-health');
health.textContent = failed
? window.tf('operations.status.failures', '{count} recent failure(s)', { count: failed })
: window.t('operations.status.healthy', 'Automation healthy');
health.className = `operations-health ${failed ? 'warning' : 'healthy'}`;
const badge = $('nav-operations-badge');
if (badge) { badge.hidden = failed === 0; badge.textContent = failed || ''; }
}
function renderOperationsStorage(report) {
_operationsStorageReport = report;
if (!report) {
$('operations-storage-summary').innerHTML = '';
$('operations-storage-details').innerHTML = '<p class="hint">No storage report available yet.</p>';
return;
}
const summary = report.summary || {};
const metrics = [
[window.t('operations.storage.metrics.mounts', 'Mounts'), summary.roots],
[window.t('operations.storage.metrics.games', 'Games'), summary.games],
[window.t('operations.storage.metrics.backups', 'Backups'), summary.backups],
[window.t('operations.storage.metrics.missing', 'Missing'), summary.missingBackups],
[window.t('operations.storage.metrics.duplicates', 'Duplicate games'), summary.duplicateGames],
[window.t('operations.storage.metrics.partial', 'Partial files'), summary.partialFiles],
[window.t('operations.storage.metrics.orphaned', 'Orphaned folders'), summary.orphanGameFolders],
];
$('operations-storage-summary').innerHTML = metrics.map(([label, value]) => `<div class="operations-metric"><strong>${Number(value || 0)}</strong><span>${escHtml(label)}</span></div>`).join('');
const sections = [];
if ((report.roots || []).length) sections.push(`<div class="operations-audit-section"><h4>${escHtml(window.t('operations.storage.volumes', 'Volumes'))}</h4>${report.roots.map(root => `<div class="operations-audit-row"><code>${escHtml(root.path)}</code><span>${root.error ? escHtml(root.error) : `${operationsBytes(root.availableBytes)} ${escHtml(window.t('operations.storage.freeOf', 'free of'))} ${operationsBytes(root.totalBytes)}`}</span></div>`).join('')}</div>`);
const issue = (title, items, render) => {
if (!items?.length) return;
sections.push(`<details class="operations-audit-section"><summary>${escHtml(title)} <span>${items.length}</span></summary>${items.slice(0, 100).map(render).join('')}${items.length > 100 ? `<p class="hint">${escHtml(window.t('operations.storage.first100', 'Only the first 100 items are shown.'))}</p>` : ''}</details>`);
};
issue(window.t('operations.storage.issues.missing', 'Missing backups'), report.missingBackups, item => `<div class="operations-audit-row"><strong>${escHtml(item.gameId)}</strong><code>${escHtml(item.path)}</code></div>`);
issue(window.t('operations.storage.issues.duplicates', 'Duplicate backup registrations'), report.duplicateBackups, item => `<div class="operations-audit-row"><strong>${escHtml(item.gameId)} (${item.count})</strong><span>${item.paths.map(escHtml).join('<br>')}</span></div>`);
issue(window.t('operations.storage.issues.partial', 'Partial files'), report.partialFiles, item => `<div class="operations-audit-row"><code>${escHtml(item)}</code></div>`);
issue(window.t('operations.storage.issues.orphaned', 'Orphaned game folders'), report.orphanGameFolders, item => `<div class="operations-audit-row"><strong>${escHtml(item.gameId)}</strong><code>${escHtml(item.path)}</code></div>`);
if (!report.missingBackups?.length && !report.partialFiles?.length && !report.orphanGameFolders?.length) sections.push(`<div class="operations-empty-ok">✓ ${escHtml(window.t('operations.storage.healthy', 'No missing backups, partial files or orphaned game folders found.'))}</div>`);
$('operations-storage-details').innerHTML = `<p class="hint">${escHtml(window.t('operations.storage.reportAt', 'Report from'))} ${operationsDate(report.generatedAt)}</p>${sections.join('')}`;
}
function renderStorageTopology(packet) {
const target = $('operations-storage-topology');
if (!target || !packet) return;
_operationsStorageTopology = packet;
target.innerHTML = `<p class="hint">${escHtml(window.t('operations.storage.consoleSource', 'Console source'))}: ${escHtml(packet.provenance?.console || 'unknown')} · ${escHtml(window.t('operations.storage.serverSource', 'server source'))}: ${escHtml(packet.provenance?.server || 'unknown')}</p><div class="ecosystem-update-list">${(packet.volumes || []).map(volume => `<div class="ecosystem-service-row"><div><strong>${escHtml(volume.scope === 'console' ? `PS5 ${volume.kind}` : volume.mountpoint)}</strong><span>${escHtml(volume.mountpoint || 'unknown')} · ${escHtml(volume.filesystem || 'unknown')} · ${volume.freeBytes == null ? escHtml(window.t('operations.storage.freeUnknown', 'free space unknown')) : `${operationsBytes(volume.freeBytes)} ${escHtml(window.t('operations.storage.free', 'free'))}`} · ${volume.totalBytes == null ? escHtml(window.t('operations.storage.totalUnknown', 'total unknown')) : operationsBytes(volume.totalBytes)}</span></div>${operationsStatusBadge(volume.status === 'unknown' || volume.source === 'unavailable' ? 'unknown' : volume.readOnly === true ? 'warning' : 'compatible')}</div>`).join('')}</div>`;
}
async function loadStorageTopology() {
if (!window.pork.consoleStorageTopology) return;
renderStorageTopology(await window.pork.consoleStorageTopology());
}
function renderOperationsRecovery(snapshots) {
$('operations-recovery-list').innerHTML = (snapshots || []).map(snapshot => {
const included = (snapshot.remote || []).filter(item => item.included).length;
return `<div class="operations-recovery-item" data-snapshot-id="${escHtml(snapshot.id)}">
<div class="operations-recovery-icon">↺</div>
<div class="operations-recovery-info"><strong>${escHtml(snapshot.label || 'Restore point')}</strong><span>${operationsDate(snapshot.createdAt)} · Chimera ${escHtml(snapshot.chimeraVersion || '?')} · ${included} remote files · ${snapshot.integrity === 'sha256' ? 'SHA-256' : 'legacy verification'}</span><code>${escHtml(snapshot.id)}</code></div>
<div class="operations-inline-actions"><button class="btn btn-sm operations-recovery-verify">Verify</button><button class="btn btn-sm operations-recovery-export">Download</button><button class="btn btn-sm operations-recovery-restore">Restore</button><button class="btn btn-sm btn-danger operations-recovery-delete">Delete</button></div>
</div>`;
}).join('') || '<div class="operations-empty"><strong>No restore points yet</strong><span>Create a restore point before making major changes.</span></div>';
}
function operationsAuditFilters() {
const since = $('operations-audit-since').value;
const until = $('operations-audit-until').value;
return {
level: $('operations-audit-level').value,
classification: $('operations-audit-classification').value,
result: $('operations-audit-result').value,
search: $('operations-audit-search').value.trim(),
since: since ? new Date(since).getTime() : null,
until: until ? new Date(until).getTime() : null,
limit: 200,
};
}
function renderOperationsAudit(result, health) {
const summary = result.summary || {};
$('operations-audit-summary').innerHTML = [
['Matched', result.totalMatched || 0], ['Succeeded', summary.success || 0], ['Failed', summary.failed || 0],
['Sensitive', summary.sensitive || 0], ['Destructive', summary.destructive || 0], ['Errors', summary.errors || 0],
].map(([label, value]) => `<div class="operations-metric"><strong>${Number(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
const files = health?.files || [];
$('operations-audit-health').textContent = `${files.length} log file(s) · ${operationsBytes(health?.totalBytes)} · rotation ${operationsBytes(health?.retention?.maxBytes)} × ${Number(health?.retention?.maxFiles || 0)} · secrets redacted at rest`;
$('operations-audit-list').innerHTML = (result.entries || []).map(entry => `<div class="operations-run">
<div><strong>${escHtml(entry.command || entry.event || entry.level)}</strong><span>${operationsDate(entry.time)}${entry.requestIp ? ` · ${escHtml(entry.requestIp)}` : ''}</span></div>
${operationsStatusBadge(entry.ok === true ? 'completed' : entry.ok === false ? 'failed' : 'unknown')}
<span>${escHtml(entry.classification || entry.level)}</span>
<span class="operations-run-message" title="${escHtml(entry.error || '')}">${escHtml(entry.error || (entry.durationMs == null ? entry.event : `${entry.event} · ${operationsDuration(entry.durationMs)}`))}</span>
</div>`).join('') || '<div class="operations-empty"><strong>No audit records</strong><span>No records match these filters.</span></div>';
}
async function loadOperationsAudit() {
const [result, health] = await Promise.all([window.pork.auditQuery(operationsAuditFilters()), window.pork.auditHealth()]);
renderOperationsAudit(result, health);
return result;
}
function renderOperationsCompatibility(data) {
const profile = data?.profile || {};
$('operations-profile-firmware').value = profile.firmware || '';
$('operations-profile-hen').value = profile.hen || '';
$('operations-profile-hen-version').value = profile.henVersion || '';
$('operations-profile-kstuff').value = profile.kstuffVersion || '';
$('operations-profile-loader').value = profile.loader || '';
$('operations-profile-notes').value = profile.notes || '';
$('operations-compatibility-checks').innerHTML = (data?.checks || []).map(check => `<div class="operations-check"><div><strong>${escHtml(check.label)}</strong><span>${escHtml(check.detail)}</span></div>${operationsStatusBadge(check.status)}</div>`).join('') || '<p class="hint">No checks available.</p>';
}
function renderReplicationStatus(status) {
const targetState = status.available && status.writable ? 'Available and writable' : status.error || 'Unavailable';
const free = status.storage ? `${operationsBytes(status.storage.availableBytes)} free` : 'free space unknown';
const protection = status.protection || {};
const rpo = protection.effectiveRpoSeconds == null ? 'not established' : protection.effectiveRpoSeconds < 3600
? `${Math.ceil(protection.effectiveRpoSeconds / 60)} min`
: `${Math.round(protection.effectiveRpoSeconds / 360) / 10} h`;
const rto = protection.lastRestoreDrillDurationMs == null ? 'not measured' : `${Math.max(1, Math.round(protection.lastRestoreDrillDurationMs / 1000))} s drill`;
$('operations-replication-status').innerHTML = `
<div class="operations-audit-row"><strong>${escHtml(targetState)}</strong><code>${escHtml(status.target || '/replication')}</code><span>${escHtml(free)}${status.running ? ' · replication running' : ''}</span></div>
<div class="ecosystem-metrics">
<div class="operations-metric"><strong>${protection.threeTwoOneSatisfied ? 'Met' : 'Not met'}</strong><span>3-2-1 status</span></div>
<div class="operations-metric"><strong>${escHtml(rpo)}</strong><span>Effective RPO</span></div>
<div class="operations-metric"><strong>${escHtml(rto)}</strong><span>Observed restore drill</span></div>
<div class="operations-metric"><strong>${protection.immutableObserved ? 'Yes' : 'No'}</strong><span>Immutable target</span></div>
</div>
<div class="${protection.threeTwoOneSatisfied ? 'operations-empty-ok' : 'operations-warning'}"><strong>Target contract: ${escHtml(status.targetContract?.type || 'unknown')}</strong><p>Atomic: ${status.targetContract?.atomicPublish ? 'yes' : 'unknown'} · hashes: ${status.targetContract?.hashVerified ? 'yes' : 'unknown'} · immutable: ${status.targetContract?.immutable ? 'yes' : 'no'} · off-host: ${escHtml(status.targetContract?.offHost || 'unknown')}</p>${protection.blockers?.length ? `<ul>${protection.blockers.map(item => `<li>${escHtml(item)}</li>`).join('')}</ul>` : ''}</div>`;
$('operations-replication-retention-enabled').checked = status.retention?.enabled !== false;
$('operations-replication-retention-count').value = status.retention?.maxSnapshots || 14;
$('operations-replication-retention-days').value = status.retention?.maxAgeDays || 180;
$('operations-replication-run').disabled = !status.available || !status.writable || status.running;
$('operations-replication-list').innerHTML = (status.snapshots || []).map(snapshot => snapshot.invalid
? `<div class="operations-recovery-item"><div class="operations-recovery-info"><strong>Invalid snapshot</strong><code>${escHtml(snapshot.id)}</code><span>${escHtml(snapshot.error)}</span></div></div>`
: `<div class="operations-recovery-item" data-replication-id="${escHtml(snapshot.id)}"><div class="operations-recovery-info"><strong>${escHtml(snapshot.label || 'Replication snapshot')}</strong><span>${operationsDate(snapshot.completedAt || snapshot.createdAt)} · ${Number(snapshot.files || 0)} files · ${operationsBytes(snapshot.bytes)}${snapshot.verifiedAt ? ` · verified ${operationsDate(snapshot.verifiedAt)}` : ''}${snapshot.lastRestoreDrillAt ? ` · restore drill ${operationsDate(snapshot.lastRestoreDrillAt)}` : ''}</span><code>${escHtml(snapshot.id)}</code></div><div class="operations-inline-actions"><button class="btn btn-sm operations-replication-verify">Verify SHA-256</button><button class="btn btn-sm operations-replication-drill">Local restore drill</button></div></div>`).join('') || '<p class="hint">No replication snapshots yet.</p>';
}
function renderMonitoring(value) {
const metrics = [
['Uptime', `${Math.round(Number(value.uptimeSeconds || 0) / 60)} min.`],
['Memory', operationsBytes(value.memory?.rssBytes)],
['Games', value.library?.totalGames || 0],
['Backups', value.library?.totalBackups || 0],
['FTP', value.ftpConnected ? 'Connected' : 'Offline'],
['Task failures', value.automation?.recentFailures || 0],
['Replications', value.replication?.snapshots || 0],
['API p95', `${Number(value.api?.p95DurationMs || 0)} ms`],
['Last scan', value.scans?.lastDurationMs == null ? 'not measured' : `${Number(value.scans.lastDurationMs)} ms`],
['Transfer / 24h', operationsBytes(value.transfers?.bytes)],
['Cache hit', `${Math.round(Number(value.transport?.staticCacheHitRatio || 0) * 100)}%`],
['WebSocket disconnects', Number(value.transport?.wsDisconnects || 0)],
];
$('operations-monitoring-summary').innerHTML = metrics.map(([label, result]) => `<div class="operations-metric"><strong>${escHtml(result)}</strong><span>${escHtml(label)}</span></div>`).join('');
}
function renderSessions(result) {
$('operations-session-list').innerHTML = (result.sessions || []).map(session => `<div class="operations-recovery-item" data-session-id="${escHtml(session.id)}"><div class="operations-recovery-info"><strong>${session.current ? 'This browser' : escHtml(session.userAgent || 'Unknown browser')}</strong><span>${escHtml(session.ip || 'unknown')} · last active ${operationsDate(session.lastSeenAt)} · expires ${operationsDate(session.expiresAt)}</span><code>${escHtml(session.id)}</code></div><div class="operations-inline-actions"><button class="btn btn-sm btn-danger operations-session-revoke">${session.current ? 'Sign out' : 'Revoke'}</button></div></div>`).join('') || '<p class="hint">No active sessions.</p>';
}
async function loadOperationsReliability({ focus = '' } = {}) {
if (focus === 'security') {
if (!window.pork.authSessions) {
$('operations-session-list').innerHTML = '<p class="hint">Session management is available only in server mode.</p>';
return;
}
renderSessions(await window.pork.authSessions());
return;
}
if (!window.pork.backupReplicationStatus || !window.pork.authSessions) {
$('operations-replication-status').innerHTML = '<p class="hint">Session and replication management are available only in server mode.</p>';
return;
}
const [replication, monitoring, token, sessions] = await Promise.all([
window.pork.backupReplicationStatus(), window.pork.monitoringSnapshot(), window.pork.monitoringTokenStatus(), window.pork.authSessions(),
]);
renderReplicationStatus(replication);
renderMonitoring(monitoring);
$('operations-monitoring-token-state').textContent = token.configured
? window.t('operations.monitoring.tokenActive', 'Metrics token active · endpoint /metrics')
: window.t('operations.monitoring.tokenDisabled', 'Metrics disabled until a token is created');
renderSessions(sessions);
}
async function loadOperations({ background = false } = {}) {
const [status, storage, snapshots, compatibility, topology] = await Promise.all([
window.pork.operationsStatus(),
window.pork.storageStatus().catch(() => null),
window.pork.recoveryList().catch(() => []),
window.pork.compatibilityGet().catch(() => ({ profile: {}, checks: [] })),
window.pork.consoleStorageTopology?.().catch(() => null) || Promise.resolve(null),
]);
renderOperationsAutomation(status);
renderOperationsStorage(storage);
renderOperationsRecovery(snapshots);
renderOperationsCompatibility(compatibility);
renderStorageTopology(topology);
_operationsLoaded = true;
if (!background) $('operations-health').title = window.tf('operations.status.updated', 'Updated {time}', {
time: new Date().toLocaleTimeString(window.i18n?.current || 'en'),
});
}
async function loadOperationsShadowMount() {
const result = await window.pork.shadowMountStatus();
const config = result.files?.['shadowmount-config'] || {};
const manual = result.files?.['shadowmount-manual'] || {};
$('operations-shadow-config').value = config.text || '';
$('operations-shadow-manual').value = manual.text || '';
for (const [element, file] of [[$('operations-shadow-config-state'), config], [$('operations-shadow-manual-state'), manual]]) {
element.textContent = file.ok ? 'Loaded' : 'Unavailable';
element.className = `badge ${file.ok ? 'badge-green' : 'badge-red'}`;
element.title = file.error || '';
}
renderOperationsShadowMountModel(result.model || { summary: {}, entries: [], unknownStatusLines: [] });
renderOperationsShadowPreflight(result.preflight);
}
function renderOperationsShadowPreflight(preflight) {
const target = $('operations-shadow-preflight');
if (!target) return;
if (!preflight) {
target.innerHTML = '<strong>Configuration preflight unavailable</strong><p>Load a reachable config.ini or run the check again while saving.</p>';
return;
}
const counts = preflight.counts || {};
const headline = !preflight.ok ? 'Save blocked' : preflight.requiresRiskConfirmation ? 'Explicit risk confirmation required' : 'Preflight passed';
const findings = (preflight.findings || []).map(item => `<li><strong>${escHtml(item.severity)}</strong>${item.line ? ` · line ${Number(item.line)}` : ''}${item.key ? ` · ${escHtml(item.key)}` : ''}: ${escHtml(item.message)}</li>`).join('');
target.innerHTML = `<strong>${escHtml(headline)}</strong><p>${Number(counts.invalid || 0)} invalid · ${Number(counts.critical || 0)} critical · ${Number(counts.warning || 0)} warning(s) · ${Number(counts.unknown || 0)} unknown future key(s)</p>${findings ? `<ul>${findings}</ul>` : '<p>No safety issues in the recognized configuration.</p>'}`;
}
function renderOperationsShadowMountModel(model) {
const summary = model.summary || {};
const entries = model.entries || [];
const statusBadge = $('operations-shadow-status-state');
statusBadge.textContent = summary.invalid || summary.unknownStatusLines ? 'review needed' : `${summary.installed || 0} installed`;
statusBadge.className = `badge ${summary.invalid || summary.unknownStatusLines ? 'badge-yellow' : 'badge-green'}`;
$('operations-shadow-summary').innerHTML = [
['Active sources', summary.active || 0],
['Installed', summary.installed || 0],
['Pending', summary.pending || 0],
['Deleted', summary.deleted || 0],
['Experimental', summary.experimental || 0],
['Library matches', summary.libraryMatches || 0],
].map(([label, value]) => `<div class="operations-metric"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
const stateLabel = { installed: 'Installed', pending: 'Pending', deleted: 'Deleted', invalid: 'Invalid' };
const supportLabel = { recommended: 'Recommended', compatibility: 'Compatibility', experimental: 'Experimental', normal: 'Folder', unknown: 'Unknown' };
const rows = entries.map(entry => `<article class="operations-detail-row shadowmount-entry ${entry.warnings?.length ? 'has-warning' : ''}">
<div><strong>${escHtml(entry.titleName || entry.titleId || entry.sourceName || 'Unknown source')}</strong><span>${escHtml(entry.titleId || 'no title ID')} · ${escHtml(entry.format?.label || 'Unknown')} · ${escHtml(supportLabel[entry.format?.support] || entry.format?.support || 'Unknown')}</span><code>${escHtml(entry.sourcePath)}</code>${entry.warnings?.length ? `<small>${entry.warnings.map(escHtml).join(' ')}</small>` : ''}</div>
<span class="badge ${entry.state === 'installed' ? 'badge-green' : entry.state === 'deleted' || entry.state === 'invalid' ? 'badge-red' : 'badge-yellow'}">${escHtml(stateLabel[entry.state] || entry.state)}</span>
</article>`);
if (model.unknownStatusLines?.length) rows.push(`<article class="operations-detail-row has-warning"><div><strong>${model.unknownStatusLines.length} unknown status line(s)</strong><span>Preserved because the upstream format was not recognized.</span><code>${escHtml(model.unknownStatusLines.map(item => `line ${item.line}: ${item.text}`).join('\n'))}</code></div><span class="badge badge-yellow">Raw</span></article>`);
$('operations-shadow-entries').innerHTML = rows.length ? rows.join('') : '<p class="hint">manual.lst and manual.status do not contain game sources yet.</p>';
}
async function loadOperationsPackages() {
const result = await window.pork.packagesStatus();
$('operations-package-services').innerHTML = (result.services || []).map(service => `<div class="operations-service"><span class="operations-service-dot ${service.reachable ? 'online' : ''}"></span><div><strong>${escHtml(service.label)}</strong><span>Port ${service.port}${service.error ? ` · ${escHtml(service.error)}` : ''}</span></div><b>${service.reachable ? 'Online' : 'Offline'}</b></div>`).join('');
return result;
}
function showOperationsJson(element, value) {
element.hidden = false;
element.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
function klogFilters() {
return { severity: $('operations-klog-severity').value, search: $('operations-klog-search').value.trim(), limit: 200 };
}
function renderKlogStatus(status) {
const badge = $('operations-klog-state');
badge.textContent = status.running ? status.connected ? 'Capture active' : 'Connecting…' : 'Stopped';
badge.className = `badge ${status.running ? status.connected ? 'badge-green' : 'badge-yellow' : ''}`;
$('operations-klog-start').disabled = status.running || !status.available;
$('operations-klog-stop').disabled = !status.running;
$('operations-klog-summary').innerHTML = [
['Port', status.port || 3232], ['Status', status.reason || 'unknown'], ['Lines', status.lines || 0],
['Received', operationsBytes(status.bytes)], ['Write contract', status.zeroWriteContract ? '0 bytes' : 'unknown'], ['Payload started', status.payloadLaunchAttempted ? 'yes' : 'no'],
].map(([label, value]) => `<div class="operations-metric"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
}
function renderKlogEntries(result) {
$('operations-klog-list').innerHTML = (result.entries || []).map(entry => `<div class="operations-run">
<div><strong>${escHtml(entry.severity.toUpperCase())}</strong><span>${operationsDate(entry.time)}</span></div>
${operationsStatusBadge(entry.severity === 'error' ? 'failed' : entry.severity === 'warn' ? 'warning' : 'completed')}
<span>${escHtml(String(entry.sessionId || '').slice(0, 8))}</span>
<span class="operations-run-message" title="${escHtml(entry.line)}">${escHtml(entry.line)}</span>
</div>`).join('') || '<p class="hint">No kernel log records match these filters.</p>';
}
async function loadKlog() {
const [status, result] = await Promise.all([window.pork.klogStatus(), window.pork.klogQuery(klogFilters())]);
renderKlogStatus(status);
renderKlogEntries(result);
}
document.querySelectorAll('.operations-tab').forEach(button => button.addEventListener('click', async () => {
const tab = button.dataset.operationsTab;
document.querySelectorAll('.operations-tab').forEach(item => item.classList.toggle('active', item === button));
document.querySelectorAll('.operations-panel').forEach(panel => {
const active = panel.dataset.operationsPanel === tab;
panel.classList.toggle('active', active);
panel.hidden = !active;
});
try {
if (tab === 'shadowmount') await loadOperationsShadowMount();
if (tab === 'storage') await loadStorageTopology();
if (tab === 'packages') await loadOperationsPackages();
if (tab === 'reliability') await loadOperationsReliability();
if (tab === 'audit') await loadOperationsAudit();
if (tab === 'diagnostics') await loadKlog();
} catch (error) { showToast(error.message, 'error'); }
}));
$('operations-refresh')?.addEventListener('click', event => operationsAction('refresh', event.currentTarget, () => loadOperations()).catch(error => showToast(error.message, 'error')));
$('operations-task-list')?.addEventListener('click', async event => {
const row = event.target.closest('.operations-task');
if (!row) return;
const taskId = row.dataset.taskId;
try {
if (event.target.closest('.operations-task-run')) {
await operationsAction(`run-${taskId}`, event.target.closest('button'), () => window.pork.operationsRunTask(taskId));
showToast('Task completed.', 'ok');
await loadOperations();
}
if (event.target.closest('.operations-task-save')) {
await operationsAction(`save-${taskId}`, event.target.closest('button'), () => window.pork.operationsConfigureTask(taskId, {
enabled: row.querySelector('.operations-task-enabled').checked,
intervalMinutes: Number(row.querySelector('.operations-task-interval').value),
}));
showToast('Schedule saved.', 'ok');
await loadOperations();
}
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-storage-run')?.addEventListener('click', async event => {
try {
const report = await operationsAction('storage', event.currentTarget, () => window.pork.storageAudit());
renderOperationsStorage(report);
showToast('Storage audit completed. Nothing was deleted.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-storage-topology-refresh')?.addEventListener('click', event => operationsAction('storage-topology', event.currentTarget, loadStorageTopology).catch(error => showToast(error.message, 'error')));
$('operations-recovery-create')?.addEventListener('click', async event => {
const label = await showPrompt('Name for this restore point:', 'Manual restore point');
if (label == null) return;
try {
await operationsAction('recovery-create', event.currentTarget, () => window.pork.recoveryCreate({ label, includeRemote: true }));
renderOperationsRecovery(await window.pork.recoveryList());
showToast('Restore point saved safely.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-recovery-import')?.addEventListener('click', async event => {
try {
const result = await operationsAction('recovery-import', event.currentTarget, () => window.pork.recoveryImport());
if (!result) return;
renderOperationsRecovery(await window.pork.recoveryList());
showToast(`Archive imported as ${result.id} and verified with SHA-256. Nothing was restored.`, 'ok', 9000);
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('operations-recovery-list')?.addEventListener('click', async event => {
const row = event.target.closest('.operations-recovery-item');
if (!row) return;
const id = row.dataset.snapshotId;
try {
if (event.target.closest('.operations-recovery-verify')) {
const verified = await operationsAction(`verify-${id}`, event.target.closest('button'), () => window.pork.recoveryVerify(id));
showToast(`${verified.files.length} files verified (${verified.integrity}). Nothing was restored.`, 'ok');
}
if (event.target.closest('.operations-recovery-export')) await operationsAction(`export-${id}`, event.target.closest('button'), () => window.pork.recoveryExport(id));
if (event.target.closest('.operations-recovery-restore')) {
if (!await showExactConfirmation('This restores the database, settings and available PS5 configuration. A new safety recovery point is created first.', 'RESTORE')) return;
await operationsAction(`restore-${id}`, event.target.closest('button'), () => window.pork.recoveryRestore(id, true));
showToast('Restore completed. Refresh the page if settings are not immediately visible.', 'ok', 8000);
await loadOperations();
}
if (event.target.closest('.operations-recovery-delete')) {
if (!await showExactConfirmation('Permanently delete this recovery point?', 'DELETE')) return;
await operationsAction(`delete-${id}`, event.target.closest('button'), () => window.pork.recoveryDelete(id));
renderOperationsRecovery(await window.pork.recoveryList());
}
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-replication-refresh')?.addEventListener('click', event => operationsAction('replication-refresh', event.currentTarget, loadOperationsReliability).catch(error => showToast(error.message, 'error')));
$('operations-replication-run')?.addEventListener('click', async event => {
const includeGameBackups = $('operations-replication-games').checked;
const warning = includeGameBackups ? '\n\nFull game backups are selected and may use a large amount of storage.' : '';
if (!await showConfirm(`Create a new hash-verified snapshot on the secondary backup target? Nothing is removed from the source.${warning}`)) return;
try {
const label = `Manual replication ${new Date().toLocaleString(window.i18n?.current || 'en')}`;
await operationsAction('replication-run', event.currentTarget, () => window.pork.backupReplicationRun({ includeGameBackups, label }));
await loadOperationsReliability();
showToast('Replication completed and every copied file was verified.', 'ok', 8000);
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('operations-replication-list')?.addEventListener('click', async event => {
const row = event.target.closest('[data-replication-id]');
if (!row) return;
try {
const button = event.target.closest('button');
if (button?.classList.contains('operations-replication-verify')) {
await operationsAction(`replication-verify-${row.dataset.replicationId}`, button, () => window.pork.backupReplicationVerify(row.dataset.replicationId));
showToast('Snapshot fully verified with SHA-256.', 'ok');
} else if (button?.classList.contains('operations-replication-drill')) {
if (!await showConfirm('Copy every snapshot file to a temporary local destination, verify its hash, then remove the temporary copies? The PS5 is not modified.')) return;
const result = await operationsAction(`replication-drill-${row.dataset.replicationId}`, button, () => window.pork.backupReplicationRestoreDrill(row.dataset.replicationId));
showToast(`Restore drill succeeded: ${result.files} files, PS5 unchanged.`, 'ok', 8000);
} else return;
await loadOperationsReliability();
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('operations-replication-retention')?.addEventListener('submit', async event => {
event.preventDefault();
try {
const preview = await operationsAction('replication-retention', event.submitter, () => window.pork.backupReplicationRetentionSave({ enabled: $('operations-replication-retention-enabled').checked, maxSnapshots: Number($('operations-replication-retention-count').value), maxAgeDays: Number($('operations-replication-retention-days').value) }));
showToast(`Retention saved. ${preview.removable?.length || 0} snapshot(s) will be eligible after the next successful replication.`, 'ok', 9000);
await loadOperationsReliability();
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-monitoring-refresh')?.addEventListener('click', event => operationsAction('monitoring-refresh', event.currentTarget, async () => renderMonitoring(await window.pork.monitoringSnapshot())).catch(error => showToast(error.message, 'error')));
$('operations-monitoring-token')?.addEventListener('click', async event => {
if (!await showConfirm('Create a new metrics token? An existing token becomes invalid immediately.')) return;
try {
const result = await operationsAction('metrics-token', event.currentTarget, () => window.pork.monitoringTokenRotate());
$('operations-monitoring-token-state').textContent = 'New token active. Save it now; Chimera will not show it again.';
showOperationsJson($('operations-monitoring-token-output'), `Endpoint: ${location.origin}${result.endpoint}\nAuthorization: Bearer ${result.token}\n\nThis token is shown only once.`);
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-sessions-refresh')?.addEventListener('click', event => operationsAction('sessions-refresh', event.currentTarget, async () => renderSessions(await window.pork.authSessions())).catch(error => showToast(error.message, 'error')));
$('operations-session-list')?.addEventListener('click', async event => {
const row = event.target.closest('[data-session-id]');
if (!row || !event.target.closest('.operations-session-revoke') || !await showExactConfirmation('Revoke this session immediately?', 'REVOKE')) return;
try {
const result = await window.pork.authSessionRevoke(row.dataset.sessionId);
if (result.current) { location.replace('/server-login.html'); return; }
renderSessions(await window.pork.authSessions());
showToast('Session revoked.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-password-form')?.addEventListener('submit', async event => {
event.preventDefault();
const currentPassword = $('operations-password-current').value;
const newPassword = $('operations-password-new').value;
if (newPassword !== $('operations-password-repeat').value) { showToast('The new passwords do not match.', 'error'); return; }
if (!await showConfirm('Change the password and revoke all other active sessions?')) return;
try {
const result = await operationsAction('password-change', event.submitter, () => window.pork.authPasswordChange(currentPassword, newPassword));
event.currentTarget.reset();
showToast(`Password changed; ${result.otherSessionsRevoked} other session(s) revoked.`, 'ok', 8000);
renderSessions(await window.pork.authSessions());
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-profile-save')?.addEventListener('click', async event => {
try {
const result = await operationsAction('profile', event.currentTarget, () => window.pork.compatibilitySet({
firmware: $('operations-profile-firmware').value,
hen: $('operations-profile-hen').value,
henVersion: $('operations-profile-hen-version').value,
kstuffVersion: $('operations-profile-kstuff').value,
loader: $('operations-profile-loader').value,
notes: $('operations-profile-notes').value,
}));
renderOperationsCompatibility(result);
showToast('PS5 profile saved.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-shadow-reload')?.addEventListener('click', event => operationsAction('shadow-load', event.currentTarget, loadOperationsShadowMount).catch(error => showToast(error.message, 'error')));
$('operations-shadow-propose')?.addEventListener('click', async event => {
try {
const proposal = await operationsAction('shadow-proposal', event.currentTarget, () => window.pork.shadowMountProposal());
$('operations-shadow-manual').value = proposal.proposedManual;
$('operations-shadow-preflight').innerHTML = `<strong>Library proposal · ${proposal.additions.length} addition(s)</strong><p>${proposal.unchanged.length} unchanged · ${proposal.unresolved.length} unresolved. Nothing was written to the PS5.</p>${proposal.unresolved.length ? `<ul>${proposal.unresolved.slice(0, 20).map(item => `<li>${escHtml(item.gameId)} · ${escHtml(item.reason)}</li>`).join('')}</ul>` : ''}`;
showToast(proposal.additions.length ? 'ShadowMount proposal loaded for review; save is still separate.' : 'No new PS5-visible paths were found.', proposal.unresolved.length ? 'info' : 'ok', 9000);
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('operations-shadow-save')?.addEventListener('click', async event => {
try {
const config = $('operations-shadow-config').value;
const manual = $('operations-shadow-manual').value;
const preflight = await operationsAction('shadow-preflight', event.currentTarget, () => window.pork.shadowMountPreflight(config));
renderOperationsShadowPreflight(preflight);
if (!preflight.ok) throw new Error('The configuration contains invalid values. Resolve the preflight findings before saving.');
let confirmation = null;
if (preflight.requiresRiskConfirmation) {
confirmation = await showExactConfirmation('This configuration activates critical ShadowMount options. Review the preflight carefully.', preflight.confirmationPhrase);
if (!confirmation) return;
} else if (!await showConfirm('Save the ShadowMount configuration? Chimera creates a recovery point first.')) return;
const result = await operationsAction('shadow-save', event.currentTarget, () => window.pork.shadowMountSave({ config, manual, confirmation }));
showToast(`ShadowMount saved. Safety restore point: ${result.snapshotId}`, 'ok', 8000);
await loadOperationsShadowMount();
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-shadow-logs')?.addEventListener('click', async event => {
try {
const result = await operationsAction('shadow-logs', event.currentTarget, () => window.pork.shadowMountLogs());
if (!result.ok) throw new Error(result.error);
showOperationsJson($('operations-shadow-output'), result.text || 'debug.log is empty.');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-package-probe')?.addEventListener('click', event => operationsAction('package-probe', event.currentTarget, loadOperationsPackages).catch(error => showToast(error.message, 'error')));
$('operations-package-list')?.addEventListener('click', async event => {
try { showOperationsJson($('operations-package-output'), await operationsAction('package-list', event.currentTarget, () => window.pork.packagesList())); }
catch (error) { showToast(error.message, 'error'); }
});
$('operations-package-progress')?.addEventListener('click', async event => {
try { showOperationsJson($('operations-package-output'), await operationsAction('package-progress', event.currentTarget, () => window.pork.packagesProgress())); }
catch (error) { showToast(error.message, 'error'); }
});
$('operations-package-install')?.addEventListener('click', async event => {
const url = $('operations-package-url').value.trim();
if (!url || !await showConfirm(`Send this HTTPS URL to etaHEN DPI?\n\n${url}`)) return;
try {
const result = await operationsAction('package-install', event.currentTarget, () => window.pork.packagesInstallUrl(url));
showOperationsJson($('operations-package-output'), result);
showToast('Package URL sent to etaHEN DPI.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-diagnostics-run')?.addEventListener('click', async event => {
try {
const result = await operationsAction('diagnostics', event.currentTarget, () => window.pork.diagnosticsSummary());
const services = result.packages?.services || [];
const build = result.chimera?.build || {};
$('operations-diagnostics-summary').innerHTML = [
['Chimera', result.chimera?.version || '—'],
['Build', build.id || 'development'],
['Revision', String(build.revision || 'development').slice(0, 12)],
['Built', build.builtAt ? operationsDate(build.builtAt) : '—'],
['Uptime', `${Math.round(Number(result.chimera?.uptimeSeconds || 0) / 60)} min.`],
['FTP', result.ps5?.ftpConnected ? 'Connected' : result.ps5?.ftpReconnectAvailable ? 'Reconnect available' : 'Not connected'],
['PS5 services', `${services.filter(item => item.reachable).length}/${services.length} online`],
].map(([label, value]) => `<div class="operations-metric"><strong>${escHtml(value)}</strong><span>${escHtml(label)}</span></div>`).join('');
showOperationsJson($('operations-diagnostics-log'), result.recentLogs || []);
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-diagnostics-export')?.addEventListener('click', event => operationsAction('diagnostics-export', event.currentTarget, () => window.pork.diagnosticsExport()).then(() => showToast('Geschoonde supportbundel aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error')));
$('operations-audit-filter')?.addEventListener('submit', event => {
event.preventDefault();
operationsAction('audit-filter', event.submitter, loadOperationsAudit).catch(error => showToast(error.message, 'error'));
});
$('operations-audit-refresh')?.addEventListener('click', event => operationsAction('audit-refresh', event.currentTarget, loadOperationsAudit).catch(error => showToast(error.message, 'error')));
$('operations-audit-export')?.addEventListener('click', event => operationsAction('audit-export', event.currentTarget, () => window.pork.auditExport(operationsAuditFilters())).then(() => showToast('Gefilterde auditexport aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error')));
$('operations-klog-start')?.addEventListener('click', async event => {
try {
const status = await operationsAction('klog-start', event.currentTarget, () => window.pork.klogStart({ durationSeconds: Number($('operations-klog-duration').value) }));
renderKlogStatus(status);
showToast('Passive capture started; Chimera writes nothing to the PS5.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-klog-stop')?.addEventListener('click', async event => {
try {
renderKlogStatus(await operationsAction('klog-stop', event.currentTarget, () => window.pork.klogStop()));
await loadKlog();
} catch (error) { showToast(error.message, 'error'); }
});
$('operations-klog-refresh')?.addEventListener('click', event => operationsAction('klog-refresh', event.currentTarget, loadKlog).catch(error => showToast(error.message, 'error')));
$('operations-klog-export')?.addEventListener('click', event => operationsAction('klog-export', event.currentTarget, () => window.pork.klogExport(klogFilters())).then(() => showToast('Geschoonde kernellogexport aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error')));
window.pork?.on?.('operations:task-update', () => {
if (document.querySelector('#page-operations.active')) loadOperations().catch(() => {});
});
window.pork?.on?.('klog:status', status => renderKlogStatus(status));
window.addEventListener('chimera:language-changed', () => {
if (!document.querySelector('#page-operations.active [data-operations-panel="storage"]:not([hidden])')) return;
renderOperationsStorage(_operationsStorageReport);
if (_operationsStorageTopology) renderStorageTopology(_operationsStorageTopology);
});
+29
View File
@@ -0,0 +1,29 @@
'use strict';
(function () {
let catalog = { sets: [], stats: {} };
const esc = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
const tr = (key, fallback) => window.t?.(key, fallback) || fallback;
if (!document.querySelector('link[data-package-library-style]')) {
const link = document.createElement('link');
Object.assign(link, { rel: 'stylesheet', href: 'styles/package-library.css' });
link.dataset.packageLibraryStyle = 'true';
document.head.appendChild(link);
}
const componentHtml = component => `<li><div><strong>${esc(component.type)}</strong><span>${esc(component.componentId)} ${tr(`packageLibrary.${component.required ? 'required' : 'optional'}`, component.required ? 'required' : 'optional')}</span></div><div><span>v${esc(component.version || '?')} ${esc(component.region || '')}</span><code title="SHA-256 ${esc(component.sha256)}">${esc(component.sha256)}</code></div></li>`;
const recipeHtml = item => `<li><strong>${esc(item.label || item.recipeKey)}</strong><span>v${esc(item.version)} ${esc(item.state)} ${esc(item.recipeSha256.slice(0, 12))}...</span></li>`;
function setHtml(set) {
const recipes = set.recipes.length ? set.recipes.map(recipeHtml).join('') : `<li class="package-library-empty">${esc(tr('packageLibrary.noRecipes', 'No recipes yet'))}</li>`;
return `<article class="package-library-set"><header><div><span>${esc(set.title.externalTitleId)}</span><h3>${esc(set.title.name)}</h3></div><div><strong>${esc(set.label || set.setKey)}</strong><small>${esc(set.platform)} ${esc(set.region || '')} ${esc(set.confidence)} ${esc(set.source)}</small></div></header><div class="package-library-columns"><section><h4>${esc(tr('packageLibrary.components', 'Components'))} (${set.components.length})</h4><ul class="package-component-list">${set.components.map(componentHtml).join('')}</ul></section><section><h4>${esc(tr('packageLibrary.recipes', 'Recipes'))} (${set.recipes.length})</h4><ul class="package-recipe-list">${recipes}</ul></section></div></article>`;
}
function render() {
const host = document.getElementById('package-library-content');
if (!host) return;
const query = document.getElementById('package-library-search')?.value.trim().toLowerCase();
const sets = catalog.sets.filter(set => !query || JSON.stringify(set).toLowerCase().includes(query));
host.innerHTML = sets.length ? `<div class="package-library-list">${sets.map(setHtml).join('')}</div>` : `<div class="operations-empty"><strong>${esc(tr('packageLibrary.empty', 'No package sets match'))}</strong><span>${esc(tr('packageLibrary.emptyDetail', 'Import or adjust the search. Package inventory never implies installation.'))}</span></div>`;
const stats = document.getElementById('package-library-stats');
if (stats) stats.textContent = [['titles', 'titles'], ['sets', 'sets'], ['components', 'componentsLower'], ['recipes', 'recipesLower']].map(([field, key]) => `${catalog.stats[field] || 0} ${tr(`packageLibrary.${key}`, field)}`).join(' / ');
}
window.loadPackages = async () => { catalog = await window.pork.packageCatalog(); render(); };
document.getElementById('package-library-search')?.addEventListener('input', render);
})();
+636
View File
@@ -0,0 +1,636 @@
// ── Payload Manager ───────────────────────────────────────────────────────────
function renderPs5PayloadManagerObservability(snapshot) {
const root = $('pldmgr-observability');
if (!root || !snapshot) return;
const autoload = snapshot.autoload || {};
const processes = snapshot.processes || {};
const logs = snapshot.logs || {};
const stateLabels = {
idle: window.t('payload.state.idle', 'idle'),
countdown: window.t('payload.state.countdown', 'countdown'),
running: window.t('payload.state.running', 'running'),
done: window.t('payload.state.done', 'done'),
unknown: window.t('common.unknown', 'unknown'),
};
const stateClasses = { done: 'badge-green', running: 'badge-blue', countdown: 'badge-yellow', idle: '', unknown: 'badge-red' };
const state = autoload.supported ? (autoload.state || 'unknown') : 'unknown';
const daemonItems = (processes.items || []).filter(item => item.daemon).slice(0, 40);
root.hidden = false;
$('pldmgr-autoload-metric').textContent = autoload.supported ? `${autoload.done || 0}/${autoload.total || autoload.list?.length || 0}` : 'n/b';
$('pldmgr-process-metric').textContent = processes.supported ? String(processes.total || 0) : 'n/b';
$('pldmgr-memory-metric').textContent = processes.supported ? `${Number(processes.memoryMiB || 0).toLocaleString(window.i18n?.current || 'en', { maximumFractionDigits: 1 })} MiB` : 'n/a';
$('pldmgr-log-metric').textContent = logs.supported ? String(logs.total || 0) : 'n/b';
$('pldmgr-autoload-state').textContent = stateLabels[state] || state;
$('pldmgr-autoload-state').className = `badge ${stateClasses[state] || ''}`.trim();
const detail = [];
if (autoload.supported) {
if (autoload.current) detail.push(window.tf('payload.current', 'Current: {value}', { value: autoload.current }));
if (autoload.remaining > 0) detail.push(window.tf('payload.startsIn', 'starts in {seconds}s', { seconds: autoload.remaining }));
if (autoload.list?.length) detail.push(window.tf('payload.itemsConfigured', '{count} item(s) configured', { count: autoload.list.length }));
if (autoload.scanUsbPayloads) detail.push(window.t('payload.usbScanActive', 'USB scan active'));
} else detail.push(autoload.unavailable || window.t('payload.endpointUnsupported', 'This endpoint is not supported by this Payload Manager version.'));
$('pldmgr-autoload-detail').textContent = detail.join(' · ');
$('pldmgr-process-list').innerHTML = processes.supported
? (daemonItems.length ? daemonItems.map(item => `<div class="payload-process-row"><div><strong>${escHtml(item.name)}</strong><span>PID ${item.pid}</span></div><b>${Number(item.memoryMiB || 0).toLocaleString(window.i18n?.current || 'en', { maximumFractionDigits: 1 })} MiB</b></div>`).join('') : `<p class="hint">${escHtml(window.t('payload.noDaemons', 'No separate payload daemons detected.'))}</p>`)
: `<p class="hint">${escHtml(processes.unavailable || window.t('payload.processesUnsupported', 'Processes are unavailable in this Payload Manager version.'))}</p>`;
$('pldmgr-log-output').textContent = logs.supported && logs.lines?.length
? `${logs.truncated ? `[${window.t('payload.latestLogsOnly', 'latest lines only')}]\n` : ''}${logs.lines.join('\n')}`
: (logs.unavailable || window.t('payload.noLogs', 'No log lines yet.'));
$('pldmgr-observability-note').textContent = window.tf(
'payload.snapshotNote',
'Read-only snapshot from {time}. Chimera never terminates a process automatically.',
{ time: new Date(snapshot.checkedAt || Date.now()).toLocaleTimeString(window.i18n?.current || 'en') },
);
}
function renderPs5PayloadManagerInventory(inventory) {
const list = $('pldmgr-installed-list');
if (!list) return;
const payloads = (inventory?.payloads || []).slice(0, 200);
list.innerHTML = payloads.length ? payloads.map(item => `<div class="payload-process-row"><div><strong>${escHtml(item.name || item.filename)}</strong><span>${escHtml(item.version || window.t('payload.unknownVersion', 'unknown version'))}${item.checksumVerified ? ` · ${escHtml(window.t('payload.checksumKnown', 'checksum known'))}` : ''}</span></div><button class="btn btn-sm pldmgr-safe-launch" data-payload-path="${escHtml(item.path)}">${escHtml(window.t('payload.preflightStart', 'Preflight & start'))}</button></div>`).join('') : `<p class="hint">${escHtml(window.t('payload.noneInstalled', 'No installed payloads reported.'))}</p>`;
}
async function refreshPs5PayloadManagerObservability({ quiet = false } = {}) {
if (!window.pork.pldmgrObservability) return null;
try {
const result = await window.pork.pldmgrObservability();
renderPs5PayloadManagerObservability(result);
return result;
} catch (error) {
if (!quiet) showToast(error.message, 'error');
return null;
}
}
async function connectPs5PayloadManager({ quiet = false, persistPort = false } = {}) {
const status = $('pldmgr-status');
const version = $('pldmgr-version');
const frame = $('pldmgr-frame');
if (!window.pork.pldmgrProbe || !status || !frame) return;
if (!quiet) status.textContent = window.t('payload.connectingPs5', 'Connecting to PS5…');
try {
const port = Number($('pldmgr-port')?.value || 8084);
if (persistPort) await window.pork.setSettings({ payloadManagerPort: port });
const settings = await window.pork.getSettings();
if (!settings.ftpHost) throw new Error(window.t('payload.configureIp', 'Configure the PS5 IP address first.'));
const [result] = await Promise.all([
window.pork.pldmgrProbe(),
refreshPs5PayloadManagerObservability({ quiet: true }),
]);
const versionValue = typeof result.version === 'string'
? result.version
: (result.version?.version || result.version?.menu_version || 'online');
version.textContent = String(versionValue).replace(/^"|"$/g, '') || 'online';
const payloadCount = Number(result.inventory?.total || 0);
renderPs5PayloadManagerInventory(result.inventory);
const sourceCount = Number(result.sources?.total || 0);
const repositoryCount = Number(result.repository?.total || 0);
const autoloadRaw = result.autoload?.list || result.autoload?.AUTOLOAD_LIST || '';
const autoloadCount = Array.isArray(autoloadRaw)
? autoloadRaw.length
: String(autoloadRaw || '').split(',').filter(Boolean).length;
status.textContent = window.tf(
'payload.connectedSummary',
'Connected · {payloads} installed · {autoload} autoload · {sources} sources · {available} available',
{ payloads: payloadCount, autoload: autoloadCount, sources: sourceCount, available: repositoryCount },
);
const embedUrl = await window.pork.pldmgrEmbedUrl();
frame.dataset.url = embedUrl;
if (!quiet || frame.dataset.userOpened === 'true') {
frame.src = embedUrl;
frame.hidden = false;
} else {
frame.hidden = true;
}
} catch (error) {
version.textContent = 'offline';
frame.hidden = true;
if ($('pldmgr-observability')) $('pldmgr-observability').hidden = true;
status.textContent = `${window.t('payload.unreachable', 'Unavailable')}: ${error.message}`;
}
}
$('btn-pldmgr-refresh')?.addEventListener('click', () => connectPs5PayloadManager());
$('btn-pldmgr-logs-refresh')?.addEventListener('click', () => refreshPs5PayloadManagerObservability());
$('pldmgr-installed-list')?.addEventListener('click', async event => {
const button = event.target.closest('.pldmgr-safe-launch');
if (!button) return;
const payloadPath = button.dataset.payloadPath;
button.disabled = true;
try {
const preflight = await window.pork.pldmgrLaunchPreflight(payloadPath);
let confirmConflict = false;
if (preflight.conflict) {
const conflictConfirmation = await showExactConfirmation(
`${preflight.warning}\n\nActive: ${preflight.active.map(item => `${item.name} (PID ${item.pid})`).join(', ')}\n\nA duplicate instance can make the console unstable.`,
'START PAYLOAD',
);
if (!conflictConfirmation) return;
confirmConflict = true;
} else if (!await showExactConfirmation(`Start ${preflight.selected} on the PS5?`, 'START PAYLOAD')) return;
await window.pork.pldmgrLaunch(payloadPath, confirmConflict, 'START PAYLOAD');
showToast(`${preflight.selected} was sent to Payload Manager.`, 'ok');
setTimeout(() => refreshPs5PayloadManagerObservability({ quiet: true }), 1_000);
} catch (error) { showToast(error.message, 'error'); }
finally { button.disabled = false; }
});
$('pldmgr-port')?.addEventListener('change', () => connectPs5PayloadManager({ persistPort: true }));
$('btn-pldmgr-open')?.addEventListener('click', async () => {
try {
const frame = $('pldmgr-frame');
const url = frame?.dataset.url || await window.pork.pldmgrEmbedUrl();
if (window.pork.isWebMode && String(url).startsWith('/')) {
frame.dataset.userOpened = 'true';
frame.src = url;
frame.hidden = false;
frame.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else await window.pork.openShell(url);
} catch (error) { setStatus(error.message, 'error'); }
});
setInterval(() => {
const page = $('page-jailbreak');
if (page?.classList.contains('active') && $('pldmgr-version')?.textContent !== 'offline') {
refreshPs5PayloadManagerObservability({ quiet: true });
}
}, 15_000);
async function loadPs5ServiceMatrix() {
const grid = $('service-matrix-grid');
const summary = $('service-matrix-summary');
const button = $('btn-service-matrix-refresh');
if (!grid || !window.pork.ps5ServiceMatrix) return;
if (button) button.disabled = true;
summary.textContent = 'controleren…';
try {
const result = await window.pork.ps5ServiceMatrix();
summary.textContent = `${result.online}/${result.total} online`;
grid.innerHTML = result.services.map(service => `
<div class="service-matrix-item ${service.online ? 'online' : 'offline'}">
<div class="service-matrix-head">
<span class="service-matrix-dot"></span>
<span class="service-matrix-name">${escHtml(service.name)}</span>
<span class="service-matrix-port">:${service.port}${service.online && service.latencyMs != null ? ` · ${service.latencyMs} ms` : ''}</span>
</div>
<p>${service.online ? `${escHtml(service.section)} is reachable.` : escHtml(service.hint)}</p>
</div>
`).join('');
} catch (error) {
summary.textContent = 'check failed';
grid.innerHTML = `<p class="hint">${escHtml(error.message)}</p>`;
} finally { if (button) button.disabled = false; }
}
$('btn-service-matrix-refresh')?.addEventListener('click', loadPs5ServiceMatrix);
async function renderSettingsPayloadSources() {
const sources = await window.pork.payloadSourcesList();
const cont = $('settings-payload-sources');
if (!sources.length) {
cont.innerHTML = '<p class="hint" style="margin:6px 0">No sources yet. Add one below.</p>';
return;
}
const expertMode = document.body.classList.contains('chimera-expert-mode');
const visibleSources = sources.filter(source =>
expertMode || payloadCatalogGroup({ source, files: [] }) !== 'expert'
);
const hiddenCount = sources.length - visibleSources.length;
const hiddenNotice = hiddenCount > 0
? `<p class="hint payload-expert-hidden">${escHtml(window.tf(
'payload.settings.expertHidden',
'{count} expert source(s) hidden. Enable Expert tools to manage them.',
{ count: hiddenCount },
))}</p>`
: '';
cont.innerHTML = hiddenNotice + visibleSources.map(s => `
<div class="payload-settings-source-row" data-source-id="${s.id}">
<input type="checkbox" class="source-toggle" ${s.enabled ? 'checked' : ''}/>
<span class="payload-settings-source-name">${escHtml(s.name)}</span>
<span class="payload-settings-source-url" title="${escHtml(s.github_url)}">${escHtml(s.github_url)}</span>
${s.latest_tag ? `<span class="payload-tag-chip">${escHtml(s.latest_tag)}</span>` : ''}
${s.built_in ? `<span class="payload-trust-chip payload-trust-official">${escHtml(window.t('payload.builtIn', 'built-in'))}</span>` : '<button class="btn btn-sm btn-source-remove" style="margin-left:auto">&#10005;</button>'}
</div>
`).join('');
}
window.addEventListener('chimera:expert-mode-changed', () => {
if ($('page-settings')?.classList.contains('active')) renderSettingsPayloadSources().catch(error => showToast(error.message, 'error'));
});
function payloadStatusBadge(file) {
if (file.local_hash && file.local_version && file.version && file.local_version !== file.version) {
return `<span class="badge badge-yellow">&#8595; ${escHtml(window.t('payload.status.update', 'Update available'))}</span>`;
}
if (file.local_hash && file.remote_hash) {
if (file.local_hash === file.remote_hash) return `<span class="badge badge-green">&#10003; ${escHtml(window.t('payload.status.current', 'Up to date'))}</span>`;
return `<span class="badge badge-yellow">&#8595; ${escHtml(window.t('payload.status.update', 'Update available'))}</span>`;
}
if (file.local_hash) return `<span class="badge badge-dim">${escHtml(window.t('payload.status.downloadedUnverified', 'Downloaded (no checksum)'))}</span>`;
if (file.remote_hash) return `<span class="badge badge-dim">${escHtml(window.t('payload.status.notDownloaded', 'Not downloaded'))}</span>`;
return '<span class="badge badge-dim">—</span>';
}
function payloadActionBtn(file, source_id) {
const upToDate = !!(file.local_hash && (
(file.remote_hash && file.local_hash === file.remote_hash) ||
(!file.remote_hash && file.local_version && file.version && file.local_version === file.version)
));
const hasUpdate = !!(file.local_hash && (
(file.remote_hash && file.local_hash !== file.remote_hash) ||
(file.local_version && file.version && file.local_version !== file.version)
));
const dlLabel = hasUpdate
? `&#8595;&nbsp;${escHtml(window.t('payload.action.update', 'Update'))}`
: `&#8595;&nbsp;${escHtml(window.t('payload.action.download', 'Download'))}`;
const dlCls = hasUpdate ? 'btn btn-sm btn-teal btn-payload-download' : 'btn btn-sm btn-payload-download';
const dlBtn = !upToDate
? `<button class="${dlCls}"
data-source-id="${source_id}"
data-asset-name="${escHtml(file.asset_name)}"
data-asset-url="${escHtml(file.asset_url || '')}"
data-version="${escHtml(file.version || '')}">${dlLabel}</button>`
: '';
const pushBtn = file.local_path
? `<button class="btn btn-sm btn-payload-push-remote"
data-local-path="${escHtml(file.local_path)}"
data-filename="${escHtml(file.asset_name)}"
title="Upload to PS5 via FTP transfer queue">&#8593;&nbsp;PS5</button>`
: '';
return (dlBtn || pushBtn)
? `<span style="display:inline-flex;gap:4px">${dlBtn}${pushBtn}</span>`
: '';
}
function payloadHasUpdate(file) {
return Boolean(file.local_hash && (
(file.remote_hash && file.local_hash !== file.remote_hash) ||
(file.local_version && file.version && file.local_version !== file.version)
));
}
function payloadCatalogGroup({ source, files }) {
const fingerprint = `${source.name || ''} ${source.category || ''} ${source.description || ''} ${(files || []).map(file => file.asset_name).join(' ')}`.toLowerCase();
const expertCategory = ['kernel', 'debug', 'linux'].includes(String(source.category || '').toLowerCase());
if (expertCategory || /(kernel|kstuff|kpatch|exploit|debugger|gdb|elf\s*(?:ldr|loader)|binloader|shell(?:code)?|jailbreak|linux\s+loader)/.test(fingerprint)) return 'expert';
if ((files || []).some(payloadHasUpdate)) return 'updates';
if (source.trust === 'custom' || /(experimental|prototype|research|legacy)/.test(fingerprint)) return 'experimental';
if (source.trust === 'official' && (files || []).some(file => file.remote_hash)) return 'recommended';
return 'compatible';
}
const PAYLOAD_GROUPS = [
{ id: 'updates', label: 'Updates', detail: 'Downloaded payloads with a newer or changed upstream asset.' },
{ id: 'recommended', label: 'Recommended', detail: 'Reviewed official sources with a published checksum.' },
{ id: 'compatible', label: 'Compatible', detail: 'Normal payloads that still require a manual compatibility check.' },
{ id: 'experimental', label: 'Experimental', detail: 'Custom, research or incomplete sources. Review before downloading.' },
{ id: 'expert', label: 'Expert', detail: 'Low-level payloads that can affect console stability.' },
];
function payloadSourceMarkup({ source, files, error }, forceOpen = false) {
const usableFiles = (files || []).filter(file =>
/\.(bin|elf|js)$/i.test(file.asset_name) &&
!/sha256|checksums|sha2|sums/i.test(file.asset_name)
);
const tag = source.latest_tag ? `<span class="payload-tag-chip">${escHtml(source.latest_tag)}</span>` : '';
const trust = escHtml(source.trust || 'custom');
const checked = source.last_checked
? `<span class="hint payload-checked-at">${escHtml(window.tf('payload.checkedAt', 'checked {date}', { date: new Date(source.last_checked).toLocaleString() }))}</span>`
: '';
const errRow = error ? `<p class="hint payload-source-error">${escHtml(error)}</p>` : '';
const rows = usableFiles.map(file => `
<tr>
<td class="payload-asset-name">${escHtml(file.asset_name)}</td>
<td>${payloadStatusBadge(file)}</td>
<td class="payload-hash-snippet">${file.remote_hash ? file.remote_hash.slice(0, 12) + '…' : '—'}</td>
<td class="payload-asset-meta">${escHtml(file.version || '—')} · ${file.asset_size ? fmt(file.asset_size) : escHtml(window.t('payload.unknownSize', 'unknown size'))}</td>
<td>${payloadActionBtn(file, source.id)}</td>
</tr>
`).join('');
return `
<details class="payload-check-source" ${forceOpen ? 'open' : ''}>
<summary class="payload-check-source-header">
<span class="payload-check-source-name">${escHtml(source.name)}</span>
<span class="payload-source-count">${usableFiles.length}</span>
${tag} ${checked}
<span class="payload-source-meta">
<span class="payload-trust-chip payload-trust-${trust}">${trust}</span>
<span class="payload-tag-chip">${escHtml(source.category || 'other')}</span>
</span>
</summary>
<div class="payload-source-body">
${source.description ? `<p class="payload-source-description">${escHtml(source.description)}</p>` : ''}
${errRow}
${rows ? `<div class="payload-asset-scroll"><table class="payload-asset-table"><tbody>${rows}</tbody></table></div>` : `<p class="hint">${escHtml(window.t('payload.noAssets', 'No assets found.'))}</p>`}
</div>
</details>`;
}
function renderPayloadSourceCheckList(results) {
const cont = $('payload-sources-check-list');
if (!results || !results.length) {
cont.innerHTML = `<div class="library-empty"><span class="library-empty-icon">⌁</span><strong>${escHtml(window.t('payload.empty.title', 'No payload sources match'))}</strong><span>${escHtml(window.t('payload.empty.detail', 'Adjust the filters or add a source in Settings.'))}</span></div>`;
return;
}
const queryActive = Boolean(String($('payload-catalog-search')?.value || '').trim());
const grouped = Object.groupBy
? Object.groupBy(results, payloadCatalogGroup)
: results.reduce((groups, item) => {
const id = payloadCatalogGroup(item);
(groups[id] ||= []).push(item);
return groups;
}, {});
cont.innerHTML = PAYLOAD_GROUPS.map(group => {
const items = grouped[group.id] || [];
if (!items.length) return '';
const label = window.t(`payload.groups.${group.id}.label`, group.label);
const detail = window.t(`payload.groups.${group.id}.detail`, group.detail);
const risk = group.id === 'expert' || group.id === 'experimental' ? group.id : 'standard';
return `<details class="payload-catalog-group payload-catalog-group-${risk}" ${queryActive || group.id === 'updates' || group.id === 'recommended' ? 'open' : ''}>
<summary><span><strong>${escHtml(label)}</strong><small>${escHtml(detail)}</small></span><b>${items.length}</b></summary>
<div class="payload-catalog-group-body">${items.map(item => payloadSourceMarkup(item, queryActive)).join('')}</div>
</details>`;
}).join('');
}
let _payloadCatalog = null;
function catalogResults(catalog) {
return (catalog.sources || []).map(source => ({
source,
files: (catalog.files || []).filter(file => file.source_id === source.id),
error: source.last_error || null,
}));
}
function applyPayloadCatalogFilters() {
if (!_payloadCatalog) return;
const query = String($('payload-catalog-search')?.value || '').trim().toLowerCase();
const category = String($('payload-catalog-category')?.value || 'all');
const showExpert = Boolean($('payload-show-expert')?.checked);
const results = catalogResults(_payloadCatalog)
.filter(({ source }) => category === 'all' || source.category === category)
.map(item => ({
...item,
files: item.files.filter(file => !query || `${file.asset_name} ${item.source.name} ${item.source.description || ''}`.toLowerCase().includes(query)),
}))
.filter(item => !query || item.files.length || `${item.source.name} ${item.source.description || ''}`.toLowerCase().includes(query))
.filter(item => showExpert || payloadCatalogGroup(item) !== 'expert');
renderPayloadSourceCheckList(results);
}
function renderPayloadCatalog(catalog) {
_payloadCatalog = catalog;
const stats = catalog.stats || {};
$('payload-stat-assets').textContent = String(stats.available || 0);
$('payload-stat-sources').textContent = String(stats.enabledSources || stats.sources || 0);
$('payload-stat-downloaded').textContent = String(stats.downloaded || 0);
$('payload-stat-updates').textContent = String(stats.updates || 0);
$('payload-stat-verified').textContent = String(stats.verified || 0);
const select = $('payload-catalog-category');
const selected = select.value || 'all';
const categories = [...new Set((catalog.sources || []).map(source => source.category || 'other'))].sort();
select.innerHTML = `<option value="all">${escHtml(window.t('payload.allCategories', 'All categories'))}</option>` + categories.map(value => `<option value="${escHtml(value)}">${escHtml(value)}</option>`).join('');
select.value = categories.includes(selected) ? selected : 'all';
applyPayloadCatalogFilters();
}
async function loadPayloadCatalog() {
const catalog = await window.pork.payloadCatalog();
renderPayloadCatalog(catalog);
return catalog;
}
async function renderLocalPayloads() {
let files;
try { files = await window.pork.payloadListLocal(); } catch { files = []; }
const list = $('payload-local-list');
const empty = $('payload-local-empty');
if (!files.length) {
list.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
list.innerHTML = files.map(f => {
const ext = (f.name.split('.').pop() || '').toLowerCase();
const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || '';
return `<div class="al-file-card payload-local-card">
<span class="al-ext ${extClass}">${escHtml(ext || '?')}</span>
<span class="al-file-name" title="${escHtml(f.local_path)}">${escHtml(f.name)}</span>
<span class="payload-card-size">${fmt(f.size)}</span>
${payloadStatusBadge(f)}
<div class="payload-card-actions">
<button class="btn btn-sm btn-teal btn-payload-push"
data-local-path="${escHtml(f.local_path)}"
data-filename="${escHtml(f.name)}" title="Push to PS5 via FTP transfer queue">&#8593;&nbsp;FTP</button>
<button class="btn btn-sm btn-payload-delete-local"
data-id="${f.db_id ?? ''}"
data-local-path="${escHtml(f.local_path)}" title="Delete local file">&#128465;</button>
</div>
</div>`;
}).join('');
}
async function renderPayloadSender() {
let files;
try { files = await window.pork.payloadListLocal(); } catch { files = []; }
const list = $('payload-sender-list');
const empty = $('payload-sender-empty');
if (!files.length) {
list.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
const status = $('payload-sender-status');
if (status) status.textContent = '';
list.innerHTML = files.map(f => {
const ext = (f.name.split('.').pop() || '').toLowerCase();
const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || '';
return `<div class="al-file-card payload-sender-card">
<span class="al-ext ${extClass}">${escHtml(ext || '?')}</span>
<span class="al-file-name" title="${escHtml(f.local_path)}">${escHtml(f.name)}</span>
<span class="payload-card-size">${fmt(f.size)}</span>
<button class="btn btn-sm btn-teal btn-payload-tcp-send"
data-local-path="${escHtml(f.local_path)}"
data-filename="${escHtml(f.name)}">&#x21D2; Send</button>
</div>`;
}).join('');
}
$('btn-payload-check-all').addEventListener('click', async () => {
const btn = $('btn-payload-check-all');
const status = $('payload-check-status');
btn.disabled = true;
status.textContent = 'Checking…';
try {
const results = await window.pork.payloadCheckUpdates();
await loadPayloadCatalog();
await renderLocalPayloads();
status.textContent = `Done — ${results.length} source(s) checked`;
} catch (e) {
status.textContent = `Error: ${e.message}`;
setStatus(`Payload check failed: ${e.message}`, 'error');
} finally {
btn.disabled = false;
}
});
$('payload-catalog-search')?.addEventListener('input', applyPayloadCatalogFilters);
$('payload-catalog-category')?.addEventListener('change', applyPayloadCatalogFilters);
$('payload-show-expert')?.addEventListener('change', event => {
localStorage.setItem('chimera.payload.showExpert', event.target.checked ? '1' : '0');
applyPayloadCatalogFilters();
});
$('btn-payload-update-downloaded')?.addEventListener('click', async () => {
const btn = $('btn-payload-update-downloaded');
const status = $('payload-check-status');
btn.disabled = true;
status.textContent = 'Checking and updating local payloads…';
try {
const result = await window.pork.payloadUpdateDownloaded();
await loadPayloadCatalog();
await renderLocalPayloads();
await renderPayloadSender();
status.textContent = `${result.checked} checked locally · ${result.updated} updated${result.failed ? ` · ${result.failed} failed` : ''}`;
} catch (error) {
status.textContent = `Update failed: ${error.message}`;
} finally { btn.disabled = false; }
});
$('payload-sources-check-list').addEventListener('click', async e => {
const pushBtn = e.target.closest('.btn-payload-push-remote');
if (pushBtn) {
const local_path = pushBtn.dataset.localPath;
const filename = pushBtn.dataset.filename;
const origText = pushBtn.textContent;
pushBtn.disabled = true;
pushBtn.textContent = 'Queuing…';
try {
await window.pork.payloadPush({ local_path, filename });
setStatus(`${filename} queued for FTP transfer to PS5`, 'ok');
navigate('transfers');
} catch (err) {
setStatus(`FTP push failed: ${err.message}`, 'error');
pushBtn.disabled = false;
pushBtn.textContent = origText;
}
return;
}
const dlBtn = e.target.closest('.btn-payload-download');
if (!dlBtn) return;
const source_id = Number(dlBtn.dataset.sourceId);
const asset_name = dlBtn.dataset.assetName;
const asset_url = dlBtn.dataset.assetUrl;
const version = dlBtn.dataset.version;
dlBtn.disabled = true;
dlBtn.textContent = 'Downloading…';
try {
await window.pork.payloadDownload({ source_id, asset_name, asset_url, version });
setStatus(`Downloaded ${asset_name}`, 'ok');
await loadPayloadCatalog();
await renderLocalPayloads();
await renderPayloadSender();
} catch (err) {
setStatus(`Download failed: ${err.message}`, 'error');
dlBtn.disabled = false;
dlBtn.textContent = '↓ Retry';
}
});
$('payload-local-list').addEventListener('click', async e => {
const pushBtn = e.target.closest('.btn-payload-push');
if (pushBtn) {
const local_path = pushBtn.dataset.localPath;
const filename = pushBtn.dataset.filename;
try {
await window.pork.payloadPush({ local_path, filename });
setStatus(`${filename} queued for transfer`, 'ok');
navigate('transfers');
} catch (e2) {
setStatus(`Push failed: ${e2.message}`, 'error');
}
return;
}
const delBtn = e.target.closest('.btn-payload-delete-local');
if (delBtn) {
const confirmation = await showExactConfirmation(`Delete local file "${delBtn.dataset.localPath}"?`, 'VERWIJDER');
if (!confirmation) return;
try {
const id = delBtn.dataset.id ? Number(delBtn.dataset.id) : null;
const local_path = delBtn.dataset.localPath;
await window.pork.payloadDeleteLocal({ id, local_path, confirmation });
await renderLocalPayloads();
await renderPayloadSender();
} catch (e2) {
setStatus(`Delete failed: ${e2.message}`, 'error');
}
}
});
$('payload-sender-list').addEventListener('click', async e => {
const btn = e.target.closest('.btn-payload-tcp-send');
if (!btn) return;
const local_path = btn.dataset.localPath;
const filename = btn.dataset.filename;
const port = parseInt($('payload-sender-port')?.value || '9021', 10);
const status = $('payload-sender-status');
btn.disabled = true;
btn.textContent = 'Sending\u2026';
if (status) { status.textContent = `Sending ${filename} on port ${port}\u2026`; status.style.color = ''; }
try {
await window.pork.payloadTcpSend({ localPath: local_path, port, filename });
if (status) { status.textContent = `\u2713 ${filename} sent \u2014 PS5 notified`; status.style.color = 'var(--teal)'; }
setStatus(`${filename} sent to PS5 on port ${port}`, 'ok');
} catch (e2) {
if (status) { status.textContent = `Error: ${e2.message}`; status.style.color = 'var(--red)'; }
setStatus(`TCP send failed: ${e2.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = '\u21D2 Send';
}
});
$('autoloader-file-list').addEventListener('click', async e => {
const saveBtn = e.target.closest('.btn-al-save-local');
if (saveBtn) {
e.stopPropagation(); // don't also add to sequence
const filename = saveBtn.dataset.filename;
const orig = saveBtn.textContent;
saveBtn.disabled = true;
saveBtn.textContent = '⋯';
try {
await window.pork.autoloaderSaveLocal({ filename });
setStatus(`${filename} saved to local payloads`, 'ok');
saveBtn.textContent = '✓';
await renderLocalPayloads();
await renderPayloadSender();
setTimeout(() => { saveBtn.textContent = orig; saveBtn.disabled = false; }, 2000);
} catch (e2) {
setStatus(`Save failed: ${e2.message}`, 'error');
saveBtn.textContent = orig;
saveBtn.disabled = false;
}
return;
}
const card = e.target.closest('[data-al-add]');
if (!card) return;
_alSequence.push({ type: 'payload', filename: card.dataset.alAdd });
_alRenderSequence();
$('btn-autoloader-save').disabled = false;
});
window.pork.on('payload:download:progress', ({ asset_name, percent }) => {
const btn = document.querySelector(`.btn-payload-download[data-asset-name="${CSS.escape(asset_name)}"]`);
if (btn) btn.textContent = `${percent}%`;
});
+160
View File
@@ -0,0 +1,160 @@
// ── PFS Ripper — PS5 PFS/image library browser ───────────────────────────────
// Native port of Deckerr97's "PFS Ripper": browse a library of PS5 image files,
// show cover art (ProsperoPatches), search, view PPSA IDs + versions, export.
let _pfsInit = false;
let _pfsItems = [];
let _pfsSearch = '';
const _pfsIconCache = new Map();
function _pfsEsc(s) { return (typeof escHtml === 'function') ? escHtml(s) : String(s ?? ''); }
function _pfsToast(m, k) { if (typeof showToast === 'function') showToast(m, k); }
function _pfsFmt(n) {
if (!n) return '';
if (n < 1048576) return (n / 1024).toFixed(0) + ' KB';
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB';
return (n / 1073741824).toFixed(2) + ' GB';
}
function _pfsBuildUI() {
const root = document.getElementById('page-pfs-ripper');
if (!root) return;
root.innerHTML = `
<div class="embed-toolbar">
<div class="embed-controls">
<span class="embed-page-title">PFS Ripper</span>
<button id="pfs-add" class="btn btn-sm">+ Add Folder</button>
<button id="pfs-rescan" class="btn btn-xs">⟳ Rescan</button>
<span id="pfs-count" class="hint"></span>
</div>
<div class="embed-controls">
<input id="pfs-search" type="text" placeholder="Search title or ID…" style="width:220px" />
</div>
</div>
<div id="pfs-folders" style="display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px"></div>
<div id="pfs-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;padding:12px"></div>`;
document.getElementById('pfs-add').addEventListener('click', async () => {
await window.pork.pfsFoldersAdd();
await _pfsRenderFolders();
await _pfsScan();
});
document.getElementById('pfs-rescan').addEventListener('click', () => _pfsScan());
document.getElementById('pfs-search').addEventListener('input', e => {
_pfsSearch = e.target.value.toLowerCase();
_pfsRenderGrid();
});
}
async function _pfsRenderFolders() {
const el = document.getElementById('pfs-folders');
if (!el) return;
const folders = (await window.pork.pfsFoldersList().catch(() => [])) || [];
if (!folders.length) {
el.innerHTML = '<span class="hint">No library folders yet — click “Add Folder”.</span>';
return;
}
el.innerHTML = folders.map(f =>
`<span class="pill" style="display:inline-flex;align-items:center;gap:6px;padding:3px 8px;border:1px solid var(--border);border-radius:12px;font-size:12px">
<code style="font-size:11px">${_pfsEsc(f)}</code>
<button class="btn btn-xs btn-red pfs-folder-rm" data-folder="${_pfsEsc(f)}" title="Remove">✕</button>
</span>`).join('');
el.querySelectorAll('.pfs-folder-rm').forEach(b =>
b.addEventListener('click', async () => {
await window.pork.pfsFoldersRemove(b.dataset.folder);
await _pfsRenderFolders();
await _pfsScan();
}));
}
async function _pfsScan() {
const grid = document.getElementById('pfs-grid');
if (grid) grid.innerHTML = '<div class="embed-empty-message" style="padding:16px">Scanning…</div>';
_pfsItems = (await window.pork.pfsScan().catch(() => [])) || [];
_pfsRenderGrid();
}
function _pfsFiltered() {
if (!_pfsSearch) return _pfsItems;
return _pfsItems.filter(it =>
(it.gameId || '').toLowerCase().includes(_pfsSearch) ||
(it.file || '').toLowerCase().includes(_pfsSearch));
}
function _pfsRenderGrid() {
const grid = document.getElementById('pfs-grid');
const countEl = document.getElementById('pfs-count');
if (!grid) return;
const list = _pfsFiltered();
if (countEl) countEl.textContent = `${list.length} image${list.length !== 1 ? 's' : ''}`;
if (!list.length) {
grid.innerHTML = '<div class="embed-empty-message" style="padding:16px">No images found. Add a folder containing .ffpkg / .pkg / .exfat images.</div>';
return;
}
grid.innerHTML = list.map((it, i) => `
<div class="pfs-card" style="border:1px solid var(--border);border-radius:8px;overflow:hidden;display:flex;flex-direction:column">
<div class="pfs-art" data-gid="${_pfsEsc(it.gameId)}" style="aspect-ratio:1;background:rgba(var(--accent-rgb),.08);display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--text-dim)">
${it.gameId ? _pfsEsc(it.gameId) : '—'}
</div>
<div style="padding:8px;display:flex;flex-direction:column;gap:2px;flex:1">
<div style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${_pfsEsc(it.file)}">${_pfsEsc(it.gameId || it.file)}</div>
<div class="hint" style="font-size:11px">
<span class="pill-num" style="padding:0 4px">${_pfsEsc(it.ext)}</span>
${it.version ? ' v' + _pfsEsc(it.version) : ''} · ${_pfsFmt(it.size)}
</div>
<div style="display:flex;gap:4px;margin-top:6px;flex-wrap:wrap">
<button class="btn btn-xs pfs-export" data-i="${i}" title="Copy to a folder">Export</button>
<button class="btn btn-xs pfs-ftp" data-i="${i}" title="Send to PS5 via FTP">→ PS5</button>
<button class="btn btn-xs pfs-reveal" data-i="${i}" title="Show in Explorer">📂</button>
</div>
</div>
</div>`).join('');
// Lazy-load cover art via ProsperoPatches.
grid.querySelectorAll('.pfs-art[data-gid]').forEach(el => {
const gid = el.dataset.gid;
if (!gid) return;
_pfsLoadArt(gid, el);
});
grid.querySelectorAll('.pfs-export').forEach(b => b.addEventListener('click', () => _pfsExport(list[+b.dataset.i])));
grid.querySelectorAll('.pfs-ftp').forEach(b => b.addEventListener('click', () => _pfsFtp(list[+b.dataset.i])));
grid.querySelectorAll('.pfs-reveal').forEach(b => b.addEventListener('click', () => window.pork.pfsReveal(list[+b.dataset.i].path)));
}
async function _pfsLoadArt(gid, el) {
try {
let url = _pfsIconCache.get(gid);
if (url === undefined) {
const result = await window.pork.prosperoFetchIcon(gid).catch(() => null);
url = result?.iconUrl || '';
_pfsIconCache.set(gid, url || null);
}
if (url && el.isConnected) {
el.innerHTML = `<img src="${_pfsEsc(url)}" alt="" style="width:100%;height:100%;object-fit:cover" data-image-error="hide">`;
}
} catch (_) { /* leave the ID placeholder */ }
}
async function _pfsExport(it) {
if (!it) return;
try {
const r = await window.pork.pfsExportCopy(it.path);
if (r && r.ok) _pfsToast('Exported to ' + r.dest, 'ok');
} catch (e) { _pfsToast('Export failed: ' + e.message, 'error'); }
}
async function _pfsFtp(it) {
if (!it) return;
try {
await window.pork.pfsExportFtp(it.path);
_pfsToast('Queued upload to PS5 — see Transfers', 'ok');
} catch (e) { _pfsToast('Send failed: ' + e.message, 'error'); }
}
// Entry point invoked by the page router (state.js pageLoaders).
async function loadPfsRipper() {
if (!_pfsInit) { _pfsInit = true; _pfsBuildUI(); }
await _pfsRenderFolders();
await _pfsScan();
}
+203
View File
@@ -0,0 +1,203 @@
'use strict';
(function mountPremiumProjectIllustrations() {
const targets = Object.freeze({
'games-empty': 'library',
'backups-empty': 'protection',
'transfers-empty': 'transfer',
'media-empty': 'media',
});
const illustrations = Object.freeze({
'library': `<div class="premium-project-illustration" data-variant="library" aria-hidden="true">
<span class="premium-project-glow"></span>
<svg viewBox="0 0 320 180" focusable="false">
<path class="premium-project-path path-a" d="M31 124C76 50 122 57 159 88s75 37 130-23"/>
<path class="premium-project-path path-b" d="M37 58c42 18 65 4 96 34s70 44 151 27"/>
<g class="premium-project-source library-source"><rect x="24" y="82" width="38" height="54" rx="6"/><rect x="31" y="74" width="38" height="54" rx="6"/><path d="M39 91h22M39 100h16"/></g>
<g class="premium-project-surface library-index"><rect x="119" y="39" width="83" height="104" rx="11"/><path d="M135 63h51M135 79h34M135 105h18M160 105h26M135 119h18M160 119h26"/></g>
<g class="premium-project-node library-art"><rect x="260" y="48" width="38" height="48" rx="7"/><path d="m268 84 8-10 7 7 6-8"/><circle cx="286" cy="60" r="4"/></g>
<g class="premium-project-node library-proof"><circle cx="276" cy="126" r="15"/><path d="m269 126 5 5 9-11"/></g>
</svg>
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
</div>`,
'protection': `<div class="premium-project-illustration" data-variant="protection" aria-hidden="true">
<span class="premium-project-glow"></span>
<svg viewBox="0 0 320 180" focusable="false">
<path class="premium-project-path path-a" d="M35 95h79c21 0 27-30 49-30h27c24 0 29 31 49 31h47"/>
<path class="premium-project-path path-b" d="M65 131h67c18 0 25-21 42-21h35c17 0 23 21 43 21h34"/>
<g class="premium-project-source protection-copy"><rect x="27" y="61" width="46" height="68" rx="7"/><path d="M38 80h24M38 94h19M38 108h24"/></g>
<g class="premium-project-node protection-hash"><rect x="129" y="49" width="68" height="55" rx="10"/><path d="M142 68h42M142 80h32"/><text x="144" y="94">SHA</text></g>
<g class="premium-project-surface protection-vault"><path d="M238 55h55v77h-55z"/><circle cx="265" cy="93" r="14"/><path d="M265 84v18M256 93h18"/></g>
<g class="premium-project-shield"><path d="M183 112c0 19-12 28-25 34-13-6-25-15-25-34l25-9z"/><path d="m149 121 7 7 12-16"/></g>
</svg>
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
</div>`,
'transfer': `<div class="premium-project-illustration" data-variant="transfer" aria-hidden="true">
<span class="premium-project-glow"></span>
<svg viewBox="0 0 320 180" focusable="false">
<path class="premium-project-path path-a" d="M68 87h67c21 0 26-27 47-27h28c22 0 27 27 47 27h26"/>
<path class="premium-project-path path-b" d="M68 119h63c22 0 31 25 53 25h30c21 0 29-25 47-25h22"/>
<g class="premium-project-source transfer-console"><path d="M27 61h46l8 65H19z"/><path d="M37 78h27M42 111h17"/><circle cx="50" cy="93" r="5"/></g>
<g class="premium-project-surface transfer-router"><rect x="133" y="42" width="69" height="78" rx="11"/><path d="M146 64h43M146 79h43M146 94h31"/><circle cx="186" cy="95" r="3"/></g>
<g class="premium-project-node transfer-vault"><rect x="251" y="61" width="48" height="65" rx="7"/><path d="M260 78h30M260 91h30M260 104h21"/></g>
<g class="premium-project-progress"><path d="M126 144h82"/><path d="M126 144h55"/></g>
</svg>
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
</div>`,
'media': `<div class="premium-project-illustration" data-variant="media" aria-hidden="true">
<span class="premium-project-glow"></span>
<svg viewBox="0 0 320 180" focusable="false">
<path class="premium-project-path path-a" d="M85 90h58c21 0 24-28 46-28h23c21 0 25 28 44 28h35"/>
<path class="premium-project-path path-b" d="M84 120h57c22 0 27 22 47 22h26c20 0 25-22 44-22h33"/>
<g class="premium-project-source media-console"><rect x="22" y="48" width="70" height="84" rx="9"/><path d="m34 111 15-18 12 12 18-24"/><circle cx="71" cy="66" r="6"/></g>
<g class="premium-project-wave"><path d="M119 92h8l5-17 9 35 8-25 8 13 7-6h8"/></g>
<g class="premium-project-surface media-gallery"><rect x="225" y="43" width="67" height="47" rx="7"/><rect x="217" y="99" width="75" height="43" rx="7"/><path d="m235 80 10-11 8 8 11-14 17 17M227 132l11-12 9 8 11-13 22 17"/></g>
<g class="premium-project-node media-play"><circle cx="184" cy="95" r="19"/><path d="m180 86 12 9-12 9z"/></g>
</svg>
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
</div>`,
'trophies': `<div class="premium-project-illustration" data-variant="trophies" aria-hidden="true">
<span class="premium-project-glow"></span>
<svg viewBox="0 0 320 180" focusable="false">
<path class="premium-project-path path-a" d="M48 126c39-72 80-80 119-45s65 40 111-25"/>
<path class="premium-project-path path-b" d="M46 66c43 5 66 23 91 59s80 32 139 4"/>
<g class="premium-project-source trophy-game"><rect x="27" y="54" width="52" height="75" rx="7"/><path d="M38 75h30M38 88h20M38 108h30"/></g>
<g class="premium-project-surface trophy-cup"><path d="M139 49h46v22c0 23-9 36-23 36s-23-13-23-36z"/><path d="M139 58h-14v8c0 13 7 20 19 21M185 58h14v8c0 13-7 20-19 21M162 107v17M145 126h34"/></g>
<g class="premium-project-node trophy-locked"><circle cx="257" cy="62" r="19"/><path d="M250 62v-5a7 7 0 0 1 14 0v5M248 62h18v14h-18z"/></g>
<g class="premium-project-node trophy-earned"><circle cx="268" cy="127" r="17"/><path d="m259 127 6 6 12-14"/></g>
<g class="premium-project-progress trophy-progress"><circle cx="162" cy="84" r="56"/><path d="M162 28a56 56 0 0 1 51 79"/></g>
</svg>
<span class="premium-project-packet packet-a"></span><span class="premium-project-packet packet-b"></span><span class="premium-project-packet packet-c"></span>
</div>`,
});
const reducedMotion = typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-reduced-motion: reduce)')
: { matches: false, addListener() {}, removeListener() {} };
const mounted = new Set();
const visibilityObserver = 'IntersectionObserver' in window ? new IntersectionObserver(entries => {
for (const entry of entries) {
entry.target.classList.toggle('is-active', entry.isIntersecting);
syncMotion(entry.target);
}
}, { threshold: .16 }) : null;
function motionPaused() {
return reducedMotion.matches || document.visibilityState !== 'visible';
}
function syncMotion(element) {
if (!element) return;
element.classList.toggle('is-paused', motionPaused());
}
function syncAllMotion() {
for (const element of mounted) {
if (!element.isConnected) {
visibilityObserver?.unobserve(element);
mounted.delete(element);
continue;
}
syncMotion(element);
}
}
function bind(illustration) {
if (!illustration || mounted.has(illustration)) return;
syncAllMotion();
mounted.add(illustration);
visibilityObserver?.observe(illustration);
if (!visibilityObserver) illustration.classList.add('is-active');
syncMotion(illustration);
let pointerFrame = 0;
let pointerX = 0;
let pointerY = 0;
illustration.addEventListener('pointermove', event => {
if (motionPaused() || event.pointerType === 'touch') return;
pointerX = event.clientX;
pointerY = event.clientY;
if (pointerFrame) return;
pointerFrame = window.requestAnimationFrame(() => {
pointerFrame = 0;
const rect = illustration.getBoundingClientRect();
illustration.style.setProperty('--illustration-x', `${((pointerX - rect.left) / rect.width - .5) * 7}deg`);
illustration.style.setProperty('--illustration-y', `${((pointerY - rect.top) / rect.height - .5) * -5}deg`);
});
}, { passive: true });
illustration.addEventListener('pointerleave', () => {
if (pointerFrame) window.cancelAnimationFrame(pointerFrame);
pointerFrame = 0;
illustration.style.removeProperty('--illustration-x');
illustration.style.removeProperty('--illustration-y');
});
}
function bindHomeTopology() {
const home = document.getElementById('chimera-home-command-center');
if (!home) return;
home.classList.add('premium-topology-motion');
bind(home);
}
function dockImportLauncher() {
const launcher = document.getElementById('chimera-import-open');
const sidebarDonate = document.getElementById('sidebar-donate');
if (!launcher || !sidebarDonate) return false;
launcher.classList.add('chimera-import-launcher-docked');
sidebarDonate.after(launcher);
return true;
}
function ensureTrophyIllustration() {
const target = document.querySelector('.trophy-library-empty');
if (!target) return;
let illustration = target.querySelector('.premium-project-illustration');
if (!illustration) {
target.insertAdjacentHTML('afterbegin', illustrations.trophies);
illustration = target.querySelector('.premium-project-illustration');
}
bind(illustration);
}
for (const [id, variant] of Object.entries(targets)) {
const target = document.getElementById(id);
if (!target) continue;
const ensure = () => {
let illustration = target.querySelector('.premium-project-illustration');
if (!illustration) {
target.insertAdjacentHTML('afterbegin', illustrations[variant]);
illustration = target.querySelector('.premium-project-illustration');
}
bind(illustration);
};
ensure();
new MutationObserver(ensure).observe(target, { childList: true });
}
// Trophy content is rendered by a route module and the page node may be
// replaced while the feature is loading. Observe the stable content root so
// both a late render and a replacement page receive the illustration.
const trophyHost = document.getElementById('content') || document.body;
const touchesTrophyLibrary = mutation => {
const target = mutation.target?.nodeType === Node.ELEMENT_NODE ? mutation.target : mutation.target?.parentElement;
if (target?.matches?.('#page-trophies, #page-trophies *')) return true;
return [...mutation.addedNodes].some(node => node.nodeType === Node.ELEMENT_NODE && (
node.matches?.('#page-trophies, .trophy-library-empty')
|| node.querySelector?.('#page-trophies, .trophy-library-empty')
));
};
ensureTrophyIllustration();
new MutationObserver(mutations => {
if (mutations.some(touchesTrophyLibrary)) ensureTrophyIllustration();
}).observe(trophyHost, { childList: true, subtree: true });
bindHomeTopology();
if (!dockImportLauncher()) {
const importLauncherObserver = new MutationObserver(() => {
if (!dockImportLauncher()) return;
importLauncherObserver.disconnect();
});
importLauncherObserver.observe(document.body, { childList: true });
}
document.addEventListener('visibilitychange', syncAllMotion);
if (typeof reducedMotion.addEventListener === 'function') reducedMotion.addEventListener('change', syncAllMotion);
else reducedMotion.addListener(syncAllMotion);
})();
+103
View File
@@ -0,0 +1,103 @@
(function initProtectionModel(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.chimeraProtectionModel = api;
})(typeof window !== 'undefined' ? window : globalThis, function createProtectionModel() {
'use strict';
const list = value => Array.isArray(value) ? value : [];
const value = input => input == null || input === '' ? null : input;
const first = (...inputs) => inputs.map(value).find(item => item != null) ?? null;
const lower = input => String(input || '').trim().toLowerCase();
const missingStates = new Set(['missing', 'unavailable', 'disconnected', 'offline', 'not_mounted', 'absent']);
const failedIntegrityStates = new Set(['failed', 'corrupt', 'mismatch', 'invalid']);
function normalizeCopy(title, copy, index) {
const volume = copy.volume || {};
const device = copy.device || volume.device || copy.storageDevice || {};
const id = first(copy.id, copy.copyId, copy.copy_id);
const volumeId = first(copy.volumeId, copy.volume_id, volume.id);
const deviceId = first(copy.deviceId, copy.device_id, device.id, volume.deviceId, volume.device_id);
const deviceFingerprint = first(copy.deviceFingerprint, copy.stableFingerprint, device.stableFingerprint, device.stable_fingerprint);
const volumeKey = first(copy.stableVolumeKey, copy.stable_volume_key, volume.stableVolumeKey, volume.stable_volume_key);
const deviceKey = deviceId != null ? `device:${deviceId}` : deviceFingerprint ? `fingerprint:${deviceFingerprint}` : 'device:unknown';
const relativePath = first(copy.relativePath, copy.relative_path, copy.path, '');
const mountPath = first(copy.mountPath, copy.mount_path, volume.mountPath, volume.mount_path, '');
const fullPath = mountPath && relativePath
? `${String(mountPath).replace(/[\\/]+$/, '')}/${String(relativePath).replace(/^[\\/]+/, '')}`
: String(first(relativePath, mountPath, 'Unknown path'));
const availabilityState = lower(first(copy.availabilityState, copy.availability_state, volume.availabilityState, volume.availability_state, device.availabilityState, device.availability_state, 'unknown'));
const integrityState = lower(first(copy.integrityState, copy.integrity_state, 'unknown'));
const hashState = lower(first(copy.hashState, copy.hash_state, copy.inventoryHash || copy.inventory_hash ? 'recorded' : 'missing'));
const missing = missingStates.has(availabilityState);
const integrityFailed = failedIntegrityStates.has(integrityState);
return Object.freeze({
copyId: id != null ? String(id) : `unidentified-${index + 1}`,
titleId: String(first(title.game_id, title.titleId, title.title_id, title.id, 'unknown')),
titleName: String(first(title.prospero_name, title.name, title.title, title.game_id, 'Unknown title')),
region: String(first(title.prospero_region, title.region, '')),
deviceId: deviceId == null ? null : String(deviceId),
deviceKey,
deviceName: String(first(copy.deviceName, copy.displayName, device.displayName, device.display_name, deviceFingerprint, 'Unknown device')),
deviceFingerprint: deviceFingerprint == null ? null : String(deviceFingerprint),
volumeId: volumeId == null ? null : String(volumeId),
volumeKey: volumeKey == null ? null : String(volumeKey),
path: fullPath,
sizeBytes: Number(first(copy.sizeBytes, copy.size_bytes, 0)) || 0,
hash: first(copy.inventoryHash, copy.inventory_hash),
hashAlgorithm: String(first(copy.hashAlgorithm, copy.hash_algorithm, 'sha256')),
hashState,
integrityState,
lastVerifiedAt: first(copy.lastVerifiedAt, copy.last_verified_at),
lastSeenAt: first(copy.lastSeenAt, copy.last_seen_at),
availabilityState,
missing,
integrityFailed,
restoreTarget: Object.freeze({
copyId: id != null ? String(id) : `unidentified-${index + 1}`,
titleId: String(first(title.game_id, title.titleId, title.title_id, title.id, 'unknown')),
volumeId: volumeId == null ? null : String(volumeId),
deviceId: deviceId == null ? null : String(deviceId),
path: fullPath
})
});
}
function evaluate(copies, requiredDistinctDevices = 2) {
const concrete = list(copies);
const healthyCopies = concrete.filter(copy => !copy.missing && !copy.integrityFailed);
const knownDevices = new Set(healthyCopies.map(copy => copy.deviceKey).filter(key => key !== 'device:unknown'));
const unknownDeviceCopies = healthyCopies.filter(copy => copy.deviceKey === 'device:unknown').length;
const distinctDevices = knownDevices.size + (unknownDeviceCopies ? 1 : 0);
const required = Math.max(1, Number(requiredDistinctDevices) || 2);
const missingCopies = concrete.filter(copy => copy.missing).length;
const failedCopies = concrete.filter(copy => copy.integrityFailed).length;
const healthy = distinctDevices >= required;
const sameDeviceExcess = Math.max(0, healthyCopies.length - distinctDevices);
const status = healthy ? 'healthy' : healthyCopies.length ? 'needs-diversity' : concrete.length ? 'degraded' : 'unprotected';
return Object.freeze({
copyCount: concrete.length,
healthyCopyCount: healthyCopies.length,
distinctDeviceCount: distinctDevices,
requiredDistinctDevices: required,
missingCopyCount: missingCopies,
failedCopyCount: failedCopies,
sameDeviceExcess,
healthy,
status
});
}
function flatten(titles, options = {}) {
const result = [];
for (const title of list(titles)) {
const copies = list(title.localCopies).map((copy, index) => normalizeCopy(title, copy, index));
const policy = evaluate(copies, options.requiredDistinctDevices);
copies.forEach(copy => result.push(Object.freeze({ ...copy, policy })));
}
return result;
}
return Object.freeze({ normalizeCopy, evaluate, flatten });
});
+268
View File
@@ -0,0 +1,268 @@
// ── PS Notify page ────────────────────────────────────────────────────────────
let _psnotifyConfigured = false;
function applyPsNotifyAvailability(configured, detail = '') {
_psnotifyConfigured = configured;
['btn-psn-send', 'btn-psn-test', 'btn-psn-push-payload'].forEach(id => {
const button = $(id);
if (!button) return;
button.disabled = !configured;
button.title = configured ? '' : 'Configure a PS5 host in Settings before using this action.';
});
document.querySelectorAll('#page-psnotify [id^="btn-psn-exp-"]').forEach(button => {
button.disabled = !configured;
button.title = configured ? '' : 'Configure a PS5 host in Settings before using this action.';
});
document.querySelectorAll('.psnotify-compose-card, .psnotify-payload-card').forEach(card => {
card.classList.toggle('is-unavailable', !configured);
});
const status = $('psn-status');
if (!configured && status && !status.textContent) {
status.textContent = detail || 'Configure a PS5 host in Settings to enable console actions.';
}
}
function enhancePsNotifyLayout() {
const page = $('page-psnotify');
if (!page || page.dataset.enhanced) return;
page.dataset.enhanced = 'true';
const header = page.querySelector(':scope > .page-header');
header?.insertAdjacentHTML('beforeend', '<p class="page-subtitle psnotify-page-subtitle">Compose, test and review PS5 notifications from one focused workspace.</p><span id="psn-page-readiness" class="operations-status operations-status-unknown">Not checked</span>');
const cards = [...page.querySelectorAll(':scope > .card')];
const [compose, settings, payload, lab, history] = cards;
if (!compose || !settings || !payload || !lab || !history) return;
compose.classList.add('psnotify-compose-card');
settings.classList.add('psnotify-settings-card');
payload.classList.add('psnotify-payload-card');
lab.classList.add('psnotify-lab', 'collapsed');
history.classList.add('psnotify-history-card');
compose.querySelector('.card-header h3').textContent = 'Compose notification';
compose.querySelector('.hint').textContent = 'Sent to the PS5 host from your active console profile.';
compose.querySelector('.form-grid')?.insertAdjacentHTML('beforebegin', `
<div class="psnotify-templates" aria-label="Notification templates">
<button class="btn btn-xs" data-psn-template="complete">Build complete</button>
<button class="btn btn-xs" data-psn-template="backup">Backup complete</button>
<button class="btn btn-xs" data-psn-template="update">Update ready</button>
<button class="btn btn-xs" data-psn-template="warning">Attention needed</button>
</div>`);
compose.querySelector('.form-grid')?.insertAdjacentHTML('afterend', `
<div class="psnotify-preview" aria-live="polite">
<span>PS5 preview</span>
<strong id="psn-preview-message">Your notification appears here</strong>
<small id="psn-preview-submessage">Optional supporting detail</small>
</div>`);
$('psn-message')?.insertAdjacentHTML('afterend', '<span id="psn-message-count" class="psnotify-counter">0/120</span>');
$('psn-submessage')?.insertAdjacentHTML('afterend', '<span id="psn-submessage-count" class="psnotify-counter">0/120</span>');
settings.querySelector('.card-header h3').textContent = 'Service connection';
payload.querySelector('.card-header h3').textContent = 'Notification payload';
history.querySelector('.card-header h3').textContent = 'Notification history';
lab.querySelector('.card-header')?.insertAdjacentHTML('beforeend', '<button id="psn-lab-toggle" class="btn btn-sm" type="button" aria-expanded="false">Show advanced lab</button>');
const grid = document.createElement('div');
grid.className = 'psnotify-grid';
compose.insertAdjacentElement('beforebegin', grid);
grid.append(compose);
const side = document.createElement('div');
side.className = 'psnotify-side';
side.append(settings, payload);
grid.append(side);
history.insertAdjacentElement('afterend', lab);
$('psn-lab-toggle')?.addEventListener('click', event => {
const expanded = event.currentTarget.getAttribute('aria-expanded') === 'true';
event.currentTarget.setAttribute('aria-expanded', String(!expanded));
event.currentTarget.textContent = expanded ? 'Show advanced lab' : 'Hide advanced lab';
lab.classList.toggle('collapsed', expanded);
});
const templates = {
complete: ['Build complete', 'The latest Chimera task finished successfully.'],
backup: ['Backup complete', 'Your PS5 data has been verified and stored.'],
update: ['Update ready', 'A reviewed update is ready in Chimera.'],
warning: ['Attention needed', 'Open Chimera for details and the recommended next step.'],
};
compose.querySelector('.psnotify-templates')?.addEventListener('click', event => {
const button = event.target.closest('[data-psn-template]');
if (!button) return;
[$('psn-message').value, $('psn-submessage').value] = templates[button.dataset.psnTemplate];
updatePsNotifyPreview();
$('psn-message').focus();
});
}
function updatePsNotifyPreview() {
const message = $('psn-message')?.value || '';
const submessage = $('psn-submessage')?.value || '';
if ($('psn-message-count')) $('psn-message-count').textContent = `${message.length}/120`;
if ($('psn-submessage-count')) $('psn-submessage-count').textContent = `${submessage.length}/120`;
if ($('psn-preview-message')) $('psn-preview-message').textContent = message || 'Your notification appears here';
if ($('psn-preview-submessage')) $('psn-preview-submessage').textContent = submessage || 'Optional supporting detail';
}
async function loadPsNotify() {
enhancePsNotifyLayout();
// Populate settings fields from store
try {
const s = await window.pork.getSettings();
$('psn-enabled').checked = s.psnotifyEnabled !== false;
$('psn-port').value = s.psnotifyPort || 6969;
const readiness = $('psn-page-readiness');
if (readiness) {
readiness.textContent = s.ftpHost ? `Configured · ${s.ftpHost}:${s.psnotifyPort || 6969}` : 'PS5 host not configured';
readiness.className = `operations-status ${s.ftpHost ? 'operations-status-compatible' : 'operations-status-warning'}`;
}
applyPsNotifyAvailability(Boolean(s.ftpHost));
} catch (_) {
applyPsNotifyAvailability(false, 'Console configuration could not be loaded.');
}
updatePsNotifyPreview();
await renderPsnHistory();
}
$('psn-message').addEventListener('input', updatePsNotifyPreview);
$('psn-submessage').addEventListener('input', updatePsNotifyPreview);
$('psn-message').addEventListener('keydown', event => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') $('btn-psn-send').click();
});
async function renderPsnHistory() {
const hist = await window.pork.psnotifyHistory();
const list = $('psn-history');
const empty = $('psn-history-empty');
if (!hist.length) {
list.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
list.innerHTML = hist.slice().reverse().map(e => {
const cls = e.ok !== false ? 'psn-ok' : 'psn-fail';
const ts = new Date(e.ts).toLocaleTimeString();
const sub = e.subMessage ? `<span class="psn-entry-sub">\u2014 ${escHtml(e.subMessage)}</span>` : '';
return `<div class="psn-entry ${cls}">
<span class="psn-entry-ts">${ts}</span>
<span class="psn-entry-msg">${escHtml(e.message)}</span>${sub}
</div>`;
}).join('');
}
$('btn-psn-send').addEventListener('click', async () => {
if (!_psnotifyConfigured) return;
const msg = $('psn-message').value.trim();
const sub = $('psn-submessage').value.trim();
if (!msg) { $('psn-status').textContent = 'Message is required.'; return; }
const btn = $('btn-psn-send');
btn.disabled = true;
$('psn-status').textContent = 'Sending\u2026';
try {
await window.pork.psnotifySend(msg, sub);
$('psn-status').textContent = 'Sent \u2713';
$('psn-message').value = '';
$('psn-submessage').value = '';
updatePsNotifyPreview();
await renderPsnHistory();
} catch (e) {
$('psn-status').textContent = 'Error: ' + e.message;
}
btn.disabled = !_psnotifyConfigured;
});
$('btn-psn-test').addEventListener('click', async () => {
if (!_psnotifyConfigured) return;
const btn = $('btn-psn-test');
btn.disabled = true;
$('psn-status').textContent = 'Sending test\u2026';
try {
await window.pork.psnotifyTest();
$('psn-status').textContent = 'Test sent \u2713';
if ($('psn-page-readiness')) {
$('psn-page-readiness').textContent = 'Online';
$('psn-page-readiness').className = 'operations-status operations-status-compatible';
}
await renderPsnHistory();
} catch (e) {
$('psn-status').textContent = 'Error: ' + e.message;
if ($('psn-page-readiness')) {
$('psn-page-readiness').textContent = 'Offline';
$('psn-page-readiness').className = 'operations-status operations-status-warning';
}
}
btn.disabled = !_psnotifyConfigured;
});
$('btn-psn-save-settings').addEventListener('click', async () => {
const port = parseInt($('psn-port').value) || 6969;
await window.pork.setSettings({
psnotifyEnabled: $('psn-enabled').checked,
psnotifyPort: port,
});
$('psn-port').value = port;
$('psn-status').textContent = 'Settings saved \u2713';
});
$('btn-psn-refresh-history').addEventListener('click', renderPsnHistory);
// ── PS Notify colour/encoding experiments ────────────────────────────────────────
async function _sendExp(msg, sub = '') {
const el = $('psn-exp-status');
if (!_psnotifyConfigured) {
el.textContent = 'Configure a PS5 host in Settings first.';
return;
}
el.textContent = 'Sending…';
try {
await window.pork.psnotifySend(msg, sub);
el.textContent = 'Sent \u2713 — check your PS5';
await renderPsnHistory();
} catch (e) {
el.textContent = 'Error: ' + e.message;
}
}
$('btn-psn-push-payload').addEventListener('click', async () => {
if (!_psnotifyConfigured) return;
const btn = $('btn-psn-push-payload');
const st = $('psn-push-status');
btn.disabled = true;
st.textContent = 'Queuing upload\u2026';
try {
const res = await window.pork.psnotifyPushPayload();
st.textContent = `Queued \u2713 \u2014 sending to ${res.remotePath}. Reload the payload on your PS5 then emoji will work.`;
} catch (e) {
st.textContent = 'Error: ' + e.message;
}
btn.disabled = !_psnotifyConfigured;
});
// HTML markup
$('btn-psn-exp-html-red').addEventListener('click', () =>
_sendExp('<font color="red">This is red text</font>', 'HTML <font color> test'));
$('btn-psn-exp-html-multi').addEventListener('click', () =>
_sendExp('<font color="red">Red</font> <font color="green">Green</font> <font color="blue">Blue</font>', 'HTML multi-colour test'));
$('btn-psn-exp-sony-tag').addEventListener('click', () =>
_sendExp('$[color=ff0000]Red$[/color] $[color=00ff00]Green$[/color]', 'Sony $[color] tag test'));
// Emoji colour squares (4-byte UTF-8 — need TextDecoder patch in customPSNotify.js to arrive intact)
$('btn-psn-exp-sq-red').addEventListener('click', () =>
_sendExp('\uD83D\uDFE5 Red notification', 'Emoji \uD83D\uDFE5 (U+1F7E5, 4-byte UTF-8)'));
$('btn-psn-exp-sq-green').addEventListener('click', () =>
_sendExp('\uD83D\uDFE9 Green notification', 'Emoji \uD83D\uDFE9 (U+1F7E9, 4-byte UTF-8)'));
$('btn-psn-exp-sq-blue').addEventListener('click', () =>
_sendExp('\uD83D\uDFE6 Blue notification', 'Emoji \uD83D\uDFE6 (U+1F7E6, 4-byte UTF-8)'));
$('btn-psn-exp-sq-yellow').addEventListener('click', () =>
_sendExp('\uD83D\uDFE8 Yellow notification', 'Emoji \uD83D\uDFE8 (U+1F7E8, 4-byte UTF-8)'));
$('btn-psn-exp-sq-all').addEventListener('click', () =>
_sendExp('\uD83D\uDFE5\uD83D\uDFE7\uD83D\uDFE8\uD83D\uDFE9\uD83D\uDFE6\uD83D\uDFEA Colour squares test', 'All 6 colour squares'));
// ANSI escape codes
$('btn-psn-exp-ansi-red').addEventListener('click', () =>
_sendExp('\x1b[31mRed ANSI text\x1b[0m', 'ANSI \\x1b[31m colour test'));
$('btn-psn-exp-ansi-bold').addEventListener('click', () =>
_sendExp('\x1b[1mBold ANSI text\x1b[0m', 'ANSI \\x1b[1m bold test'));
+5
View File
@@ -0,0 +1,5 @@
'use strict';
if ('serviceWorker' in navigator && window.isSecureContext && window.pork?.isWebMode) {
window.addEventListener('load', () => navigator.serviceWorker.register('/service-worker.js', { scope: '/' }).catch(error => console.warn('[Chimera PWA]', error.message)));
}
+561
View File
@@ -0,0 +1,561 @@
// ── Local Save Manager (native GarlicMgr UI) ────────────────────────────────
// ── State ───────────────────────────────────────────────────────────────────
const smState = {
saves: [], selectedIdx: -1, mounted: false,
files: [], expanded: new Set(),
busy: false, tab: 'browse',
};
let _smHasSuccessfulWarm = false;
// ── Terminal log ─────────────────────────────────────────────────────────────
function smLog(msg, cls) {
const logEl = $('sm-log');
if (!logEl) return;
const t = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = 'sm-ll' + (cls ? ' sm-' + cls : '');
div.innerHTML = `<span class="sm-lt">${t}</span><span>${escHtml(msg)}</span>`;
logEl.appendChild(div);
logEl.scrollTop = logEl.scrollHeight;
}
// ── Confirmation modal (native browser dialogs are blocked) ─────────────────
function smConfirm(message) {
return new Promise(resolve => {
const bg = document.createElement('div');
bg.style.cssText = 'position:fixed;inset:0;z-index:9900;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;';
bg.setAttribute('aria-hidden', 'true');
bg.innerHTML = `<div role="dialog" aria-modal="true" aria-labelledby="smconf-message" tabindex="-1" style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px 28px;max-width:360px;width:90%;">
<p id="smconf-message" style="font-size:14px;color:var(--text);margin-bottom:20px;line-height:1.5;">${escHtml(message)}</p>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button class="btn btn-sm" id="smconf-skip">Skip</button>
<button class="btn btn-sm btn-accent" id="smconf-ok">Replace</button>
</div></div>`;
document.body.appendChild(bg);
const cleanup = result => {
closeAccessibleDialog(bg);
bg.remove();
resolve(result);
};
bg.querySelector('#smconf-skip').addEventListener('click', () => cleanup(false));
bg.querySelector('#smconf-ok').addEventListener('click', () => cleanup(true));
bg.addEventListener('keydown', event => { if (event.key === 'Escape') cleanup(false); });
openAccessibleDialog(bg, bg.querySelector('#smconf-skip'));
});
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function smFmtBytes(b) {
if (!b) return '0B';
if (b < 1024) return b + 'B';
if (b < 1048576) return (b / 1024).toFixed(1) + 'K';
return (b / 1048576).toFixed(1) + 'M';
}
function smSetBusy(v) {
smState.busy = v;
['btn-sm-dl-zip','btn-sm-dl-enc','btn-sm-dump-usb','btn-sm-upload','btn-sm-unmount']
.forEach(id => { const el = $(id); if (el) el.disabled = v; });
}
function smShowUsbProgress(pct) {
const bar = $('sm-usb-progress'); if (bar) bar.hidden = false;
const fill = $('sm-usb-fill'); if (fill) fill.style.width = pct + '%';
}
function smHideUsbProgress() { const bar = $('sm-usb-progress'); if (bar) bar.hidden = true; }
function smShowUploadProgress(pct, loaded, total) {
const bar = $('sm-upload-progress'); if (bar) bar.hidden = false;
const fill = $('sm-upload-fill'); if (fill) fill.style.width = pct + '%';
const txt = $('sm-upload-text'); if (txt) txt.textContent = `${smFmtBytes(loaded)} / ${smFmtBytes(total)} (${pct}%)`;
}
function smHideUploadProgress() { const bar = $('sm-upload-progress'); if (bar) bar.hidden = true; }
// ── Load / reload saves ──────────────────────────────────────────────────────
async function loadSaveMgr(options = {}) {
const background = !!options.background;
const s = await window.pork.getSettings();
const ip = s.ftpHost || '';
const port = s.savemgrPort || 8082;
$('sm-ip-display').textContent = ip || '(not set — configure FTP Settings)';
$('sm-port').value = port;
const ssp = $('s-savemgr-port'); if (ssp) ssp.value = port;
if (ip && (!background || _smHasSuccessfulWarm)) await smFetchSaves();
}
async function smFetchSaves() {
try {
const d = await window.pork.savemgrRequest('/api/saves');
smState.saves = d.saves || [];
_smHasSuccessfulWarm = true;
smRenderSaves();
smLog(`Loaded ${smState.saves.length} saves`, 'ok');
} catch (e) {
smLog('Failed to load saves: ' + e.message, 'err');
smLog('Start the session only when needed with “Start session”.');
smState.saves = [];
smRenderSaves();
}
}
function smRenderSaves() {
const el = $('sm-saves-list');
if (!smState.saves.length) {
el.innerHTML = '<div class="sm-empty-list">No saves found</div>';
return;
}
el.innerHTML = smState.saves.map((s, i) =>
`<div class="sm-save-item${i === smState.selectedIdx ? ' active' : ''}" data-idx="${i}">
<div class="sm-save-tid">${escHtml(s.title_id)}</div>
<div class="sm-save-name">${escHtml(s.save_name)}</div>
</div>`
).join('');
el.querySelectorAll('.sm-save-item').forEach(item =>
item.addEventListener('click', () => smSelectSave(parseInt(item.dataset.idx)))
);
}
async function smSelectSave(idx) {
if (smState.busy || smState.tab !== 'browse') return;
smState.selectedIdx = idx;
smRenderSaves();
await smMount(idx);
}
async function smMount(idx) {
if (smState.busy) return;
smSetBusy(true);
const s = smState.saves[idx];
smLog(`Mounting ${s.title_id}/${s.save_name}...`);
try {
const d = await window.pork.savemgrRequest(`/api/mount?idx=${idx}`);
if (d.error) { smLog('Mount failed: ' + d.error, 'err'); smSetBusy(false); return; }
smState.mounted = true;
smState.files = d.files || [];
smState.expanded = new Set(smState.files.filter(f => f.dir).map(f => f.name));
$('sm-save-title-text').textContent = d.save_title || 'Untitled';
$('sm-save-title-id').innerHTML = d.title_id ? `Title ID: <b>${escHtml(d.title_id)}</b>` : '';
$('sm-save-account-id').innerHTML = d.account_id ? `&nbsp;&nbsp;Account: <b style="font-family:monospace">${escHtml(d.account_id)}</b>` : '';
$('sm-save-ftp-row').textContent = d.mount ? `FTP: ${d.mount}` : '';
$('sm-save-header').hidden = false;
$('sm-empty-state').hidden = true;
smLoadIcon();
smRenderFileTree();
smLog(`Mounted ${[d.title_id, d.save_title, d.account_id ? '['+d.account_id+']' : ''].filter(Boolean).join(' ')}`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
async function smLoadIcon() {
try {
const dataUrl = await window.pork.savemgrIcon();
const img = $('sm-game-icon');
if (dataUrl) { img.src = dataUrl; img.hidden = false; }
else img.hidden = true;
} catch (_) { $('sm-game-icon').hidden = true; }
}
// ── File tree ────────────────────────────────────────────────────────────────
function smRenderFileTree() {
const tree = $('sm-filetree');
tree.hidden = false;
let h = '';
smState.files.forEach((f, i) => {
const parts = f.name.split('/'); parts.pop();
let visible = true, pp = '';
for (let j = 0; j < parts.length; j++) {
pp = j ? pp + '/' + parts[j] : parts[j];
if (!smState.expanded.has(pp)) { visible = false; break; }
}
if (!visible) return;
const depth = f.name.split('/').length - 1;
const name = f.name.split('/').pop();
const pad = 12 + depth * 16;
if (f.dir) {
const open = smState.expanded.has(f.name);
h += `<div class="sm-fi sm-fi-dir" style="padding-left:${pad}px" data-fidx="${i}">
<span class="sm-fi-arrow">${open ? '▾' : '▸'}</span> 📁 ${escHtml(name)}</div>`;
} else {
const fname = escHtml(f.name).replace(/'/g, "\\'");
h += `<div class="sm-fi" style="padding-left:${pad}px" data-fname="${escHtml(f.name)}">
📄 ${escHtml(name)}<span class="sm-fi-sz">${smFmtBytes(f.size)}</span></div>`;
}
});
h += '<div class="sm-tip">Tip: drag &amp; drop files here to add or replace them</div>';
tree.innerHTML = h;
tree.querySelectorAll('.sm-fi-dir').forEach(el =>
el.addEventListener('click', () => {
const name = smState.files[parseInt(el.dataset.fidx)].name;
if (smState.expanded.has(name)) smState.expanded.delete(name);
else smState.expanded.add(name);
smRenderFileTree();
})
);
tree.querySelectorAll('.sm-fi:not(.sm-fi-dir)').forEach(el =>
el.addEventListener('contextmenu', e => { e.preventDefault(); smShowCtxMenu(e, el.dataset.fname); })
);
smSetupDropZone(tree, smHandleFileDrop);
}
// ── Context menu ─────────────────────────────────────────────────────────────
document.addEventListener('click', () => { const m = $('sm-ctx-menu'); if (m) m.remove(); });
function smShowCtxMenu(e, fname) {
const old = $('sm-ctx-menu'); if (old) old.remove();
const m = document.createElement('div');
m.className = 'sm-ctx'; m.id = 'sm-ctx-menu';
m.innerHTML = `<div data-action="download">Download</div>
<div data-action="upload">Upload File</div>
<div class="sm-ctx-del" data-action="delete">Delete</div>`;
m.style.left = e.clientX + 'px'; m.style.top = e.clientY + 'px';
document.body.appendChild(m);
const r = m.getBoundingClientRect();
if (r.right > window.innerWidth) m.style.left = (window.innerWidth - r.width - 4) + 'px';
if (r.bottom > window.innerHeight) m.style.top = (window.innerHeight - r.height - 4) + 'px';
m.querySelectorAll('[data-action]').forEach(item => item.addEventListener('click', () => {
m.remove();
if (item.dataset.action === 'download') smDownloadFile(fname);
else if (item.dataset.action === 'upload') smPickUploadFile();
else if (item.dataset.action === 'delete') smDeleteFile(fname);
}));
}
// ── File operations ──────────────────────────────────────────────────────────
async function smDownloadFile(fname) {
smLog(`Downloading ${fname}...`);
try {
await window.pork.savemgrDownload(`/api/download_file?name=${encodeURIComponent(fname)}`, fname.split('/').pop());
smLog(`Downloaded ${fname.split('/').pop()}`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
}
function smPickUploadFile() {
const input = document.createElement('input');
input.type = 'file'; input.multiple = true;
input.onchange = () => { if (input.files.length) smHandleFileDrop(Array.from(input.files)); };
input.click();
}
async function smDeleteFile(fname) {
const ok = await smConfirm(`Delete ${fname}?`);
if (!ok) return;
try {
const d = await window.pork.savemgrRequest(`/api/delete_file?name=${encodeURIComponent(fname)}`);
if (d.error) throw new Error(d.error);
smLog(`Deleted ${fname}`, 'ok');
await smRefreshFiles();
} catch (e) { smLog('Error: ' + e.message, 'err'); }
}
async function smRefreshFiles() {
const d = await window.pork.savemgrRequest('/api/files');
smState.files = d.files || [];
smState.expanded = new Set(smState.files.filter(f => f.dir).map(f => f.name));
smRenderFileTree();
}
async function smHandleFileDrop(files) {
if (smState.busy || !smState.mounted) return;
smSetBusy(true);
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.name.endsWith('.zip')) {
smLog(`Extracting ${file.name} (${smFmtBytes(file.size)})...`);
const buf = await file.arrayBuffer();
const d = await window.pork.savemgrUpload('/api/upload', buf);
smLog(`Extracted ${d.files} files`, 'ok');
} else {
const ce = await window.pork.savemgrRequest(`/api/file_exists?name=${encodeURIComponent(file.name)}`);
if (ce.exists) {
const replace = await smConfirm(`${file.name} already exists. Replace it?`);
if (!replace) { smLog(`Skipped ${file.name}`); continue; }
}
smLog(`Uploading ${file.name} (${smFmtBytes(file.size)})...`);
const buf = await file.arrayBuffer();
const d = await window.pork.savemgrUpload(`/api/upload_file?name=${encodeURIComponent(file.name)}`, buf);
smHideUploadProgress();
if (d.ok) smLog(`Added ${file.name}`, 'ok');
else smLog(d.error || 'Upload failed', 'err');
}
}
await smRefreshFiles();
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
smHideUploadProgress();
}
// ── Browse actions ────────────────────────────────────────────────────────────
async function smDownloadZip() {
if (smState.busy) return; smSetBusy(true);
const s = smState.saves[smState.selectedIdx];
const filename = s ? `${s.title_id}_${s.save_name}.zip` : 'save.zip';
smLog('Downloading decrypted zip...');
try {
await window.pork.savemgrDownload('/api/download', filename);
smLog(`Downloaded ${filename}`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
async function smDownloadEncrypted() {
if (smState.busy || smState.selectedIdx < 0) return; smSetBusy(true);
const s = smState.saves[smState.selectedIdx];
smLog('Unmounting and downloading encrypted save...');
try {
await window.pork.savemgrRequest('/api/unmount');
smState.mounted = false;
await window.pork.savemgrDownload(`/api/download_raw?idx=${smState.selectedIdx}`, `${s.title_id}_${s.save_name}`);
smState.selectedIdx = -1; smState.files = []; smState.expanded = new Set();
smRenderSaves();
$('sm-save-header').hidden = true; $('sm-filetree').hidden = true; $('sm-empty-state').hidden = false;
smLog('Downloaded encrypted save', 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
async function smDumpToUsb() {
if (smState.busy || smState.selectedIdx < 0) return; smSetBusy(true);
smLog('Dumping to USB...');
window.pork.on('savemgr:usb-progress', data => { smShowUsbProgress(data.progress); });
try {
const result = await window.pork.savemgrDumpUsb(smState.selectedIdx);
window.pork.off('savemgr:usb-progress');
smHideUsbProgress();
smLog(result.path ? `Dumped to ${result.path} (${smFmtBytes(result.size)})` : 'Dump complete', 'ok');
} catch (e) { window.pork.off('savemgr:usb-progress'); smHideUsbProgress(); smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
async function smUnmount() {
if (smState.busy) return; smSetBusy(true);
try {
await window.pork.savemgrRequest('/api/unmount');
smState.mounted = false; smState.selectedIdx = -1; smState.files = []; smState.expanded = new Set();
smRenderSaves();
$('sm-save-header').hidden = true; $('sm-filetree').hidden = true; $('sm-empty-state').hidden = false;
smLog('Unmounted', 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
// ── Sub-tab switching ─────────────────────────────────────────────────────────
function smSwitchTab(tab) {
smState.tab = tab;
document.querySelectorAll('.sm-tab').forEach(btn =>
btn.classList.toggle('active', btn.dataset.smTab === tab)
);
document.querySelectorAll('.sm-pane').forEach(pane =>
pane.hidden = (pane.id !== `sm-pane-${tab}`)
);
if (tab === 'browse') smFetchSaves();
}
// ── Decrypt drop ──────────────────────────────────────────────────────────────
async function smHandleDecryptDrop(files) {
const file = files[0]; if (!file || smState.busy) return;
smSetBusy(true);
smLog(`Uploading ${file.name} (${smFmtBytes(file.size)}) for decrypt...`);
try {
const buf = await file.arrayBuffer();
const filename = file.name.replace(/\.[^.]+$/, '') + '.zip';
const r = await window.pork.savemgrDecrypt(buf, filename);
if (r.canceled) smLog('Cancelled');
else smLog(`Decrypted → ${filename} (${smFmtBytes(r.size)})`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
// ── Encrypt drop ──────────────────────────────────────────────────────────────
async function smHandleEncryptDrop(files, folderName) {
if (!files.length || smState.busy) return;
const aid = $('sm-enc-aid').value.trim();
smSetBusy(true);
let totalSize = 0; for (const f of files) totalSize += f.size;
smLog(`Creating PFS image (${smFmtBytes(totalSize)} of files)...`);
try {
const pfs = await window.pork.savemgrCreatePfs(totalSize);
if (pfs.error) throw new Error(pfs.error);
smLog('PFS image created', 'ok');
for (let i = 0; i < files.length; i++) {
const f = files[i];
const raw = f._path || f.webkitRelativePath || f.name;
const parts = raw.split('/');
const relPath = parts.length > 1 ? parts.slice(1).join('/') : parts[0];
smLog(`Uploading (${i+1}/${files.length}) ${relPath}...`);
const buf = await f.arrayBuffer();
const d = await window.pork.savemgrUpload(`/api/upload_file?name=${encodeURIComponent(relPath)}`, buf);
if (!d.ok) throw new Error(d.error || `Upload failed: ${relPath}`);
}
smLog(`All ${files.length} files uploaded`, 'ok');
const name = folderName || 'encrypted_save';
smLog('Downloading encrypted save...');
const r = await window.pork.savemgrDownloadNew(name, aid);
if (r.canceled) smLog('Cancelled');
else smLog(`Encrypted save downloaded (${smFmtBytes(r.size)})`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
// ── Resign drop ───────────────────────────────────────────────────────────────
async function smHandleResignDrop(files) {
const file = files[0]; if (!file || smState.busy) return;
const aid = $('sm-resign-aid').value.trim();
if (!aid) { smLog('Enter new Account ID first', 'err'); return; }
smSetBusy(true);
smLog(`Uploading ${file.name} for resign...`);
try {
const buf = await file.arrayBuffer();
const filename = file.name.replace(/\.[^.]+$/, '') + '_resigned';
const r = await window.pork.savemgrResign(buf, aid, filename);
if (r.canceled) smLog('Cancelled');
else smLog(`Resigned → ${filename} (${smFmtBytes(r.size)})`, 'ok');
} catch (e) { smLog('Error: ' + e.message, 'err'); }
smSetBusy(false);
}
// ── Drop zone setup ───────────────────────────────────────────────────────────
function smSetupDropZone(el, handler, folderMode) {
el.ondragover = e => { e.preventDefault(); el.classList.add('sm-drag-over'); };
el.ondragleave = e => { e.preventDefault(); el.classList.remove('sm-drag-over'); };
el.ondrop = async e => {
e.preventDefault(); el.classList.remove('sm-drag-over');
if (folderMode) {
const items = e.dataTransfer.items, entries = [];
for (let i = 0; i < items.length; i++) {
const ent = items[i].webkitGetAsEntry?.(); if (ent) entries.push(ent);
}
if (entries.length && entries[0].isDirectory) {
const files = await smReadFolderEntries(entries[0], '');
handler(files, entries[0].name);
} else if (e.dataTransfer.files.length) handler(Array.from(e.dataTransfer.files));
} else {
if (e.dataTransfer.files.length) handler(Array.from(e.dataTransfer.files));
}
};
const btn = el.querySelector('.sm-browse-btn');
if (btn) btn.onclick = e => {
e.stopPropagation();
const input = document.createElement('input'); input.type = 'file';
if (folderMode) input.webkitdirectory = true;
input.onchange = () => {
if (!input.files.length) return;
if (folderMode) { const prefix = input.files[0].webkitRelativePath.split('/')[0]; handler(Array.from(input.files), prefix); }
else handler(Array.from(input.files));
};
input.click();
};
}
async function smReadFolderEntries(entry, basePath) {
return new Promise(resolve => {
if (entry.isFile) { entry.file(f => { f._path = basePath + f.name; resolve([f]); }); }
else if (entry.isDirectory) {
const reader = entry.createReader(), all = [];
const readBatch = () => reader.readEntries(entries => {
if (!entries.length) { Promise.all(all).then(arrs => resolve(arrs.flat())); return; }
for (const e of entries) all.push(smReadFolderEntries(e, basePath + entry.name + '/'));
readBatch();
});
readBatch();
} else resolve([]);
});
}
// ── Event wiring (run once on startup) ───────────────────────────────────────
(function smInitEvents() {
document.querySelectorAll('.sm-tab').forEach(btn =>
btn.addEventListener('click', () => smSwitchTab(btn.dataset.smTab))
);
$('btn-sm-start-session').addEventListener('click', async event => {
const button = event.currentTarget;
const s = await window.pork.getSettings();
const ip = s.ftpHost || '';
const port = parseInt($('sm-port').value) || 8082;
if (!ip) { showToast('PS5 IP not set. Configure FTP Settings first.', 'error'); return; }
button.disabled = true;
await window.pork.setSettings({ savemgrPort: port });
smLog('Starting Garlic Save Manager explicitly through PS5 Payload Manager…');
try {
await window.pork.savemgrStart();
smLog('Garlic Save Manager started', 'ok');
showToast('Save Manager session started.', 'ok');
await smFetchSaves();
} catch (error) {
smLog('Start failed: ' + error.message, 'err');
showToast(`Save Manager failed to start: ${error.message}`, 'error');
} finally {
button.disabled = false;
}
});
$('btn-sm-load').addEventListener('click', async () => {
const s = await window.pork.getSettings();
const ip = s.ftpHost || '';
const port = parseInt($('sm-port').value) || 8082;
if (!ip) { showToast('PS5 IP not set. Configure FTP Settings first.', 'error'); return; }
await window.pork.setSettings({ savemgrPort: port });
smFetchSaves();
});
$('btn-sm-save').addEventListener('click', async () => {
const port = parseInt($('sm-port').value) || 8082;
await window.pork.setSettings({ savemgrPort: port });
const ssp = $('s-savemgr-port'); if (ssp) ssp.value = port;
showToast('Save Manager port saved.', 'ok');
});
$('btn-sm-open-ext').addEventListener('click', async () => {
const s = await window.pork.getSettings();
const ip = s.ftpHost || ''; const port = parseInt($('sm-port').value) || 8082;
if (!ip) { showToast('PS5 IP not set.', 'error'); return; }
window.pork.openShell(`http://${ip}:${port}`);
});
$('btn-sm-dl-zip').addEventListener('click', smDownloadZip);
$('btn-sm-dl-enc').addEventListener('click', smDownloadEncrypted);
$('btn-sm-dump-usb').addEventListener('click', smDumpToUsb);
$('btn-sm-unmount').addEventListener('click', smUnmount);
$('btn-sm-upload').addEventListener('click', smPickUploadFile);
smSetupDropZone($('sm-decrypt-drop'), smHandleDecryptDrop, false);
smSetupDropZone($('sm-encrypt-drop'), (f, n) => smHandleEncryptDrop(f, n), true);
smSetupDropZone($('sm-resign-drop'), smHandleResignDrop, false);
// Drag-drop onto the right panel (file browser)
const rp = $('sm-right-pane');
rp.ondragover = e => { e.preventDefault(); if (smState.mounted) rp.classList.add('sm-drag-over'); };
rp.ondragleave = e => { e.preventDefault(); rp.classList.remove('sm-drag-over'); };
rp.ondrop = e => {
e.preventDefault(); rp.classList.remove('sm-drag-over');
if (smState.mounted && e.dataTransfer.files.length) smHandleFileDrop(Array.from(e.dataTransfer.files));
};
$('btn-sm-push-payload').addEventListener('click', async () => {
const confirmation = 'INSTALLEER GARLIC AUTOLOAD';
const entered = await showPrompt(
`Deze actie schrijft garlic-savemgr.elf en autoload.txt naar je PS5. Typ exact ${confirmation} om door te gaan:`,
''
);
if (entered !== confirmation) {
showToast('Installation cancelled: the confirmation phrase did not match exactly.', 'info');
return;
}
const btn = $('btn-sm-push-payload'), st = $('sm-push-status');
btn.disabled = true; st.textContent = 'Installing\u2026';
try {
const res = await window.pork.savemgrPushPayload({ confirmation });
st.textContent = res.appended
? 'Installed \u2713 \u2014 garlic-savemgr.elf uploaded and added to autoload.txt. Restart your PS5 to apply.'
: 'Installed \u2713 \u2014 garlic-savemgr.elf uploaded (already in autoload.txt). Restart your PS5 to apply.';
} catch (e) { st.textContent = 'Error: ' + e.message; }
btn.disabled = false;
});
const ssBtn = $('btn-s-savemgr-save');
if (ssBtn) ssBtn.addEventListener('click', async () => {
const port = parseInt($('s-savemgr-port').value) || 8082;
await window.pork.setSettings({ savemgrPort: port });
showToast('Save Manager port saved.', 'ok');
});
})();
+861
View File
@@ -0,0 +1,861 @@
function darkenHex(hex, amount = 0.18, satCap = null) {
const r = parseInt(hex.slice(1,3), 16) / 255;
const g = parseInt(hex.slice(3,5), 16) / 255;
const b = parseInt(hex.slice(5,7), 16) / 255;
const max = Math.max(r,g,b), min = Math.min(r,g,b);
let h = 0, s = 0, l = (max+min)/2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d/(2-max-min) : d/(max+min);
switch(max) {
case r: h = ((g-b)/d + (g<b?6:0))/6; break;
case g: h = ((b-r)/d + 2)/6; break;
case b: h = ((r-g)/d + 4)/6; break;
}
}
l = Math.max(0, l - amount);
if (satCap !== null) s = Math.min(s, satCap);
const hue2rgb = (p, q, t) => {
if (t<0) t+=1; if (t>1) t-=1;
if (t<1/6) return p+(q-p)*6*t;
if (t<1/2) return q;
if (t<2/3) return p+(q-p)*(2/3-t)*6;
return p;
};
const q = l < 0.5 ? l*(1+s) : l+s-l*s;
const p = 2*l - q;
const toHex = v => Math.round(hue2rgb(p,q,v)*255).toString(16).padStart(2,'0');
return `#${toHex(h+1/3)}${toHex(h)}${toHex(h-1/3)}`;
}
function applyAccent(hex) {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return;
// --accent = tint (text/borders); --accent-d = fill (buttons); --accent-rgb tracks the fill.
const fill = darkenHex(hex, 0.26, 0.78);
const r = parseInt(fill.slice(1,3), 16);
const g = parseInt(fill.slice(3,5), 16);
const b = parseInt(fill.slice(5,7), 16);
const root = document.documentElement;
root.style.setProperty('--accent', hex);
root.style.setProperty('--accent-d', fill);
root.style.setProperty('--accent-rgb', `${r}, ${g}, ${b}`);
const swatch = $('accent-swatch');
const picker = $('s-accent-color');
const hexEl = $('s-accent-hex');
if (swatch) swatch.style.background = fill;
if (picker) picker.value = hex;
if (hexEl) hexEl.value = hex.toUpperCase();
}
let settingsTimer = null;
let remoteGamePaths = [];
let localBackupPaths = [];
let _userDirty = false;
let _passDirty = false;
const settingsT = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback;
function renderConsoleProfiles(packet) {
const select = $('console-profile-active');
if (!select) return;
select.innerHTML = (packet.profiles || []).map(profile => `<option value="${escHtml(profile.id)}">${escHtml(profile.label)} · ${escHtml(profile.host || settingsT('settings.status.hostUnknown', 'host unknown'))}${profile.active ? ` · ${settingsT('settings.status.active', 'active')}` : ''}</option>`).join('');
select.value = packet.activeId || '';
const active = (packet.profiles || []).find(profile => profile.id === packet.activeId);
$('console-profile-status').textContent = active
? `${active.platform} · Remote Play ${active.secrets?.remotePlayKey ? settingsT('settings.status.linked', 'linked') : settingsT('settings.status.notLinked', 'not linked')} · Sentinel ${active.secrets?.sentinelPairingKey ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')}`
: settingsT('settings.status.noActiveProfile', 'No active profile');
}
function renderConsoleIdentity(identity) {
if (!$('settings-identity-profile')) return;
const unavailable = settingsT('settings.identity.unavailable', 'Not observed');
$('settings-identity-profile').textContent = identity?.profileLabel || unavailable;
$('settings-identity-local').textContent = identity?.local?.username || unavailable;
$('settings-identity-account').textContent = identity?.local?.accountIdMasked || unavailable;
$('settings-identity-psn').textContent = identity?.linkedPsn?.linked && identity.linkedPsn.onlineId ? identity.linkedPsn.onlineId : unavailable;
const observed = identity?.provenance?.observedAt ? new Date(identity.provenance.observedAt).toLocaleString() : settingsT('settings.identity.noTimestamp', 'no timestamp');
$('settings-identity-evidence').textContent = `${identity?.provenance?.source || 'unknown'} · ${observed}`;
}
async function loadConsoleProfiles() {
if (!window.pork.consoleProfiles || !$('console-profile-active')) return;
renderConsoleProfiles(await window.pork.consoleProfiles());
if (window.pork.consoleIdentity) renderConsoleIdentity(await window.pork.consoleIdentity());
}
function renderCompanionWorkers(workers) {
const target = $('companion-workers-list');
if (!target) return;
target.innerHTML = workers.length ? workers.map(worker => `<div class="ecosystem-service-row" data-worker-id="${escHtml(worker.id)}"><div><strong>${escHtml(worker.label)}</strong><span>${escHtml(worker.endpoint)} · ${worker.paired ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')}</span></div><div class="operations-inline-actions"><button class="btn btn-sm companion-worker-test">Capabilities</button><button class="btn btn-sm companion-worker-pair">${worker.paired ? settingsT('settings.companion.rotateKey', 'Rotate key') : 'Pairing key'}</button><button class="btn btn-sm btn-danger companion-worker-remove">${settingsT('btn.remove', 'Remove')}</button></div></div>`).join('') : `<p class="hint">${settingsT('settings.companion.empty', 'No companion workers configured.')}</p>`;
}
async function loadCompanionWorkers() {
if (window.pork.companionWorkers && $('companion-workers-list')) renderCompanionWorkers(await window.pork.companionWorkers());
}
async function loadTotpStatus() {
if (!$('totp-settings-status')) return;
if (!window.pork.totpStatus) {
$('totp-settings-status').textContent = settingsT('settings.twoFactor.unavailable', 'Unavailable in this runtime');
$('totp-settings-begin').disabled = true;
$('totp-settings-disable').hidden = true;
$('passkey-readiness').innerHTML = `<strong>${escHtml(settingsT('settings.passkeys.preparedTitle', 'Passkeys: safely prepared, not active yet'))}</strong><p>${escHtml(settingsT('settings.passkeys.runtimeUnavailable', 'This runtime does not expose the required enrollment service.'))}</p>`;
return;
}
let status;
try {
status = await Promise.race([
window.pork.totpStatus(),
new Promise((_, reject) => setTimeout(() => reject(new Error(settingsT('settings.twoFactor.timeout', 'Status check timed out'))), 3500)),
]);
} catch (error) {
$('totp-settings-status').textContent = settingsT('settings.twoFactor.unavailable', 'Temporarily unavailable');
$('totp-settings-status').title = error.message;
$('totp-settings-begin').disabled = true;
return;
}
$('totp-settings-begin').disabled = false;
$('totp-settings-status').textContent = status.enabled
? `${settingsT('settings.status.active', 'Active')} · ${status.recoveryCodesRemaining} ${settingsT('settings.twoFactor.codesRemaining', 'recovery codes remaining')}`
: status.pending ? settingsT('settings.twoFactor.pending', 'Pairing started but not confirmed') : settingsT('settings.status.inactive', 'Inactive');
$('totp-settings-begin').textContent = status.enabled ? settingsT('settings.twoFactor.repair', 'Pair authenticator again') : settingsT('settings.twoFactor.pair', 'Pair authenticator');
$('totp-settings-disable').hidden = !status.enabled;
$('passkey-readiness').innerHTML = `<strong>Passkeys: ${status.passkeys?.available ? settingsT('settings.status.available', 'available') : settingsT('settings.passkeys.prepared', 'safely prepared, not active yet')}</strong><p>${escHtml(status.passkeys?.reason || settingsT('settings.status.none', 'No status.'))}</p>`;
}
async function loadGenericNotificationStatus() {
if (!window.pork.genericNotificationStatus || !$('generic-notification-status')) return;
const status = await window.pork.genericNotificationStatus();
$('generic-notification-enabled').checked = status.enabled;
$('generic-notification-endpoint').value = '';
$('generic-notification-endpoint').placeholder = status.endpointConfigured ? `${settingsT('settings.notifications.configuredFor', 'Configured for')} ${status.endpointHost}; ${settingsT('settings.notifications.leaveBlank', 'leave blank to keep')}` : 'https://notify.example/chimera';
$('generic-notification-token').value = '';
$('generic-notification-cooldown').value = status.cooldownMinutes || 60;
$('generic-notification-rules').innerHTML = (status.availableRules || []).map(rule => `<label class="ecosystem-check"><input type="checkbox" data-notification-rule="${escHtml(rule)}" ${status.rules?.includes(rule) ? 'checked' : ''}> ${escHtml(settingsT(`settings.notifications.rule.${rule}`, rule.replace(/-/g, ' ')))}</label>`).join('');
$('generic-notification-status').textContent = `${status.format} · endpoint ${status.endpointConfigured ? status.endpointHost : settingsT('settings.status.notConfigured', 'not configured')} · bearer ${status.bearerConfigured ? settingsT('settings.status.encrypted', 'configured and encrypted') : settingsT('settings.status.notConfigured', 'not configured')}`;
}
function renderConsoleComparison(packet) {
const target = $('console-profiles-comparison');
target.hidden = false;
target.innerHTML = `<div class="ecosystem-update-list">${(packet.profiles || []).map(profile => `<div class="ecosystem-service-row"><div><strong>${profile.active ? '● ' : ''}${escHtml(profile.label)}</strong><span>${escHtml(profile.host)} · firmware ${escHtml(profile.firmware)} · ${escHtml(profile.hen)} ${escHtml(profile.henVersion)}</span></div><span class="hint">Remote Play ${profile.pairing?.remotePlay ? settingsT('settings.status.linked', 'linked') : settingsT('settings.status.notLinked', 'not linked')} · Sentinel ${profile.pairing?.sentinel ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')} · ${settingsT('settings.status.storage', 'storage')} ${escHtml(profile.storage?.status || 'unknown')}</span></div>`).join('')}</div>`;
}
$('console-profile-activate')?.addEventListener('click', async event => {
try {
const result = await window.pork.consoleProfileActivate($('console-profile-active').value);
renderConsoleProfiles(result);
await loadSettings();
showToast('Console profile activated; no automatic connection was started.', 'ok', 7000);
} catch (error) { showToast(error.message, 'error'); }
});
$('console-profile-new')?.addEventListener('submit', async event => {
event.preventDefault();
try {
const result = await window.pork.consoleProfileSave({ label: $('console-profile-new-label').value.trim(), host: $('console-profile-new-host').value.trim(), makeActive: false });
renderConsoleProfiles(result);
event.target.reset();
showToast(settingsT('settings.consoleProfiles.added', 'Console profile added without starting a connection.'), 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('console-profiles-compare')?.addEventListener('click', async event => {
try { renderConsoleComparison(await window.pork.consoleProfilesCompare()); }
catch (error) { showToast(error.message, 'error'); }
});
$('companion-worker-form')?.addEventListener('submit', async event => {
event.preventDefault();
try {
renderCompanionWorkers(await window.pork.companionWorkerSave({ id: $('companion-worker-id').value.trim(), label: $('companion-worker-label').value.trim(), endpoint: $('companion-worker-endpoint').value.trim() }));
event.target.reset(); showToast('Companion worker saved; no job was started.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('companion-workers-list')?.addEventListener('click', async event => {
const row = event.target.closest('[data-worker-id]'); if (!row) return; const id = row.dataset.workerId;
try {
if (event.target.closest('.companion-worker-test')) {
const status = await window.pork.companionWorkerCapabilities(id);
showToast(`Worker verified: ${status.capabilities.join(', ') || 'no supported capabilities'}; shell disabled.`, 'ok', 9000);
}
if (event.target.closest('.companion-worker-pair')) {
const workers = await window.pork.companionWorkers(); const rotate = workers.find(worker => worker.id === id)?.paired === true;
const phrase = `PAIR WORKER ${id}`; const confirmation = await showExactConfirmation(`${rotate ? 'Rotate' : 'Create'} the pairing key for this worker?`, phrase); if (!confirmation) return;
const result = await window.pork.companionWorkerPair({ id, rotate, confirmation });
if (result.pairingKey) { const output = $('companion-worker-pairing-output'); output.hidden = false; output.textContent = `Worker: ${id}\nPairing key (eenmalig zichtbaar): ${result.pairingKey}\n\nImporteer lokaal in Chimera Worker en wis deze weergave.`; }
await loadCompanionWorkers();
}
if (event.target.closest('.companion-worker-remove') && await showExactConfirmation(`Remove ${id} and erase its pairing key?`, 'DELETE')) renderCompanionWorkers(await window.pork.companionWorkerRemove(id));
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('totp-settings-begin')?.addEventListener('click', async event => {
if (!await showConfirm('Pair a new authenticator? TOTP becomes active only after you confirm a valid code.')) return;
try {
const enrollment = await window.pork.totpBegin(); const output = $('totp-settings-output'); output.hidden = false; output.textContent = `One-time TOTP secret: ${enrollment.secret}\n\nAuthenticator URI:\n${enrollment.uri}`;
const code = await showPrompt('Add the secret or URI to your authenticator, then enter the current 6-digit code:'); if (code == null) return;
const confirmed = await window.pork.totpConfirm(code); output.textContent = `TOTP is active. Save these recovery codes now; they will not be shown again:\n\n${confirmed.recoveryCodes.join('\n')}`;
await loadTotpStatus(); showToast('Two-factor authentication is active.', 'ok', 8000);
} catch (error) { showToast(error.message, 'error', 10000); }
});
$('totp-settings-disable')?.addEventListener('click', async () => {
const code = await showPrompt('Enter a valid authenticator or recovery code to disable TOTP:'); if (code == null) return;
if (!await showExactConfirmation('Disable two-factor authentication?', 'UITSCHAKELEN')) return;
try { await window.pork.totpDisable(code); $('totp-settings-output').hidden = true; await loadTotpStatus(); showToast('Two-factor authentication disabled.', 'ok'); }
catch (error) { showToast(error.message, 'error'); }
});
$('generic-notification-form')?.addEventListener('submit', async event => {
event.preventDefault();
try {
const token = $('generic-notification-token').value;
const endpoint = $('generic-notification-endpoint').value.trim();
const rules = [...document.querySelectorAll('[data-notification-rule]:checked')].map(input => input.dataset.notificationRule);
await window.pork.genericNotificationSave({ enabled: $('generic-notification-enabled').checked, rules, cooldownMinutes: Number($('generic-notification-cooldown').value), ...(endpoint ? { endpoint } : {}), ...(token ? { bearerToken: token } : {}) });
await loadGenericNotificationStatus(); showToast('Generic notification settings saved.', 'ok');
} catch (error) { showToast(error.message, 'error'); }
});
$('generic-notification-test')?.addEventListener('click', async event => {
try { await window.pork.genericNotificationTest(); showToast('Test notification delivered.', 'ok'); }
catch (error) { showToast(error.message, 'error'); }
});
$('settings-automation-analyze')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try {
const [sources, protection] = await Promise.all([window.pork.automationSourcesAnalyze(), window.pork.automationProtectionStatus()]);
$('settings-automation-analysis').innerHTML = `
<div class="ecosystem-metrics">
<div class="operations-metric"><strong>${sources.roots.length}</strong><span>roots inspected</span></div>
<div class="operations-metric"><strong>${sources.recommendations.length}</strong><span>high-confidence suggestions</span></div>
<div class="operations-metric"><strong>${protection.saveVault.eligible ? 'Ready' : 'Setup'}</strong><span>Save Vault</span></div>
</div>
${(sources.roots || []).map(item => `<div class="ecosystem-service-row"><div><strong>${escHtml(item.root)}</strong><span>${escHtml(item.classification)} · ${escHtml(item.confidence)} confidence · ${item.writable ? 'writable' : 'read-only/unavailable'}</span></div><span class="badge">${escHtml(item.exists ? 'inspected' : item.error || 'missing')}</span></div>`).join('')}
<p class="hint">No configuration changes were applied. Enable Save Vault, replication and restore drills from Settings → Automation after reviewing their targets.</p>`;
} catch (error) { showToast(error.message, 'error'); }
finally { button.disabled = false; }
});
function scheduleSettingsSave() {
clearTimeout(settingsTimer);
settingsTimer = setTimeout(commitSettingsSave, 600);
}
let _webhookDirty = false;
$('s-discord-webhook')?.addEventListener('input', () => { _webhookDirty = true; });
async function commitSettingsSave() {
const payload = {
ftpHost: $('s-host').value.trim(),
ftpPorts: $('s-ports').value.split(',').map(p => p.trim()).filter(Boolean),
backupPaths: localBackupPaths,
backupPath: localBackupPaths[0] || '',
payloadLocalPath: $('s-payload-local').value.trim(),
payloadRemotePath:$('s-payload-remote').value.trim(),
cheatsRemotePath: $('s-cheats-remote').value.trim(),
mediaPsNotifyEnabled: $('s-media-discord-notify').checked,
autoConnect: $('s-auto-connect').checked,
timeFormat: $('s-time-format').value,
hashConcurrency: Number($('s-hash-concurrency')?.value) || 8,
};
if (_userDirty) payload.ftpUser = $('s-user').value.trim();
if (_passDirty) payload.ftpPass = $('s-pass').value;
if (_webhookDirty) payload.discordWebhookUrl = $('s-discord-webhook').value.trim();
await window.pork.setSettings(payload);
_passDirty = false;
_webhookDirty = false;
$('d-host').value = $('s-host').value;
$('d-ports').value = $('s-ports').value;
setStatus('Settings saved', 'ok');
}
async function loadSettings(options = {}) {
const background = !!options.background;
const s = await window.pork.getSettings();
$('s-host').value = s.ftpHost || '';
$('s-ports').value = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts.join(', ') : '1337, 2121, 21';
localBackupPaths = s.backupPaths || (s.backupPath ? [s.backupPath] : []);
renderLocalBackupPaths();
$('s-payload-local').value = s.payloadLocalPath || '';
$('s-payload-remote').value = s.payloadRemotePath || '/data/payloads/';
$('s-cheats-remote').value = s.cheatsRemotePath || '/data/etaHEN/cheats/';
$('s-discord-webhook').value = '';
$('s-discord-webhook').placeholder = s.discordWebhookConfigured ? 'Stored securely — enter to replace' : 'https://discord.com/api/webhooks/...';
$('s-media-discord-notify').checked = s.mediaPsNotifyEnabled !== false; // default true
$('media-discord-psn-detail').hidden = s.mediaPsNotifyEnabled === false;
$('s-media-psn-download').checked = s.mediaPsNotifyDiscordDownload !== false;
$('s-media-psn-convert').checked = s.mediaPsNotifyDiscordConvert !== false;
$('s-media-psn-upload').checked = s.mediaPsNotifyDiscordUpload !== false;
$('s-media-psn-done').checked = s.mediaPsNotifyDiscordDone !== false;
$('s-media-psn-error').checked = s.mediaPsNotifyDiscordError !== false;
$('s-auto-connect').checked = s.autoConnect === true;
_timeFormat = s.timeFormat || '12h';
$('s-time-format').value = _timeFormat;
if ($('s-interface-density')) $('s-interface-density').value = applyDensity(s.interfaceDensity);
if (!background) {
$('d-host').value = s.ftpHost || '';
$('d-ports').value = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts.join(', ') : '1337, 2121, 21';
}
applyAccent(s.accentColor || '#CBBAFF');
remoteGamePaths = s.remoteGamePaths || [];
renderRemoteGamePaths();
await renderSettingsPayloadSources();
if (s.svHotkeys) _svHotkeys = { ..._svHotkeys, ...s.svHotkeys };
const hkMap = { 'hk-mute': 'mute', 'hk-gif': 'gif', 'hk-video': 'video', 'hk-fullscreen': 'fullscreen', 'hk-popout': 'popout' };
Object.entries(hkMap).forEach(([id, action]) => {
const el = $(id); if (el) el.value = _svHotkeys[action] || '';
});
let convMode = s.gameConversionMode || 'pfs';
if (window.pork.isWebMode) {
for (const unavailable of ['ffpkg', 'exfat']) {
const radio = document.querySelector(`input[name="conv-mode"][value="${unavailable}"]`);
if (radio) radio.disabled = true;
radio?.closest('.radio-card')?.setAttribute('title', settingsT('settings.windowsModeUnavailable', 'This Windows mode is unavailable in the Unraid container. Use FFPFSC.'));
const queueButton = document.querySelector(`.conv-mode-btn[data-conv-mode="${unavailable}"]`);
if (queueButton) queueButton.disabled = true;
}
if (!['pfs', 'ffpfsc'].includes(convMode)) {
convMode = 'ffpfsc';
await window.pork.setSettings({ gameConversionMode: convMode });
}
}
const modeRadio = document.querySelector(`input[name="conv-mode"][value="${convMode}"]`);
if (modeRadio) modeRadio.checked = true;
applyConvModeUI(convMode);
$('s-conv-ufs2-path').value = s.ufs2ToolPath || '';
$('s-conv-exfat-path').value = s.osfmountPath || s.exfatToolPath || '';
$('s-conv-output-dir').value = s.convOutputDir || '';
$('s-conv-temp-dir').value = s.convTempDir || '';
$('s-conv-ftp-upload').checked = !!s.convFtpUpload;
$('s-conv-delete-after').checked = !!s.convDeleteAfter;
$('s-ps4-package-builder').value = s.ps4PackageBuilderPath || '';
$('s-ps4-package-output').value = s.ps4PackageOutputPath || '';
window.pork.ps4PackageBuilderStatus().then(status => {
$('s-ps4-package-status').textContent = status.available ? settingsT('ps4Packages.settings.ready', 'Ready') : settingsT('ps4Packages.settings.blocked', 'Not configured');
$('s-ps4-package-status').className = `badge ${status.available ? 'badge-green' : 'badge-orange'}`;
}).catch(error => { $('s-ps4-package-status').textContent = error.message; });
$('s-ps5-native-package-builder').value = s.ps5NativePackageBuilderPath || '';
$('s-ps5-native-package-output').value = s.ps5NativePackageOutputPath || '';
window.pork.ps5NativePackageBuilderStatus().then(status => {
$('s-ps5-native-package-status').textContent = status.available ? settingsT('ps5NativePackages.settings.ready', 'Pinned bridge ready') : settingsT('ps5NativePackages.settings.blocked', 'Research bridge not configured');
$('s-ps5-native-package-status').className = `badge ${status.available ? 'badge-green' : 'badge-orange'}`;
}).catch(error => { $('s-ps5-native-package-status').textContent = error.message; });
$('conv-delete-after-row').hidden = false;
$('s-conv-delete-after').disabled = !s.convFtpUpload;
$('conv-delete-after-row').style.opacity = s.convFtpUpload ? '' : '0.45';
$('s-conv-psn-enabled').checked = s.convPsNotifyEnabled !== false;
$('conv-psn-detail').hidden = !s.convPsNotifyEnabled;
$('s-conv-psn-queued').checked = s.convPsNotifyOnGameQueued !== false;
$('s-conv-psn-batch').checked = s.convPsNotifyOnBatchQueued !== false;
$('s-conv-psn-copy').checked = s.convPsNotifyOnCopyStart !== false;
$('s-conv-psn-convert').checked = s.convPsNotifyOnConvertStart!== false;
$('s-conv-psn-done').checked = s.convPsNotifyOnJobDone !== false;
await refreshConvToolStatus();
if ($('s-hash-concurrency')) $('s-hash-concurrency').value = s.hashConcurrency || 8;
if ($('s-webui-port')) $('s-webui-port').value = s.webUiPort || 6967;
if (window.pork.isWebMode) {
$('s-webui-enabled').disabled = true;
$('s-webui-port').disabled = true;
$('btn-webui-apply').disabled = true;
$('s-webui-enabled').closest('.toggle-row')?.setAttribute('title', settingsT('settings.serverManagedByUnraid', 'This web server is managed as an Unraid container.'));
}
await refreshWebuiStatus();
const _langCode = s.language || 'en';
try {
await refreshLangDropdown(_langCode);
} catch (_) {}
await window.i18n.load(_langCode);
await Promise.all([
loadConsoleProfiles(),
loadCompanionWorkers(),
loadTotpStatus(),
loadGenericNotificationStatus(),
]);
}
['s-host', 's-ports', 's-hash-concurrency'].forEach(id => {
const el = $(id);
if (el) el.addEventListener('input', scheduleSettingsSave);
});
async function refreshLangDropdown(selectedCode) {
const langs = await window.pork.langList();
const langSel = $('s-language');
langSel.innerHTML = langs.map(l => {
const label = l.source === 'imported' ? `${escHtml(l.name)} ✦` : escHtml(l.name);
return `<option value="${escHtml(l.code)}"${l.code === selectedCode ? ' selected' : ''}>${label}</option>`;
}).join('');
}
$('s-language').addEventListener('change', async function() {
const code = this.value;
await window.pork.setSettings({ language: code });
await window.i18n.load(code);
await Promise.all([
loadConsoleProfiles(),
loadCompanionWorkers(),
loadTotpStatus(),
loadGenericNotificationStatus(),
]);
setStatus(window.t('status.languageChanged', 'Language changed'), 'ok');
});
$('s-interface-density')?.addEventListener('change', async function() {
const interfaceDensity = applyDensity(this.value);
await window.pork.setSettings({ interfaceDensity });
setStatus(window.t('settings.density.saved', 'Interface density updated.'), 'ok');
});
$('btn-import-lang').addEventListener('click', async () => {
const btn = $('btn-import-lang');
btn.disabled = true;
try {
const res = await window.pork.langImport();
if (res.canceled) return;
const currentCode = $('s-language').value;
await refreshLangDropdown(currentCode);
setStatus(`${window.t('lang.importDone', 'Language imported')}: ${res.name}`, 'ok');
if (res.code === currentCode) await window.i18n.load(res.code);
} catch (e) {
setStatus(`${window.t('lang.importError', 'Import failed')}: ${e.message}`, 'error');
} finally {
btn.disabled = false;
}
});
$('btn-export-lang').addEventListener('click', async () => {
const btn = $('btn-export-lang');
btn.disabled = true;
try {
const res = await window.pork.langExportBase();
if (!res.canceled) setStatus(window.t('lang.exportDone', 'English base file exported.'), 'ok');
} catch (e) {
setStatus(`${window.t('lang.exportError', 'Export failed')}: ${e.message}`, 'error');
} finally {
btn.disabled = false;
}
});
$('d-host').addEventListener('input', () => {
$('s-host').value = $('d-host').value;
scheduleSettingsSave();
});
$('d-ports').addEventListener('input', () => {
$('s-ports').value = $('d-ports').value;
scheduleSettingsSave();
});
$('s-user').addEventListener('input', () => { _userDirty = true; scheduleSettingsSave(); });
$('s-pass').addEventListener('input', () => { _passDirty = true; scheduleSettingsSave(); });
function renderLocalBackupPaths() {
const cont = $('settings-backup-paths-list');
if (!cont) return;
if (!localBackupPaths.length) {
cont.innerHTML = '<p class="hint" style="margin:6px 0">No folders added yet. Click &ldquo;+ Add Folder&hellip;&rdquo; to get started.</p>';
return;
}
cont.innerHTML = localBackupPaths.map((p, i) => `
<div class="remote-path-row" data-backup-idx="${i}">
<span class="remote-path-value" title="${escHtml(p)}" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p)}</span>
<button class="btn btn-sm btn-backup-path-remove" style="margin-left:auto;flex-shrink:0">&#10005;</button>
</div>
`).join('');
}
async function saveLocalBackupPaths() {
await window.pork.setSettings({
backupPaths: localBackupPaths,
backupPath: localBackupPaths[0] || '',
});
setStatus('Settings saved', 'ok');
}
$('settings-backup-paths-list').addEventListener('click', async e => {
const row = e.target.closest('[data-backup-idx]');
if (!row || !e.target.closest('.btn-backup-path-remove')) return;
const idx = Number(row.dataset.backupIdx);
const ok = await showConfirm(`Remove "${escHtml(localBackupPaths[idx])}" from game source folders?`);
if (!ok) return;
localBackupPaths = localBackupPaths.filter((_, i) => i !== idx);
await saveLocalBackupPaths();
renderLocalBackupPaths();
});
$('btn-add-backup-path').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (!folder) return;
if (localBackupPaths.includes(folder)) { setStatus('That folder is already in the list', 'ok'); return; }
localBackupPaths = [...localBackupPaths, folder];
await saveLocalBackupPaths();
renderLocalBackupPaths();
});
$('btn-browse-payload-local').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (!folder) return;
$('s-payload-local').value = folder;
await commitSettingsSave();
});
$('s-payload-remote').addEventListener('input', scheduleSettingsSave);
$('s-cheats-remote').addEventListener('input', scheduleSettingsSave);
$('s-discord-webhook').addEventListener('input', scheduleSettingsSave);
$('btn-browse-cheats-remote').addEventListener('click', async () => {
const cur = $('s-cheats-remote').value.trim() || '/';
const p = await openFtpBrowser(cur);
if (p) { $('s-cheats-remote').value = p; scheduleSettingsSave(); }
});
$('s-auto-connect').addEventListener('change', commitSettingsSave);
$('s-time-format').addEventListener('change', e => {
_timeFormat = e.target.value;
commitSettingsSave();
renderMediaGrid();
});
$('s-media-discord-notify').addEventListener('change', function() {
$('media-discord-psn-detail').hidden = !this.checked;
commitSettingsSave();
});
[
['s-media-psn-download', 'mediaPsNotifyDiscordDownload'],
['s-media-psn-convert', 'mediaPsNotifyDiscordConvert'],
['s-media-psn-upload', 'mediaPsNotifyDiscordUpload'],
['s-media-psn-done', 'mediaPsNotifyDiscordDone'],
['s-media-psn-error', 'mediaPsNotifyDiscordError'],
].forEach(([id, key]) => {
$(id).addEventListener('change', function() {
window.pork.setSettings({ [key]: this.checked });
});
});
function applyConvModeUI(mode) {
const isPfs = mode === 'pfs';
const isFfpkg = mode === 'ffpkg';
const isExfat = mode === 'exfat';
const isConv = !isPfs;
$('conv-ffpkg-fields').hidden = !isFfpkg;
$('conv-exfat-fields').hidden = !isExfat;
$('conv-shared-fields').hidden = !isConv;
$('conv-notify-fields').hidden = !isConv;
const badge = $('conv-queue-mode-badge');
if (badge) badge.textContent = isPfs ? 'Raw Dumps' : isFfpkg ? 'FFPKG' : isExfat ? 'ExFAT' : 'FFPFSC';
}
async function refreshConvToolStatus() {
const mode = document.querySelector('input[name="conv-mode"]:checked')?.value || 'pfs';
if (mode === 'ffpkg') {
try {
const info = await window.pork.convToolInfo();
const el = $('s-conv-ufs2-status');
if (!el) return;
if (info.toolAvailable) {
el.textContent = '✓ UFS2Tool.exe found';
el.style.color = 'var(--green)';
} else {
el.textContent = info.toolPath ? '✗ File not found at configured path' : '✗ Not configured';
el.style.color = 'var(--red)';
}
} catch (_) {}
}
if (mode === 'exfat') {
try {
const info = await window.pork.convExfatToolInfo();
const el = $('s-conv-exfat-status');
if (!el) return;
if (info.toolAvailable) {
el.textContent = `✓ OSFMount found — native exFAT (${info.osfmountPath || info.toolPath})`;
el.style.color = 'var(--green)';
} else {
el.textContent = '✗ OSFMount not found — install OSFMount or pick osfmount.com';
el.style.color = 'var(--red)';
}
} catch (_) {}
}
}
document.querySelectorAll('input[name="conv-mode"]').forEach(radio => {
radio.addEventListener('change', async () => {
const mode = radio.value;
applyConvModeUI(mode);
await window.pork.setSettings({ gameConversionMode: mode });
await refreshConvToolStatus();
setStatus('Conversion mode saved', 'ok');
});
});
document.querySelectorAll('input[name="ufs2-method"]').forEach(radio => {
radio.addEventListener('change', async () => {
await window.pork.setSettings({ ufs2Method: radio.value });
setStatus('UFS2 method saved', 'ok');
});
});
$('btn-conv-ufs2-pick').addEventListener('click', async () => {
const info = await window.pork.convToolPick();
if (!info) return;
$('s-conv-ufs2-path').value = info.toolPath || '';
const el = $('s-conv-ufs2-status');
if (el) {
el.textContent = info.toolAvailable ? '✓ UFS2Tool.exe found' : '✗ File not found';
el.style.color = info.toolAvailable ? 'var(--green)' : 'var(--red)';
}
setStatus('UFS2Tool path saved', 'ok');
});
$('btn-conv-exfat-pick').addEventListener('click', async () => {
const info = await window.pork.convExfatToolPick();
if (!info) return;
$('s-conv-exfat-path').value = info.toolPath || '';
await refreshConvToolStatus();
setStatus('ExFAT tool path saved', 'ok');
});
$('btn-conv-output-pick').addEventListener('click', async () => {
const p = await window.pork.convPickOutputDir();
if (!p) return;
$('s-conv-output-dir').value = p;
setStatus('Output directory saved', 'ok');
});
$('btn-conv-temp-pick').addEventListener('click', async () => {
const p = await window.pork.convPickTempDir();
if (!p) return;
$('s-conv-temp-dir').value = p;
setStatus('Temp directory saved', 'ok');
});
$('s-conv-ftp-upload').addEventListener('change', async function() {
const ftpEnabled = this.checked;
$('s-conv-delete-after').disabled = !ftpEnabled;
$('conv-delete-after-row').style.opacity = ftpEnabled ? '' : '0.45';
if (!ftpEnabled) {
$('s-conv-delete-after').checked = false;
await window.pork.setSettings({ convFtpUpload: false, convDeleteAfter: false });
} else {
await window.pork.setSettings({ convFtpUpload: true });
}
setStatus('Settings saved', 'ok');
});
$('s-conv-delete-after').addEventListener('change', async function() {
await window.pork.setSettings({ convDeleteAfter: this.checked });
setStatus('Settings saved', 'ok');
});
$('btn-ps4-package-builder').addEventListener('click', async () => {
const folder = await window.pork.selectFolder(); if (!folder) return;
await window.pork.setSettings({ ps4PackageBuilderPath: folder });
$('s-ps4-package-builder').value = folder;
const status = await window.pork.ps4PackageBuilderStatus();
$('s-ps4-package-status').textContent = status.available ? settingsT('ps4Packages.settings.ready', 'Ready') : settingsT('ps4Packages.settings.blocked', 'Not configured');
});
$('btn-ps4-package-output').addEventListener('click', async () => {
const folder = await window.pork.selectFolder(); if (!folder) return;
await window.pork.setSettings({ ps4PackageOutputPath: folder });
$('s-ps4-package-output').value = folder;
showToast(settingsT('ps4Packages.settings.restart', 'Restart Chimera to activate the new package vault.'), 'info');
});
$('btn-ps5-native-package-builder').addEventListener('click', async () => {
const folder = await window.pork.selectFolder(); if (!folder) return;
await window.pork.setSettings({ ps5NativePackageBuilderPath: folder });
$('s-ps5-native-package-builder').value = folder;
const status = await window.pork.ps5NativePackageBuilderStatus();
$('s-ps5-native-package-status').textContent = status.available ? settingsT('ps5NativePackages.settings.ready', 'Pinned bridge ready') : settingsT('ps5NativePackages.settings.blocked', 'Research bridge not configured');
});
$('btn-ps5-native-package-output').addEventListener('click', async () => {
const folder = await window.pork.selectFolder(); if (!folder) return;
await window.pork.setSettings({ ps5NativePackageOutputPath: folder });
$('s-ps5-native-package-output').value = folder;
showToast(settingsT('ps5NativePackages.settings.restart', 'Restart Chimera to activate the native package vault.'), 'info');
});
$('s-conv-psn-enabled').addEventListener('change', async function() {
$('conv-psn-detail').hidden = !this.checked;
await window.pork.setSettings({ convPsNotifyEnabled: this.checked });
setStatus('Settings saved', 'ok');
});
[
['s-conv-psn-queued', 'convPsNotifyOnGameQueued'],
['s-conv-psn-batch', 'convPsNotifyOnBatchQueued'],
['s-conv-psn-copy', 'convPsNotifyOnCopyStart'],
['s-conv-psn-convert', 'convPsNotifyOnConvertStart'],
['s-conv-psn-done', 'convPsNotifyOnJobDone'],
].forEach(([id, key]) => {
$(id).addEventListener('change', function() {
window.pork.setSettings({ [key]: this.checked });
});
});
$('settings-ftp-presets').addEventListener('click', e => {
const btn = e.target.closest('[data-add-port]');
if (!btn) return;
const port = btn.dataset.addPort;
const current = $('s-ports').value.split(',').map(p => p.trim()).filter(Boolean);
if (!current.includes(port)) {
current.push(port);
$('s-ports').value = current.join(', ');
commitSettingsSave();
}
});
{
const hkMap = { 'hk-mute': 'mute', 'hk-gif': 'gif', 'hk-video': 'video', 'hk-fullscreen': 'fullscreen', 'hk-popout': 'popout' };
Object.entries(hkMap).forEach(([id, action]) => {
const input = $(id);
if (!input) return;
input.addEventListener('focus', () => {
input.dataset.prev = input.value;
input.value = '';
input.placeholder = 'press a key…';
input.classList.add('capturing');
});
input.addEventListener('blur', () => {
if (!input.value) input.value = input.dataset.prev || '';
input.placeholder = '—';
input.classList.remove('capturing');
});
input.addEventListener('keydown', async e => {
e.preventDefault();
if (e.key === 'Escape') { input.value = input.dataset.prev || ''; input.blur(); return; }
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
input.value = key;
_svHotkeys[action] = key;
input.blur();
await window.pork.setSettings({ svHotkeys: { ..._svHotkeys } });
setStatus('Hotkey saved', 'ok');
});
});
}
function renderRemoteGamePaths() {
const cont = $('settings-remote-paths-list');
if (!remoteGamePaths.length) {
cont.innerHTML = '<p class="hint" style="margin:6px 0">No paths configured. Scan will use standard PS5 paths.</p>';
return;
}
cont.innerHTML = remoteGamePaths.map((p, i) => `
<div class="remote-path-row" data-idx="${i}">
<span class="remote-path-label">${escHtml(p.label)}</span>
<span class="remote-path-value" title="${escHtml(p.path)}">${escHtml(p.path)}</span>
<button class="btn btn-sm btn-remote-path-remove" style="margin-left:auto">&#10005;</button>
</div>
`).join('');
}
async function saveRemoteGamePaths() {
await window.pork.setSettings({ remoteGamePaths });
setStatus('Settings saved', 'ok');
}
$('btn-add-remote-path').addEventListener('click', async () => {
const label = $('new-remote-label').value.trim();
const path = $('new-remote-path').value.trim();
if (!path) { setStatus('Enter a remote path', 'error'); return; }
remoteGamePaths = [...remoteGamePaths, { label: label || path, path }];
await saveRemoteGamePaths();
renderRemoteGamePaths();
$('new-remote-label').value = '';
$('new-remote-path').value = '';
});
$('settings-remote-paths-list').addEventListener('click', async e => {
const row = e.target.closest('[data-idx]');
if (!row || !e.target.closest('.btn-remote-path-remove')) return;
const idx = Number(row.dataset.idx);
const ok = await showConfirm(`Remove "${escHtml(remoteGamePaths[idx]?.label)}" from remote game paths?`);
if (!ok) return;
remoteGamePaths = remoteGamePaths.filter((_, i) => i !== idx);
await saveRemoteGamePaths();
renderRemoteGamePaths();
});
async function refreshWebuiStatus() {
try {
const status = await window.pork.webuiStatus();
const badge = $('webui-status-badge');
const urlRow = $('webui-url-row');
const urlEl = $('webui-url');
const toggle = $('s-webui-enabled');
if (badge) badge.hidden = !status.running;
if (toggle) toggle.checked = status.running;
if (urlRow && urlEl) {
if (status.running) {
const port = status.port;
const hosts = Array.isArray(status.hosts) && status.hosts.length ? status.hosts : ['localhost'];
const primary = (window.pork.isWebMode && location.hostname)
? location.hostname
: hosts[0];
const primaryUrl = status.publicOrigin || (window.pork.isWebMode ? location.origin : `http://${primary}:${port}`);
urlEl.textContent = primaryUrl;
urlRow.hidden = false;
const openBtn = $('btn-webui-open');
if (openBtn) openBtn.onclick = () => window.pork.openShell ? window.pork.openShell(primaryUrl) : window.open(primaryUrl, '_blank');
} else {
urlRow.hidden = true;
}
}
} catch (_) {}
}
$('s-webui-enabled').addEventListener('change', async function () {
const btn = $('btn-webui-apply');
if (btn) btn.disabled = true;
try {
if (this.checked) {
const port = Number($('s-webui-port').value) || 6967;
await window.pork.setSettings({ webUiEnabled: true, webUiPort: port });
const res = await window.pork.webuiStart();
if (!res.ok) { setStatus(`Web UI failed: ${res.error}`, 'error'); this.checked = false; }
else setStatus('Web UI server started', 'ok');
} else {
await window.pork.setSettings({ webUiEnabled: false });
const res = await window.pork.webuiStop();
if (res.ok) setStatus('Web UI server stopped', 'ok');
}
} catch (e) {
setStatus(`Web UI error: ${e.message}`, 'error');
} finally {
if (btn) btn.disabled = false;
await refreshWebuiStatus();
}
});
$('btn-webui-apply').addEventListener('click', async () => {
const port = Number($('s-webui-port').value) || 6967;
await window.pork.setSettings({ webUiPort: port });
if ($('s-webui-enabled').checked) {
await window.pork.webuiStop();
const res = await window.pork.webuiStart();
if (res.ok) setStatus(`Web UI restarted on port ${port}`, 'ok');
else setStatus(`Web UI restart failed: ${res.error}`, 'error');
} else {
setStatus('Port saved', 'ok');
}
await refreshWebuiStatus();
});
+485
View File
@@ -0,0 +1,485 @@
'use strict';
// State
const state = {
connected: false,
games: [],
backups: [],
sortCol: localStorage.getItem('chimera.games.sortCol') || 'title',
sortDir: localStorage.getItem('chimera.games.sortDir') || 'asc',
filter: localStorage.getItem('chimera.games.filter') || 'all',
platformFilter: ['all', 'ps4', 'ps5', 'unknown'].includes(localStorage.getItem('chimera.games.platform'))
? localStorage.getItem('chimera.games.platform')
: 'all',
search: '',
firmwareFilter: 'all',
gameView: localStorage.getItem('chimera.games.view') || 'grid',
gameRenderLimit: 160,
currentGame: null,
};
// Shared hash summary maps — populated by loadGames() and loadBackporks().
// _gameHashSummary: game_id → summary for hash_type='game'
// _backporkHashSummary: 'game_id|firmware_label' → summary for hash_type='backpork'
const _gameHashSummary = new Map();
const _backporkHashSummary = new Map();
const _pageLoadTimestamps = new Map();
const _pageLoadModes = new Map();
const _pageLoadInFlight = new Map();
let _pageWarmTimer = null;
let _pageWarmRunning = false;
// Feature code is route-loaded. Background refreshes only revisit modules that a user
// already opened; they never download the entire application after startup.
const _PAGE_WARM_ORDER = [];
const _PAGE_STALE_MS = {
dashboard: 15_000,
transfers: 5_000,
'system-view': 20_000,
media: 30_000,
default: 45_000,
};
function _populateHashMaps(summaries) {
_gameHashSummary.clear();
_backporkHashSummary.clear();
for (const s of summaries) {
if (s.hash_type === 'backpork') {
_backporkHashSummary.set(s.game_id + '|' + (s.firmware_label || ''), s);
} else {
_gameHashSummary.set(s.game_id, s);
}
}
}
// System View state
let _svStream = null;
let _svMuted = false;
let _svAudioCtx = null; // AudioContext for low-latency capture-card audio
let _svAudioGain = null; // GainNode — controls mute without stopping the stream
let _svHotkeys = { mute: 'm', gif: 'g', video: 'v', fullscreen: 'f', popout: 'p' };
let _svRecording = false;
let _svRecFrames = []; // Uint8ClampedArray[] — one per captured frame
let _svRecInterval = null;
let _svRecTimer = null;
let _svRecStart = 0;
let _svRecCanvas = null; // lazy-created hidden 640×360 canvas
let _svRecCtx = null;
const GIF_W = 640, GIF_H = 360;
// Video recording state
let _svVidRecorder = null;
let _svVidWritable = null;
let _svVidChunks = null;
let _svVidTimer = null;
let _svVidStart = 0;
let _svVidPaused = false;
let _svVidBytes = 0;
let _svVidMimeType = '';
let _svVidExt = '';
// Helpers
const $ = id => document.getElementById(id);
const _dialogReturnFocus = new WeakMap();
function dialogFocusable(container) {
if (!container) return [];
return [...container.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')]
.filter(element => !element.hidden && element.getAttribute('aria-hidden') !== 'true' && element.getClientRects().length);
}
function openAccessibleDialog(overlay, initialFocus = null) {
if (!overlay) return;
_dialogReturnFocus.set(overlay, document.activeElement instanceof HTMLElement ? document.activeElement : null);
overlay.hidden = false;
overlay.setAttribute('aria-hidden', 'false');
document.body.classList.add('chimera-dialog-open');
requestAnimationFrame(() => (initialFocus || dialogFocusable(overlay)[0] || overlay.querySelector('[role="dialog"]'))?.focus?.());
}
function closeAccessibleDialog(overlay) {
if (!overlay) return;
overlay.hidden = true;
overlay.setAttribute('aria-hidden', 'true');
const returnFocus = _dialogReturnFocus.get(overlay);
_dialogReturnFocus.delete(overlay);
if (![...document.querySelectorAll('[role="dialog"][aria-modal="true"]')]
.some(dialog => !dialog.closest('[hidden]') && dialog.getClientRects().length)) {
document.body.classList.remove('chimera-dialog-open');
}
if (returnFocus?.isConnected) requestAnimationFrame(() => returnFocus.focus());
}
document.addEventListener('keydown', event => {
const tab = event.target.closest?.('[role="tab"]');
if (tab && ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
const tabs = [...tab.closest('[role="tablist"]')?.querySelectorAll('[role="tab"]') || []].filter(item => !item.disabled && !item.hidden);
if (tabs.length) {
event.preventDefault();
const current = Math.max(0, tabs.indexOf(tab));
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
tabs[next].focus();
tabs[next].click();
return;
}
}
if (event.key !== 'Tab') return;
const dialogs = [...document.querySelectorAll('[role="dialog"][aria-modal="true"]')]
.filter(dialog => !dialog.closest('[hidden]') && dialog.getClientRects().length);
const dialog = dialogs.at(-1);
if (!dialog) return;
const focusable = dialogFocusable(dialog);
if (!focusable.length) { event.preventDefault(); dialog.focus(); return; }
const first = focusable[0];
const last = focusable.at(-1);
if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
});
document.addEventListener('error', event => {
const image = event.target;
if (!(image instanceof HTMLImageElement) || !image.dataset.imageError) return;
const fallbackSrc = image.dataset.fallbackSrc;
if (fallbackSrc && image.dataset.fallbackAttempted !== 'true' && image.currentSrc !== fallbackSrc) {
image.dataset.fallbackAttempted = 'true';
image.classList.add('is-fallback-artwork');
image.src = fallbackSrc;
return;
}
image.hidden = true;
if (image.dataset.imageError === 'fallback' && image.nextElementSibling) image.nextElementSibling.hidden = false;
}, true);
function setGameWorkspaceContext(gameId, target) {
const normalized = String(gameId || '').trim().toUpperCase();
if (!/^(?:CUSA|PPSA)\d{5}$/.test(normalized)) return false;
sessionStorage.setItem('chimera.gameWorkspace', JSON.stringify({ gameId: normalized, target: String(target || ''), at: Date.now() }));
return true;
}
function consumeGameWorkspaceContext(target) {
try {
const raw = sessionStorage.getItem('chimera.gameWorkspace');
const context = raw ? JSON.parse(raw) : null;
if (!context || context.target !== target || Date.now() - Number(context.at || 0) > 5 * 60 * 1000) return null;
sessionStorage.removeItem('chimera.gameWorkspace');
return /^(?:CUSA|PPSA)\d{5}$/.test(String(context.gameId || '')) ? context.gameId : null;
} catch (_) {
sessionStorage.removeItem('chimera.gameWorkspace');
return null;
}
}
function _isNarrowLayout() {
return window.matchMedia('(max-width: 900px)').matches;
}
function setMobileNavOpen(open) {
document.body.classList.toggle('mobile-nav-open', !!open && _isNarrowLayout());
const isOpen = document.body.classList.contains('mobile-nav-open');
const toggle = $('mobile-nav-toggle');
if (toggle) {
toggle.setAttribute('aria-expanded', String(isOpen));
toggle.setAttribute('aria-label', isOpen ? 'Close navigation' : 'Open navigation');
toggle.title = isOpen ? 'Close navigation' : 'Open navigation';
toggle.textContent = isOpen ? '×' : '☰';
}
const companionMore = $('chimera-companion-more');
if (companionMore) companionMore.setAttribute('aria-expanded', String(isOpen));
const scrim = $('sidebar-scrim');
if (scrim) scrim.hidden = !isOpen;
const companionDock = $('chimera-companion-dock');
if (companionDock) {
companionDock.toggleAttribute('inert', isOpen);
if (isOpen) companionDock.setAttribute('aria-hidden', 'true');
else companionDock.removeAttribute('aria-hidden');
}
if (isOpen) {
requestAnimationFrame(() => document.querySelector('.chimera-route-link.active, .chimera-domain-home.active')?.scrollIntoView({ block: 'nearest' }));
}
}
function closeMobileNav() {
setMobileNavOpen(false);
}
function applyDensity(value) {
const density = value === 'compact' ? 'compact' : 'comfortable';
document.body.dataset.density = density;
return density;
}
function _pageLoader(page, background = false) {
const loaders = {
dashboard: () => loadDashboard(),
games: () => loadGames(),
packages: () => loadPackages(),
backporks: () => loadBackporks(),
backups: () => loadBackups(),
database: () => loadDatabase(),
settings: () => loadSettings({ background }),
jailbreak: () => loadJailbreak(),
psnotify: () => loadPsNotify(),
xavatar: () => loadXavatar(),
'system-view': () => loadSystemView(background ? { passive: true } : {}),
transfers: () => loadTransfers(),
media: () => loadMedia(),
cheats: () => loadCheats({ background }),
garlic: () => loadGarlic(),
'elf-arsenal': () => (typeof loadElfArsenal === 'function' ? loadElfArsenal({ background }) : null),
'pfs-ripper': () => (typeof loadPfsRipper === 'function' ? loadPfsRipper({ background }) : null),
savemgr: () => loadSaveMgr({ background }),
ecosystem: () => (typeof loadEcosystem === 'function' ? loadEcosystem({ background }) : null),
operations: () => (typeof loadOperations === 'function' ? loadOperations({ background }) : null),
};
return loaders[page] || null;
}
function _pageStaleMs(page) {
return _PAGE_STALE_MS[page] || _PAGE_STALE_MS.default;
}
function pageIsStale(page) {
const last = _pageLoadTimestamps.get(page) || 0;
return (Date.now() - last) > _pageStaleMs(page);
}
function ensurePageReady(page, { background = false, force = false } = {}) {
const needsActiveHydrate = !background && _pageLoadModes.get(page) === 'background';
if (!force && !needsActiveHydrate && !pageIsStale(page)) return _pageLoadInFlight.get(page) || Promise.resolve();
if (_pageLoadInFlight.has(page)) return _pageLoadInFlight.get(page);
const run = Promise.resolve()
.then(() => window.ChimeraModuleLoader?.ensureForPage(page))
.then(() => {
const loader = _pageLoader(page, background);
return loader ? loader() : null;
})
.then(() => {
_pageLoadTimestamps.set(page, Date.now());
const priorMode = _pageLoadModes.get(page);
_pageLoadModes.set(page, background && priorMode === 'active' ? 'active' : (background ? 'background' : 'active'));
})
.catch(err => {
if (background) {
const method = isExpectedUnavailable(err) ? 'info' : 'warn';
console[method](`[page-load] ${page} was not preloaded: ${err.message}`);
return;
}
throw err;
})
.finally(() => {
if (_pageLoadInFlight.get(page) === run) _pageLoadInFlight.delete(page);
});
_pageLoadInFlight.set(page, run);
return run;
}
function scheduleBackgroundWarm(delay = 600) {
if (_pageWarmTimer || _pageWarmRunning) return;
_pageWarmTimer = setTimeout(() => {
_pageWarmTimer = null;
startBackgroundWarm().catch(() => {});
}, delay);
}
async function startBackgroundWarm() {
if (_pageWarmRunning) return;
_pageWarmRunning = true;
try {
for (const page of _PAGE_WARM_ORDER) {
const active = document.querySelector('.page.active')?.id?.replace('page-', '');
if (page === active) continue;
await ensurePageReady(page, { background: true });
await new Promise(resolve => setTimeout(resolve, 250));
}
} finally {
_pageWarmRunning = false;
scheduleBackgroundWarm(_PAGE_STALE_MS.default);
}
}
function fmt(bytes) {
const n = Number(bytes);
if (!n || isNaN(n)) return '—';
const gb = n / 1073741824;
return gb >= 1 ? `${gb.toFixed(2)} GB` : `${(n / 1048576).toFixed(1)} MB`;
}
function yesNo(v) {
return v ? '<span class="pill pill-yes">Yes</span>' : '<span class="pill pill-no">No</span>';
}
function escHtml(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Pinnable cards registry
const PINNABLE_CARDS = [
{ id: 'payload-update', label: 'Payload Update Checker', page: 'jailbreak', hint: 'Check sources for payload updates and push to PS5.' },
{ id: 'payload-mgr', label: 'Payload Sender', page: 'jailbreak', type: 'widget', hint: 'Quick-send a payload to PS5 via TCP.' },
{ id: 'psnotify', label: 'Send Notification', page: 'psnotify', type: 'widget', hint: 'Send a PS5 notification from the dashboard.' },
{ id: 'xavatar-convert', label: 'XAvatar Quick Send', page: 'xavatar', type: 'widget', hint: 'Convert an image and send to PS5 as .xavatar.' },
{ id: 'xavatar-library', label: 'XAvatar Library', page: 'xavatar', hint: 'Manage .xavatar files on your PS5.' },
{ id: 'transfers', label: 'Transfer Manager', page: 'transfers', hint: 'Monitor active and queued FTP transfers.' },
{ id: 'games', label: 'Games Library', page: 'games', hint: 'Browse and manage your PS5 game collection.' },
{ id: 'cheats', label: 'Cheats', page: 'cheats', hint: 'Browse and install game cheats.' },
{ id: 'media', label: 'Media', page: 'media', hint: 'Browse screenshots and video clips from PS5.' },
{ id: 'system-view', label: 'System View', page: 'system-view', hint: 'View your PS5 via capture card stream.' },
{ id: 'backups', label: 'Backups', page: 'backups', hint: 'Browse and manage game backup files.' },
// Other page widgets
{ id: 'dash-savemgr', label: 'Save Manager', page: 'savemgr', type: 'widget', hint: 'Browse and download PS5 save files.' },
];
const _PIN_PAGE_LABELS = {
jailbreak: 'Payload Workspace', psnotify: 'Notifications', xavatar: 'XAvatar',
transfers: 'Transfers', games: 'Games', cheats: 'Cheats & patches', media: 'Media',
'system-view': 'System View', backups: 'Backups', savemgr: 'Local Save Manager',
ecosystem: 'Console Status', operations: 'Operations',
};
function setStatus(msg, type = '') {
const el = $('sb-msg');
el.textContent = msg;
el.style.color = type === 'error' ? 'var(--red)' : type === 'ok' ? 'var(--green)' : 'var(--yellow)';
if (msg) setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 5000);
}
function titleHue(id){let h=0;for(const c of String(id||''))h=(h*31+c.charCodeAt(0))%360;return h;}
// Server messages are Dutch; translated at display time via serverError.* keys.
function friendlyMessage(text) {
const message = String(text ?? '').trim();
if (!/^en(?:-|$)/i.test(document.documentElement.lang || 'en')) return message;
const mapped = window.i18n?.strings?.['serverError.' + message];
if (mapped) return mapped;
return message
.replace(/niet bereikbaar/gi, 'unreachable')
.replace(/niet beschikbaar/gi, 'unavailable')
.replace(/niet geconfigureerd/gi, 'not configured')
.replace(/ontbreekt/gi, 'is missing');
}
function showToast(msg, type = 'info', duration = 5000) {
const icons = { ok: '✓', error: '✕', info: '●' };
const safeType = Object.hasOwn(icons, type) ? type : 'info';
const message = friendlyMessage(msg);
if (!message) return null;
const container = $('toast-container');
const key = `${safeType}:${message.replace(/\s+/g, ' ').toLocaleLowerCase()}`;
const existing = [...container.querySelectorAll('.toast')].find(item => item.dataset.toastKey === key);
if (existing) {
const count = Number(existing.dataset.toastCount || 1) + 1;
existing.dataset.toastCount = String(count);
let badge = existing.querySelector('.toast-count');
if (!badge) {
badge = document.createElement('span');
badge.className = 'toast-count';
existing.querySelector('.toast-body')?.after(badge);
}
badge.textContent = `×${count}`;
clearTimeout(existing._dismissTimer);
existing._dismissTimer = setTimeout(() => existing._dismiss?.(), duration);
return existing;
}
const toast = document.createElement('div');
toast.className = `toast toast-${safeType}`;
toast.dataset.toastKey = key;
toast.dataset.toastCount = '1';
toast.setAttribute('role', safeType === 'error' ? 'alert' : 'status');
const closeLabel = window.t?.('notifications.close', 'Close notification') || 'Close notification';
const icon = document.createElement('span');
icon.className = 'toast-icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = icons[safeType];
const body = document.createElement('span');
body.className = 'toast-body';
body.textContent = message;
const close = document.createElement('button');
close.className = 'toast-close';
close.type = 'button';
close.setAttribute('aria-label', closeLabel);
close.textContent = '×';
toast.append(icon, body, close);
while (container.children.length >= 4) container.firstElementChild?.remove();
container.appendChild(toast);
const dismiss = () => {
if (!toast.isConnected || toast.classList.contains('toast-out')) return;
toast.classList.add('toast-out');
toast.addEventListener('animationend', () => toast.remove(), { once: true });
setTimeout(() => toast.remove(), 350);
};
toast._dismiss = dismiss;
close.addEventListener('click', dismiss);
toast._dismissTimer = setTimeout(dismiss, duration);
toast.addEventListener('mouseenter', () => clearTimeout(toast._dismissTimer));
toast.addEventListener('mouseleave', () => { toast._dismissTimer = setTimeout(dismiss, 2000); });
return toast;
}
// ── Navigation ────────────────────────────────────────────────────────────────
function navigate(page) {
document.querySelectorAll('.nav-link').forEach(a => a.classList.toggle('active', a.dataset.page === page));
document.querySelectorAll('.page').forEach(p => p.classList.toggle('active', p.id === `page-${page}`));
// Embed pages need #content to be overflow:hidden so the iframe can fill available height
const isEmbed = page === 'savemgr';
document.getElementById('content').classList.toggle('embed-page-active', isEmbed);
ensurePageReady(page, { force: pageIsStale(page) });
scheduleBackgroundWarm(800);
closeMobileNav();
syncPinStates();
}
$('mobile-nav-toggle')?.addEventListener('click', () => {
setMobileNavOpen(!document.body.classList.contains('mobile-nav-open'));
});
$('sidebar-scrim')?.addEventListener('click', closeMobileNav);
window.addEventListener('resize', () => {
if (!_isNarrowLayout()) closeMobileNav();
});
async function syncPinStates() {
try {
const s = await window.pork.getSettings();
const pinned = new Set(s.pinnedCards || []);
document.querySelectorAll('.btn-pin').forEach(btn =>
btn.classList.toggle('pinned', pinned.has(btn.dataset.pinId))
);
} catch (_) {}
}
async function loadJailbreak() {
// Reset UI
$('payload-check-status').textContent = '';
$('payload-sources-check-list').innerHTML = '';
if ($('payload-show-expert')) {
$('payload-show-expert').checked = localStorage.getItem('chimera.payload.showExpert') === '1';
}
try { await loadPayloadCatalog(); } catch (error) {
$('payload-sources-check-list').innerHTML = `<p class="hint">${escHtml(window.t('payload.catalogLoadFailed', 'Catalog could not be loaded'))}: ${escHtml(error.message)}</p>`;
}
await renderLocalPayloads();
await renderPayloadSender();
await renderAutoloaderSnapshots();
try {
const settings = await window.pork.getSettings();
if ($('pldmgr-port')) $('pldmgr-port').value = settings.payloadManagerPort || 8084;
if (!settings.ftpHost) {
if ($('service-matrix-summary')) $('service-matrix-summary').textContent = window.t('services.ps5NotConfigured', 'PS5 not configured');
if ($('service-matrix-grid')) $('service-matrix-grid').innerHTML = `<p class="hint">${escHtml(window.t('services.configureProfile', 'Configure a console profile before checking services.'))}</p>`;
if ($('pldmgr-version')) $('pldmgr-version').textContent = 'offline';
if ($('pldmgr-status')) $('pldmgr-status').textContent = window.t('services.ps5NotConfigured', 'PS5 not configured');
return;
}
await loadPs5ServiceMatrix();
await connectPs5PayloadManager({ quiet: true });
} catch (_) {}
}
function isExpectedUnavailable(error) {
if (error?.expected || ['NOT_CONFIGURED', 'UNAVAILABLE', 'UPSTREAM_UNAVAILABLE'].includes(error?.code)) return true;
return /niet (?:bereikbaar|verbonden|beschikbaar|actief|geconfigureerd|gevonden)|maak eerst verbinding|not (?:reachable|connected|available|active|configured|found|mounted)|connect (?:to )?(?:the )?PS5|offline|ECONNREFUSED|timed? out|reageerde niet op tijd|HTTP (?:403|429|5\d\d)|rate.?limit|ontbreekt/i.test(String(error?.message || ''));
}
+76
View File
@@ -0,0 +1,76 @@
// System State Controls
let pendingSystemAction = null;
const systemStateLabels = {
shutdown: 'Shutdown', reboot: 'Reboot', restmode: 'Enter Rest Mode', eject: 'Eject Disc',
};
function systemStateStatus(text, error = false) {
const el = $('system-state-status');
el.textContent = text;
el.style.color = error ? 'var(--red)' : '';
}
async function refreshSystemStateStatus() {
systemStateStatus('Checking service...');
const result = await window.pork.systemStateStatus();
systemStateStatus(result.connected ? `Service ready: ${result.response || 'port 9112'}` : (result.error || result.response || 'Service unavailable.'), !result.connected);
}
$('btn-system-state-status').addEventListener('click', () => refreshSystemStateStatus().catch(error => systemStateStatus(error.message, true)));
$('btn-system-state-deploy').addEventListener('click', async () => {
const button = $('btn-system-state-deploy');
button.disabled = true;
systemStateStatus('Sending payload to loader port 9021...');
try {
const result = await window.pork.systemStateDeploy();
systemStateStatus(`${result.message} (${result.bytes} bytes)`);
} catch (error) {
systemStateStatus(error.message, true);
} finally {
button.disabled = false;
}
});
document.querySelectorAll('.system-state-action').forEach((button) => button.addEventListener('click', () => {
pendingSystemAction = button.dataset.systemAction;
const command = pendingSystemAction === 'restmode' ? 'RESTMODE' : pendingSystemAction.toUpperCase();
$('system-state-confirm-title').textContent = `Confirm: ${systemStateLabels[pendingSystemAction]}`;
const shutdownWarning = pendingSystemAction === 'shutdown'
? ' The upstream SystemStateManager project still marks shutdown as in testing; use it only if you accept that risk.'
: '';
$('system-state-confirm-copy').textContent = `This sends ${command} to SystemStateManager on your PS5. Type "CONFIRM ${command}" exactly to enable the action.${shutdownWarning}`;
$('system-state-confirm-input').value = '';
$('system-state-confirm').hidden = false;
$('system-state-confirm-input').focus();
}));
$('system-state-confirm-input').addEventListener('input', () => {
if (!pendingSystemAction) return;
const command = pendingSystemAction === 'restmode' ? 'RESTMODE' : pendingSystemAction.toUpperCase();
$('btn-system-state-confirm').disabled = $('system-state-confirm-input').value !== `CONFIRM ${command}`;
});
$('btn-system-state-cancel').addEventListener('click', () => {
pendingSystemAction = null;
$('system-state-confirm').hidden = true;
});
$('btn-system-state-confirm').addEventListener('click', async () => {
if (!pendingSystemAction) return;
const action = pendingSystemAction;
const command = action === 'restmode' ? 'RESTMODE' : action.toUpperCase();
const button = $('btn-system-state-confirm');
button.disabled = true;
systemStateStatus(`Sending ${command}...`);
try {
const result = await window.pork.systemStateCommand({ action, confirmation: `CONFIRM ${command}` });
systemStateStatus(`${systemStateLabels[action]} command accepted: ${result.response || 'OK'}`);
$('system-state-confirm').hidden = true;
pendingSystemAction = null;
} catch (error) {
systemStateStatus(error.message, true);
button.disabled = false;
}
});
+767
View File
@@ -0,0 +1,767 @@
// ── System View ───────────────────────────────────────────────────────────────
let _svGifWorker = null;
let _svGifJobId = 0;
let _svGifSavePath = '';
let _svVidWriteQueue = Promise.resolve();
const SV_GIF_MAX_WIDTH = 480;
const SV_GIF_MAX_COLORS = 128;
const SV_GIF_MAX_FPS = 8;
function updateSvSourceUi() {
$('page-system-view')?.classList.toggle('sv-has-source', Boolean($('sv-video-device')?.value));
}
async function loadSystemView(options = {}) {
const passive = !!options.passive;
if (window.pork.isWebMode) {
if (!passive) await _loadSystemViewWeb();
return;
}
$('sv-status-codec').textContent = svDetectCodec().label;
if (!passive) await enumerateSvDevices();
let autoLoad = false;
// Restore all persisted system-view settings
try {
const s = await window.pork.getSettings();
if (s.svResolution) $('sv-resolution').value = s.svResolution;
if (s.svGifFps) $('sv-gif-fps').value = s.svGifFps;
if (s.svVidQuality) $('sv-vid-quality').value = s.svVidQuality;
if (s.svHotkeys) _svHotkeys = { ..._svHotkeys, ...s.svHotkeys };
autoLoad = !!s.svAutoLoad;
$('sv-auto-load').checked = autoLoad;
// Restore saved device selections only if nothing is currently selected
if (!passive) {
const videoSel = $('sv-video-device');
const audioSel = $('sv-audio-device');
if (!videoSel.value && s.svVideoDevice &&
[...videoSel.options].some(o => o.value === s.svVideoDevice)) {
videoSel.value = s.svVideoDevice;
}
if (!audioSel.value && s.svAudioDevice &&
[...audioSel.options].some(o => o.value === s.svAudioDevice)) {
audioSel.value = s.svAudioDevice;
}
}
} catch (_) {}
if (!$('sv-gif-fps').value) $('sv-gif-fps').value = '8';
if (!$('sv-vid-quality').value) $('sv-vid-quality').value = '8000000';
// Re-attach existing stream if still active (user navigated away and back)
if (_svStream && !_svStream.active) _svStream = null;
if (_svStream) {
// Re-attach video-only (AudioContext keeps running independently)
$('sv-video').srcObject = new MediaStream(_svStream.getVideoTracks());
$('sv-video').muted = true;
$('sv-no-signal').classList.add('hidden');
$('btn-sv-mute').disabled = false;
$('btn-sv-popout').disabled = false;
$('btn-sv-record').disabled = false;
$('btn-sv-vid-rec').disabled = false;
$('sv-ctx-popout').disabled = false;
updateSvMuteBtn();
svSetLive($('sv-video-device').selectedOptions[0]?.text || '');
} else if (!passive && autoLoad && $('sv-video-device').value) {
// Auto-start stream only when the user explicitly enabled it
await startSvStream();
}
updateSvSourceUi();
}
async function enumerateSvDevices() {
let devices;
try {
if (!navigator.mediaDevices?.enumerateDevices) throw new Error('MediaDevices is unavailable in this browser.');
// Device discovery must never trigger a camera permission prompt merely by
// opening the page. Permission is requested only when the user explicitly
// starts a selected source.
devices = await Promise.race([
navigator.mediaDevices.enumerateDevices(),
new Promise(resolve => setTimeout(() => resolve([]), 1800)),
]);
} catch (e) {
console.error('enumerateDevices failed:', e);
$('sv-no-signal-msg').textContent = 'Capture devices could not be listed. Check the secure browser context and permissions, then retry.';
$('sv-no-signal').classList.remove('hidden');
return;
}
const prevVideo = $('sv-video-device').value;
const prevAudio = $('sv-audio-device').value;
// Video
const videoSel = $('sv-video-device');
while (videoSel.options.length > 1) videoSel.remove(1);
devices.filter(d => d.kind === 'videoinput').forEach(d => {
videoSel.add(new Option(d.label || `Camera ${d.deviceId.slice(0,8)}`, d.deviceId));
});
// Audio
const audioSel = $('sv-audio-device');
while (audioSel.options.length > 1) audioSel.remove(1);
devices.filter(d => d.kind === 'audioinput').forEach(d => {
audioSel.add(new Option(d.label || `Mic ${d.deviceId.slice(0,8)}`, d.deviceId));
});
if (!devices.some(device => device.kind === 'videoinput')) {
$('sv-no-signal-msg').textContent = 'No capture source detected. Connect a capture device, then refresh the device list.';
$('sv-no-signal').classList.remove('hidden');
}
// Restore previous selection if device still present
if (prevVideo && [...videoSel.options].some(o => o.value === prevVideo)) videoSel.value = prevVideo;
if (prevAudio && [...audioSel.options].some(o => o.value === prevAudio)) audioSel.value = prevAudio;
updateSvSourceUi();
}
function _svAudioStop() {
if (_svAudioCtx) { _svAudioCtx.close().catch(() => {}); _svAudioCtx = null; }
_svAudioGain = null;
}
// ── System View — stream relay (web UI forwarding) ────────────────────────────
let _relayWs = null;
let _relayRecorder = null;
let _relayRequested = false;
let _webViewerWs = null; // browser-side viewer WS
const _RELAY_CODECS = [
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8',
'video/webm',
];
async function startSvRelay() {
if (_relayWs || !_svStream || !_svStream.active) return;
try {
const status = await window.pork.webuiStatus();
if (!status.running) return;
const mimeType = _RELAY_CODECS.find(m => MediaRecorder.isTypeSupported(m)) || 'video/webm';
_relayWs = new WebSocket(`ws://localhost:${status.port}/sv-stream?role=encoder`);
_relayWs.onopen = () => {
_relayWs.send(JSON.stringify({ type: 'meta', mimeType }));
_relayRecorder = new MediaRecorder(_svStream, {
mimeType,
videoBitsPerSecond: 4_000_000,
});
_relayRecorder.ondataavailable = async (e) => {
if (e.data.size === 0 || !_relayWs || _relayWs.readyState !== WebSocket.OPEN) return;
try {
const buf = await e.data.arrayBuffer();
if (_relayWs && _relayWs.readyState === WebSocket.OPEN) _relayWs.send(buf);
} catch (_) {}
};
_relayRecorder.start(500);
};
_relayWs.onclose = () => {
if (_relayRecorder && _relayRecorder.state !== 'inactive') {
try { _relayRecorder.stop(); } catch (_) {}
}
_relayRecorder = null;
_relayWs = null;
};
_relayWs.onerror = () => { try { _relayWs.close(); } catch (_) {} };
} catch (e) {
console.error('[SvRelay] Failed to start:', e);
}
}
function stopSvRelay() {
if (_relayRecorder && _relayRecorder.state !== 'inactive') {
try { _relayRecorder.stop(); } catch (_) {}
}
_relayRecorder = null;
if (_relayWs) { try { _relayWs.close(); } catch (_) {} _relayWs = null; }
}
window.pork.on('sv:relay:request', ({ active }) => {
_relayRequested = active;
if (active && _svStream && _svStream.active) {
startSvRelay().catch(() => {});
} else if (!active) {
stopSvRelay();
}
});
// ── System View — web-mode viewer ─────────────────────────────────────────────
async function _loadSystemViewWeb() {
// In server mode the capture card belongs to the browser machine, so use
// MediaDevices there directly instead of waiting for an Electron relay.
const deviceBar = document.querySelector('.sv-device-bar');
if (deviceBar) deviceBar.hidden = false;
['btn-sv-refresh-devices', 'btn-sv-mute', 'btn-sv-record', 'sv-gif-fps',
'btn-sv-vid-rec', 'sv-vid-quality',
].forEach(id => { const el = $(id); if (el) el.hidden = false; });
$('btn-sv-popout').hidden = true;
$('sv-status-codec').textContent = typeof MediaRecorder !== 'undefined' ? svDetectCodec().label : 'Unavailable';
if (!window.isSecureContext || !navigator.mediaDevices?.enumerateDevices) {
$('sv-no-signal-msg').textContent = window.t('systemView.httpsRequired.detail', 'Capture needs HTTPS or localhost, plus camera and microphone access.');
$('sv-no-signal').classList.remove('hidden');
svSetIdle(window.t('systemView.httpsRequired', 'HTTPS required for capture'));
return;
}
await enumerateSvDevices();
try {
const settings = await window.pork.getSettings();
if (settings.svResolution) $('sv-resolution').value = settings.svResolution;
if (settings.svGifFps) $('sv-gif-fps').value = settings.svGifFps;
if (settings.svVidQuality) $('sv-vid-quality').value = settings.svVidQuality;
if (settings.svVideoDevice && [...$('sv-video-device').options].some(option => option.value === settings.svVideoDevice)) $('sv-video-device').value = settings.svVideoDevice;
if (settings.svAudioDevice && [...$('sv-audio-device').options].some(option => option.value === settings.svAudioDevice)) $('sv-audio-device').value = settings.svAudioDevice;
$('sv-auto-load').checked = settings.svAutoLoad === true;
if (settings.svAutoLoad && $('sv-video-device').value) await startSvStream();
updateSvSourceUi();
} catch (_) {}
}
async function startSvStream() {
if (_svRecording) stopSvRecording();
stopSvRelay();
if (_svStream) { _svStream.getTracks().forEach(t => t.stop()); _svStream = null; }
_svAudioStop();
$('sv-video').srcObject = null;
const videoId = $('sv-video-device').value;
updateSvSourceUi();
if (!videoId) {
$('sv-no-signal-msg').textContent = 'Select a video source above to begin';
$('sv-no-signal').classList.remove('hidden');
$('btn-sv-mute').disabled = true;
$('btn-sv-popout').disabled = true;
$('btn-sv-record').disabled = true;
$('btn-sv-vid-rec').disabled = true;
$('sv-ctx-popout').disabled = true;
svSetIdle('No device selected');
return;
}
$('sv-no-signal-msg').textContent = 'Starting stream…';
const audioId = $('sv-audio-device').value;
const res = $('sv-resolution').value; // "1920x1080" or ""
const [rW, rH] = res ? res.split('x').map(Number) : [];
const _buildConstraints = (exactRes) => {
const vc = { deviceId: { exact: videoId } };
if (rW) {
vc.width = exactRes ? { exact: rW } : { ideal: rW };
vc.height = exactRes ? { exact: rH } : { ideal: rH };
}
return {
video: vc,
audio: audioId ? {
deviceId: { exact: audioId },
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
channelCount: 2,
} : false,
};
};
try {
// First try exact resolution; fall back to ideal if the device can't match
try {
_svStream = await navigator.mediaDevices.getUserMedia(_buildConstraints(true));
} catch (exactErr) {
if (rW && exactErr.name === 'OverconstrainedError') {
console.warn(`[SysView] Exact ${rW}×${rH} not supported, falling back to ideal`);
_svStream = await navigator.mediaDevices.getUserMedia(_buildConstraints(false));
} else {
throw exactErr;
}
}
// Feed only video tracks to the video element. Chromium syncs audio
// to the video element's clock, which introduces ~150-300 ms of buffering.
// Routing audio separately via AudioContext (latencyHint:'interactive')
// drops that to ~20-40 ms and eliminates the choppiness.
$('sv-video').srcObject = new MediaStream(_svStream.getVideoTracks());
$('sv-video').muted = true; // always; audio is handled by AudioContext below
const audioTracks = _svStream.getAudioTracks();
if (audioId && audioTracks.length) {
_svAudioCtx = new AudioContext({ latencyHint: 'interactive' });
_svAudioGain = _svAudioCtx.createGain();
_svAudioGain.gain.value = _svMuted ? 0 : 1;
_svAudioCtx.createMediaStreamSource(new MediaStream(audioTracks))
.connect(_svAudioGain)
.connect(_svAudioCtx.destination);
}
$('sv-no-signal').classList.add('hidden');
$('btn-sv-mute').disabled = false;
$('btn-sv-popout').disabled = false;
$('btn-sv-record').disabled = false;
$('btn-sv-vid-rec').disabled = false;
$('sv-ctx-popout').disabled = false;
updateSvMuteBtn();
svSetLive($('sv-video-device').selectedOptions[0]?.text || videoId);
if (_relayRequested) startSvRelay().catch(() => {});
} catch (e) {
$('sv-no-signal-msg').textContent = `Error: ${e.message}`;
$('sv-no-signal').classList.remove('hidden');
$('btn-sv-mute').disabled = true;
$('btn-sv-popout').disabled = true;
$('btn-sv-record').disabled = true;
$('btn-sv-vid-rec').disabled = true;
$('sv-ctx-popout').disabled = true;
svSetIdle(`Error: ${e.message}`);
}
}
function updateSvMuteBtn() {
$('btn-sv-mute').textContent = _svMuted ? '🔇 Unmute' : '🔊 Mute';
}
// ── System View — status bar ───────────────────────────────────────────────────
function svSetLive(label) {
$('sv-status-dot').className = 'sv-status-dot live';
$('sv-status-text').textContent = `Live — ${label}`;
}
function svSetIdle(msg = 'No device selected') {
$('sv-status-dot').className = 'sv-status-dot';
$('sv-status-text').textContent = msg;
$('sv-status-res').textContent = '';
}
function svSetSaving() {
$('sv-status-dot').className = 'sv-status-dot saving';
$('sv-status-text').textContent = 'Saving video…';
}
function svSetGifSaving() {
$('sv-status-dot').className = 'sv-status-dot saving';
$('sv-status-text').textContent = 'Saving GIF…';
}
$('sv-video').addEventListener('loadedmetadata', () => {
const { videoWidth: w, videoHeight: h } = $('sv-video');
if (w && h) $('sv-status-res').textContent = `${w}×${h}`;
});
// ── System View — codec detection ─────────────────────────────────────────────
function svDetectCodec() {
const candidates = [
{ mime: 'video/mp4;codecs="avc1.640032,mp4a.40.2"', ext: 'mp4', label: 'H264/AAC · MP4' },
{ mime: 'video/mp4;codecs="avc1.42E01E,mp4a.40.2"', ext: 'mp4', label: 'H264/AAC · MP4' },
{ mime: 'video/mp4;codecs=avc1,mp4a.40.2', ext: 'mp4', label: 'H264/AAC · MP4' },
{ mime: 'video/mp4', ext: 'mp4', label: 'MP4' },
{ mime: 'video/webm;codecs="vp9,opus"', ext: 'webm', label: 'VP9/Opus · WebM' },
{ mime: 'video/webm;codecs="vp8,opus"', ext: 'webm', label: 'VP8/Opus · WebM' },
{ mime: 'video/webm', ext: 'webm', label: 'WebM' },
];
return candidates.find(c => MediaRecorder.isTypeSupported(c.mime))
|| { mime: '', ext: 'webm', label: 'WebM (default)' };
}
// ── System View — video recording ─────────────────────────────────────────────
function svFmtBytes(n) {
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function svFmtTime(ms) {
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function svUpdateVidStatus() {
const elapsed = _svVidPaused
? _svVidStart
: Date.now() - _svVidStart;
$('sv-vid-rec-time').textContent = svFmtTime(elapsed);
$('sv-vid-rec-size').textContent = _svVidBytes > 0 ? ` · ${svFmtBytes(_svVidBytes)}` : '';
}
async function startSvVidRecording() {
if (!_svStream) return;
const codec = svDetectCodec();
_svVidMimeType = codec.mime;
_svVidExt = codec.ext;
$('sv-status-codec').textContent = codec.label;
const bitrate = Math.max(2_500_000, Math.min(12_000_000, parseInt($('sv-vid-quality').value) || 8_000_000));
const name = `chimera-${new Date().toISOString().slice(0,19).replace(/[T:]/g,'-')}.${_svVidExt}`;
_svVidChunks = [];
if (!window.pork.isWebMode) {
try {
const picked = await window.pork.systemViewPickSave({
defaultPath: name,
filters: [{ name: `${_svVidExt.toUpperCase()} Video`, extensions: [_svVidExt] }],
});
if (picked?.canceled || !picked?.filePath) return;
_svVidWritable = picked.filePath;
_svVidWriteQueue = Promise.resolve();
await window.pork.systemViewWriteFile({ filePath: _svVidWritable, buffer: new Uint8Array(0).buffer });
} catch (e) {
setStatus(`Video save dialog failed: ${e.message}`, 'error');
return;
}
} else {
_svVidWritable = name;
}
_svVidBytes = 0; _svVidPaused = false; _svVidStart = Date.now();
const recOpts = { videoBitsPerSecond: bitrate, audioBitsPerSecond: 256_000 };
if (_svVidMimeType) recOpts.mimeType = _svVidMimeType;
_svVidRecorder = new MediaRecorder(_svStream, recOpts);
_svVidRecorder.ondataavailable = async (e) => {
if (!e.data || e.data.size === 0) return;
_svVidBytes += e.data.size;
if (window.pork.isWebMode) {
_svVidChunks.push(e.data);
} else if (_svVidWritable) {
const filePath = _svVidWritable;
_svVidWriteQueue = _svVidWriteQueue.then(async () => {
const buffer = await e.data.arrayBuffer();
await window.pork.systemViewAppendFile({ filePath, buffer });
}).catch(err => {
console.error('[system-view] video append failed', err);
});
}
};
_svVidRecorder.onstop = async () => {
clearInterval(_svVidTimer);
svSetSaving();
await _svVidWriteQueue.catch(() => {});
if (window.pork.isWebMode && _svVidChunks?.length) {
const url = URL.createObjectURL(new Blob(_svVidChunks, { type: _svVidMimeType || 'video/webm' }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = _svVidWritable || name;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 30000);
}
_svVidChunks = null;
_svVidWritable = null;
_svVidRecorder = null;
svSetVidIdle();
if (_svStream) svSetLive($('sv-video-device').selectedOptions[0]?.text || '');
};
_svVidRecorder.start(500);
$('btn-sv-vid-rec').hidden = true;
$('btn-sv-vid-pause').hidden = false;
$('btn-sv-vid-stop').hidden = false;
$('sv-vid-rec-status').hidden = false;
$('sv-vid-rec-label').textContent = 'REC';
$('sv-vid-rec-status').className = 'sv-vid-rec-status';
$('btn-sv-record').disabled = true; // no GIF while video recording
_svVidTimer = setInterval(svUpdateVidStatus, 500);
}
function pauseSvVidRecording() {
if (!_svVidRecorder) return;
if (_svVidPaused) {
_svVidRecorder.resume();
_svVidStart = Date.now() - _svVidStart;
_svVidPaused = false;
$('btn-sv-vid-pause').textContent = '⏸ Pause';
$('sv-vid-rec-label').textContent = 'REC';
$('sv-vid-rec-status').className = 'sv-vid-rec-status';
} else {
_svVidRecorder.pause();
_svVidStart = Date.now() - _svVidStart;
_svVidPaused = true;
$('btn-sv-vid-pause').textContent = '▶ Resume';
$('sv-vid-rec-label').textContent = 'PAUSED';
$('sv-vid-rec-status').className = 'sv-vid-rec-status paused';
}
}
function stopSvVidRecording() {
if (!_svVidRecorder) return;
if (_svVidPaused) { _svVidRecorder.resume(); _svVidPaused = false; }
_svVidRecorder.stop();
}
function svSetVidIdle() {
$('btn-sv-vid-rec').hidden = false;
$('btn-sv-vid-pause').hidden = true;
$('btn-sv-vid-stop').hidden = true;
$('sv-vid-rec-status').hidden = true;
$('btn-sv-vid-pause').textContent = '⏸ Pause';
$('sv-vid-rec-label').textContent = 'REC';
$('sv-vid-rec-status').className = 'sv-vid-rec-status';
$('btn-sv-record').disabled = !_svStream;
_svVidPaused = false;
}
// ── System View — recording helpers ───────────────────────────────────────────
function getSvRecCtx() {
const vid = $('sv-video');
const srcW = vid.videoWidth || GIF_W;
const srcH = vid.videoHeight || GIF_H;
const scale = srcW > SV_GIF_MAX_WIDTH ? (SV_GIF_MAX_WIDTH / srcW) : 1;
const w = Math.max(2, Math.round(srcW * scale));
const h = Math.max(2, Math.round(srcH * scale));
if (!_svRecCanvas || _svRecCanvas.width !== w || _svRecCanvas.height !== h) {
_svRecCanvas = document.createElement('canvas');
_svRecCanvas.width = w; _svRecCanvas.height = h;
_svRecCtx = _svRecCanvas.getContext('2d');
}
return _svRecCtx;
}
function getSvGifWorker() {
if (_svGifWorker) return _svGifWorker;
const workerSource = `
importScripts(${JSON.stringify(new URL('gif-encoder.js', location.href).href)});
self.onmessage = e => {
const { id, width, height, fps, maxColors, frames } = e.data || {};
try {
const enc = new self.GifEncoder(width, height, { fps, maxColors });
for (const frame of frames || []) enc.addFrame(new Uint8ClampedArray(frame));
const bytes = enc.encode();
self.postMessage({ id, ok: true, bytes }, [bytes.buffer]);
} catch (error) {
self.postMessage({ id, ok: false, error: error?.message || String(error) });
}
};
`;
const workerUrl = URL.createObjectURL(new Blob([workerSource], { type: 'application/javascript' }));
_svGifWorker = new Worker(workerUrl);
URL.revokeObjectURL(workerUrl);
return _svGifWorker;
}
function encodeGifOffThread({ width, height, fps, frames, maxColors }) {
return new Promise((resolve, reject) => {
const worker = getSvGifWorker();
const id = ++_svGifJobId;
const onMessage = (event) => {
const msg = event.data || {};
if (msg.id !== id) return;
worker.removeEventListener('message', onMessage);
if (!msg.ok) reject(new Error(msg.error || 'GIF encode failed'));
else resolve(new Uint8Array(msg.bytes));
};
worker.addEventListener('message', onMessage);
worker.postMessage({
id,
width,
height,
fps,
maxColors,
frames: frames.map(f => f.buffer),
}, frames.map(f => f.buffer));
});
}
async function startSvRecording() {
if (_svRecording || !_svStream) return;
const fps = Math.min(SV_GIF_MAX_FPS, parseInt($('sv-gif-fps').value) || 10);
const defaultName = `chimera-${Date.now()}.gif`;
if (window.pork.isWebMode) {
_svGifSavePath = defaultName;
} else {
const picked = await window.pork.systemViewPickSave({
defaultPath: defaultName,
filters: [{ name: 'GIF Image', extensions: ['gif'] }],
});
if (picked?.canceled || !picked?.filePath) return;
_svGifSavePath = picked.filePath;
}
_svRecording = true; _svRecFrames = []; _svRecStart = Date.now();
$('btn-sv-record').textContent = '⏹ Stop & Save';
$('sv-rec-status').hidden = false;
const ctx = getSvRecCtx();
const vid = $('sv-video');
const gifW = _svRecCanvas.width, gifH = _svRecCanvas.height;
_svRecInterval = setInterval(() => {
ctx.drawImage(vid, 0, 0, gifW, gifH);
_svRecFrames.push(new Uint8ClampedArray(ctx.getImageData(0, 0, gifW, gifH).data));
}, Math.round(1000 / fps));
_svRecTimer = setInterval(() => {
const s = Math.floor((Date.now() - _svRecStart) / 1000);
$('sv-rec-time').textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}, 500);
}
async function stopSvRecording() {
if (!_svRecording) return;
clearInterval(_svRecInterval); clearInterval(_svRecTimer);
_svRecording = false;
$('btn-sv-record').textContent = '⏺ Record GIF';
$('sv-rec-status').hidden = true;
$('sv-rec-time').textContent = '0:00';
if (!_svRecFrames.length) return;
const fps = Math.min(SV_GIF_MAX_FPS, parseInt($('sv-gif-fps').value) || 10);
const gifW = _svRecCanvas?.width || GIF_W;
const gifH = _svRecCanvas?.height || GIF_H;
const frames = _svRecFrames;
const filePath = _svGifSavePath;
_svRecFrames = [];
_svGifSavePath = '';
svSetGifSaving();
try {
const bytes = await encodeGifOffThread({
width: gifW,
height: gifH,
fps,
frames,
maxColors: SV_GIF_MAX_COLORS,
});
if (window.pork.isWebMode) {
const url = URL.createObjectURL(new Blob([bytes], { type: 'image/gif' }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filePath || `chimera-${Date.now()}.gif`;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 30000);
} else {
await window.pork.systemViewWriteFile({ filePath, buffer: bytes.buffer });
}
} catch (e) {
console.error('[system-view] gif save failed', e);
setStatus(`GIF save failed: ${e.message}`, 'error');
}
if (_svStream) svSetLive($('sv-video-device').selectedOptions[0]?.text || '');
}
// System View — event handlers (attached once at startup)
$('sv-video-device').addEventListener('change', () => {
window.pork.setSettings({ svVideoDevice: $('sv-video-device').value });
updateSvSourceUi();
startSvStream();
});
$('sv-audio-device').addEventListener('change', () => {
window.pork.setSettings({ svAudioDevice: $('sv-audio-device').value });
startSvStream();
});
$('btn-sv-refresh-devices').addEventListener('click', () => enumerateSvDevices());
$('sv-resolution').addEventListener('change', () => {
window.pork.setSettings({ svResolution: $('sv-resolution').value });
if ($('sv-video-device').value) startSvStream();
});
$('sv-gif-fps').addEventListener('change', () =>
window.pork.setSettings({ svGifFps: $('sv-gif-fps').value }));
$('sv-vid-quality').addEventListener('change', () =>
window.pork.setSettings({ svVidQuality: $('sv-vid-quality').value }));
$('sv-auto-load').addEventListener('change', () => {
window.pork.setSettings({ svAutoLoad: $('sv-auto-load').checked });
});
$('btn-sv-mute').addEventListener('click', () => {
_svMuted = !_svMuted;
if (_svAudioGain) _svAudioGain.gain.value = _svMuted ? 0 : 1;
updateSvMuteBtn();
});
// In-app fullscreen: CSS-based fixed overlay — requestFullscreen() is
// unreliable in Electron's frameless window and silently fails.
function svToggleFullscreen() {
const wrap = $('sv-viewer-wrap');
const entering = !wrap.classList.contains('sv-viewer-wrap--fullscreen');
wrap.classList.toggle('sv-viewer-wrap--fullscreen', entering);
$('btn-sv-fullscreen').title = entering ? 'Exit Fullscreen (Esc)' : 'Fullscreen (or double-click)';
$('btn-sv-fullscreen').innerHTML = entering ? '&#x2715;' : '&#x26F6;';
}
$('btn-sv-fullscreen').addEventListener('click', svToggleFullscreen);
$('sv-viewer-wrap').addEventListener('dblclick', svToggleFullscreen);
$('btn-sv-record').addEventListener('click', () => {
(_svRecording ? stopSvRecording() : startSvRecording()).catch(err => {
setStatus(err.message, 'error');
});
});
$('btn-sv-vid-rec').addEventListener('click', startSvVidRecording);
$('btn-sv-vid-pause').addEventListener('click', pauseSvVidRecording);
$('btn-sv-vid-stop').addEventListener('click', stopSvVidRecording);
// Right-click context menu on video area
const _svCtxMenu = $('sv-ctx-menu');
$('sv-viewer-wrap').addEventListener('contextmenu', e => {
e.preventDefault();
const x = Math.min(e.clientX, window.innerWidth - 164);
const y = Math.min(e.clientY, window.innerHeight - 80);
_svCtxMenu.style.left = `${x}px`;
_svCtxMenu.style.top = `${y}px`;
_svCtxMenu.hidden = false;
});
document.addEventListener('click', () => { _svCtxMenu.hidden = true; });
$('sv-ctx-fs').addEventListener('click', svToggleFullscreen);
$('sv-ctx-popout').addEventListener('click', () => $('btn-sv-popout').click());
if (navigator.mediaDevices) {
navigator.mediaDevices.addEventListener('devicechange', () => enumerateSvDevices().catch(() => {}));
}
$('btn-sv-popout').addEventListener('click', async () => {
const videoId = $('sv-video-device').value;
const audioId = $('sv-audio-device').value;
const resolution = $('sv-resolution').value;
if (_svRecording) stopSvRecording();
stopSvRelay();
// Stop the main stream — the pop-out will start its own
if (_svStream) { _svStream.getTracks().forEach(t => t.stop()); _svStream = null; }
_svAudioStop();
$('sv-video').srcObject = null;
$('sv-no-signal').classList.remove('hidden');
$('sv-no-signal-msg').textContent = 'Stream moved to pop-out window';
$('btn-sv-mute').disabled = true;
$('btn-sv-popout').disabled = true;
$('btn-sv-record').disabled = true;
await window.pork.openSystemViewPopout(videoId, audioId, resolution);
});
// When pop-out closes, auto-restart stream in main window if device still selected
window.pork.on('system-view:popout-closed', () => {
if ($('page-system-view')?.classList.contains('active') && $('sv-video-device').value) {
startSvStream();
}
});
// ── System View mini-cards (Transfers / Jailbreak) ────────────────────────────
function svMiniGetStatus() {
const sel = $('sv-video-device');
const opt = sel?.selectedOptions?.[0];
return (opt && opt.value) ? `Capture device: ${opt.text}` : 'No capture device selected.';
}
['transfers', 'jailbreak'].forEach(pageId => {
$(`sv-mini-${pageId}-hdr`).addEventListener('click', () => {
const card = $(`sv-mini-${pageId}`);
card.classList.toggle('open');
if (card.classList.contains('open')) {
$(`sv-mini-${pageId}-status`).textContent = svMiniGetStatus();
}
});
$(`btn-sv-mini-${pageId}-nav`).addEventListener('click', () => navigate('system-view'));
$(`btn-sv-mini-${pageId}-popout`).addEventListener('click', async () => {
const videoId = $('sv-video-device').value;
const audioId = $('sv-audio-device').value;
const resolution = $('sv-resolution').value;
try {
await window.pork.openSystemViewPopout(videoId, audioId, resolution);
} catch (e) {
setStatus(e.message, 'error');
}
});
});
document.querySelectorAll('.nav-link').forEach(a =>
a.addEventListener('click', e => { e.preventDefault(); navigate(a.dataset.page); })
);
+219
View File
@@ -0,0 +1,219 @@
(function initTitleWorkspace(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.chimeraTitleWorkspace = api;
})(typeof window !== 'undefined' ? window : globalThis, function createTitleWorkspace() {
'use strict';
const array = value => Array.isArray(value) ? value : [];
const text = value => value == null || value === '' ? null : String(value);
const first = (...values) => values.map(text).find(Boolean) || null;
const lower = value => String(value || '').trim().toLowerCase();
const missingStates = new Set(['missing', 'unavailable', 'disconnected', 'offline', 'not_mounted']);
function deviceState(item) {
return first(
item && item.deviceAvailabilityState,
item && item.availabilityState,
item && item.device && item.device.availabilityState,
item && item.storageDevice && item.storageDevice.availabilityState,
item && item.volume && item.volume.device && item.volume.device.availabilityState
);
}
function locationLabel(item, fallback) {
return first(
item && item.volume && item.volume.label,
item && item.volume && item.volume.name,
item && item.volumeLabel,
item && item.device && item.device.label,
item && item.storageDevice && item.storageDevice.label,
item && item.console && item.console.name,
item && item.consoleName,
item && item.path,
fallback
);
}
function isConfirmedInstall(install) {
const state = lower(first(install && install.presenceState, install && install.state, install && install.installState));
return state === 'present' || state === 'installed' || state === 'confirmed';
}
function isMissing(item) {
return missingStates.has(lower(deviceState(item)));
}
function collectActivity(title, locations) {
const entries = [];
const add = (kind, value, label) => {
if (!value) return;
const timestamp = new Date(value);
if (Number.isNaN(timestamp.getTime())) return;
entries.push({ kind, label, timestamp: timestamp.toISOString() });
};
add('title', title.updatedAt || title.updated_at, 'Title metadata updated');
add('title', title.createdAt || title.created_at, 'Title first recorded');
locations.forEach(location => {
add(location.kind, location.raw.lastSeenAt || location.raw.last_seen_at, `${location.label} last seen`);
add(location.kind, location.raw.verifiedAt || location.raw.verified_at, `${location.label} verified`);
add(location.kind, location.raw.detectedAt || location.raw.detected_at, `${location.label} detected`);
});
array(title.media).forEach(item => add('media', item.importedAt || item.provenance?.importedAt, `Imported ${item.kind || 'media'}: ${item.originalName || item.name || 'asset'}`));
return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}
function project(title = {}) {
const localCopies = array(title.localCopies);
const consoleInstalls = array(title.consoleInstalls);
const packages = array(title.packages);
const saves = array(title.saves);
const trophies = array(title.trophies);
const media = array(title.media);
const locations = [
...localCopies.map((raw, index) => ({
kind: 'local-copy',
label: locationLabel(raw, `Local copy ${index + 1}`),
state: deviceState(raw) || 'unknown',
version: first(raw.version && raw.version.value, raw.version, raw.appVersion),
format: first(raw.format, raw.copyType, raw.type),
path: first(raw.path, raw.relativePath),
missing: isMissing(raw),
raw
})),
...consoleInstalls.map((raw, index) => ({
kind: 'console-install',
label: locationLabel(raw, `Console install ${index + 1}`),
state: first(raw.presenceState, raw.state, raw.installState, 'unknown'),
version: first(raw.version && raw.version.value, raw.version, raw.appVersion),
format: first(raw.format, raw.packageType),
path: first(raw.path, raw.installPath),
missing: isMissing(raw),
confirmed: isConfirmedInstall(raw),
raw
}))
];
const missingDrive = Number(title.availability && title.availability.missingDeviceCount || 0) > 0 || locations.some(location => location.missing);
const installed = consoleInstalls.some(isConfirmedInstall) || Number(title.availability && title.availability.confirmedInstallCount || 0) > 0;
const protectionState = lower(first(title.protection && title.protection.state, title.protection && title.protection.status));
const protectedCopy = ['protected', 'healthy', 'verified', 'complete'].includes(protectionState)
|| Number(title.protection && title.protection.protectedCopyCount || 0) > 0;
const badges = [];
if (installed) badges.push({ code: 'installed', tone: 'positive' });
if (localCopies.length) badges.push({ code: 'local-copy', tone: 'info' });
if (missingDrive) badges.push({ code: 'missing-drive', tone: 'warning' });
if (protectedCopy) badges.push({ code: 'protected', tone: 'positive' });
if ((installed || localCopies.length) && !protectedCopy) badges.push({ code: 'needs-protection', tone: 'danger' });
if (!locations.length) badges.push({ code: 'metadata-only', tone: 'neutral' });
return Object.freeze({
title,
badges,
locations,
localCopies,
consoleInstalls,
packages,
saves,
trophies,
media,
missingDrive,
installed,
protectedCopy,
activity: collectActivity(title, locations)
});
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function tr(key, fallback) {
if (typeof window === 'undefined' || typeof window.t !== 'function') return fallback;
const translated = window.t(key);
return translated && translated !== key ? translated : fallback;
}
function setCount(id, count) {
const node = document.getElementById(id);
if (!node) return;
node.textContent = count ? String(count) : '';
node.hidden = !count;
}
function emptyState(key, fallback, action) {
return `<div class="title-workspace-empty"><p>${escapeHtml(tr(key, fallback))}</p>${action || ''}</div>`;
}
function renderLocation(location) {
const kind = location.kind === 'console-install'
? tr('library.workspace.consoleInstall', 'Console install')
: tr('library.workspace.localCopy', 'Local copy');
const details = [location.version && `v${location.version}`, location.format, location.path].filter(Boolean);
return `<article class="title-location-card${location.missing ? ' is-missing' : ''}">
<div><span class="title-location-kind">${escapeHtml(kind)}</span><h4>${escapeHtml(location.label)}</h4></div>
<span class="title-location-state">${escapeHtml(location.missing ? tr('library.workspace.driveMissing', 'Drive unavailable') : location.state)}</span>
${details.length ? `<p>${details.map(escapeHtml).join(' · ')}</p>` : ''}
</article>`;
}
function renderList(id, items, emptyKey, emptyFallback, mapper, action) {
const node = document.getElementById(id);
if (!node) return;
node.innerHTML = items.length ? items.map(mapper).join('') : emptyState(emptyKey, emptyFallback, action);
}
function render(title) {
if (typeof document === 'undefined') return project(title);
const workspace = project(title);
const labels = {
installed: tr('library.badge.installed', 'Installed'),
'local-copy': tr('library.badge.localCopy', 'Local copy'),
'missing-drive': tr('library.badge.missingDrive', 'Drive unavailable'),
protected: tr('library.badge.protected', 'Protected'),
'needs-protection': tr('library.badge.needsProtection', 'Needs protection'),
'metadata-only': tr('library.badge.metadataOnly', 'Metadata only')
};
const badges = document.getElementById('modal-derived-badges');
if (badges) badges.innerHTML = workspace.badges.map(badge => `<span class="title-badge tone-${badge.tone}">${escapeHtml(labels[badge.code])}</span>`).join('');
renderList('modal-copies-content', workspace.locations, 'library.workspace.noCopies', 'No local copies or confirmed console installations recorded.', renderLocation);
renderList('modal-packages-content', workspace.packages, 'library.workspace.noPackages', 'No package evidence linked to this title.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.name, item.label, item.packageId, 'Package'))}</strong><span>${escapeHtml(first(item.state, item.status, item.type, 'recorded'))}</span></article>`);
window.chimeraRecipeEditor?.mount?.({ title, workspace });
renderList('modal-saves-content', workspace.saves, 'library.workspace.noSaves', 'No save evidence linked to this title.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.name, item.accountLabel, 'Save'))}</strong><span>${escapeHtml(first(item.state, item.status, 'recorded'))}</span></article>`, `<button class="btn" type="button" data-route="protection/saves">${escapeHtml(tr('library.workspace.openSaves', 'Open Save Vault'))}</button>`);
renderList('modal-trophies-content', workspace.trophies, 'library.workspace.noTrophies', 'No verified read-only trophy evidence imported.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.name, item.label, 'Trophy evidence'))}</strong><span>${escapeHtml(first(item.state, item.status, 'read-only'))}</span></article>`);
renderList('modal-media-content', workspace.media, 'library.workspace.noMedia', 'No media assets linked to this title.', item => `<article class="title-evidence-row"><strong>${escapeHtml(first(item.originalName, item.name, item.label, item.kind, 'Media'))}</strong><span>${escapeHtml(first(item.provenance?.provider, item.state, item.status, 'available'))}${item.preferred ? ` · ${escapeHtml(tr('media.import.preferredActive', 'Preferred'))}` : ''}</span></article>`, `<button class="btn" type="button" data-route="library/media">${escapeHtml(tr('library.workspace.openMedia', 'Open Media Library'))}</button>`);
renderList('modal-activity-content', workspace.activity, 'library.workspace.noActivity', 'No timestamped evidence recorded yet.', item => `<article class="title-evidence-row"><strong>${escapeHtml(item.label)}</strong><time datetime="${escapeHtml(item.timestamp)}">${escapeHtml(new Date(item.timestamp).toLocaleString())}</time></article>`);
const compatibility = document.getElementById('modal-compatibility-content');
if (compatibility) {
const versions = workspace.locations.map(item => item.version).filter(Boolean);
const formats = workspace.locations.map(item => item.format).filter(Boolean);
compatibility.innerHTML = `<dl class="title-fact-grid">
<div><dt>${escapeHtml(tr('library.workspace.availability', 'Availability'))}</dt><dd>${escapeHtml(workspace.missingDrive ? tr('library.workspace.partialAvailability', 'Partial - drive unavailable') : workspace.locations.length ? tr('library.workspace.availableEvidence', 'Evidence available') : tr('library.workspace.unknown', 'Unknown'))}</dd></div>
<div><dt>${escapeHtml(tr('library.workspace.versions', 'Observed versions'))}</dt><dd>${escapeHtml([...new Set(versions)].join(', ') || tr('library.workspace.unknown', 'Unknown'))}</dd></div>
<div><dt>${escapeHtml(tr('library.workspace.formats', 'Observed formats'))}</dt><dd>${escapeHtml([...new Set(formats)].join(', ') || tr('library.workspace.unknown', 'Unknown'))}</dd></div>
<div><dt>${escapeHtml(tr('library.workspace.protection', 'Protection'))}</dt><dd>${escapeHtml(workspace.protectedCopy ? tr('library.workspace.protected', 'Protected copy recorded') : tr('library.workspace.unprotected', 'No protected copy recorded'))}</dd></div>
</dl>`;
window.chimeraCompatibilityGuidance?.load?.(compatibility, {
titleId: first(title.externalTitleId, title.game_id, title.gameId),
version: versions[0] || '', firmware: first(title.consoleFirmware, title.firmware),
loader: first(title.loader, title.hen), format: formats[0] || ''
});
}
setCount('mtab-copies-count', workspace.locations.length);
setCount('mtab-packages-count', workspace.packages.length);
setCount('mtab-saves-count', workspace.saves.length);
setCount('mtab-trophies-count', workspace.trophies.length);
setCount('mtab-media-count', workspace.media.length);
setCount('mtab-activity-count', workspace.activity.length);
return workspace;
}
return Object.freeze({ project, render });
});
+216
View File
@@ -0,0 +1,216 @@
// ── Payload Manager Settings modal ────────────────────────────────────────────
$('btn-payload-settings').addEventListener('click', async () => {
const s = await window.pork.getSettings();
$('pm-local-path').value = s.payloadLocalPath || '';
$('pm-remote-path').value = s.payloadRemotePath || '';
openAccessibleDialog($('payload-settings-overlay'), $('pm-remote-path'));
});
$('btn-pm-browse-local').addEventListener('click', async () => {
const folder = await window.pork.selectFolder();
if (folder) $('pm-local-path').value = folder;
});
$('btn-pm-settings-save').addEventListener('click', async () => {
await window.pork.setSettings({
payloadLocalPath: $('pm-local-path').value.trim(),
payloadRemotePath: $('pm-remote-path').value.trim(),
});
closeAccessibleDialog($('payload-settings-overlay'));
});
['payload-settings-close', 'btn-pm-settings-cancel'].forEach(id =>
$(id).addEventListener('click', () => closeAccessibleDialog($('payload-settings-overlay')))
);
$('payload-settings-overlay').addEventListener('click', event => {
if (event.target === $('payload-settings-overlay')) closeAccessibleDialog($('payload-settings-overlay'));
});
$('payload-settings-overlay').addEventListener('keydown', event => {
if (event.key === 'Escape') closeAccessibleDialog($('payload-settings-overlay'));
});
// ── Transfer Manager ──────────────────────────────────────────────────────────
function fmtSpeed(bps) {
if (!bps || bps < 0) return '—';
if (bps < 1024) return `${Math.round(bps)} B/s`;
if (bps < 1048576) return `${(bps / 1024).toFixed(1)} KB/s`;
return `${(bps / 1048576).toFixed(1)} MB/s`;
}
let _transferState = { jobs: [], paused: false, maxConcurrent: 1, activeCount: 0, queuedCount: 0, interruptedCount: 0, policy: {} };
function renderTransferPage(st) {
_transferState = st;
// Stats bar
$('tstat-active').textContent = st.activeCount;
$('tstat-queued').textContent = st.queuedCount;
$('tstat-interrupted').textContent = st.interruptedCount || 0;
$('tstat-concurrent').textContent = st.maxConcurrent;
const slider = $('transfer-concurrent-slider');
if (slider && parseInt(slider.value) !== st.maxConcurrent) slider.value = st.maxConcurrent;
if ($('transfer-window-start') && document.activeElement !== $('transfer-window-start')) $('transfer-window-start').value = st.policy?.windowStart || '';
if ($('transfer-window-end') && document.activeElement !== $('transfer-window-end')) $('transfer-window-end').value = st.policy?.windowEnd || '';
if ($('transfer-policy-state')) $('transfer-policy-state').textContent = st.policy?.windowStart && st.policy?.windowEnd
? `${st.policy.windowStart}–${st.policy.windowEnd} · ${window.t('transfers.policy.bandwidthUnsupported', 'bandwidth limiting is not supported by the current FTP layer')}`
: `${window.t('transfers.policy.noWindow', 'No time window')} · ${window.t('transfers.policy.bandwidthUnsupported', 'bandwidth limiting is not supported by the current FTP layer')}`;
// Total speed from all active jobs
const totalSpeed = st.jobs
.filter(j => j.status === 'active')
.reduce((sum, j) => sum + (j.progress?.speedBps || 0), 0);
$('tstat-speed').textContent = fmtSpeed(totalSpeed);
// Pause/resume button
const pauseBtn = $('btn-transfer-pause');
if (pauseBtn) {
pauseBtn.textContent = st.paused
? `▶ ${window.t('transfers.resumeAll', 'Resume All')}`
: `⏸ ${window.t('transfers.pauseAll', 'Pause All')}`;
pauseBtn.disabled = !st.paused && !st.jobs.some(job => ['queued', 'active', 'retrying'].includes(job.status));
}
const clearBtn = $('btn-transfer-clear');
if (clearBtn) clearBtn.disabled = !st.jobs.some(job => ['done', 'error', 'cancelled'].includes(job.status));
// Job list
const list = $('transfers-list');
const empty = $('transfers-empty');
const visibleJobs = st.jobs.filter(j => j.status !== 'cancelled');
if (!visibleJobs.length) {
list.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
list.innerHTML = visibleJobs.map(job => {
const statusText = {
queued: 'Queued',
active: 'Transferring…',
done: 'Done',
error: 'Error',
cancelled: 'Cancelled',
interrupted: window.t('transfers.status.interrupted', 'Interrupted after restart'),
retrying: 'Retrying…',
}[job.status] || job.status;
const typeLabel = { upload: 'UPLOAD', download: 'DOWNLOAD', pork: 'PORK' }[job.type] || (job.type || '').toUpperCase();
const typeClass = { upload: 'type-upload', download: 'type-download', pork: 'type-pork' }[job.type] || '';
const pct = job.progress?.percent || 0;
const speed = job.status === 'active' ? fmtSpeed(job.progress?.speedBps) : '';
const file = job.progress?.file || '';
const filesDone = job.progress?.filesDone || 0;
const filesTotal = job.progress?.filesTotal || 0;
const transferred = job.progress?.transferred || 0;
const total = job.progress?.total || 0;
const showCancel = job.cancellable !== false && ['queued', 'active', 'retrying'].includes(job.status);
const showRetry = ['error', 'interrupted'].includes(job.status) && job.retryable;
const showBar = job.status === 'active' || job.status === 'done';
const currentBytes = job.progress?.currentBytes || 0;
const currentFileSize = job.progress?.currentFileSize || 0;
// File counter or single-file byte progress
const sizeStr = filesTotal > 1
? `File ${filesDone}/${filesTotal}`
: (total > 0 ? `${fmt(transferred)} / ${fmt(total)}` : '');
// Current-file byte progress for directory uploads (shows activity on large files)
const fileByteStr = (filesTotal > 1 && currentFileSize > 0 && job.status === 'active')
? `${fmt(currentBytes)} / ${fmt(currentFileSize)}`
: '';
const pctLabel = (showBar && job.status === 'active') ? `${pct}%` : (job.status === 'done' ? '100%' : '');
return `
<div class="transfer-item transfer-item-${job.status}">
<div class="transfer-item-header">
<span class="transfer-label" title="${escHtml(job.localPath || job.remotePath || '')}">${escHtml(job.label)}</span>
<span class="transfer-type-badge ${typeClass}">${typeLabel}</span>
<span class="transfer-status-text">${statusText}</span>
${showRetry ? `<button class="btn btn-sm btn-retry-transfer" data-job-id="${escHtml(job.id)}">Retry</button>` : ''}
${showCancel ? `<button class="btn-cancel-transfer" data-job-id="${job.id}" title="Cancel">×</button>` : ''}
</div>
${showBar ? `
<div class="transfer-progress-wrap">
<div class="transfer-progress-bar">
<div class="transfer-progress-fill" style="width:${pct}%"></div>
</div>
${pctLabel ? `<span class="transfer-pct-label">${pctLabel}</span>` : ''}
</div>` : ''}
<div class="transfer-meta">
${speed ? `<span class="transfer-speed">${speed}</span>` : ''}
${file ? `<span class="transfer-file">${escHtml(file)}</span>` : ''}
${fileByteStr ? `<span class="transfer-sizes">${fileByteStr}</span>` : sizeStr ? `<span class="transfer-sizes">${sizeStr}</span>` : ''}
${job.error ? `<span class="transfer-error">${escHtml(job.error)}</span>` : ''}
${job.cancellable === false && ['queued', 'active', 'retrying'].includes(job.status) ? '<span class="transfer-error">Upstream offers no safe cancellation; only task status is tracked.</span>' : ''}
</div>
</div>`;
}).join('');
}
function updateNavTransferBadge(st) {
const badge = $('nav-transfer-badge');
if (!badge) return;
const n = (st.activeCount || 0) + (st.queuedCount || 0);
badge.hidden = n === 0;
badge.textContent = n;
}
async function loadTransfers() {
const st = await window.pork.transferState();
renderTransferPage(st);
updateNavTransferBadge(st);
}
$('btn-transfer-pause').addEventListener('click', async () => {
if (_transferState.paused) await window.pork.transferResume();
else await window.pork.transferPause();
});
$('btn-transfer-clear').addEventListener('click', () => window.pork.transferClearDone());
$('transfer-concurrent-slider').addEventListener('input', async event => {
const n = parseInt(event.target.value);
$('tstat-concurrent').textContent = n;
await window.pork.transferSetConcurrent(n);
});
$('transfers-list').addEventListener('click', event => {
const retry = event.target.closest('.btn-retry-transfer');
if (retry) {
window.pork.transferRetry?.(retry.dataset.jobId).catch(error => showToast(error.message, 'error'));
return;
}
const cancel = event.target.closest('.btn-cancel-transfer');
if (cancel?.dataset.jobId) window.pork.transferCancel(cancel.dataset.jobId);
});
$('btn-transfer-policy-save').addEventListener('click', async () => {
try {
await window.pork.transferSetPolicy?.({
windowStart: $('transfer-window-start').value || null,
windowEnd: $('transfer-window-end').value || null,
});
showToast(window.t('transfers.policy.saved', 'Transfer policy saved.'), 'ok');
} catch (error) {
showToast(error.message, 'error');
}
});
window.pork.on('transfer:update', state => {
renderTransferPage(state);
updateNavTransferBadge(state);
});
window.addEventListener('chimera:language-changed', () => {
if (document.querySelector('#page-transfers.active')) renderTransferPage(_transferState);
});
+707
View File
@@ -0,0 +1,707 @@
// ── PS Avatar ─────────────────────────────────────────────────────────────────
let _xavatarClient = null;
let _xavatarLastResult = null; // { buffer: ArrayBuffer, filename: string }
const _xavatarThumbCache = new Map(); // remotePath → blob URL
let _ps5ProfileInventory = null;
let _ps5ProfileSelectedUid = null;
let _ps5ProfileBound = false;
function ps5ProfileText(key, fallback) {
return typeof window.t === 'function' ? window.t(`ps5Profile.${key}`, fallback) : fallback;
}
function ps5ProfileSetAction(message, state = '') {
const target = $('ps5-profile-action-state');
if (!target) return;
target.textContent = message || '';
target.className = `ps5-profile-action-state${state ? ` is-${state}` : ''}`;
}
function ps5ProfileSelectedUser() {
return _ps5ProfileInventory?.users?.find(user => user.uid === _ps5ProfileSelectedUid) || null;
}
async function loadPs5ProfileRuntime() {
const card = document.querySelector('.ps5-profile-runtime');
const title = $('ps5-profile-runtime-title');
const state = $('ps5-profile-runtime-state');
const detail = $('ps5-profile-runtime-detail');
const button = $('btn-ps5-profile-start-payload');
if (!card || !state || !detail || !button || !window.pork?.ps5ProfileRuntimePreflight) return null;
card.classList.remove('is-ready');
button.disabled = true;
state.textContent = ps5ProfileText('runtimeCheckingShort', 'Checking…');
try {
const result = await window.pork.ps5ProfileRuntimePreflight();
if (title) title.textContent = `PS5Upload ${result.version || '5.4.19'}`;
const artifactsReady = result.engine?.verified && result.engine?.available && result.payload?.verified && result.console?.configured;
if (result.ready) {
card.classList.add('is-ready');
state.textContent = ps5ProfileText('runtimeReady', 'Ready to start');
detail.textContent = ps5ProfileText('runtimeReadyDetail', 'Exact engine and ELF hashes verified; the PS5 loader is reachable on port 9021.');
button.disabled = false;
} else if (artifactsReady && result.loader?.reason === 'unreachable') {
state.textContent = ps5ProfileText('runtimeLoaderClosed', 'Loader waiting');
detail.textContent = ps5ProfileText('runtimeLoaderHelp', 'Engine and ELF are verified. Start the jailbreak ELF loader on the PS5, then refresh this check.');
} else {
state.textContent = ps5ProfileText('runtimeBlocked', 'Preflight blocked');
detail.textContent = ps5ProfileText('runtimeBlockedDetail', 'A pinned artifact, configured PS5 host or local engine check failed.');
}
return result;
} catch (error) {
state.textContent = ps5ProfileText('runtimeBlocked', 'Preflight blocked');
detail.textContent = error.message;
return null;
}
}
function renderPs5ProfileOperations(operations = []) {
const target = $('ps5-profile-operations');
if (!target) return;
if (!operations.length) {
target.innerHTML = `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('noActivity', 'No prepared profile changes yet.'))}</div>`;
return;
}
target.innerHTML = operations.slice().reverse().slice(0, 8).map(operation => {
const canRollback = ['verified', 'verification_failed', 'failed', 'interrupted'].includes(operation.status);
const label = operation.kind === 'rename' ? ps5ProfileText('renameActivity', 'Display name') : ps5ProfileText('avatarActivity', 'Profile image');
const detail = operation.kind === 'rename' ? `${operation.snapshot?.username || ''} → ${operation.desired?.username || ''}` : operation.uidHex;
return `<article class="ps5-profile-operation" data-profile-operation="${escHtml(operation.id)}" data-profile-uid="${escHtml(operation.uidHex)}">
<div><strong>${escHtml(label)} <span class="ps5-profile-operation-status">${escHtml(operation.status)}</span></strong><small>${escHtml(detail)} · ${escHtml(new Date(operation.updatedAt || operation.createdAt).toLocaleString())}</small></div>
${canRollback ? `<button type="button" class="btn btn-sm ps5-profile-rollback">${escHtml(ps5ProfileText('rollback', 'Restore previous state'))}</button>` : ''}
</article>`;
}).join('');
}
function selectPs5LocalProfile(uid) {
_ps5ProfileSelectedUid = Number(uid);
const user = ps5ProfileSelectedUser();
document.querySelectorAll('.ps5-profile-user').forEach(card => card.classList.toggle('is-selected', Number(card.dataset.profileUid) === _ps5ProfileSelectedUid));
const name = $('ps5-profile-name');
const rename = $('btn-ps5-profile-rename');
const applyAvatar = $('btn-ps5-profile-apply-avatar');
if (!user) return;
$('ps5-profile-selected-title').textContent = user.username;
$('ps5-profile-selected-uid').textContent = user.uidHex;
$('ps5-profile-avatar-initial').textContent = [...user.username][0]?.toUpperCase() || '?';
name.value = user.username;
name.disabled = false;
rename.disabled = false;
applyAvatar.disabled = !_xavatarClient?._masterCanvas;
ps5ProfileSetAction(user.avatar?.available
? ps5ProfileText('avatarObserved', 'Current profile image evidence was read successfully.')
: ps5ProfileText('avatarDefault', 'This user currently has no recoverable custom image. Applying is blocked until exact recovery is possible.'));
const preview = $('ps5-profile-avatar-preview');
const shell = preview?.closest('.ps5-profile-avatar-shell');
if (preview && shell && !_xavatarClient?._masterCanvas) {
shell.classList.remove('has-preview');
window.pork.ps5ProfileCurrentAvatar({ uid: user.uid }).then(result => {
if (_ps5ProfileSelectedUid !== user.uid || !result?.dataUrl || _xavatarClient?._masterCanvas) return;
const image = new Image();
image.onload = () => { const context = preview.getContext('2d'); context.clearRect(0, 0, preview.width, preview.height); context.drawImage(image, 0, 0, preview.width, preview.height); shell.classList.add('has-preview'); };
image.src = result.dataUrl;
}).catch(() => {});
}
}
async function loadPs5LocalProfiles() {
const provider = $('ps5-profile-provider-state');
const usersTarget = $('ps5-profile-users');
if (!provider || !usersTarget || !window.pork?.ps5ProfileInspect) return;
if (!_ps5ProfileBound) bindPs5ProfileWorkspace();
loadPs5ProfileRuntime();
provider.className = 'ps5-profile-provider-state';
provider.textContent = ps5ProfileText('checking', 'Checking the guarded profile bridge…');
usersTarget.innerHTML = `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('loading', 'Reading local users…'))}</div>`;
try {
const [inventory, operations] = await Promise.all([window.pork.ps5ProfileInspect(), window.pork.ps5ProfileListOperations()]);
_ps5ProfileInventory = inventory;
renderPs5ProfileOperations(operations);
if (!inventory.available) {
provider.classList.add('is-blocked');
provider.textContent = ps5ProfileText('bridgeBlocked', 'Profile bridge unavailable · configure PS5 host and pinned PS5Upload 5.4.19');
usersTarget.innerHTML = `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('bridgeHelp', 'Start the pinned loopback bridge and configure the PS5 host. No console change is possible while this gate is closed.'))}</div>`;
return;
}
provider.classList.add('is-ready');
provider.textContent = `${ps5ProfileText('bridgeReady', 'Verified bridge')} · ${inventory.provider?.version || '5.4.19'}`;
usersTarget.innerHTML = inventory.users.length ? inventory.users.map(user => `<button type="button" class="ps5-profile-user" data-profile-uid="${user.uid}">
<span class="ps5-profile-user-avatar">${escHtml([...user.username][0]?.toUpperCase() || '?')}</span><span><strong>${escHtml(user.username)}</strong><small>${escHtml(user.uidHex)}</small></span>${user.foreground ? `<em>${escHtml(ps5ProfileText('active', 'Active'))}</em>` : ''}
</button>`).join('') : `<div class="ps5-profile-empty">${escHtml(ps5ProfileText('noUsers', 'No local users were reported.'))}</div>`;
const preferred = inventory.users.find(user => user.uid === _ps5ProfileSelectedUid) || inventory.users.find(user => user.foreground) || inventory.users[0];
if (preferred) selectPs5LocalProfile(preferred.uid);
} catch (error) {
provider.classList.add('is-blocked');
provider.textContent = ps5ProfileText('bridgeError', 'Profile bridge check failed');
usersTarget.innerHTML = `<div class="ps5-profile-empty">${escHtml(error.message)}</div>`;
}
}
function bindPs5ProfileWorkspace() {
if (_ps5ProfileBound) return;
_ps5ProfileBound = true;
$('btn-ps5-profile-refresh')?.addEventListener('click', loadPs5LocalProfiles);
$('btn-ps5-profile-start-payload')?.addEventListener('click', async () => {
const button = $('btn-ps5-profile-start-payload');
const confirmation = await showExactConfirmation(
ps5ProfileText('startPayloadConfirm', 'Send only the verified PS5Upload 5.4.19 ELF to the configured PS5 loader? This payload remains active until rest mode or reboot.'),
'START PS5 PROFILE PAYLOAD',
);
if (!confirmation) return;
button.disabled = true;
ps5ProfileSetAction(ps5ProfileText('startingPayload', 'Sending the verified payload and checking the management service…'));
try {
const result = await window.pork.ps5ProfileStartPayload({ confirmation });
if (!result.managementReady) throw new Error(ps5ProfileText('payloadNotReady', 'The ELF was sent, but the PS5 management service did not become ready. Check the jailbreak and firmware compatibility.'));
ps5ProfileSetAction(ps5ProfileText('payloadReady', 'Verified payload active. Reading local profiles…'), 'ok');
await loadPs5LocalProfiles();
} catch (error) {
ps5ProfileSetAction(error.message, 'error');
await loadPs5ProfileRuntime();
}
});
$('ps5-profile-users')?.addEventListener('click', event => {
const card = event.target.closest('.ps5-profile-user');
if (card) selectPs5LocalProfile(card.dataset.profileUid);
});
$('btn-ps5-profile-rename')?.addEventListener('click', async () => {
const user = ps5ProfileSelectedUser();
if (!user) return;
ps5ProfileSetAction(ps5ProfileText('preparing', 'Creating recovery snapshot and checking fresh evidence…'));
try {
const operation = await window.pork.ps5ProfilePrepareRename({ uid: user.uid, name: $('ps5-profile-name').value });
const confirmation = await showExactConfirmation(ps5ProfileText('renameConfirm', 'Apply this local display name after the recovery snapshot and fresh-evidence check?'), operation.confirmation);
if (!confirmation) return void loadPs5LocalProfiles();
await window.pork.ps5ProfileApply({ id: operation.id, confirmation });
ps5ProfileSetAction(ps5ProfileText('verifiedRestart', 'Verified on the console. Restart the PS5 to show the change everywhere.'), 'ok');
await loadPs5LocalProfiles();
} catch (error) { ps5ProfileSetAction(error.message, 'error'); }
});
$('btn-ps5-profile-apply-avatar')?.addEventListener('click', async () => {
const user = ps5ProfileSelectedUser();
if (!user || !_xavatarClient?._masterCanvas) return;
ps5ProfileSetAction(ps5ProfileText('converting', 'Building the canonical profile image set…'));
try {
const converted = await _xavatarClient.convert();
const operation = await window.pork.ps5ProfilePrepareAvatar({ uid: user.uid, archive: converted.buffer });
const confirmation = await showExactConfirmation(ps5ProfileText('avatarConfirm', 'Apply this reviewed image after preserving the current profile image?'), operation.confirmation);
if (!confirmation) return void loadPs5LocalProfiles();
await window.pork.ps5ProfileApply({ id: operation.id, confirmation });
ps5ProfileSetAction(ps5ProfileText('verifiedRestart', 'Verified on the console. Restart the PS5 to show the change everywhere.'), 'ok');
await loadPs5LocalProfiles();
} catch (error) { ps5ProfileSetAction(error.message, 'error'); }
});
$('ps5-profile-operations')?.addEventListener('click', async event => {
const button = event.target.closest('.ps5-profile-rollback');
const row = button?.closest('.ps5-profile-operation');
if (!row) return;
const phrase = `ROLLBACK PS5 PROFILE ${row.dataset.profileUid}`;
const confirmation = await showExactConfirmation(ps5ProfileText('rollbackConfirm', 'Restore the exact local profile state captured before this operation?'), phrase);
if (!confirmation) return;
button.disabled = true;
try {
await window.pork.ps5ProfileRollback({ id: row.dataset.profileOperation, confirmation });
ps5ProfileSetAction(ps5ProfileText('rollbackVerified', 'Previous local profile state restored and verified.'), 'ok');
await loadPs5LocalProfiles();
} catch (error) { ps5ProfileSetAction(error.message, 'error'); button.disabled = false; }
});
}
async function loadXavatarCompanion() {
const status = $('xavatar-companion-status');
const button = $('btn-xavatar-install-companion');
if (!status || !button || !window.pork.xavatarCompanion) return;
try {
const companion = await window.pork.xavatarCompanion();
button.disabled = !companion.installerReady;
button.dataset.ready = companion.installerReady ? 'true' : 'false';
status.textContent = companion.installerReady
? `${companion.titleId} · v${companion.version} · ${(companion.size / 1024 / 1024).toFixed(1)} MB · etaHEN DPI ready`
: `${companion.titleId} · v${companion.version} · ${companion.installerError}`;
status.title = `Expected SHA-256: ${companion.sha256}`;
} catch (error) {
button.disabled = true;
status.textContent = `Installer check failed: ${error.message}`;
}
if (button.dataset.bound) return;
button.dataset.bound = 'true';
button.addEventListener('click', async () => {
const confirmation = await showExactConfirmation(
"Install Lapy's Avatar Changer through etaHEN Direct Package Installer? The PS5 downloads the fixed reviewed package URL; Chimera does not launch the app.",
'INSTALL XAVATAR',
);
if (!confirmation) return;
button.disabled = true;
status.textContent = 'Submitting package to etaHEN DPI…';
try {
await window.pork.xavatarInstallCompanion(confirmation);
status.textContent = 'Installation submitted to etaHEN DPI. Follow the package progress on your PS5.';
showToast('Avatar Changer installation submitted.', 'ok', 9000);
} catch (error) {
status.textContent = `Installation failed: ${error.message}`;
showToast(error.message, 'error', 10000);
}
await loadXavatarCompanion();
});
}
function loadXavatar() {
// Refresh the library on every navigate; only initialise the converter once.
loadXavatarLibrary();
loadXavatarCompanion();
loadPs5LocalProfiles();
if (_xavatarClient) return;
if (!window.XavatarClient) {
$('xavatar-status').textContent = 'XavatarClient not available — ensure the module script loaded.';
return;
}
// Build the API object — prefer the dedicated xavatarAPI bridge; fall back to
// the pork bridge channel so conversion still works even if the separate
// xavatarElectronModule preload failed to expose window.xavatarAPI.
let _xavApi = window.xavatarAPI;
if (!_xavApi) {
console.info('[Chimera] Dedicated XAvatar bridge unavailable; using the supported web compatibility path.');
_xavApi = {
async convertFromCanvas(canvas, opts = {}) {
const ctx = canvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const rgba = new Uint8Array(imgData.data.buffer);
const res = await window.pork.xavatarConvertCanvas(
rgba, canvas.width, canvas.height, opts.filename || 'avatar'
);
if (!res || !res.ok) throw new Error((res && res.error) || 'Conversion failed');
return { buffer: res.buffer && res.buffer.buffer ? res.buffer.buffer : res.buffer, filename: res.filename };
}
};
}
_xavatarClient = new window.XavatarClient({ api: _xavApi });
const dropZone = $('xavatar-drop-zone');
const fileInput = $('xavatar-file-input');
const preview = $('xavatar-preview');
const status = $('xavatar-status');
const convertBtn = $('btn-xavatar-convert');
const sendBtn = $('btn-xavatar-send-ps5');
const convProgress = $('xavatar-conv-progress');
const convBar = $('xavatar-conv-bar');
function _xavatarSetProgress(pct, label) {
if (!convProgress) return;
convProgress.hidden = pct == null;
if (pct != null) {
convBar.style.width = `${pct}%`;
convBar.textContent = label || '';
}
}
_xavatarClient.on('image-ready', (canvas) => {
const ctx = preview.getContext('2d');
preview.width = canvas.width;
preview.height = canvas.height;
ctx.drawImage(canvas, 0, 0);
preview.hidden = false;
convertBtn.disabled = false;
sendBtn.disabled = false;
_xavatarLastResult = null; // new image — invalidate previous conversion
_xavatarSetProgress(null);
status.textContent = 'Image loaded — convert & download, or send directly to PS5';
dropZone.classList.add('xavatar-drop-zone--has-image');
const profilePreview = $('ps5-profile-avatar-preview');
const profileShell = profilePreview?.closest('.ps5-profile-avatar-shell');
if (profilePreview && profileShell) {
const profileContext = profilePreview.getContext('2d');
profileContext.clearRect(0, 0, profilePreview.width, profilePreview.height);
profileContext.drawImage(canvas, 0, 0, profilePreview.width, profilePreview.height);
profileShell.classList.add('has-preview');
$('btn-ps5-profile-apply-avatar').disabled = !ps5ProfileSelectedUser();
}
});
_xavatarClient.on('load-error', (err) => {
status.textContent = 'Failed to load image: ' + err.message;
});
_xavatarClient.on('convert-start', () => {
convertBtn.disabled = true;
convertBtn.textContent = 'Converting…';
status.textContent = 'Converting…';
});
_xavatarClient.on('convert-error', (err) => {
convertBtn.disabled = false;
convertBtn.textContent = 'Convert & Download';
_xavatarSetProgress(null);
status.textContent = 'Error: ' + err.message;
});
_xavatarClient.wireFileInput(fileInput);
_xavatarClient.wireDropZone(dropZone);
_xavatarClient.wirePaste(document);
$('btn-xavatar-browse').addEventListener('click', () => fileInput.click());
dropZone.addEventListener('click', (e) => {
if (e.target !== $('btn-xavatar-browse')) fileInput.click();
});
dropZone.addEventListener('dragover', () => dropZone.classList.add('xavatar-drop-zone--over'));
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('xavatar-drop-zone--over'));
dropZone.addEventListener('drop', () => dropZone.classList.remove('xavatar-drop-zone--over'));
convertBtn.addEventListener('click', async () => {
status.textContent = '';
_xavatarSetProgress(null);
try {
const result = await _xavatarClient.convert();
_xavatarLastResult = result;
convertBtn.disabled = false;
convertBtn.textContent = 'Convert & Download';
const saved = await window.pork.xavatarSaveFile(result.buffer, result.filename);
status.textContent = saved?.saved ? `Saved: ${result.filename}` : 'Converted — not saved.';
} catch (err) {
convertBtn.disabled = false;
convertBtn.textContent = 'Convert & Download';
if (!$('xavatar-status').textContent) {
status.textContent = 'Error: ' + err.message;
}
}
});
sendBtn.addEventListener('click', async () => {
if (!_xavatarClient?._masterCanvas) return;
convertBtn.disabled = true;
sendBtn.disabled = true;
sendBtn.textContent = 'Working…';
status.textContent = '';
try {
// ── Step 1: Convert ──────────────────────────────────────────────────
_xavatarSetProgress(10, 'Converting…');
status.textContent = 'Converting image to .xavatar…';
const result = await _xavatarClient.convert();
_xavatarLastResult = result;
// ── Step 2: Random filename ──────────────────────────────────────────
const pad = n => String(n).padStart(5, '0');
const rnd = pad(Math.floor(Math.random() * 100000));
const rndName = `chimera${rnd}.xavatar`;
_xavatarSetProgress(55, 'Uploading…');
status.textContent = `Queuing upload → /data/AVATARS/${rndName}…`;
// ── Step 3: Upload ──────────────────────────────────────────────────
await window.pork.xavatarUploadToPs5(result.buffer, rndName);
_xavatarSetProgress(100, 'Queued ✓');
status.textContent = `Queued → /data/AVATARS/${rndName}`;
sendBtn.textContent = '↗ Send to PS5';
sendBtn.disabled = false;
convertBtn.disabled = false;
convertBtn.textContent = 'Convert & Download';
if ($('xavatar-notify-toggle')?.checked) {
window.pork.psnotifySend('XAvatar Queued', rndName).catch(() => {});
}
setTimeout(() => _xavatarSetProgress(null), 3000);
} catch (e) {
_xavatarSetProgress(null);
convertBtn.disabled = false;
sendBtn.disabled = false;
sendBtn.textContent = '↗ Send to PS5';
convertBtn.textContent = 'Convert & Download';
status.textContent = 'Error: ' + e.message;
}
});
$('btn-xavatar-refresh-lib').addEventListener('click', loadXavatarLibrary);
}
function _xavatarFmtSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
async function loadXavatarLibrary() {
const listEl = $('xavatar-library-list');
const emptyEl = $('xavatar-library-empty');
if (!listEl) return;
listEl.innerHTML = '<div class="jb-list-placeholder" style="padding:18px 14px">Loading…</div>';
emptyEl.hidden = true;
try {
const ftpStatus = await window.pork.ftpStatus().catch(() => ({ connected: false }));
if (!ftpStatus?.connected) {
listEl.innerHTML = '';
emptyEl.hidden = false;
emptyEl.textContent = 'Connect to PS5 FTP to browse /data/AVATARS.';
return;
}
const files = await window.pork.ftpListDir('/data/AVATARS');
const xavs = files.filter(f => !f.isDir);
if (!xavs.length) {
listEl.innerHTML = '';
emptyEl.hidden = false;
return;
}
emptyEl.hidden = true;
listEl.innerHTML = '<div class="xavatar-lib-grid">' +
xavs.map(f => {
const rpath = `/data/AVATARS/${escHtml(f.name)}`;
return `<div class="xavatar-lib-card" data-xa-card-path="${rpath}" data-xa-card-name="${escHtml(f.name)}">
<div class="xavatar-thumb-wrap">
<img class="xavatar-thumb" data-xa-path="${rpath}" src="" alt="${escHtml(f.name)}">
<div class="xavatar-thumb-placeholder">&#128100;</div>
</div>
<div class="xavatar-lib-card-info">
<span class="xavatar-lib-card-name" title="${escHtml(f.name)}">${escHtml(f.name)}</span>
<span class="xavatar-lib-card-size">${_xavatarFmtSize(f.size)}</span>
</div>
<div class="xavatar-lib-card-actions">
<button class="btn btn-xs" title="Download .xavatar" data-xa="download" data-xa-path="${rpath}" data-xa-name="${escHtml(f.name)}">&#8659;</button>
<button class="btn btn-xs btn-danger" title="Delete from PS5" data-xa="delete" data-xa-path="${rpath}" data-xa-name="${escHtml(f.name)}">&#10005;</button>
</div>
</div>`;
}).join('') + '</div>';
_setupXavatarThumbObserver(listEl);
} catch (err) {
listEl.innerHTML = '';
emptyEl.hidden = false;
emptyEl.textContent = 'Failed to load library: ' + err.message;
}
}
/** Attach IntersectionObserver to lazily load thumbnails as cards scroll into view */
function _setupXavatarThumbObserver(container) {
const imgs = container.querySelectorAll('img.xavatar-thumb');
if (!imgs.length) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
observer.unobserve(img);
_loadXavatarThumb(img, img.dataset.xaPath);
}
});
}, { rootMargin: '120px', threshold: 0.01 });
imgs.forEach(img => observer.observe(img));
}
/** Fetch + extract avatar.png from a .xavatar on FTP, cache as blob URL */
async function _getXavatarThumbUrl(rpath) {
let url = _xavatarThumbCache.get(rpath);
if (!url) {
const xavBuf = await window.pork.ftpDownloadFile(rpath);
const pngBuf = await window.pork.xavatarExtractPng(xavBuf);
const blob = new Blob([pngBuf], { type: 'image/png' });
url = URL.createObjectURL(blob);
_xavatarThumbCache.set(rpath, url);
}
return url;
}
/** Load one thumbnail into an img element */
async function _loadXavatarThumb(img, rpath) {
const card = img.closest?.('.xavatar-lib-card');
try {
const url = await _getXavatarThumbUrl(rpath);
img.src = url;
img.onload = () => card?.classList.add('xavatar-thumb-loaded');
img.onerror = () => card?.classList.add('xavatar-thumb-error');
} catch (_) {
card?.classList.add('xavatar-thumb-error');
}
}
// ── XAvatar full-size modal ───────────────────────────────────────────────────
let _xavatarModalPath = null;
function _xavatarOpenModal(rpath, name) {
_xavatarModalPath = rpath;
const modal = $('xavatar-thumb-modal');
const img = $('xavatar-modal-img');
const namEl = $('xavatar-modal-name');
img.src = '';
img.alt = 'Loading…';
namEl.textContent = name;
openAccessibleDialog(modal, $('btn-xavatar-modal-close'));
// reset modal send button
const sendBtn = $('btn-xavatar-modal-send');
if (sendBtn) { sendBtn.disabled = false; sendBtn.textContent = '↗ Send to PS5'; }
_getXavatarThumbUrl(rpath)
.then(url => { if (_xavatarModalPath === rpath) { img.src = url; img.alt = name; } })
.catch(() => { if (_xavatarModalPath === rpath) img.alt = 'Preview unavailable'; });
}
// Close modal on backdrop click or close button
document.addEventListener('click', e => {
const modal = $('xavatar-thumb-modal');
if (!modal || modal.hidden) return;
if (e.target === modal || e.target.id === 'btn-xavatar-modal-close') {
closeAccessibleDialog(modal);
_xavatarModalPath = null;
}
});
document.addEventListener('keydown', event => {
const modal = $('xavatar-thumb-modal');
if (event.key === 'Escape' && modal && !modal.hidden) {
closeAccessibleDialog(modal);
_xavatarModalPath = null;
}
});
// ── XAvatar: format picker dialog ──────────────────────────────────────────
function _xavatarPickFormat() {
return new Promise(resolve => {
const dlg = document.createElement('dialog');
dlg.style.cssText = [
'border:none',
'border-radius:10px',
'padding:22px 26px 18px',
'background:var(--bg2,#1e1e2e)',
'color:var(--fg,#cdd6f4)',
'box-shadow:0 8px 32px rgba(0,0,0,.65)',
'text-align:center',
'min-width:260px',
'position:fixed',
'top:50%',
'left:50%',
'transform:translate(-50%,-50%)',
'margin:0',
].join(';');
dlg.innerHTML = [
'<p id="_xa-fmt-title" style="margin:0 0 16px;font-size:14px;font-weight:600;">Choose download format</p>',
'<div style="display:flex;gap:10px;justify-content:center;">',
' <button class="btn btn-teal" id="_xa-fmt-xav" style="min-width:96px;">.xavatar</button>',
' <button class="btn" id="_xa-fmt-png" style="min-width:96px;">.png</button>',
' <button class="btn" id="_xa-fmt-cancel" style="min-width:74px;opacity:.7;">Cancel</button>',
'</div>',
].join('');
dlg.setAttribute('aria-labelledby', '_xa-fmt-title');
document.body.appendChild(dlg);
dlg.showModal();
const done = (val) => { dlg.removeEventListener('close', onClose); dlg.close(); dlg.remove(); resolve(val); };
const onClose = () => { dlg.remove(); resolve(null); };
dlg.addEventListener('close', onClose);
dlg.querySelector('#_xa-fmt-xav').addEventListener('click', () => done('xavatar'));
dlg.querySelector('#_xa-fmt-png').addEventListener('click', () => done('png'));
dlg.querySelector('#_xa-fmt-cancel').addEventListener('click', () => done(null));
});
}
// Modal action buttons
function _xavatarBindModalButtons() {
const modalDownload = $('btn-xavatar-modal-download');
const modalToPng = $('btn-xavatar-modal-topng');
const modalSend = $('btn-xavatar-modal-send');
if (!modalDownload) return;
modalDownload.addEventListener('click', async () => {
if (!_xavatarModalPath) return;
const fmt = await _xavatarPickFormat();
if (!fmt) return;
const name = $('xavatar-modal-name')?.textContent || 'avatar.xavatar';
modalDownload.disabled = true;
try {
const buf = await window.pork.ftpDownloadFile(_xavatarModalPath);
if (fmt === 'png') {
const pngName = name.replace(/\.xavatar$/i, '') + '.png';
const pngBuf = await window.pork.xavatarExtractPng(buf);
await window.pork.xavatarSaveFile(pngBuf, pngName);
} else {
await window.pork.xavatarSaveFile(buf, name);
}
} catch (err) { showToast('Download failed: ' + err.message, 'error'); }
modalDownload.disabled = false;
});
modalToPng.addEventListener('click', async () => {
if (!_xavatarModalPath) return;
const name = $('xavatar-modal-name')?.textContent || 'avatar';
const pngName = name.replace(/\.xavatar$/i, '') + '.png';
modalToPng.disabled = true;
try {
const xavBuf = await window.pork.ftpDownloadFile(_xavatarModalPath);
const pngBuf = await window.pork.xavatarExtractPng(xavBuf);
await window.pork.xavatarSaveFile(pngBuf, pngName);
} catch (err) { showToast('PNG save failed: ' + err.message, 'error'); }
modalToPng.disabled = false;
});
modalSend.addEventListener('click', async () => {
if (!_xavatarModalPath) return;
const name = $('xavatar-modal-name')?.textContent || 'avatar.xavatar';
modalSend.disabled = true;
modalSend.textContent = 'Sending…';
try {
const buf = await window.pork.ftpDownloadFile(_xavatarModalPath);
await window.pork.xavatarUploadToPs5(buf, name);
modalSend.textContent = '↗ Send to PS5';
modalSend.disabled = false;
showToast(`Queued: /data/AVATARS/${name}`, 'success');
} catch (err) {
showToast('Upload failed: ' + err.message, 'error');
modalSend.textContent = '↗ Send to PS5';
modalSend.disabled = false;
}
});
}
// Bind now if the DOM is already parsed, otherwise wait — this module may be
// loaded after DOMContentLoaded has already fired.
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _xavatarBindModalButtons);
else _xavatarBindModalButtons();
// ── XAvatar: card thumbnail click → open modal ────────────────────────────────
document.addEventListener('click', e => {
const card = e.target.closest('.xavatar-lib-card');
if (card && !e.target.closest('[data-xa]')) {
_xavatarOpenModal(card.dataset.xaCardPath, card.dataset.xaCardName);
}
});
// ── XAvatar: library card action buttons (↓ download / ✕ delete) ─────────────
document.addEventListener('click', async e => {
const btn = e.target.closest('[data-xa]');
if (!btn) return;
const action = btn.dataset.xa;
const rpath = btn.dataset.xaPath;
const name = btn.dataset.xaName;
btn.disabled = true;
try {
if (action === 'download') {
const fmt = await _xavatarPickFormat();
if (!fmt) { btn.disabled = false; return; }
const buf = await window.pork.ftpDownloadFile(rpath);
if (fmt === 'png') {
const pngName = name.replace(/\.xavatar$/i, '') + '.png';
const pngBuf = await window.pork.xavatarExtractPng(buf);
await window.pork.xavatarSaveFile(pngBuf, pngName);
} else {
await window.pork.xavatarSaveFile(buf, name);
}
} else if (action === 'delete') {
// Native browser dialogs are blocked in this app; use the in-app modal.
const confirmation = await showExactConfirmation(`Delete /data/AVATARS/${name} from your PS5?`, 'VERWIJDER VAN PS5');
if (!confirmation) { btn.disabled = false; return; }
await window.pork.ftpDeleteFile(rpath, confirmation);
btn.closest('.xavatar-lib-card')?.remove();
_xavatarThumbCache.delete(rpath);
const grid = $('xavatar-library-list')?.querySelector('.xavatar-lib-grid');
if (grid && !grid.children.length) $('xavatar-library-empty').hidden = false;
}
} catch (err) {
showToast('XAvatar: ' + err.message, 'error');
}
btn.disabled = false;
});
+191
View File
@@ -0,0 +1,191 @@
// ── Y2JB Update Generator ───────────────────────────────────────────────────
const y2jbGen = { entries: [], payloads: [], catalog: [] };
const y2jbGenBytes = value => {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
function y2jbGenRender() {
const list = $('y2jbgen-list');
if (!list) return;
if (!y2jbGen.entries.length) {
list.innerHTML = '<p class="hint autoloader-empty-hint">No entries yet. Add payload files, then order payloads, delays, and messages.</p>';
return;
}
list.innerHTML = y2jbGen.entries.map((entry, index) => {
const value = escHtml(entry.value || '');
const title = entry.type === 'payload' ? 'Payload' : entry.type === 'delay' ? 'Delay (ms)' : 'Message';
const placeholder = entry.type === 'payload' ? 'payload.elf' : entry.type === 'delay' ? '1000' : 'Shown during autoload';
return `<div class="autoloader-row" data-y2jb-index="${index}">
<span class="autoload-row-num">${index + 1}</span>
<select class="y2jbgen-type" data-y2jb-type="${index}" aria-label="Y2JB entry type">
<option value="payload" ${entry.type === 'payload' ? 'selected' : ''}>Payload</option>
<option value="delay" ${entry.type === 'delay' ? 'selected' : ''}>Delay</option>
<option value="message" ${entry.type === 'message' ? 'selected' : ''}>Message</option>
</select>
<input class="y2jbgen-value" data-y2jb-value="${index}" value="${value}" placeholder="${placeholder}" aria-label="${title}">
<button class="btn btn-xs" data-y2jb-up="${index}" title="Move up">&#8593;</button>
<button class="btn btn-xs" data-y2jb-down="${index}" title="Move down">&#8595;</button>
<button class="btn btn-xs" data-y2jb-remove="${index}" title="Remove">&#10005;</button>
</div>`;
}).join('');
}
function y2jbGenSetStatus(text, type = '') {
const status = $('y2jbgen-status');
status.textContent = text;
status.className = `hint ${type}`;
}
async function y2jbGenLoadCatalog() {
const select = $('y2jbgen-catalog');
if (!select || !window.pork.y2jbGenCatalog) return;
select.disabled = true;
select.innerHTML = '<option>Loading local payloads…</option>';
try {
const packet = await window.pork.y2jbGenCatalog();
y2jbGen.catalog = packet.items || [];
select.innerHTML = y2jbGen.catalog.length
? y2jbGen.catalog.map(item => `<option value="${escHtml(item.id)}">${escHtml(item.name)}${item.version ? ` · ${escHtml(item.version)}` : ''} · ${y2jbGenBytes(item.size)}</option>`).join('')
: '<option disabled>No downloaded payloads yet — use the Online Payload Catalog first.</option>';
y2jbGenSetStatus(y2jbGen.catalog.length
? `${y2jbGen.catalog.length} local catalog payload${y2jbGen.catalog.length === 1 ? '' : 's'} available.`
: 'The local Payload Catalog is empty.');
} catch (error) {
select.innerHTML = '<option disabled>Catalog unavailable</option>';
y2jbGenSetStatus(`Catalog error: ${error.message}`, 'error');
}
select.disabled = false;
}
$('btn-y2jbgen-refresh-catalog').addEventListener('click', y2jbGenLoadCatalog);
$('btn-y2jb-discover')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try {
const release = await window.pork.y2jbDiscover(true);
$('y2jb-upstream-status').innerHTML = `<strong>${escHtml(release.version || 'Latest official Y2JB release')}</strong><span>${escHtml(release.source)} · ${y2jbGenBytes(release.size || 0)} · ${release.digest ? 'published SHA-256 available' : 'ZIP validation; no published SHA-256'}</span>`;
} catch (error) {
$('y2jb-upstream-status').innerHTML = `<strong>Release discovery unavailable</strong><span>${escHtml(error.message)}</span>`;
} finally { button.disabled = false; }
});
$('btn-y2jb-stage')?.addEventListener('click', async event => {
if (!await showConfirm('Download the discovered official Y2JB ZIP, validate it and copy it to the configured PS5 update folder? The update is not executed.')) return;
const button = event.currentTarget;
button.disabled = true;
try {
const result = await window.pork.y2jbApply();
$('y2jb-upstream-status').innerHTML = `<strong>Verified update staged</strong><span>${escHtml(result.version || 'Y2JB')} · ${escHtml(result.remotePath)} · SHA-256 ${escHtml(result.sha256.slice(0, 16))}… · not executed</span>`;
} catch (error) {
$('y2jb-upstream-status').innerHTML = `<strong>Staging failed</strong><span>${escHtml(error.message)}</span>`;
} finally { button.disabled = false; }
});
$('btn-y2jbgen-add-catalog').addEventListener('click', () => {
const selected = [...$('y2jbgen-catalog').selectedOptions];
let added = 0;
for (const option of selected) {
const payload = y2jbGen.catalog.find(item => item.id === option.value);
if (!payload || y2jbGen.entries.some(entry => entry.type === 'payload' && entry.value === payload.name)) continue;
y2jbGen.entries.push({ type: 'payload', value: payload.name, catalogId: payload.id });
added += 1;
}
y2jbGenRender();
y2jbGenSetStatus(added ? `Added ${added} catalog payload${added === 1 ? '' : 's'} ✓` : 'No new catalog payloads selected.');
});
$('btn-y2jbgen-add-payloads').addEventListener('click', async () => {
const btn = $('btn-y2jbgen-add-payloads');
btn.disabled = true;
y2jbGenSetStatus('Choosing payload files…');
try {
const chosen = await window.pork.y2jbGenSelectPayloads();
let added = 0;
for (const payload of chosen || []) {
if (y2jbGen.payloads.some((item) => item.name === payload.name)
|| y2jbGen.entries.some(entry => entry.type === 'payload' && entry.value === payload.name)) continue;
y2jbGen.payloads.push(payload);
y2jbGen.entries.push({ type: 'payload', value: payload.name });
added += 1;
}
y2jbGenRender();
y2jbGenSetStatus(added ? `Added ${added} payload file${added === 1 ? '' : 's'} ✓` : 'No new payload files selected.');
} catch (error) {
y2jbGenSetStatus(`Error: ${error.message}`, 'error');
}
btn.disabled = false;
});
$('btn-y2jbgen-add-delay').addEventListener('click', () => {
y2jbGen.entries.push({ type: 'delay', value: '1000' });
y2jbGenRender();
});
$('btn-y2jbgen-add-message').addEventListener('click', () => {
y2jbGen.entries.push({ type: 'message', value: 'Starting next payload' });
y2jbGenRender();
});
$('y2jbgen-list').addEventListener('input', (event) => {
const index = Number(event.target.dataset.y2jbValue);
if (Number.isInteger(index) && y2jbGen.entries[index]) y2jbGen.entries[index].value = event.target.value;
});
$('y2jbgen-list').addEventListener('change', (event) => {
const index = Number(event.target.dataset.y2jbType);
if (Number.isInteger(index) && y2jbGen.entries[index]) {
y2jbGen.entries[index].type = event.target.value;
if (event.target.value === 'delay' && !/^\d+$/.test(y2jbGen.entries[index].value)) y2jbGen.entries[index].value = '1000';
y2jbGenRender();
}
});
$('y2jbgen-list').addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button) return;
const up = Number(button.dataset.y2jbUp);
const down = Number(button.dataset.y2jbDown);
const remove = Number(button.dataset.y2jbRemove);
if (Number.isInteger(up) && y2jbGen.entries[up] && up > 0) {
[y2jbGen.entries[up - 1], y2jbGen.entries[up]] = [y2jbGen.entries[up], y2jbGen.entries[up - 1]];
} else if (Number.isInteger(down) && y2jbGen.entries[down] && down < y2jbGen.entries.length - 1) {
[y2jbGen.entries[down + 1], y2jbGen.entries[down]] = [y2jbGen.entries[down], y2jbGen.entries[down + 1]];
} else if (Number.isInteger(remove) && y2jbGen.entries[remove]) {
y2jbGen.entries.splice(remove, 1);
} else {
return;
}
y2jbGenRender();
});
$('btn-y2jbgen-build').addEventListener('click', async () => {
const btn = $('btn-y2jbgen-build');
btn.disabled = true;
y2jbGenSetStatus('Validating and building ZIP…');
try {
const referenced = new Set(y2jbGen.entries.filter(entry => entry.type === 'payload').map(entry => entry.value));
const catalogPayloadIds = y2jbGen.entries
.filter(entry => entry.type === 'payload' && entry.catalogId && referenced.has(entry.value))
.map(entry => entry.catalogId);
const result = await window.pork.y2jbGenBuild({
entries: y2jbGen.entries.map(({ catalogId, ...entry }) => entry),
payloads: y2jbGen.payloads.filter(payload => referenced.has(payload.name)),
catalogPayloadIds,
});
if (result?.canceled) {
y2jbGenSetStatus('Save cancelled.');
} else {
y2jbGenSetStatus(`Built ✓ ${result.outputZip} (${result.entryCount} entries)`);
}
} catch (error) {
y2jbGenSetStatus(`Error: ${error.message}`, 'error');
}
btn.disabled = false;
});
y2jbGenLoadCatalog();
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#15111b">
<title>Chimera · offline</title>
<style>@font-face{font-family:Inter;src:url('assets/fonts/inter-latin.woff2') format('woff2');font-style:normal;font-weight:100 900;font-display:swap}@font-face{font-family:'Space Grotesk';src:url('assets/fonts/space-grotesk-latin.woff2') format('woff2');font-style:normal;font-weight:300 700;font-display:swap}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#15111b;color:#f4eef8;font:16px system-ui,sans-serif}.offline{max-width:520px;margin:24px;padding:32px;border:1px solid #4c3d58;border-radius:18px;background:#211a29;text-align:center;box-shadow:0 24px 70px rgba(0,0,0,.35)}.offline img{width:96px;height:96px;border-radius:20px}.offline p{color:#c9bacf;line-height:1.55}.offline a{display:inline-block;padding:10px 16px;border:1px solid #8f6da3;border-radius:9px;background:#37283f;color:#fff;text-decoration:none}.offline a:focus-visible{outline:2px solid #CBBAFF;outline-offset:3px}@media(max-width:520px){.offline{margin:16px;padding:24px 20px}}</style>
</head>
<body><main class="offline"><img src="__CHIMERA_ICON_SRC__" alt="Chimera"><h1>Chimera is offline</h1><p>The secure offline shell intentionally contains no console data, credentials, cached API responses or mutating controls. Restore the HTTPS connection to continue.</p><a href="/">Try again</a></main></body>
</html>
+1
View File
@@ -0,0 +1 @@
(()=>{const p=window.pork;if(!p?.isWebMode)return;p.ps5NativePackageRetainedLaunchEvidence=async operationId=>{const r=await fetch('/api/commands/ps5-native-packages%3Aretained-launch-evidence',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({args:[{operationId}]})});const d=await r.json();if(!r.ok||d.ok!==true){if(r.status===401)location.replace('/server-login.html');throw Object.assign(new Error(d.error||`HTTP ${r.status}`),{code:d.code||'COMMAND_FAILED'});}return d.result;};p.download=(buffer,filename)=>{const url=URL.createObjectURL(new Blob([buffer],{type:'application/json'}));const a=Object.assign(document.createElement('a'),{href:url,download:filename});document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),10000);};})();
+1
View File
@@ -0,0 +1 @@
!function(){"use strict";if(window.pork)return;let e=null;const t=fetch("/api/auth/status",{credentials:"same-origin"}).then(async t=>{const a=await t.json();if(!a.authenticated)throw location.replace("/server-login.html"),new Error("Authentication required");return e=a.csrfToken,a}),a=new Map;let o=null;function s(e){let t="";for(let a=0;a<e.length;a+=32768)t+=String.fromCharCode(...e.subarray(a,a+32768));return btoa(t)}function r(e){if(null==e)return e;if(e instanceof ArrayBuffer)return{__porkBinary:!0,base64:s(new Uint8Array(e))};if(ArrayBuffer.isView(e))return{__porkBinary:!0,base64:s(new Uint8Array(e.buffer,e.byteOffset,e.byteLength))};if(Array.isArray(e))return e.map(e=>r(e));if("object"==typeof e){const t={};for(const[a,o]of Object.entries(e))t[a]=r(o);return t}return e}function i(e){if(!e||"object"!=typeof e)return e;if(!0===e.__porkBinary&&"string"==typeof e.base64)return function(e){const t=atob(e),a=new Uint8Array(t.length);for(let e=0;e<t.length;e++)a[e]=t.charCodeAt(e);return a}(e.base64);if(Array.isArray(e))return e.map(e=>i(e));const t={};for(const[a,o]of Object.entries(e))t[a]=i(o);return t}async function n(a,...o){await t;const s=await fetch(`/api/commands/${encodeURIComponent(a)}`,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json","X-CSRF-Token":e},body:JSON.stringify({args:r(o)})});if(401===s.status)throw location.replace("/server-login.html"),new Error("Authentication required");const n=await s.json();if(!n.ok){const e=new Error(n.error||"IPC error");throw e.status=s.status,e.code=n.code||"COMMAND_FAILED",e.expected=["NOT_CONFIGURED","UNAVAILABLE","UPSTREAM_UNAVAILABLE"].includes(e.code),e}return i(n.result)}async function p(a,{method:o="GET",body:s}={}){await t;const r={"X-CSRF-Token":e};void 0!==s&&(r["Content-Type"]="application/json");const i=await fetch(a,{method:o,credentials:"same-origin",headers:r,body:void 0===s?void 0:JSON.stringify(s)});if(401===i.status)throw location.replace("/server-login.html"),new Error("Authentication required");const n=await i.json();if(!i.ok||!1===n.ok)throw new Error(n.error||`HTTP ${i.status}`);return n}function c(e){return"string"==typeof e&&e.startsWith("pork-cache://")?"/pork-cache/"+e.slice(13):e}async function l(e){const t=await e;if(t?.downloadUrl){const e=document.createElement("a");e.href=t.downloadUrl,e.download=t.filename||"",e.style.display="none",document.body.appendChild(e),e.click(),e.remove()}return t}async function m(e){const t=await l(e);return t?.filename||t}function d(e=""){return new Promise((t,a)=>{const o=document.createElement("input");o.type="file",o.accept=e,o.style.display="none",o.addEventListener("change",async()=>{try{const e=o.files?.[0];t(e?{filename:e.name,data:new Uint8Array(await e.arrayBuffer())}:null)}catch(e){a(e)}finally{o.remove()}},{once:!0}),o.addEventListener("cancel",()=>{o.remove(),t(null)},{once:!0}),document.body.appendChild(o),o.click()})}function u(e=""){return new Promise((t,a)=>{const o=document.createElement("input");o.type="file",o.multiple=!0,o.accept=e,o.style.display="none",o.addEventListener("change",async()=>{try{const e=[...o.files||[]];t(await Promise.all(e.map(async e=>({name:e.name,data:new Uint8Array(await e.arrayBuffer())}))))}catch(e){a(e)}finally{o.remove()}},{once:!0}),o.addEventListener("cancel",()=>{o.remove(),t([])},{once:!0}),document.body.appendChild(o),o.click()})}function y(e,t){const a=window.prompt(`${e}\n\nGebruik een pad dat als volume in de Chimera-container is gekoppeld.`,t||"/data");return Promise.resolve(null!=a&&String(a).trim()?String(a).trim():null)}function h(e){const t=Object.getOwnPropertyDescriptor(e,"src");if(!t||!t.set)return;const a=t.set;Object.defineProperty(e,"src",{...t,set(e){a.call(this,c(e))}})}!function e(){const t="https:"===location.protocol?"wss:":"ws:";o=new WebSocket(`${t}//${location.host}/ws`),o.onmessage=e=>{try{const{channel:t,args:o}=JSON.parse(e.data),s=a.get(t);if(s)for(const e of s)e(...o||[])}catch(e){}},o.onclose=()=>setTimeout(e,2e3),o.onerror=()=>{try{o.close()}catch(e){}}}(),h(HTMLVideoElement.prototype),h(HTMLImageElement.prototype);const g=Element.prototype.setAttribute;Element.prototype.setAttribute=function(e,t){return"src"===e&&(t=c(t)),g.call(this,e,t)},window.pork={isWebMode:!0,minimize:()=>{},maximize:()=>{},close:()=>{},getSettings:()=>n("settings:get"),setSettings:e=>n("settings:set",e),isAdmin:()=>Promise.resolve(!0),ftpConnect:e=>n("ftp:connect",e),ftpDisconnect:()=>n("ftp:disconnect"),ftpStatus:()=>n("ftp:status"),ftpScan:()=>n("ftp:scan"),ftpDownload:e=>n("ftp:download",e),ftpDownloadFolder:e=>n("ftp:download-folder",{game_id:e}),ftpUpload:e=>n("ftp:upload",e),ftpInstallPfs:e=>n("ftp:install-pfs",e),ftpListDir:e=>n("ftp:list-dir",{path:e}),ftpFindGame:e=>n("ftp:find-game",{game_id:e}),ftpDeleteGame:(e,t)=>n("ftp:delete-game",{game_id:e,confirmation:t}),ftpDownloadFile:e=>n("ftp:download-file",{remotePath:e}),ftpDeleteFile:(e,t)=>n("ftp:delete-file",{remotePath:e,confirmation:t}),listGamesLine truncated
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark">
<link rel="icon" type="image/svg+xml" href="/assets/chimera-crest.svg"><link rel="apple-touch-icon" href="__CHIMERA_ICON_SRC__">
<title>Chimera — sign in</title><link rel="stylesheet" href="/styles/server-login.css">
</head><body><main class="auth-shell"><section class="auth-card">
<img src="/assets/chimera-crest.svg" alt="" class="logo"><p class="eyebrow">PERSONAL PLAYSTATION PLATFORM</p><h1>Chimera</h1>
<p id="intro">Sign in securely to your personal Chimera server.</p><div id="message" role="status" aria-live="polite"></div>
<form id="login-form" hidden>
<label>Username<input name="username" autocomplete="username" required></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required></label><label id="totp-row" hidden>Authenticator or recovery code<input name="totpCode" inputmode="numeric" autocomplete="one-time-code"></label><button type="submit">Sign in</button>
</form>
<form id="setup-form" hidden><h2>Initial setup</h2><p class="step">1. Local administrator</p>
<label>One-time bootstrap token<input name="bootstrapToken" type="password" autocomplete="one-time-code"><small>Required for network setup. Read it from the root-owned file configured as <code>CHIMERA_BOOTSTRAP_TOKEN_PATH</code>; loopback setup may leave this blank.</small></label>
<div class="grid"><label>Username<input name="username" autocomplete="username" minlength="3" maxlength="64" required></label>
<label>Password (12 characters minimum)<input name="password" type="password" autocomplete="new-password" minlength="12" required></label></div>
<p class="step">2. Persistent storage and volumes</p><div id="data-status" class="panel"></div>
<fieldset><legend>Allowed server roots</legend><div id="roots"></div></fieldset>
<p class="step">3. PS5 connection profile</p><div class="grid three">
<label>PS5 host/IP<input name="ftpHost" placeholder="192.168.10.x"></label>
<label>FTP port<input name="ftpPort" type="number" value="21" min="1" max="65535"></label>
<label>FTP user<input name="ftpUser" autocomplete="off"></label></div>
<label>FTP password<input name="ftpPassword" type="password" autocomplete="new-password"><small>Stored encrypted and never returned to the browser.</small></label>
<button type="button" class="secondary" id="test-ftp">Test FTP connection</button>
<p class="step">4. Language, backups and capabilities</p><div class="grid">
<label>Language<select name="language"><option value="en">English</option><option value="nl">Nederlands</option></select></label>
<label>Automatic backups to retain<input name="backupRetention" type="number" value="14" min="1" max="90"></label></div>
<div id="capabilities" class="panel"></div>
<label class="check"><input name="safetyConfirmed" type="checkbox" required> I understand that destructive PS5 actions require confirmation and that backups and persistent storage remain enabled.</label>
<button type="submit">Complete setup</button>
</form>
</section></main><script src="/server-login.js"></script></body></html>
+48
View File
@@ -0,0 +1,48 @@
(function () {
'use strict';
const loginForm = document.querySelector('#login-form');
const setupForm = document.querySelector('#setup-form');
const message = document.querySelector('#message');
function showMessage(text, kind = '') { message.textContent = text; message.className = kind; }
async function api(url, options = {}) {
const response = await fetch(url, { credentials: 'same-origin', ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } });
const data = await response.json();
if (!response.ok || data.ok === false) throw new Error(data.error || 'Request failed.');
return data;
}
function values(form) { return Object.fromEntries(new FormData(form).entries()); }
async function initialize() {
const auth = await api('/api/auth/status');
if (auth.authenticated) { location.replace('/'); return; }
const status = await api('/api/setup/status');
if (status.configured) { loginForm.hidden = false; const authStatus = await api('/api/auth/status'); document.querySelector('#totp-row').hidden = !authStatus.totpRequired; loginForm.elements.totpCode.required = authStatus.totpRequired; document.querySelector('#intro').textContent = authStatus.totpRequired ? 'Sign in with your password and two-factor authentication.' : 'Sign in to your personal Chimera server.'; return; }
setupForm.hidden = false; setupForm.elements.language.value = status.defaultLanguage || 'en'; document.querySelector('#intro').textContent = 'Complete the one-time secure Docker configuration.';
document.querySelector('#data-status').textContent = `Data root: ${status.dataRoot} — ${status.volumes.filter(v => v.writable).length}/${status.volumes.length} volumes writable.`;
const roots = document.querySelector('#roots');
for (const volume of status.volumes) {
const label = document.createElement('label'); label.className = 'check';
const input = document.createElement('input'); input.type = 'checkbox'; input.name = 'allowedRoots'; input.value = volume.path; input.checked = true; input.disabled = !volume.writable;
label.append(input, document.createTextNode(` ${volume.path} ${volume.writable ? '' : '(read-only)'}`)); roots.append(label);
}
document.querySelector('#capabilities').textContent = Object.entries(status.capabilities.features).map(([name, feature]) => `${name}: ${feature.available ? 'available' : `unavailable — ${feature.reason}`}`).join('\n');
}
loginForm.addEventListener('submit', async event => {
event.preventDefault(); showMessage('Signing in…');
try { await api('/api/auth/login', { method: 'POST', body: JSON.stringify(values(loginForm)) }); location.replace('/'); }
catch (error) { showMessage(error.message, 'error'); }
});
document.querySelector('#test-ftp').addEventListener('click', async () => {
const data = values(setupForm); if (!data.ftpHost) { showMessage('Enter a PS5 host or IP first.', 'error'); return; }
showMessage('Testing FTP connection…');
try { await api('/api/setup/test-ftp', { method: 'POST', headers: { 'X-Chimera-Bootstrap-Token': data.bootstrapToken || '' }, body: JSON.stringify({ host: data.ftpHost, port: data.ftpPort, user: data.ftpUser, password: data.ftpPassword }) }); showMessage('FTP connection successful.', 'success'); }
catch (error) { showMessage(error.message, 'error'); }
});
setupForm.addEventListener('submit', async event => {
event.preventDefault(); showMessage('Saving setup securely…'); const data = values(setupForm);
data.allowedRoots = [...setupForm.querySelectorAll('input[name="allowedRoots"]:checked:not(:disabled)')].map(input => input.value);
data.safetyConfirmed = setupForm.elements.safetyConfirmed.checked;
try { await api('/api/setup', { method: 'POST', headers: { 'X-Chimera-Bootstrap-Token': data.bootstrapToken || '' }, body: JSON.stringify(data) }); location.replace('/'); }
catch (error) { showMessage(error.message, 'error'); }
});
initialize().catch(error => showMessage(error.message, 'error'));
})();
+39
View File
@@ -0,0 +1,39 @@
'use strict';
const CACHE = 'chimera-shell-v1';
const SHELL = ['/offline.html', '/manifest.webmanifest', '/build/icon.png'];
self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', event => {
event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key.startsWith('chimera-shell-') && key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()));
});
function cacheable(url) {
return url.origin === self.location.origin && (url.pathname === '/manifest.webmanifest' || url.pathname === '/offline.html' || url.pathname === '/build/icon.png' || url.pathname.startsWith('/styles/') || url.pathname.startsWith('/modules/'));
}
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
if (event.request.mode === 'navigate') {
event.respondWith(fetch(event.request).catch(() => caches.match('/offline.html')));
return;
}
if (!cacheable(url)) return;
const isIcon = url.pathname === '/build/icon.png';
const cacheFallback = isIcon ? '/build/icon.png' : event.request;
event.respondWith(
fetch(event.request).then(response => {
if (response.ok && response.type === 'basic') {
caches.open(CACHE).then(cache => {
cache.put(event.request, response.clone());
if (isIcon) cache.put(cacheFallback, response.clone());
});
}
return response;
}).catch(() => caches.match(cacheFallback)),
);
});
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<meta name="color-scheme" content="dark"/>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:;"/>
<title>Starting Chimera</title>
<style>@font-face{font-family:Inter;src:url('assets/fonts/inter-latin.woff2') format('woff2');font-style:normal;font-weight:100 900;font-display:swap}@font-face{font-family:'Space Grotesk';src:url('assets/fonts/space-grotesk-latin.woff2') format('woff2');font-style:normal;font-weight:300 700;font-display:swap}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: #0d0a11;
font-family: Inter,'Segoe UI',system-ui,sans-serif;
user-select: none; -webkit-app-region: drag;
}
body {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 16px;
}
.icon {
width: 72px; height: 72px; border-radius: 18px;
filter: drop-shadow(0 0 18px rgba(187,134,252,0.45));
animation: pop .35s cubic-bezier(.34,1.56,.64,1) both;
}
@keyframes pop {
from { opacity: 0; transform: scale(.7); }
to { opacity: 1; transform: scale(1); }
}
h1 {
font-size: 20px; font-weight: 700;
color: #e0e0e0; letter-spacing: .04em;
}
.subtitle {
font-size: 11px; color: #757575;
margin-top: -10px; letter-spacing: .05em; text-transform: uppercase;
}
.bar-track {
width: 180px; height: 2px;
background: #2c2c2c; border-radius: 2px; overflow: hidden;
margin-top: 4px;
}
.bar-fill {
height: 100%; width: 35%;
background: linear-gradient(90deg, transparent, #CBBAFF, transparent);
animation: shimmer 1.3s ease-in-out infinite;
}
@keyframes shimmer {
from { transform: translateX(-200%); }
to { transform: translateX(500%); }
}
#status {
font-size: 11px; color: #555;
min-height: 15px; transition: opacity .2s;
}
@media (prefers-reduced-motion: reduce) { .icon, .bar-fill { animation: none; } .bar-fill { width: 100%; opacity: .65; } #status { transition: none; } }
</style>
</head>
<body>
<img class="icon" src="__CHIMERA_ICON_SRC__" alt=""/>
<h1>Chimera</h1>
<p class="subtitle">PS operations hub</p>
<div class="bar-track" role="progressbar" aria-label="Starting Chimera"><div class="bar-fill"></div></div>
<p id="status" role="status" aria-live="polite">Starting up…</p>
<script>
// Allow main process to update the status line
if (typeof window !== 'undefined') {
window.setSplashStatus = (msg) => {
const el = document.getElementById('status');
if (el) el.textContent = msg;
};
}
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
:root{--sidebar-w:210px;--titlebar-h:32px;--statusbar-h:26px}*,::after,::before{box-sizing:border-box;margin:0;padding:0}[hidden]{display:none!important}body,html{height:100%;background:var(--bg);color:var(--text);font-family:-apple-system,'Segoe UI',system-ui,sans-serif;font-size:14px;overflow:hidden;user-select:none}#titlebar{position:fixed;top:0;left:0;right:0;height:var(--titlebar-h);background:#0d0d0d;border-bottom:1px solid #000;display:flex;align-items:center;justify-content:space-between;z-index:2000;-webkit-app-region:drag}#titlebar-drag{display:flex;align-items:center;gap:8px;padding-left:12px;font-size:12px;font-weight:600;color:var(--text-dim)}#mobile-nav-toggle{display:none;width:34px;height:34px;border:none;background:0 0;color:var(--text);font-size:20px;border-radius:8px;cursor:pointer;-webkit-app-region:no-drag}#mobile-nav-toggle:hover{background:rgba(255,255,255,.08)}.titlebar-icon{width:18px;height:18px}.titlebar-by{font-size:11px;font-weight:400;color:var(--text-dim);opacity:.5;letter-spacing:.04em;pointer-events:none}#titlebar-x-link{display:inline-flex;align-items:center;gap:5px;color:var(--text-dim);font-size:11px;text-decoration:none;padding:3px 10px;border-radius:16px;border:1px solid var(--border);transition:color .15s,border-color .15s;-webkit-app-region:no-drag;flex-shrink:0;margin:0 auto}#titlebar-x-link:hover{color:var(--text);border-color:var(--text-dim)}#titlebar-x-link svg{flex-shrink:0}#titlebar-controls{display:flex;-webkit-app-region:no-drag}#titlebar-controls button{width:44px;height:32px;background:0 0;border:none;color:var(--text-dim);font-size:12px;cursor:pointer;transition:background .15s,color .15s}#titlebar-controls button:hover{background:rgba(255,255,255,.1);color:var(--text)}#btn-cls:hover{background:var(--red)!important;color:#fff!important}body.web-mode #titlebar{-webkit-app-region:no-drag;justify-content:flex-start}body.web-mode #titlebar-controls,body.web-mode #titlebar-x-link{display:none}body.web-mode #titlebar-drag{width:100%;padding-right:12px}#app{position:fixed;top:var(--titlebar-h);left:0;right:0;bottom:var(--statusbar-h);display:flex}#sidebar{width:var(--sidebar-w);flex-shrink:0;background:#0d0d0d;border-right:1px solid #000;display:flex;flex-direction:column;padding:16px 0}#sidebar-scrim{display:none;position:fixed;top:var(--titlebar-h);left:0;right:0;bottom:var(--statusbar-h);background:rgba(0,0,0,.5);z-index:1840}.sidebar-brand{display:flex;align-items:center;gap:8px;padding:4px 16px 20px;font-size:16px;font-weight:700;color:var(--accent)}.brand-icon{width:26px;height:26px;flex-shrink:0}.nav-list{list-style:none;flex:1}.nav-link{display:flex;align-items:center;gap:10px;padding:10px 16px;color:var(--text-dim);text-decoration:none;font-size:12px;cursor:pointer;transition:.15s;border-left:3px solid transparent}.nav-link svg{width:17px;height:17px;flex-shrink:0}.nav-link:hover{color:var(--text);background:rgba(255,255,255,.05)}.nav-link.active{color:var(--accent);border-left-color:var(--accent);background:rgba(var(--accent-rgb),.08)}#sidebar-donate{display:flex;flex-direction:column;align-items:center;gap:5px;padding:10px 16px 8px;background:0 0;border:none;border-top:1px solid var(--border);cursor:pointer;width:100%;transition:background .15s;-webkit-app-region:no-drag}#sidebar-donate:hover{background:rgba(187,134,252,.06)}#sidebar-donate:active{background:rgba(187,134,252,.12)}.donate-icon{width:30px;height:30px;border-radius:8px;filter:drop-shadow(0 0 6px rgba(187,134,252,.4));transition:filter .2s}#sidebar-donate:hover .donate-icon{filter:drop-shadow(0 0 10px rgba(187,134,252,.7))}.donate-label{font-size:11px;font-weight:600;color:var(--accent);letter-spacing:.06em;text-transform:uppercase}#sidebar-conn{display:flex;align-items:center;gap:8px;padding:12px 16px;font-size:12px;color:var(--text-dim);border-top:1px solid var(--border)}#server-logout{margin:0 12px;padding:7px 10px;color:var(--text-dim);background:rgba(255,255,255,.025);border:1px solid var(--border);border-radius:8px;font:inherit;font-size:11px;cursor:pointer}#server-logout:focus-visible,#server-logout:hover{color:var(--text);border-color:rgba(var(--accent-rgb),.45)}.dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.dot.red{background:var(--red)}.dot.green{background:var(--green)}#content{flex:1;min-width:0;overflow-y:auto;padding:24px;display:flex;flex-direction:column;gap:20px}#content::-webkit-scrollbar{width:6px}#content::-webkit-scrollbar-track{background:0 0}#content::-webkit-scrollbar-thumb{background:var(--border);border-radius:8px}.page{display:none;flex-direction:column;gap:20px}.page.active{display:flex}.page-header{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px}.page-header h1{font-size:22px;font-weight:600;color:var(--text)}.header-tools{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.stats-row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.stat-card{background:var(--surface);border:1px solid varLine truncated
+442
View File
@@ -0,0 +1,442 @@
/* Chimera design system — corrections layer.
Loaded last on purpose: several component stylesheets are linked after
stitch.css, so token-level fixes have to sit downstream of all of them.
Tokens still live in stitch.css; this file only resolves conflicts. */
/* ============================================================================
Design-system corrections — 2026-08-23
One authoritative block. stitch.css owns the tokens; base.css owns the reset.
Ordered: type scale, elevation, surfaces, controls, forms, edges.
========================================================================== */
/* --- Type: one scale, sentence case above the label sizes ----------------- */
#chimera-route-title{
font-family:var(--display);
font-size:var(--fs-display);
font-weight:640;
letter-spacing:-.03em;
line-height:1.04;
text-transform:none;
text-wrap:balance;
}
.stat-num{font-family:var(--display);font-size:var(--fs-xl);font-weight:600;line-height:1;font-variant-numeric:tabular-nums}
.stat-lbl{font-size:var(--fs-xs)}
.page-header h1{font-family:var(--display);font-size:var(--fs-xl);font-weight:620;letter-spacing:-.025em;text-transform:none}
h2{font-size:var(--fs-lg)}
h3,h4{font-size:var(--fs-md)}
.game-card .s-tag{font-size:var(--fs-micro)}
#chimera-route-breadcrumb{font-size:var(--fs-micro);letter-spacing:.14em}
/* --- Elevation: three levels, nothing invented per component -------------- */
.card,.stat-card,.table-wrap,.chimera-home-panel{box-shadow:var(--shadow-panel)}
.modal,.modal-shell,.import-wizard,#toast-container .toast,.chimera-command-palette{box-shadow:var(--shadow-float)}
.btn,.chimera-toolbar-button,.chimera-route-link,.fbtn,.badge,.pill{box-shadow:var(--shadow-flat)}
/* --- Surfaces: inherit the radius scale, stop hand-rolling values --------- */
.card,.stat-card,.table-wrap,.backup-card,.progress-area,.chimera-home-panel,.catalog-health-strip{border-radius:var(--radius)}
.btn,.fbtn,input,select,textarea,.filter-select,.search-input,
.chimera-domain-button,.modal-workspace-links button{border-radius:var(--radius-sm)}
.modal,.modal-shell,.import-wizard{border-radius:var(--radius-lg)}
.badge,.pill,.s-tag,.bk-badge{border-radius:999px}
/* --- Controls: one filled button per view --------------------------------- */
/* Teal was acting as a second primary. It becomes an outlined action and the
accent fill stays the single call to action. */
.btn-teal{
background:transparent;
color:var(--teal);
border-color:rgba(34,199,232,.42);
font-weight:600;
}
.btn-teal:hover:not(:disabled){
background:rgba(34,199,232,.1);
border-color:var(--teal);
color:var(--teal);
}
.btn-accent{background:var(--accent-d);border-color:var(--accent-d);color:#fff}
.btn-accent:hover:not(:disabled){background:var(--accent);border-color:var(--accent);color:#1a1220}
.btn{min-height:36px;font-size:var(--fs-xs);display:inline-flex;align-items:center;justify-content:center;gap:7px}
/* --- Forms: label above field, one control height, full card width -------- */
.card .form-row,.card .form-grid{width:100%}
.card label{flex-direction:column;align-items:flex-start;gap:6px;font-size:var(--fs-xs)}
.card label.toggle-label,.card .toggle-row{flex-direction:row;align-items:center}
input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=color]),
select,textarea{min-height:40px;font-size:var(--fs-xs)}
/* The accent picker was a raw native colour input sitting outside the system. */
.color-swatch{
position:relative;width:40px;height:40px;flex:none;overflow:hidden;
border:1px solid var(--border);border-radius:var(--radius-sm);
background:var(--accent-d);
}
.color-picker-input{
position:absolute;inset:-4px;width:calc(100% + 8px);height:calc(100% + 8px);
padding:0;border:0;background:transparent;cursor:pointer;opacity:0;
}
.color-hex-label{
width:11ch;font-family:var(--mono);font-size:var(--fs-xs);
text-transform:uppercase;text-align:center;
}
/* --- Edges: error placement, toast clearance, orphaned rails -------------- */
/* The header banner and the toast reported the same failure twice. The banner
keeps it (state persists); the toast is suppressed for route-load errors. */
.chimera-route-availability{
max-width:52ch;padding:7px 11px;line-height:1.45;
background:rgba(228,173,85,.1);border:1px solid rgba(228,173,85,.3);
color:var(--yellow);white-space:normal;
}
/* Toast stack clears the floating import button instead of landing under it. */
#toast-container{bottom:calc(var(--statusbar-h) + 74px)}
/* The catalog health rail was floating in the grid with no container, and its
text values were rendered at the numeral display size. */
.catalog-health-strip{
background:var(--surface2);
border:1px solid var(--border-soft);
padding:6px 4px;
}
#game-catalog-health{font-size:var(--fs-xs)}
#game-catalog-health strong,#game-catalog-health b{
font-family:var(--display);font-size:var(--fs-lg);font-weight:600;
line-height:1.15;font-variant-numeric:tabular-nums;
}
/* Compact settings rows: a class, not 17 copies of the same inline style. */
.setting-label{
flex:none;
white-space:nowrap;
font-size:var(--fs-xs);
color:var(--text-dim);
}
.form-row>.setting-label,.color-picker-row>.setting-label{align-self:center}
/* Last two off-scale sizes: a button inheriting the UA default, and a glyph. */
#sidebar-donate{font-size:var(--fs-xs)}
.chimera-command-search-icon,.game-library-search>span[aria-hidden]{font-size:var(--fs-md);line-height:1}
/* ============================================================================
Library: poster-first cards
The grid is the artwork. Actions live on the art and only when reached;
the card itself opens the detail modal, so it carries no button stack.
========================================================================== */
#games-grid{
grid-template-columns:repeat(auto-fill,minmax(170px,1fr));
gap:20px 18px;
align-items:start;
}
.game-card{
display:flex;flex-direction:column;gap:0;
background:0 0;border:0;box-shadow:none;padding:0;
overflow:visible;
}
/* --- the poster ---------------------------------------------------------- */
.game-card-art{
position:relative;
aspect-ratio:var(--game-cover-ratio, 2 / 3);
overflow:hidden;
border-radius:var(--radius);
background:var(--surface2);
border:1px solid var(--border-soft);
box-shadow:0 12px 28px -14px rgba(0,0,0,.75);
transition:box-shadow .24s cubic-bezier(.2,.8,.2,1),border-color .24s;
}
.game-card:hover .game-card-art,
.game-card:focus-visible .game-card-art{
border-color:rgba(var(--accent-rgb),.5);
box-shadow:0 22px 46px -16px rgba(0,0,0,.85),0 0 0 1px rgba(var(--accent-rgb),.16);
}
.game-card-cover{width:100%;height:100%;object-fit:cover;display:block}
/* Coverless titles get a mark derived from their own title ID, so a grid with
no artwork still reads as a shelf of distinct things. */
.game-card-cover-fallback.is-visible{
position:absolute;inset:0;aspect-ratio:auto;
padding:14px;gap:4px;
background:
radial-gradient(circle at 74% 16%, hsl(var(--card-hue,265) 70% 46% / .42), transparent 46%),
radial-gradient(circle at 18% 88%, hsl(calc(var(--card-hue,265) + 46) 68% 40% / .34), transparent 50%),
linear-gradient(158deg,#1a1422,#0a0b11);
}
.game-card-cover-fallback.is-visible::before{
top:14%;right:-22%;width:86%;
border-color:hsl(var(--card-hue,265) 80% 78% / .22);
}
.game-card-cover-fallback strong{font-size:var(--fs-md);max-width:13ch;line-height:1.12}
.game-card-cover-fallback span{color:hsl(var(--card-hue,265) 85% 80% / .9)}
/* --- state marker: a corner chip, not a banner across the poster ---------- */
.game-card-sync{
position:absolute;top:10px;left:10px;right:auto;
display:inline-flex;align-items:center;gap:6px;
max-width:calc(100% - 20px);
padding:4px 9px;
font-size:var(--fs-micro);font-weight:600;letter-spacing:.02em;
color:var(--text);background:rgba(10,8,14,.72);
border:1px solid var(--border-soft);border-radius:999px;
backdrop-filter:blur(10px);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.game-card-sync.pending::before{
content:"";flex:none;width:6px;height:6px;border-radius:50%;
background:var(--yellow);box-shadow:0 0 0 3px rgba(228,173,85,.18);
}
/* --- actions: on the art, on reach --------------------------------------- */
.game-card-actions{
position:absolute;left:0;right:0;bottom:0;
display:flex;gap:6px;padding:10px;
background:linear-gradient(transparent,rgba(8,6,12,.82) 42%);
opacity:0;transform:translateY(6px);
z-index:4;
transition:opacity .2s,transform .2s cubic-bezier(.2,.8,.2,1);
}
.game-card:hover .game-card-actions,
.game-card:focus-within .game-card-actions{opacity:1;transform:none}
.game-card-actions .btn{flex:1;min-height:32px;padding-inline:8px;font-size:var(--fs-micro)}
/* Pointer-less and reduced-motion contexts never get hover, so pin them open. */
@media (hover:none){
.game-card-actions{position:static;opacity:1;transform:none;background:0 0;padding:8px 0 0}
}
@media (prefers-reduced-motion:reduce){
.game-card-art,.game-card-actions{transition:none}
}
/* --- caption ------------------------------------------------------------- */
.game-card-body{
display:flex;flex-direction:column;gap:3px;
padding:10px 2px 0;background:0 0;border:0;
}
.game-card-heading strong{
display:block;font-size:var(--fs-xs);font-weight:600;line-height:1.3;color:var(--text);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.game-card-meta{
font-family:var(--mono);font-size:var(--fs-micro);color:var(--text-muted);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.game-card .game-card-status{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px}
/* The sync chip was a full-width purple banner in uppercase mono across the
poster. It is a state marker, not a headline. */
.game-card-sync,
.game-card-sync.pending{
color:var(--text);
background:rgba(10,8,14,.72);
border-color:var(--border-soft);
font:600 var(--fs-micro)/1.2 var(--ui);
letter-spacing:.01em;
text-transform:none;
right:auto;
}
/* A coverless poster carries the title as its artwork, so the caption below it
would only repeat itself. Show the caption title when real cover art exists. */
/* Two art treatments already print the title onto the poster: the generated
fallback, and the "case" used when only a square icon exists. In both the
caption below would just repeat it. */
.game-card:has(.game-card-cover-fallback.is-visible) .game-card-heading,
.game-card:has(.game-card-cover-caption) .game-card-heading{display:none}
.game-card:has(.game-card-cover-fallback.is-visible) .game-card-body,
.game-card:has(.game-card-cover-caption) .game-card-body{padding-top:8px}
/* ============================================================================
Title detail: hero header
The dialog opened on a fifteen-row key/value table with an empty banner bar.
The header now carries the artwork, the name and the identity chips.
========================================================================== */
#modal-header{border-bottom:0}
#modal-banner{
filter:blur(18px) saturate(1.15);
transform:scale(1.25);
opacity:.55;
}
/* No artwork: a ground derived from the title's own ID, same seed as the card. */
#modal-header::before{
content:"";position:absolute;inset:0;
background:
radial-gradient(circle at 78% 8%, hsl(var(--modal-hue,265) 72% 48% / .40), transparent 52%),
radial-gradient(circle at 12% 96%, hsl(calc(var(--modal-hue,265) + 46) 66% 42% / .30), transparent 55%),
linear-gradient(160deg,#181322,#0b0910);
}
#modal-banner.has-art{opacity:.78}
/* Scrim so the title always holds against whatever artwork loads. */
#modal-header::after{
content:"";position:absolute;inset:0;pointer-events:none;
background:linear-gradient(180deg,rgba(10,8,14,.30),rgba(18,14,23,.86) 72%,var(--surface));
}
#modal-header-content{position:relative;z-index:2}
#modal-icon{
width:84px!important;height:84px!important;
border-radius:var(--radius-sm)!important;
border:1px solid rgba(255,255,255,.14);
box-shadow:0 16px 34px -12px rgba(0,0,0,.9)!important;
}
#modal-header h2#modal-title{text-wrap:balance}
#modal-subtitle{
margin-top:6px;
font-family:var(--mono);
font-size:var(--fs-micro);
color:var(--text-dim);
letter-spacing:.04em;
}
#modal-close{
position:relative;z-index:3;
width:36px;height:36px;display:grid;place-items:center;
border-radius:50%;
background:rgba(10,8,14,.55);border:1px solid var(--border-soft);
color:var(--text);backdrop-filter:blur(10px);
}
#modal-close:hover{background:rgba(10,8,14,.8);border-color:var(--accent)}
/* Workspace links read as destinations, not as a second toolbar. */
.modal-workspace-links{margin-top:12px;gap:6px}
.modal-workspace-links button{
background:rgba(10,8,14,.5)!important;
border-color:var(--border-soft)!important;
backdrop-filter:blur(10px);
}
/* --- metric tiles --------------------------------------------------------- */
.modal-metrics{
display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));
gap:1px;background:var(--border-soft);
border:1px solid var(--border-soft);border-radius:var(--radius);
overflow:hidden;margin-bottom:18px;
}
.modal-metric{
display:flex;flex-direction:column;gap:5px;
padding:14px 16px;background:var(--surface2);
}
.modal-metric span{
font-family:var(--mono);font-size:var(--fs-micro);
letter-spacing:.11em;text-transform:uppercase;color:var(--text-muted);
}
.modal-metric strong{
font-family:var(--display);font-size:var(--fs-md);font-weight:600;
color:var(--text);font-variant-numeric:tabular-nums;line-height:1.15;
}
.modal-metric.is-ok strong{color:var(--green)}
.modal-metric.is-warn strong{color:var(--yellow)}
.modal-metric.is-idle strong{color:var(--text-muted)}
/* --- tab strip: fade the overflow instead of showing a raw scrollbar ------- */
.modal-tabs{
position:relative;
scrollbar-width:none;
-webkit-mask-image:linear-gradient(90deg,#000 0,#000 calc(100% - 44px),transparent);
mask-image:linear-gradient(90deg,#000 0,#000 calc(100% - 44px),transparent);
}
.modal-tabs::-webkit-scrollbar{height:0}
/* --- metadata table: let absent values recede ----------------------------- */
.detail-table th{
font-family:var(--ui);font-size:var(--fs-xs);font-weight:500;
letter-spacing:0;text-transform:none;color:var(--text-muted);
}
.detail-table td{font-size:var(--fs-xs)}
.detail-table td{color:var(--text)}
/* ============================================================================
Title detail: subsystem rail
Thirteen subsystems sat in a horizontal tab strip that overflowed and had to
be scrolled to be read. The same buttons become a vertical rail — every
destination scannable at once, no horizontal scroll, and room for the summary
to breathe. Structure is untouched: #modal already has exactly four children,
so this is a grid re-layout, not a rebuild.
Below 760px the original horizontal strip is kept; a rail costs too much width.
========================================================================== */
@media (min-width:760px){
#modal{
display:grid;
grid-template-columns:206px minmax(0,1fr);
grid-template-rows:auto minmax(0,1fr) auto;
}
#modal-header{grid-column:1/-1;grid-row:1}
#modal-body{grid-column:2;grid-row:2;min-height:0;overflow-y:auto}
#modal-footer{grid-column:1/-1;grid-row:3}
.modal-tabs{
grid-column:1;grid-row:2;
display:flex;flex-direction:column;align-items:stretch;
gap:2px;
min-height:0;
padding:14px 10px 18px;
overflow-x:hidden;overflow-y:auto;
border-bottom:0;
border-right:1px solid var(--border-soft);
background:rgba(10,8,14,.28);
/* cancel the horizontal overflow fade */
-webkit-mask-image:none;mask-image:none;
scrollbar-width:thin;
}
.modal-tabs::before{
content:"Subsystems";
padding:0 11px 9px;
font-family:var(--mono);font-size:var(--fs-micro);
letter-spacing:.14em;text-transform:uppercase;color:var(--text-muted);
}
.modal-tab{
flex:0 0 auto;
justify-content:flex-start;
min-height:34px;
margin-bottom:0;
padding:7px 11px;
text-align:left;
border-bottom:0;
border-left:2px solid transparent;
border-radius:var(--radius-sm);
font-size:var(--fs-xs);
}
.modal-tab:hover{background:rgba(255,255,255,.045)}
.modal-tab.active{
color:var(--text);
background:rgba(var(--accent-rgb),.16);
border-bottom-color:transparent;
border-left-color:var(--accent);
}
/* the count badge trails the label instead of crowding it */
.modal-tab .tab-badge{margin-left:auto}
}
/* ============================================================================
Utilities
index.html carried 275 inline style attributes. Inline styles beat every
stylesheet rule without !important, which is why the type scale could not
reach the settings labels no matter what the CSS said. These six recurring
declarations are now classes; the remaining ~220 are per-site layout and
dynamic values (progress widths) that need judgement, not a find-replace.
========================================================================== */
.u-flex-1{flex:1}
.u-push{margin-left:auto}
.u-row-8{align-items:center;gap:8px}
.u-fs-micro{font-size:var(--fs-micro)}
.u-field-md{min-width:150px}
.u-field-sm{width:140px}
/* <small> otherwise inherits the browser's 0.83em and compounds below the floor. */
small,.hint,.toggle-hint{font-size:var(--fs-xs)}
/* An unclassed <button> otherwise takes the UA default (13.33px) and sits off
the scale. Any class rule still wins, so styled buttons are unaffected. */
button{font-size:inherit}
/* Spacing utilities on the 4/8 scale the art direction specifies. These replace
56 ad-hoc inline margins that sat between steps (5, 6, 10, 14, 18px). */
.u-mt-4{margin-top:4px}
.u-mt-8{margin-top:8px}
.u-mt-12{margin-top:12px}
.u-mt-16{margin-top:16px}
.u-mb-4{margin-bottom:4px}
.u-mb-8{margin-bottom:8px}
.u-mb-12{margin-bottom:12px}
.u-mb-16{margin-bottom:16px}
+249
View File
@@ -0,0 +1,249 @@
#modal-overlay{position:fixed;inset:0;z-index:3000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.7)}
#modal-overlay[hidden]{display:none}
#modal{display:flex;flex-direction:column;overflow:hidden;background:var(--surface);border:1px solid var(--border)}
#modal-header{position:relative;overflow:hidden;border-bottom:1px solid var(--border)}
#modal-banner{position:absolute;inset:0;background-position:center;background-size:cover;filter:blur(4px);transform:scale(1.12);transition:background-image .25s}
#modal-header-content{position:relative;display:flex;align-items:flex-start;justify-content:space-between}
#modal-header h2{color:#fff;font-size:18px;text-shadow:0 1px 4px rgba(0,0,0,.9)}
.modal-workspace-links{display:flex;flex-wrap:wrap}
.modal-workspace-links button{color:rgba(255,255,255,.86);font:inherit;cursor:pointer}
.modal-workspace-links button:hover,.modal-workspace-links button:focus-visible{color:#fff;border-color:var(--accent);outline:2px solid rgba(var(--accent-rgb),.25);outline-offset:1px}
#modal-close:hover{color:var(--red)}
#modal-body{display:flex;flex:1;flex-direction:column;gap:20px;overflow-y:auto}
#modal-footer{display:flex;gap:10px;border-top:1px solid var(--border)}
.detail-table{width:100%;border-collapse:collapse}
.detail-table tr{border-bottom:1px solid #1a1a1a}
.detail-table th{width:35%;padding:7px 0;color:var(--text-dim);font-size:12px;font-weight:500;text-align:left}
.detail-table td{padding:7px 0;font-size:12px}
#modal-backup-section h4{margin-bottom:10px;color:var(--text-dim);font-size:12px;font-weight:500}
#modal-backup-list{display:flex;flex-direction:column;gap:6px;list-style:none}
#modal-backup-list li{display:flex;justify-content:space-between;padding:8px 12px;color:var(--text-dim);font-size:12px;background:var(--surface2);border-radius:8px}
.modal-tabs{display:flex;flex-shrink:0;border-bottom:1px solid var(--border)}
.modal-tab{display:flex;align-items:center;gap:6px;margin-bottom:-1px;color:var(--text-dim);background:none;border:0;border-bottom:2px solid transparent;cursor:pointer;transition:all .15s}
.modal-tab:hover{color:var(--text)}
.modal-tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab-badge{display:inline-block;min-width:18px;height:18px;padding:0 4px;color:var(--accent);font-size:11px;font-weight:700;line-height:18px;text-align:center;background:rgba(var(--accent-rgb),.2);border-radius:12px}
.tab-badge:empty{display:none}
.modal-panel{display:none;flex-direction:column;gap:14px}
.modal-panel.active{display:flex}
.prospero-placeholder{padding:40px;color:var(--text-dim);font-size:12px;font-style:italic;text-align:center}
.patch-list,.dlc-list{display:flex;flex-direction:column;gap:8px;list-style:none}
.patch-item{display:flex;flex-direction:column;gap:6px;padding:10px 14px;background:var(--surface2);border-radius:8px}
.patch-item-latest{border-left:3px solid var(--teal);border-top-left-radius:0;border-bottom-left-radius:0}
.patch-header{display:flex;flex-wrap:wrap;align-items:center;gap:10px}
.patch-latest{flex-shrink:0;padding:2px 7px;color:var(--bg);font-size:11px;font-weight:700;background:var(--teal);border-radius:8px}
.patch-ver{color:var(--accent);font-size:12px;font-weight:700}
.patch-meta{display:flex;flex-wrap:wrap;gap:14px;margin-left:auto;color:var(--text-dim);font-size:12px}
.patch-changelog{padding-top:4px;color:var(--text-dim);font-size:12px;line-height:1.5;white-space:pre-wrap;border-top:1px solid rgba(255,255,255,.06)}
.dlc-item{display:flex;align-items:center;gap:12px;padding:10px 14px;background:var(--surface2);border-radius:8px}
.dlc-icon{width:40px;height:40px;flex-shrink:0;object-fit:cover;background:var(--surface3);border-radius:8px}
.dlc-name{font-size:12px;font-weight:500}
.dlc-meta{color:var(--text-dim);font-size:12px}
.region-pills{display:flex;flex-wrap:wrap;gap:8px;padding:8px 0}
.region-pill{display:flex;align-items:center;gap:6px;padding:6px 14px;color:var(--text);font-size:12px;background:var(--surface2);border:1.5px solid var(--border);border-radius:16px;cursor:pointer;transition:all .15s}
.region-pill:hover{color:var(--accent);border-color:var(--accent)}
.region-pill.owned{color:var(--teal);border-color:var(--teal)}
.region-pill.owned::after{font-size:11px;content:' ✓'}
.game-description{margin-top:6px;padding:12px 14px;color:var(--text-dim);font-size:12px;line-height:1.6;background:var(--surface2);border-radius:8px}
.backpork-list{display:flex;flex-direction:column;gap:12px}
.backpork-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
.backpork-card-header{padding:14px 20px;display:flex;align-items:center;gap:16px}
.backpork-badge{padding:5px 14px;border-radius:16px;font-size:14px;font-weight:700;background:rgba(3,218,198,.15);color:var(--teal);border:1.5px solid var(--teal);flex-shrink:0;min-width:64px;text-align:center}
.backpork-info{flex:1;min-width:0}
.backpork-path{font-size:12px;color:var(--text-dim);margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.backpork-count{font-size:12px;color:var(--text-dim);margin-top:2px}
.backpork-count strong{color:var(--accent)}
.backpork-actions{display:flex;gap:8px;flex-shrink:0}
.backpork-games{border-top:1px solid var(--border);display:flex;flex-direction:column}
.backpork-game-row{padding:9px 20px 9px 28px;display:flex;align-items:center;gap:12px;border-bottom:1px solid rgba(255,255,255,.04)}
.backpork-game-row:last-child{border-bottom:none}
.backpork-game-icon{width:32px;height:32px;border-radius:8px;object-fit:cover;background:var(--surface2);flex-shrink:0}
.backpork-game-info{flex:1;min-width:0}
.backpork-game-title{font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.backpork-game-id{font-size:11px;color:var(--text-dim)}
.backpork-game-actions{display:flex;gap:6px;flex-shrink:0}
.backpork-porked-pill{font-size:11px;padding:2px 7px;border-radius:12px;background:rgba(3,218,198,.15);color:var(--teal);border:1px solid rgba(3,218,198,.35);white-space:nowrap}
/* ── Firmware label pills (in game rows) ─────────────────────────────────────── */
.fw-pill{display:inline-block;padding:1px 7px;border-radius:12px;font-size:11px;font-weight:700;background:rgba(3,218,198,.15);color:var(--teal);border:1px solid rgba(3,218,198,.35);margin-right:3px;white-space:nowrap}
.fw-pill.fw-pill-patched{background:rgba(132,204,22,.15);color:#84cc16;border-color:rgba(132,204,22,.35)}
.s-tag.s-patched{background:rgba(132,204,22,.13);color:#84cc16;border:1px solid rgba(132,204,22,.3)}
/* ── Manage panel ────────────────────────────────────────────────────────────── */
.manage-section{display:flex;flex-direction:column;gap:10px}
.manage-section-title{font-size:12px;color:var(--text-dim);font-weight:500;padding-bottom:8px;border-bottom:1px solid var(--border)}
#manage-backup-list{display:flex;flex-direction:column;gap:6px}
/* Backpork version entries in Manage tab */
#manage-backpork-list{display:flex;flex-direction:column;gap:6px}
.manage-backpork-entry{background:var(--surface2);border-radius:8px;padding:10px 14px;display:flex;align-items:center;gap:12px}
.manage-backpork-fw{padding:3px 10px;border-radius:16px;font-size:12px;font-weight:700;background:rgba(3,218,198,.15);color:var(--teal);border:1px solid rgba(3,218,198,.35);flex-shrink:0}
.manage-backpork-path{flex:1;font-size:11px;color:var(--text-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.manage-porked-status{font-size:12px;padding:7px 10px;border-radius:8px;background:rgba(3,218,198,.08);border:1px solid rgba(3,218,198,.25);color:var(--teal);display:flex;align-items:center;gap:8px}
.manage-porked-status .clear-pork{margin-left:auto;font-size:11px;cursor:pointer;color:var(--text-dim);background:none;border:none}
.manage-backup-item{background:var(--surface2);border-radius:8px;padding:10px 14px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;border:1.5px solid transparent;transition:border-color .15s}
.manage-item-selected{border-color:var(--teal)}
.manage-backup-info{display:flex;align-items:center;gap:10px;min-width:0;flex:1}
.manage-filename{font-size:12px;font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px}
.manage-size{font-size:12px;color:var(--text-dim);white-space:nowrap}
.manage-backup-actions{display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap}
#manage-upload-log{margin-top:6px;font-family:'Consolas',monospace;font-size:12px}
/* ── Hash verification ───────────────────────────────────────────────────────── */
#modal-hash-section{display:flex;flex-direction:column;gap:10px}
.hash-section-header{display:flex;align-items:center;gap:10px}
.hash-section-header h4{font-size:12px;color:var(--text-dim);font-weight:500}
#modal-hash-list{display:flex;flex-direction:column;gap:6px}
.hash-entry{background:var(--surface2);border-radius:8px;padding:8px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.hash-entry-info{display:flex;align-items:center;gap:10px;min-width:0}
.hash-entry-status{display:flex;align-items:center;gap:8px;flex-shrink:0}
.hash-filename{font-size:12px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px}
.hash-value{font-size:11px;color:var(--text-dim);font-family:'Consolas',monospace;white-space:nowrap}
.hash-badge{display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;white-space:nowrap}
.hash-none{background:rgba(158,158,158,.15);color:var(--text-dim)}
.hash-local{background:rgba(var(--accent-rgb),.2);color:var(--accent)}
.hash-community{background:rgba(3,218,198,.2);color:var(--teal);border:1px solid rgba(3,218,198,.35)}
.hash-mismatch{background:rgba(207,102,121,.18);color:var(--red);border:1px solid rgba(207,102,121,.35)}
/* Inline game-list hash status tags (s-tag variants) */
.s-tag.s-hash-fail{background:rgba(207,102,121,.18);color:var(--red);border-color:rgba(207,102,121,.35)}
.s-tag.s-hash-ok{background:rgba(3,218,198,.15);color:var(--teal);border-color:rgba(3,218,198,.3)}
.s-tag.s-hash-local{background:rgba(var(--accent-rgb),.15);color:var(--accent);border-color:rgba(var(--accent-rgb),.3)}
/* Backpork game-row hash pills */
.hash-pill-fail{background:rgba(207,102,121,.18);color:var(--red);border:1px solid rgba(207,102,121,.35);display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600}
.hash-pill-ok{background:rgba(3,218,198,.15);color:var(--teal);border:1px solid rgba(3,218,198,.3);display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600}
.hash-pill-local{background:rgba(var(--accent-rgb),.15);color:var(--accent);border:1px solid rgba(var(--accent-rgb),.3);display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600}
/* Hash Database tab card layout */
.hash-db-card{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:16px;margin-bottom:16px}
.hash-db-info-row{display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start;margin-bottom:14px}
.hash-db-info-col{display:flex;flex-direction:column;gap:4px}
.hash-db-label{font-size:11px;color:var(--text-dim);font-weight:600;text-transform:uppercase;letter-spacing:.04em}
.hash-db-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:10px}
/* Progress bar */
.hash-progress-bar{height:4px;background:var(--surface3);border-radius:8px;overflow:hidden}
.hash-progress-fill{height:100%;background:var(--teal);border-radius:8px;width:0%;transition:width .2s}
.hash-progress-label{font-size:11px;color:var(--text-dim);margin-top:5px}
/* Inline confirm for re-verify */
.hash-confirm{display:flex;align-items:center;gap:6px}
.hash-confirm-msg{font-size:12px;color:var(--yellow);white-space:nowrap}
/* ── Color picker ────────────────────────────────────────────────────────────── */
/* ── System View Hotkey editor ───────────────────────────────────────────── */
.hotkey-grid{display:grid;grid-template-columns:1fr auto;gap:8px 20px;align-items:center;max-width:360px}
.hotkey-label{font-size:12px;color:var(--text-dim)}
.hotkey-input{width:72px;text-align:center;font-family:'Consolas','SF Mono',monospace;font-size:12px;background:#1a1a1a;border:1px solid var(--border);border-radius:8px;color:var(--text);padding:5px 8px;cursor:pointer;transition:border-color .15s}
.hotkey-input:focus{outline:none;border-color:var(--accent);background:#111}
.hotkey-input.capturing{border-color:var(--yellow);color:var(--yellow)}
.color-picker-row{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.color-picker-wrap{display:flex;align-items:center;gap:10px}
.color-swatch{width:36px;height:36px;border-radius:8px;border:2px solid var(--border);cursor:pointer;flex-shrink:0;transition:border-color .15s;position:relative;overflow:hidden}
.color-swatch:hover{border-color:var(--accent)}
/* Hide the native input but keep it functional and clickable */
input[type="color"].color-picker-input{position:absolute;inset:0;opacity:0;width:100%;height:100%;cursor:pointer;border:none;padding:0}
.color-hex-label{font-family:'Consolas',monospace;font-size:12px;color:var(--text);background:var(--bg);border:1.5px solid var(--border);border-radius:8px;padding:6px 10px;width:96px;text-transform:uppercase;outline:none;transition:border-color .2s}
.color-hex-label:focus{border-color:var(--accent)}
/* ── Firmware filter row ─────────────────────────────────────────────────────── */
.firmware-filter-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.fw-filter-label{font-size:12px;color:var(--text-dim);white-space:nowrap}
.fwbtn{padding:4px 12px;border-radius:16px;border:1.5px solid rgba(3,218,198,.4);background:transparent;color:var(--teal);font-size:12px;cursor:pointer;transition:all .15s}
.fwbtn:hover{background:rgba(3,218,198,.1)}
.fwbtn.active{background:var(--teal);border-color:var(--teal);color:var(--bg);font-weight:700}
/* ── Confirm dialog ──────────────────────────────────────────────────────────── */
#confirm-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:9000}
#confirm-overlay[hidden]{display:none}
#confirm-dialog{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:28px 32px;max-width:400px;width:90%;box-shadow:0 8px 40px rgba(0,0,0,.6)}
#confirm-message{font-size:14px;line-height:1.6;color:var(--text);margin:0 0 20px;white-space:pre-wrap;word-break:break-word}
.confirm-buttons{display:flex;gap:10px;justify-content:flex-end}
/* ── Selective Clear dialog ─────────────────────────────────────────── */
#clear-select-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:9050}
#clear-select-overlay[hidden]{display:none}
#clear-select-dialog{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:28px 32px;max-width:420px;width:90%;box-shadow:0 8px 40px rgba(0,0,0,.6)}
.clear-opt{display:flex;flex-direction:row;align-items:center;gap:10px;cursor:pointer;padding:8px 12px;border-radius:8px;background:var(--surface-2);transition:background .15s}
.clear-opt:hover{background:var(--surface-3,rgba(255,255,255,.08))}
.clear-opt input[type="checkbox"]{width:16px;height:16px;accent-color:var(--red,#f44);cursor:pointer;flex-shrink:0}
.clear-opt-label{font-size:12px;font-weight:600;color:var(--text);min-width:80px}
.clear-opt-hint{font-size:12px;color:var(--text-dim)}
/* ── DB row selection toolbar ──────────────────────────────────────── */
.db-row-toolbar{display:flex;align-items:center;gap:10px;padding:8px 12px;margin-bottom:6px;background:var(--surface-2);border-radius:8px;border:1px solid var(--border)}
.db-row-toolbar[hidden]{display:none}
.db-row-toolbar-count{font-size:12px;color:var(--accent);font-weight:600;margin-right:auto}
.db-pagebar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 0 8px}
.db-pagebar > div{display:flex;gap:6px}
.db-row-cb{width:32px;text-align:center;padding:4px !important}
.db-row-cb input[type="checkbox"]{width:15px;height:15px;accent-color:var(--accent);cursor:pointer}
tr.db-row-selected{background:rgba(187,134,252,.1) !important}
tr.db-row-selected:hover{background:rgba(187,134,252,.18) !important}
/* ── Prompt (input) dialog ─────────────────────────────────────────── */
#prompt-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:9100}
#prompt-overlay[hidden]{display:none}
#prompt-dialog{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:28px 32px;max-width:400px;width:90%;box-shadow:0 8px 40px rgba(0,0,0,.6)}
#prompt-message{font-size:14px;line-height:1.6;color:var(--text);margin:0 0 12px}
#prompt-input{width:100%;box-sizing:border-box;margin-bottom:16px}
/* ── Admin-required warning modal ────────────────────────────────────── */
#admin-warn-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;z-index:9100}
#admin-warn-overlay[hidden]{display:none}
#admin-warn-dialog{background:var(--surface);border:1px solid var(--red,#c0392b);border-radius:var(--radius);padding:28px 32px;max-width:420px;width:92%;box-shadow:0 8px 40px rgba(0,0,0,.7);text-align:center}
.admin-warn-icon{font-size:36px;margin-bottom:10px}
.admin-warn-title{font-size:16px;font-weight:700;color:var(--red,#e05);margin:0 0 14px}
.admin-warn-body{font-size:12px;line-height:1.6;color:var(--text);margin:0 0 12px;text-align:left}
.admin-warn-hint{font-size:12px;line-height:1.6;color:var(--text-dim);background:rgba(255,255,255,.04);border:1px solid var(--border);border-radius:8px;padding:8px 12px;margin:0 0 16px;text-align:left}
#admin-warn-overlay[hidden]{display:none !important}
.conv-admin-warn{color:var(--red,#e05);border-color:var(--red,#e05) !important;background:rgba(192,57,43,.12) !important}
/* ── Manage Jailbreak ────────────────────────────────────────────────────────── */
.jb-card{display:flex;flex-direction:column;gap:16px}
.jb-badge{font-size:11px;font-weight:700;padding:2px 8px;border-radius:8px;letter-spacing:.5px;text-transform:uppercase}
.jb-badge-soon{background:rgba(255,255,255,.08);color:var(--text-dim)}
.jb-tool-body{display:flex;flex-direction:column;gap:12px}
.jb-section{display:flex;flex-direction:column;gap:10px;padding:14px;border-radius:8px;background:rgba(255,255,255,.03);border:1px solid var(--border)}
.jb-section-title{font-size:12px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.6px}
.jb-status-row{display:flex;align-items:center;gap:12px;font-size:12px}
.jb-label{color:var(--text-dim);min-width:160px;flex-shrink:0}
.jb-value{color:var(--text);font-weight:500}
.jb-actions{display:flex;gap:8px;flex-wrap:wrap}
.jb-list-placeholder{font-size:12px;font-style:italic;color:var(--text-dim);padding:12px;border-radius:8px;background:rgba(255,255,255,.02);text-align:center}
/* ── Auto-Backpork Generator panel ──────────────────────────────────────────── */
.bpgen-panel{border:1px solid var(--border);border-radius:var(--radius);background:var(--surface);overflow:hidden}
.bpgen-summary{padding:12px 18px;cursor:pointer;user-select:none;display:flex;align-items:center;gap:8px;list-style:none;border-radius:var(--radius)}
.bpgen-summary::-webkit-details-marker{display:none}
.bpgen-summary::before{content:'▶';font-size:11px;color:var(--text-dim);transition:transform .15s;flex-shrink:0}
details[open] .bpgen-summary::before{transform:rotate(90deg)}
.bpgen-summary-icon{font-size:16px}
.bpgen-body{padding:0 18px 16px;display:flex;flex-direction:column;gap:8px}
.bpgen-row{align-items:center;gap:10px}
.bpgen-label{font-size:12px;color:var(--text-dim);min-width:140px;flex-shrink:0}
.bpgen-status{flex:1}
.bpgen-status.status-ok{color:var(--teal)}
.bpgen-status.status-error{color:var(--danger,#e74c3c)}
.bpgen-status.status-warn{color:#f0a500}
.bpgen-game-picker{border:1px solid var(--border);border-radius:8px;margin:0 0 4px;overflow:hidden}
.bpgen-picker-header{background:var(--surface2);padding:6px 10px;display:flex;align-items:center;gap:8px}
.bpgen-picker-header .hint{flex:1}
.bpgen-game-list{max-height:220px;overflow-y:auto}
.bpgen-game-row{display:flex;align-items:center;gap:8px;padding:6px 10px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.04);font-size:12px}
.bpgen-game-row:last-child{border-bottom:none}
.bpgen-game-row:hover{background:rgba(255,255,255,.04)}
.bpgen-game-title{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bpgen-game-id{font-size:11px;color:var(--text-dim);flex-shrink:0}
.bpgen-loading{padding:10px;color:var(--text-dim);font-size:12px}
.bpgen-progress-bar-wrap{height:6px;background:var(--surface2);border-radius:8px;overflow:hidden}
.bpgen-progress-bar{height:100%;background:var(--teal);border-radius:8px;transition:width .3s ease}
.bpgen-log{font-family:monospace;font-size:11px;color:var(--text-dim);max-height:180px;overflow-y:auto;margin-top:6px;background:var(--surface2);border-radius:8px;padding:6px 8px}
.bpgen-log-row{line-height:1.5}
.bpgen-log-error{color:var(--danger,#e74c3c)}
/* ── Fakelib Manager sub-panel ───────────────────────────────────────────────── */
.bpgen-fl-panel{border:1px solid var(--border);border-radius:8px;background:var(--surface2);margin-top:2px;overflow:hidden}
.bpgen-fl-summary{padding:8px 12px;cursor:pointer;user-select:none;display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;list-style:none}
.bpgen-fl-summary::-webkit-details-marker{display:none}
.bpgen-fl-summary::before{content:'▶';font-size:11px;color:var(--text-dim);transition:transform .15s;flex-shrink:0}
details[open] .bpgen-fl-summary::before{transform:rotate(90deg)}
.bpgen-fl-body{padding:0 12px 12px;display:flex;flex-direction:column;gap:6px}
.bpgen-fl-badge{font-size:11px;font-weight:normal;padding:2px 7px;border-radius:12px;margin-left:auto}
.bpgen-fl-badge-ok{background:rgba(0,200,150,.15);color:var(--teal)}
.bpgen-fl-badge-warn{background:rgba(240,165,0,.15);color:#f0a500}
/* 10-pair grid */
.bpgen-fl-grid{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
.bpgen-fl-pair{display:flex;flex-direction:column;align-items:center;gap:2px;width:62px;padding:6px 4px;border-radius:8px;cursor:pointer;border:1px solid var(--border);background:var(--surface);font-size:11px;transition:border-color .15s,background .15s}
.bpgen-fl-pair:hover{border-color:var(--teal);background:rgba(0,200,150,.06)}
.bpgen-fl-pair-active{border-color:var(--teal) !important;background:rgba(0,200,150,.12) !important}
.bpgen-fl-pair-ok{border-color:var(--teal)}
.bpgen-fl-pair-missing{border-color:var(--border);opacity:.75}
.bpgen-fl-pair-num{font-size:12px;font-weight:700}
.bpgen-fl-pair-icon{font-size:14px}
.bpgen-fl-pair-ok .bpgen-fl-pair-icon{color:var(--teal)}
.bpgen-fl-pair-missing .bpgen-fl-pair-icon{color:var(--text-dim)}
.bpgen-fl-pair-label{font-size:11px;color:var(--text-dim)}
+14
View File
@@ -0,0 +1,14 @@
.etahen-link-card { margin-bottom: 14px; border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
.etahen-link-kicker { display: block; margin-bottom: 4px; color: var(--accent); font-size:11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.etahen-link-health { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.etahen-link-health-item { min-width: 0; padding: 12px; border: 1px solid var(--border); border-radius:12px; background: color-mix(in srgb, var(--card) 88%, transparent); }
.etahen-link-health-item span, .etahen-link-health-item strong { display: block; overflow-wrap: anywhere; }
.etahen-link-health-item span { color: var(--text-dim); font-size:11px; }
.etahen-link-health-item strong { margin-top: 4px; }
.etahen-link-health-item.state-online strong { color: var(--green); }
.etahen-link-health-item.state-offline strong { color: var(--red); }
.etahen-link-alert { margin-top: 10px; padding: 10px 12px; border-radius:8px; background: color-mix(in srgb, var(--accent) 10%, transparent); }
.etahen-link-alert.is-critical { border: 2px solid var(--red); background: color-mix(in srgb, var(--red) 14%, transparent); font-weight: 700; }
#etahen-link-root:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius:12px; }
@media (max-width: 700px) { .etahen-link-health { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { .etahen-link-card *, .etahen-link-card *::before, .etahen-link-card *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } }
+576
View File
@@ -0,0 +1,576 @@
.payload-manager-connect-row{display:flex;align-items:center;gap:14px;margin:12px 0;flex-wrap:wrap}
.payload-manager-connect-row label{display:inline-flex;align-items:center;gap:8px;color:var(--text-dim);font-size:12px}
.payload-manager-connect-row input{width:88px}
.payload-manager-frame{width:100%;min-height:680px;border:1px solid var(--border);border-radius:12px;background:#080a0f}
.payload-workspace-nav{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:12px}
.payload-workspace-nav button{display:grid;grid-template-columns:26px minmax(0,1fr);gap:1px 8px;align-items:center;min-width:0;padding:10px;color:var(--text);text-align:left;border:1px solid var(--border);border-radius:12px;background:var(--surface);cursor:pointer}.payload-workspace-nav button:hover,.payload-workspace-nav button:focus-visible{border-color:color-mix(in srgb,var(--accent) 60%,var(--border));background:color-mix(in srgb,var(--accent) 7%,var(--surface))}
.payload-workspace-nav button>span{grid-row:1/3;display:grid;width:26px;height:26px;place-items:center;color:var(--accent);font-weight:700;border-radius:50%;background:color-mix(in srgb,var(--accent) 12%,transparent)}
.payload-workspace-nav strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}
.payload-workspace-nav small{overflow:hidden;color:var(--text-dim);font-size:11px;text-overflow:ellipsis;white-space:nowrap}
.jb-card{scroll-margin-top:108px;transition:border-color .25s ease,box-shadow .25s ease}
.payload-workspace-highlight{border-color:var(--accent)!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 12%,transparent)}
@media(max-width:900px){.payload-workspace-nav{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media(max-width:520px){.payload-workspace-nav{grid-template-columns:1fr}
}
/* ── PSNotify log ─────────────────────────────────────────────────────────── */
.psn-history{display:flex;flex-direction:column;gap:4px}
.psn-entry{display:flex;align-items:baseline;gap:10px;padding:6px 10px;border-radius:8px;font-size:12px;background:rgba(255,255,255,.03)}
.psn-entry.psn-ok{border-left:2px solid var(--teal)}
.psn-entry.psn-fail{border-left:2px solid var(--red)}
.psn-entry-ts{color:var(--text-dim);white-space:nowrap;flex-shrink:0;font-family:monospace}
.psn-entry-msg{font-weight:600}
.psn-entry-sub{color:var(--text-dim)}
.psnotify-page-subtitle{flex:1 1 320px;margin-left:14px}
#psn-page-readiness{margin-left:auto}
.psnotify-grid{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(280px,.75fr);gap:12px;align-items:start}
.psnotify-side{display:grid;gap:12px}
.psnotify-templates{display:flex;gap:6px;margin:12px 0;flex-wrap:wrap}
.psnotify-compose-card label{position:relative}
.psnotify-counter{position:absolute;right:9px;bottom:8px;color:var(--text-dim);font-size:11px;pointer-events:none}
.psnotify-compose-card input{padding-right:54px}
.psnotify-preview{display:flex;flex-direction:column;gap:3px;margin-top:12px;padding:12px 14px;border:1px solid color-mix(in srgb,var(--accent) 30%,var(--border));border-radius:12px;background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 10%,var(--surface2)),var(--surface2))}
.psnotify-preview>span{color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
.psnotify-preview>strong{font-size:14px}
.psnotify-preview>small{color:var(--text-dim)}
.psnotify-lab.collapsed>:not(.card-header){display:none !important}
.psnotify-lab .card-header{margin-bottom:0}
.psnotify-lab:not(.collapsed) .card-header{margin-bottom:12px}
.psnotify-history-card{margin-top:12px}
.psnotify-lab{margin-top:12px}
@media (max-width:880px){.psnotify-grid{grid-template-columns:1fr}
.psnotify-page-subtitle{order:3;flex-basis:100%;margin:4px 0 0}
}
.jb-placeholder-notice{font-size:12px;color:var(--text-dim);padding:8px 12px;border-radius:8px;background:rgba(255,255,255,.03);border:1px dashed var(--border);text-align:center}
/* ── Transfer Manager ────────────────────────────────────────────────────────── */
/* Nav badge — shows active+queued count beside the Transfers nav item */
.nav-badge{margin-left:auto;background:var(--teal);color:#000;border-radius:12px;font-size:11px;font-weight:700;padding:1px 6px;line-height:1.6}
/* Stats bar */
.transfer-stats-bar{display:flex;align-items:center;gap:24px;flex-wrap:wrap;padding:10px 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius)}
.tstat-group{display:flex;align-items:center;gap:8px;font-size:12px}
.tstat-label{color:var(--text-dim)}
.tstat-value{font-weight:700;color:var(--teal);min-width:24px}
.tstat-concurrent{margin-left:auto}
.concurrent-slider{-webkit-appearance:none;height:4px;width:90px;border-radius:8px;background:var(--surface3);accent-color:var(--teal);cursor:pointer}
.concurrent-slider::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--teal);cursor:pointer}
/* Transfer list */
#transfers-list{display:flex;flex-direction:column;gap:8px}
/* Transfer item card */
.transfer-item{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px;display:flex;flex-direction:column;gap:8px;transition:border-color .15s}
.transfer-item-active{border-color:var(--teal)}
.transfer-item-error{border-color:var(--red)}
.transfer-item-done{opacity:.5}
.transfer-item-cancelled{opacity:.4}
/* Header row */
.transfer-item-header{display:flex;align-items:center;gap:10px;min-width:0}
.transfer-label{flex:1;font-weight:600;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.transfer-type-badge{flex-shrink:0;font-size:11px;font-weight:700;padding:2px 7px;border-radius:8px;letter-spacing:.06em;text-transform:uppercase;background:rgba(3,218,198,.15);color:var(--teal)}
.transfer-type-badge.type-download{background:rgba(var(--accent-rgb),.15);color:var(--accent)}
.transfer-type-badge.type-pork{background:rgba(240,192,64,.15);color:var(--yellow)}
.transfer-status-text{flex-shrink:0;font-size:12px;color:var(--text-dim)}
/* Progress bar */
.transfer-progress-wrap{display:flex;align-items:center;gap:8px;margin-bottom:4px}
.transfer-progress-bar{flex:1;height:4px;background:var(--surface3);border-radius:8px;overflow:hidden}
.transfer-progress-fill{height:100%;background:var(--teal);transition:width .3s linear}
.transfer-item-error .transfer-progress-fill{background:var(--red)}
.transfer-pct-label{flex-shrink:0;min-width:36px;text-align:right;font-size:11px;font-weight:600;color:var(--teal);font-variant-numeric:tabular-nums}
/* Meta row */
.transfer-meta{display:flex;align-items:center;gap:16px;flex-wrap:wrap;font-size:12px;color:var(--text-dim)}
.transfer-speed{color:var(--teal);font-weight:600;flex-shrink:0}
.transfer-file{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:monospace;font-size:11px}
.transfer-sizes{flex-shrink:0}
.transfer-error{color:var(--red);font-size:12px}
/* Cancel / action button inside card */
.btn-cancel-transfer{background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:16px;line-height:1;padding:0 4px;transition:color .15s;flex-shrink:0}
.btn-cancel-transfer:hover{color:var(--red)}
/* ── Payload Manager ──────────────────────────────────────────────────────── */
.catalog-health-strip,.payload-catalog-stats{display:grid;grid-template-columns:repeat(4,minmax(130px,1fr));gap:8px;margin:0 0 14px}
.catalog-health-strip > div,.payload-catalog-stats > div{display:flex;flex-direction:column;gap:2px;padding:10px 12px;background:var(--surface);border:1px solid var(--border);border-radius:8px}
.catalog-health-strip strong,.payload-catalog-stats strong{color:var(--teal);font-size:17px;line-height:1.15}
.catalog-health-strip span,.payload-catalog-stats span{color:var(--text-dim);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
.payload-catalog-stats{grid-template-columns:repeat(5,minmax(90px,1fr));margin-top:12px}
.payload-catalog-toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
.payload-catalog-toolbar .search-input{min-width:220px;flex:1}
.payload-catalog-toolbar select{min-width:155px}
.payload-source-meta{display:flex;align-items:center;gap:6px;margin-left:auto}
.payload-trust-chip{font-size:11px;padding:2px 6px;border-radius:8px;text-transform:uppercase;letter-spacing:.05em}
.payload-trust-official{color:var(--teal);background:rgba(3,218,198,.12)}
.payload-trust-community{color:var(--yellow);background:rgba(240,192,64,.13)}
.payload-trust-custom{color:var(--text-dim);background:rgba(255,255,255,.06)}
.payload-source-description{margin:0 0 8px;font-size:11px;color:var(--text-dim)}
.payload-asset-meta{color:var(--text-dim);font-size:11px;white-space:nowrap}
.service-matrix-grid{display:grid;grid-template-columns:repeat(3,minmax(190px,1fr));gap:8px;margin-top:12px}
.service-matrix-item{padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface2)}
.service-matrix-head{display:flex;gap:8px;align-items:center;margin-bottom:5px}
.service-matrix-dot{width:8px;height:8px;border-radius:50%;background:var(--red);box-shadow:0 0 8px rgba(255,80,80,.35)}
.service-matrix-item.online .service-matrix-dot{background:var(--teal);box-shadow:0 0 8px rgba(3,218,198,.5)}
.service-matrix-name{font-weight:700;font-size:12px}
.service-matrix-port{margin-left:auto;color:var(--text-dim);font:11px monospace}
.service-matrix-item p{margin:0;color:var(--text-dim);font-size:11px;line-height:1.4}
@media (max-width:850px){.catalog-health-strip{grid-template-columns:repeat(2,minmax(0,1fr))}
.payload-catalog-stats{grid-template-columns:repeat(2,minmax(0,1fr))}
.service-matrix-grid{grid-template-columns:1fr}
}
/* Settings:source list rows */
.payload-settings-source-row{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--border);font-size:12px}
.payload-settings-source-row:last-child{border-bottom:none}
.payload-settings-source-name{font-weight:600;min-width:100px;flex-shrink:0}
.payload-settings-source-url{flex:1;color:var(--text-dim);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Jailbreak:update checker source block */
.payload-source-check-list{margin-top:8px}
.payload-check-source{padding:12px 0;border-bottom:1px solid var(--border)}
.payload-check-source:last-child{border-bottom:none}
.payload-check-source-header{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.payload-check-source-name{font-weight:700;font-size:12px}
.payload-tag-chip{font-size:11px;padding:2px 7px;border-radius:8px;background:rgba(3,218,198,.12);color:var(--teal);font-weight:600}
/* Asset table inside update checker */
.payload-asset-table{width:100%;border-collapse:collapse;font-size:12px}
.payload-asset-table td{padding:5px 8px;border-bottom:1px solid rgba(255,255,255,.04)}
.payload-asset-table tr:last-child td{border-bottom:none}
.payload-asset-name{font-family:monospace}
.payload-hash-snippet{font-family:monospace;font-size:11px;color:var(--text-dim)}
/* Extra badge variants */
.badge-yellow{background:rgba(240,192,64,.2);color:var(--yellow)}
.badge-dim{background:rgba(255,255,255,.06);color:var(--text-dim)}
/* Local payload list */
.payload-local-list,.payload-ps5-list{display:flex;flex-direction:column;gap:4px;margin-top:6px}
.payload-local-row,.payload-ps5-row{display:flex;align-items:center;gap:10px;padding:8px 10px;background:var(--surface2);border-radius:8px;font-size:12px}
.payload-row-name{flex:1;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.payload-row-size{color:var(--text-dim);min-width:56px;text-align:right;flex-shrink:0}
.payload-row-actions{display:flex;gap:6px;flex-shrink:0}
/* Local payload list — card style (reuses al-file-card items inside a bordered scroll box) */
.payload-card-list{display:flex;flex-direction:column;background:var(--bg);border:1px solid var(--border);border-radius:8px;max-height:300px;overflow-y:auto;margin-top:6px}
.payload-card-list:empty{display:none}
/* Extra elements inside payload al-file-cards */
.payload-card-size{flex-shrink:0;font-size:11px;color:var(--text-dim);min-width:52px;text-align:right}
.payload-card-actions{display:flex;gap:5px;flex-shrink:0;margin-left:4px}
/* The push/delete buttons are very compact inside the card */
.payload-local-card .btn,.payload-sender-card .btn{font-size:11px;padding:3px 8px}
/* PS5 Payload Sender bar — port input + status */
.payload-sender-bar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:6px}
.payload-sender-port-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-dim);white-space:nowrap}
.payload-sender-port-input{width:72px;padding:4px 8px;border-radius:8px;border:1px solid var(--border);background:var(--surface2);color:var(--text);font-size:12px;text-align:center}
.payload-sender-status{flex:1;font-size:12px;transition:color .2s}
/* Save-to-local button on autoloader file cards */
.btn-al-save-local{background:none;border:1px solid var(--border);border-radius:8px;color:var(--text-dim);cursor:pointer;font-size:12px;line-height:1;padding:2px 5px;flex-shrink:0;transition:background .12s,color .12s,border-color .12s}
.btn-al-save-local:hover{background:var(--surface3);color:var(--teal);border-color:var(--teal)}
/* ── System View ──────────────────────────────────────────────────────────── */
.sv-bar-label{font-size:12px;color:var(--text-dim);white-space:nowrap;flex-shrink:0}
.sv-device-bar{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;margin-bottom:16px}
.sv-viewer-wrap{position:relative;flex:1;min-height:300px;background:#000;border-radius:var(--radius) var(--radius) 0 0;overflow:hidden;display:flex;align-items:center;justify-content:center}
#page-system-view.active{display:flex;flex-direction:column;gap:0}
#page-system-view .page-header{margin-bottom:12px}
/* ── Integrated service pages ───────────────────────────────────────────────── */
#content.embed-page-active{overflow:auto}
#page-savemgr.active{display:flex;min-height:min(720px,calc(100dvh - 205px));flex-direction:column;gap:0;overflow:hidden;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg)}
body[data-chimera-route="protection/save-browser"] #page-savemgr .embed-page-title{display:none}
.embed-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;flex-shrink:0;padding:8px 16px;border-bottom:1px solid var(--border);background:var(--surface)}
.embed-controls{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.embed-page-title{font-size:14px;font-weight:600;color:var(--text);flex-shrink:0;margin-right:4px}
.embed-empty-message{color:var(--text-dim);font-size:12px;text-align:center}
/* Install accordion — sits between toolbar and iframe,collapsed by default */
.install-accordion{flex-shrink:0;border-bottom:1px solid var(--border);background:var(--surface)}
.install-accordion > summary{list-style:none;display:flex;align-items:center;gap:6px;padding:6px 16px;cursor:pointer;font-size:12px;color:var(--text-dim);user-select:none}
.install-accordion > summary::-webkit-details-marker{display:none}
.install-accordion > summary::before{content:'▶';font-size:11px;transition:transform .15s}
.install-accordion[open] > summary::before{transform:rotate(90deg)}
.install-accordion > summary:hover{color:var(--text)}
.install-accordion-body{padding:12px 16px 14px;display:flex;flex-direction:column;gap:8px}
.install-accordion-body p{font-size:12px;color:var(--text-dim);margin:0;line-height:1.5}
#sv-video{width:100%;height:100%;max-height:70vh;object-fit:contain;display:block}
/* In-app fullscreen — CSS-based,works in Electron's frameless window */
.sv-viewer-wrap--fullscreen{position:fixed !important;inset:0 !important;z-index:9000 !important;border-radius:0 !important;min-height:0 !important}
.sv-viewer-wrap--fullscreen #sv-video{max-height:100vh !important}
.sv-viewer-wrap--fullscreen .sv-overlay-controls{opacity:1 !important;pointer-events:auto !important}
/* Overlay controls — bottom-right,fade in on hover */
.sv-overlay-controls{position:absolute;bottom:12px;right:12px;display:flex;gap:8px;opacity:0;transition:opacity .2s;pointer-events:none}
.sv-viewer-wrap:hover .sv-overlay-controls{opacity:1;pointer-events:auto}
.sv-overlay-btn{background:rgba(18,18,18,.85);border:1px solid #555;border-radius:8px;padding:6px 10px;font-size:16px;cursor:pointer;color:var(--text);transition:background .15s}
.sv-overlay-btn:hover{background:rgba(50,50,50,.95)}
/* No-signal placeholder */
.sv-no-signal{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;color:var(--text-dim);font-size:12px;pointer-events:none}
.sv-no-signal.hidden{display:none}
/* ── System View — Recording indicator ─────────────────────────────────────── */
.sv-rec-status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:#ff5252;font-weight:500;flex-shrink:0}
.sv-rec-dot{width:8px;height:8px;border-radius:50%;background:#ff5252;animation:sv-pulse 1s ease-in-out infinite;flex-shrink:0}
@keyframes sv-pulse{0%,100%{opacity:1}
50%{opacity:0.3}
}
/* ── System View — toolbar separator ───────────────────────────────────────── */
.sv-tool-sep{width:1px;height:18px;background:var(--border);flex-shrink:0;margin:0 4px}
/* ── System View — video recording buttons ──────────────────────────────────── */
.btn-sv-vid-idle{border-color:#ff5252;color:#ff5252}
.btn-sv-vid-idle:hover:not(:disabled){background:rgba(255,82,82,.12)}
.btn-sv-vid-pause-btn{border-color:#ffb300;color:#ffb300}
.btn-sv-vid-pause-btn:hover:not(:disabled){background:rgba(255,179,0,.12)}
.btn-sv-vid-stop-btn{border-color:#4caf50;color:#4caf50}
.btn-sv-vid-stop-btn:hover:not(:disabled){background:rgba(76,175,80,.12)}
/* ── System View — video rec indicator ──────────────────────────────────────── */
.sv-vid-rec-status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:#ff5252;font-weight:500;flex-shrink:0}
.sv-vid-rec-status .sv-rec-dot{background:#ff5252}
.sv-vid-rec-status.paused{color:#ffb300}
.sv-vid-rec-status.paused .sv-rec-dot{background:#ffb300;animation-play-state:paused}
.sv-vid-rec-size{font-size:11px;opacity:.75}
/* ── System View — status bar ───────────────────────────────────────────────── */
.sv-status-bar{display:flex;align-items:center;gap:8px;padding:4px 10px;background:var(--surface);border:1px solid var(--border);border-top:none;border-radius:0 0 var(--radius) var(--radius);font-size:11px;color:var(--text-dim);flex-shrink:0}
.sv-status-dot{width:7px;height:7px;border-radius:50%;background:#444;flex-shrink:0}
.sv-status-dot.live{background:#4caf50;animation:sv-pulse 1.5s ease-in-out infinite}
.sv-status-dot.saving{background:#ffb300;animation:sv-pulse .6s ease-in-out infinite}
.sv-status-codec{margin-left:auto;font-family:monospace;font-size:11px;opacity:.55}
/* ── System View — right-click context menu ─────────────────────────────────── */
.sv-ctx-menu{position:fixed;z-index:9000;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 4px 24px rgba(0,0,0,.7);padding:3px 0;min-width:150px}
.sv-ctx-menu[hidden]{display:none}
.sv-ctx-item{display:flex;align-items:center;gap:8px;width:100%;padding:7px 13px;font-size:12px;color:var(--text);background:none;border:none;cursor:pointer;text-align:left;white-space:nowrap}
.sv-ctx-item:hover:not(:disabled){background:rgba(187,134,252,.12);color:var(--accent)}
.sv-ctx-item:disabled{opacity:.35;cursor:default}
.sv-ctx-sep{height:1px;background:var(--border);margin:3px 0}
/* ── Destination picker ─────────────────────────────────────────────────────── */
#dest-picker-overlay{position:fixed;inset:0;background:rgba(0,0,0,.72);display:flex;align-items:center;justify-content:center;z-index:8500}
#dest-picker-overlay[hidden]{display:none}
#dest-picker{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);width:440px;max-width:92vw;box-shadow:0 8px 40px rgba(0,0,0,.7);display:flex;flex-direction:column}
.dest-picker-title{padding:18px 18px 10px;font-size:14px;font-weight:600;color:var(--text);border-bottom:1px solid var(--border)}
.dest-picker-hint{padding:10px 18px 12px;font-size:12px;color:var(--text-dim);line-height:1.5}
.dest-picker-option{display:flex;flex-direction:column;gap:3px;text-align:left;padding:12px 18px;background:none;border:none;border-top:1px solid var(--border);cursor:pointer;transition:background .15s;width:100%}
.dest-picker-option:hover{background:rgba(var(--accent-rgb),.1)}
.dest-opt-label{font-size:12px;font-weight:600;color:var(--text)}
.dest-opt-path{font-size:11px;font-family:monospace;color:var(--text-dim)}
.dest-picker-footer{padding:12px 18px;display:flex;justify-content:flex-end;border-top:1px solid var(--border)}
/* ── Manage:install location display ───────────────────────────────────────── */
.manage-install-loc{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;margin-bottom:8px}
.manage-loc-label{font-size:11px;color:var(--text-dim);flex-shrink:0}
.manage-loc-path-wrap{display:flex;align-items:center;gap:6px;flex:1;min-width:0}
.manage-loc-path{font-size:11px;font-family:monospace;color:var(--teal);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* ── Remote Game Locations (Settings) ──────────────────────────────────────── */
.remote-path-row{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--border);font-size:12px}
.remote-path-row:last-child{border-bottom:none}
.remote-path-label{font-weight:600;min-width:120px;flex-shrink:0;color:var(--text)}
.remote-path-value{flex:1;color:var(--text-dim);font-size:12px;font-family:monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* ── FTP Browser modal ──────────────────────────────────────────────────────── */
#ftp-browser-overlay{position:fixed;inset:0;background:rgba(0,0,0,.72);display:flex;align-items:center;justify-content:center;z-index:8000}
#ftp-browser-overlay[hidden]{display:none}
#ftp-browser{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);width:520px;max-width:92vw;display:flex;flex-direction:column;max-height:70vh;box-shadow:0 8px 40px rgba(0,0,0,.7)}
.ftp-browser-titlebar{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 12px;border-bottom:1px solid var(--border);font-size:12px;font-weight:600;color:var(--text)}
.btn-icon-sm{background:none;border:none;color:var(--text-dim);font-size:14px;cursor:pointer;padding:2px 6px;border-radius:8px;transition:background .15s,color .15s}
.btn-icon-sm:hover{background:rgba(255,255,255,.1);color:var(--text)}
.ftp-browser-pathbar{display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid var(--border);background:var(--surface2)}
.ftp-browser-cwd{flex:1;font-family:monospace;font-size:12px;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ftp-browser-body{flex:1;overflow-y:auto;padding:4px 0;min-height:200px}
.ftp-browser-loading{padding:24px 16px;text-align:center;color:var(--text-dim);font-size:12px}
.ftp-browser-entry{display:flex;align-items:center;gap:10px;padding:7px 14px;font-size:12px;cursor:default;transition:background .1s}
.ftp-entry-dir{cursor:pointer}
.ftp-entry-dir:hover{background:rgba(255,255,255,.06)}
.ftp-entry-file{color:var(--text-dim)}
.ftp-entry-icon{font-size:16px;flex-shrink:0}
.ftp-entry-name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ftp-browser-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 14px;border-top:1px solid var(--border)}
/* ── Y2JB / Payload Manager settings modals ─────────────────────────────────── */
.jb-settings-overlay{position:fixed;inset:0;background:rgba(0,0,0,.72);display:flex;align-items:center;justify-content:center;z-index:6000;padding:16px;backdrop-filter:blur(8px);overscroll-behavior:contain}
.jb-settings-modal{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);width:480px;max-width:100%;max-height:calc(100dvh - 32px);overflow:hidden;display:flex;flex-direction:column;box-shadow:0 8px 40px rgba(0,0,0,.7)}
.jb-settings-modal.install-dialog{width:560px}
.jb-settings-header{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--border)}
.jb-settings-header h3{font-size:14px;font-weight:600}
.jb-settings-body{padding:16px 18px;display:flex;flex-direction:column;gap:14px;overflow-y:auto;overscroll-behavior:contain}
.jb-settings-footer{display:flex;gap:10px;padding:12px 18px;border-top:1px solid var(--border);justify-content:flex-end}
.jb-settings-overlay .btn-icon-sm:focus-visible,.jb-settings-overlay .btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
@media (max-width:620px){.jb-settings-overlay{align-items:flex-end;padding:8px 8px 0}
.jb-settings-modal{width:100%;max-height:calc(100dvh - 8px);border-radius:18px 18px 0 0}
.jb-settings-header{position:sticky;top:0;z-index:2;background:var(--surface)}
.jb-settings-footer{padding-bottom:calc(12px + env(safe-area-inset-bottom));flex-wrap:wrap}
.jb-settings-footer .btn{flex:1 1 9rem}
.install-mode-tabs{overflow-x:auto;padding-bottom:4px;scrollbar-width:thin}
.install-mode-tab{flex:0 0 auto}
}
/* ── System View mini-card (collapsible,embedded in Transfers / Jailbreak) ─── */
.sv-mini-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-top:16px;overflow:hidden}
.sv-mini-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;cursor:pointer;user-select:none;transition:background .15s}
.sv-mini-header:hover{background:rgba(255,255,255,.04)}
.sv-mini-title{font-size:12px;font-weight:600;color:var(--text-dim);display:flex;align-items:center;gap:8px}
.sv-mini-chevron{font-size:11px;color:var(--text-dim);transition:transform .2s}
.sv-mini-card.open .sv-mini-chevron{transform:rotate(180deg)}
.sv-mini-body{display:none;padding:12px 16px 14px;border-top:1px solid var(--border);flex-direction:column;gap:10px}
.sv-mini-card.open .sv-mini-body{display:flex}
/* ── Media page ──────────────────────────────────────────────────────────── */
#page-media.active{display:flex;flex-direction:column;gap:0}
.media-save-bar{display:flex;align-items:center;gap:10px;padding:6px 0 14px}
.media-save-label{font-size:12px;color:var(--text-dim);white-space:nowrap}
.media-scan-status{font-size:12px;color:var(--text-dim);padding:4px 0 10px;display:flex;align-items:center;gap:8px}
.media-scan-status::before{content:'';display:inline-block;width:8px;height:8px;border-radius:50%;border:2px solid var(--accent);border-top-color:transparent;animation:spin .7s linear infinite;flex-shrink:0}
/* Thumbnail batch progress bar */
.media-thumb-status{font-size:11px;color:var(--text-dim);padding:2px 0 8px;display:flex;align-items:center;gap:8px}
.media-thumb-status::before{content:'';display:inline-block;width:7px;height:7px;border-radius:50%;border:2px solid var(--accent);border-top-color:transparent;animation:spin .7s linear infinite;flex-shrink:0}
/* Per-card label shown during download/decode */
.thumb-label{position:absolute;bottom:6px;left:0;right:0;text-align:center;font-size:11px;color:rgba(255,255,255,.55);pointer-events:none;z-index:2;letter-spacing:.3px}
/* Retry button on failed cards */
.btn-thumb-retry{font-size:11px;padding:2px 8px;border-radius:12px;background:rgba(207,102,121,.18);border:1px solid var(--red);color:var(--red);cursor:pointer;margin-bottom:4px;width:100%}
@keyframes spin{to{transform:rotate(360deg)}
}
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;overflow-y:auto}
.media-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column;transition:border-color .15s;cursor:pointer}
.media-card:hover{border-color:var(--accent)}
.media-card[data-type="clip"]{cursor:default}
.media-card[data-type="clip"]:hover{border-color:var(--border)}
/* ── Thumbnail area ── */
.media-card-thumb{height:120px;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center}
.media-card-thumb.type-screenshot{background:#0a0a0a}
.media-card-thumb.type-clip{background:rgba(187,134,252,.06)}
.media-thumb-wrap{padding:0}
/* Actual screenshot image */
.media-thumb-img{width:100%;height:100%;object-fit:cover;position:absolute;inset:0;opacity:0;transition:opacity .25s}
.media-thumb-img.thumb-loaded{opacity:1}
/* Spinner shown while thumb is loading */
.thumb-spin{width:20px;height:20px;border-radius:50%;border:2px solid var(--accent);border-top-color:transparent;animation:spin .7s linear infinite;flex-shrink:0;z-index:1}
/* Error state */
.media-card-thumb.thumb-error::after{content:'!';font-size:22px;color:var(--red);opacity:.6}
/* Video clip — play icon overlay on top of the thumbnail */
.clip-play-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;/* let card click-through */
opacity:0;transition:opacity .15s}
.media-card:hover .clip-play-overlay{opacity:1}
/* Always visible on clip cards (static WEBM badge,no thumbnail) */
.type-clip .clip-play-overlay{opacity:1}
/* WEBM type badge in top-right corner of clip cards */
.clip-type-badge{position:absolute;top:6px;right:6px;font-size:11px;font-weight:700;letter-spacing:.8px;text-transform:uppercase;background:rgba(0,0,0,.5);color:rgba(255,255,255,.7);padding:2px 6px;border-radius:8px;pointer-events:none;z-index:3}
/* ── Card body ── */
.media-card-body{padding:9px 11px;display:flex;flex-direction:column;gap:5px;flex:1}
.media-card-game{display:flex;align-items:center;gap:6px}
.media-card-game-icon{width:22px;height:22px;border-radius:8px;object-fit:cover;flex-shrink:0}
.media-card-game-icon-empty{width:22px;height:22px;border-radius:8px;flex-shrink:0;background:var(--surface2);border:1px solid var(--border)}
.media-card-game-id{font-size:11px;font-weight:700;color:var(--accent)}
.media-card-filename{font-size:11px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.media-card-meta{display:flex;align-items:center;justify-content:space-between;gap:8px}
.media-cache-panel{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0 0 12px;padding:12px 14px;background:rgba(34,199,232,.055);border:1px solid rgba(34,199,232,.2);border-radius:12px}
.media-cache-panel > div:first-child{display:grid;gap:3px}
.media-cache-panel span,.media-card-provenance{color:var(--text-dim);font:11px/1.4 var(--mono)}
.media-cache-actions,.media-card-provenance{display:flex;align-items:center;gap:8px}
.media-card-provenance{justify-content:space-between;min-width:0}
.media-card-provenance span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.btn-cache-pin{flex:none;padding:2px 6px;color:var(--teal);background:transparent;border:1px solid rgba(34,199,232,.25);border-radius:8px;font:700 11px/1.4 var(--mono);cursor:pointer}
@media (max-width:680px){.media-cache-panel{align-items:stretch;flex-direction:column}
.media-cache-actions{display:grid;grid-template-columns:1fr 1fr}
}
.media-import-panel{display:grid;grid-template-columns:minmax(180px,1fr) repeat(3,minmax(120px,auto)) auto;gap:9px;align-items:center;margin-bottom:12px;padding:14px;background:var(--surface2);border:1px solid var(--border-soft);border-radius:12px}
.media-import-panel > div{display:grid;gap:3px}
.media-import-panel > div span,#media-import-status{color:var(--text-dim);font-size:11px}
.media-import-panel input[type=file]{grid-column:1/4}
.media-imported-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:10px;margin-bottom:14px}
.media-imported-card{display:grid;grid-template-columns:84px minmax(0,1fr);gap:10px;min-height:84px;padding:9px;background:var(--surface2);border:1px solid var(--border-soft);border-radius:12px}
.media-imported-card img,.media-imported-card video{width:84px;height:84px;object-fit:cover;background:#08070a;border-radius:8px}
.media-imported-card > div{display:flex;min-width:0;flex-direction:column;gap:6px;align-items:flex-start}
.media-imported-card span{max-width:100%;overflow:hidden;color:var(--text-dim);font-size:11px;text-overflow:ellipsis;white-space:nowrap}
@media (max-width:900px){.media-import-panel{grid-template-columns:1fr 1fr}
.media-import-panel > div,.media-import-panel input[type=file]{grid-column:1/-1}
}
.settings-identity-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:1px;margin:0;overflow:hidden;background:var(--border-soft);border:1px solid var(--border-soft);border-radius:12px}
.settings-identity-grid > div{min-width:0;padding:12px;background:var(--surface2)}
.settings-identity-grid dt{color:var(--text-muted);font:700 11px/1.3 var(--mono);letter-spacing:.06em;text-transform:uppercase}
.settings-identity-grid dd{margin:5px 0 0;overflow:hidden;color:var(--text);text-overflow:ellipsis;white-space:nowrap}
.settings-identity-guidance{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-top:12px;padding:12px 14px;background:rgba(34,199,232,.055);border:1px solid rgba(34,199,232,.18);border-radius:12px}
.settings-identity-guidance p{margin:3px 0 0;color:var(--text-dim);font-size:11px}
@media (max-width:900px){.settings-identity-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media (max-width:560px){.settings-identity-grid{grid-template-columns:1fr}
.settings-identity-guidance{align-items:stretch;flex-direction:column}
}
.media-card-size{font-size:11px;color:var(--text-dim)}
.media-card-date{font-size:11px;color:var(--text-dim);white-space:nowrap;font-variant-numeric:tabular-nums}
.media-card-actions{padding-top:6px;display:flex;gap:6px;flex-wrap:wrap}
/* ── Media Lightbox ── */
.media-lightbox{position:fixed;inset:0;z-index:9000;display:flex;align-items:center;justify-content:center}
.media-lightbox[hidden]{display:none}
.media-lightbox-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.88);cursor:pointer}
.media-lightbox-frame{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:10px;max-width:calc(100vw - 60px);max-height:calc(100vh - 60px);padding:16px}
.media-lightbox-close{position:absolute;top:-8px;right:-8px;width:28px;height:28px;border-radius:50%;background:var(--surface2);border:1px solid var(--border);color:var(--text);cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;transition:background .15s}
.media-lightbox-close:hover{background:var(--red);color:#fff}
.media-lightbox-meta{font-size:12px;color:var(--text-dim);text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:600px}
.media-lightbox-img{max-width:100%;max-height:calc(100vh - 140px);border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,.6);object-fit:contain}
.media-lightbox-spinner{display:flex;flex-direction:column;align-items:center;gap:12px;color:var(--text-dim);font-size:12px;padding:40px}
.lightbox-spin-ring{width:36px;height:36px;border-radius:50%;border:3px solid var(--accent);border-top-color:transparent;animation:spin .7s linear infinite}
.media-lightbox-footer{display:flex;gap:8px}
body.lightbox-open{overflow:hidden}
/* Discord share button on media cards */
.btn-media-share{background:#4f56c7;border:none;color:#fff;font-size:11px;padding:4px 9px;border-radius:var(--radius);cursor:pointer;white-space:nowrap;transition:background .15s,opacity .15s}
.btn-media-share:hover{background:#5865F2}
.btn-media-share:disabled{opacity:.55;cursor:default}
/* Catbox upload progress fill — slides a translucent bar across the button */
.btn-media-share.uploading{position:relative;overflow:hidden;transition:none}
.btn-media-share.uploading::before{content:'';position:absolute;left:0;top:0;bottom:0;width:var(--upload-pct,0%);background:rgba(255,255,255,.22);transition:width .35s linear;pointer-events:none;border-radius:inherit}
/* Integrations card sections */
.integration-section{padding:4px 0}
.integration-header{display:flex;align-items:center;gap:8px;margin-bottom:6px}
/* ── Cheats page ─────────────────────────────────────────────────────────── */
#page-cheats.active{display:flex;flex-direction:column;gap:0}
.cheats-stats-bar{display:flex;align-items:center;gap:16px;padding:8px 2px;font-size:12px;color:var(--text-dim);margin-bottom:10px}
/* Download progress panel */
.cheats-progress-bar-wrap{height:6px;background:#1a1a1a;border-radius:8px;overflow:hidden;margin:10px 0 8px}
.cheats-progress-bar{height:100%;background:var(--accent);border-radius:8px;transition:width .3s ease}
.cheats-dl-stats{display:flex;gap:18px;font-size:12px;color:var(--text-dim);flex-wrap:wrap}
.cheats-dl-stats span{white-space:nowrap}
#cheats-dl-pct{color:var(--accent);font-weight:600}
#cheats-dl-saved{color:var(--green)}
#cheats-dl-failed{color:var(--red)}
#cheats-dl-eta{color:var(--text)}
.cheats-dl-current{margin-top:6px;font-size:11px;color:#555;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* Cheat cards grid */
.cheats-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px}
.cheat-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px;cursor:pointer;transition:border-color .15s}
.cheat-card:hover{border-color:var(--accent)}
.cheat-card-header{display:flex;align-items:center;gap:10px}
.cheat-card-icon{width:40px;height:40px;border-radius:8px;object-fit:cover;flex-shrink:0}
.cheat-card-icon-empty{width:40px;height:40px;border-radius:8px;flex-shrink:0;background:var(--surface2);border:1px solid var(--border)}
.cheat-card-info{display:flex;flex-direction:column;gap:3px;min-width:0}
.cheat-card-title-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.cheat-card-id{font-size:11px;font-weight:700;color:var(--accent);background:rgba(var(--accent-rgb),.1);border-radius:8px;padding:2px 6px;white-space:nowrap;flex-shrink:0;margin-top:2px}
.cheat-card-title{font-size:12px;font-weight:600;color:var(--text);line-height:1.3}
.cheat-card-meta{font-size:11px;color:var(--text-dim);display:flex;gap:10px}
.cheat-card-files{color:var(--text-dim)}
.cheat-card-body{display:none;margin-top:10px;border-top:1px solid var(--border);padding-top:10px}
.cheat-card.open .cheat-card-body{display:block}
/* Individual cheat file block inside a card */
.cheat-file-block{margin-bottom:10px}
.cheat-file-block:last-child{margin-bottom:0}
.cheat-file-ver{font-size:11px;font-weight:600;color:var(--text-dim);margin-bottom:6px;display:flex;align-items:center;gap:6px}
.cheat-file-ver code{font-size:11px;background:#1a1a1a;border-radius:8px;padding:1px 5px;color:var(--text)}
.cheat-mod-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text);padding:3px 0;border-bottom:1px solid var(--border)}
.cheat-mod-row:last-child{border-bottom:none}
.cheat-mod-name{flex:1}
.cheat-mod-type{font-size:11px;color:var(--text-dim);background:var(--surface2);border-radius:8px;padding:1px 5px}
/* Modal cheats panel */
#mpanel-cheats{padding:4px 0}
.modal-cheat-fetch{display:flex;align-items:center;gap:12px;padding:16px;color:var(--text-dim);font-size:12px}
.cheat-mod-list{list-style:none;padding:0;margin:0}
.cheat-mod-list li{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:12px;color:var(--text)}
.cheat-mod-list li:last-child{border-bottom:none}
/* Cheat count badge in games table */
.cheat-count-badge{display:inline-block;background:rgba(var(--accent-rgb),.15);color:var(--accent);border-radius:8px;font-size:11px;font-weight:600;padding:1px 7px}
/* Unmatched cheats section */
#cheats-unmatched-section{border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)}
.cheats-unmatched-summary{padding:10px 14px;cursor:pointer;font-size:12px;font-weight:600;color:var(--text);list-style:none;display:flex;align-items:center;gap:6px;user-select:none}
.cheats-unmatched-summary::-webkit-details-marker{display:none}
.cheats-unmatched-summary::before{content:'▶';font-size:11px;color:var(--text-dim);transition:transform .15s;display:inline-block}
#cheats-unmatched-section[open] .cheats-unmatched-summary::before{transform:rotate(90deg)}
.cheats-unmatched-list{padding:4px 10px 10px;display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--border)}
.cheat-unmatched-row{display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;padding:8px 10px;background:var(--bg);border-radius:8px;border:1px solid var(--border)}
.cheat-unmatched-info{display:flex;flex-direction:column;gap:2px;min-width:0}
.cheat-unmatched-title{font-size:12px;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cheat-unmatched-file{font-size:11px;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cheat-unmatched-assign{display:flex;gap:6px;align-items:center;flex-shrink:0}
.cheat-unmatched-assign input{width:110px;padding:4px 8px;font-size:12px;font-family:var(--mono);background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--text);text-transform:uppercase}
.cheat-unmatched-assign input:focus{outline:none;border-color:var(--accent)}
.cheat-unmatched-assign input.input-error{border-color:var(--red);animation:shake .2s ease}
@keyframes shake{0%,100%{transform:translateX(0)}
25%{transform:translateX(-4px)}
75%{transform:translateX(4px)}
}
/* FTP install panel */
.cheats-ftp-instructions{padding:4px 0 6px}
.cheats-ftp-instructions ol{padding-left:20px;display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--text-dim);line-height:1.5}
.cheats-ftp-instructions code{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1px 5px;font-size:12px;color:var(--accent)}
/* ── Dashboard pinned cards ────────────────────────────────────────────────── */
/* ── Operations Center ─────────────────────────────────────────────────────── */
.nav-section-label{padding:13px 16px 5px;color:var(--text-dim);font-size:11px;font-weight:800;letter-spacing:.13em;text-transform:uppercase;opacity:.72;user-select:none}
.operations-header,.operations-header-actions,.operations-card-header,.operations-inline-actions,.operations-task-main,.operations-task,.operations-run,.operations-service,.operations-check,.operations-recovery-item,.operations-audit-row{display:flex;align-items:center}
.operations-header{justify-content:space-between;gap:20px}
.operations-header-actions,.operations-inline-actions{gap:8px;flex-wrap:wrap}
.operations-health{border:1px solid var(--border);border-radius:999px;padding:6px 10px;color:var(--text-dim);font-size:11px;font-weight:700;background:var(--surface)}
.operations-health.healthy{color:#8ee9b4;border-color:rgba(78,207,132,.35);background:rgba(78,207,132,.08)}
.operations-health.warning{color:#ffd17a;border-color:rgba(255,182,72,.38);background:rgba(255,182,72,.08)}
.operations-tabs{display:flex;gap:4px;overflow-x:auto;margin:0 0 16px;padding:4px;border:1px solid var(--border);border-radius:12px;background:rgba(0,0,0,.15)}
.operations-tab{flex:0 0 auto;border:0;border-radius:8px;padding:9px 13px;color:var(--text-dim);background:transparent;font:inherit;font-size:12px;font-weight:700;cursor:pointer}
.operations-tab:hover{color:var(--text);background:rgba(255,255,255,.04)}
.operations-tab.active{color:#fff;background:rgba(var(--accent-rgb),.26);box-shadow:inset 0 0 0 1px rgba(var(--accent-rgb),.38)}
.operations-panel{animation:operations-in .16s ease}
@keyframes operations-in{from{opacity:0;transform:translateY(3px)}
to{opacity:1;transform:none}
}
.operations-card-header{justify-content:space-between;gap:14px}
.operations-card-header h3{margin-bottom:3px}
.operations-task-list,.operations-run-list,.operations-recovery-list{display:flex;flex-direction:column;gap:8px}
.operations-task{display:grid;grid-template-columns:minmax(240px,1fr) auto auto auto auto;gap:10px;padding:10px;border:1px solid var(--border);border-radius:8px;background:rgba(0,0,0,.13)}
.operations-task-main{gap:10px;min-width:0}
.operations-task-main > div,.operations-service > div,.operations-check > div,.operations-recovery-info{display:flex;flex-direction:column;gap:3px;min-width:0}
.operations-task-main span,.operations-service span,.operations-check span,.operations-recovery-info span{color:var(--text-dim);font-size:11px}
.operations-task-indicator,.operations-service-dot{width:9px;height:9px;flex:0 0 auto;border-radius:50%;background:#68636e;box-shadow:0 0 0 3px rgba(104,99,110,.13)}
.operations-task-indicator.enabled,.operations-service-dot.online{background:#4ecf84;box-shadow:0 0 0 3px rgba(78,207,132,.14)}
.operations-task-indicator.running{background:#d6a7ff;box-shadow:0 0 0 3px rgba(var(--accent-rgb),.2);animation:operations-pulse 1s infinite alternate}
@keyframes operations-pulse{to{opacity:.45}
}
.operations-switch,.operations-interval{display:flex;align-items:center;gap:5px;color:var(--text-dim);font-size:11px}
.operations-switch{flex-direction:row}
.operations-interval input{width:78px}
.operations-run{display:grid;grid-template-columns:minmax(190px,1fr) 92px 70px minmax(120px,1.2fr);gap:10px;padding:8px 10px;border-bottom:1px solid var(--border);font-size:11px;color:var(--text-dim)}
.operations-run > div{display:flex;flex-direction:column;gap:2px}
.operations-run-message{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.operations-status{display:inline-flex;width:max-content;align-items:center;border-radius:999px;padding:3px 8px;font-size:11px;font-weight:800}
.operations-status-completed,.operations-status-compatible{background:rgba(78,207,132,.12);color:#8ee9b4}
.operations-status-failed,.operations-status-warning{background:rgba(255,116,116,.12);color:#ffadad}
.operations-status-running{background:rgba(var(--accent-rgb),.18);color:#d6a7ff}
.operations-status-unknown{background:rgba(255,255,255,.06);color:var(--text-dim)}
.operations-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:9px;margin:12px 0}
.operations-metric{padding:12px;border:1px solid var(--border);border-radius:8px;background:rgba(0,0,0,.14)}
.operations-metric strong{display:block;color:var(--text);font-size:18px;overflow-wrap:anywhere}
.operations-metric span{display:block;margin-top:3px;color:var(--text-dim);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
.operations-audit-section{margin-top:10px;padding:10px;border:1px solid var(--border);border-radius:8px}
.operations-audit-section h4{margin:0 0 8px}
.operations-audit-section summary{cursor:pointer;font-weight:700}
.operations-audit-section summary span{float:right;color:var(--text-dim)}
.operations-audit-row{justify-content:space-between;align-items:flex-start;gap:12px;padding:7px 0;border-bottom:1px solid var(--border);font-size:11px}
.operations-audit-row:last-child{border-bottom:0}
.operations-audit-row code{max-width:70%;overflow-wrap:anywhere;color:var(--text-dim)}
.operations-empty-ok,.operations-empty{padding:18px;border:1px dashed rgba(78,207,132,.35);border-radius:8px;color:#8ee9b4;text-align:center}
.operations-empty{display:flex;min-height:96px;flex-direction:column;align-items:center;justify-content:center;gap:5px;color:var(--text);border-color:var(--border)}
.operations-empty span{color:var(--text-dim);font-size:11px}
.operations-recovery-item{gap:12px;padding:11px;border:1px solid var(--border);border-radius:8px;background:rgba(0,0,0,.12)}
.operations-recovery-icon{width:34px;height:34px;display:grid;place-items:center;border-radius:12px;color:#d6a7ff;background:rgba(var(--accent-rgb),.13);font-size:20px}
.operations-recovery-info{flex:1}
.operations-recovery-info code{color:var(--text-dim);font-size:11px}
.operations-two-column{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
body[data-chimera-route="protection/replication"] #operations-replication-card{grid-column:1/-1}
.operations-profile-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.operations-field{display:flex;flex-direction:column;gap:5px;margin:10px 0;color:var(--text-dim);font-size:11px}
.operations-field textarea,.operations-code-editor{resize:vertical}
.operations-check{justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)}
.operations-source-note{margin-top:12px}
.operations-warning-card{border-color:rgba(255,182,72,.35);background:rgba(255,182,72,.07);color:#ffd17a;line-height:1.5}
.operations-code-editor,.operations-log{width:100%;font-family:var(--mono);font-size:11px;line-height:1.55}
.operations-log{box-sizing:border-box;max-height:430px;overflow:auto;margin-top:12px;padding:12px;border:1px solid var(--border);border-radius:8px;background:#111015;color:#d9d5df;white-space:pre-wrap;overflow-wrap:anywhere}
.operations-service{gap:10px;padding:10px 0;border-bottom:1px solid var(--border)}
.operations-service > div{flex:1}
.operations-service b{color:var(--text-dim);font-size:11px}
@media (max-width:1080px){.operations-task{grid-template-columns:minmax(220px,1fr) auto auto}
.operations-task-save,.operations-task-run{grid-row:2}
.operations-two-column{grid-template-columns:1fr}
}
@media (max-width:720px){.nav-section-label{padding-left:13px}
.operations-header{align-items:flex-start;flex-direction:column}
.operations-task{display:flex;align-items:stretch;flex-direction:column}
.operations-run{grid-template-columns:1fr auto}
.operations-run-message{grid-column:1 / -1}
.operations-recovery-item{align-items:flex-start;flex-wrap:wrap}
.operations-recovery-info{flex-basis:calc(100% - 50px)}
.operations-recovery-item .operations-inline-actions{width:100%}
.operations-profile-grid{grid-template-columns:1fr}
}
.payload-observability{margin:14px 0;display:grid;gap:12px}
.payload-observability-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
.payload-observability-columns{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px}
.payload-observability-panel{min-width:0;padding:12px;border:1px solid var(--border);border-radius:12px;background:color-mix(in srgb,var(--card) 88%,transparent)}
.card-header.compact{margin-bottom:8px}
.card-header.compact h4{margin:0}
.payload-process-list{display:grid;gap:5px;max-height:250px;margin-top:10px;overflow:auto}
.payload-process-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:7px 9px;border:1px solid var(--border);border-radius:8px}
.payload-process-row div{min-width:0}
.payload-process-row strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.payload-process-row span{color:var(--text-dim);font-size:11px}
.payload-process-row b{white-space:nowrap;font-size:12px}
.payload-native-launch-heading{margin:14px 0 0}
.payload-log-output{box-sizing:border-box;width:100%;min-height:160px;max-height:280px;margin:0;padding:10px;overflow:auto;border:1px solid var(--border);border-radius:8px;background:#080b10;color:#c9d5e6;font:11px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}
@media (max-width:760px){.payload-observability-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}
.payload-observability-columns{grid-template-columns:1fr}
}
.shadowmount-entry code{display:block;margin-top:5px;overflow-wrap:anywhere;white-space:pre-wrap}
.shadowmount-entry small,.operations-detail-row.has-warning small{display:block;margin-top:5px;color:var(--warning,#f4bf5f)}
.transfer-policy-card{display:flex;flex-direction:row;align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:12px}
.transfer-policy-card>div{flex:1 1 320px}
.transfer-policy-card>div strong,.transfer-policy-card>div span{display:block}
.transfer-policy-card label{display:inline-flex;align-items:center;gap:5px}
.transfer-policy-card input{width:110px}
+1
View File
@@ -0,0 +1 @@
.game-card-cover-case{position:absolute;inset:9px;z-index:2;display:flex;flex-direction:column;overflow:hidden;background:linear-gradient(145deg,rgba(26,20,35,.98),rgba(11,9,15,.98));border:1px solid rgba(255,255,255,.17);border-radius:8px;box-shadow:0 15px 36px rgba(0,0,0,.48)}.game-card-cover-case.has-native-cover{inset:0;border:0;border-radius:inherit;background:#0e0b12;box-shadow:none}.game-card-cover-case.has-native-cover .game-card-cover{position:absolute;inset:0;display:block;width:100%;height:100%;flex:1 1 auto;margin:0;padding:0;object-fit:cover;object-position:center;border:0;border-radius:0;box-shadow:none;filter:none}.game-card:has(.game-card-cover-case.has-native-cover) .game-card-art{aspect-ratio:var(--game-cover-ratio,2 / 3)}.game-card-cover-platform{flex:0 0 auto;padding:6px 10px 5px;color:#fff;background:linear-gradient(90deg,#006fcd,#2b8fe5);font:700 11px/1 Space Grotesk,Inter,sans-serif;letter-spacing:.13em}.game-card-cover-case > .game-card-cover:not(.game-card-cover-empty){position:relative;width:calc(100% - 16px);height:auto;aspect-ratio:1;margin:8px;object-fit:contain;border-radius:8px;box-shadow:0 7px 18px rgba(0,0,0,.34)}.game-card-cover-case.has-native-cover > .game-card-cover:not(.game-card-cover-empty){position:absolute;inset:0;width:100%;height:100%;aspect-ratio:auto;margin:0;padding:0;object-fit:cover;object-position:center;border:0;border-radius:0;box-shadow:none;filter:none}.game-card-cover-caption{display:flex;flex:1;min-height:0;flex-direction:column;justify-content:center;gap:4px;padding:7px 9px 9px;background:linear-gradient(180deg,rgba(16,12,21,.55),rgba(16,12,21,.98))}.game-card-cover-caption strong{display:-webkit-box;overflow:hidden;color:#fff;font:700 12px/1.16 Space Grotesk,Inter,sans-serif;-webkit-box-orient:vertical;-webkit-line-clamp:2}.game-card-cover-caption small{color:rgba(255,255,255,.58);font:600 11px/1.1 ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.06em}.game-card-cover-backdrop{position:absolute;inset:-12px;z-index:0;width:calc(100% + 24px);height:calc(100% + 24px);object-fit:cover;filter:blur(12px) saturate(.9) brightness(.43);transform:scale(1.08)}.game-card-art:has(.game-card-cover-backdrop)::after{position:absolute;inset:0;z-index:1;content:"";background:linear-gradient(180deg,rgba(12,9,16,.08),rgba(12,9,16,.42))}.game-card-art .game-card-sync{z-index:4}.game-row-icon.is-portrait{width:32px;height:44px;border-radius:8px;object-fit:cover;box-shadow:0 5px 12px rgba(0,0,0,.32)}@media (prefers-reduced-motion:no-preference){.game-card:hover .game-card-cover-case.has-native-cover .game-card-cover{transform:scale(1.025)}.game-card-cover-case.has-native-cover .game-card-cover{transition:transform 240ms cubic-bezier(.2,.8,.2,1)}}
+12
View File
@@ -0,0 +1,12 @@
.chimera-import-launcher{position:fixed;right:22px;bottom:22px;z-index:1150;display:flex;align-items:center;gap:9px;padding:11px 17px;color:#0b1110;font:700 12px/1.2 "Space Grotesk",sans-serif;background:linear-gradient(135deg,var(--teal),#8df3d8);border:0;border-radius:999px;box-shadow:0 12px 32px rgba(3,218,198,.22);cursor:pointer}
.chimera-import-launcher:focus-visible,.import-wizard button:focus-visible,.import-wizard input:focus-visible,.import-wizard select:focus-visible,.import-wizard textarea:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
.import-wizard-overlay{position:fixed;inset:0;z-index:4200;display:grid;place-items:center;padding:24px;background:rgba(5,7,9,.82);backdrop-filter:blur(8px)}
.import-wizard-overlay[hidden]{display:none}.import-wizard{width:min(980px,100%);max-height:min(820px,94vh);display:grid;grid-template-rows:auto auto minmax(0,1fr) auto;overflow:hidden;color:var(--text);background:linear-gradient(155deg,#181d20,#0e1114 62%);border:1px solid rgba(141,243,216,.22);border-radius:16px;box-shadow:0 32px 90px rgba(0,0,0,.62)}
.import-wizard-head{display:flex;align-items:flex-start;justify-content:space-between;padding:22px 24px 17px;border-bottom:1px solid var(--border)}.import-wizard-head h2{margin:3px 0 4px;font:700 24px/1.1 "Space Grotesk",sans-serif}.import-wizard-kicker{color:var(--teal);font:700 11px/1.2 monospace;letter-spacing:.16em;text-transform:uppercase}.import-wizard-head p{margin:0;color:var(--text-dim);font-size:12px}.import-wizard-close{width:36px;height:36px;color:var(--text);font-size:20px;background:var(--surface2);border:1px solid var(--border);border-radius:12px;cursor:pointer}
.import-wizard-steps{display:grid;grid-template-columns:repeat(5,1fr);padding:0 20px;border-bottom:1px solid var(--border)}.import-wizard-step{position:relative;padding:14px 7px;color:var(--text-dim);font-size:11px;text-align:center;border:0;border-bottom:2px solid transparent;background:none}.import-wizard-step::before{display:block;width:23px;height:23px;margin:0 auto 5px;line-height:23px;content:attr(data-number);background:var(--surface3);border-radius:50%}.import-wizard-step.active{color:var(--teal);border-color:var(--teal)}.import-wizard-step.complete::before{color:#07100e;background:var(--teal)}
.import-wizard-body{min-height:360px;padding:24px;overflow:auto}.import-wizard-drop{display:grid;grid-template-columns:1fr 1fr;gap:14px;padding:22px;background:radial-gradient(circle at 20% 10%,rgba(3,218,198,.12),transparent 52%),var(--surface2);border:1px dashed rgba(141,243,216,.35);border-radius:16px}.import-wizard-drop p{grid-column:1/-1;margin:0;color:var(--text-dim);line-height:1.55}.import-wizard-choice{padding:18px;color:var(--text);font-weight:700;background:#171f21;border:1px solid var(--border);border-radius:12px;cursor:pointer}.import-wizard-choice:hover{border-color:var(--teal)}
.import-wizard-resumes,.import-wizard-components{display:grid;gap:9px;margin-top:18px}.import-wizard-resume,.import-wizard-component{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center;padding:11px 13px;background:var(--surface2);border:1px solid var(--border);border-radius:12px}.import-wizard-resume small,.import-wizard-component small{display:block;color:var(--text-dim)}.import-wizard-component-controls{display:flex;gap:5px}.import-wizard-component-controls button{min-width:32px}.import-wizard-progress{height:9px;margin:16px 0 7px;overflow:hidden;background:#090b0d;border-radius:99px}.import-wizard-progress span{display:block;height:100%;background:linear-gradient(90deg,var(--teal),var(--accent));transition:width .18s}.import-wizard-status{min-height:20px;margin-top:12px;color:var(--text-dim);font-size:12px}.import-wizard-status.error{color:var(--red)}
.import-wizard-issues{display:grid;gap:8px;padding:0;list-style:none}.import-wizard-issues li{padding:10px 12px;color:#ffd9c9;background:rgba(255,92,57,.1);border-left:3px solid #ff713f}.import-wizard-field{display:grid;gap:7px;margin-top:16px;color:var(--text-dim);font-size:12px}.import-wizard-field textarea,.import-wizard-field select{width:100%;padding:11px;color:var(--text);background:var(--surface2);border:1px solid var(--border);border-radius:12px}.import-wizard-field textarea{min-height:95px;resize:vertical}.import-wizard-attach{display:flex;align-items:center;gap:9px}.import-wizard-preview{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:18px}.import-wizard-preview div{padding:13px;background:var(--surface2);border-radius:12px}.import-wizard-preview span,.import-wizard-preview strong{display:block}.import-wizard-preview span{margin-bottom:5px;color:var(--text-dim);font-size:11px}.import-wizard-preview strong{overflow-wrap:anywhere}
.import-wizard-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 24px;border-top:1px solid var(--border)}.import-wizard-footer-actions{display:flex;gap:8px}.import-wizard-footer button{padding:9px 14px}.import-wizard-confirm{display:flex;align-items:flex-start;gap:9px;max-width:500px;color:var(--text-dim);font-size:12px}.import-wizard-empty{padding:26px;color:var(--text-dim);text-align:center;border:1px dashed var(--border);border-radius:12px}
@media(max-width:640px){.chimera-import-launcher{right:14px;bottom:76px}.import-wizard-overlay{padding:0}.import-wizard{width:100%;height:100%;max-height:none;border-radius:0}.import-wizard-head{padding:17px}.import-wizard-head h2{font-size:20px}.import-wizard-steps{padding:0 5px}.import-wizard-step{font-size:0}.import-wizard-step::before{font-size:11px}.import-wizard-body{padding:16px}.import-wizard-drop,.import-wizard-preview{grid-template-columns:1fr}.import-wizard-drop p{grid-column:auto}.import-wizard-footer{align-items:stretch;flex-direction:column;padding:12px 16px}.import-wizard-footer-actions{justify-content:flex-end}.import-wizard-confirm{max-width:none}}
@media(prefers-reduced-motion:reduce){.import-wizard-progress span{transition:none}}
+1
View File
@@ -0,0 +1 @@
.install-destination-picker{display:grid;gap:9px;margin:12px 0;}.install-destination-picker select{width:100%;}.install-destination-status{border:1px solid var(--border);border-radius:12px;display:grid;gap:3px;padding:10px 12px;}.install-destination-status strong{font-size:12px;}.install-destination-status span{color:var(--text-dim);font-size:11px;}.install-destination-status.is-eligible{background:rgba(36,137,88,.11);border-color:rgba(114,214,162,.35);}.install-destination-status.is-ineligible{background:rgba(169,108,21,.11);border-color:rgba(240,190,99,.35);}.install-expert-destination{border-top:1px solid var(--border);margin-top:12px;padding-top:9px;}.install-expert-destination summary{color:var(--text-dim);cursor:pointer;font-size:11px;}.install-expert-destination[open] summary{color:#f0be63;margin-bottom:8px;}
+1
View File
@@ -0,0 +1 @@
.chimera-companion-dock{display:none}@media(max-width:700px){:root{--companion-dock-h:68px;--companion-gutter:14px}body,html{overscroll-behavior:none;background:radial-gradient(circle at 82% -5%,rgba(118,81,230,.22),transparent 24rem),#0d0a11}body.web-mode{--titlebar-h:0px}body.web-mode #titlebar{display:none}#statusbar{display:none}#app{bottom:calc(var(--companion-dock-h) + env(safe-area-inset-bottom))}#content{gap:14px;padding:0 var(--companion-gutter) 24px;scroll-padding-top:72px;overscroll-behavior-y:contain}#chimera-shell-toolbar{top:0;z-index:1500;min-height:58px;margin:0 calc(var(--companion-gutter) * -1);padding:8px var(--companion-gutter);gap:8px;background:linear-gradient(180deg,rgba(14,10,18,.98),rgba(14,10,18,.93)),radial-gradient(circle at 70% 0,rgba(118,81,230,.18),transparent 18rem);border-bottom:1px solid rgba(203,186,255,.12);box-shadow:0 10px 28px rgba(0,0,0,.26);backdrop-filter:blur(18px)}#mobile-nav-toggle,#chimera-command-open{width:42px;min-width:42px;flex:0 0 42px;min-height:42px;justify-content:center;padding:0;color:var(--text);background:rgba(255,255,255,.035);border:1px solid rgba(244,238,251,.11);border-radius:12px}#mobile-nav-toggle{display:inline-grid;place-items:center;font-size:21px}body.mobile-nav-open #mobile-nav-toggle{position:fixed;top:max(14px,env(safe-area-inset-top));left:calc(min(350px,92vw) - 56px);z-index:2400;background:rgba(25,18,32,.96);border-color:rgba(203,186,255,.24);box-shadow:0 10px 24px rgba(0,0,0,.34);pointer-events:auto}body.mobile-nav-open #chimera-shell-toolbar{z-index:2350;pointer-events:none;background:transparent;border-color:transparent;box-shadow:none;backdrop-filter:none}body.mobile-nav-open #chimera-shell-toolbar>:not(#mobile-nav-toggle){visibility:hidden}#chimera-command-open::before{content:"⌕";font:500 25px/1 var(--display)}#chimera-command-open>span,#chimera-command-open>kbd,#chimera-jobs-open,.chimera-toolbar-spacer{display:none}.chimera-shell-health{min-width:0;min-height:42px;flex:1;justify-content:flex-start;gap:9px;padding:7px 11px;background:rgba(255,255,255,.025);border:1px solid rgba(244,238,251,.09);border-radius:12px}.chimera-shell-health-copy{min-width:0}.chimera-shell-health-copy small{display:none}.chimera-shell-health-copy strong{overflow:hidden;font:700 11px/1.25 var(--mono);letter-spacing:.055em;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}#chimera-route-header{min-height:0;gap:10px;padding:18px 2px 16px;border-bottom:1px solid rgba(244,238,251,.09)}#chimera-breadcrumbs{min-height:16px;margin-bottom:7px;color:var(--teal);font-size:11px;letter-spacing:.13em}#chimera-breadcrumbs button{color:var(--teal)}#chimera-route-title{font-size:clamp(28px,9vw,36px);line-height:.98;letter-spacing:-.045em}#chimera-route-description{display:-webkit-box;max-width:34rem;margin-top:9px;overflow:hidden;color:var(--text-dim);font-size:12px;line-height:1.5;-webkit-box-orient:vertical;-webkit-line-clamp:2}.chimera-route-actions{width:100%;min-height:0;align-items:center;justify-content:flex-start}#chimera-route-refresh{width:42px;min-width:42px;min-height:42px;margin-left:auto;padding:0;border-radius:12px;font-size:0}#chimera-route-refresh::first-letter{font-size:18px}.chimera-route-risk,.chimera-route-availability{max-width:calc(100% - 52px);padding:7px 10px;border-radius:12px;font-size:11px}.page{gap:14px}.card{gap:13px;padding:16px;border-color:rgba(203,186,255,.12);border-radius:16px;box-shadow:0 12px 30px rgba(0,0,0,.14)}.card-header{min-width:0;align-items:flex-start;justify-content:space-between;flex-wrap:wrap}.card-header h2,.card-header h3{font-size:17px}.form-row,.form-grid{display:grid;grid-template-columns:1fr;align-items:stretch;gap:12px}.form-row>label,.form-grid>label,.form-row input,.form-row select,.form-row textarea,.form-grid input,.form-grid select,.form-grid textarea{width:100%!important;min-width:0;max-width:none}input:not([type=checkbox]):not([type=radio]):not([type=range]),select,textarea{min-height:46px;font-size:16px}.btn-row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.btn-row>.btn{min-width:0;min-height:46px;white-space:normal}.btn-row>.btn:only-child{grid-column:1/-1}.hint{line-height:1.55}#sidebar{top:0;bottom:0;width:min(350px,92vw);padding:0 0 calc(18px + env(safe-area-inset-bottom));background:radial-gradient(circle at 0 0,rgba(118,81,230,.19),transparent 18rem),#100c15;border-right:1px solid rgba(203,186,255,.18);z-index:2300;box-shadow:28px 0 80px rgba(0,0,0,.66)}#sidebar-scrim{inset:0;z-index:2250;background:rgba(4,3,6,.76);backdrop-filter:blur(5px)}.sidebar-brand{position:sticky;top:0;z-index:2;min-height:76px;padding:14px 18px;background:linear-gradient(180deg,rgba(16,12,21,.98),rgba(16,12,21,.92));border-bottom:1px solid rgba(244,238,251,.08)}.sidebar-brand .brand-icon{width:36px;height:36px}.sidebar-brand-copy strong{font-size:18px}.sidebar-brand-copy small{font-size:11px}.chimera-nav-root{padding:10px 9px 16px;overflow-y:auto;scrollbar-width:thin}.chimera-domaLine truncated
+1
View File
@@ -0,0 +1 @@
:root{--sidebar-w:252px}body:not(.chimera-shell-v2) #sidebar .nav-list{visibility:hidden}body.chimera-shell-v2{--chimera-shell-surface:rgba(17, 18, 22, .92)}body.chimera-modal-open{overflow:hidden}body.chimera-shell-v2 #chimera-route-header,body.chimera-shell-v2 .page.active{width:min(100%,1680px);margin-inline:auto}body[data-density=compact] .page{gap:12px}body[data-density=compact] .card{gap:10px;padding:14px}body[data-density=compact] .stat-card{padding:14px}body[data-density=compact] .chimera-domain-button{min-height:36px;padding-block:6px}body[data-density=compact] .chimera-route-link{min-height:29px;padding-block:5px}.chimera-nav-root{min-height:0;overflow-x:hidden;overflow-y:auto;padding:4px 8px 12px;scrollbar-width:thin}.chimera-nav-root::-webkit-scrollbar{width:5px}.chimera-nav-root::-webkit-scrollbar-thumb{background:var(--border);border-radius:8px}.chimera-domain{list-style:none;margin:2px 0}.chimera-domain-button{width:100%;min-height:42px;display:flex;align-items:center;gap:11px;padding:9px 10px;color:var(--text-dim);background:0 0;border:1px solid transparent;border-radius:8px;text-decoration:none;font:inherit;font-size:12px;font-weight:650;text-align:left;cursor:pointer;transition:color .16s,background .16s,border-color .16s}.chimera-domain-button svg{width:18px;height:18px;flex:0 0 auto}.chimera-domain-button>span:nth-child(2){flex:1}.chimera-domain-button:focus-visible,.chimera-domain-button:hover{color:var(--text);background:rgba(255,255,255,.055)}.chimera-domain-button.active,.chimera-domain.active>.chimera-domain-button{color:var(--accent);background:rgba(var(--accent-rgb),.09);border-color:rgba(var(--accent-rgb),.16)}.chimera-domain-chevron{flex:0 0 auto!important;font-size:20px;line-height:1;transition:transform .18s}.chimera-domain.expanded .chimera-domain-chevron{transform:rotate(90deg)}.chimera-domain-routes{list-style:none;margin:3px 0 7px 18px;padding:2px 0 2px 12px;border-left:1px solid var(--border)}.chimera-route-link{min-height:33px;display:flex;align-items:center;gap:8px;padding:7px 9px;color:var(--text-dim);border-radius:8px;text-decoration:none;font-size:12px;transition:color .14s,background .14s}.chimera-route-link>span:first-child{flex:1;min-width:0}.chimera-route-link:focus-visible,.chimera-route-link:hover{color:var(--text);background:rgba(255,255,255,.05)}.chimera-route-link.active{color:var(--accent);background:rgba(var(--accent-rgb),.11);font-weight:650}.chimera-risk-dot{display:inline-grid;place-items:center;width:17px;height:17px;border-radius:50%;color:var(--yellow);border:1px solid currentColor;font-size:11px;font-weight:800}.chimera-expert-route{display:none}.chimera-expert-mode .chimera-expert-route,.chimera-expert-route.active{display:flex}.chimera-expert-toggle{list-style:none;margin:10px 2px 2px;padding-top:10px;border-top:1px solid var(--border)}.chimera-expert-toggle label{min-height:42px;display:flex;flex-direction:row;align-items:center;gap:10px;padding:7px 8px;color:var(--text-dim);cursor:pointer}.chimera-expert-toggle input{width:34px;height:19px;flex:0 0 34px;position:relative;appearance:none;margin:0;padding:0;background:rgba(255,255,255,.08);border:1px solid var(--border);border-radius:999px;cursor:pointer;transition:background .16s,border-color .16s}.chimera-expert-toggle input::before{content:"";position:absolute;width:13px;height:13px;top:2px;left:2px;background:var(--text-dim);border-radius:50%;transition:transform .16s,background .16s}.chimera-expert-toggle input:checked{background:rgba(var(--accent-rgb),.28);border-color:rgba(var(--accent-rgb),.7)}.chimera-expert-toggle input:checked::before{background:var(--accent);transform:translateX(15px)}.chimera-expert-toggle label:hover input{border-color:rgba(var(--accent-rgb),.6)}.chimera-expert-toggle small,.chimera-expert-toggle strong{display:block}.chimera-expert-toggle strong{color:var(--text);font-size:11px}.chimera-expert-toggle small{margin-top:2px;font-size:11px}#chimera-shell-toolbar{position:sticky;top:-24px;z-index:1200;display:flex;align-items:center;justify-content:flex-end;gap:8px;min-height:38px;margin:-24px -24px 0;padding:8px 24px;background:linear-gradient(180deg,rgba(11,12,15,.98),rgba(11,12,15,.88));border-bottom:1px solid rgba(255,255,255,.055);backdrop-filter:blur(16px)}.chimera-toolbar-button{min-height:30px;display:inline-flex;align-items:center;gap:8px;padding:5px 9px;color:var(--text-dim);background:rgba(255,255,255,.035);border:1px solid var(--border);border-radius:8px;font:inherit;font-size:11px;cursor:pointer}.chimera-toolbar-button:focus-visible,.chimera-toolbar-button:hover{color:var(--text);border-color:rgba(var(--accent-rgb),.45)}.chimera-command-dialog kbd,.chimera-toolbar-button kbd{min-width:21px;padding:2px 5px;color:var(--text-dim);background:rgba(255,255,255,.055);border:1px solid var(--border);border-bottom-color:rgba(255,255,255,.24);border-radius:8px;font:11px/1.25 ui-monospace,SFMono-Regular,Consolas,monospace;text-align:center}.chimera-jobs-pulse{width:7pxLine truncated
+6
View File
@@ -0,0 +1,6 @@
.package-library-toolbar{display:flex;align-items:end;justify-content:space-between;gap:20px;background:linear-gradient(120deg,var(--surface),color-mix(in srgb,var(--accent) 8%,var(--surface)))}
.package-library-toolbar label{min-width:min(520px,100%)}.package-library-toolbar input{width:100%}.package-library-stats{font-family:var(--font-mono);font-size:var(--fs-micro);color:var(--text-muted)}
.package-library-list{display:grid;gap:16px}.package-library-set{border:1px solid var(--border);border-radius:16px;background:var(--surface);overflow:hidden}.package-library-set>header{display:flex;justify-content:space-between;gap:20px;padding:18px 20px;background:linear-gradient(90deg,color-mix(in srgb,var(--accent) 10%,transparent),transparent)}
.package-library-set h3,.package-library-set h4{margin:0}.package-library-set header span,.package-library-set header small{display:block;color:var(--text-muted)}.package-library-set header>div:last-child{text-align:right}.package-library-columns{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(260px,.8fr);gap:20px;padding:20px}
.package-component-list,.package-recipe-list{list-style:none;margin:10px 0 0;padding:0;display:grid;gap:8px}.package-component-list li,.package-recipe-list li{display:flex;justify-content:space-between;gap:16px;padding:11px 12px;border:1px solid var(--border);border-radius:12px}.package-component-list li>div:last-child{text-align:right}.package-component-list span,.package-recipe-list span{display:block;color:var(--text-muted);font-size:var(--fs-micro)}.package-component-list code{display:block;max-width:30ch;overflow:hidden;text-overflow:ellipsis;font-size:var(--fs-micro)}.package-library-empty{color:var(--text-muted)}
@media(max-width:760px){.package-library-toolbar,.package-library-set>header{align-items:stretch;flex-direction:column}.package-library-set header>div:last-child{text-align:left}.package-library-columns{grid-template-columns:1fr;padding:14px}.package-component-list li{flex-direction:column}.package-component-list li>div:last-child{text-align:left}.package-component-list code{max-width:100%}}
+603
View File
@@ -0,0 +1,603 @@
.page-subtitle{font-size:12px;color:var(--text-dim);margin-top:3px;font-weight:400}
.dash-pinned-section{display:flex;flex-direction:column;gap:8px}
.dash-pinned-header{display:flex;align-items:center;gap:10px}
.dash-pinned-label{font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.07em}
.dash-pinned-grid{display:flex;flex-wrap:wrap;gap:10px}
.dash-pinned-tile{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px;min-width:170px;flex:1 1 170px;max-width:260px;display:flex;flex-direction:column;gap:5px;cursor:pointer;transition:border-color .15s,background .15s}
.dash-pinned-tile:hover{border-color:var(--accent);background:var(--surface2)}
.dash-pinned-tile-title{font-size:12px;font-weight:600;color:var(--text)}
.dash-pinned-tile-page{font-size:11px;color:var(--accent);font-weight:500}
.dash-pinned-tile-hint{font-size:11px;color:var(--text-dim);line-height:1.45;flex:1}
.dash-pinned-tile-open{margin-top:8px;align-self:flex-start;font-size:12px}
.dash-pinned-empty{font-size:12px;color:var(--text-dim);padding:4px 0}
/* Pin toggle button — appears in card-headers and header-tools */
.btn-pin{background:none;border:none;cursor:pointer;padding:2px 5px;font-size:14px;line-height:1;filter:grayscale(1) opacity(.38);transition:filter .15s;flex-shrink:0}
.btn-pin:hover{filter:grayscale(.4) opacity(.8)}
.btn-pin.pinned{filter:none}
/* ── PS5 Autoloader ─────────────────────────────────────────────────────────── */
.autoloader-path-hint{font-weight:400;text-transform:none;font-size:11px;color:var(--text-dim);margin-left:6px;font-family:var(--mono,monospace)}
.autoloader-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap}
.autoloader-status-text{font-size:12px;color:var(--text-dim)}
.autoloader-layout{display:grid;grid-template-columns:240px 1fr;gap:14px}
@media (max-width:680px){.autoloader-layout{grid-template-columns:1fr}
}
.autoloader-col-header{font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
/* Available files column */
.autoloader-file-list{background:var(--bg);border:1px solid var(--border);border-radius:8px;max-height:360px;overflow-y:auto}
.al-file-card{display:flex;align-items:center;gap:8px;padding:7px 10px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .12s;user-select:none}
.al-file-card:last-child{border-bottom:none}
.al-file-card:hover{background:var(--surface3)}
.al-file-name{flex:1;font-size:12px;font-family:var(--mono,monospace);color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.al-file-add{font-size:12px;color:var(--text-dim);flex-shrink:0;transition:color .12s}
.al-file-card:hover .al-file-add{color:var(--teal)}
/* Sequence editor column */
.autoloader-sequence{background:var(--bg);border:1px solid var(--border);border-radius:8px;max-height:360px;overflow-y:auto}
.autoloader-seq-footer{margin-top:8px}
/* Empty state */
.al-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px 16px;gap:6px;color:var(--text-dim);text-align:center}
.al-empty-icon{font-size:26px;opacity:.45}
.al-empty-hint{font-size:12px;margin:0}
/* Sequence rows */
.al-row{display:flex;align-items:flex-start;gap:6px;padding:6px 8px;border-bottom:1px solid var(--border);transition:background .12s}
.al-row:last-child{border-bottom:none}
.al-row.al-dragging{opacity:.4}
.al-row.al-drag-over{background:rgba(0,180,160,.10);border-top:2px solid var(--teal)}
.al-payload{background:transparent}
.al-delay{background:rgba(187,134,252,.05)}
/* Drag handle */
.al-grip{cursor:grab;color:var(--text-dim);font-size:12px;padding:2px 2px 0;flex-shrink:0;opacity:.5;align-self:center}
.al-grip:active{cursor:grabbing}
/* Extension badge */
.al-ext{font-size:11px;font-weight:700;letter-spacing:.04em;padding:2px 5px;border-radius:8px;flex-shrink:0;text-transform:uppercase;align-self:center;background:var(--surface3);color:var(--text-dim)}
.al-ext-elf{background:rgba(0,180,160,.18);color:var(--teal)}
.al-ext-bin{background:rgba(255,167,38,.18);color:#ffa726}
.al-ext-js{background:rgba(255,214,0,.15);color:#e6c200}
/* Payload filename */
.al-name{flex:1;font-size:12px;font-family:var(--mono,monospace);color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;align-self:center;min-width:0}
/* Insert delay after (payload row) */
.al-ins-delay{background:none;border:1px solid var(--border);border-radius:8px;color:var(--text-dim);cursor:pointer;font-size:11px;padding:2px 5px;line-height:1.4;flex-shrink:0;transition:background .12s,color .12s,border-color .12s;align-self:center;white-space:nowrap}
.al-ins-delay:hover{background:var(--surface3);color:var(--teal);border-color:var(--teal)}
/* Delete button */
.al-del{background:none;border:1px solid transparent;border-radius:8px;color:var(--text-dim);cursor:pointer;font-size:11px;padding:2px 5px;line-height:1.4;flex-shrink:0;transition:background .12s,color .12s,border-color .12s;align-self:center}
.al-del:hover{background:rgba(207,102,121,.15);color:var(--red);border-color:var(--red)}
/* Delay row body */
.al-delay-clock{font-size:14px;flex-shrink:0;align-self:flex-start;padding-top:3px}
.al-delay-body{flex:1;display:flex;flex-direction:column;gap:5px;min-width:0}
.al-delay-top{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.al-delay-lbl{font-size:11px;color:var(--text-dim);flex-shrink:0}
.al-delay-num{width:64px;padding:2px 6px;font-size:12px;text-align:right;background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--text);outline:none;flex-shrink:0}
.al-delay-num:focus{border-color:var(--accent)}
.al-delay-ms{font-size:11px;color:var(--text-dim);flex-shrink:0}
.al-delay-secs{font-size:11px;color:var(--teal);flex-shrink:0;font-style:italic}
/* Preset buttons */
.al-presets{display:flex;gap:4px;flex-wrap:wrap}
.al-preset{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--text-dim);cursor:pointer;font-size:11px;padding:2px 6px;line-height:1.4;transition:background .12s,color .12s,border-color .12s;flex-shrink:0}
.al-preset:hover{background:var(--surface3);color:var(--text)}
.al-preset-active{background:var(--teal);border-color:var(--teal);color:#fff}
.al-preset-active:hover{background:var(--teal);color:#fff}
/* Range slider */
.al-slider{width:100%;accent-color:var(--teal);cursor:pointer}
/* ── Autoloader Snapshots ─────────────────────────────────────────────────────────────── */
.snapshot-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:12px}
.snapshot-list{display:flex;flex-direction:column;gap:0}
.snapshot-card{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 10px;border-bottom:1px solid var(--border)}
.snapshot-card:last-child{border-bottom:none}
.snapshot-card:hover{background:var(--surface3);border-radius:8px}
.snapshot-card-info{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}
.snapshot-card-label{font-size:12px;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.snapshot-card-meta{font-size:11px;color:var(--text-dim)}
.snapshot-card-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
.btn-snap-delete{color:var(--text-dim);border-color:transparent}
.btn-snap-delete:hover{color:var(--red);border-color:var(--red);background:transparent}
/* ── PS Avatar ─────────────────────────────────────────────────────────────── */
.xavatar-drop-zone{border:2px dashed var(--border);border-radius:var(--radius);padding:36px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;cursor:pointer;transition:border-color .15s,background .15s;color:var(--text-dim);text-align:center}
.xavatar-drop-zone:hover,.xavatar-drop-zone--over{border-color:var(--teal);background:rgba(0,180,160,.06)}
.xavatar-drop-zone--has-image{border-style:solid;border-color:var(--teal)}
.xavatar-drop-icon{color:var(--text-dim);flex-shrink:0}
.xavatar-drop-label{font-size:12px;margin:0}
.xavatar-preview{display:block;margin:16px auto 0;max-width:220px;max-height:220px;border-radius:var(--radius);border:1px solid var(--border);image-rendering:pixelated}
.xavatar-conv-progress{margin-top:12px;height:22px;background:var(--bg2);border-radius:var(--radius);overflow:hidden;border:1px solid var(--border)}
.xavatar-conv-progress[hidden]{display:none}
.xavatar-conv-bar{height:100%;min-width:2em;background:var(--teal);color:#fff;font-size:11px;font-weight:600;line-height:22px;padding:0 8px;border-radius:var(--radius);transition:width .35s ease;white-space:nowrap;overflow:hidden}
.xavatar-actions{display:flex;align-items:center;gap:14px;margin-top:14px}
.xavatar-status{font-size:12px;color:var(--text-dim)}
.link-btn{background:none;border:none;color:var(--teal);cursor:pointer;padding:0;font-size:inherit;text-decoration:underline}
/* ── XAvatar library ───────────────────────────────────────────────────────── */
.xavatar-lapy-notice{display:flex;align-items:center;justify-content:space-between;gap:16px;background:color-mix(in srgb,var(--teal) 8%,var(--surface));border:1px solid color-mix(in srgb,var(--teal) 35%,var(--border));border-radius:var(--radius);padding:13px 14px;font-size:12px;color:var(--text);margin-bottom:12px}
.xavatar-lapy-notice > div:first-child{display:flex;min-width:0;flex-direction:column;gap:4px}
.xavatar-lapy-notice span,.xavatar-lapy-notice small{color:var(--text-dim);overflow-wrap:anywhere}
.xavatar-companion-actions{display:flex;flex:none;align-items:center;gap:10px}
.xavatar-lapy-notice a{color:var(--accent);text-decoration:none;cursor:pointer}
.xavatar-lapy-notice a:hover{color:#fff}
@media (max-width:700px){.xavatar-lapy-notice{align-items:stretch;flex-direction:column}
.xavatar-companion-actions{justify-content:space-between}
}
/* Card grid */
.xavatar-lib-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;padding:12px 14px}
.xavatar-lib-card{display:flex;flex-direction:column;align-items:center;gap:6px;border:1px solid var(--border);border-radius:var(--radius);padding:8px 6px;cursor:pointer;transition:border-color .15s,background .15s;position:relative}
.xavatar-lib-card:hover{border-color:var(--teal);background:rgba(0,180,160,.05)}
/* Thumbnail wrapper */
.xavatar-thumb-wrap{position:relative;width:80px;height:80px;flex-shrink:0;border-radius:calc(var(--radius) - 2px);overflow:hidden;background:var(--bg2);display:flex;align-items:center;justify-content:center}
.xavatar-thumb{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:0;transition:opacity .25s}
.xavatar-thumb-loaded .xavatar-thumb{opacity:1}
.xavatar-thumb-placeholder{font-size:32px;color:var(--text-dim);pointer-events:none}
.xavatar-thumb-loaded .xavatar-thumb-placeholder{display:none}
.xavatar-thumb-error .xavatar-thumb-placeholder{display:none}
.xavatar-thumb-error .xavatar-thumb-wrap::after{content:'?';position:absolute;font-size:28px;color:var(--text-dim)}
/* Card info */
.xavatar-lib-card-info{display:flex;flex-direction:column;align-items:center;gap:2px;width:100%;overflow:hidden}
.xavatar-lib-card-name{font-size:11px;font-family:var(--mono,monospace);color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;text-align:center}
.xavatar-lib-card-size{font-size:11px;color:var(--text-dim)}
/* Card action buttons row */
.xavatar-lib-card-actions{display:flex;gap:4px;margin-top:2px}
.btn-xs{font-size:11px;padding:2px 7px;line-height:1.4}
/* ── XAvatar full-size modal ─────────────────────────────────────────────── */
.xavatar-modal-box{background:var(--bg1);border:1px solid var(--border);border-radius:var(--radius);padding:20px;display:flex;flex-direction:column;align-items:center;gap:12px;max-width:500px;width:90vw;pointer-events:auto;max-height:calc(100dvh - 32px);overflow:auto}
.xavatar-modal-img{width:280px;height:280px;object-fit:contain;border-radius:var(--radius);background:var(--bg2);border:1px solid var(--border);image-rendering:pixelated}
.xavatar-modal-name{font-size:12px;font-family:var(--mono,monospace);color:var(--text-dim);text-align:center;word-break:break-all;margin:0}
.xavatar-modal-actions{display:flex;flex-wrap:wrap;gap:8px;justify-content:center}
#xavatar-thumb-modal[hidden]{display:none !important}
#xavatar-thumb-modal{padding:16px;backdrop-filter:blur(8px)}
#xavatar-thumb-modal .btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
@media (max-width:560px){.xavatar-modal-box{width:100%;padding:16px}
.xavatar-modal-img{width:min(280px,72vw);height:min(280px,72vw)}
.xavatar-modal-actions{display:grid;grid-template-columns:1fr 1fr;width:100%}
.xavatar-modal-actions .btn{min-width:0}
}
/* ── Game Conversion ─────────────────────────────────────────────────────── */
/* Extra badge colour */
.badge-blue{background:rgba(64,156,255,.2);color:#64b0ff;border-radius:8px;padding:2px 6px;font-size:11px;font-weight:600}
/* OS-restriction badge (shown inside button labels) */
.badge-os{display:inline-block;background:rgba(240,192,64,.18);color:var(--yellow);border-radius:8px;padding:1px 4px;font-size:11px;font-weight:700;vertical-align:middle;margin-left:3px}
/* ── Settings:radio mode-card selector ── */
.radio-card{display:flex;flex-direction:column;gap:3px;padding:10px 14px;border:1.5px solid var(--border);border-radius:var(--radius);background:var(--surface2);cursor:pointer;min-width:140px;transition:border-color .15s,background .15s}
.radio-card:hover{border-color:var(--accent-d)}
.radio-card input[type="radio"]{display:none}
.radio-card-title{font-size:12px;font-weight:700;color:var(--text)}
.radio-card-desc{font-size:11px;color:var(--text-dim)}
.radio-card:has(input:checked){border-color:var(--accent);background:rgba(var(--accent-rgb),.08)}
.radio-card:has(input:checked) .radio-card-title{color:var(--accent)}
/* ── Settings:conv mode sections ── */
.conv-mode-section{margin-top:10px;padding-top:10px;border-top:1px solid var(--border)}
#conv-shared-fields{margin-top:12px}
/* ── Small toggle row (for psnotify checkboxes) ── */
.toggle-row-sm{display:flex;flex-direction:row;align-items:center;gap:8px;font-size:12px;color:var(--text-dim);cursor:pointer;margin-top:4px}
.toggle-row-sm input[type="checkbox"]{width:14px;height:14px;accent-color:var(--accent);cursor:pointer}
.conv-psn-checkboxes{display:flex;flex-wrap:wrap;gap:0 18px;padding:8px 0 4px 8px}
/* Row-style labels must override the global vertical form-label layout. */
.ecosystem-check{flex-direction:row}
/* ── Conversion Queue Panel (shown on Games page) ── */
.conv-queue-panel{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:14px;overflow:hidden}
.conv-queue-header{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--surface3);border-bottom:1px solid var(--border);flex-wrap:wrap}
.conv-queue-title{font-size:12px;font-weight:700;color:var(--text-dim);white-space:nowrap}
.conv-queue-spacer{flex:1}
.conv-mode-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:999px;border:1px solid var(--border);background:rgba(187,134,252,.12);color:var(--accent);font-size:11px;font-weight:700;white-space:nowrap}
.conv-queue-body{}
.conv-queue-list{}
.conv-queue-empty{padding:12px 16px;font-size:12px;color:var(--text-dim);text-align:center}
/* ── Individual conv job row ── */
.conv-job-row{padding:8px 12px;border-bottom:1px solid var(--border);font-size:12px}
.conv-job-row:last-child{border-bottom:none}
.conv-job-summary{display:flex;align-items:center;gap:10px}
.conv-log-box{margin:5px 0 0;padding:5px 8px;background:var(--surface3);border-radius:8px;font-family:var(--mono,monospace);font-size:10.5px;color:var(--text-dim);white-space:pre-wrap;word-break:break-all;max-height:160px;overflow-y:auto}
.conv-job-error{margin-top:4px;color:var(--red,#e05);font-size:11px;word-break:break-word;white-space:pre-wrap}
.conv-job-id{color:var(--text-dim);min-width:28px;font-family:var(--mono,monospace)}
.conv-job-game{font-weight:600;color:var(--text);min-width:100px}
.conv-job-mode{color:var(--accent);min-width:50px;font-size:11px}
.conv-job-phase{flex:1;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.conv-job-bar-wrap{width:80px;height:6px;background:var(--surface3);border-radius:8px;overflow:hidden;flex-shrink:0}
.conv-job-bar{height:100%;background:var(--accent);border-radius:8px;transition:width .3s}
.conv-job-bar.conv-bar-done{background:var(--green)}
.conv-job-bar.conv-bar-error{background:var(--red)}
.conv-job-bar.conv-bar-running{background:var(--teal)}
.conv-job-status{min-width:60px;text-align:right;font-size:11px}
.conv-status-queued{color:var(--text-dim)}
.conv-status-running{color:var(--teal)}
.conv-status-done{color:var(--green)}
.conv-status-error{color:var(--red)}
.conv-status-cancelled{color:var(--text-dim)}
.conv-job-actions{display:flex;gap:4px;flex-shrink:0}
@media (max-width:560px){.conv-queue-header{align-items:stretch}
.conv-queue-spacer{display:none}
.conv-queue-header > .btn{flex:1 1 calc(50% - 4px);min-width:0;white-space:normal}
.conv-mode-switcher{flex-wrap:nowrap;overflow-x:auto;padding-bottom:6px;scrollbar-width:thin}
.conv-job-summary{flex-wrap:wrap}
.conv-job-phase{min-width:140px}
}
/* ── Install modal:mode tabs ── */
.install-mode-tab{border-radius:8px;font-size:11px;padding:4px 10px;border:1.5px solid var(--border);background:transparent;color:var(--text-dim);cursor:pointer;transition:all .15s}
.install-mode-tab:hover{border-color:var(--accent-d);color:var(--text)}
.install-mode-tab.active{border-color:var(--accent);background:rgba(var(--accent-rgb),.1);color:var(--accent);font-weight:600}
.install-mode-section{}
/* ── Conversion queue panel — mode switcher & warning ────────────────────── */
.conv-mode-switcher{display:flex;align-items:center;gap:6px;padding:6px 12px 2px;border-bottom:1px solid var(--border);flex-wrap:wrap}
.conv-mode-switcher-label{font-size:11px;color:var(--text-muted);margin-right:2px;text-transform:uppercase;letter-spacing:.04em}
.conv-mode-btn{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:16px;border:1px solid var(--border);background:transparent;color:var(--text-muted);font-size:11px;font-weight:600;cursor:pointer;transition:background .15s,color .15s,border-color .15s;white-space:nowrap}
.conv-mode-btn:hover{border-color:var(--accent);color:var(--text)}
.conv-mode-btn.active{background:var(--accent);border-color:var(--accent);color:#fff}
.conv-mode-warn{display:flex;align-items:center;gap:10px;padding:7px 12px;background:rgba(255,140,0,.1);border-bottom:1px solid rgba(255,140,0,.3);font-size:12px;color:#ff8c00;flex-wrap:wrap}
.conv-mode-warn .btn{flex-shrink:0;padding:2px 10px;font-size:11px;border-color:#ff8c00;color:#ff8c00}
.conv-mode-warn .btn:hover{background:rgba(255,140,0,.2)}
/* ── GarlicSaves ─────────────────────────────────────────────────────────── */
.garlic-progress-area{display:flex;flex-direction:column;gap:6px;margin:10px 0 4px}
.garlic-progress-step{font-size:12px;color:var(--text-dim)}
.garlic-progress-bar-wrap{height:4px;border-radius:8px;background:var(--border);overflow:hidden}
.garlic-progress-bar{height:100%;border-radius:8px;background:var(--accent);transition:width .25s ease}
.garlic-config-pre{background:var(--bg-elevated,var(--surface2));border:1px solid var(--border);border-radius:8px;padding:10px 14px;font-size:12px;font-family:monospace;margin:8px 0 0;white-space:pre-wrap;word-break:break-all;color:var(--text);line-height:1.6}
.garlic-tips{padding-left:20px;margin:4px 0 0;display:flex;flex-direction:column;gap:7px}
.garlic-tips li{font-size:12px;color:var(--text);line-height:1.5}
.garlic-link{color:var(--accent);text-decoration:none}
.garlic-link:hover{text-decoration:underline}
.garlic-confirm-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;z-index:9100}
.garlic-confirm-overlay[hidden]{display:none}
.garlic-confirm-dialog{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px 28px;max-width:420px;width:100%;box-shadow:0 8px 32px rgba(0,0,0,.6)}
.garlic-confirm-title{font-size:16px;font-weight:600;color:var(--text);margin:0 0 12px}
.garlic-confirm-dialog p{font-size:12px;color:var(--text-dim);line-height:1.5;margin:0}
.garlic-confirm-btns{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
/* ── Native Save Manager (GarlicMgr) ─────────────────────────────────────── */
.sm-body{display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden}
/* Sub-tabs */
.sm-tabs{display:flex;gap:4px;padding:7px 14px;border-bottom:1px solid var(--border);flex-shrink:0;background:var(--surface2)}
.sm-tab{padding:4px 14px;border-radius:16px;border:1.5px solid var(--border);background:transparent;color:var(--text-dim);font-size:11px;font-weight:700;cursor:pointer;transition:all .15s;text-transform:uppercase;letter-spacing:.6px}
.sm-tab:hover{border-color:var(--accent);color:var(--text)}
.sm-tab.active{background:var(--accent);border-color:var(--accent);color:var(--bg)}
/* Panes */
.sm-pane{display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden}
/* Browse tab layout */
.sm-layout{display:flex;flex:1;min-height:0;overflow:hidden}
/* Left saves list */
.sm-left{width:220px;flex-shrink:0;border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;background:var(--surface)}
.sm-section-label{padding:7px 14px;font-size:11px;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid var(--border);flex-shrink:0}
.sm-saves{flex:1;overflow-y:auto}
.sm-save-item{padding:9px 14px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.04);transition:background .1s}
.sm-save-item:hover{background:rgba(255,255,255,.05)}
.sm-save-item.active{background:rgba(var(--accent-rgb),.12);border-left:2px solid var(--accent);padding-left:12px}
.sm-save-tid{font-size:11px;font-weight:700;color:var(--accent);font-family:monospace}
.sm-save-item.active .sm-save-tid{color:var(--text)}
.sm-save-name{font-size:11px;color:var(--text-dim);margin-top:2px;font-family:monospace;word-break:break-all}
.sm-empty-list{padding:20px 14px;font-size:12px;color:var(--text-dim);text-align:center}
/* Right panel */
.sm-right{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden}
.sm-right.sm-drag-over{outline:2px dashed var(--accent);outline-offset:-2px;background:rgba(var(--accent-rgb),.04)}
/* Empty state */
.sm-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--text-dim);text-align:center;padding:32px}
.sm-empty p{margin:0;font-size:14px}
/* Mounted save header */
.sm-save-header{display:flex;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid var(--border);flex-shrink:0;background:var(--surface);flex-wrap:wrap}
.sm-game-icon{width:80px;height:45px;border-radius:8px;object-fit:cover;background:var(--surface2);flex-shrink:0}
.sm-save-meta-block{flex:1;min-width:0}
.sm-save-title-text{font-size:14px;font-weight:700;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.sm-save-meta-row{font-size:11px;color:var(--text-dim);margin-top:3px}
.sm-save-ftp-row{font-size:11px;color:var(--text-dim);margin-top:2px;opacity:.5;font-family:monospace}
.sm-save-btns{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
/* File tree */
.sm-filetree{flex:1;overflow-y:auto;background:var(--bg)}
.sm-fi{display:flex;align-items:center;gap:5px;padding:5px 12px;cursor:pointer;user-select:none;font-size:12px;color:var(--text);transition:background .1s}
.sm-fi:hover{background:var(--surface2)}
.sm-fi.sm-fi-dir{color:var(--accent);font-weight:600}
.sm-fi-sz{margin-left:auto;font-size:11px;color:var(--text-dim);font-family:monospace}
.sm-tip{padding:6px 14px;font-size:11px;color:var(--text-dim);opacity:.5;text-align:center}
/* Context menu */
.sm-ctx{position:fixed;z-index:9999;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:3px 0;min-width:140px;box-shadow:0 4px 16px rgba(0,0,0,.6)}
.sm-ctx div{padding:7px 14px;font-size:12px;cursor:pointer;color:var(--text)}
.sm-ctx div:hover{background:var(--surface3)}
.sm-ctx .sm-ctx-del{color:var(--red)}
/* Single panes (decrypt / encrypt / resign) */
.sm-single{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:40px}
.sm-field-row{display:flex;align-items:center;gap:10px}
.sm-field-label{font-size:11px;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}
.sm-field-row input{background:var(--bg);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:8px;font-family:monospace;font-size:12px;width:180px}
.sm-drop{border:2px dashed var(--border);border-radius:var(--radius);padding:36px 60px;text-align:center;transition:border-color .15s,background .15s;display:flex;flex-direction:column;align-items:center;gap:14px;min-width:300px}
.sm-drop.sm-drag-over{border-color:var(--accent);background:rgba(var(--accent-rgb),.06)}
.sm-drop-label{font-size:12px;color:var(--text-dim);line-height:1.6}
.sm-drop-label strong{color:var(--text)}
/* Progress bars */
.sm-progress{padding:4px 14px;flex-shrink:0}
.sm-progress-bar{height:3px;background:var(--border);border-radius:8px;overflow:hidden}
.sm-progress-fill{height:100%;background:var(--accent);border-radius:8px;transition:width .1s}
.sm-progress-text{font-size:11px;color:var(--text-dim);margin-top:3px;font-family:monospace}
/* Terminal */
.sm-terminal{flex-shrink:0;height:90px;border-top:1px solid var(--border);background:#0a0a0a;display:flex;flex-direction:column}
.sm-terminal-header{padding:3px 12px;font-size:11px;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid rgba(255,255,255,.05);flex-shrink:0}
.sm-log{flex:1;overflow-y:auto;padding:3px 8px}
.sm-ll{padding:1px 0;font-size:11px;color:var(--text-dim);display:flex;gap:8px;font-family:monospace}
.sm-lt{color:rgba(255,255,255,.25);flex-shrink:0}
.sm-ll.sm-ok{color:#4caf50}
.sm-ll.sm-err{color:var(--red)}
/* ── Dashboard interactive widget tiles ──────────────────────────────────────── */
.dash-pinned-tile--widget{cursor:default}
.dash-pinned-tile--widget:hover{border-color:var(--border);background:var(--surface)}
.dash-widget-hdr{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:6px}
.dash-widget-input{width:100%;box-sizing:border-box;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:4px;display:block}
.dash-widget-input:focus{border-color:var(--accent)}
.dash-widget-select{width:100%;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);padding:5px 8px;font-size:12px;cursor:pointer;margin-bottom:6px;display:block;outline:none}
.dash-widget-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px}
.dash-widget-status{font-size:11px;color:var(--text-dim);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dash-widget-port-input{width:62px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);padding:4px 6px;font-size:12px;font-family:monospace;text-align:right;outline:none}
.dash-widget-port-lbl{font-size:11px;color:var(--text-dim);white-space:nowrap}
.dash-widget-log{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);font-family:monospace;font-size:11px;padding:6px 8px;max-height:110px;overflow-y:auto;color:var(--text-dim);flex:1;margin-bottom:4px;white-space:pre-wrap;word-break:break-all;min-height:50px}
.dash-widget-drop{background:var(--surface2);border:2px dashed var(--border);border-radius:var(--radius);padding:10px 8px;text-align:center;font-size:11px;color:var(--text-dim);cursor:pointer;margin-bottom:6px;transition:border-color .15s,color .15s}
.dash-widget-drop:hover,.dash-widget-drop.dw-drag-over{border-color:var(--accent);color:var(--accent)}
.dash-widget-savelist{font-size:11px;max-height:100px;overflow-y:auto;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:4px 0;margin-bottom:4px;color:var(--text-dim);padding-left:8px}
.dash-dw-save-row{display:flex;align-items:center;gap:6px;padding:3px 8px 3px 0;border-bottom:1px solid var(--border)}
.dash-dw-save-row:last-child{border-bottom:none}
.dash-dw-save-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);font-size:11px}
/* ── Chimera Hub ─────────────────────────────────────────────────────────── */
.ecosystem-header,.ecosystem-header-actions,.ecosystem-card-header,.ecosystem-inline-actions,.ecosystem-filter-row{display:flex;align-items:center;gap:10px}
.ecosystem-header,.ecosystem-card-header{justify-content:space-between}
.ecosystem-header-actions{flex-wrap:wrap}
.ecosystem-tabs{display:flex;gap:4px;overflow-x:auto;margin:0 0 16px;padding:4px;border:1px solid var(--border);border-radius:12px;background:var(--surface)}
.ecosystem-tab{border:0;border-radius:8px;background:transparent;color:var(--text-dim);padding:9px 12px;white-space:nowrap;cursor:pointer;font:inherit}
.ecosystem-tab:hover{color:var(--text);background:var(--surface2)}
.ecosystem-tab.active{color:var(--text);background:color-mix(in srgb,var(--accent) 20%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent) 45%,transparent)}
.ecosystem-panel{display:none}
.ecosystem-panel.active{display:block}
.ecosystem-two-column{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px}
.ecosystem-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin-bottom:16px}
.ecosystem-service-row,.ecosystem-source-row,.ecosystem-plugin-row,.ecosystem-save-row,.ecosystem-vault-row,.ecosystem-history-row,.ecosystem-cheat-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 0;border-bottom:1px solid var(--border)}
.ecosystem-service-row:last-child,.ecosystem-source-row:last-child,.ecosystem-plugin-row:last-child,.ecosystem-save-row:last-child,.ecosystem-vault-row:last-child,.ecosystem-history-row:last-child{border-bottom:0}
.ecosystem-service-row>div,.ecosystem-source-row>div:first-child,.ecosystem-plugin-row>div:first-child,.ecosystem-save-row>div:first-child,.ecosystem-vault-row>div:first-child,.ecosystem-history-row>div:first-child,.ecosystem-cheat-row>div:first-child{display:flex;flex-direction:column;gap:3px;min-width:0}
.ecosystem-service-row span,.ecosystem-source-row span,.ecosystem-plugin-row span,.ecosystem-save-row span,.ecosystem-vault-row span,.ecosystem-history-row span,.ecosystem-cheat-row span,.ecosystem-cheat-row small{color:var(--text-dim);font-size:12px}
.ecosystem-source-row code,.ecosystem-plugin-row code,.ecosystem-vault-row code{font-size:11px;overflow-wrap:anywhere}
.ecosystem-safety{display:grid;gap:12px}
.ecosystem-safety p{display:flex;flex-direction:column;gap:3px;margin:0;padding:10px;border-left:3px solid var(--teal);background:color-mix(in srgb,var(--teal) 7%,transparent)}
.ecosystem-safety span{color:var(--text-dim);font-size:12px}
.ecosystem-component-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px}
.ecosystem-component{display:grid;grid-template-columns:1fr auto;gap:8px;padding:12px;border:1px solid var(--border);border-radius:12px;background:var(--surface2)}
.ecosystem-component.expert{border-color:color-mix(in srgb,var(--red) 45%,var(--border))}
.ecosystem-component>div{display:flex;flex-direction:column;gap:3px}
.ecosystem-component>div:nth-child(2){align-items:flex-end}
.ecosystem-component span,.ecosystem-component p{color:var(--text-dim);font-size:11px}
.ecosystem-component p{grid-column:1/-1;margin:0;min-height:32px}
.ecosystem-component button{justify-self:start}
.ecosystem-component-summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-bottom:14px}
.ecosystem-component-summary>div{display:flex;min-width:0;flex-direction:column;gap:2px;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--surface2)}
.ecosystem-component-summary strong{overflow:hidden;color:var(--text);font-size:14px;text-overflow:ellipsis;white-space:nowrap}
.ecosystem-component-summary span{color:var(--text-dim);font-size:11px}
.ecosystem-component-group{margin-top:18px}
.ecosystem-component-group>header{display:flex;align-items:end;justify-content:space-between;margin-bottom:8px;padding-bottom:7px;border-bottom:1px solid var(--border)}
.ecosystem-component-group>header h4{margin:0;font-size:12px}
.ecosystem-component-group>header span{color:var(--text-dim);font-size:11px}
.ecosystem-plugin-inspection{margin:0 0 8px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:#080a0f;color:var(--text-dim);font:11px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
.ecosystem-preset-row{margin-bottom:14px}
.ecosystem-preset-row label{min-width:180px}
.ecosystem-config-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px}
.ecosystem-config-field{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface2)}
.ecosystem-config-field small{color:var(--yellow);text-transform:uppercase;font-size:11px}
.ecosystem-config-field.expert{border-color:color-mix(in srgb,var(--red) 40%,var(--border))}
.ecosystem-config-field input[type=number]{width:78px}
.ecosystem-raw-config{margin-top:14px}
.ecosystem-raw-config summary{cursor:pointer;color:var(--text-dim);margin-bottom:8px}
.ecosystem-diff-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px;border-bottom:1px solid var(--border)}
.ecosystem-diff-row.advanced,.ecosystem-diff-row.expert{border-left:3px solid var(--yellow)}
.ecosystem-history{margin-top:16px}
.ecosystem-homebrew-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}
.ecosystem-homebrew-card{display:grid;grid-template-columns:56px minmax(0,1fr) auto;align-items:center;gap:11px;padding:11px;border:1px solid var(--border);border-radius:12px;background:var(--surface2)}
.ecosystem-homebrew-card img,.ecosystem-homebrew-placeholder{width:56px;height:56px;border-radius:12px;object-fit:cover}
.ecosystem-homebrew-placeholder{display:grid;place-items:center;background:color-mix(in srgb,var(--accent) 20%,var(--bg));color:var(--accent);font-weight:800}
.ecosystem-homebrew-card>div:nth-child(2){display:flex;flex-direction:column;gap:3px;min-width:0}
.ecosystem-homebrew-card span{color:var(--text-dim);font-size:11px}
.ecosystem-homebrew-card code{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ecosystem-source-form{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.ecosystem-source-form label{display:flex;flex-direction:column;gap:5px;color:var(--text-dim);font-size:12px}
.ecosystem-source-form button{align-self:end}
.ecosystem-plugin-row.conflict,.ecosystem-cheat-row.conflict{border-left:3px solid var(--yellow);padding-left:9px}
.ecosystem-filter-row{margin:12px 0;flex-wrap:wrap}
.ecosystem-filter-row .search-input{min-width:240px;flex:1}
.ecosystem-check{display:flex;align-items:center;gap:7px;color:var(--text-dim);font-size:12px}
.ecosystem-page-summary{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:8px}
.ecosystem-page-summary p{margin:0}
.ecosystem-cheat-list{max-height:65vh;overflow:auto;padding-right:4px}
.ecosystem-cheat-row{padding:10px}
.ecosystem-cheat-row small{display:block}
.ecosystem-log{max-height:62vh;overflow:auto;border:1px solid var(--border);border-radius:8px;background:#080a0f;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}
.ecosystem-log-row{display:grid;grid-template-columns:70px 100px minmax(0,1fr);gap:10px;padding:5px 8px;border-bottom:1px solid rgba(255,255,255,.05)}
.ecosystem-log-row time{color:var(--text-dim)}
.ecosystem-log-row strong{color:var(--teal)}
.ecosystem-log-row.warning strong{color:var(--yellow)}
.ecosystem-log-row.error{background:rgba(255,70,70,.08)}
.ecosystem-log-row.error strong{color:var(--red)}
.game-updates-primary-form{grid-template-columns:minmax(0,1fr)}
.game-updates-primary-form select,.game-updates-primary-form button{max-width:none;width:100%}
.ecosystem-command-grid{margin-top:16px}
.ecosystem-alert{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:11px 0;border-bottom:1px solid var(--border)}
.ecosystem-alert:last-child{border-bottom:0}
.ecosystem-alert>div:first-child{display:flex;flex-direction:column;gap:4px;min-width:0}
.ecosystem-alert>div:first-child span{color:var(--text-dim);font-size:12px;line-height:1.45}
.ecosystem-alert-critical{border-left:3px solid var(--red);padding-left:10px}
.ecosystem-alert-warning{border-left:3px solid var(--yellow);padding-left:10px}
.ecosystem-alert.acknowledged{opacity:.58}
.ecosystem-activity{max-height:420px;overflow:auto}
.ecosystem-activity-row{display:grid;grid-template-columns:130px minmax(0,1fr);gap:10px;padding:9px 0;border-bottom:1px solid var(--border)}
.ecosystem-activity-row:last-child{border-bottom:0}
.ecosystem-activity-row time{color:var(--text-dim);font-size:11px}
.ecosystem-activity-row>div{display:flex;flex-direction:column;gap:3px}
.ecosystem-activity-row span{color:var(--text-dim);font-size:12px}
.ecosystem-health-trend{height:96px;display:flex;align-items:flex-end;gap:4px;margin:0 0 12px;padding:10px 8px 20px;border:1px solid var(--border);border-radius:8px;background:linear-gradient(to top,color-mix(in srgb,var(--red) 5%,transparent),color-mix(in srgb,var(--teal) 5%,transparent))}
.ecosystem-health-trend>div{position:relative;display:flex;align-items:flex-end;flex:1;height:100%;min-width:5px}
.ecosystem-health-trend i{display:block;width:100%;min-height:3px;border-radius:3px 3px 0 0;background:linear-gradient(to top,var(--yellow),var(--teal));opacity:.8}
.ecosystem-health-trend span{position:absolute;top:calc(100% + 3px);left:50%;transform:translateX(-50%);color:var(--text-dim);font-size:11px}
.ecosystem-health-trend>div:not(:nth-last-child(-n+10)) span{display:none}
.ecosystem-plan-safety{display:flex;flex-direction:column;gap:4px;margin:4px 0 14px;padding:11px 13px;border-left:3px solid var(--teal);background:color-mix(in srgb,var(--teal) 7%,transparent)}
.ecosystem-plan-safety span{color:var(--text-dim);font-size:12px;overflow-wrap:anywhere}
.ecosystem-update-list{display:grid;gap:9px}
.ecosystem-update-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(340px,auto);align-items:center;gap:14px;padding:13px;border:1px solid var(--border);border-radius:12px;background:var(--surface2)}
.ecosystem-update-row.expert{border-color:color-mix(in srgb,var(--red) 45%,var(--border))}
.ecosystem-update-main{display:flex;flex-direction:column;gap:4px;min-width:0}
.ecosystem-update-main>div{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.ecosystem-update-main span,.ecosystem-update-main small{color:var(--text-dim);font-size:11px}
.ecosystem-update-controls{display:flex;align-items:flex-end;justify-content:flex-end;gap:7px;flex-wrap:wrap}
.ecosystem-update-controls label{display:flex;flex-direction:column;gap:4px;color:var(--text-dim);font-size:11px}
.ecosystem-update-controls input{width:132px}
.ecosystem-update-controls select{max-width:230px}
.ecosystem-discovery{display:flex;flex-direction:column;gap:6px;margin:0 0 12px;padding:10px 12px;border:1px solid color-mix(in srgb,var(--teal) 35%,var(--border));border-radius:8px;background:color-mix(in srgb,var(--teal) 6%,transparent)}
.ecosystem-discovery>div{display:flex;gap:7px;flex-wrap:wrap}
.ecosystem-discovery span{font-size:11px;color:var(--text-dim);padding:4px 7px;border:1px solid var(--border);border-radius:8px}
.ecosystem-update-group{margin:0 0 18px}
.ecosystem-update-group>header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:8px;padding:9px 11px;border-left:3px solid var(--border);background:var(--surface2)}
.ecosystem-update-group>header>div{display:flex;flex-direction:column;gap:2px}
.ecosystem-update-group>header h4{margin:0;font-size:12px}
.ecosystem-update-group>header span{color:var(--text-dim);font-size:11px}
.ecosystem-update-group>header b{display:grid;min-width:24px;height:24px;place-items:center;border-radius:12px;background:rgba(255,255,255,.06)}
.ecosystem-update-group-ready>header{border-color:var(--teal)}
.ecosystem-update-group-review>header{border-color:var(--yellow)}
.ecosystem-update-group-current>header{border-color:var(--green)}
.y2jb-catalog-picker{display:grid;grid-template-columns:minmax(180px,.8fr) minmax(280px,1.5fr) auto;align-items:center;gap:12px;margin-top:14px;padding:12px;border:1px solid var(--border);border-radius:12px;background:var(--surface2)}
.y2jb-catalog-picker>div:first-child{display:flex;flex-direction:column;gap:3px}
.y2jb-catalog-picker span{color:var(--text-dim);font-size:11px}
.y2jb-catalog-picker select{width:100%;min-height:86px}
.y2jb-catalog-actions{display:flex;flex-direction:column;gap:7px}
.ea-native-status{font-size:12px;color:var(--text-dim)}
.ea-native-status.ok{color:var(--green)}
.ea-native-status.warning{color:var(--yellow)}
.ea-native-status.error{color:var(--red)}
.ea-native-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-bottom:12px}
.ea-native-card{padding-bottom:8px}
.ea-native-path{display:flex;align-items:center;gap:10px;margin:2px 0 10px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface2)}
.ea-native-path code{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ea-native-list{display:grid;gap:6px}
.ea-native-row{display:grid;grid-template-columns:28px minmax(0,1fr) auto;align-items:center;gap:10px;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--surface2)}
.ea-native-row>div:nth-child(2){display:flex;min-width:0;flex-direction:column;gap:3px}
.ea-native-row strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ea-native-row span{color:var(--text-dim);font-size:11px}
.ea-native-icon{font-size:16px;text-align:center}
.ecosystem-repository-bridge{display:grid;grid-template-columns:minmax(240px,1fr) minmax(260px,420px) auto auto;align-items:center;gap:8px;margin:0 0 14px;padding:12px;border:1px solid color-mix(in srgb,var(--accent) 35%,var(--border));border-radius:12px;background:color-mix(in srgb,var(--accent) 6%,transparent)}
.ecosystem-repository-bridge>div{display:flex;flex-direction:column;gap:3px}
.ecosystem-repository-bridge span{color:var(--text-dim);font-size:11px;line-height:1.4}
.ecosystem-repository-bridge input{min-width:0;font:11px ui-monospace,SFMono-Regular,Consolas,monospace}
.ecosystem-readiness-card{margin-bottom:12px}
.ecosystem-readiness-flow{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
.ecosystem-readiness-step{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:start;gap:9px;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--surface2);color:var(--text);text-align:left}
.ecosystem-readiness-step:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}
.ecosystem-readiness-step>span:first-child{display:grid;place-items:center;width:24px;height:24px;border-radius:999px;background:var(--surface);color:var(--text-dim);font-weight:800}
.ecosystem-readiness-step>span:nth-child(2){display:flex;flex-direction:column;gap:3px;min-width:0}
.ecosystem-readiness-step small,.ecosystem-readiness-step em{color:var(--text-dim);line-height:1.35}
.ecosystem-readiness-step em{font-size:11px}
.ecosystem-readiness-step>b{color:var(--text-dim);font-size:11px;text-transform:uppercase}
.ecosystem-readiness-step.ready{border-color:color-mix(in srgb,var(--teal) 45%,var(--border))}
.ecosystem-readiness-step.ready>span:first-child{background:color-mix(in srgb,var(--teal) 18%,var(--surface));color:var(--teal)}
.ecosystem-readiness-step.attention>span:first-child{background:color-mix(in srgb,var(--yellow) 16%,var(--surface));color:var(--yellow)}
.ecosystem-readiness-step.not-configured,.ecosystem-readiness-step.optional{opacity:.78}
@media(max-width:1000px){.ecosystem-two-column{grid-template-columns:1fr}
.ecosystem-component-list{grid-template-columns:1fr}
.ecosystem-update-row,.ecosystem-repository-bridge{grid-template-columns:1fr}
.ecosystem-update-controls{justify-content:flex-start}
.ecosystem-readiness-flow{grid-template-columns:repeat(2,minmax(0,1fr))}
.y2jb-catalog-picker{grid-template-columns:1fr}
.y2jb-catalog-actions{flex-direction:row}
}
@media(max-width:700px){.ecosystem-header,.ecosystem-card-header{align-items:flex-start;flex-direction:column}
.ecosystem-source-form{grid-template-columns:1fr}
.ecosystem-service-row,.ecosystem-source-row,.ecosystem-plugin-row,.ecosystem-save-row,.ecosystem-vault-row,.ecosystem-cheat-row,.ecosystem-alert{align-items:flex-start;flex-direction:column}
.ecosystem-activity-row{grid-template-columns:1fr}
.ecosystem-update-controls{align-items:stretch;flex-direction:column}
.ecosystem-update-controls input,.ecosystem-update-controls select,.ecosystem-update-controls button,.ecosystem-repository-bridge input,.ecosystem-repository-bridge button{max-width:none;width:100%}
.ecosystem-homebrew-card{grid-template-columns:48px 1fr}
.ecosystem-homebrew-card img,.ecosystem-homebrew-placeholder{width:48px;height:48px}
.ecosystem-homebrew-card button{grid-column:1/-1;width:100%}
.ecosystem-log-row{grid-template-columns:60px 80px minmax(0,1fr)}
.ecosystem-readiness-flow{grid-template-columns:1fr}
.ecosystem-component-summary{grid-template-columns:repeat(2,minmax(0,1fr))}
.ea-native-summary{grid-template-columns:1fr}
.ea-native-row{grid-template-columns:26px minmax(0,1fr)}
.ea-native-row>.ecosystem-inline-actions{grid-column:1/-1;justify-content:flex-end}
}
.remote-play-page{display:grid!important;grid-template-columns:minmax(0,1fr);gap:16px;min-width:0}
.remote-play-page[hidden]{display:none!important}
.remote-play-page>.card,.remote-play-page>.remote-play-columns,.remote-play-columns>.card{min-width:0}
.remote-play-hero{position:relative;overflow:hidden;background:radial-gradient(circle at 90% -30%,color-mix(in srgb,var(--teal) 24%,transparent),transparent 44%),linear-gradient(145deg,var(--surface),color-mix(in srgb,var(--surface2) 92%,var(--teal)))}
.remote-play-hero:after{content:"";position:absolute;right:-70px;bottom:-110px;width:250px;height:250px;border:1px solid color-mix(in srgb,var(--teal) 22%,transparent);border-radius:50%;pointer-events:none}
.remote-play-kicker{display:block;margin-bottom:5px;color:var(--teal);font-size:11px;font-weight:800;letter-spacing:.14em;text-transform:uppercase}
.remote-play-readiness{position:relative;z-index:1;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:18px 0}
.remote-play-check{display:flex;align-items:center;gap:10px;min-width:0;padding:12px;border:1px solid var(--border);border-radius:12px;background:color-mix(in srgb,var(--surface2) 84%,transparent)}
.remote-play-check-dot{width:10px;height:10px;flex:0 0 auto;border-radius:50%;background:var(--text-dim);box-shadow:0 0 0 4px color-mix(in srgb,var(--text-dim) 12%,transparent)}
.remote-play-check.ready{border-color:color-mix(in srgb,var(--teal) 38%,var(--border))}
.remote-play-check.ready .remote-play-check-dot{background:var(--teal);box-shadow:0 0 0 4px color-mix(in srgb,var(--teal) 15%,transparent)}
.remote-play-check.standby .remote-play-check-dot{background:#f4a261;box-shadow:0 0 0 4px color-mix(in srgb,#f4a261 15%,transparent)}
.remote-play-check>div{display:flex;flex-direction:column;gap:2px;min-width:0}
.remote-play-check small{overflow:hidden;color:var(--text-dim);font-size:11px;text-overflow:ellipsis;white-space:nowrap}
.remote-play-primary-actions{position:relative;z-index:1;display:flex;align-items:center;gap:9px;flex-wrap:wrap}
.remote-play-primary-actions .hint{flex:1;min-width:220px;margin-left:4px}
.remote-play-columns{align-items:start}
.remote-play-profile-form{grid-template-columns:repeat(2,minmax(0,1fr))}
.remote-play-profile-form button{align-self:end}
.remote-play-workers{display:grid;gap:2px}
.remote-play-worker-row .ecosystem-inline-actions{align-items:center;flex-direction:row}
.remote-play-page code{font-size:11px}
@media(max-width:1000px){.remote-play-readiness{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media(max-width:700px){.remote-play-readiness,.remote-play-profile-form{grid-template-columns:1fr}
.remote-play-primary-actions{align-items:stretch;flex-direction:column}
.remote-play-primary-actions .btn{width:100%}
.remote-play-primary-actions .hint{min-width:0;margin-left:0}
.remote-play-page .ecosystem-inline-actions{flex-wrap:wrap}
.remote-play-worker-row .ecosystem-inline-actions{align-items:flex-start}
}
/* ── Games library ─────────────────────────────────────────────────────────── */
.view-switch{display:inline-flex;padding:2px;border:1px solid var(--border);border-radius:8px;background:var(--surface)}
.view-switch-btn{width:30px;height:28px;border:0;border-radius:8px;background:transparent;color:var(--text-dim);cursor:pointer}
.view-switch-btn:hover,.view-switch-btn.active{background:var(--surface3);color:var(--accent)}
.game-platform-switch{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin:12px 0;padding:6px;border:1px solid var(--border);border-radius:12px;background:color-mix(in srgb,var(--surface) 82%,transparent)}
.game-platform-btn{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;padding:10px 12px;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-dim);font:600 12px/1.2 inherit;cursor:pointer;transition:background .15s ease,border-color .15s ease,color .15s ease}
.game-platform-btn:hover{background:var(--surface2);color:var(--text)}
.game-platform-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.game-platform-btn.active{border-color:color-mix(in srgb,var(--accent) 55%,var(--border));background:color-mix(in srgb,var(--accent) 12%,var(--surface2));color:var(--text)}
.game-platform-btn-ps4.active{border-color:rgba(91,147,226,.58);background:rgba(58,123,213,.12)}
.game-platform-btn-ps5.active{border-color:rgba(0,206,209,.48);background:rgba(0,206,209,.09)}
.game-platform-btn strong{display:grid;place-items:center;min-width:28px;height:22px;padding:0 7px;border-radius:999px;background:var(--surface3);color:var(--text-dim);font-size:11px}
.game-platform-btn.active strong{background:color-mix(in srgb,var(--accent) 18%,var(--surface3));color:var(--text)}
.game-platform-btn[hidden]{display:none}
.games-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:14px;margin-top:14px}
.game-card{min-width:0;overflow:hidden;border:1px solid var(--border);border-radius:12px;background:var(--surface);box-shadow:0 4px 18px rgba(0,0,0,.12);transition:transform .15s ease,border-color .15s ease,box-shadow .15s ease}
.game-card:hover,.game-card:focus-visible{transform:translateY(-2px);border-color:color-mix(in srgb,var(--accent) 60%,var(--border));box-shadow:0 8px 24px rgba(0,0,0,.2)}
.game-card-art{aspect-ratio:1/1;overflow:hidden;background:linear-gradient(145deg,var(--surface2),var(--bg))}
.game-card-cover{display:block;width:100%;height:100%;object-fit:cover}
.game-card-cover-empty{display:grid;place-items:center;padding:18px;color:var(--text-dim);background:radial-gradient(circle at 25% 15%,rgba(164,112,255,.18),transparent 34%),linear-gradient(145deg,var(--surface3),var(--bg))}
.game-card-cover-empty span{font:600 12px/1.4 var(--mono,monospace);text-align:center;word-break:break-all}
.game-card-body{display:grid;gap:9px;padding:12px}
.game-card-heading{display:grid;gap:4px}
.game-card-heading strong{overflow:hidden;color:var(--text);font-size:12px;text-overflow:ellipsis;white-space:nowrap}
.game-card-heading code,.game-card-meta{overflow:hidden;color:var(--text-dim);font-size:11px;text-overflow:ellipsis;white-space:nowrap}
.game-card .game-status-col{min-height:22px}
.game-card-actions{display:flex;flex-wrap:wrap;gap:6px}
.game-card-actions .btn{flex:1 1 auto}
.library-empty{display:grid;justify-items:center;gap:8px;margin:28px 0;padding:38px 24px;border:1px dashed var(--border);border-radius:12px;color:var(--text-dim);text-align:center}
.library-empty[hidden]{display:none}
.library-empty strong{color:var(--text)}
.library-empty-icon{font-size:32px;color:var(--accent)}
.library-load-more{display:flex;align-items:center;justify-content:center;gap:10px;padding:16px 0}
body[data-density="compact"] .games-grid{grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
body[data-density="compact"] .game-card-body{gap:6px;padding:9px}
@media(max-width:640px){.game-platform-switch{grid-template-columns:repeat(2,minmax(0,1fr))}
.games-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}
.game-card-actions .btn{min-width:100%}
}
/* ── Payload catalog 2.0 ───────────────────────────────────────────────────── */
.payload-expert-filter{display:inline-flex;align-items:center;gap:7px;color:var(--text-dim);font-size:11px;white-space:nowrap}
.payload-source-check-list{display:grid;gap:11px;margin-top:12px}
.payload-catalog-group{overflow:hidden;border:1px solid var(--border);border-radius:12px;background:var(--surface2)}
.payload-catalog-group>summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;cursor:pointer;list-style:none}
.payload-catalog-group>summary::-webkit-details-marker,.payload-check-source>summary::-webkit-details-marker{display:none}
.payload-catalog-group>summary>span{display:grid;gap:2px}
.payload-catalog-group>summary small{color:var(--text-dim);font-size:11px;font-weight:400}
.payload-catalog-group>summary b{display:grid;place-items:center;min-width:28px;height:24px;border-radius:999px;background:var(--surface3);color:var(--text-dim);font-size:11px}
.payload-catalog-group-experimental{border-color:color-mix(in srgb,var(--yellow) 40%,var(--border))}
.payload-catalog-group-expert{border-color:color-mix(in srgb,var(--red) 46%,var(--border))}
.payload-catalog-group-body{display:grid;gap:8px;padding:0 10px 10px}
.payload-check-source{overflow:hidden;border:1px solid var(--border);border-radius:8px;background:var(--surface)}
.payload-check-source-header{display:flex;align-items:center;gap:8px;padding:10px 11px;cursor:pointer;list-style:none}
.payload-check-source-header:before{content:"›";color:var(--text-dim);font-size:18px;line-height:1;transition:transform .15s}
.payload-check-source[open]>.payload-check-source-header:before{transform:rotate(90deg)}
.payload-check-source-name{min-width:120px;font-weight:650}
.payload-source-count{display:grid;place-items:center;min-width:22px;height:20px;border-radius:999px;background:var(--surface3);color:var(--text-dim);font-size:11px}
.payload-checked-at{margin-left:auto;font-size:11px}
.payload-source-meta{display:flex;gap:5px}
.payload-source-body{padding:0 12px 12px}
.payload-source-error{margin:4px 0;color:var(--red)}
.payload-asset-scroll{overflow-x:auto}
@media(max-width:760px){.payload-check-source-header{align-items:flex-start;flex-wrap:wrap}
.payload-checked-at{width:100%;margin-left:28px}
.payload-source-meta{margin-left:auto}
.payload-catalog-toolbar>*{max-width:none;width:100%}
.payload-expert-filter{width:auto}
}
+13
View File
@@ -0,0 +1,13 @@
:root{--premium-line:rgba(255, 255, 255, 0.085)}body::before{position:fixed;inset:var(--titlebar-h) 0 var(--statusbar-h) var(--sidebar-w);z-index:-1;pointer-events:none;content:"";background:radial-gradient(circle at 82% 4%,rgba(var(--accent-rgb),.1),transparent 28rem),radial-gradient(circle at 12% 92%,rgba(0,210,214,.055),transparent 24rem),linear-gradient(rgba(255,255,255,.011) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.009) 1px,transparent 1px);background-size:auto,auto,48px 48px,48px 48px;mask-image:linear-gradient(#000,rgba(0,0,0,0.35))}.card,.game-card,.stat-card,.table-wrap{border-color:var(--premium-line);box-shadow:inset 0 1px 0 rgba(255,255,255,.045),0 10px 34px rgba(0,0,0,.14)}.card,.stat-card{background-image:linear-gradient(145deg,rgba(255,255,255,.026),transparent 42%)}.btn,.chimera-route-link,.chimera-toolbar-button{box-shadow:inset 0 1px 0 rgba(255,255,255,.055)}.btn:focus-visible,.chimera-domain-button:focus-visible,.chimera-route-link:focus-visible,.game-card:focus-visible{outline:2px solid rgba(var(--accent-rgb),.9);outline-offset:3px;box-shadow:0 0 0 5px rgba(var(--accent-rgb),.1)}.game-card-cover-fallback{position:absolute;inset:0;display:flex;min-height:100%;flex-direction:column;justify-content:flex-end;gap:6px;padding:18px;color:#fff;background:radial-gradient(circle at 78% 18%,rgba(0,218,198,.3),transparent 38%),radial-gradient(circle at 16% 82%,rgba(var(--accent-rgb),.43),transparent 43%),linear-gradient(145deg,#17111f,#090b12)}.game-card-cover-fallback[hidden]{display:none}.game-card-cover-fallback.is-visible{position:relative;aspect-ratio:2/3}.game-card-cover-fallback::before{position:absolute;top:18%;right:-16%;width:80%;aspect-ratio:1;border:1px solid rgba(255,255,255,.14);border-radius:30%;content:"";transform:rotate(34deg)}.game-card-cover-fallback span{color:var(--teal);font:750 11px/1 var(--mono);letter-spacing:.16em;text-transform:uppercase}.game-card-cover-fallback strong{position:relative;max-width:15ch;font-size:18px;line-height:1.08}.game-card-cover-fallback small{color:rgba(255,255,255,.6);font:650 11px/1 var(--mono);letter-spacing:.08em}.game-card-cover.is-fallback-artwork{object-fit:contain!important;padding:9%!important;background:radial-gradient(circle at 50% 35%,rgba(var(--accent-rgb),.22),transparent 55%),#0d0b12}.game-row-icon.is-fallback-artwork{width:36px;height:36px;padding:0!important;border-radius:8px;object-fit:cover!important}.game-card-sync{backdrop-filter:blur(12px);box-shadow:0 5px 18px rgba(0,0,0,.28)}.chimera-shell-v2 #chimera-route-title{letter-spacing:-.035em;text-wrap:balance}.chimera-shell-v2 #chimera-route-header{padding-bottom:18px;border-bottom-color:var(--premium-line)}body.chimera-shell-v2 #page-dashboard>.dashboard-connection-card,body.chimera-shell-v2 #page-trophies>.trophy-page-header{display:none}.chimera-import-launcher{position:static!important;right:auto!important;bottom:auto!important;width:calc(100% - 24px);min-height:42px;margin:8px 12px 2px;padding:8px 12px;border-color:rgba(203,186,255,.3);box-shadow:inset 0 1px rgba(255,255,255,.1)}@media (max-width:900px){body::before{left:0}}.premium-project-illustration{--project-color:var(--accent);--project-rgb:118,81,230;position:relative;isolation:isolate;width:min(360px,92%);height:190px;margin:0 auto 10px;overflow:hidden;contain:layout paint style;transform:rotateX(var(--illustration-y,0)) rotateY(var(--illustration-x,0));transition:transform .18s cubic-bezier(.2, .8, .2, 1)}.premium-project-illustration::before{position:absolute;inset:20px 9px;z-index:-1;content:"";background-image:linear-gradient(rgba(var(--project-rgb),.045) 1px,transparent 1px),linear-gradient(90deg,rgba(var(--project-rgb),.045) 1px,transparent 1px);background-size:18px 18px;border:1px solid rgba(var(--project-rgb),.08);border-radius:50%;mask-image:radial-gradient(#000 25%,transparent 74%)}.premium-project-illustration[data-variant=transfer]{--project-color:var(--teal);--project-rgb:34,199,232}.premium-project-illustration[data-variant=protection]{--project-color:var(--green);--project-rgb:78,222,163}.premium-project-illustration[data-variant=media]{--project-color:#e4a4ff;--project-rgb:228,164,255}.premium-project-illustration[data-variant=trophies]{--project-color:var(--yellow);--project-rgb:228,173,85}.premium-project-illustration svg{position:absolute;inset:0;width:100%;height:100%;filter:drop-shadow(0 18px 24px rgba(0, 0, 0, .26))}.premium-project-glow{position:absolute;inset:23% 27%;z-index:-1;background:rgba(var(--project-rgb),.16);border-radius:50%;filter:blur(30px)}.premium-project-path{fill:none;stroke:rgba(var(--project-rgb),0.32);stroke-width:1.15;stroke-dasharray:4 8;vector-effect:non-scaling-stroke}.premium-project-node>:first-child,.premium-project-source>:first-child,.premium-project-surface>:first-child{fill:rgba(15,11,20,0.92);stroke:rgba(var(--project-rgb),0.72);stroke-width:1.4}.premium-project-node>:not(:first-child),.premium-project-progress>*,.premium-project-shield>*,.premLine truncated
"M 37 95 H 114 C 135 95 142 65 163 65 H 190 C 214 65 219 96 239 96 H 288"
)}.premium-project-illustration[data-variant=protection] .packet-b{offset-path:path(
"M 67 131 H 132 C 150 131 157 110 174 110 H 209 C 226 110 232 131 252 131 H 288"
)}.premium-project-illustration[data-variant=transfer] .packet-a,.premium-project-illustration[data-variant=transfer] .packet-c{offset-path:path(
"M 70 87 H 135 C 156 87 161 60 182 60 H 210 C 232 60 237 87 257 87 H 285"
)}.premium-project-illustration[data-variant=transfer] .packet-b{offset-path:path(
"M 70 119 H 131 C 153 119 162 144 184 144 H 214 C 235 144 243 119 261 119 H 285"
)}.premium-project-illustration[data-variant=media] .packet-a,.premium-project-illustration[data-variant=media] .packet-c{offset-path:path(
"M 87 90 H 143 C 164 90 167 62 189 62 H 212 C 233 62 237 90 256 90 H 293"
)}.premium-project-illustration[data-variant=media] .packet-b{offset-path:path(
"M 86 120 H 141 C 163 120 168 142 188 142 H 214 C 234 142 239 120 258 120 H 293"
)}.premium-project-illustration[data-variant=trophies] .packet-a,.premium-project-illustration[data-variant=trophies] .packet-c{offset-path:path("M 50 126 C 89 54 130 46 169 81 S 234 121 280 56")}.premium-project-illustration[data-variant=trophies] .packet-b{offset-path:path("M 48 66 C 91 71 114 89 139 125 S 219 157 278 129")}.premium-project-illustration.is-active:not(.is-paused) .premium-project-path{animation:7s linear infinite project-flow}.premium-project-illustration.is-active:not(.is-paused) .premium-project-packet{animation:4.8s cubic-bezier(.4,0,.2,1) infinite project-packet}.premium-project-illustration.is-active:not(.is-paused) .packet-b{animation-delay:-1.6s}.premium-project-illustration.is-active:not(.is-paused) .packet-c{animation-delay:-3.2s}.premium-project-illustration.is-active:not(.is-paused) :is(.library-proof,.premium-project-shield,.media-play,.trophy-earned){animation:3.2s ease-in-out infinite project-confirm;transform-origin:center}.premium-project-illustration.is-active:not(.is-paused) .premium-project-progress path:last-child{animation:3.6s ease-in-out infinite project-progress;transform-origin:left}.premium-project-illustration.is-paused *,.premium-topology-motion.is-paused *{animation-play-state:paused!important}.premium-topology-motion.is-active:not(.is-paused) .chimera-home-map-lines path{animation:8s linear infinite topology-flow}.premium-topology-motion.is-active:not(.is-paused) .chimera-home-map-lines path:nth-child(2n){animation-direction:reverse;animation-duration:10s}@keyframes project-flow{to{stroke-dashoffset:-96}}@keyframes project-packet{0%{offset-distance:0;opacity:0}12%,86%{opacity:1}100%{offset-distance:100%;opacity:0}}@keyframes project-confirm{50%{filter:drop-shadow(0 0 7px rgba(var(--project-rgb), .82));transform:scale(1.04)}}@keyframes project-progress{0%,20%{transform:scaleX(.18)}100%,70%{transform:scaleX(1)}}@keyframes topology-flow{to{stroke-dashoffset:-120}}.empty-msg:has(.premium-project-illustration),.library-empty:has(.premium-project-illustration){min-height:430px}.library-empty:has(.premium-project-illustration)>.library-empty-icon{display:none}.trophy-library-empty:has(.premium-project-illustration){min-height:380px}.trophy-library-empty:has(.premium-project-illustration)>.trophy-library-empty-icon{display:none}.empty-msg .premium-project-illustration+*,.library-empty .premium-project-illustration+*{margin-top:0}@media (min-width:701px) and (max-height:820px){.premium-project-illustration{height:152px;margin-bottom:2px}.empty-msg:has(.premium-project-illustration),.library-empty:has(.premium-project-illustration){min-height:340px}.trophy-library-empty:has(.premium-project-illustration){min-height:320px}}@media (max-width:560px){body.chimera-shell-v2[data-chimera-route="protection/trophies"] #chimera-route-refresh{display:inline-flex}body.chimera-shell-v2[data-chimera-route="protection/trophies"] #chimera-route-refresh::before{content:"↻";font-size:18px;line-height:1}.premium-project-illustration{width:min(330px,100%);height:158px}.empty-msg:has(.premium-project-illustration),.library-empty:has(.premium-project-illustration){min-height:330px}.trophy-library-empty:has(.premium-project-illustration){min-height:330px}}@media (prefers-reduced-motion:reduce){.premium-project-illustration{transform:none;transition:none}.premium-project-illustration *,.premium-topology-motion *{animation:none!important}.premium-project-packet{offset-distance:72%;opacity:.7}}
+1
View File
@@ -0,0 +1 @@
.protection-copy-card{align-items:stretch;display:grid;gap:14px;grid-template-columns:minmax(190px,1.15fr) minmax(280px,1.85fr) auto;}.protection-copy-card.is-missing{border-color:rgba(240,190,99,.5);}.protection-copy-card.has-integrity-failure{border-color:rgba(239,140,131,.55);}.protection-copy-title{align-items:center;display:flex;gap:11px;min-width:0;}.protection-copy-title .backup-card-icon{flex:0 0 auto;}.protection-copy-title-copy{min-width:0;}.protection-copy-title-copy strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.protection-copy-title-copy code{color:var(--text-dim);font-size:11px;}.protection-copy-facts{display:grid;gap:7px 14px;grid-template-columns:repeat(2,minmax(0,1fr));}.protection-copy-fact{min-width:0;}.protection-copy-fact span{color:var(--text-dim);display:block;font-size:11px;letter-spacing:.06em;margin-bottom:2px;text-transform:uppercase;}.protection-copy-fact code,.protection-copy-fact strong{display:block;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.protection-device-label{max-width:100%;overflow:hidden;padding:0;color:var(--text);background:none;border:0;border-bottom:1px dashed var(--text-dim);font:inherit;font-size:12px;text-align:left;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;}.protection-device-label:hover,.protection-device-label:focus-visible{color:var(--teal);border-bottom-color:currentColor;}.protection-copy-actions{align-items:flex-end;display:flex;flex-direction:column;gap:8px;justify-content:center;}.protection-copy-policy{border-radius:999px;font-size:11px;font-weight:700;padding:5px 8px;white-space:nowrap;}.protection-copy-policy.is-healthy{background:rgba(36,137,88,.15);color:#72d6a2;}.protection-copy-policy.needs-diversity{background:rgba(169,108,21,.15);color:#f0be63;}.protection-copy-policy.is-degraded{background:rgba(167,54,48,.14);color:#ef8c83;}@media (max-width:860px){.protection-copy-card{grid-template-columns:1fr;}.protection-copy-actions{align-items:stretch;}}@media (max-width:520px){.protection-copy-facts{grid-template-columns:1fr;}}
+2
View File
@@ -0,0 +1,2 @@
.ps4-package-dialog{width:min(940px,calc(100vw - 32px));max-height:min(850px,calc(100vh - 32px));overflow:auto;background:linear-gradient(145deg,rgba(25,20,35,.98),rgba(12,10,18,.99));border:1px solid rgba(181,135,255,.26);box-shadow:0 32px 100px rgba(0,0,0,.62),0 0 80px rgba(117,66,220,.1)}
.ps4-package-header{position:relative;gap:16px;padding:22px 24px;border-bottom:1px solid rgba(255,255,255,.08);overflow:hidden}.ps4-package-header:after{content:"";position:absolute;inset:-80px -20% auto 42%;height:180px;background:radial-gradient(circle,rgba(128,75,235,.22),transparent 68%);pointer-events:none}.ps4-package-emblem{position:relative;display:grid;place-items:center;width:58px;height:58px;border:1px solid rgba(194,158,255,.42);border-radius:16px;background:linear-gradient(145deg,rgba(154,102,255,.2),rgba(57,37,86,.16));box-shadow:inset 0 1px rgba(255,255,255,.16),0 12px 34px rgba(93,51,174,.24)}.ps4-package-emblem span{font:700 27px/1 var(--font-display,inherit);color:#e8dcff}.ps4-package-emblem i{position:absolute;inset:7px;border:1px solid rgba(255,255,255,.09);border-radius:12px;transform:rotate(8deg)}.ps4-package-kicker{display:block;color:#af82ff;font-size:11px;font-weight:800;letter-spacing:.16em;text-transform:uppercase;margin-bottom:4px}.ps4-package-header h3{margin:0;font-size:22px}.ps4-package-header p{margin:4px 0 0}.ps4-package-body{padding:22px 24px}.ps4-package-phasebar{display:grid;grid-template-columns:repeat(4,1fr);margin:0 0 18px}.ps4-package-phasebar span{position:relative;display:flex;align-items:center;gap:8px;color:var(--text-dim);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}.ps4-package-phasebar span:not(:last-child):after{content:"";height:1px;flex:1;background:linear-gradient(90deg,rgba(170,124,255,.55),rgba(255,255,255,.08));margin-right:10px}.ps4-package-phasebar b{display:grid;place-items:center;width:25px;height:25px;border:1px solid rgba(171,126,255,.36);border-radius:50%;color:#d8c1ff;background:rgba(137,85,233,.12)}.ps4-package-phasebar i{font-style:normal}.ps4-package-readiness{min-height:44px;padding:11px 14px;margin-bottom:16px;border-radius:12px;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.025);color:var(--text-dim)}.ps4-package-readiness[data-state="ready"]{border-color:rgba(61,213,162,.28);color:#91e8c8;background:rgba(31,146,108,.08)}.ps4-package-readiness[data-state="blocked"]{border-color:rgba(244,162,97,.3);color:#ffc28f;background:rgba(174,94,32,.08)}.ps4-package-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.ps4-package-card{position:relative;padding:20px;border:1px solid rgba(255,255,255,.09);border-radius:15px;background:linear-gradient(155deg,rgba(255,255,255,.045),rgba(255,255,255,.015));overflow:hidden}.ps4-package-card:before{content:"";position:absolute;inset:0 auto 0 0;width:2px;background:linear-gradient(#ad79ff,transparent)}.ps4-package-card-index{position:absolute;right:16px;top:12px;color:rgba(213,188,255,.13);font-size:34px;font-weight:800}.ps4-package-card h4{position:relative;margin:0 0 8px;font-size:16px}.ps4-package-card p{min-height:54px;margin:0 0 16px;color:var(--text-dim);font-size:12px;line-height:1.55}.ps4-package-card label{display:block;margin:10px 0;color:var(--text-dim);font-size:11px;font-weight:700}.ps4-package-card input,.ps4-package-card select{width:100%;min-width:0;padding:9px 10px;color:var(--text);background:rgba(0,0,0,.22);border:1px solid rgba(255,255,255,.1);border-radius:8px}.ps4-package-path{display:flex;gap:7px;margin-top:5px}.ps4-package-path input{flex:1}.ps4-package-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:18px}.ps4-package-jobs{margin-top:14px;padding:16px;border:1px solid rgba(255,255,255,.08);border-radius:13px;background:rgba(0,0,0,.13)}.ps4-package-jobs-head{display:flex;justify-content:space-between;align-items:center}.ps4-package-jobs h4{margin:0}.ps4-package-job{display:grid;grid-template-columns:1fr auto;gap:5px 16px;padding:11px 0;border-bottom:1px solid rgba(255,255,255,.06)}.ps4-package-job:last-child{border:0}.ps4-package-job small{color:var(--text-dim)}.ps4-package-job-state{align-self:center;padding:4px 8px;border:1px solid rgba(171,126,255,.25);border-radius:999px;color:#d8c1ff;font-size:11px;font-weight:800;text-transform:uppercase}.ps4-package-dialog .jb-settings-footer{justify-content:space-between}.ps4-package-dialog .jb-settings-footer p{margin:0}@media(max-width:760px){.ps4-package-grid{grid-template-columns:1fr}.ps4-package-phasebar i{display:none}.ps4-package-card p{min-height:0}.ps4-package-dialog{width:calc(100vw - 16px)}}
+88
View File
@@ -0,0 +1,88 @@
.ps5-native-package-dialog {
background: radial-gradient(circle at 82% 8%, rgba(93, 214, 255, .12), transparent 32%), radial-gradient(circle at 8% 88%, rgba(154, 124, 255, .12), transparent 30%), linear-gradient(145deg, rgba(17, 22, 34, .99), rgba(10, 11, 19, .99));
border-color: rgba(93, 214, 255, .25);
}
.ps5-native-package-emblem { border-color: rgba(154, 124, 255, .62); box-shadow: 0 0 28px rgba(154, 124, 255, .2), inset 0 0 18px rgba(93, 214, 255, .08); }
.ps5-native-package-emblem i { border-color: rgba(93, 214, 255, .62); }
.ps5-native-truthbar { display: grid; grid-template-columns: auto 1fr; gap: 12px; align-items: center; padding: 12px 14px; margin-bottom: 16px; border: 1px solid rgba(93, 214, 255, .22); border-radius:12px; background: linear-gradient(90deg, rgba(93, 214, 255, .1), rgba(154, 124, 255, .08)); color: var(--text-dim); font-size:12px; }
.ps5-native-truthbar strong { color: #d8f6ff; letter-spacing: .04em; text-transform: uppercase; }
.ps5-native-settings-card { position: relative; overflow: hidden; }
.ps5-native-settings-card::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; background: linear-gradient(#5dd6ff, #9a7cff); }
.ps5-native-settings-truth { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(154, 124, 255, .14); }
.ps5-fpkg-phasebar { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.ps5-fpkg-evidence-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 0 0 18px; }
.ps5-fpkg-evidence-grid article { display: grid; gap: 7px; min-width: 0; padding: 13px 14px; border: 1px solid rgba(255,255,255,.09); border-radius: 13px; background: rgba(4,8,16,.34); }
.ps5-fpkg-evidence-grid small { color: var(--text-dim); font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.ps5-fpkg-evidence-grid strong { overflow-wrap: anywhere; color: #cbd5e6; font-size: 12px; line-height: 1.45; }
.ps5-fpkg-evidence-grid strong[data-state="safe"], .ps5-fpkg-evidence-grid strong[data-state="ready"], .ps5-fpkg-evidence-grid strong[data-state="qualified"] { color: #64e6bd; }
.ps5-fpkg-evidence-grid strong[data-state="pending"], .ps5-fpkg-evidence-grid strong[data-state="running"] { color: #8ce9ff; }
.ps5-fpkg-evidence-grid strong[data-state="blocked"], .ps5-fpkg-evidence-grid strong[data-state="failed"] { color: #ff9da7; }
.ps5-staging-workflow { position: relative; display: grid; grid-template-columns: minmax(230px, .8fr) minmax(300px, 1.2fr); gap: 18px 24px; padding: 20px; margin: 0 0 18px; overflow: hidden; border: 1px solid rgba(154, 124, 255, .24); border-radius:16px; background: linear-gradient(125deg, rgba(30, 39, 61, .86), rgba(13, 16, 28, .92)); box-shadow: inset 0 1px rgba(255,255,255,.04), 0 18px 55px rgba(0,0,0,.18); }
.ps5-staging-workflow::after { content: ""; position: absolute; width: 260px; height: 260px; right: -120px; top: -150px; border-radius: 50%; background: rgba(93, 214, 255, .07); filter: blur(3px); pointer-events: none; }
.ps5-staging-copy { position: relative; z-index: 1; }
.ps5-staging-copy h4 { margin: 5px 0 7px; color: #eefaff; }
.ps5-staging-copy p, .ps5-staging-limit { margin: 0; color: var(--text-dim); font-size:12px; line-height: 1.55; }
.ps5-staging-illustration { display: flex; align-items: center; min-height: 76px; padding: 8px 0; }
.ps5-stage-node { position: relative; display: grid; flex: 0 0 58px; height: 58px; place-items: center; border: 1px solid rgba(93,214,255,.38); border-radius:17px; background: radial-gradient(circle at 35% 25%, rgba(93,214,255,.2), rgba(12,18,31,.95)); box-shadow: 0 0 26px rgba(93,214,255,.11), inset 0 1px rgba(255,255,255,.08); }
.ps5-stage-node.vault { border-color: rgba(154,124,255,.52); transform: rotate(45deg); }
.ps5-stage-node.vault i { transform: rotate(-45deg); }
.ps5-stage-node.package { border-color: rgba(73,226,179,.45); }
.ps5-stage-node i { font: 700 11px/1 var(--font-mono, monospace); color: #d9f8ff; letter-spacing: .08em; font-style: normal; }
.ps5-stage-track { position: relative; flex: 1; height: 1px; overflow: visible; background: linear-gradient(90deg, rgba(93,214,255,.28), rgba(154,124,255,.5)); }
.ps5-stage-track.build { background: linear-gradient(90deg, rgba(154,124,255,.5), rgba(73,226,179,.35)); }
.ps5-stage-track i { position: absolute; width: 7px; height: 7px; top: -3px; border-radius: 50%; background: #8ce9ff; box-shadow: 0 0 12px #5dd6ff; animation: ps5-stage-pulse 2.2s ease-in-out infinite; }
.ps5-stage-track.build i { animation-delay: 1.1s; background: #a994ff; box-shadow: 0 0 12px #9a7cff; }
@keyframes ps5-stage-pulse { 0% { left: 0; opacity: 0; } 18% { opacity: 1; } 82% { opacity: 1; } 100% { left: calc(100% - 7px); opacity: 0; } }
.ps5-staging-controls { display: flex; gap: 9px; align-items: center; justify-content: flex-end; z-index: 1; }
.ps5-staging-components { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 9px; }
.ps5-staging-component { display: grid; grid-template-columns: auto 1fr auto; gap: 9px; align-items: center; min-width: 0; padding: 11px 12px; border: 1px solid rgba(255,255,255,.09); border-radius:12px; background: rgba(4,8,16,.34); }
.ps5-staging-component input { accent-color: #7cddff; }
.ps5-staging-component > span { display: grid; min-width: 0; gap: 4px; }
.ps5-staging-component strong { font-size:12px; text-transform: uppercase; letter-spacing: .06em; }
.ps5-staging-component small { overflow: hidden; color: var(--text-dim); text-overflow: ellipsis; white-space: nowrap; }
.ps5-staging-component em { padding: 3px 6px; border-radius:99px; background: rgba(93,214,255,.09); color: #bcefff; font-size:11px; font-style: normal; text-transform: uppercase; }
.ps5-staging-limit { grid-column: 1 / -1; padding-top: 12px; border-top: 1px solid rgba(255,255,255,.07); }
.ps5-package-comparison { display: grid; gap: 14px; margin: 0 0 18px; padding: 18px; border: 1px solid rgba(154,124,255,.24); border-radius:16px; background: linear-gradient(135deg, rgba(23,29,47,.86), rgba(10,13,23,.94)); }
.ps5-package-comparison h4 { margin: 5px 0 6px; }
.ps5-package-comparison p { margin: 0; color: var(--text-dim); font-size:12px; line-height: 1.5; }
.ps5-package-comparison-controls { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr) auto; gap: 10px; align-items: end; }
.ps5-package-comparison-controls label { display: grid; min-width: 0; gap: 6px; color: var(--text-dim); font-size:11px; }
.ps5-package-comparison-controls select { width: 100%; min-width: 0; }
.ps5-package-comparison-controls .btn { white-space: nowrap; }
.ps5-package-comparison-result { min-height: 56px; padding: 13px; border: 1px solid rgba(93,214,255,.16); border-radius:12px; background: rgba(4,8,16,.34); }
.ps5-package-comparison-trust { display: grid; grid-template-columns: 1fr auto 1fr; gap: 12px; align-items: center; margin-bottom: 10px; }
.ps5-package-comparison-trust span { display: grid; gap: 3px; }
.ps5-package-comparison-trust span:last-child { text-align: right; }
.ps5-package-comparison-trust small { color: var(--text-dim); }
.ps5-package-comparison-trust strong[data-trust="post-scan-proven"] { color: #64e6bd; }
.ps5-package-comparison-trust strong[data-trust="appinst-rejected"] { color: #ff8f9b; }
.ps5-package-comparison-result ul { display: grid; gap: 6px; margin: 0 0 10px; padding: 0; list-style: none; }
.ps5-package-comparison-result li { display: flex; justify-content: space-between; gap: 12px; font-size:11px; }
.ps5-package-comparison-result li span { color: var(--text-dim); }
.ps5-upstream-control { position: relative; display: grid; gap: 15px; margin: 0 0 18px; padding: 19px; overflow: hidden; border: 1px solid rgba(73,226,179,.24); border-radius:16px; background: radial-gradient(circle at 92% 5%, rgba(73,226,179,.13), transparent 34%), linear-gradient(135deg, rgba(17,32,40,.88), rgba(9,14,24,.95)); box-shadow: inset 0 1px rgba(255,255,255,.04), 0 16px 48px rgba(0,0,0,.16); }
.ps5-upstream-control::after { content: "UPSTREAM / STOCK"; position: absolute; right: 18px; top: 17px; color: rgba(157,247,220,.24); font: 700 11px/1 var(--font-mono, monospace); letter-spacing: .14em; pointer-events: none; }
.ps5-upstream-control-copy { display: flex; gap: 12px; align-items: flex-start; }
.ps5-upstream-control-copy h4 { margin: 5px 0 6px; color: #ecfff9; }
.ps5-upstream-control-copy p { max-width: 720px; margin: 0; color: var(--text-dim); font-size:12px; line-height: 1.55; }
.ps5-upstream-control-proof { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 9px; }
.ps5-upstream-control-proof span { display: grid; gap: 4px; padding: 11px 12px; border: 1px solid rgba(73,226,179,.13); border-radius:12px; background: rgba(3,12,17,.38); }
.ps5-upstream-control-proof small { color: var(--text-dim); font-size:11px; letter-spacing: .08em; text-transform: uppercase; }
.ps5-upstream-control-proof strong { overflow: hidden; color: #dffbf3; font: 600 11px/1.3 var(--font-mono, monospace); text-overflow: ellipsis; white-space: nowrap; }
.ps5-upstream-control-proof strong[data-trust="post-scan-proven"] { color: #64e6bd; }
.ps5-upstream-control-status { min-height: 20px; color: #bceee0; font-size:11px; }
.ps5-upstream-control-actions { display: flex; flex-wrap: wrap; gap: 9px; }
.ps5-p15-r2-qualification::after { content: "R4 / READ ONLY"; }
.ps5-p15-r2-matrix { width: 100%; border-collapse: collapse; color: var(--text-dim); font-size: 11px; }
.ps5-p15-r2-matrix th, .ps5-p15-r2-matrix td { padding: 7px 9px; border-bottom: 1px solid rgba(73,226,179,.12); text-align: left; }
.ps5-p15-r2-matrix td[data-result="PASS"] { color: #64e6bd; font-weight: 700; }
.ps5-p15-r2-matrix td[data-result="FAIL"] { color: #ff8f9b; font-weight: 700; }
.ps5-p15-r2-kstuff-evidence { display: block; max-width: 72ch; color: inherit; overflow-wrap: anywhere; white-space: normal; }
.ps5-launch-reference { border-color: rgba(93,214,255,.28); background: radial-gradient(circle at 92% 5%, rgba(93,214,255,.14), transparent 34%), linear-gradient(135deg, rgba(17,27,44,.9), rgba(9,14,24,.95)); }
.ps5-launch-reference::after { content: "LAUNCH / REFERENCE"; color: rgba(157,225,247,.28); }
.ps5-launch-reference .ps5-upstream-control-proof span { border-color: rgba(93,214,255,.15); }
@media (prefers-reduced-motion: reduce) { .ps5-stage-track i { animation: none; left: 50%; } }
@media (max-width: 760px) { .ps5-staging-workflow { grid-template-columns: 1fr; } .ps5-staging-controls { justify-content: flex-start; } .ps5-staging-components { grid-template-columns: 1fr; } }
@media (max-width: 760px) { .ps5-package-comparison-controls { grid-template-columns: 1fr; } }
@media (max-width: 760px) { .ps5-fpkg-phasebar { grid-template-columns: repeat(3, minmax(0, 1fr)); } .ps5-fpkg-evidence-grid { grid-template-columns: 1fr; } }
@media (max-width: 620px) { .ps5-upstream-control-proof { grid-template-columns: 1fr; } }
@media (max-width: 620px) { .ps5-native-truthbar { grid-template-columns: 1fr; } }
+12
View File
@@ -0,0 +1,12 @@
.ps5-profile-workspace{position:relative;margin-bottom:16px;overflow:hidden;border:1px solid rgba(var(--accent-rgb),.26);border-radius:16px;background:linear-gradient(145deg,rgba(var(--accent-rgb),.09),rgba(9,10,17,.78) 38%,rgba(0,203,190,.04));box-shadow:0 26px 70px rgba(0,0,0,.28),inset 0 1px rgba(255,255,255,.06)}
.ps5-profile-workspace::before{position:absolute;inset:0;pointer-events:none;content:"";background-image:linear-gradient(rgba(255,255,255,.018) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.014) 1px,transparent 1px);background-size:38px 38px;mask-image:linear-gradient(#000,transparent 75%)}
.ps5-profile-hero{position:relative;display:grid;grid-template-columns:130px minmax(0,1fr) auto;align-items:center;gap:24px;padding:26px 28px;border-bottom:1px solid rgba(255,255,255,.07)}
.ps5-profile-orbit{position:relative;width:112px;aspect-ratio:1;border:1px solid rgba(var(--accent-rgb),.35);border-radius:50%;box-shadow:inset 0 0 35px rgba(var(--accent-rgb),.12),0 0 40px rgba(var(--accent-rgb),.1)}
.ps5-profile-orbit::before,.ps5-profile-orbit::after{position:absolute;inset:15px;border:1px solid rgba(0,210,214,.25);border-radius:42% 58% 55% 45%;content:"";animation:profile-orbit 12s linear infinite}.ps5-profile-orbit::after{inset:31px;animation-direction:reverse;animation-duration:8s}.ps5-profile-orbit span{position:absolute;inset:0;display:grid;place-items:center;color:var(--teal);font-size:28px;text-shadow:0 0 18px var(--teal)}.ps5-profile-orbit i{position:absolute;z-index:1;width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px var(--accent)}.ps5-profile-orbit i:nth-child(1){top:8px;left:51px}.ps5-profile-orbit i:nth-child(2){right:9px;bottom:27px;background:var(--teal)}.ps5-profile-orbit i:nth-child(3){bottom:9px;left:28px}
@keyframes profile-orbit{to{transform:rotate(1turn)}}
.ps5-profile-kicker,.ps5-profile-panel-head span,.ps5-profile-recovery>div:first-child>span{color:var(--teal);font:750 11px/1.2 var(--mono);letter-spacing:.16em;text-transform:uppercase}.ps5-profile-hero h2{margin:5px 0 7px;font-size:25px;letter-spacing:-.035em}.ps5-profile-hero p{max-width:720px;margin:0;color:var(--text-dim);font-size:12px;line-height:1.55}.ps5-profile-boundary{display:flex;gap:7px;align-items:center;margin-top:11px;color:var(--green);font-size:11px}.ps5-profile-provider-state{max-width:200px;padding:9px 12px;border:1px solid var(--border);border-radius:999px;color:var(--text-dim);background:rgba(0,0,0,.22);font-size:11px;text-align:center}.ps5-profile-provider-state.is-ready{border-color:rgba(70,220,160,.42);color:var(--green)}.ps5-profile-provider-state.is-blocked{border-color:rgba(240,175,70,.38);color:var(--yellow)}
.ps5-profile-runtime{position:relative;display:grid;grid-template-columns:50px minmax(0,1fr) auto;align-items:center;gap:15px;padding:13px 28px;border-bottom:1px solid rgba(255,255,255,.07);background:linear-gradient(90deg,rgba(0,210,214,.075),rgba(var(--accent-rgb),.045))}.ps5-profile-runtime-mark{display:grid;place-items:center;width:38px;aspect-ratio:1;border:1px solid rgba(0,210,214,.38);border-radius:12px;background:rgba(0,210,214,.08);box-shadow:inset 0 0 18px rgba(0,210,214,.12)}.ps5-profile-runtime-mark span{width:9px;aspect-ratio:1;border-radius:50%;background:var(--yellow);box-shadow:0 0 13px currentColor}.ps5-profile-runtime.is-ready .ps5-profile-runtime-mark span{color:var(--green);background:var(--green)}.ps5-profile-runtime h3{margin:2px 0;font-size:12px}.ps5-profile-runtime p{margin:0;color:var(--text-dim);font-size:11px}.ps5-profile-runtime-actions{display:flex;align-items:center;gap:10px}.ps5-profile-runtime-actions>span{color:var(--yellow);font:700 11px var(--mono);text-transform:uppercase}.ps5-profile-runtime.is-ready .ps5-profile-runtime-actions>span{color:var(--green)}
.ps5-profile-grid{position:relative;display:grid;grid-template-columns:minmax(260px,.72fr) minmax(380px,1.28fr);gap:1px;background:rgba(255,255,255,.07)}.ps5-profile-panel{min-width:0;padding:22px;background:rgba(12,12,20,.9)}.ps5-profile-panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:15px}.ps5-profile-panel h3,.ps5-profile-recovery h3{margin:3px 0 0;font-size:16px}.ps5-profile-users{display:grid;gap:8px}.ps5-profile-user{display:grid;grid-template-columns:42px minmax(0,1fr) auto;align-items:center;gap:11px;width:100%;padding:10px;border:1px solid var(--border);border-radius:12px;color:var(--text);background:rgba(255,255,255,.025);text-align:left;cursor:pointer;transition:.18s ease}.ps5-profile-user:hover,.ps5-profile-user.is-selected{border-color:rgba(var(--accent-rgb),.62);background:rgba(var(--accent-rgb),.1);transform:translateX(3px)}.ps5-profile-user-avatar{display:grid;place-items:center;width:42px;aspect-ratio:1;border-radius:13px;color:#fff;background:linear-gradient(145deg,var(--accent),var(--teal));font-weight:800}.ps5-profile-user strong,.ps5-profile-user small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ps5-profile-user small{margin-top:3px;color:var(--text-dim);font:11px var(--mono)}.ps5-profile-user em{color:var(--green);font:700 11px var(--mono);font-style:normal;text-transform:uppercase}.ps5-profile-uid{color:var(--text-dim);font:11px var(--mono)}
.ps5-profile-avatar-stage{display:grid;grid-template-columns:132px 1fr;align-items:center;gap:20px;padding:17px;border:1px solid var(--border);border-radius:16px;background:linear-gradient(120deg,rgba(var(--accent-rgb),.08),rgba(255,255,255,.015))}.ps5-profile-avatar-shell{position:relative;width:116px;aspect-ratio:1;overflow:hidden;border:1px solid rgba(var(--accent-rgb),.46);border-radius:32%;background:radial-gradient(circle at 65% 20%,rgba(0,210,214,.36),transparent 42%),#17131f;box-shadow:0 15px 30px rgba(0,0,0,.26)}.ps5-profile-avatar-shell canvas{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:0}.ps5-profile-avatar-shell.has-preview canvas{opacity:1}.ps5-profile-avatar-shell.has-preview span{opacity:0}.ps5-profile-avatar-shell span{position:absolute;inset:0;display:grid;place-items:center;color:white;font-size:38px;font-weight:800}.ps5-profile-avatar-stage strong{font-size:14px}.ps5-profile-avatar-stage p{margin:5px 0 12px;color:var(--text-dim);font-size:11px;line-height:1.5}.ps5-profile-name-field{display:block;margin-top:16px;color:var(--text);font-size:11px;font-weight:700}.ps5-profile-name-field>div{display:grid;grid-template-columns:1fr auto;gap:8px;margin-top:7px}.ps5-profile-name-field input{min-width:0;padding:9px 11px;border:1px solid var(--border);border-radius:8px;color:var(--text);background:var(--bg2);font:600 12px inherit}.ps5-profile-name-field small{display:block;margin-top:7px;color:var(--text-dim);font-weight:400}.ps5-profile-action-state{min-height:18px;margin-top:11px;color:var(--text-dim);font-size:11px}.ps5-profile-action-state.is-error{color:var(--red)}.ps5-profile-action-state.is-ok{color:var(--green)}
.ps5-profile-recovery{position:relative;display:grid;grid-template-columns:minmax(220px,.55fr) minmax(360px,1.45fr);gap:24px;padding:22px 28px;background:rgba(8,9,15,.82)}.ps5-profile-recovery p{max-width:340px;margin:7px 0 0;color:var(--text-dim);font-size:11px;line-height:1.5}.ps5-profile-operations{display:grid;gap:7px}.ps5-profile-operation{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;padding:9px 11px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.025)}.ps5-profile-operation strong,.ps5-profile-operation small{display:block}.ps5-profile-operation small{margin-top:3px;color:var(--text-dim);font-size:11px}.ps5-profile-operation-status{color:var(--teal);font:700 11px var(--mono);text-transform:uppercase}.ps5-profile-empty{padding:16px;border:1px dashed var(--border);border-radius:12px;color:var(--text-dim);font-size:11px;text-align:center}
@media(max-width:900px){.ps5-profile-hero{grid-template-columns:90px 1fr}.ps5-profile-orbit{width:82px}.ps5-profile-provider-state{grid-column:1/-1;max-width:none}.ps5-profile-runtime{grid-template-columns:42px 1fr}.ps5-profile-runtime-actions{grid-column:1/-1;justify-content:space-between}.ps5-profile-grid,.ps5-profile-recovery{grid-template-columns:1fr}}@media(max-width:560px){.ps5-profile-hero{grid-template-columns:1fr;padding:20px}.ps5-profile-orbit{display:none}.ps5-profile-runtime{grid-template-columns:1fr;padding:16px 20px}.ps5-profile-runtime-mark{display:none}.ps5-profile-runtime-actions{flex-direction:column;align-items:stretch}.ps5-profile-panel,.ps5-profile-recovery{padding:18px}.ps5-profile-avatar-stage{grid-template-columns:1fr}.ps5-profile-name-field>div{grid-template-columns:1fr}.ps5-profile-operation{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){.ps5-profile-orbit::before,.ps5-profile-orbit::after{animation:none}.ps5-profile-user{transition:none}.ps5-profile-user:hover,.ps5-profile-user.is-selected{transform:none}}
+13
View File
@@ -0,0 +1,13 @@
:root{--fs-micro:11px}
:root { color-scheme: dark; font: 16px/1.5 Inter, ui-sans-serif, system-ui, sans-serif; background: #0e0d12; color: #f7f3ff; }
* { box-sizing: border-box; } [hidden] { display: none !important; } body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top, #302248 0, #121018 42%, #09090c 100%); }
.auth-shell { min-height: 100vh; display: grid; place-items: center; padding: 32px 16px; }
.auth-card { width: min(820px, 100%); padding: clamp(24px, 5vw, 48px); border: 1px solid #554567; border-radius:24px; background: rgba(20,18,27,.96); box-shadow: 0 24px 80px #0009; }
.logo { width: 72px; height: 72px; object-fit: contain; float: right; } .eyebrow, .step { color: #c9a7ff; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; font-size: var(--fs-micro); }
h1 { margin: 0; font-size: clamp(2rem, 6vw, 3.6rem); } h2 { margin-top: 2rem; } form { display: grid; gap: 18px; margin-top: 28px; }
label { display: grid; gap: 7px; font-weight: 650; } input, select, button { min-height: 46px; border-radius:12px; border: 1px solid #625574; padding: 10px 12px; font: inherit; }
input, select { color: #fff; background: #0d0c11; } button { border: 0; color: #170d22; background: #c79aff; font-weight: 850; cursor: pointer; }
button.secondary { color: #eadcff; background: #30263d; border: 1px solid #67547c; } .grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 16px; } .grid.three { grid-template-columns: 2fr 1fr 1fr; }
.panel, fieldset { padding: 14px; border: 1px solid #493c58; border-radius:12px; background: #0e0d12; white-space: pre-line; } .check { display: flex; align-items: flex-start; gap: 8px; font-weight: 500; } .check input { min-height: auto; margin-top: 5px; }
#message { min-height: 24px; color: #d9c7ef; } #message.error { color: #ff9fa9; } #message.success { color: #93e5b1; } small { color: #aa9cb7; font-weight: 400; }
@media (max-width: 680px) { .grid, .grid.three { grid-template-columns: 1fr; } .auth-card { padding: 24px 18px; border-radius:16px; } .logo { width: 52px; height: 52px; } }
+1
View File
@@ -0,0 +1 @@
@font-face{font-family:Inter;src:url("../assets/fonts/inter-latin.woff2") format("woff2");font-style:normal;font-weight:100 900;font-display:swap}@font-face{font-family:"Space Grotesk";src:url("../assets/fonts/space-grotesk-latin.woff2") format("woff2");font-style:normal;font-weight:300 700;font-display:swap}:root{color-scheme:dark;--bg:#0d0a11;--surface:#120e17;--surface2:#18131e;--surface3:#211a29;--surface4:#2b2334;--border:#342b3d;--border-soft:rgba(244,238,251,.09);--accent:#cbbaff;--accent-d:#7651e6;--accent-rgb:118,81,230;--teal:#22c7e8;--teal-d:#159db9;--green:#2ed39a;--yellow:#e4ad55;--red:#ee6d75;--text:#f4eefb;--text-dim:#c6bbd2;--text-muted:#978ca3;--mono:"Cascadia Code","SFMono-Regular",Consolas,monospace;--ui:Inter,"Segoe UI",system-ui,sans-serif;--display:"Space Grotesk",Inter,"Segoe UI",sans-serif;--fs-micro:11px;--fs-xs:12px;--fs-sm:14px;--fs-md:16px;--fs-lg:20px;--fs-xl:28px;--fs-display:34px;--shadow-flat:none;--sidebar-w:232px;--titlebar-h:34px;--statusbar-h:28px;--radius-sm:8px;--radius:12px;--radius-lg:16px;--shadow-panel:0 16px 40px rgba(0,0,0,.22);--shadow-float:0 28px 80px rgba(0,0,0,.48);--focus-ring:0 0 0 3px rgba(118,81,230,.28)}body,html{background:radial-gradient(circle at 78% -15%,rgba(94,53,177,.13),transparent 34rem),var(--bg);color:var(--text);font-family:Inter,"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.45;background:radial-gradient(circle at 78% -12%,rgba(94,53,177,.17),transparent 32rem),#120e17}body,button,input,select,textarea{font-family:Inter,"Segoe UI",system-ui,sans-serif}#chimera-route-title,.sidebar-brand-copy strong,.stat-num,h1,h2,h3,h4{font-family:"Space Grotesk",Inter,sans-serif;letter-spacing:-.025em}::selection{color:#fff;background:rgba(94,53,177,.72)}:where(button,a,input,textarea,select,summary):focus-visible{outline:2px solid var(--accent);outline-offset:2px;box-shadow:var(--focus-ring)}input:not([type=checkbox]):not([type=radio]):not([type=range]),select,textarea{min-height:40px;padding:9px 12px;color:var(--text);background:rgba(22,18,28,.82);border:1px solid var(--border);border-radius:var(--radius-sm);font-size:12px;line-height:20px;transition:border-color .16s,background .16s,box-shadow .16s}.search-input,input[type=search]{height:40px;min-height:40px!important;padding-block:9px!important;line-height:20px!important}input::placeholder,textarea::placeholder{color:var(--text-muted);opacity:1}input:hover,select:hover,textarea:hover{border-color:#625c6c}input:focus,select:focus,textarea:focus{outline:0;border-color:var(--accent);background:#1a1620;box-shadow:var(--focus-ring)}input[type=checkbox],input[type=radio]{accent-color:var(--accent-d)}#titlebar{background:rgba(12,9,15,.97);border-bottom-color:var(--border-soft)}#titlebar-drag{font-family:"Space Grotesk",sans-serif;color:var(--text-dim);letter-spacing:.02em}#titlebar-x-link{color:var(--text-muted);border-color:var(--border-soft)}#app{background:0 0}#sidebar{position:relative;padding:18px 0 14px;border-right:1px solid var(--border-soft);background:linear-gradient(180deg,rgba(94,53,177,.1),transparent 15rem),#0d0a11}#sidebar::after{content:"";position:absolute;top:0;right:-1px;bottom:0;width:1px;pointer-events:none;background:linear-gradient(transparent,rgba(209,188,255,.18),transparent)}.sidebar-brand{gap:11px;color:var(--text)}.sidebar-brand-copy{display:flex;min-width:0;flex-direction:column;gap:1px}.sidebar-brand-copy strong{color:var(--text);line-height:1.1}.sidebar-brand-copy small{color:var(--text-muted);font-weight:650;letter-spacing:.12em;text-transform:uppercase}.chimera-nav-root{padding-inline:10px}.chimera-domain-button{font-size:12px}.chimera-domain-button svg{width:19px;height:19px}.chimera-domain-button:focus-visible,.chimera-domain-button:hover{color:var(--text);background:rgba(232,223,238,.055)}.chimera-domain-button.active,.chimera-domain.active>.chimera-domain-button{color:var(--accent);border-color:rgba(209,188,255,.16)}.chimera-domain-routes{border-left-color:rgba(209,188,255,.12)}.chimera-route-link{padding:7px 10px}#sidebar-donate{margin-top:auto;border-top-color:var(--border-soft)}body.chimera-shell-v2 #dash-conn-badge,body.chimera-shell-v2 #sidebar-conn{display:none}#content{padding:24px 28px 34px;gap:22px;scrollbar-gutter:stable;position:relative;background-image:linear-gradient(rgba(209,188,255,.018) 1px,transparent 1px),linear-gradient(90deg,rgba(209,188,255,.018) 1px,transparent 1px);background-size:32px 32px}body.chimera-shell-v2 #chimera-route-header,body.chimera-shell-v2 .page.active{width:min(100%,1600px)}#chimera-shell-toolbar{flex:0 0 auto;top:-24px;margin:-24px -28px 0;padding:8px 28px;justify-content:flex-start;gap:10px;border-bottom-color:var(--border-soft);box-shadow:0 12px 24px rgba(0,0,0,.08)}.chimera-toolbar-spacer{flex:1}.chimera-shell-health{display:inline-grid;grid-template-columns:9px auto;align-items:center;gap:10px;min-height:38px;padding:6px 11px;color:var(--text);border:1px solid var(--border-soft)}.chimera-shell-health-dot{width:8pLine truncated
+1
View File
@@ -0,0 +1 @@
.title-derived-badges{display:flex;flex-wrap:wrap;gap:7px;margin:0 0 18px}.title-badge{border:1px solid currentColor;border-radius:999px;font-size:11px;font-weight:700;padding:5px 9px}.title-workspace-grid,.title-fact-grid{display:grid;gap:10px;grid-template-columns:repeat(2,minmax(0,1fr))}.title-location-card,.title-evidence-row,.title-fact-grid>div,.title-workspace-empty{border:1px solid var(--border);border-radius:12px;padding:13px 14px}.title-location-card{display:grid;gap:7px;grid-template-columns:minmax(0,1fr) auto}.title-location-card.is-missing{border-color:#f0be63}.title-location-card h4{margin:3px 0 0;overflow-wrap:anywhere}.title-location-card p{color:var(--text-dim);font-size:12px;grid-column:1/-1;margin:0;overflow-wrap:anywhere}.title-location-kind,.title-location-state,.title-evidence-row span,.title-evidence-row time,.title-fact-grid dt{color:var(--text-dim);font-size:11px}.title-location-state{text-align:right}.title-evidence-list{display:grid;gap:9px}.title-evidence-row{display:flex;gap:14px;justify-content:space-between}.title-workspace-empty{color:var(--text-dim);text-align:center}.title-fact-grid{margin:0}.title-fact-grid dt{margin-bottom:6px}.title-fact-grid dd{margin:0}@media(max-width:680px){.title-workspace-grid,.title-fact-grid{grid-template-columns:1fr}.title-location-card{grid-template-columns:1fr}.title-location-state,.title-evidence-row span,.title-evidence-row time{text-align:left}.title-evidence-row{align-items:flex-start;flex-direction:column;gap:5px}}
+11
View File
@@ -0,0 +1,11 @@
.trophy-definition-import{margin-top:6px;padding:12px;border:1px dashed var(--border);border-radius:8px}.trophy-definition-import>summary{cursor:pointer;font-weight:700}.trophy-definition-import label,.trophy-definition-import textarea{display:block;width:100%;margin-top:10px}.trophy-definition-import textarea{box-sizing:border-box;resize:vertical;font:11px var(--mono);color:var(--text);background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px}.trophy-definition-import .btn{margin-top:10px}
.trophy-history-auth,.trophy-history-search,.trophy-history-selection{display:grid;gap:.75rem;margin-top:1rem}
.trophy-history-auth,.trophy-history-search{grid-template-columns:minmax(12rem,1fr) auto;align-items:end}
.trophy-history-auth summary{grid-column:1/-1;cursor:pointer;font-weight:700}
.trophy-history-auth label,.trophy-history-search label{grid-column:1/-1}
.trophy-history-games{display:grid;gap:.5rem;max-height:30rem;overflow:auto;padding:.25rem}
.trophy-history-game{display:flex;gap:.75rem;align-items:center;padding:.7rem;border:1px solid var(--border-color,rgba(255,255,255,.12));border-radius:.5rem}
.trophy-history-game span{display:grid;gap:.2rem}.trophy-history-game small{opacity:.72}
.trophy-history-selection>div:first-child,.trophy-history-selection>div:last-child{display:flex;gap:.75rem;align-items:center;justify-content:space-between;flex-wrap:wrap}
.trophy-history-snapshots{display:grid;grid-template-columns:auto minmax(12rem,1fr);gap:.75rem;align-items:center;margin-top:1rem}.trophy-history-detail{display:grid;gap:.5rem;margin-top:.75rem}.trophy-history-detail details{border:1px solid var(--border-color,rgba(255,255,255,.12));border-radius:.5rem;padding:.6rem}.trophy-history-detail summary{display:flex;justify-content:space-between;gap:1rem;cursor:pointer}
@media(max-width:700px){.trophy-history-auth,.trophy-history-search{grid-template-columns:1fr}}
+1
View File
@@ -0,0 +1 @@
.trophy-installed{display:grid;gap:10px}.trophy-installed-game{border:1px solid var(--border);border-radius:8px;overflow:hidden}.trophy-installed-game>summary{display:flex;justify-content:space-between;gap:12px;padding:12px;cursor:pointer;background:rgba(255,255,255,.025)}.trophy-installed-game>summary span{color:var(--text-dim);font-size:11px}.trophy-installed-game>.trophy-active-list{padding:8px}
+11
View File
@@ -0,0 +1,11 @@
.trophy-library-shell{display:grid;gap:22px;overflow:hidden;padding:22px;background:radial-gradient(circle at 8% 0%,rgba(118,81,230,.14),transparent 32%),radial-gradient(circle at 92% 18%,rgba(0,188,212,.09),transparent 28%),var(--surface)}
.trophy-library-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:22px}.trophy-library-heading>div>span,.trophy-library-unlock-review>div>span{color:var(--accent);font:700 11px var(--mono);letter-spacing:.14em;text-transform:uppercase}.trophy-library-heading h2{margin:5px 0 6px;font-size:clamp(22px,3vw,30px);letter-spacing:-.03em}.trophy-library-heading p,.trophy-library-detail-header p{max-width:650px;margin:0;color:var(--text-dim);font-size:11px;line-height:1.6}.trophy-library-totals{display:grid;grid-template-columns:auto auto;align-items:baseline;gap:2px 8px;min-width:126px;padding:12px 15px;border:1px solid rgba(255,255,255,.1);border-radius:12px;background:rgba(8,7,14,.46);box-shadow:inset 0 1px rgba(255,255,255,.04)}.trophy-library-totals strong{color:var(--teal);font-size:17px}.trophy-library-totals span{color:var(--text-dim);font-size:11px}
.trophy-library-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px}.trophy-library-game-card{position:relative;display:grid;grid-template-columns:74px minmax(0,1fr);align-items:center;gap:12px;min-height:118px;padding:10px;text-align:left;color:var(--text);background:linear-gradient(145deg,rgba(255,255,255,.055),rgba(255,255,255,.018));border:1px solid var(--border);border-radius:13px;cursor:pointer;overflow:hidden;transition:border-color .18s ease,transform .18s ease,box-shadow .18s ease}.trophy-library-game-card::after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(115deg,transparent 25%,rgba(255,255,255,.06),transparent 70%);transform:translateX(-120%);transition:transform .5s ease}.trophy-library-game-card:hover,.trophy-library-game-card:focus-visible{border-color:rgba(118,81,230,.58);transform:translateY(-2px);box-shadow:0 16px 28px rgba(0,0,0,.2)}.trophy-library-game-card:hover::after{transform:translateX(120%)}.trophy-library-game-card.is-selected{border-color:var(--accent);box-shadow:0 0 0 1px rgba(118,81,230,.25),0 16px 32px rgba(39,23,77,.25)}.trophy-library-game-card.is-live::before{content:"";position:absolute;top:9px;right:9px;width:6px;height:6px;border-radius:50%;background:var(--green);box-shadow:0 0 12px var(--green)}
.trophy-library-cover{--tone:hsl(var(--trophy-tone),68%,56%);position:relative;display:grid;place-items:center;width:68px;height:96px;border-radius:12px;overflow:hidden;background:radial-gradient(circle at 62% 28%,hsla(var(--trophy-tone),80%,68%,.6),transparent 32%),linear-gradient(155deg,hsl(var(--trophy-tone),52%,26%),#0b0910 72%);box-shadow:0 10px 20px rgba(0,0,0,.28),inset 0 1px rgba(255,255,255,.16)}.trophy-library-cover::before{content:"";position:absolute;inset:0;background:repeating-linear-gradient(120deg,transparent 0 11px,rgba(255,255,255,.035) 12px 13px)}.trophy-library-cover-mark{position:relative;z-index:1;color:#fff;font:800 21px var(--mono);letter-spacing:-.08em;text-shadow:0 4px 12px #000}.trophy-library-cover-orbit{position:absolute;width:58px;height:58px;border:1px solid hsla(var(--trophy-tone),82%,72%,.38);border-radius:50%;transform:rotateX(63deg)}.trophy-library-cover-progress{position:absolute;right:5px;bottom:5px;z-index:1;padding:3px 5px;color:#fff;background:rgba(0,0,0,.55);border-radius:999px;font:700 7px var(--mono)}.trophy-library-card-copy{display:grid;gap:6px;min-width:0}.trophy-library-card-copy>strong{display:-webkit-box;overflow:hidden;font-size:12px;line-height:1.35;-webkit-box-orient:vertical;-webkit-line-clamp:2}.trophy-library-card-copy>small{color:var(--text-dim);font-size:11px}.trophy-library-card-meta{display:flex;align-items:center;gap:5px}.trophy-library-card-meta b,.trophy-library-card-meta i{padding:3px 5px;border-radius:999px;font:700 7px var(--mono);font-style:normal}.trophy-library-card-meta b{color:#b9a9ff;background:rgba(118,81,230,.15)}.trophy-library-card-meta i{color:var(--green);background:rgba(76,175,80,.1)}.trophy-library-card-track{height:3px;overflow:hidden;background:rgba(255,255,255,.07);border-radius:999px}.trophy-library-card-track i{display:block;height:100%;background:linear-gradient(90deg,var(--accent),var(--teal));border-radius:inherit}
.trophy-library-detail{display:grid;gap:14px;padding:18px;border:1px solid rgba(255,255,255,.09);border-radius:16px;background:rgba(7,6,12,.44);box-shadow:inset 0 1px rgba(255,255,255,.035)}.trophy-library-detail-header{display:grid;grid-template-columns:82px minmax(0,1fr) auto;align-items:center;gap:16px}.trophy-library-detail-header h3{margin:3px 0 5px;font-size:21px;letter-spacing:-.02em}.trophy-library-platform{color:#b9a9ff;font:700 11px var(--mono);letter-spacing:.12em;text-transform:uppercase}.trophy-library-detail-header dl{display:flex;gap:8px;margin:0}.trophy-library-detail-header dl div{min-width:62px;padding:9px;border:1px solid var(--border);border-radius:12px;text-align:center;background:rgba(255,255,255,.025)}.trophy-library-detail-header dt{color:var(--text-dim);font-size:11px}.trophy-library-detail-header dd{margin:3px 0 0;font-size:16px;font-weight:800}.trophy-library-progress-ring{--ring-color:var(--teal);display:grid;place-items:center;width:74px;height:74px;border-radius:50%;background:radial-gradient(circle at center,#11101a 56%,transparent 58%),conic-gradient(var(--ring-color) var(--trophy-progress),rgba(255,255,255,.08) 0);box-shadow:0 0 24px rgba(0,188,212,.1)}.trophy-library-progress-ring strong{font:800 16px var(--mono)}
.trophy-library-toolbar{display:grid;grid-template-columns:auto minmax(170px,1fr) 126px auto;gap:9px;align-items:center}.trophy-library-segments{display:flex;padding:3px;background:rgba(255,255,255,.04);border:1px solid var(--border);border-radius:12px}.trophy-library-segments button{padding:7px 10px;color:var(--text-dim);background:transparent;border:0;border-radius:8px;font-size:11px;cursor:pointer}.trophy-library-segments button.is-active{color:var(--text);background:rgba(118,81,230,.2);box-shadow:0 1px 5px rgba(0,0,0,.25)}.trophy-library-search input,.trophy-library-grade select{width:100%;min-height:34px;box-sizing:border-box;color:var(--text);background:rgba(0,0,0,.22);border:1px solid var(--border);border-radius:8px;padding:7px 10px;font-size:11px}.trophy-library-hidden{display:flex;align-items:center;gap:6px;color:var(--text-dim);font-size:11px;white-space:nowrap}.trophy-library-hidden input{width:16px;height:16px}.trophy-library-list{display:grid;gap:7px}.trophy-library-row{display:grid;grid-template-columns:42px minmax(0,1fr) 150px;align-items:center;gap:11px;min-height:62px;padding:9px 11px;border:1px solid var(--border);border-radius:12px;background:linear-gradient(110deg,rgba(255,255,255,.025),rgba(255,255,255,.01));transition:border-color .16s ease,background .16s ease}.trophy-library-row:hover{border-color:rgba(255,255,255,.17);background:rgba(255,255,255,.035)}.trophy-library-row.is-earned{border-color:rgba(76,175,80,.25);background:linear-gradient(110deg,rgba(76,175,80,.06),rgba(255,255,255,.01))}.trophy-library-medal{position:relative;display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:linear-gradient(145deg,#9b672f,#4d2d18);box-shadow:inset 0 1px rgba(255,255,255,.34),0 5px 10px rgba(0,0,0,.25)}.trophy-library-medal::before{content:"";position:absolute;top:-4px;width:12px;height:10px;background:#7651e6;clip-path:polygon(0 0,100% 0,75% 100%,25% 100%)}.trophy-library-medal i{width:15px;height:15px;border:1px solid rgba(255,255,255,.5);border-radius:50%}.trophy-library-medal[data-grade=gold]{background:linear-gradient(145deg,#f4cc69,#8b5e15)}.trophy-library-medal[data-grade=silver]{background:linear-gradient(145deg,#d5dbe3,#667281)}.trophy-library-medal[data-grade=platinum]{background:linear-gradient(145deg,#b7f4ff,#7067b8)}.trophy-library-row-copy{display:grid;gap:3px;min-width:0}.trophy-library-row-copy strong{font-size:11px}.trophy-library-row-copy small{color:var(--text-dim);font-size:11px;line-height:1.45}.trophy-library-row-copy em{color:var(--text-dim);font:normal 7px var(--mono);text-transform:capitalize}.trophy-library-row-state{display:grid;justify-items:end;gap:4px;text-align:right}.trophy-library-row-state>b{color:var(--text-dim);font-size:11px}.trophy-library-row.is-earned .trophy-library-row-state>b{color:var(--green)}.trophy-library-row-state time{color:var(--text-dim);font-size:7px}.trophy-library-unlock{color:#ffd7d5;border-color:rgba(239,83,80,.35);background:rgba(239,83,80,.09)}
.trophy-library-unlock-review{display:grid;grid-template-columns:44px minmax(200px,1fr) minmax(260px,.9fr) auto auto;align-items:center;gap:8px 10px;padding:13px;border:1px solid rgba(239,83,80,.35);border-radius:12px;background:radial-gradient(circle at 0 50%,rgba(239,83,80,.12),transparent 30%),rgba(239,83,80,.035)}.trophy-library-review-icon{grid-row:1/3;display:grid;place-items:center;width:38px;height:38px;border-radius:50%;color:#ffd7d5;background:rgba(239,83,80,.15);font:800 11px var(--mono)}.trophy-library-unlock-review>div:not(.trophy-library-review-icon){grid-row:1/3}.trophy-library-unlock-review h3{margin:3px 0;font-size:14px}.trophy-library-unlock-review p{margin:0;color:var(--text-dim);font-size:11px}.trophy-library-unlock-review code{grid-column:3/-1;padding:6px 8px;overflow-wrap:anywhere;color:var(--amber);background:rgba(255,193,7,.055);border-radius:8px;font-size:11px}.trophy-library-unlock-review input{grid-column:3;min-width:0;color:var(--text);background:rgba(0,0,0,.25);border:1px solid var(--border);border-radius:8px;padding:8px}.trophy-library-unlock-review>p{grid-column:2/-1;min-height:12px}.trophy-library-result{margin:0;padding:10px;color:var(--green);border:1px solid rgba(76,175,80,.24);border-radius:12px;background:rgba(76,175,80,.06);font-size:11px}.trophy-library-no-results,.trophy-library-empty{display:grid;place-items:center;gap:6px;min-height:150px;text-align:center;color:var(--text-dim)}.trophy-library-no-results strong,.trophy-library-empty h2{color:var(--text)}.trophy-library-empty-icon{font-size:45px;color:var(--accent)}
.trophy-advanced-workspace{border:1px solid var(--border);border-radius:13px;background:rgba(255,255,255,.015);overflow:hidden}.trophy-advanced-workspace>summary{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:10px;padding:14px 16px;cursor:pointer;list-style:none}.trophy-advanced-workspace>summary::-webkit-details-marker{display:none}.trophy-advanced-workspace>summary::before{content:"+";display:grid;place-items:center;width:24px;height:24px;border:1px solid var(--border);border-radius:50%;color:var(--accent);font:700 12px var(--mono)}.trophy-advanced-workspace[open]>summary::before{content:"−"}.trophy-advanced-workspace>summary span{grid-column:2;color:var(--accent);font:700 11px var(--mono);letter-spacing:.12em;text-transform:uppercase}.trophy-advanced-workspace>summary strong{grid-column:2}.trophy-advanced-workspace>summary small{grid-column:3;grid-row:1/3;color:var(--text-dim);font-size:11px}.trophy-advanced-content{display:grid;gap:16px;padding:0 14px 14px}
@media(max-width:900px){.trophy-library-detail-header{grid-template-columns:74px minmax(0,1fr)}.trophy-library-detail-header dl{grid-column:1/-1}.trophy-library-toolbar{grid-template-columns:1fr 1fr}.trophy-library-segments{grid-column:1/-1}.trophy-library-unlock-review{grid-template-columns:40px 1fr}.trophy-library-review-icon,.trophy-library-unlock-review>div:not(.trophy-library-review-icon){grid-row:auto}.trophy-library-unlock-review code,.trophy-library-unlock-review input,.trophy-library-unlock-review button,.trophy-library-unlock-review>p{grid-column:1/-1}.trophy-library-unlock-review button{width:100%}}
@media(max-width:600px){.trophy-library-shell{padding:15px}.trophy-library-heading{display:grid}.trophy-library-totals{width:max-content}.trophy-library-grid{display:flex;margin-inline:-15px;padding-inline:15px;overflow-x:auto;scroll-snap-type:x mandatory;scrollbar-width:none}.trophy-library-grid::-webkit-scrollbar{display:none}.trophy-library-game-card{min-width:230px;scroll-snap-align:start}.trophy-library-detail{padding:12px}.trophy-library-toolbar{grid-template-columns:1fr}.trophy-library-search,.trophy-library-grade{grid-column:1}.trophy-library-row{grid-template-columns:36px minmax(0,1fr)}.trophy-library-row-state{grid-column:2;justify-items:start;text-align:left}.trophy-library-detail-header dl{display:grid;grid-template-columns:repeat(3,1fr);width:100%}.trophy-advanced-workspace>summary small{display:none}}
@media(prefers-reduced-motion:reduce){.trophy-library-game-card,.trophy-library-game-card::after,.trophy-library-row{transition:none}.trophy-library-game-card:hover{transform:none}}
+1
View File
@@ -0,0 +1 @@
.trophy-active{display:grid;gap:14px;overflow:hidden}.trophy-active-summary{display:flex;align-items:baseline;gap:8px}.trophy-active-summary strong{font-size:24px;color:var(--teal)}.trophy-active-summary span{color:var(--text-dim);font-size:11px}.trophy-active-list{display:grid;gap:6px;margin:0;padding:0;list-style:none}.trophy-active-row{display:grid;grid-template-columns:34px minmax(0,1fr) 72px 78px;align-items:center;gap:10px;padding:10px;border:1px solid var(--border);border-radius:8px;background:rgba(255,255,255,.018)}.trophy-active-row.is-unlocked{border-color:rgba(76,175,80,.3);background:rgba(76,175,80,.045)}.trophy-active-row strong,.trophy-active-row small{display:block}.trophy-active-row small{margin-top:3px;color:var(--text-dim);font-size:11px}.trophy-active-id{display:grid;place-items:center;width:28px;height:28px;border-radius:50%;background:rgba(255,255,255,.055);font:700 11px var(--mono)}.trophy-active-grade,.trophy-active-state{text-transform:capitalize;color:var(--text-dim);font-size:11px}.trophy-active-row.is-unlocked .trophy-active-state{color:var(--green)}@media(max-width:700px){.trophy-active-row{grid-template-columns:32px minmax(0,1fr)}.trophy-active-grade,.trophy-active-state{grid-column:2}}
+4
View File
@@ -0,0 +1,4 @@
.trophy-progression{display:grid;gap:1rem}.trophy-progression-controls{display:grid;grid-template-columns:repeat(3,minmax(12rem,1fr));gap:.75rem}.trophy-progression-controls label{display:grid;gap:.35rem;font-size:var(--fs-xs);font-weight:700}.trophy-progression-controls input,.trophy-progression-controls select{min-width:0;padding:.65rem;border:1px solid var(--border-color,rgba(255,255,255,.14));border-radius:.5rem;background:var(--bg);color:var(--text)}
.trophy-progression-actions{display:flex;gap:.75rem;align-items:center;justify-content:space-between}.trophy-progression-choices{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.5rem;max-height:28rem;overflow:auto}.trophy-progression-choice{display:flex;gap:.7rem;align-items:center;padding:.7rem;border:1px solid var(--border-color,rgba(255,255,255,.12));border-radius:.5rem}.trophy-progression-choice span{display:grid;gap:.2rem}.trophy-progression-choice small{opacity:.72}
.trophy-progression-preview{display:grid;gap:.75rem;padding-top:.75rem;border-top:1px solid var(--border-color,rgba(255,255,255,.12))}.trophy-synthetic-warning{display:grid;gap:.25rem;padding:.8rem;border:1px solid rgba(245,158,11,.45);border-radius:.55rem;background:rgba(245,158,11,.08)}.trophy-progression-preview ol{display:grid;gap:.5rem;list-style:none;padding:0;margin:0}.trophy-progression-preview li{display:grid;grid-template-columns:minmax(11rem,auto) 1fr;gap:1rem;align-items:center;padding:.65rem;border-left:3px solid var(--accent,#7c3aed)}.trophy-progression-preview li div{display:grid;gap:.15rem}.trophy-progression-preview small{opacity:.72}.trophy-progression-preview code{overflow-wrap:anywhere}
@media(max-width:900px){.trophy-progression-controls{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:650px){.trophy-progression-controls,.trophy-progression-choices{grid-template-columns:1fr}.trophy-progression-preview li{grid-template-columns:1fr;gap:.25rem}}
+1
View File
@@ -0,0 +1 @@
.trophy-active-state{display:grid;gap:5px}.trophy-unlock-panel{display:grid;gap:8px;padding:12px;border:1px solid rgba(239,83,80,.3);border-radius:8px}.trophy-unlock-panel code{overflow-wrap:anywhere;color:var(--amber)}.trophy-unlock-panel input{width:100%;box-sizing:border-box;color:var(--text);background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:9px}.trophy-hidden-controls{display:flex;align-items:center;justify-content:space-between;gap:18px}.trophy-hidden-controls h2{margin:0}.trophy-hidden-controls p{margin:5px 0 0;color:var(--muted)}.trophy-hidden-toggle{display:flex;align-items:center;gap:9px;white-space:nowrap}.trophy-hidden-toggle input{width:18px;height:18px}@media(max-width:720px){.trophy-hidden-controls{align-items:flex-start;flex-direction:column}.trophy-hidden-toggle{white-space:normal}}
+1
View File
@@ -0,0 +1 @@
.trophy-workspace{display:grid;gap:16px}.trophy-evidence,.trophy-expert{display:grid;gap:16px;overflow:hidden}.trophy-section-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:18px}.trophy-section-heading span{color:var(--accent);font:700 11px var(--mono);letter-spacing:.12em;text-transform:uppercase}.trophy-section-heading h2{margin:4px 0 0;font-size:20px}.trophy-readonly-badge,.trophy-local-only{padding:6px 9px;color:var(--teal);background:rgba(0,188,212,.08);border:1px solid rgba(0,188,212,.25);border-radius:999px;font-size:11px}.trophy-local-only{color:var(--yellow);background:rgba(255,193,7,.07);border-color:rgba(255,193,7,.28)}.trophy-snapshot-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px}.trophy-snapshot-grid article{display:grid;gap:5px;padding:14px;background:linear-gradient(145deg,rgba(255,255,255,.04),rgba(255,255,255,.015));border:1px solid var(--border);border-radius:12px}.trophy-snapshot-grid article.has-difference{border-color:rgba(255,193,7,.34)}.trophy-snapshot-grid span,.trophy-snapshot-grid small{color:var(--text-dim);font-size:11px}.trophy-snapshot-grid strong{font-size:12px}.trophy-diff-list{display:grid;gap:7px}.trophy-diff-row{display:grid;grid-template-columns:130px minmax(150px,1fr) minmax(210px,.8fr);align-items:center;gap:12px;padding:11px;border:1px solid var(--border);border-radius:12px;background:rgba(255,255,255,.018)}.trophy-diff-row strong,.trophy-diff-row small,.trophy-times span{display:block}.trophy-diff-row small,.trophy-times{color:var(--text-dim);font-size:11px}.trophy-state{width:max-content;padding:4px 7px;border-radius:999px;background:rgba(255,255,255,.06);font-size:11px;font-weight:700}.trophy-state-psn_only{color:var(--yellow);background:rgba(255,193,7,.09)}.trophy-state-both{color:var(--green);background:rgba(76,175,80,.09)}.trophy-state-metadata_mismatch,.trophy-state-unmappable{color:var(--red);background:rgba(244,67,54,.08)}.trophy-preconditions{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin:0}.trophy-preconditions div{padding:11px;background:rgba(255,255,255,.025);border:1px solid var(--border);border-radius:8px}.trophy-preconditions dt{color:var(--text-dim);font-size:11px}.trophy-preconditions dd{margin:5px 0 0;font-size:11px;overflow-wrap:anywhere}.trophy-plan-list{display:grid;gap:7px;margin:0;padding:0;list-style:none}.trophy-plan-item{display:grid;grid-template-columns:28px minmax(0,1fr);gap:9px;padding:11px;border:1px solid var(--border);border-radius:8px}.trophy-plan-item>span{width:26px;height:26px;display:grid;place-items:center;border-radius:50%;font-weight:800}.trophy-plan-item.is-eligible>span{color:var(--green);background:rgba(76,175,80,.1)}.trophy-plan-item.is-ineligible>span{color:var(--red);background:rgba(244,67,54,.1)}.trophy-plan-item small,.trophy-plan-item p{display:block;margin:3px 0 0;color:var(--text-dim);font-size:11px}.trophy-plan-item ul{margin:7px 0 0;padding-left:18px;color:var(--yellow);font-size:11px}.trophy-confirmation{display:grid;grid-template-columns:minmax(220px,1fr) auto;gap:8px;align-items:end;padding:14px;background:rgba(244,67,54,.045);border:1px solid rgba(244,67,54,.22);border-radius:12px}.trophy-confirmation label,.trophy-confirmation small{grid-column:1/-1}.trophy-confirmation label span,.trophy-confirmation label code{display:block}.trophy-confirmation label code{margin-top:5px;color:var(--yellow)}.trophy-confirmation input{min-height:38px}.trophy-confirmation small,.trophy-action-status,.trophy-empty,.trophy-warning{color:var(--text-dim);font-size:11px}.trophy-action-status{min-height:34px;padding:10px;background:rgba(255,255,255,.025);border-radius:8px}@media(max-width:900px){.trophy-snapshot-grid{grid-template-columns:1fr}.trophy-preconditions{grid-template-columns:repeat(2,minmax(0,1fr))}.trophy-diff-row{grid-template-columns:1fr}.trophy-times{display:flex;gap:12px;flex-wrap:wrap}}@media(max-width:560px){.trophy-section-heading{flex-direction:column}.trophy-preconditions{grid-template-columns:1fr}.trophy-confirmation{grid-template-columns:1fr}.trophy-confirmation .btn{width:100%;min-height:44px}}
+424
View File
@@ -0,0 +1,424 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws:;"/>
<title>System View — Chimera</title>
<style>@font-face{font-family:Inter;src:url('assets/fonts/inter-latin.woff2') format('woff2');font-style:normal;font-weight:100 900;font-display:swap}@font-face{font-family:'Space Grotesk';src:url('assets/fonts/space-grotesk-latin.woff2') format('woff2');font-style:normal;font-weight:300 700;font-display:swap}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0a11;
--surface: #1d1822;
--border: #342b3d;
--text: #f4eefb;
--text-dim: #c6bbd2;
--accent: #CBBAFF;
--radius: 12px;
}
html, body { height: 100%; background: #000; color: var(--text);
font-family: Inter,'Segoe UI',system-ui,sans-serif;
font-size: 13px; overflow: hidden; user-select: none; }
/* ── Controls bar ─────────────────────────────────────────────────────────── */
#controls {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; background: var(--surface);
border-bottom: 1px solid var(--border); flex-shrink: 0;
position: relative; z-index: 10; flex-wrap: wrap;
}
.ctrl-label { font-size: 11px; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
select {
background: var(--bg); color: var(--text); border: 1px solid var(--border);
border-radius: 5px; padding: 3px 6px; font-size: 12px; outline: none; cursor: pointer;
}
select:focus { border-color: var(--accent); }
select:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.sel-video { flex: 2; min-width: 120px; }
.sel-audio { flex: 2; min-width: 100px; }
.sel-res { width: 100px; }
.sel-fps { width: 68px; }
button {
background: var(--surface); color: var(--text); border: 1px solid var(--border);
border-radius: 5px; padding: 3px 10px; font-size: 12px; cursor: pointer;
white-space: nowrap; flex-shrink: 0; transition: background 0.15s;
}
button:hover:not(:disabled) { background: #2c2c2c; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Recording indicator ──────────────────────────────────────────────────── */
#rec-status {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; color: #ff5252; font-weight: 500; flex-shrink: 0;
}
#rec-status.hidden { display: none; }
.rec-dot {
width: 8px; height: 8px; border-radius: 50%; background: #ff5252; flex-shrink: 0;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{ opacity:1; } 50%{ opacity:0.3; } }
/* ── Video area ───────────────────────────────────────────────────────────── */
#shell { display: flex; flex-direction: column; height: 100%; }
#viewer {
flex: 1; position: relative; display: flex;
align-items: center; justify-content: center; background: #000; overflow: hidden;
}
#sv-video { width: 100%; height: 100%; object-fit: contain; display: block; }
/* ── No-signal overlay ────────────────────────────────────────────────────── */
#no-signal {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 14px;
color: var(--text-dim); font-size: 13px; pointer-events: none;
}
#no-signal.hidden { display: none; }
#no-signal img { max-width: 280px; opacity: 0.55; }
/* ── Fullscreen button ────────────────────────────────────────────────────── */
#btn-fs {
position: absolute; bottom: 14px; right: 14px; z-index: 20;
background: rgba(18,18,18,.85); border: 1px solid #555; border-radius: 6px;
padding: 6px 10px; font-size: 16px; cursor: pointer; color: var(--text);
opacity: 0; transition: opacity 0.2s, background 0.15s; pointer-events: none;
}
#viewer:hover #btn-fs, #btn-fs:focus-visible { opacity: 1; pointer-events: auto; }
#btn-fs:hover { background: rgba(50,50,50,.95); }
/* ── In-app fullscreen — CSS-based, reliable in Electron ─────────────────── */
body.sv-fullscreen #controls { display: none; }
body.sv-fullscreen #viewer { position: fixed; inset: 0; z-index: 9000; }
/* Always show the exit button when fullscreen */
body.sv-fullscreen #btn-fs { opacity: 1; pointer-events: auto; }
@media (hover: none) { #btn-fs { opacity: 1; pointer-events: auto; } }
@media (prefers-reduced-motion: reduce) { .rec-dot { animation: none; } button, #btn-fs { transition: none; } }
</style>
</head>
<body>
<div id="shell">
<div id="controls">
<label class="ctrl-label" for="video-select">Video</label>
<select id="video-select" class="sel-video"><option value="">— Select source —</option></select>
<label class="ctrl-label" for="audio-select">Audio</label>
<select id="audio-select" class="sel-audio"><option value="">— No audio —</option></select>
<label class="ctrl-label" for="res-select">Resolution</label>
<select id="res-select" class="sel-res">
<option value="">Auto</option>
<option value="1280x720">1280×720</option>
<option value="1920x1080">1920×1080</option>
<option value="2560x1440">2560×1440</option>
<option value="3840x2160">3840×2160</option>
</select>
<button id="btn-mute" disabled>&#128266; Mute</button>
<select id="fps-select" class="sel-fps" aria-label="GIF frame rate">
<option value="5">5 fps</option>
<option value="10" selected>10 fps</option>
<option value="15">15 fps</option>
<option value="25">25 fps</option>
<option value="30">30 fps</option>
<option value="60">60 fps</option>
</select>
<button id="btn-record" disabled>&#9210; Record GIF</button>
<span id="rec-status" class="hidden">
<span class="rec-dot"></span>REC <span id="rec-time">0:00</span>
</span>
</div>
<div id="viewer">
<video id="sv-video" autoplay playsinline muted></video>
<div id="no-signal">
<img src="assets/no-signal/no-signal.png" alt="No signal"/>
<p id="no-signal-msg">Select a video source above to begin</p>
</div>
<button id="btn-fs" type="button" title="Fullscreen (or double-click)" aria-label="Enter fullscreen">&#x26F6;</button>
</div>
</div>
<script src="gif-encoder.js"></script>
<script>
'use strict';
// ── URL params ─────────────────────────────────────────────────────────────────
const params = new URLSearchParams(window.location.search);
const initVideoId = params.get('videoId') || '';
const initAudioId = params.get('audioId') || '';
const initResolution = params.get('resolution') || '';
// ── State ──────────────────────────────────────────────────────────────────────
let stream = null;
let muted = false;
let recording = false;
let recFrames = [];
let recInterval = null;
let recTimer = null;
let recStart = 0;
let recCanvas = null;
let recCtx = null;
// AudioContext for low-latency audio — avoids Chromium's video-clock sync buffering
let audioCtx = null;
let audioGain = null;
const GIF_W = 640, GIF_H = 360;
// ── Element refs ───────────────────────────────────────────────────────────────
const videoEl = document.getElementById('sv-video');
const videoSel = document.getElementById('video-select');
const audioSel = document.getElementById('audio-select');
const resSel = document.getElementById('res-select');
const fpsSel = document.getElementById('fps-select');
const btnMute = document.getElementById('btn-mute');
const btnRecord = document.getElementById('btn-record');
const btnFs = document.getElementById('btn-fs');
const noSignal = document.getElementById('no-signal');
const noSignalMsg= document.getElementById('no-signal-msg');
const controls = document.getElementById('controls');
const recStatus = document.getElementById('rec-status');
const recTimeEl = document.getElementById('rec-time');
// ── Audio helpers ──────────────────────────────────────────────────────────────
function audioStop() {
if (audioCtx) { audioCtx.close().catch(() => {}); audioCtx = null; }
audioGain = null;
}
// ── Device enumeration ─────────────────────────────────────────────────────────
async function enumerateDevices() {
try {
if (!stream) {
try {
const tmp = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
tmp.getTracks().forEach(t => t.stop());
} catch (_) {}
}
const devices = await navigator.mediaDevices.enumerateDevices();
const prevVideo = videoSel.value, prevAudio = audioSel.value;
while (videoSel.options.length > 1) videoSel.remove(1);
devices.filter(d => d.kind === 'videoinput').forEach(d =>
videoSel.add(new Option(d.label || `Camera ${d.deviceId.slice(0,8)}`, d.deviceId)));
while (audioSel.options.length > 1) audioSel.remove(1);
devices.filter(d => d.kind === 'audioinput').forEach(d =>
audioSel.add(new Option(d.label || `Mic ${d.deviceId.slice(0,8)}`, d.deviceId)));
const tV = prevVideo || initVideoId, tA = prevAudio || initAudioId;
if (tV && [...videoSel.options].some(o => o.value === tV)) videoSel.value = tV;
if (tA && [...audioSel.options].some(o => o.value === tA)) audioSel.value = tA;
} catch (e) {
console.error('enumerateDevices failed:', e);
}
}
// ── Stream management ──────────────────────────────────────────────────────────
async function startStream() {
if (recording) stopRecording();
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
audioStop();
videoEl.srcObject = null;
const videoId = videoSel.value;
if (!videoId) {
noSignalMsg.textContent = 'Select a video source above to begin';
noSignal.classList.remove('hidden');
btnMute.disabled = true;
btnRecord.disabled = true;
return;
}
noSignalMsg.textContent = 'Starting stream…';
const audioId = audioSel.value;
const res = resSel.value;
const [rW, rH] = res ? res.split('x').map(Number) : [];
const _buildConstraints = (exactRes) => {
const vc = { deviceId: { exact: videoId } };
if (rW) {
vc.width = exactRes ? { exact: rW } : { ideal: rW };
vc.height = exactRes ? { exact: rH } : { ideal: rH };
}
return {
video: vc,
audio: audioId ? {
deviceId: { exact: audioId },
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
channelCount: 2,
} : false,
};
};
try {
// First try exact resolution; fall back to ideal if the device can't match
try {
stream = await navigator.mediaDevices.getUserMedia(_buildConstraints(true));
} catch (exactErr) {
if (rW && exactErr.name === 'OverconstrainedError') {
console.warn(`[SysView] Exact ${rW}×${rH} not supported, falling back to ideal`);
stream = await navigator.mediaDevices.getUserMedia(_buildConstraints(false));
} else {
throw exactErr;
}
}
// Feed only video tracks to the video element. Chromium syncs audio to
// the video clock which adds ~150-300 ms of buffering / choppiness.
// Routing audio separately via AudioContext drops this to ~20-40 ms.
videoEl.srcObject = new MediaStream(stream.getVideoTracks());
videoEl.muted = true; // always; audio is handled by AudioContext below
const audioTracks = stream.getAudioTracks();
if (audioId && audioTracks.length) {
audioCtx = new AudioContext({ latencyHint: 'interactive' });
audioGain = audioCtx.createGain();
audioGain.gain.value = muted ? 0 : 1;
audioCtx.createMediaStreamSource(new MediaStream(audioTracks))
.connect(audioGain)
.connect(audioCtx.destination);
}
noSignal.classList.add('hidden');
btnMute.disabled = false;
btnRecord.disabled = false;
updateMuteBtn();
} catch (e) {
noSignalMsg.textContent = `Error: ${e.message}`;
noSignal.classList.remove('hidden');
btnMute.disabled = true;
btnRecord.disabled = true;
}
}
function updateMuteBtn() {
btnMute.textContent = muted ? '🔇 Unmute' : '🔊 Mute';
}
// ── GIF Recording ──────────────────────────────────────────────────────────────
function getRecCtx() {
const w = videoEl.videoWidth || GIF_W;
const h = videoEl.videoHeight || GIF_H;
if (!recCanvas || recCanvas.width !== w || recCanvas.height !== h) {
recCanvas = document.createElement('canvas');
recCanvas.width = w; recCanvas.height = h;
recCtx = recCanvas.getContext('2d');
}
return recCtx;
}
function startRecording() {
if (recording || !stream) return;
const fps = parseInt(fpsSel.value) || 10;
recording = true; recFrames = []; recStart = Date.now();
btnRecord.textContent = '⏹ Stop & Save';
recStatus.classList.remove('hidden');
const ctx = getRecCtx();
const gifW = recCanvas.width, gifH = recCanvas.height;
recInterval = setInterval(() => {
ctx.drawImage(videoEl, 0, 0, gifW, gifH);
recFrames.push(new Uint8ClampedArray(ctx.getImageData(0, 0, gifW, gifH).data.buffer));
}, Math.round(1000 / fps));
recTimer = setInterval(() => {
const s = Math.floor((Date.now() - recStart) / 1000);
recTimeEl.textContent = `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`;
}, 500);
}
function stopRecording() {
if (!recording) return;
clearInterval(recInterval); clearInterval(recTimer);
recording = false;
btnRecord.textContent = '⏺ Record GIF';
recStatus.classList.add('hidden');
recTimeEl.textContent = '0:00';
if (!recFrames.length) return;
const fps = parseInt(fpsSel.value) || 10;
const gifW = recCanvas?.width || GIF_W;
const gifH = recCanvas?.height || GIF_H;
const enc = new GifEncoder(gifW, gifH, { fps });
for (const f of recFrames) enc.addFrame(f);
recFrames = [];
const bytes = enc.encode();
const url = URL.createObjectURL(new Blob([bytes], { type: 'image/gif' }));
const a = Object.assign(document.createElement('a'), {
href: url, download: `chimera-${Date.now()}.gif`,
});
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
// ── In-app fullscreen (CSS-based — requestFullscreen unreliable in Electron) ───
function toggleFullscreen() {
const entering = !document.body.classList.contains('sv-fullscreen');
document.body.classList.toggle('sv-fullscreen', entering);
btnFs.innerHTML = entering ? '&#x2715;' : '&#x26F6;';
btnFs.title = entering ? 'Exit Fullscreen (Esc)' : 'Fullscreen (or double-click)';
btnFs.setAttribute('aria-label', entering ? 'Exit fullscreen' : 'Enter fullscreen');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.body.classList.contains('sv-fullscreen')) {
toggleFullscreen();
}
});
// ── Event handlers ─────────────────────────────────────────────────────────────
videoSel.addEventListener('change', startStream);
audioSel.addEventListener('change', startStream);
resSel.addEventListener('change', () => { if (videoSel.value) startStream(); });
btnMute.addEventListener('click', () => {
muted = !muted;
if (audioGain) audioGain.gain.value = muted ? 0 : 1;
updateMuteBtn();
});
btnRecord.addEventListener('click', () => { recording ? stopRecording() : startRecording(); });
btnFs.addEventListener('click', toggleFullscreen);
document.getElementById('viewer').addEventListener('dblclick', toggleFullscreen);
navigator.mediaDevices.addEventListener('devicechange', () => enumerateDevices().catch(() => {}));
// ── Boot ───────────────────────────────────────────────────────────────────────
(async () => {
// If device IDs were passed via URL query (auto-launch path), skip the
// getUserMedia label-unlock inside enumerateDevices and start the stream
// immediately. Enumeration runs in the background after the stream opens —
// by then the stream itself has already unlocked device labels.
if (initVideoId) {
// Pre-populate the selects with the saved IDs so startStream can read them
// even before enumerateDevices has filled the <option> list.
if (!videoSel.value) videoSel.add(new Option(initVideoId, initVideoId));
videoSel.value = initVideoId;
if (initAudioId) {
if (![...audioSel.options].some(o => o.value === initAudioId))
audioSel.add(new Option(initAudioId, initAudioId));
audioSel.value = initAudioId;
}
if (initResolution && [...resSel.options].some(o => o.value === initResolution))
resSel.value = initResolution;
// Start stream right away — don't wait for enumeration.
await startStream();
// Enumerate in the background now that the stream has unlocked labels;
// this refreshes the dropdown with proper device names.
enumerateDevices().catch(() => {});
} else {
// No device pre-selected — enumerate first so the user can pick one.
await enumerateDevices();
if (initResolution && [...resSel.options].some(o => o.value === initResolution))
resSel.value = initResolution;
if (videoSel.value) await startStream();
}
})();
</script>
</body>
</html>